mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 13:10:51 +02:00
1217 lines
57 KiB
Lua
1217 lines
57 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 Water = V.require("Water")
|
|
local VoxelGrid = V.require("VoxelGrid")
|
|
local DayNight = V.require("DayNight")
|
|
local FirstPerson = V.require("FirstPerson")
|
|
local BattleBillboard = V.require("BattleBillboard")
|
|
local Pokedex = V.require("Pokedex")
|
|
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
|
|
local sky = VoxelScene.skyShade(SKY_SHADE, t)
|
|
-- outdoors the flat fill follows the CLOCK: it becomes the hour's haze --
|
|
-- gold at dusk, navy at night -- so a battle staged on the map at
|
|
-- midnight is under a midnight void, not a noon one. Free-roam is
|
|
-- unchanged by this: Sky.dress overwrites the fill with the same value.
|
|
local haze = Sky.haze()
|
|
if haze then sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] end
|
|
return sky
|
|
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
|
|
|
|
-- The facing a pose SHOWS this camera. The flat frames are "how this pose
|
|
-- looks from the south", which is where the orbit always stands; a
|
|
-- first-person eye stands anywhere, so deep enough into the blend the
|
|
-- facing is remapped to how the pose looks from THERE -- walk behind an
|
|
-- NPC and their card wears the back sprite. Used by the camera draw and
|
|
-- the sun pass BOTH: the card the sun stored and the transform a lit card
|
|
-- reads its own shadowing with must describe the same frame, or the
|
|
-- mirror-flip half of the pair asks the map about texels the sun filed
|
|
-- under the other cheek.
|
|
-- The player's own card asks a different function for the same answer:
|
|
-- their body's bearing is what the camera is derived FROM, so it is known
|
|
-- continuously rather than as one of four directions, and measuring
|
|
-- against the compass point instead flicks the card to a profile for a
|
|
-- frame or two when the camera is spun fast (see playerFacing).
|
|
local function viewFacing(p)
|
|
if FirstPerson.cardBlend() > 0.5 then
|
|
if p.isPlayer then
|
|
return FirstPerson.playerFacing(p.facing, p.px + 8, p.py + 8)
|
|
end
|
|
return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8)
|
|
end
|
|
return p.facing
|
|
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.
|
|
--
|
|
-- IN FIRST PERSON the card stops leaning and starts TURNING: upright, yawed
|
|
-- about its feet to face the eye (cylindrical billboarding). A south-facing
|
|
-- card is invisible edge-on to an eye standing east of it, which no orbit
|
|
-- camera could ever do and a first-person one does constantly. The blend
|
|
-- carries one pose into the other -- the lean eases out as the yaw eases in
|
|
-- -- and cardBlend is zero for every camera that is not the first-person
|
|
-- rig, the battle's placed shot included, so nothing else moves.
|
|
-- The pitch the sprite cards lean back by -- normally the rung's own
|
|
-- camera angle, overridable in radians. VR sets the override to the top
|
|
-- rung's 75 degrees for every diorama and battle frame: a table watched
|
|
-- from a freely moving head has no one camera pitch for the cards to
|
|
-- match, and the near-upright top-rung lean is the pose that reads as
|
|
-- "standing" from anywhere around it. nil (the default, and the flat
|
|
-- screen always) leans with the rung as ever.
|
|
VoxelScene.spriteLean = nil
|
|
|
|
local function leanAngle()
|
|
return VoxelScene.spriteLean or V.require("VoxelState").angle
|
|
end
|
|
|
|
local function billboardMatrix(px, py, y, mirror)
|
|
local b = FirstPerson.cardBlend()
|
|
local m = Mat4.translate(px + 8, y, py + 8)
|
|
if b > 0 then
|
|
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b))
|
|
end
|
|
m = Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b)))
|
|
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()
|
|
return VoxelScene.pull(math.max(leanAngle(), 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.
|
|
--
|
|
-- First person turns him at the eye like the walkers (see billboardMatrix)
|
|
-- -- about his own middle, because unlike a character card his local space
|
|
-- starts at x = 0 rather than being anchored by a -8 shift, and a yaw about
|
|
-- his edge would swing him off his seat. The width rode in on the record
|
|
-- for exactly this (ChunkMesher.buildFigureMeshes).
|
|
local function figureMatrix(f, offX, offZ)
|
|
local b = FirstPerson.cardBlend()
|
|
local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0)
|
|
local m = Mat4.translate(wx, f.y, wz)
|
|
if b > 0 and f.w and f.w > 0 then
|
|
local half = f.w / 2
|
|
m = Mat4.mul(m, Mat4.translate(half, 0, 0))
|
|
m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b))
|
|
m = Mat4.mul(m, Mat4.translate(-half, 0, 0))
|
|
end
|
|
return Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b)))
|
|
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 -- and SNUGGED, exactly as the sun stored it
|
|
-- (castShadows draws this mesh through ShadowMap.snug) -- is where each
|
|
-- vertex asks whether the light reached it; see ShadowMap.snug for why
|
|
-- the lookup must match the stored transform to the letter
|
|
Voxel3D.draw(mesh, tex, billboardMatrix(px, py, y, mirror),
|
|
billboardPull(),
|
|
ShadowMap.snug(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, viewFacing(p), 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.
|
|
-- The water surface rides along with whichever variant answers: it was
|
|
-- cut out of that build's own geometry (ChunkMesher.pair), so the two
|
|
-- always come from the same slot and a lake is never drawn twice or left
|
|
-- as a hole.
|
|
ChunkMesher.request(state.map, false, masks, true)
|
|
local terrain, water = ChunkMesher.pair(state.map, false)
|
|
if not terrain then
|
|
terrain, water = ChunkMesher.pair(state.map, true)
|
|
end
|
|
local nbMesh, nbWater = {}, {}
|
|
for i, nb in ipairs(state.neighbors or {}) do
|
|
ChunkMesher.request(nb.map, true)
|
|
nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, true)
|
|
if not nbMesh[i] then
|
|
nbMesh[i], nbWater[i] = ChunkMesher.pair(nb.map, false)
|
|
end
|
|
end
|
|
Voxel.ready = terrain ~= nil
|
|
return terrain, nbMesh, water, nbWater
|
|
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]
|
|
-- marked so the camera draw can leave the card out in first
|
|
-- person, where it would fill the lens from inside; the SUN pass
|
|
-- reads the same list and deliberately does not check the mark
|
|
me.isPlayer = true
|
|
end
|
|
end
|
|
end
|
|
return posed, me
|
|
end
|
|
|
|
-- ------- the glint's drive
|
|
--
|
|
-- A reflection is something the VIEWPOINT does, so the window glint is fed
|
|
-- by the camera's own travel rather than by a clock: its phase advances
|
|
-- with distance covered and its strength fades in over a few steps of
|
|
-- walking and back out within a beat of standing still. Stand still and
|
|
-- the glass is still; move and the light crosses it.
|
|
-- The rate is slow on purpose: the sweep pattern lives in the pane's own
|
|
-- texels (see the scene shader), so this is a FRACTION of a texel per world
|
|
-- pixel walked -- one full pass of the glint across a pane per eight or so
|
|
-- cells of travel, with no frame ever jumping it far enough to strobe.
|
|
VoxelScene.GLINT_RATE = 0.05 -- radians of sweep per world pixel travelled
|
|
VoxelScene.GLINT_IN = 0.12 -- strength gained per moving frame
|
|
VoxelScene.GLINT_OUT = 0.08 -- and lost per resting frame
|
|
|
|
function VoxelScene.glintStep(g, cx, cy)
|
|
local dist = 0
|
|
if g.x then
|
|
dist = math.abs(cx - g.x) + math.abs(cy - g.y)
|
|
end
|
|
g.x, g.y = cx, cy
|
|
g.phase = ((g.phase or 0) + dist * VoxelScene.GLINT_RATE) % (2 * math.pi)
|
|
if dist > 0.05 then
|
|
g.amp = math.min(1, (g.amp or 0) + VoxelScene.GLINT_IN)
|
|
else
|
|
g.amp = math.max(0, (g.amp or 0) - VoxelScene.GLINT_OUT)
|
|
end
|
|
return g
|
|
end
|
|
|
|
local glint = {}
|
|
|
|
-- ------- the cast
|
|
--
|
|
-- Everybody standing on the map: the walkers, and the authored FIGURES the
|
|
-- tileset draws into its own furniture (they ARE characters as far as the
|
|
-- artwork is concerned, just ones drawn by the tileset instead of by a
|
|
-- sprite sheet, so they get the same lean and the same camera-ward pull).
|
|
--
|
|
-- One function because it is drawn TWICE and the two must be identical: once
|
|
-- into the frame, and once into the water's reflection copy (see drawWater --
|
|
-- Gen 1 draws people over the world, and water is world, so the cast cannot
|
|
-- be composited before the water it has to appear in).
|
|
--
|
|
-- 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 -- see BattleBillboard.)
|
|
--
|
|
-- Sprite sheets until the figure pass: their texture coordinates mean
|
|
-- nothing to the tileset-shaped glass mask, so the glass is off or the
|
|
-- panes' atlas positions stripe the cast with lamplight at night.
|
|
local function drawCast(state, posed, atlasFor)
|
|
Voxel3D.glass(false)
|
|
Voxel3D.seams(false)
|
|
-- 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.
|
|
--
|
|
-- In first person two of them change: the player's own card is left out
|
|
-- (the eye is standing in it), and every other card wears the frame its
|
|
-- pose SHOWS this eye (viewFacing) rather than the one it shows the
|
|
-- south. Both run through here, so the water's reflection copy -- drawn
|
|
-- by this same function -- agrees with the frame to the pixel.
|
|
local hideMe = FirstPerson.hidePlayer()
|
|
for _, p in ipairs(posed) do
|
|
if not (p.isPlayer and hideMe) then
|
|
drawEntity(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh,
|
|
p.colors, p.lift)
|
|
end
|
|
end
|
|
-- back on for everything textured from the atlas again -- figures, grass
|
|
-- and flowers all sample it, where the mask's coordinates are honest
|
|
Voxel3D.glass(true)
|
|
-- Figures after the walkers, so a player standing in front of the couch
|
|
-- wins the overlap -- 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,
|
|
ShadowMap.snug(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,
|
|
ShadowMap.snug(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)
|
|
end
|
|
|
|
-- ------- the water pass
|
|
--
|
|
-- Between the terrain and everything that stands on it, because water is a
|
|
-- MIRROR and a mirror can only reflect what is already down: the ground, the
|
|
-- shoreline, the trees and buildings behind it, and the sky the frame opened
|
|
-- with.
|
|
--
|
|
-- THE CAST IS THE AWKWARD ONE, and it is settled by drawing it twice. Gen 1
|
|
-- draws people over the world and water is world, so a surfing player has to
|
|
-- composite OVER the water they are sitting on -- which puts them after it,
|
|
-- and a reflection can only hold what came before it. So `cast` is painted
|
|
-- into the reflection copy alone (Voxel3D.beginWater), where it is in the
|
|
-- picture the water reflects and not yet in the picture the water is drawn
|
|
-- into. Both draws go through drawCast, so they cannot come out different.
|
|
--
|
|
-- The ray march finds them the honest way round: a sprite is not in the
|
|
-- DEPTH buffer at that point, so a ray aimed at one passes through to the
|
|
-- terrain standing behind it and reads the copy there -- where the sprite is
|
|
-- already painted. The reflection lands a hair off the sprite's own depth
|
|
-- and exactly on its colour, which at a lake's worth of ripple is the same
|
|
-- picture.
|
|
--
|
|
-- `draws` is a list of { mesh, texture, model }. Nothing is a special case:
|
|
-- with the row OFF, no depth texture to read, or a shader that would not
|
|
-- build, the same meshes go through the ordinary scene shader and come out
|
|
-- as the flat animated water this mode always drew.
|
|
-- The overworld's alone: the staged battle draws its water plain, always --
|
|
-- its placed camera reads this pass wrong, and a stage set wants painted
|
|
-- water anyway (see BattleScene, where the choice is argued).
|
|
-- ------- and why the flat draw happens FIRST while the world is curved
|
|
--
|
|
-- The reflective pass writes no depth -- it cannot, the depth canvas is
|
|
-- detached for the length of it so the shader can READ it -- and it does its
|
|
-- own depth test against that texture instead. That test asks whether
|
|
-- something opaque is in front, and it answers correctly for every case but
|
|
-- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so
|
|
-- no lake can hide another, and the pass simply paints them in mesh order.
|
|
--
|
|
-- On a flat world that never matters: every surface lies in the one plane
|
|
-- at its own recessed height, and a farther sheet always lands farther down
|
|
-- the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by the
|
|
-- square of its distance, so the far side of the map swings down and back
|
|
-- up into the near field of view -- and a sheet of sea a hundred and fifty
|
|
-- tiles away, drawn later in the same mesh, paints straight over the pond
|
|
-- at the player's feet. Not a reflection of the far shore: the far shore
|
|
-- itself, rasterised on top of the water in front of you.
|
|
--
|
|
-- So WHILE THE CURVE IS ON, the meshes go down flat first, through the
|
|
-- ordinary scene shader with depth writes on, and the reflective pass draws
|
|
-- over the top of what survived: the depth buffer now holds the water
|
|
-- surface, so the pass's own test throws the far sheet away, and the
|
|
-- reflection COPY holds it too, so a ray grazing another part of the lake
|
|
-- reads water rather than the void behind it.
|
|
--
|
|
-- With the curve OFF the prepass is not just unnecessary, it is a LIABILITY,
|
|
-- and it stays off -- the reflective pass tests only against terrain, as it
|
|
-- always did. Painting the surface into the depth texture turns the pass's
|
|
-- test into a comparison of the surface against ITSELF, which asks the two
|
|
-- rasterisations to agree to within interpolation error -- and on mobile
|
|
-- GPUs they don't reliably (that fight is what put the Android port back on
|
|
-- flat water). Confined to the curve there is no regression to reach: the
|
|
-- flat world never had the far-shore bug in the first place.
|
|
function VoxelScene.drawWater(draws, cast)
|
|
-- prepass only under the bend; see the header
|
|
local curved = (Voxel3D.curveK or 0) > 0
|
|
if curved then
|
|
for _, d in ipairs(draws) do
|
|
Voxel3D.draw(d[1], d[2], d[3])
|
|
end
|
|
end
|
|
local plain = not curved
|
|
if Water.enabled() and Voxel3D.depthReadable() then
|
|
local mirror, depth = Voxel3D.beginWater(cast)
|
|
local w, h = Voxel3D.size()
|
|
local ok = mirror and depth and Water.begin({
|
|
reflect = mirror, depth = depth,
|
|
vp = Voxel3D.vp, eye = Voxel3D.eye, curve = { Voxel3D.curveX or 0,
|
|
Voxel3D.curveZ or 0,
|
|
Voxel3D.curveK or 0 },
|
|
screen = { w, h }, cell = Voxel3D.cell, fov = Voxel3D.fovY,
|
|
skyEdge = Voxel3D.skyEdge, grid = VoxelGrid.enabled(),
|
|
lookFlat = Voxel3D.lookFlat, descent = Voxel3D.descent,
|
|
})
|
|
if ok then
|
|
for _, d in ipairs(draws) do
|
|
Water.draw(d[1], d[2], d[3])
|
|
end
|
|
Water.finish()
|
|
plain = false
|
|
end
|
|
-- Unconditionally, and OUTSIDE the success branch: beginWater unbinds
|
|
-- the shader and the depth mode BEFORE it can discover it cannot go on,
|
|
-- so a frame that bails halfway through has to be put back together
|
|
-- exactly like one that succeeded -- otherwise every pass after it runs
|
|
-- with no shader and no depth test.
|
|
Voxel3D.endWater()
|
|
end
|
|
-- the fallback flat draw -- unless the curve's prepass already put the
|
|
-- same meshes down, in which case a bailed frame is already whole
|
|
if plain then
|
|
for _, d in ipairs(draws) do
|
|
Voxel3D.draw(d[1], d[2], d[3])
|
|
end
|
|
end
|
|
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))
|
|
-- the sun itself: the cycle swings the shear as the clock runs, and a map
|
|
-- lit from somewhere new must be redrawn from there too. Quantised by the
|
|
-- rig's own step (DayNight.rigTime), so a running cycle redraws the map a
|
|
-- few times a minute rather than every frame.
|
|
put(math.floor(ShadowMap.KX * 128))
|
|
put(math.floor(ShadowMap.KZ * 128))
|
|
-- and the first-person head: the box is fitted around wherever it looks
|
|
-- and the sprite cards swap frames as it circles them, so a turn on the
|
|
-- spot re-fits and redraws exactly like a camera move ("" outside 1ST)
|
|
put(FirstPerson.signature())
|
|
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, water, nbWater, battleCards, battleToken)
|
|
if not ShadowMap.available() then return end
|
|
local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
|
|
-- a staged fight's pics move every frame the animation does, and the sun
|
|
-- has to follow them (VR frames only; see render)
|
|
if battleToken then sig = sig .. "|btl" .. tostring(battleToken) end
|
|
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
|
|
-- The water surface, which the terrain mesh no longer carries (it is its
|
|
-- own reflective pass now -- see Water). The sun still has to see it, or
|
|
-- the map the light records has a hole at every lake and the frustum's
|
|
-- far plane answers for the surface a shoreline tree's shadow falls on.
|
|
ShadowMap.draw(water, atlasFor(state.map), nil)
|
|
for i, nb in ipairs(state.neighbors or {}) do
|
|
ShadowMap.draw(nbWater and nbWater[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.
|
|
-- Every thin card from here down is SNUGGED toward the sun along its own
|
|
-- ray (ShadowMap.snug) so its shadow keeps contact with its feet instead
|
|
-- of starting a bias-width away.
|
|
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map),
|
|
ShadowMap.snug(nil))
|
|
for _, nb in ipairs(state.neighbors or {}) do
|
|
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
|
|
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
|
end
|
|
-- From here down it is the CAST, marked as such in the map (see
|
|
-- ShadowMap.sprites) so water can decline them: everything the world casts
|
|
-- still shades a lake, a silhouette of somebody standing beside it does
|
|
-- not. Ground, roofs and the characters themselves take them as before.
|
|
ShadowMap.sprites(true)
|
|
-- 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), ShadowMap.snug(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), ShadowMap.snug(caster))
|
|
end)
|
|
end
|
|
for _, p in ipairs(posed) do
|
|
local def = p.sprite.def
|
|
-- viewFacing, exactly as the camera draw picks it (see viewFacing for
|
|
-- why the two passes must agree): in first person the sun's card
|
|
-- swaps frame as the eye circles, which costs a redraw the signature
|
|
-- already charges for (FirstPerson.signature) and keeps a card from
|
|
-- fringing against a mirror-flipped record of itself
|
|
local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip)
|
|
local mesh = SpriteBillboards.shadowQuad(def, frame)
|
|
if mesh then
|
|
ShadowMap.draw(mesh, p.sprite:resolveImage(),
|
|
ShadowMap.snug(
|
|
Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0),
|
|
mirror)))
|
|
end
|
|
end
|
|
-- a staged fight's mons (VR frames only): the same cards the eye pass
|
|
-- stands on the arena, snugged like every thin card, marked as the cast
|
|
-- so the water can decline them like everybody else's silhouette
|
|
for _, card in ipairs(battleCards or {}) do
|
|
ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model))
|
|
end
|
|
ShadowMap.sprites(false)
|
|
-- and the STADIUM models, outside the sprite flag and un-snugged, for
|
|
-- the reasons the flat battle pass gives (BattleScene.castShadows):
|
|
-- these are geometry, not cut-outs
|
|
pcall(function()
|
|
local stageArena, stageY = V.require("OverworldBattle").stage()
|
|
if stageArena and stageArena.discs then
|
|
V.require("StadiumStage").cast(ShadowMap, stageArena, stageY or 0)
|
|
end
|
|
V.require("Stadium").cast(ShadowMap)
|
|
end)
|
|
|
|
ShadowMap.finish(sig)
|
|
end
|
|
|
|
-- Render the world. Without `eyes`, one frame into one canvas -- the flat
|
|
-- path every rung has always taken. With `eyes` -- a list of
|
|
-- { camera, w, h, slot, adopt } records, plus optional cx/cy for the
|
|
-- scene centre -- the same frame is drawn once per entry and the list of
|
|
-- canvases comes back: the VR path, two eyes over one shared shadow map,
|
|
-- pose capture and glint step.
|
|
function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
|
|
-- 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, water, nbWater = 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
|
|
|
|
-- the hour's light, before anything is cast or drawn: point the shared
|
|
-- rig at the clock (or at noon, indoors -- a cave at midnight is exactly
|
|
-- as dark as a cave at noon) and set the tint the scene shader multiplies
|
|
-- every surface by. A CANOPY map (Viridian Forest) is the case between:
|
|
-- the rig stays at noon and no sky is painted, but the hour's tint still
|
|
-- falls through the leaves -- night reaches a forest floor.
|
|
local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false
|
|
DayNight.applyRig(outdoor)
|
|
Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(state.map))
|
|
-- and the window glass: the tileset's own panes (found in its art --
|
|
-- GlassMask), lit after dark. Outdoors only, like everything the clock
|
|
-- touches, which also keeps any pane-shaped art in an interior tileset
|
|
-- from picking up a glint.
|
|
local GlassMask = V.require("GlassMask")
|
|
Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil
|
|
Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0
|
|
local g = VoxelScene.glintStep(glint, cx, cy)
|
|
Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp
|
|
|
|
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)
|
|
|
|
-- The first-person rig, built (or blended) for this frame and handed to
|
|
-- Voxel3D BEFORE either pass runs: the sun's box is fitted around this
|
|
-- camera, and every card matrix asks it which way to turn. With the
|
|
-- blend fully out the call clears the placed camera and the orbit is
|
|
-- exactly what it always was. The scene centre it returns walks from
|
|
-- the orbit's view centre into the head, so the curve's focus and the
|
|
-- depth reference follow the camera actually in charge.
|
|
--
|
|
-- A VR frame skips all of it: the caller brought its own cameras, and
|
|
-- its own idea of the scene centre with them.
|
|
if not eyes then
|
|
local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh)
|
|
if fpRig then cx, cy = fpCx, fpCy end
|
|
elseif eyes.cx then
|
|
cx, cy = eyes.cx, eyes.cy
|
|
end
|
|
|
|
-- A staged fight, seen by the VR eyes: the flat screen draws the battle
|
|
-- SCREEN while one is up (this pass never runs), but the headset keeps
|
|
-- looking at the world, so the world had better have the fight on it.
|
|
-- Fetched per frame for the sun, and again per EYE in drawScene, because
|
|
-- the cards yaw toward whichever eye is asking.
|
|
local battleCards, battleTex, battleToken = nil, nil, nil
|
|
if eyes then
|
|
local okB, cards, tex, token = pcall(function()
|
|
return V.require("OverworldBattle").worldCards()
|
|
end)
|
|
if okB and cards then
|
|
battleCards, battleTex, battleToken = cards, tex, token
|
|
end
|
|
end
|
|
|
|
-- The sun's box, pushed along the first-person look so it covers the
|
|
-- ground THIS camera sees (a no-op at blend zero): the orbit's fit
|
|
-- reaches far north and barely south, which is right for every rung
|
|
-- but a head free to face south.
|
|
local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh)
|
|
castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor,
|
|
water, nbWater, battleCards, battleToken)
|
|
|
|
-- Everything between beginScene and endScene, as one function: the flat
|
|
-- path runs it once, a VR frame runs it once PER EYE -- same posed
|
|
-- list, same shadow map, same glint, so the two eyes can never disagree
|
|
-- about anything but their viewpoint.
|
|
local function drawScene()
|
|
|
|
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, viewFacing(p), p.phase, p.flip, p.gh,
|
|
p.lift)
|
|
end
|
|
Voxel3D.endShadows()
|
|
end
|
|
|
|
-- and the water over the top of it, reflecting everything just drawn plus
|
|
-- the sky the frame opened with (see drawWater).
|
|
--
|
|
-- After the fallback decals deliberately: those are the stand-in drop
|
|
-- shadows for a frame with no shadow map, they write no depth, and a
|
|
-- lake would otherwise wear one as a black smear. Water covers them,
|
|
-- which is the same answer the shadow map's own pass gives (see
|
|
-- ShadowMap.sprites) -- people do not shadow water either way.
|
|
local waterDraws = {}
|
|
if water then
|
|
waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil }
|
|
end
|
|
for i, nb in ipairs(state.neighbors or {}) do
|
|
if nbWater and nbWater[i] then
|
|
waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map),
|
|
Mat4.translate(nb.ox, 0, nb.oy) }
|
|
end
|
|
end
|
|
-- the cast goes into the reflection copy only -- see drawWater for why it
|
|
-- cannot be composited yet and why it is drawn through the same function
|
|
-- the real pass below uses
|
|
if #waterDraws > 0 then
|
|
VoxelScene.drawWater(waterDraws, function()
|
|
drawCast(state, posed, atlasFor)
|
|
end)
|
|
end
|
|
|
|
|
|
-- Sprite sheets from here to the figure pass: their texture coordinates
|
|
-- mean nothing to the tileset-shaped glass mask, so the glass is off or
|
|
-- the panes' atlas positions stripe the cast with lamplight at night
|
|
Voxel3D.glass(false)
|
|
|
|
-- 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.
|
|
--
|
|
-- Not in first person: the card it silhouettes is the one the camera is
|
|
-- standing inside, and "the world is in front of the player" is every
|
|
-- wall the player faces.
|
|
if me and not FirstPerson.hidePlayer() 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.
|
|
drawCast(state, posed, atlasFor)
|
|
-- The staged fight's mons, standing on their arena cells in THIS eye's
|
|
-- view (VR frames only; battleTex is nil otherwise). Rebuilt per eye
|
|
-- because the cards yaw toward the eye that is looking. No wireframe
|
|
-- and no glass on them for the reasons BattleBillboard and the battle
|
|
-- pass each argue: the cards are not on the voxel grid, and their
|
|
-- texcoords mean nothing to the tileset's pane mask. The hit flash
|
|
-- rides the same flatten the battle pass uses, held short of solid.
|
|
if battleTex then
|
|
local okB, cards = pcall(function()
|
|
return V.require("OverworldBattle").worldCards()
|
|
end)
|
|
if okB and cards then
|
|
local BattleScene = V.require("BattleScene")
|
|
Voxel3D.glass(false)
|
|
Voxel3D.seams(false)
|
|
if battleTex.flash then
|
|
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
|
|
end
|
|
for _, card in ipairs(cards) do
|
|
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
|
|
BattleBillboard.PULL)
|
|
end
|
|
-- and, on the STADIUM rungs, the models -- the same skinned meshes the
|
|
-- flat pass and the sun already used this frame, drawn again through
|
|
-- THIS eye. Unlike the cards there is nothing per-eye about them: a
|
|
-- model faces its opponent, not the viewer, so both eyes see the same
|
|
-- pose from their own seats, which is what makes it read as solid.
|
|
--
|
|
-- On a disc rung the platforms come with them. In a headset the world is
|
|
-- still drawn -- the player is standing IN it, which is the whole point
|
|
-- of the headset, so the rung's "no map" does not apply here -- and the
|
|
-- discs then read as a stage set down on the ground, which is what they
|
|
-- are.
|
|
pcall(function()
|
|
local stageArena, stageY = V.require("OverworldBattle").stage()
|
|
if stageArena and stageArena.discs then
|
|
V.require("StadiumStage").draw(stageArena, stageY or 0)
|
|
end
|
|
V.require("Stadium").draw(BattleBillboard.PULL)
|
|
end)
|
|
if battleTex.flash then Voxel3D.flatten(nil) end
|
|
-- and the MOVE ANIMATIONS, standing on the same arena: the
|
|
-- engine's own effects layer on the plane through both cells
|
|
-- (BattleScene.fxCard), pulled a little harder than the mons so
|
|
-- a burst plays over the card it is bursting on
|
|
local okA, fxTex, fxModel = pcall(function()
|
|
return V.require("OverworldBattle").worldAnim()
|
|
end)
|
|
if okA and fxTex and fxModel then
|
|
Voxel3D.draw(BattleBillboard.mesh(), fxTex, fxModel,
|
|
BattleBillboard.PULL + 6)
|
|
end
|
|
Voxel3D.seams(true)
|
|
Voxel3D.glass(true)
|
|
end
|
|
end
|
|
-- 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).
|
|
-- the same angle the cards leaned by (leanAngle honours VR's override),
|
|
-- so the tuft rows keep exactly the characters' own depth handicap
|
|
local lean = math.max(leanAngle(), 0.05)
|
|
local pull = VoxelScene.pull(lean)
|
|
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(lean))
|
|
-- flowers are snugged casters too, so they read their own shadowing
|
|
-- through the same snugged transform the sun stored them with
|
|
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
|
|
fpull, ShadowMap.snug(nil))
|
|
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,
|
|
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
|
|
end
|
|
|
|
-- The VR pokedex in the player's left hand, last of all: a prop over
|
|
-- the world drawn with real depth, so leaning it into a wall still
|
|
-- occludes honestly. Its frame only exists while a session is live and
|
|
-- the left hand is tracked (VR.lua sets it), so every flat frame skips
|
|
-- this in one field read. No wireframe and no glass, like the cast:
|
|
-- the device is a drawing riding the scene, not part of the terrain.
|
|
if Pokedex.frame then
|
|
Voxel3D.glass(false)
|
|
Voxel3D.seams(false)
|
|
Pokedex.draw()
|
|
Voxel3D.seams(true)
|
|
Voxel3D.glass(true)
|
|
end
|
|
|
|
-- HORDE MODE's handgun, in the same slot and for the same reasons: a
|
|
-- prop over the world with real depth, no wireframe and no glass. In VR
|
|
-- it rides the tracked right hand (lib/VR placed it this frame); on the
|
|
-- flat screen it is carried by the camera, which is why it draws here
|
|
-- rather than in the overlay -- a view model that is 2D cannot be
|
|
-- occluded by the wall the player just backed into.
|
|
do
|
|
local HordeGun = V.require("HordeGun")
|
|
if HordeGun.visible() then
|
|
Voxel3D.glass(false)
|
|
Voxel3D.seams(false)
|
|
HordeGun.draw()
|
|
Voxel3D.seams(true)
|
|
Voxel3D.glass(true)
|
|
end
|
|
end
|
|
|
|
end -- drawScene
|
|
|
|
if not eyes then
|
|
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
|
|
return nil
|
|
end
|
|
drawScene()
|
|
return Voxel3D.endScene()
|
|
end
|
|
|
|
-- The VR frame: the same scene once per eye, each into its own named
|
|
-- canvas slot under its own placed camera. `adopt` hands the eye's
|
|
-- record to FirstPerson as the live rig, which is what turns the
|
|
-- billboards toward THIS eye in first person (cardBlend keys on rig
|
|
-- identity -- see FirstPerson) and leaves them leaning in the diorama,
|
|
-- where the blend is zero.
|
|
local out = {}
|
|
for i, eye in ipairs(eyes) do
|
|
Voxel3D.camera = eye.camera
|
|
if eye.adopt then FirstPerson.adoptVReye(eye.camera) end
|
|
if not Voxel3D.beginScene(eye.w, eye.h, cx, cy, vw, vh,
|
|
skyFor(state.map), eye.slot) then
|
|
return nil
|
|
end
|
|
drawScene()
|
|
out[i] = Voxel3D.endScene()
|
|
end
|
|
return out
|
|
end
|
|
|
|
return VoxelScene
|