Join the RoClicks Discord
RoClicks

ChunkStreamer.luau

Luau, 220 lines from Building overview

Download ChunkStreamer.luau
--!strict
-- ChunkStreamer: client-side streaming of a large or endless map in square chunks, with LODs, a per-frame
-- time budget and pools. A skeleton: plug in your own chunk content via `Streamer.content`.
--
-- The four rules this encodes, each from a real bug:
--   1. PUBLISH THEN RELEASE. Build the new LOD copy fully, record it as the loaded copy, and only then queue
--      the old copy's release. Releasing inside the build job orphaned whole chunks when the job was cancelled
--      between the two steps (duplicate ground z-fighting, stale buildings, leaked parts).
--   2. NEVER CANCEL A RELEASE. Releases yield piece by piece; a "cancel" just drops the coroutine at its last
--      yield and leaves a half-destroyed chunk. Release jobs have their own tag and are never cancelled.
--   3. FILTER BEFORE CLONE. Decide whether a template child is wanted from the SOURCE child; cloning a 2.8k
--      part template and hiding most of it cost ~50 ms per chunk and kept hidden parts alive in pools.
--   4. POOLS RESET AND STAY SMALL. Reset Transparency (and anything else you change) on acquire, cap each
--      template's pool, and clear pools on quality change. A reused chunk kept its ground hidden from an
--      old LOD swap and showed the void through the map.

local RunService = game:GetService("RunService")

local Streamer = {}
Streamer.BUDGET = 0.004 -- seconds of work per frame
Streamer.CHUNK = 96 -- studs per chunk side
Streamer.RADII = { full = 1, lite = 2, flat = 4 } -- in chunks, Chebyshev distance from the focus chunk
Streamer.POOL_MAX = { default = 6 } -- per-template caps; set 1 for whole-chunk templates

-- content provider: return the template for a chunk at an LOD, and whether a template child is wanted
Streamer.content = {
	template = function(_cx: number, _cz: number, _lod: string): Model? return nil end,
	-- `at` = where the piece would land; return false to skip it (never cloned, never pooled)
	keep = function(_src: PVInstance, _at: CFrame): boolean return true end,
}

---------------------------------------------------------------- scheduler (time-budgeted coroutines)
type Job = { co: thread, tag: string, prio: number, dead: boolean }
local jobs: { Job } = {}
local frameStart = 0

-- call inside a job between units of work: yields if this frame's budget is spent
function Streamer.step()
	if os.clock() - frameStart > Streamer.BUDGET then
		coroutine.yield()
	end
end

local function add(fn: () -> (), prio: number, tag: string): Job
	local job = { co = coroutine.create(fn), tag = tag, prio = prio, dead = false }
	table.insert(jobs, job)
	table.sort(jobs, function(a, b) return a.prio > b.prio end)
	return job
end

-- cancel BUILD jobs only. Anything tagged "release" always runs to the end.
local function cancel(job: Job)
	if job.tag ~= "release" then
		job.dead = true
	end
end

RunService.Heartbeat:Connect(function()
	frameStart = os.clock()
	local i = 1
	while i <= #jobs and os.clock() - frameStart < Streamer.BUDGET do
		local job = jobs[i]
		if job.dead or coroutine.status(job.co) == "dead" then
			table.remove(jobs, i)
		else
			local ok, err = coroutine.resume(job.co)
			if not ok then
				warn("[Streamer] job error: " .. tostring(err))
				job.dead = true
			end
			i += 1
		end
	end
end)

---------------------------------------------------------------- pools
-- Pooled per SOURCE piece (a direct child of a chunk template), so a filtered-out piece is never cloned.
local pools: { [Instance]: { PVInstance } } = {}

local function acquire(src: PVInstance): PVInstance
	local pool = pools[src]
	local m = pool and table.remove(pool)
	if not m then
		return src:Clone()
	end
	-- reset everything a previous user may have changed (rule 4). Clones keep descendant order.
	local a, b = src:GetDescendants(), m:GetDescendants()
	table.insert(a, 1, src)
	table.insert(b, 1, m)
	for i, d in b do
		local s = a[i]
		if d:IsA("BasePart") and s and s:IsA("BasePart") then
			d.Transparency = s.Transparency
			d.Color = s.Color
		end
	end
	return m
end

local function release(src: PVInstance, m: PVInstance)
	m.Parent = nil
	local pool = pools[src] or {}
	pools[src] = pool
	local tpl = src.Parent
	local cap = Streamer.POOL_MAX[tpl and tpl.Name or ""] or Streamer.POOL_MAX.default
	if #pool < cap then
		table.insert(pool, m)
	else
		m:Destroy()
	end
end

function Streamer.clearPools()
	for _, pool in pools do
		for _, m in pool do m:Destroy() end
	end
	table.clear(pools)
end

---------------------------------------------------------------- chunks
type Piece = { src: PVInstance, inst: PVInstance }
type Rec = { lod: string, folder: Folder, pieces: { Piece } }
local loaded: { [string]: Rec } = {}
local inflight: { [string]: Job } = {}
local root = Instance.new("Folder")
root.Name = "StreamedWorld"
root.Parent = workspace

local function key(cx: number, cz: number): string return `{cx},{cz}` end

-- uncancellable: tagged "release" (rule 2)
local function queueRelease(rec: Rec)
	add(function()
		for _, piece in rec.pieces do
			release(piece.src, piece.inst)
			Streamer.step()
		end
		rec.folder:Destroy()
	end, -10, "release")
end

local function build(cx: number, cz: number, lod: string)
	local k = key(cx, cz)
	local tpl = Streamer.content.template(cx, cz, lod)
	local folder = Instance.new("Folder")
	folder.Name = `C{k}_{lod}`
	local rec: Rec = { lod = lod, folder = folder, pieces = {} }
	-- If this job is cancelled at a step(), nothing was published: the unparented folder is simply
	-- dropped. (Put a one-part "backstop" ground under every chunk elsewhere, so an LOD swap in flight
	-- shows plain ground, never the void.)
	if tpl then
		local origin = CFrame.new(cx * Streamer.CHUNK, 0, cz * Streamer.CHUNK)
		local toLocal = tpl:GetPivot():Inverse()
		for _, src in tpl:GetChildren() do
			if src:IsA("PVInstance") then
				local at = origin * (toLocal * src:GetPivot())
				if Streamer.content.keep(src, at) then -- rule 3: decide on the SOURCE, before cloning
					local inst = acquire(src)
					inst:PivotTo(at)
					inst.Parent = folder
					table.insert(rec.pieces, { src = src, inst = inst })
					Streamer.step()
				end
			end
		end
	end
	folder.Parent = root
	local old = loaded[k]
	loaded[k] = rec -- rule 1: publish first...
	inflight[k] = nil
	if old then queueRelease(old) end -- ...then release
end

local function lodFor(dist: number): string
	if dist <= Streamer.RADII.full then return "full" end
	if dist <= Streamer.RADII.lite then return "lite" end
	if dist <= Streamer.RADII.flat then return "flat" end
	return "none"
end

-- call a few times per second with the camera focus
function Streamer.update(focus: Vector3)
	local fx, fz = math.floor(focus.X / Streamer.CHUNK + 0.5), math.floor(focus.Z / Streamer.CHUNK + 0.5)
	local r = Streamer.RADII.flat
	local want: { [string]: boolean } = {}
	for cx = fx - r, fx + r do
		for cz = fz - r, fz + r do
			local k = key(cx, cz)
			local lod = lodFor(math.max(math.abs(cx - fx), math.abs(cz - fz)))
			want[k] = true
			local rec, job = loaded[k], inflight[k]
			if (not rec or rec.lod ~= lod) and not job then
				local prio = -math.max(math.abs(cx - fx), math.abs(cz - fz)) -- near first
				inflight[k] = add(function() build(cx, cz, lod) end, prio, "build")
			end
		end
	end
	for k, rec in loaded do
		if not want[k] then
			loaded[k] = nil
			local job = inflight[k]
			if job then cancel(job); inflight[k] = nil end -- a build: safe, nothing published yet
			queueRelease(rec)
		end
	end
end

-- quality change / map reset: cancel builds, let releases finish, then drop pools
function Streamer.clear()
	for _, job in jobs do cancel(job) end
	table.clear(inflight)
	for k, rec in loaded do
		loaded[k] = nil
		queueRelease(rec)
	end
	task.delay(2, Streamer.clearPools)
end

return Streamer