Files
DramaticShapeVoxelMod/lib/ChunkMesher.lua
T
DramaticShape 702d8049f0 initial commit
2026-07-26 16:43:53 -04:00

964 lines
38 KiB
Lua

-- Voxel world mode: turn a map's tile layer into one static 3D mesh.
--
-- The scene description comes from Structures.lua, which -- 3dSen-style --
-- detects each connected drawn thing on the map and picks its model:
--
-- flat ground / water / void: a single quad.
-- top art ledges, roofs (profile-authored): a box with the art on its
-- TOP face; partial side bands crop the art (a 6px ledge face
-- is the bottom of the lip drawing).
-- volume walls, buildings, tree lines: each column rises to the
-- structure's REAL drawn height (Structures measures it,
-- repeat-aware and region-consistent -- a 6-row house is 48px,
-- a 40-row border forest is rows of 16px trees). The south
-- face folds the full artwork upright, 8px band by band, band
-- k sampling the map row k tiles north; the top wears the
-- structure's top rows.
-- object small props with a silhouette (plants, signs, lone trees):
-- per-pixel voxel prisms prebuilt by Structures, standing on
-- synthesized ground -- this mesher just emits their quads.
-- Round trees arrive as STAMPS (a shared hull template plus a
-- cell offset) and expand here, straight into the vertex
-- stream, so no map retains per-cell copies of its forests.
--
-- Side faces are never stretched: all sides are 8px bands with the art
-- tiled per band and cropped at partial bands.
--
-- Texturing samples the TILESET ATLAS, not a rendered copy of the map. The
-- atlas is 128x48; a map-space canvas covering the biggest routes would be
-- ~5 MB each with up to five live at once (connected maps), which is real
-- memory on the mobile targets. Sampling the atlas costs 24 KB, and costs
-- nothing in fidelity because TerrainAtlas hands back the same atlas
-- TileRenderer draws with -- including the fully recolored one RED++
-- bakes -- so terrain color comes through untouched.
--
-- BUILDS ARE ASYNCHRONOUS. A frame never blocks on meshing: VoxelScene
-- requests what it wants to draw, request() queues a build job, and
-- pump() -- called once a frame from the pipeline's update -- advances
-- the queue inside a few-millisecond budget (BuildBudget suspends the
-- job's coroutine mid-loop when the slice is spent). Until a mesh lands
-- the scene simply draws without it: the engine's flat path while the
-- current map has nothing, the body-only variant while the full one (the
-- border ring) is still cooking, neighbours popping in as they finish.
-- The synchronous get() remains for probes and tests.
--
-- Meshes are cached per map id and EVICTED down to the live set (current
-- map + connected neighbours) whenever that set changes -- setLive()
-- releases far maps' GPU meshes and their Structures analysis, which is
-- what used to grow the heap by gigabytes over a cross-region trek.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local Structures = V.require("Structures")
local TileShape = V.require("TileShape")
local Voxel3D = V.require("Voxel3D")
local Budget = V.require("BuildBudget")
local ffi = nil
do
local ok, mod = pcall(require, "ffi")
if ok then ffi = mod end
end
local ChunkMesher = {}
-- Ring of border blocks meshed around the body, matching the width
-- TileRenderer draws so the two modes end at the same place.
local RING = 3
-- A sliver of a texel, to keep a quad's sampling inside its own tile.
-- Without any inset the perspective rasteriser lands on a NEIGHBOURING
-- tile's texel along the shared edge and stitches bright seams across the
-- whole map.
--
-- It has to be a sliver and not, as it first was, half a texel. A tile is
-- 8 texels of art across 8 world pixels -- one texel per pixel exactly --
-- and insetting the uv by half a texel at each end squeezes that art into
-- a 7-texel sample range while the quad still covers 8 world pixels. The
-- art then advances 7/8 of a texel per pixel: boundaries drift off the
-- pixel grid, one art pixel gets sampled twice and another never at all.
-- Nothing showed it until the voxel wireframe drew the grid those pixels
-- were supposed to be sitting on. Interpolation error is nowhere near a
-- fiftieth of a texel, so this is as safe against bleed and costs 0.25% of
-- a pixel of drift across a whole tile.
local INSET = 0.02
-- The south face of a volume is the artwork itself, so it draws at full
-- brightness; its top face darkens a touch so the plateau behind a
-- standing drawing reads as depth rather than repeating the same art at
-- the same energy.
local VOLUME_TOP_SHADE = 0.85
local cache = {} -- map id -> { full = mesh|false, body = ..., grass = ... }
local gen = {} -- map id -> generation, bumped by invalidate/evict
-- Horizontal neighbours: tile step, face direction id (see Voxel3D).
local SIDES = {
{ 1, 0, 1 }, -- +X east
{ -1, 0, 2 }, -- -X west
{ 0, 1, 5 }, -- +Z south
{ 0, -1, 6 }, -- -Z north
}
local function keyOf(tx, ty)
return (ty + 64) * 4096 + (tx + 64)
end
-- ------------------------------------------------------------ vertex sinks
-- A sink accepts quads (4 corners, 4 uv pairs, flat or per-corner shade)
-- and finishes into a drawable mesh. The TABLE sink reproduces the
-- historical pure-Lua output -- geometry() returns its arrays for the
-- headless suite. The FFI sink packs the same six floats per vertex
-- straight into one growing native buffer, unindexed (v1 v2 v3 v1 v3 v4),
-- skipping ~a million short-lived Lua tables per route and LOVE's slow
-- table-by-table vertex upload.
local function newTableSink()
local verts, indices, quads = {}, {}, 0
return {
push = function(c, uv, shade)
local flat = type(shade) ~= "table"
for i = 1, 4 do
local cc, t = c[i], uv[i]
verts[#verts + 1] = { cc[1], cc[2], cc[3], t[1], t[2],
flat and shade or shade[i] }
end
Voxel3D.pushQuad(indices, quads)
quads = quads + 1
end,
results = function()
return verts, indices, quads
end,
finish = function()
return Voxel3D.newMesh(verts, indices)
end,
}
end
local TRI_ORDER = { 1, 2, 3, 1, 3, 4 }
local function newFfiSink()
local cap = 4096 * 6
local buf = ffi.new("float[?]", cap * 6)
local n = 0
local sink
sink = {
push = function(c, uv, shade)
if n + 6 > cap then
local grown = ffi.new("float[?]", cap * 2 * 6)
ffi.copy(grown, buf, n * 6 * 4)
buf, cap = grown, cap * 2
end
local flat = type(shade) ~= "table"
local base = n * 6
for k = 1, 6 do
local i = TRI_ORDER[k]
local cc, t = c[i], uv[i]
buf[base] = cc[1]
buf[base + 1] = cc[2]
buf[base + 2] = cc[3]
buf[base + 3] = t[1]
buf[base + 4] = t[2]
buf[base + 5] = flat and shade or shade[i]
base = base + 6
end
n = n + 6
end,
finish = function()
if n == 0 then return nil end
-- upload in slices with budget ticks between: a route-sized mesh
-- is ~10-20MB and one atomic setVertices was the last remaining
-- frame spike. The mesh is not cached (so never drawn) until the
-- whole upload lands, and LuaJIT yields fine across pcall.
local ok, mesh = pcall(function()
local m = love.graphics.newMesh(Voxel3D.FORMAT, n,
"triangles", "static")
local CHUNK = 65536 -- vertices per slice (~1.5MB)
local i = 0
while i < n do
local count = math.min(CHUNK, n - i)
local bytes = count * 6 * 4
local data = love.data.newByteData(bytes)
ffi.copy(data:getFFIPointer(), buf + i * 6, bytes)
m:setVertices(data, i + 1)
data:release()
i = i + count
Budget.check()
end
return m
end)
return ok and mesh or nil
end,
}
return sink
end
local function newSink()
if ffi and love and love.data and love.data.newByteData
and love.graphics and love.graphics.newMesh then
return newFfiSink()
end
return newTableSink()
end
-- -------------------------------------------------------------- geometry
-- Emit the raw geometry for `map` into `sink`. `bodyOnly` skips the
-- border ring -- the shape the 2D path's drawMapOnly has always had: a
-- neighbour map contributes its body, and only the CURRENT map supplies
-- the ring around the view.
--
-- `masks` (full variant only) lists rectangles, in this map's world
-- pixels, where connected neighbour BODIES sit: ring geometry inside them
-- is suppressed. The 2D renderer never needed this because it painted
-- neighbour bodies OVER the ring; with a depth buffer the ring's standing
-- trees would rise straight through the neighbour's flat ground -- cross
-- into Route 1 and a wall of border trees sprouts over Pallet.
--
-- Kept free of any GPU call so it can be exercised headless -- the
-- geometry is the part with the interesting invariants, and a suite that
-- needed a real GL context to check them would never run in CI.
local function runGeometry(map, bodyOnly, masks, sink)
local push = sink.push
local tileset = map.tileset
local S = Structures.forMap(map)
local perRow = tileset.tilesPerRow or 16
local atlasW = tileset.imageWidth or (perRow * 8)
local atlasH = tileset.imageHeight or 48
local function heightAt(tx, ty)
local k = keyOf(tx, ty)
if S.skip[k] then return 0 end
local run = S.runs[k]
if run then return run.h end
local s = S.shapeAt[k]
return s and s.h or 0
end
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
local function uvRect(tile, vTop, vBot)
local ax = (tile % perRow) * 8
local ay = math.floor(tile / perRow) * 8
local vi = math.min(INSET, (vBot - vTop) / 4)
return (ax + INSET) / atlasW, (ax + 8 - INSET) / atlasW,
(ay + vTop + vi) / atlasH, (ay + vBot - vi) / atlasH
end
-- ------------------------------------------------------ ambient occlusion
--
-- Ambient light is what reaches a surface from the sky at large, so it is
-- blocked by how much geometry crowds a point rather than by where the
-- sun happens to be -- which makes it the exact complement of the shadow
-- pass, and the reason both are worth having. The shadow map draws the
-- long directional shadow a building throws; this draws the dark seam in
-- every corner the sky cannot see into, at every scale finer than a
-- shadow map texel.
--
-- Baked per vertex, the classic voxel way: each corner counts the
-- neighbours that crowd it and steps down once per neighbour, and the
-- rasteriser interpolates the steps into a smooth falloff. Costs exactly
-- nothing at draw time, and it is resolution-independent -- a screen
-- space pass would blur across the pixel grid this whole mode is built
-- to keep crisp.
--
-- (What was here before was a one-directional contact shadow keyed to a
-- sun in the northwest: two neighbours, one corner, top faces only.)
-- Intensity. Both terms below are DARKENING amounts rather than
-- multipliers, so this one number scales the whole effect: 1.0 is the
-- barely-there first cut, and everything is expressed against it.
local AO_STRENGTH = 2.4
local AO_STEP = 0.09 * AO_STRENGTH -- per crowding neighbour, max 3
local AO_EDGE = 1 - 0.14 * AO_STRENGTH -- creases / corners on a face
local AO_GROUND = 0.12 * AO_STRENGTH -- a prop's contact with the floor
local AO_RISE = 6 -- px over which the floor lets go
local AO_FLOOR = 0.25 -- never let a vertex reach black
-- Both sinks copy a per-corner shade straight out into the vertex stream
-- and keep no reference, so these two scratch rows are reused for every
-- quad on the map rather than allocating a table per face -- a route
-- builds a few hundred thousand of them.
local aoTop = { 0, 0, 0, 0 }
local aoSide = { 0, 0, 0, 0 }
-- A top face's four corners, each occluded by the three cells that touch
-- it: two edge neighbours and the diagonal between them.
local function aoShades(tx, ty, h, shade)
local n = heightAt(tx, ty - 1) > h
local s = heightAt(tx, ty + 1) > h
local e = heightAt(tx + 1, ty) > h
local w = heightAt(tx - 1, ty) > h
local nw = heightAt(tx - 1, ty - 1) > h
local ne = heightAt(tx + 1, ty - 1) > h
local sw = heightAt(tx - 1, ty + 1) > h
local se = heightAt(tx + 1, ty + 1) > h
if not (n or s or e or w or nw or ne or sw or se) then return shade end
local function corner(a, b, d)
local k = 0
if a then k = k + 1 end
if b then k = k + 1 end
-- a diagonal wedged behind both of its edges adds nothing: the
-- corner is already as enclosed as it can get, and counting it
-- again is what turns an ordinary inside corner black
if d and not (a and b) then k = k + 1 end
-- floored, so cranking AO_STRENGTH deepens the seams instead of
-- punching holes of pure black through the world
return shade * math.max(AO_FLOOR, 1 - AO_STEP * k)
end
-- corners in topQuad order: NW, NE, SE, SW
aoTop[1], aoTop[2] = corner(n, w, nw), corner(n, e, ne)
aoTop[3], aoTop[4] = corner(s, e, se), corner(s, w, sw)
return aoTop
end
-- The same idea on an upright face, where the crowding is of two kinds:
-- the CREASE it rises out of (the band sitting on the ground, or on
-- whatever lower neighbour exposed the face) and the INSIDE CORNERS
-- where the columns flanking it stand proud of the band. `hl`/`hr` are
-- those flanking heights in FACE order -- left then right as seen from
-- outside, per LATERAL below -- so the shades line up with sideQuad's
-- corners without the caller thinking about compass directions.
local LATERAL = {
[1] = { 0, 1, 0, -1 }, -- east face: left south, right north
[2] = { 0, -1, 0, 1 }, -- west face: left north, right south
[5] = { -1, 0, 1, 0 }, -- south face: left west, right east
[6] = { 1, 0, -1, 0 }, -- north face: left east, right west
}
-- Ground contact for the prebuilt prop quads -- the per-pixel plants,
-- signs and lone trees, and the round-tree stamps. Those arrive from
-- Structures already finished, so the neighbour counting above has no
-- columns to count. What it CAN say is that the ground plane itself
-- blocks half the sky, so the closer a voxel sits to it the less ambient
-- light reaches it -- which is what plants a prop on the floor instead
-- of leaving it looking pasted over the top.
local aoProp = { 0, 0, 0, 0 }
local function groundShades(c, shade)
if type(shade) == "table" then return shade end
local y1, y2, y3, y4 = c[1][2], c[2][2], c[3][2], c[4][2]
if math.min(y1, y2, y3, y4) >= AO_RISE then return shade end
for i = 1, 4 do
local t = c[i][2] / AO_RISE
aoProp[i] = shade * (t >= 1 and 1 or (1 - AO_GROUND * (1 - t)))
end
return aoProp
end
local AO_CORNER = math.max(AO_FLOOR, AO_EDGE * AO_EDGE) -- crease AND flank
local function sideShades(hl, hr, y0, y1, crease, shade)
if not (crease or hl > y0 or hr > y0) then return shade end
-- corners run bottom-left, bottom-right, top-right, top-left
local base = crease and AO_EDGE or 1
aoSide[1] = shade * (hl > y0 and (crease and AO_CORNER or AO_EDGE) or base)
aoSide[2] = shade * (hr > y0 and (crease and AO_CORNER or AO_EDGE) or base)
aoSide[3] = shade * (hr > y1 and AO_EDGE or 1)
aoSide[4] = shade * (hl > y1 and AO_EDGE or 1)
return aoSide
end
local function topQuad(x0, z0, h, tile, shade)
local u0, u1, v0, v1 = uvRect(tile, 0, 8)
push({ { x0, h, z0 }, { x0 + 8, h, z0 },
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
aoShades(x0 / 8, z0 / 8, h, shade))
end
-- vertical quad for face direction `d` of the tile column at (x0, z0),
-- spanning heights [y0, y1] and showing art rows [vTop, vBot] of `tile`.
-- Corners run bottom-left, bottom-right, top-right, top-left as seen
-- from outside; u follows +X on the north/south faces so a door or sign
-- never draws mirrored.
local function sideQuad(d, x0, z0, y0, y1, tile, vTop, vBot, shade)
local x1, z1 = x0 + 8, z0 + 8
local c
if d == 5 then -- south, at z1
c = { { x0, y0, z1 }, { x1, y0, z1 }, { x1, y1, z1 }, { x0, y1, z1 } }
elseif d == 6 then -- north, at z0
c = { { x1, y0, z0 }, { x0, y0, z0 }, { x0, y1, z0 }, { x1, y1, z0 } }
elseif d == 1 then -- east, at x1
c = { { x1, y0, z1 }, { x1, y0, z0 }, { x1, y1, z0 }, { x1, y1, z1 } }
else -- west, at x0
c = { { x0, y0, z0 }, { x0, y0, z1 }, { x0, y1, z1 }, { x0, y1, z0 } }
end
local u0, u1, v0, v1 = uvRect(tile, vTop, vBot)
push(c, { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, shade)
end
local def = map.def
local tw, th = def.width * 4, def.height * 4 -- map size in tiles
local r = bodyOnly and 0 or RING * 4
-- true when the (ring) position lies under a connected neighbour's body
local function masked(px0, pz0, px1, pz1)
if not masks then return false end
for _, mk in ipairs(masks) do
if px1 > mk[1] and px0 < mk[3] and pz1 > mk[2] and pz0 < mk[4] then
return true
end
end
return false
end
-- The inclusive variant for OBJECT quads: a quad TOUCHING a neighbour
-- body counts as under it. The old test took the quad's center with
-- strict bounds, and a quad whose center sat exactly on the body's
-- edge line escaped the mask -- stringing stray pixel fragments of
-- otherwise-dropped border trees along every map seam.
local function maskedClosed(px0, pz0, px1, pz1)
if not masks then return false end
for _, mk in ipairs(masks) do
if px1 >= mk[1] and px0 <= mk[3] and pz1 >= mk[2] and pz0 <= mk[4] then
return true
end
end
return false
end
for ty = -r, th + r - 1 do
for tx = -r, tw + r - 1 do
Budget.tick()
local k = keyOf(tx, ty)
local s, tile = S.shapeAt[k], S.tileAt[k]
local inBody = tx >= 0 and ty >= 0 and tx < tw and ty < th
if not inBody and masked(tx * 8, ty * 8, tx * 8 + 8, ty * 8 + 8) then
s = nil
end
if s and S.skip[k] then
-- an object stands here; paint its synthesized ground and let the
-- prebuilt prism quads (appended below) carry the art
if S.ground[k] then topQuad(tx * 8, ty * 8, 0, S.ground[k], 1) end
elseif s then
local run = S.runs[k]
local h = run and run.h or s.h
local x0, z0 = tx * 8, ty * 8
-- top face. A roofed volume gets a GABLE segment: the roof rises
-- from the facade top at the south eave to a ridge across the
-- footprint's middle, then falls back to the facade at the north
-- edge -- so the far side sits LOW. (The first cut was a shed
-- plane rising all the way north, which turns a building into a
-- ramp.) The south slope wears the structure's roof rows (ridge
-- art at the ridge, eaves art at the eave); the back slope
-- mirrors them. Exposed east/west flanks hip: their outer edge
-- drops toward the eave, rounding the drawn corner tiles into 45
-- degree corners. Flat-topped volumes wear their top rows;
-- everything else its own art.
if run and run.rise > 0 then
local mid = run.extent / 2
local function gableH(d) -- d = rows north of the south eave
local t = d <= mid and d / mid or (run.extent - d) / (run.extent - mid)
return run.h + run.rise * math.max(0, math.min(1, t))
end
local d0 = run.front - ty -- rows from the south edge
local hS = gableH(d0)
local hN = gableH(d0 + 1)
-- art by proximity to the ridge, mirrored over the back
local rel = 1 - math.abs(d0 + 0.5 - mid) / math.max(mid, 0.5)
local idx = math.min(run.roofRows - 1,
math.floor((1 - rel) * run.roofRows))
local roofTile = map:tileAt(tx, run.north + idx)
local swY, seY, neY, nwY = hS, hS, hN, hN
if heightAt(tx - 1, ty) < run.h then -- west flank: hip
swY = math.max(run.h, hS - 8)
nwY = math.max(run.h, hN - 8)
end
if heightAt(tx + 1, ty) < run.h then -- east flank: hip
seY = math.max(run.h, hS - 8)
neY = math.max(run.h, hN - 8)
end
local u0, u1, v0, v1 = uvRect(roofTile, 0, 8)
push({ { x0, swY, z0 + 8 }, { x0 + 8, seY, z0 + 8 },
{ x0 + 8, neY, z0 }, { x0, nwY, z0 } },
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 0.95)
elseif run then
local m = math.min(2, run.extent)
local topTile = map:tileAt(tx, run.north + ((ty - run.north) % m))
topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE)
else
local topTile = tile
if s.art == "upright" and s.authored then
-- Top art for a pinned box. A furniture drawing is top-view
-- rows over floor(h/8) face-on rows the fold stands upright;
-- a face row's top would repeat its front art lying flat, so
-- it wears the nearest row above the face block instead --
-- the drawn tabletop (and whatever sits on it) stays on top,
-- and a fully-folded structure (wall, desk) tops with its
-- northmost row.
local north, front = ty, ty
while ty - north < 6 do
local bs = S.shapeAt[keyOf(tx, north - 1)]
if bs and bs.authored and bs.class == s.class then
north = north - 1
else
break
end
end
while front - ty < 6 do
local bs = S.shapeAt[keyOf(tx, front + 1)]
if bs and bs.authored and bs.class == s.class then
front = front + 1
else
break
end
end
local row = math.min(ty, front - math.floor(h / 8))
if row < north then
-- the whole run folded onto the face: top with the drawn
-- row just above it when that row is furniture too (a
-- bookcase wearing its shelf-top trim), else with the
-- run's own top row
local above = S.shapeAt[keyOf(tx, north - 1)]
row = (above and above.authored and above.art == "upright")
and (north - 1) or north
end
topTile = S.tileAt[keyOf(tx, row)]
end
topQuad(x0, z0, h, topTile,
s.art == "upright" and VOLUME_TOP_SHADE or 1)
end
-- sides: 8px bands wherever the neighbour is lower. Band k spans
-- heights [8k, 8k+8) and shows one full tile of art; a partial
-- band crops the art rows to match, so nothing ever stretches.
for _, side in ipairs(SIDES) do
local nh = heightAt(tx + side[1], ty + side[2])
if nh < h then
local d = side[3]
-- the columns flanking this face, for the inside-corner term:
-- fixed for the whole face, so they are read once rather than
-- once per 8px band
local lat = LATERAL[d]
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
for band = math.floor(nh / 8), math.ceil(h / 8) - 1 do
local y0 = math.max(nh, band * 8)
local y1 = math.min(h, band * 8 + 8)
if y1 > y0 then
local src, shade = tile, Voxel3D.FACE_SHADE[d]
if run then
-- fold the structure's artwork up this face: band k
-- samples the map row k tiles north of the structure's
-- front, clamped to its extent. The south face is the
-- drawing itself (full brightness); the other sides wear
-- the same rows darkened, so a building's flank matches
-- its face instead of smearing one tile
if d == 6 then
src = map:tileAt(tx, math.min(run.front,
run.north + band))
else
src = map:tileAt(tx, math.max(run.north,
run.front - band))
end
if d == 5 then shade = 1 end
elseif s.art == "upright" then
-- profile-authored upright (a pinned wall or furniture
-- box): fold the drawing up the face, band 0 the
-- structure's southmost same-class row and higher bands
-- the rows north of it, repeating past the top. The
-- south face is the drawing itself (full brightness);
-- flanks and back wear the same front stack darkened, so
-- a desk's side matches its face instead of smearing a
-- different jumble per row.
if d == 5 then shade = 1 end
local front = ty
while front < ty + 6 do
local fs2 = S.shapeAt[keyOf(tx, front + 1)]
if fs2 and fs2.authored and fs2.class == s.class then
front = front + 1
else
break
end
end
local fk = keyOf(tx, front - band)
local fs = S.shapeAt[fk]
if fs and fs.authored and fs.class == s.class then
src = S.tileAt[fk]
end
end
sideQuad(d, x0, z0, y0, y1, src,
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
sideShades(hl, hr, y0, y1, y0 <= nh, shade))
end
end
end
end
end
end
end
-- Prebuilt quads from Structures (per-pixel voxel props, lathed
-- columns) plus the round-tree stamps expanded in place. Keep rules,
-- by the quad's own extent:
-- body-only the quad must overlap the OPEN body interval -- a
-- neighbour's ring props must not march past its edge
-- into this map, and a quad lying exactly ON the edge
-- plane would z-fight the map that owns that plane.
-- full anything overlapping the body stays whole (props that
-- straddle the edge no longer shed their outer half);
-- pure ring quads drop when they touch a neighbour body
-- (maskedClosed), which is what strings of seam pixels
-- were: fragments of dropped border trees whose centers
-- sat exactly on the boundary line.
local bw, bh = tw * 8, th * 8
local function keepQuad(x0, z0, x1, z1)
local overBody = x1 > 0 and x0 < bw and z1 > 0 and z0 < bh
if bodyOnly then return overBody end
return overBody or not maskedClosed(x0, z0, x1, z1)
end
local scUV = { { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 } }
local function quadUV(q)
if q.uv then return q.uv end
for i = 1, 4 do
scUV[i][1], scUV[i][2] = q.u, q.v
end
return scUV
end
for _, q in ipairs(S.objectQuads) do
Budget.tick()
local x0 = math.min(q[1][1], q[2][1], q[3][1], q[4][1])
local x1 = math.max(q[1][1], q[2][1], q[3][1], q[4][1])
local z0 = math.min(q[1][3], q[2][3], q[3][3], q[4][3])
local z1 = math.max(q[1][3], q[2][3], q[3][3], q[4][3])
if keepQuad(x0, z0, x1, z1) then
push({ q[1], q[2], q[3], q[4] }, quadUV(q), groundShades(q, q.shade))
end
end
-- true when the rect sits entirely inside one neighbour-body rect
local function containedInMask(x0, z0, x1, z1)
if not masks then return false end
for _, mk in ipairs(masks) do
if x0 >= mk[1] and x1 <= mk[3] and z0 >= mk[2] and z1 <= mk[4] then
return true
end
end
return false
end
-- round-tree stamps: the shared hull template translated per cell,
-- through reusable scratch corners so expansion allocates nothing.
-- A hull spans at most its own 16px cell, so one rect test usually
-- answers for the whole stamp: strictly interior stamps keep every
-- quad, ring stamps buried under a neighbour body (or, body-only, ring
-- stamps full stop) skip without touching their quads. Only stamps
-- crossing a boundary walk quad by quad.
local sc = { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
for _, st in ipairs(S.roundStamps or {}) do
local mx, mz = st.mx, st.mz
local sx0, sz0, sx1, sz1 = mx - 8, mz - 8, mx + 8, mz + 8
local interior = sx0 > 0 and sx1 < bw and sz0 > 0 and sz1 < bh
local overBody = sx1 > 0 and sx0 < bw and sz1 > 0 and sz0 < bh
local keepAll, skipAll
if bodyOnly then
keepAll = interior
skipAll = not overBody
else
keepAll = interior or not maskedClosed(sx0, sz0, sx1, sz1)
skipAll = not overBody and containedInMask(sx0, sz0, sx1, sz1)
end
if not skipAll then
for _, q in ipairs(st.quads) do
Budget.tick()
for i = 1, 4 do
local c, s2 = q[i], sc[i]
s2[1] = c[1] + mx
s2[2] = c[2]
s2[3] = c[3] + mz
end
local ok = keepAll
if not ok then
local x0 = math.min(sc[1][1], sc[2][1], sc[3][1], sc[4][1])
local x1 = math.max(sc[1][1], sc[2][1], sc[3][1], sc[4][1])
local z0 = math.min(sc[1][3], sc[2][3], sc[3][3], sc[4][3])
local z1 = math.max(sc[1][3], sc[2][3], sc[3][3], sc[4][3])
ok = keepQuad(x0, z0, x1, z1)
end
if ok then
push(sc, quadUV(q), groundShades(sc, q.shade))
end
end
end
end
end
-- The raw geometry for `map`: (vertex list, triangle index list, quad
-- count). Synchronous and GPU-free -- the headless suite and the probes
-- exercise the invariants through this.
function ChunkMesher.geometry(map, bodyOnly, masks)
local sink = newTableSink()
runGeometry(map, bodyOnly, masks, sink)
return sink.results()
end
-- Build the mesh for `map` synchronously. Returns nil when there is
-- nothing to draw or meshes are unavailable (headless).
function ChunkMesher.build(map, bodyOnly, masks)
local sink = newSink()
runGeometry(map, bodyOnly, masks, sink)
return sink.finish()
end
-- The tall-grass rows as their own mesh: VoxelScene draws it AFTER the
-- characters so the southern row of a grass cell still overdraws a
-- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary
-- terrain could never do this).
local function buildGrassMesh(map)
local S = Structures.forMap(map)
if #S.grassQuads == 0 then return nil end
local verts, indices, n = {}, {}, 0
for _, q in ipairs(S.grassQuads) do
for i = 1, 4 do
local c = q[i]
local uv = q.uv and q.uv[i] or { q.u, q.v }
verts[#verts + 1] = { c[1], c[2], c[3], uv[1], uv[2], q.shade }
end
Voxel3D.pushQuad(indices, n)
n = n + 1
end
return Voxel3D.newMesh(verts, indices)
end
-- ------------------------------------------------------------- the cache
local function entry(id)
local c = cache[id]
if not c then
c = {}
cache[id] = c
end
return c
end
local function releaseEntry(c)
for _, slot in ipairs({ "full", "body", "grass" }) do
local mesh = c[slot]
if mesh and mesh.release then pcall(mesh.release, mesh) end
c[slot] = nil
end
end
-- ---------------------------------------------------------- async builds
local jobs = {} -- FIFO of pending jobs
local jobIndex = {} -- "id:slot" -> job
local clock = (love and love.timer and love.timer.getTime) or os.clock
local function jobKey(id, slot)
return id .. ":" .. slot
end
local function finishJob(job, ok, err)
jobIndex[jobKey(job.id, job.slot)] = nil
for i, j in ipairs(jobs) do
if j == job then
table.remove(jobs, i)
break
end
end
if not ok then
-- name the reason: in a real session a lost build is a black map
print("[warn] voxel mesh build failed for " .. tostring(job.id)
.. ": " .. tostring(err))
if (gen[job.id] or 0) == job.gen then
entry(job.id)[job.slot] = false
end
end
end
-- A build only lands if the map's generation still matches the one the
-- job was queued under -- invalidate/evict bump it to cancel in-flight
-- work whose inputs went stale.
local function runJob(job)
local map = job.map
local c = entry(job.id)
if c.grass == nil then
local okG, grass = pcall(buildGrassMesh, map)
if (gen[job.id] or 0) ~= job.gen then return end
c.grass = (okG and grass) or false
end
local sink = newSink()
runGeometry(map, job.slot == "body", job.masks, sink)
local mesh = sink.finish()
if (gen[job.id] or 0) ~= job.gen then return end
c[job.slot] = mesh or false
end
-- Queue a build unless the slot is already cached or queued. Returns the
-- cached mesh when there is one (false-cached misses return nil).
-- `urgent` marks the current map's meshes: pump() gives those a bigger
-- slice and runs them before neighbour jobs.
function ChunkMesher.request(map, bodyOnly, masks, urgent)
local slot = bodyOnly and "body" or "full"
local c = cache[map.id]
if c and c[slot] ~= nil then return c[slot] or nil end
local key = jobKey(map.id, slot)
local job = jobIndex[key]
if not job then
job = { id = map.id, map = map, slot = slot, masks = masks,
urgent = urgent or false, gen = gen[map.id] or 0 }
jobIndex[key] = job
jobs[#jobs + 1] = job
elseif urgent then
job.urgent = true
end
return nil
end
function ChunkMesher.pending()
return #jobs
end
-- Advance queued builds inside a per-frame time budget. Urgent jobs (the
-- current map) come first and get the larger slice -- the first voxel
-- frame after a toggle is worth more milliseconds than a neighbour
-- popping in one frame later. `covered` says the world pass is hidden
-- this frame (a warp's fade, a menu): nothing visible can hitch, so the
-- slice opens up and a door fade swallows most of a destination build.
local URGENT_SLICE = 0.012
local IDLE_SLICE = 0.005
local COVERED_SLICE = 0.030
function ChunkMesher.pump(covered)
if #jobs == 0 then return end
local pick = jobs[1]
for _, j in ipairs(jobs) do
if j.urgent then
pick = j
break
end
end
local slice = covered and COVERED_SLICE
or (pick.urgent and URGENT_SLICE or IDLE_SLICE)
local deadline = clock() + slice
while pick do
if not pick.co then
pick.co = coroutine.create(runJob)
end
Budget.begin(pick.co, deadline - clock())
local ok, err = coroutine.resume(pick.co, pick)
Budget.finish()
if not ok then
finishJob(pick, false, err)
elseif coroutine.status(pick.co) == "dead" then
finishJob(pick, true)
else
return -- slice spent mid-build; resume next frame
end
if clock() >= deadline or #jobs == 0 then return end
pick = jobs[1]
for _, j in ipairs(jobs) do
if j.urgent then
pick = j
break
end
end
end
end
-- Meshes for `map`, built SYNCHRONOUSLY on first use -- the historical
-- contract, kept for probes and any direct caller. `false` is cached for
-- a map whose mesh could not be built so a headless run does not retry
-- every frame. `masks` (the full variant's neighbour-body rects) is
-- static per map id -- a map's connections never change -- so it caches
-- like everything else.
function ChunkMesher.get(map, bodyOnly, masks)
local slot = bodyOnly and "body" or "full"
local c = entry(map.id)
if c.grass == nil then
local okG, grass = pcall(buildGrassMesh, map)
c.grass = (okG and grass) or false
end
if c[slot] == nil then
local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks)
if not ok then
print("[warn] voxel mesh build failed for " .. tostring(map.id)
.. ": " .. tostring(mesh))
end
c[slot] = (ok and mesh) or false
local key = jobKey(map.id, slot)
local job = jobIndex[key]
if job then finishJob(job, true) end
end
return c[slot] or nil
end
-- The cached mesh, or nil -- never builds. The async path's read side.
function ChunkMesher.peek(map, bodyOnly)
local c = cache[map.id]
local mesh = c and c[bodyOnly and "body" or "full"]
return mesh or nil
end
function ChunkMesher.grass(map)
local c = cache[map.id]
return c and c.grass or nil
end
-- Evict everything outside `live` (a set of map ids): far maps' meshes
-- are released -- GPU buffer and LOVE's CPU copy both -- and their
-- Structures analysis dropped. The live set is the current map plus its
-- rendered neighbours, so memory stays bounded by what is on or near the
-- screen instead of growing with every area ever visited.
--
-- The PREVIOUS live set is retained too: warping into a building
-- collapses the set to one small interior, and evicting the town at the
-- door means rebuilding the whole neighbourhood on the way out -- a
-- flat-world flash after every house. One set of history makes the
-- round trip free while staying bounded at two neighbourhoods.
local prevLive = {}
function ChunkMesher.setLive(live)
for id, c in pairs(cache) do
if not live[id] and not prevLive[id] then
releaseEntry(c)
cache[id] = nil
gen[id] = (gen[id] or 0) + 1
Structures.invalidate(id)
end
end
for i = #jobs, 1, -1 do
local job = jobs[i]
if not live[job.id] and not prevLive[job.id] then
jobIndex[jobKey(job.id, job.slot)] = nil
table.remove(jobs, i)
end
end
prevLive = live
end
-- Drop one map's mesh (Cut swapped a block) or all of them (hot reload).
-- Structures' analysis is derived from the same block layer, so it drops
-- in the same breath; in-flight builds of the map are cancelled through
-- the generation counter.
function ChunkMesher.invalidate(mapId)
Structures.invalidate(mapId)
if mapId then
local c = cache[mapId]
if c then releaseEntry(c) end
cache[mapId] = nil
gen[mapId] = (gen[mapId] or 0) + 1
else
for _, c in pairs(cache) do releaseEntry(c) end
cache = {}
for id in pairs(gen) do gen[id] = gen[id] + 1 end
end
for i = #jobs, 1, -1 do
local job = jobs[i]
if mapId == nil or job.id == mapId then
jobIndex[jobKey(job.id, job.slot)] = nil
table.remove(jobs, i)
end
end
end
Assets.register(function() ChunkMesher.invalidate() end)
return ChunkMesher