Join the RoClicks Discord
RoClicks

MeshSpots.luau

Luau, 190 lines from Building overview

Download MeshSpots.luau
--!strict
-- MeshSpots: runtime-baked EditableMesh content that survives saves, slow starts and pools.
--
-- Why: EditableMesh -> MeshPart content is NOT saved with the place (after a reopen or rbxm round trip
-- only collision survives; the part is invisible). So models store invisible "spots" carrying a recipe,
-- and the game bakes meshes at runtime. Lessons baked into this module:
--   * bake in the BACKGROUND: a 4,400-spot bake at the top of server Main blocked every service for 11 s
--     and clients timed out waiting for remotes;
--   * bake each unique key once, clone per spot;
--   * a REPAIR loop fills spots that were cloned before the bake finished (pools reuse those forever);
--   * a spot that cannot bake gets a STAND-IN block (bounds, colour, material), never a hole;
--   * probe the FULL path: an empty CreateEditableMesh() succeeds even when the API is disabled.
--
-- A spot: an invisible BasePart tagged "MeshSpot" with attributes MeshKey (string) and MeshParams (JSON).
-- Its Size/CFrame/Color/Material describe the result. Recipes are yours: build(em, params, size) adds the
-- vertices/triangles in the spot's local space.
--
--   MeshSpots.register("roundedBox", function(em, params, size) ... end)
--   task.spawn(MeshSpots.bakeAll, ReplicatedStorage.Templates)    -- server, after services start
--   MeshSpots.watch(workspace)                                     -- repair loop
--   MeshSpots.repair(clone)                                        -- right after cloning a template

local AssetService = game:GetService("AssetService")
local CollectionService = game:GetService("CollectionService")
local HttpService = game:GetService("HttpService")

local TAG = "MeshSpot"
local MESH_NAME = "BakedMesh"
local STANDIN = "MeshStandIn"

type Recipe = (em: any, params: { [string]: any }, size: Vector3) -> ()

local MeshSpots = {}
MeshSpots.FORCE_FALLBACK = false -- tests: pretend the API is off

local recipes: { [string]: Recipe } = {}
local cache: { [string]: MeshPart | false } = {} -- false = failed, use a stand-in
local available: boolean? = nil

function MeshSpots.register(name: string, fn: Recipe)
	recipes[name] = fn
end

-- EditableMesh -> MeshPart. If clients must see server-made meshes, check the current docs for the
-- step that turns the editable into replicating content (one working pipeline went through
-- AssetService:CreateDataModelContentAsync before CreateMeshPartAsync).
local function toMeshPart(em: any): MeshPart
	return AssetService:CreateMeshPartAsync(Content.fromObject(em))
end

-- One triangle, all the way to a MeshPart. Catches "Allow Mesh & Image APIs" being off.
function MeshSpots.available(): boolean
	if available == nil then
		available = not MeshSpots.FORCE_FALLBACK and pcall(function()
			local em = AssetService:CreateEditableMesh()
			local a = em:AddVertex(Vector3.new(0, 0, 0))
			local b = em:AddVertex(Vector3.new(1, 0, 0))
			local c = em:AddVertex(Vector3.new(0, 0, 1))
			em:AddTriangle(a, b, c)
			toMeshPart(em):Destroy()
		end)
		if not available then
			print("[MeshSpots] mesh API unavailable; using stand-ins")
		end
	end
	return available :: boolean
end

local function keyOf(spot: BasePart): string
	return `{spot:GetAttribute("MeshKey")}|{spot:GetAttribute("MeshParams") or ""}|{spot.Size}`
end

local function bakeKey(spot: BasePart): MeshPart | false
	local key = keyOf(spot)
	local hit = cache[key]
	if hit ~= nil then
		return hit
	end
	local recipe = recipes[spot:GetAttribute("MeshKey") :: string]
	local result: MeshPart | false = false
	if recipe and MeshSpots.available() then
		local ok, mp = pcall(function()
			local params = HttpService:JSONDecode((spot:GetAttribute("MeshParams") :: string?) or "{}")
			local em = AssetService:CreateEditableMesh()
			recipe(em, params, spot.Size)
			return toMeshPart(em)
		end)
		if ok then
			result = mp
		else
			warn(`[MeshSpots] bake failed for {spot:GetAttribute("MeshKey")}: {mp}`)
		end
	end
	cache[key] = result
	return result
end

local function hasReal(spot: BasePart): boolean
	local m = spot:FindFirstChild(MESH_NAME)
	return m ~= nil and not m:GetAttribute(STANDIN)
end

local function attach(spot: BasePart, part: BasePart, standIn: boolean)
	local old = spot:FindFirstChild(MESH_NAME)
	if old then
		old:Destroy()
	end
	part.Name = MESH_NAME
	part.Size = spot.Size
	part.CFrame = spot.CFrame
	part.Color = spot.Color
	part.Material = spot.Material
	part.MaterialVariant = spot.MaterialVariant
	part.Anchored = spot.Anchored
	part.CanCollide = false
	part.CanQuery = spot.CanQuery
	part.Transparency = 0
	part:SetAttribute(STANDIN, standIn or nil)
	part.Archivable = spot.Archivable -- edit-mode previews: spot non-archivable => never saved
	if not spot.Anchored then
		local w = Instance.new("WeldConstraint")
		w.Part0, w.Part1 = spot, part
		w.Parent = part
	end
	part.Parent = spot
end

-- Give one spot its mesh (or a stand-in). Returns "real", "standin" or "skip".
function MeshSpots.fill(spot: BasePart): string
	if hasReal(spot) then
		return "skip"
	end
	local tpl = bakeKey(spot)
	if tpl then
		attach(spot, tpl:Clone(), false)
		return "real"
	end
	if spot:FindFirstChild(MESH_NAME) then
		return "skip" -- already has a stand-in; a later successful bake swaps it
	end
	attach(spot, Instance.new("Part"), true)
	return "standin"
end

-- Fill every spot under root. Yields every `batch` spots so it never blocks a frame for long.
function MeshSpots.repair(root: Instance, batch: number?): (number, number)
	local real, stand, n = 0, 0, 0
	for _, spot in CollectionService:GetTagged(TAG) do
		if spot:IsA("BasePart") and spot:IsDescendantOf(root) and not hasReal(spot) then
			local r = MeshSpots.fill(spot)
			if r == "real" then real += 1 elseif r == "standin" then stand += 1 end
			n += 1
			if batch and n % batch == 0 then
				task.wait()
			end
		end
	end
	return real, stand
end

-- Server: bake every template once, in the background. Retry failures once, then publish a flag.
function MeshSpots.bakeAll(root: Instance)
	local t0 = os.clock()
	MeshSpots.repair(root, 50)
	for k, v in cache do
		if v == false then
			cache[k] = nil -- one retry
		end
	end
	local _, stand = MeshSpots.repair(root, 50)
	workspace:SetAttribute("MeshesBaked", true)
	print(`[MeshSpots] baked in {string.format("%.1f", os.clock() - t0)} s, {stand} stand-ins`)
end

-- Repair loop: every 1 s until the bake flag is set and 3 passes come back clean, then every 6 s as a
-- safety net for pooled copies made before the bake.
function MeshSpots.watch(root: Instance)
	task.spawn(function()
		local clean = 0
		while true do
			local real, stand = MeshSpots.repair(root, 50)
			local baked = workspace:GetAttribute("MeshesBaked") == true
			clean = if baked and real + stand == 0 then clean + 1 else 0
			task.wait(if clean >= 3 then 6 else 1)
		end
	end)
end

return MeshSpots