-- 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