Files
DramaticShapeVoxelMod/lib/VoxelScene.lua
T
2026-07-29 20:09:18 -04:00

673 lines
31 KiB
Lua

-- Voxel world mode: assemble and draw one frame of the 3D scene.
--
-- World space is world pixels and shares its origin with the 2D paths, so
-- the terrain mesh needs no transform at all and a connected map just
-- translates by the same (ox, oy) the flat renderer already offsets it by.
--
-- Order is: the sun's shadow pass, then terrain, then characters, then a 2D
-- overlay for the field FX. There is no y-sort anywhere -- the depth buffer
-- resolves occlusion, which is the whole point of the mode. Walk behind a
-- building and the building is simply in front.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local Voxel3D = V.require("Voxel3D")
local ShadowMap = V.require("ShadowMap")
local ChunkMesher = V.require("ChunkMesher")
local SpriteBillboards = V.require("SpriteBillboards")
local TileShape = V.require("TileShape")
local TerrainAtlas = V.require("TerrainAtlas")
local Voxel = V.require("VoxelState")
local Sky = V.require("Sky")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
local VoxelScene = {}
-- What the active display mode actually paints with.
--
-- paletteFor hands back a map's RAW SGB zone palette, and that is not what
-- any of the non-colour modes draw. The flat path runs it through
-- PaletteFX.effectiveColors on the way to the shade-remap shader, and that
-- call IS where GRAY, INVERTED and CLASSIC happen -- OG / OG INV replace
-- the palette with the DMG greys (inverted for the latter), CLASSIC
-- replaces it with the green DMG set, and GBC INV permutes the zone's own
-- shades. GBC and RED++ pass through untouched.
--
-- This pass has no shader to apply that in: colour is baked into the atlas
-- and into the sprite sheets ahead of the draw, so it has to run the same
-- transform itself. Without it every mode that is not already a colour mode
-- comes through wearing the SGB palette -- grey and inverted both rendering
-- as plain SGB blue.
local function modeColors(paletteFor, map)
local c = paletteFor and paletteFor(map) or nil
return PaletteFX.effectiveColors(c)
end
VoxelScene._modeColors = modeColors -- named for the suite
-- ------------------------------------------------------------------ sky --
--
-- The void behind the diorama is SKY, at every rung -- so the world reads as
-- standing under something rather than floating on a black plate.
--
-- What is up there differs by rung, and the sky follows it rather than being
-- retuned for each. At 75 degrees the camera is pitched far enough over that
-- the horizon is genuinely in frame, and the bands run down to meet it. At the
-- steeper rungs the horizon is above the top edge and the void that shows is
-- where the ground runs OUT -- past the map edge, past the curve -- so the
-- bands take a fixed slice of the frame instead (lib/Sky.lua, Sky.SPAN) and the
-- haze below them fills the rest.
--
-- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a
-- ceiling, and the void past its walls is the outside of a box, not open
-- air. Map.isOutdoor is the same test the engine uses for door SFX and the
-- town map, and the same one Structures already asks to decide whether a
-- map rings with trees.
--
-- The colour is a four-shade ramp shaped like a world palette so the
-- display mode can transform it exactly like one: GRAY gets a grey sky,
-- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue.
-- A hardcoded blue would sit wrong in every non-colour mode -- the same
-- mismatch the terrain bake had.
--
-- This ramp is the FLAT sky -- what a caller clears the void to. The free-roam
-- camera's banded sky has a palette of its own (lib/Sky.lua), transformed the
-- same way by the same seam; they are separate because the flat one also has to
-- serve an indoor void and a battle's arena, which want a colour rather than a
-- sky.
local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 },
{ 64, 120, 192 }, { 16, 40, 80 } }
local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight
-- the ramp as the display mode has it, which is the only form anything here
-- should be reading it in
local function skyRamp()
return PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES
end
-- Full strength at every rung: the sky is painted wherever the diorama is.
--
-- The ramp that is left is for ARRIVAL alone. Switching the mode on eases the
-- camera up from flat, and the sky comes up with it over the first few degrees
-- rather than appearing whole on the keypress -- which is also what keeps a
-- top-down camera, where there is no void worth speaking of, from painting one.
local SKY_FADE_DEG = 8
local function skyStrength(angleRad)
local deg = math.deg(angleRad or 0)
if deg <= 0 then return 0 end
local t = deg / SKY_FADE_DEG
return t < 1 and t or 1
end
-- One shade off the sky ramp, transformed by the display mode, as an
-- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky
-- proper; 4 is its darkest, which is what an indoor void wants).
function VoxelScene.skyShade(shade, alpha)
local shades = skyRamp()
local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE]
return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 }
end
-- The sky `map` stands under at strength `t`, or nil where there is no sky
-- to paint: indoors, or with the horizon out of frame.
--
-- One flat colour, which is what a caller that only needs something to clear the
-- void to wants -- the overworld battle's arena shot is one of those. The
-- gradient is added on top of this by skyFor, for the free-roam camera alone.
function VoxelScene.skyColor(map, t)
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
if not t or t <= 0 then return nil end
return VoxelScene.skyShade(SKY_SHADE, t)
end
-- The free-roam sky: the flat one above, dressed with the banded gradient
-- (lib/Sky.lua).
--
-- Only here, and deliberately. This is the sky the walking camera stands under,
-- where the horizon is a quarter of the way down the frame at the top rung and
-- one flat blue reads as a wall of paint. A battle is a staged shot with its own
-- placed camera whose horizon sits above the frame entirely, so it keeps the
-- flat fill it has always had -- there is no gradient to see from down there,
-- and the arena's look is not this rung's to change.
local function skyFor(map)
local sky = VoxelScene.skyColor(map, skyStrength(Voxel.angle))
if not sky then return nil end
return Sky.dress(sky)
end
VoxelScene._skyFor = skyFor -- named for the suite
VoxelScene._skyStrength = skyStrength
-- A facing as a yaw about +Y, kept for callers that reason about which way
-- an entity points (the mod exports it). The character cards themselves
-- never yaw -- they face south and lean, like the flat game.
local YAW = {
down = 0,
up = math.pi,
right = math.pi / 2,
left = -math.pi / 2,
}
-- The ground height a cell stands at, so a character on a ledge stands on
-- top of it rather than sunk into it. Uses the same bottom-left collision
-- tile the engine walks on (Map:cellTile).
local function groundAt(map, cellX, cellY)
-- Off the map, cellTile border-extends into the map's borderBlock --
-- which on maps ringed with trees is a RAISED tile. The only entity
-- ever standing off-map is the player mid seam-step (placed one cell
-- before the connection entry), and the ground actually rendered
-- there is the departed neighbour's flat walkway: height 0. Without
-- this, crossing into such a map hoisted the walker tree-high for
-- exactly one step -- the "hops like a ledge" seam bug.
if not map:inBounds(cellX, cellY) then return 0 end
local shapes = TileShape.forMap(map)
local s = shapes[map:cellTile(cellX, cellY)]
if not s then return 0 end
-- a recessed class (water) still supports whatever stands on it; only
-- raised ground lifts the model. Stairs never do: the class height is
-- the flight's TALL end, but the player enters at floor level and the
-- warp fires as they step in -- lifting them onto the geometry read as
-- climbing an invisible block
if s.art == "stair" then return 0 end
return s.h > 0 and s.h or 0
end
VoxelScene.YAW = YAW
-- shared with the overworld battle, which stands its mons on map cells and
-- needs the same answer about what height "the floor" is there
VoxelScene.groundAt = groundAt
-- Camera-ward pull distance for billboards (and the grass rows, which
-- must keep their relative depth to feet): just enough that a leaned-back
-- slab clears the wall it leans over. The lean flattens toward top-down,
-- so the needed pull grows exactly as real occlusion stops mattering.
function VoxelScene.pull(a)
return 6 + math.max(0, 16 * math.cos(a) - 8) / math.max(math.sin(a), 0.2)
end
-- The sheet frame and mirror flag the 2D path would draw for this pose
-- (same tables as SpriteRenderer). Shared by the billboard pass and the
-- shadow pass so a walking character's shadow swings its legs too.
local function frameFor(def, facing, phase, flip)
local SR = require("src.render.SpriteRenderer")
local frame, mirror = 0, false
if (def.frames or 1) > 1 then
frame = (def.walker and phase == 1) and SR.WALK[facing]
or SR.STAND[facing]
mirror = facing == "right"
or ((facing == "down" or facing == "up") and phase == 1 and flip)
end
return frame, mirror
end
-- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as
-- a decal: its current sprite frame as a single quad, flattened onto the
-- ground along the sun line (Voxel3D.shadowMatrix). Runs inside
-- beginShadows, which supplies the translucent black; the texture is only
-- consulted for its alpha, so no palette work is needed.
local function drawShadow(sprite, px, py, facing, phase, flip, gh, lift)
local def = sprite.def
local frame, mirror = frameFor(def, facing, phase, flip)
local mesh = SpriteBillboards.shadowQuad(def, frame)
if not mesh then return end
Voxel3D.draw(mesh, sprite:resolveImage(),
Voxel3D.shadowMatrix(px, py, gh, lift, mirror))
end
-- Where a billboard character's card stands: on the middle of its cell at
-- height `y`, pivoted at the feet and tipped back by exactly the camera's
-- pitch. The slab is built centred on its sprite plane (z = 0), so only the
-- x anchor shifts; the relief bulges symmetrically front and back of it.
--
-- Shared by the solid draw and the silhouette below, so the two can never
-- drift apart -- a silhouette standing anywhere but exactly behind the
-- figure would read as a second character.
local function billboardMatrix(px, py, y, mirror)
local Voxel = V.require("VoxelState")
local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8),
Mat4.rotateX(Voxel.angle - math.pi / 2))
if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end
return Mat4.mul(m, Mat4.translate(-8, 0, 0))
end
local function billboardPull()
local Voxel = V.require("VoxelState")
return VoxelScene.pull(math.max(Voxel.angle, 0.05))
end
-- An authored FIGURE's card -- a person the tileset draws INTO a piece of
-- furniture, cut out by the profile's mask (Structures.buildFigures). It is
-- a sprite, so it gets the sprite treatment: the mesh arrives in its own
-- local space with its feet on y = 0, and this stands it at its drawn
-- position and tips it back by exactly the camera's pitch -- the same
-- pivot-at-the-feet lean billboardMatrix gives a character, so the man on
-- the Pokemon Center couch reads face-on at every tilt like the NPCs
-- around him. No cell centring: unlike a character he is not standing on a
-- cell, he is standing where he was drawn, which may straddle two.
local function figureMatrix(f, offX, offZ)
local Voxel = V.require("VoxelState")
return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.rotateX(Voxel.angle - math.pi / 2))
end
-- What the sun sees: the same card UNLEANED and flattened, exactly as
-- Voxel3D.casterMatrix does it for a character.
local function figureCaster(f, offX, offZ)
return Mat4.mul(
Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.scale(1, 1, 0))
end
-- Every figure on `map`, drawn with `draw(mesh, model, caster)`.
local function eachFigure(map, offX, offZ, draw)
for _, f in ipairs(ChunkMesher.figures(map) or {}) do
draw(f.mesh, figureMatrix(f, offX, offZ), figureCaster(f, offX, offZ))
end
end
-- Draw one posed entity. Returns true if 3D geometry carried it, false
-- when nothing could be built and the caller should fall back.
-- `colors` is the 4-color world palette the entity stands under in the SGB
-- modes (nil under RED++/trueColor): the 2D path colorizes sprites with a
-- screen-space shader the voxel canvas never runs through, so the model's
-- texture gets the palette baked in instead (TerrainAtlas.forSprite).
-- `lift` raises the figure off the ground plane (ledge hops arc UP in 3D,
-- where the 2D path could only slide the sprite north).
local function drawEntity(sprite, px, py, facing, phase, flip, gh, colors,
lift)
local def = sprite.def
local tex = sprite:resolveImage()
if colors and not def.trueColor then
tex = TerrainAtlas.forSprite(def.image, colors) or tex
end
local y = gh + (lift or 0)
-- pick the very frame the 2D path would draw (same tables). The card
-- always faces SOUTH -- the direction the 2D game implies -- and only
-- LEANS BACK, pivoting at its feet, by exactly the camera's pitch, so
-- at every tilt level the sprite reads face-on like the flat game.
-- No camera-tracking yaw: every sprite leans in parallel.
local frame, mirror = frameFor(def, facing, phase, flip)
local mesh = SpriteBillboards.mesh(def, frame)
if not mesh then return false end
-- Camera-ward pull (applied per vertex in the shader, along each
-- vertex's own eye ray, so it is a PURE depth bias with zero screen
-- drift): lets the leaned-back head win against the wall it leans
-- OVER while a character genuinely BEHIND a building is dozens of
-- pixels deeper and still loses, so real occlusion works.
-- the same card UNLEANED is what the sun saw (castShadows draws
-- exactly this mesh), so that is where each vertex asks whether the
-- light reached it -- see Voxel3D.draw
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
billboardPull(),
Voxel3D.casterMatrix(px, py, y, mirror))
return true
end
VoxelScene.drawEntity = drawEntity
-- The player's silhouette, for wherever the scenery is standing in front of
-- them (Voxel3D.beginGhost inverts the depth test around this call).
--
-- The same flat card the solid pass and the sun pass draw. That it has no
-- self-overlap is what makes it safe here: with the depth test inverted, a
-- mesh carrying both front and back faces would read its own back faces as
-- "behind something" and repaint the figure on open ground, occluded or
-- not. One quad cannot do that, and cannot double-blend into a mottled
-- patch either. A silhouette is an outline, so an outline is the right
-- mesh for it.
local function drawGhost(p)
local def = p.sprite.def
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
local mesh = SpriteBillboards.shadowQuad(def, frame)
if not mesh then return end
local tex = p.sprite:resolveImage()
if p.colors and not def.trueColor then
tex = TerrainAtlas.forSprite(def.image, p.colors) or tex
end
local y = p.gh + (p.lift or 0)
Voxel3D.draw(mesh, tex, billboardMatrix(p.px, p.py, y, mirror),
billboardPull())
end
-- Render the world. `state` is the OverworldState; `vw`/`vh` the world view
-- size in world pixels; `w`/`h` the pixel size of the canvas to render
-- into; `paletteFor(map)` yields a map's 4-color world palette (nil in the
-- color modes whose atlas is already true color). Returns the finished
-- canvas, or nil if the 3D pass could not run (headless, no depth support)
-- so the caller can fall back to 2D.
-- The last live-set key, so eviction only runs when the neighbourhood
-- actually changes (a map crossing), not every frame.
local lastLiveKey = nil
-- Request everything `state`'s frame wants and evict what it no longer
-- does; returns the current map's terrain mesh (or nil while it builds)
-- and the neighbour meshes ready to draw. render() calls this for the
-- frame it is drawing, and the pipeline's update hook calls it EVERY
-- frame -- including the frames a warp's Transition covers, when the
-- world pass is off. That update-side call is what lets a door fade hide
-- the destination's build: the map swaps behind the fade, and waiting
-- for the first visible frame to request meshes would show the flat
-- fallback while the first slices run.
function VoxelScene.prefetch(state)
local Voxel = V.require("VoxelState")
-- The live set is the current map plus its rendered neighbours. When
-- it changes, everything outside it (and the previous set, which
-- ChunkMesher retains so stepping into a house keeps the town warm)
-- is evicted -- meshes released, analysis dropped -- so memory stays
-- bounded by the neighbourhood instead of growing with every area
-- ever visited.
local liveKey = state.map.id
local live = { [state.map.id] = true }
for _, nb in ipairs(state.neighbors or {}) do
live[nb.map.id] = true
liveKey = liveKey .. "|" .. nb.map.id
end
if liveKey ~= lastLiveKey then
lastLiveKey = liveKey
ChunkMesher.setLive(live)
-- RED++ bakes one atlas per map, so its animated copy is per map too
-- and is bounded by the same neighbourhood
TerrainAtlas.setLive(live)
end
-- masks: where connected neighbour BODIES sit, so the border ring is
-- suppressed under them (see runGeometry)
local masks = {}
for _, nb in ipairs(state.neighbors or {}) do
masks[#masks + 1] = { nb.ox, nb.oy,
nb.ox + nb.map.def.width * 32,
nb.oy + nb.map.def.height * 32 }
end
-- Builds are asynchronous (ChunkMesher.pump runs in the pipeline's
-- update): request what this frame wants and draw what is ready.
-- The current map draws its body-only mesh while the full one (the
-- border ring) is still building -- a seam crossing promotes a
-- neighbour whose body is already cached, and the ring pops in a few
-- frames later, mostly hidden behind the map just left. A neighbour
-- missing its body-only mesh draws its cached FULL mesh instead -- a
-- crossing demotes the map just left, and it must not vanish from
-- behind the player while its body variant builds; its ring is
-- already masked out under this map's body, so the stand-in is safe.
local terrain = ChunkMesher.request(state.map, false, masks, true)
if not terrain then
terrain = ChunkMesher.peek(state.map, true)
end
local nbMesh = {}
for i, nb in ipairs(state.neighbors or {}) do
nbMesh[i] = ChunkMesher.request(nb.map, true)
or ChunkMesher.peek(nb.map, false)
end
Voxel.ready = terrain ~= nil
return terrain, nbMesh
end
-- Capture every entity's pose for this frame. pose() advances the hop /
-- surf bob / spinner timers, so it must be called EXACTLY once per entity
-- per frame -- the sun pass and the character pass then read the same
-- answer instead of disagreeing by a tick. Ghost NPCs live on a neighbour
-- map, so their position, ground lookup and palette all belong to that
-- map. pose() returns the VISUAL y (ledge hops arc it, surfing bobs it);
-- the difference from the entity's base y becomes vertical LIFT in 3D, so
-- a hop rises off the ground instead of sliding north.
-- Returns the pose list and, separately, the PLAYER's entry in it (nil
-- during a Fly animation, which draws the player itself and is skipped
-- below). Only that one entry gets the see-through treatment: NPCs and the
-- ghosts standing on a neighbour map are left to honest occlusion, because
-- it is only your own character you cannot afford to lose behind a roof.
local function posesOf(state, spriteColors)
local colors = spriteColors(state.map)
local posed = {}
local me = nil
for _, g in ipairs(state.ghosts or {}) do
local sprite, vx, vy, facing, phase, flip = g.npc:pose()
posed[#posed + 1] = {
sprite = sprite, px = vx + g.ox, py = g.npc.py + g.oy,
facing = facing, phase = phase, flip = flip,
gh = groundAt(g.map or state.map, g.npc.cellX, g.npc.cellY),
lift = g.npc.py - vy, colors = spriteColors(g.map or state.map),
}
end
for _, e in ipairs(state.entities or {}) do
if not (state.flyAnim and e == state.player) then
local sprite, vx, vy, facing, phase, flip = e:pose()
posed[#posed + 1] = {
sprite = sprite, px = vx, py = e.py,
facing = facing, phase = phase, flip = flip,
gh = groundAt(state.map, e.cellX, e.cellY),
lift = e.py - vy, colors = colors,
}
if e == state.player then me = posed[#posed] end
end
end
return posed, me
end
-- A stamp of everything the sun pass depends on. Nothing in it moving
-- means the shadow map it produced last frame is still exactly right, and
-- redrawing the whole world from the sun would buy nothing -- which is
-- most of a dialog, a menu, or any moment standing still.
local sigBuf = {}
local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
local n = 0
local function put(v)
n = n + 1
sigBuf[n] = v
end
-- quarter-pixel camera granularity: the light frustum is snapped to
-- whole texels anyway, each a third of a world pixel
put(math.floor(cx * 4))
put(math.floor(cy * 4))
-- the view size and the camera PITCH are both what the light frustum is
-- fitted to (a lower camera sees further north, so the box grows), so a
-- zoom step, a window resize or a rung change invalidates the map even
-- standing perfectly still
put(vw); put(vh)
put(math.floor((V.require("VoxelState").angle or 0) * 512))
put(tostring(terrain))
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
for _, p in ipairs(posed) do
put(p.sprite.def.image)
put(p.px); put(p.py); put(p.gh); put(p.lift or 0)
put(p.facing); put(p.phase); put(p.flip and 1 or 0)
end
for i = n + 1, #sigBuf do sigBuf[i] = nil end
return table.concat(sigBuf, ",")
end
-- The sun pass: render the scene once from the light, so the main pass can
-- ask any fragment whether the sun reached it. Every caster the main pass
-- draws goes in -- the terrain mesh, which is where buildings, trees,
-- ledges, signs and every prop live, plus one UPRIGHT card per character
-- (Voxel3D.casterMatrix; the leaning slab is a trick for the camera, not
-- for the sun) -- so shadows land on walls, roofs, ledges and passing NPCs
-- as readily as on the floor.
--
-- Runs BEFORE Voxel3D.beginScene, because canvases do not nest. Grass is
-- left out on purpose: thousands of tufts would cast a speckle no bigger
-- than the pixels it lands on, at the cost of the mesh being drawn twice.
local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
atlasFor)
if not ShadowMap.available() then return end
local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
if not ShadowMap.stale(sig) then return end
if not ShadowMap.begin(cx, cy, vw, vh) then return end
ShadowMap.draw(terrain, atlasFor(state.map), nil)
for i, nb in ipairs(state.neighbors or {}) do
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- flower billboards live outside the terrain mesh (they draw after the
-- characters, pulled -- see render), but the sun still sees them: a
-- handful of cutouts per meadow, unlike the grass left out below
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil)
for _, nb in ipairs(state.neighbors or {}) do
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- authored figures cast too, for the same reason the flowers do: a
-- handful of cards per map, and a person with no shadow reads as pasted on
eachFigure(state.map, 0, 0, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(state.map), caster)
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(nb.map), caster)
end)
end
for _, p in ipairs(posed) do
local def = p.sprite.def
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
local mesh = SpriteBillboards.shadowQuad(def, frame)
if mesh then
ShadowMap.draw(mesh, p.sprite:resolveImage(),
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
mirror))
end
end
ShadowMap.finish(sig)
end
function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- With nothing cached at all (the first frame of a fresh toggle),
-- return nil: the engine keeps the 2D path for the frame and
-- Voxel.ready holds the camera tween at flat, so the switch waits
-- invisibly instead of freezing or tilting an empty stage.
local terrain, nbMesh = VoxelScene.prefetch(state)
if not terrain then return nil end
local cam = state.camera
local cx, cy = cam.x + vw / 2, cam.y + vh / 2
local function atlasFor(map)
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
end
-- sprite palettes only exist in the SGB modes; under RED++ the OBP bake
-- inside sprite:resolveImage() already colors the sheet
local function spriteColors(map)
if PaletteFX.usesGbcPack() then return nil end
return modeColors(paletteFor, map)
end
local posed, me = posesOf(state, spriteColors)
castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor)
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
return nil
end
Voxel3D.draw(terrain, atlasFor(state.map), nil)
for i, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- Without a shadow map (headless, or a driver that could not make the
-- canvas) the old flat decals stand in: ground-only, characters only,
-- but better than a world with nothing under anybody. They go down
-- first, as decals the characters then stand over -- depth-tested
-- against the terrain just drawn (a shadow behind a building stays
-- hidden) but never depth-writing, so the grass pass at the end of the
-- frame still wins its feet-overdraw fights.
if not Voxel3D.shadowsActive() then
Voxel3D.beginShadows()
for _, p in ipairs(posed) do
drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.lift)
end
Voxel3D.endShadows()
end
-- The player's silhouette goes down BEFORE the characters, so the only
-- thing it can meet in the depth buffer is the WORLD -- terrain, buildings,
-- trees. Drawn after the solid pass it would meet the player's own card
-- instead, and every fragment of a figure sits behind the one that just
-- wrote it, so the silhouette would paint over the player at all times.
-- Every character then draws on top as usual, which leaves the silhouette
-- showing in exactly one situation: where the world hides them.
if me then
Voxel3D.beginGhost()
drawGhost(me)
Voxel3D.endGhost()
end
-- Characters carry no wireframe out here, whatever the V-GRID row says.
-- The seams are what makes the WORLD read as built out of voxels, and
-- the people walking around in it are the one thing that should read as
-- drawn instead -- a grid over a 16x16 sprite lands a line every couple
-- of display pixels and turns a face into a mesh. (The battle pass makes
-- the opposite call for its own combatants, deliberately: that is a
-- staged shot rather than the world being walked around in -- see
-- BattleBillboard.)
--
-- Characters, normally depth-tested: the camera-ward pull inside
-- drawEntity resolves the lean-over-the-wall-in-front case, and a
-- character genuinely behind a building is far deeper and loses the
-- test, so buildings and trees really occlude.
Voxel3D.seams(false)
for _, p in ipairs(posed) do
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.colors, p.lift)
end
-- Authored figures, alongside the characters and with the same lean and
-- the same camera-ward pull -- they ARE characters as far as the artwork
-- is concerned, just ones the tileset draws instead of a sprite sheet.
-- Drawn after the walkers so a player standing in front of the couch
-- wins the overlap, which is the order the flat game draws them in.
local figPull = billboardPull()
eachFigure(state.map, 0, 0, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, caster)
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, caster)
end)
end
-- and the seams are back on for the terrain art that follows: grass and
-- flowers are the world's own drawing, not people
Voxel3D.seams(true)
-- tall grass last, pulled camera-ward exactly as far as the characters
-- were (same per-vertex shader bias, so grass never drifts either):
-- relative depth between a walker and the tuft row south of their feet
-- is preserved, so the row still overdraws feet -- the 3D version of
-- the GB's grass-over-feet trick -- while grass keeps losing to the
-- buildings it genuinely stands behind (far deeper than the pull).
local Voxel = V.require("VoxelState")
local pull = VoxelScene.pull(math.max(Voxel.angle, 0.05))
Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull)
for _, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
-- flower billboards: pulled like the characters and the grass, MINUS
-- the depth of 8 world pixels along the view (8 sin a -- the camera
-- looks along (0, -cos a, -sin a), so that is exactly one tile row of
-- northness). A pure depth handicap with zero screen drift: every
-- flower is judged as if it stood one tile row further north. The
-- character card's feet plane sits at its cell's MIDDLE (py + 8), so
-- a flower on the walker's own cell (z +4 or +12 across the cell)
-- lands behind the card and the player obscures the patch they stand
-- ON, while the nearest flower of the cell south (+20) stays in front
-- and keeps overdrawing their feet.
local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
fpull)
for _, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull)
end
return Voxel3D.endScene()
end
return VoxelScene