diff --git a/CHANGELOG.md b/CHANGELOG.md index eed2d45..d03334c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,159 @@ # Changelog +## 1.4.0 + +### Added + +- **WATER, a new row on hotkey 9: water reflects the world, the sky, the sun + and the moon.** Every lake, sea and pond in Kanto was a flat animated + texture lying in a hole in the ground. It is now a surface, and it is + reflective. + + What it reflects, in the order the shader resolves them: + + - **The sky.** The reflected direction goes through the very matrix the + frame is drawn with, as a point at infinity, and the canvas row that + lands on is looked up on Sky's own band ramp -- the identical texture, + the identical checkerboard dither, the identical display-mode transform. + So the sky in the lake is the sky over it, and the two meet at the + waterline with no seam at any pitch, field of view, window shape or zoom. + Blue at noon, gold at dusk, navy under the moon; GRAY gets a grey lake + and CLASSIC a green one, for nothing. + + - **The sun and the moon**, hung by ANGLE rather than by screen position, + because a reflected body is usually off the top of the frame entirely + and a projected point stops meaning anything out there. The angular + radius is the painted disc's own radius run back through the camera's + field of view, so the two are the same size -- craters, dithered rim, + the sunset's loom and all, off one shared list. This is also the + specular: a low sun lays a broken gold path across the water on its own, + out of the reflection rather than out of a highlight term nailed on + beside it. + + - **The world, in screen space.** The reflected ray is walked forward in + world space, each step projected through the same matrix, looking for + where it passes behind what the depth buffer holds -- then binary-refined + onto the contact and read out of a copy of the frame as it stood before + the water went down. Shore trees, buildings, ledges and cliffs land in + the water because they are on screen; where the ray leaves the frame or + finds nothing, the sky above answers instead, which is what makes the far + half of a lake sky and the near half scenery with no seam between them. + + Fresnel decides how much of it shows: almost nothing looked straight down + at, almost everything looked along -- so the 15-degree rung is a pond and + the 75-degree rung is a mirror, off the same surface. + + Every rung gets one, though, which took a lean. A reflection off flat water + points as far above the horizon as the eye is above the water: 15 degrees + at the top rung -- grazing the sky's pale end, sweeping the sun's own path, + travelling far enough across the screen for the march to find the shoreline + -- and 75 degrees, straight up, at the steepest. Up there the bands are at + their darkest, the sun and moon sit at about 6 degrees of squashed + elevation and are nowhere near it, and the screen-space ray leaves the top + of the frame in two steps. All three are correct, and together they are a + lake with nothing in it. + + So the reflection now LEANS toward the elevation the top rung reflects at, + by however far the camera is from having a horizon in frame -- **zero** at + the rung where the horizon IS in frame, so the one place the join can be + seen, the waterline, is still the exact reflection it was. Toward an + elevation rather than by a weight, because the ray it starts from differs + at every rung and a fixed fraction lands them all somewhere different: the + middle rungs came out further from the sun than the steepest one. And it + leans the LEVEL reflection with each column's own deflection added back on + top -- leaning the perturbed ray sets its elevation outright, which at full + lean gave every column on the lake the same one, flattened the sky to a + single band and removed the moon entirely. + + Three rungs rather than a toggle. FULL is the whole thing; SKY drops the + ray march and keeps the sky, sun and moon, which is most of the look for a + handful of instructions; OFF is the flat water this mode always drew. The + FULL preset sets it to FULL. + +- **The water surface is a field of pixel-tall columns, and they are real.** + Not a normal map: a heightfield of one-world-pixel bars -- the same unit + every other voxel in this mode is built from, and exactly one texel of the + water tile -- each standing a WHOLE number of pixels high and rising and + falling on its own. + + Three travelling wave trains, and one of them dominates: a wave has a + DIRECTION, and its crest is a line running across it for as far as the + water goes. Three trains of equal weight cancel and reinforce in patches + instead, and the surface comes out as round islands of raised pixels with + no travel to them -- blobs rather than waves. The dominant train's + wavelength is about forty world pixels, five tiles, so a crest is a long + run of columns at one height with a step down either side. + + Drawn with no extra geometry at all: the mesh is still one flat quad per + tile, and the columns are found by walking the view ray down through the + slab in the pixel shader. That is what makes them read as solid -- a tall + bar hides the shorter ones behind it, you see the SIDE of the ones facing + you (wearing the mesh's own direction shading, so a crest is lit like every + other voxel in the world), and the whole field parallaxes against the plane + as the camera moves. The water's art is read at the column the ray landed + on rather than at the flat quad underneath, so the pixels travel with the + bars they are made of. + + The columns are what you SEE; the normal they reflect with is read off the + smooth surface they are a quantisation of. That distinction is the whole + difference between a moon on the water and confetti: whole-pixel heights + have whole-pixel differences, so a normal built from them can only point in + about five directions, and a sun or moon barely two degrees across falls + between them. Still one normal per column, so the surface stays + pixel-quantised in space while the value it reflects with is continuous. + + Crests stand up to five world pixels, well past the 2px recess water sits + in -- deliberately, because the columns are relief drawn inside the water + quad's own footprint, so a bar that reaches above the bank is clipped at + the water's edge rather than spilling over it. What it buys is a surface + with real swell in it instead of a two-rung terrace. + + And it moves in STEPS, at **15 a second** -- the cadence hand-drawn pixel + art is animated at. A surface built out of whole pixels that crawls + smoothly between them gives away that the quantisation is only skin deep. + Each step advances the dominant wave by exactly one world pixel, derived + from that train's own wavelength rather than tuned beside it, so nothing + ever lands half-way between two pixels and changing a wavelength moves the + speed with it. + + +### Changed + +- **The water surface is its own mesh, and its own pass.** A mirror cannot be + drawn until what it reflects exists, so water is lifted out of the terrain + mesh at build time and drawn between the world and the characters. The + shoreline faces around it are untouched -- they belong to the GROUND that + exposes them -- and the sun still sees the surface, so a tree at the water's + edge still throws its shadow onto the lake. + +- **The scene's depth buffer is a readable canvas.** It was an internal buffer + that could be written and tested and never sampled; it is now the same + buffer with a texture handle on it, at the same cost. Drivers that will not + make one fall straight back to the old buffer and lose the reflections and + nothing else. + +- **The cast is reflected too -- by being drawn 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 the walkers, the NPCs and + the authored figures are painted into the reflection COPY alone, where they + are in the picture the water reflects and not yet in the picture the water + is drawn into. Both draws go through one function, so they cannot come out + different. The staged battle does the same with its two Pokemon. + + 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 wave is the same picture. + +### Known + +- Screen-space reflections can only reflect what is in the frame. A tree just + off the top edge is not in the water below it, and a reflection whose ray + runs off the side of the screen fades into the sky rather than ending on a + hard line. + ## 1.3.1 ### Fixed diff --git a/README.md b/README.md index 6886926..db18394 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,22 @@ as leaning sprite slabs, a shadow map throws real cast shadows across whatever they land on, and an optional tilt-shift pass sells the miniature-model look. +Water is a surface rather than a texture lying in a hole. It is a field of +one-pixel-wide voxel columns, each standing a whole number of pixels tall and +rising and falling as waves — found by walking the view ray through them in +the shader, so a crest hides what is behind it and shows you its lit side, +with no extra geometry anywhere. + +And it reflects. The sky it stands under, in the same bands, the same dither +and off the same clock, so the lake and the sky above it meet at the +waterline with no seam. The sun or moon hanging in it, at the size the +painted disc is drawn, craters and all. Whoever is standing beside it — +walkers, NPCs, the two Pokémon in a staged battle. And on **FULL**, a +screen-space ray march adds the rest of what is on screen: the shoreline, the +trees behind it, the buildings across the bay. How much of it shows is +Fresnel, so the top rung is a mirror and a looking-straight-down rung is a +pond, off the same water. + And battles fought on that world rather than on a white field. When something picks a fight the map's NPCs are culled, the engine's own wipe plays over the empty map, and the battle draws over the nearest patch of @@ -34,6 +50,7 @@ menu. | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | +| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index d8d0170..d483ed5 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -141,9 +141,10 @@ local function prefetchArena(state, host) for _, nb in ipairs(state.neighbors or {}) do live[nb.map.id] = true end ChunkMesher.setLive(live) TerrainAtlas.setLive(live) - local terrain = ChunkMesher.request(host, false, nil, true) - or ChunkMesher.peek(host, true) - return terrain, {} + ChunkMesher.request(host, false, nil, true) + local terrain, water = ChunkMesher.pair(host, false) + if not terrain then terrain, water = ChunkMesher.pair(host, true) end + return terrain, {}, water, {} end -- ------- the sun @@ -227,7 +228,8 @@ local function shadowSignature(state, arena, terrain, nbMesh, token) end local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, - atlasFor, cards, token, host, neighbors) + atlasFor, cards, token, host, neighbors, + water, nbWater) if not ShadowMap.available() then return end local sig = shadowSignature(state, arena, terrain, nbMesh, token) if not ShadowMap.stale(sig) then return end @@ -237,6 +239,14 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, for i, nb in ipairs(neighbors) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + -- the water surface is its own reflective pass now (see Water) and so is + -- no longer inside the terrain mesh; the sun still has to see it, or the + -- light's map has a hole at every lake + ShadowMap.draw(water, atlasFor(host), nil) + for i, nb in ipairs(neighbors) do + ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end -- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows -- keep contact with their bases instead of starting a bias-width away ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), @@ -326,7 +336,7 @@ function BattleScene.render(state, arena, textures, token) -- shares the free-roam mode's request/evict bookkeeping, so a battle warms -- exactly the meshes walking around would have and nothing extra - local terrain, nbMesh = prefetchArena(state, host) + local terrain, nbMesh, water, nbWater = prefetchArena(state, host) if not terrain then return nil end local lx, ly, s, pw, ph = BattleScene.letterbox() @@ -356,7 +366,7 @@ function BattleScene.render(state, arena, textures, token) local cards = monCards(arena, groundY, textures) Voxel3D.camera = nil castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, atlasFor, - cards, token, host, neighbors) + cards, token, host, neighbors, water, nbWater) -- An opaque void either way. Outdoors the camera is low enough that the -- horizon is genuinely in frame, so it is sky; indoors it is the dark end @@ -393,6 +403,31 @@ function BattleScene.render(state, arena, textures, token) Voxel3D.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end + -- and the water over it, reflecting the arena and the hour's sky (see + -- VoxelScene.drawWater -- the arena fights on the same lakes). The two + -- mons are this shot's cast: painted into the reflection copy alone, so + -- a fight staged at the water's edge has both of them in the water while + -- they still composite over it, exactly as the overworld's walkers do. + local waterDraws = {} + if water then waterDraws[#waterDraws + 1] = { water, atlasFor(host) } end + for i, nb in ipairs(neighbors) do + if nbWater and nbWater[i] then + waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy) } + end + end + if #waterDraws > 0 then + VoxelScene.drawWater(waterDraws, function() + Voxel3D.seams(false) + Voxel3D.glass(false) + for _, card in ipairs(monCards(arena, groundY, textures)) do + Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, + BattleBillboard.PULL, ShadowMap.snug(card.model)) + end + Voxel3D.glass(true) + Voxel3D.seams(true) + end) + end -- The mons, standing on their tiles. Depth-tested like everything else, -- so a ledge or a tree between the camera and a Pokemon really is in -- front of it, and the alpha discard cuts the sprite's own outline out of diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index d8b2310..73ed890 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -221,8 +221,18 @@ end -- 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) +-- `waterSink`, when given, takes the WATER SURFACE quads instead of the +-- main sink -- the one class in this world that is drawn as its own pass +-- (see Water: a mirror cannot be drawn until what it reflects exists). +-- Nothing else moves: the quads are the same quads, emitted by the same +-- corner and uv arithmetic at the same recessed height, and the shoreline +-- faces around them still belong to the GROUND that exposes them. +-- +-- Omitted, water stays in the terrain mesh exactly as it always did, which +-- is what the headless geometry() below and the sun's own pass both want. +local function runGeometry(map, bodyOnly, masks, sink, waterSink) local push = sink.push + local waterPush = waterSink and waterSink.push or nil local tileset = map.tileset local S = Structures.forMap(map) local perRow = tileset.tilesPerRow or 16 @@ -358,12 +368,14 @@ local function runGeometry(map, bodyOnly, masks, sink) return aoSide end - local function topQuad(x0, z0, h, tile, shade) + -- `to` routes the quad somewhere other than the main sink -- the water + -- surface is the only caller that ever does (see runGeometry's header). + local function topQuad(x0, z0, h, tile, shade, to) 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)) + ;(to or 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), @@ -558,8 +570,14 @@ local function runGeometry(map, bodyOnly, masks, sink) end topTile = S.tileAt[keyOf(tx, row)] end + -- water's surface, and only water's: the recessed sheet itself, + -- never the ground's shoreline bands around it. A cell an object + -- stands on took the branch above and paints synthesized GROUND, + -- which is right -- a sign at the waterline stands on a plot, not + -- on the pond. topQuad(x0, z0, h, topTile, - s.art == "upright" and VOLUME_TOP_SHADE or 1) + s.art == "upright" and VOLUME_TOP_SHADE or 1, + (s.class == "water") and waterPush or nil) end -- sides: 8px bands wherever the neighbour is lower. Band k spans @@ -764,18 +782,34 @@ 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) +-- +-- `split` lifts the water surface out, as it is lifted out for the +-- reflective pass, and appends that sink's own three values -- so the suite +-- can check the same separation the GPU path relies on without a GPU. +-- Without it the water is in the first list, which is what every existing +-- caller reads. +function ChunkMesher.geometry(map, bodyOnly, masks, split) local sink = newTableSink() - runGeometry(map, bodyOnly, masks, sink) - return sink.results() + local waterSink = split and newTableSink() or nil + runGeometry(map, bodyOnly, masks, sink, waterSink) + if not waterSink then return sink.results() end + local v, i, n = sink.results() + local wv, wi, wn = waterSink.results() + return v, i, n, wv, wi, wn 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) +-- +-- `split` asks for the water surface as a SECOND mesh, returned after the +-- terrain one -- the shape the reflective pass needs (see Water). Without +-- it the water is inside the terrain mesh, which is the historical +-- contract and what every other caller still wants. +function ChunkMesher.build(map, bodyOnly, masks, split) local sink = newSink() - runGeometry(map, bodyOnly, masks, sink) - return sink.finish() + local waterSink = split and newSink() or nil + runGeometry(map, bodyOnly, masks, sink, waterSink) + return sink.finish(), waterSink and waterSink.finish() or nil end local function quadsMesh(quads) @@ -858,8 +892,17 @@ local function entry(id) return c end +-- The water surface that came out of a terrain slot's own build. Kept +-- beside it rather than in a slot of its own because the two are ONE +-- answer: a full mesh drawn beside a body build's water would draw the +-- ring's ponds twice and miss the body's own. +local function waterSlot(slot) + return slot .. "Water" +end + local function releaseEntry(c) - for _, slot in ipairs({ "full", "body", "grass", "flowers" }) do + for _, slot in ipairs({ "full", "body", "fullWater", "bodyWater", + "grass", "flowers" }) do local mesh = c[slot] if mesh and mesh.release then pcall(mesh.release, mesh) end c[slot] = nil @@ -924,13 +967,17 @@ local function runJob(job) if c.stale then c.stale.aux = nil end end local sink = newSink() - runGeometry(map, job.slot == "body", job.masks, sink) + local waterSink = newSink() + runGeometry(map, job.slot == "body", job.masks, sink, waterSink) local mesh = sink.finish() + local water = waterSink.finish() if (gen[job.id] or 0) ~= job.gen then if mesh and mesh.release then pcall(mesh.release, mesh) end + if water and water.release then pcall(water.release, water) end return end swapSlot(c, job.slot, mesh or false) + swapSlot(c, waterSlot(job.slot), water or false) if c.stale then c.stale[job.slot] = nil if not (c.stale.full or c.stale.body or c.stale.aux) then @@ -1032,12 +1079,14 @@ function ChunkMesher.get(map, bodyOnly, masks) if c.stale then c.stale.aux = nil end end if c[slot] == nil or (c.stale and c.stale[slot]) then - local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks) + local ok, mesh, water = pcall(ChunkMesher.build, map, bodyOnly, masks, + true) if not ok then print("[warn] voxel mesh build failed for " .. tostring(map.id) .. ": " .. tostring(mesh)) end swapSlot(c, slot, (ok and mesh) or false) + swapSlot(c, waterSlot(slot), (ok and water) or false) if c.stale then c.stale[slot] = nil if not (c.stale.full or c.stale.body or c.stale.aux) then @@ -1058,6 +1107,21 @@ function ChunkMesher.peek(map, bodyOnly) return mesh or nil end +-- A slot's terrain mesh AND the water surface lifted out of it, as one +-- answer. Never builds, like peek. +-- +-- Both or neither, always from the SAME slot: the water was cut out of that +-- exact geometry, so pairing a full mesh with a body build's water would +-- draw the border ring's ponds twice and leave the body's as holes. Callers +-- that fall back from one variant to the other fall back through this, so +-- there is nowhere for the two to be chosen separately. +function ChunkMesher.pair(map, bodyOnly) + local c = cache[map.id] + if not c then return nil, nil end + local slot = bodyOnly and "body" or "full" + return c[slot] or nil, c[waterSlot(slot)] or nil +end + function ChunkMesher.grass(map) local c = cache[map.id] return c and c.grass or nil diff --git a/lib/Sky.lua b/lib/Sky.lua index ec0ad2d..79431a4 100644 --- a/lib/Sky.lua +++ b/lib/Sky.lua @@ -263,6 +263,27 @@ end Sky._rampFor = rampFor -- named for the suite +-- The band ramp for the CURRENT bands, plus how many texels wide it is -- +-- for a pass that wants to read the same sky this one paints. The water's +-- reflection is the one caller: it looks the reflected direction up on this +-- very ramp, so the sky on the lake and the sky over it are one palette, +-- through one display-mode transform, off one clock. +-- +-- nil where the ramp could not be built, which is exactly when Sky.paint +-- falls back to flat bands -- so a driver that loses the gradient loses the +-- reflected gradient with it rather than showing two different skies. +function Sky.ramp() + local bands = Sky.bands() + if not (bands and bands[1]) then return nil end + local img = rampFor(bands) + if not img then return nil end + return img, #bands, bands +end + +-- How far the twilight glow reaches around the disc, in canvas pixels, for +-- a `w`-wide frame. The same number Sky.paint sends as `glowInvR`. +Sky.GLOW_REACH = 0.55 + local shader = nil -- nil = untried, false = unavailable local function getShader() @@ -323,20 +344,51 @@ end Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius --- crater centres as fractions of the radius, so they ride any disc size -local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 }, - { -0.15, 0.7 }, { 0.05, 0.05 } } +-- crater centres as fractions of the radius, so they ride any disc size. +-- Public because the water's reflection draws the same moon (see Water): +-- one list, so the disc on the lake cannot drift from the one in the sky. +Sky.MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 }, + { -0.15, 0.7 }, { 0.05, 0.05 } } + +-- a crater's radius, as a fraction of the disc's -- the r/5 paintDisc uses +Sky.CRATER_FRAC = 0.2 + +local MOON_CRATERS = Sky.MOON_CRATERS + +-- The disc's four shades as the display mode has them, lightest first. +-- Shared with the reflection pass, so the sun on the water is the same sun +-- that is in the sky, in the same mode's palette. +function Sky.discShades(moon) + local src = moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS + return PaletteFX.effectiveColors(src) or src +end + +-- Whether this body is the LOOMING low sun -- the sunset exaggeration. +local function looming(body) + return (body.glowAmt or 0) > 0.25 and not body.moon +end + +-- The disc's radius for a `h`-tall frame on a `cell`-pixel grid: in CANVAS +-- PIXELS, and in whole cells. Sized by the FRAME rather than by the world +-- (see DISC_FRAC), so a zoom does not swell the sun. +-- +-- Read by paintDisc below and by the reflection, which needs the same +-- number in radians -- a disc drawn one size and mirrored another would +-- read as two different suns. +function Sky.discRadius(h, cell, body) + cell = math.max(1, cell or 1) + local r = math.max(Sky.DISC_MIN, + math.floor(h * Sky.DISC_FRAC / cell + 0.5)) + if body and looming(body) then r = r + math.max(1, math.floor(r * 0.4)) end + return r * cell, r +end local function paintDisc(body, edge, cell, w, h) local g = love.graphics if not (body and body.y and g.setScissor) then return end - local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS - local shades = PaletteFX.effectiveColors(src) or src - local twilight = (body.glowAmt or 0) > 0.25 and not body.moon - local r = math.max(Sky.DISC_MIN, - math.floor(h * Sky.DISC_FRAC / cell + 0.5)) - -- the low sun looms: the classic sunset exaggeration, and it reads - if twilight then r = r + math.max(1, math.floor(r * 0.4)) end + local shades = Sky.discShades(body.moon) + local twilight = looming(body) + local _, r = Sky.discRadius(h, cell, body) -- snap the centre to the cell grid, like everything else in this sky local bx = math.floor(body.x / cell) * cell + cell / 2 local by = math.floor(body.y / cell) * cell + cell / 2 @@ -429,7 +481,7 @@ function Sky.paint(w, h, sky, horizonY, cell, body) if glowAmt > 0 then local gc = body.glowColor or { 248, 224, 168 } sh:send("glowPos", { body.x, body.y }) - sh:send("glowInvR", 1 / math.max(1, w * 0.55)) + sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH)) sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 }) end end) diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 62e98be..df2f311 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -283,8 +283,57 @@ local activeShader = nil -- the variant this pass bound -- resize, so the pair is stable for a session. local slots = {} local canvas, canvasW, canvasH = nil, 0, 0 -- the slot this pass bound +local held = nil -- and the whole record for it local active = false +-- A READABLE depth canvas, so a later pass in the same frame can ask the +-- buffer questions rather than only write to it -- which is the whole of +-- what makes screen-space reflections possible (see Water). +-- +-- `depth = true` in the target list, which is what this used to bind, +-- allocates an internal depth buffer that is written and tested and can +-- never be sampled. An explicit canvas is the same buffer with a texture +-- handle on it, and costs the same memory. +-- +-- nil where the driver will not make one -- the format is optional in GLES +-- and a canvas is the only honest test of it -- and beginScene falls +-- straight back to the internal buffer, which is exactly the old behaviour +-- minus the reflections. +local function newDepth(w, h) + if not (love.graphics and love.graphics.newCanvas) then return nil end + local ok, c = pcall(love.graphics.newCanvas, w, h, + { format = "depth24", readable = true }) + if not (ok and c) then return nil end + -- nearest: a depth is a distance, and a blend of two of them is a + -- distance to nothing. The march wants the texel it landed on. + pcall(c.setFilter, c, "nearest", "nearest") + pcall(c.setWrap, c, "clamp", "clamp") + -- and no compare mode: with one set, Texel returns a 0/1 shadow verdict + -- instead of the depth, which is not what any reader here wants + pcall(c.setDepthSampleMode, c) + return c +end + +-- The bound target for the slot this pass holds: the colour canvas plus +-- either the readable depth canvas or the internal buffer. +local function depthTarget() + if held and held.depth then + return { held.canvas, depthstencil = held.depth } + end + return { canvas, depth = true } +end + +-- Every GPU object one slot owns. The mirror is the copy of the frame the +-- water pass reads (see beginWater); it is only ever made if something asks +-- for one, so a session that never sees a lake never pays for it. +local function releaseSlot(slotHeld) + for _, key in ipairs({ "canvas", "depth", "mirror" }) do + local obj = slotHeld[key] + if obj and obj.release then pcall(obj.release, obj) end + slotHeld[key] = nil + end +end + local IDENTITY = Mat4.identity() -- Whether the driver admits to supporting derivatives. Only a hint -- @@ -379,6 +428,41 @@ end -- way either way. Voxel3D.camera = nil +-- ------- which way, and how steeply, this camera looks +-- +-- Two facts about the view direction, set alongside the eye and the focus +-- because they ARE the eye and the focus, and read by anything that has to +-- reason about the camera's ATTITUDE rather than about a point in front of +-- it: +-- +-- lookFlat the view direction flattened onto the ground plane and +-- normalized -- "the way the horizon lies from here", which is +-- what a reflection leans toward at the steeper rungs (Water). +-- descent how far below horizontal the view runs, as a sine: 0 looking +-- level, 1 looking straight down. It is the number that says +-- whether there is a horizon in frame at all, and it answers +-- the same way for the orbit and for a placed battle camera -- +-- which is why this is derived from the two vectors rather than +-- read off Voxel.angle, a rung the battle camera does not have. +-- +-- A camera looking exactly straight down has no horizontal direction at all, +-- and lookFlat then keeps whatever it last held rather than becoming a zero +-- vector nothing downstream could normalize. +Voxel3D.lookFlat = { 0, 0, -1 } +Voxel3D.descent = 0 + +local function setLook(eye, focus) + local dx = focus[1] - eye[1] + local dy = focus[2] - eye[2] + local dz = focus[3] - eye[3] + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len < 1e-6 then return end + Voxel3D.descent = math.max(0, math.min(1, -dy / len)) + local flat = math.sqrt(dx * dx + dz * dz) + if flat < 1e-6 then return end + Voxel3D.lookFlat = { dx / flat, 0, dz / flat } +end + -- View and projection for a `vw` x `vh` world-pixel view centred on -- (cx, cy) in world pixels. Returns the combined matrix. function Voxel3D.viewProjection(cx, cy, vw, vh) @@ -389,10 +473,15 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- kept beside the eye for horizonY: where the sky's pale end goes is a -- question about which way this camera looks, and only these two answer it Voxel3D.focus = focus + setLook(eye, focus) local dx = eye[1] - focus[1] local dy = eye[2] - focus[2] local dz = eye[3] - focus[3] local dist = math.max(1, math.sqrt(dx * dx + dy * dy + dz * dz)) + -- kept for the passes that measure an ANGLE against this camera rather + -- than a position: the water's reflected sun is sized in radians, and + -- radians per canvas pixel is exactly this over the frame height + Voxel3D.fovY = cam.fov local proj = Mat4.perspective(cam.fov, vw / vh, math.max(1, dist * 0.05), dist * 4 + 4096) -- the same clip-space Y flip the orbit needs, for the same reason: we @@ -409,12 +498,14 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- the FOV that makes a straight-down camera at `dist` frame exactly `vh` -- world pixels, which is the framing the flat view already has local fov = 2 * math.atan(1 / (2 * focal)) + Voxel3D.fovY = fov local focus = { cx, 0, cy } local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) } -- exposed for camera-facing billboards (VoxelScene yaws sprites at it) Voxel3D.eye = eye Voxel3D.focus = focus + setLook(eye, focus) -- perpendicular to the view direction in the YZ plane: north is screen-up -- when looking straight down, +Y is screen-up when looking level. Never -- parallel to the view direction, so there is no degenerate a = 0 case. @@ -538,22 +629,29 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) end if not sh then return false end local name = slot or "world" - local held = slots[name] - if not (held and held.w == w and held.h == h) then + local slotHeld = slots[name] + if not (slotHeld and slotHeld.w == w and slotHeld.h == h) then local ok, c = pcall(love.graphics.newCanvas, w, h) if not ok then return false end c:setFilter("nearest", "nearest") - if held and held.canvas and held.canvas.release then - pcall(held.canvas.release, held.canvas) - end - held = { canvas = c, w = w, h = h } - slots[name] = held + if slotHeld then releaseSlot(slotHeld) end + -- the depth canvas is sized with its colour, so a window resize + -- reallocates the pair together and they can never disagree + slotHeld = { canvas = c, w = w, h = h, depth = newDepth(w, h) } + slots[name] = slotHeld end + held = slotHeld canvas, canvasW, canvasH = held.canvas, w, h -- a depth buffer is what makes occlusion real: walk behind a building and -- the building wins, with no y-sorting anywhere - local ok = pcall(love.graphics.setCanvas, - { canvas, depth = true }) + local ok = pcall(love.graphics.setCanvas, depthTarget()) + if not ok and held.depth then + -- the readable canvas would not bind; fall back to the internal buffer + -- for the rest of this session rather than losing the whole 3D pass + pcall(held.depth.release, held.depth) + held.depth = nil + ok = pcall(love.graphics.setCanvas, depthTarget()) + end if not ok then pcall(love.graphics.setCanvas) return false @@ -561,6 +659,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- Ahead of the clear, because the sky's bands are placed off the ground -- plane's vanishing line and that is a property of this matrix. Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh) + -- This frame's pixels per WORLD pixel: the size a diorama pixel is on + -- screen. The sky's dither grid is cut to it, and so is the water's -- + -- one number, so the two break up on the same checkerboard. + Voxel3D.cell = w / math.max(1, vw or w) + -- and where the sky's bottom edge lands, which is what the reflection + -- reads its bands against (see Water). nil when nothing painted bands. + Voxel3D.skyEdge = (sky and sky.bands) + and Sky.region(h, Voxel3D.horizonY(h)) or nil if sky then love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) -- The sky goes down here, in the one window in this function where a @@ -573,7 +679,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- are the same size as the world's own and follow every resize and zoom. -- The banded sky also hangs the hour's sun or moon (skyBody projects it -- through this very camera); a flat sky has no bands and hangs nothing. - Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w), + Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell, sky.bands and Voxel3D.skyBody(w, h) or nil) else love.graphics.clear(0, 0, 0, 0, true, true) @@ -720,6 +826,108 @@ function Voxel3D.flatten(color, amount) end end +-- ------------------------------------------------------- the water pass -- +-- +-- A reflective surface has to READ the frame it is being drawn into: the +-- colour of what is standing around it and the depth that says where. Both +-- are attachments of the target this pass is bound to, and a texture cannot +-- be sampled while it is one -- so for the length of the water draw the +-- frame is taken apart: +-- +-- the COLOUR is copied to a mirror canvas, which is a texture like any +-- other and is what the reflection samples. +-- +-- the DEPTH is simply detached. The water shader does the test itself +-- against the texture (see Water), which is the same comparison the +-- hardware would have made -- what it gives up is depth WRITES, and water +-- is flat, never overlaps itself, and has nothing drawn under it later. +-- +-- `paint`, when given, is called with the MIRROR bound and the scene shader +-- set, to add things that must be REFLECTED without being composited yet. +-- +-- The characters are the whole reason it exists. Gen 1 draws people over +-- the world and water is world, so the cast has to composite AFTER the +-- water -- but a reflection can only contain what was drawn BEFORE it, and +-- a lake with everyone standing beside it and nobody in it reads as glass. +-- Painting them into the mirror alone settles both: they are in the picture +-- the water reflects and not yet in the picture the water is drawn into. +-- +-- They go down depth-TESTED and depth-WRITE-FREE. Tested, so a figure behind +-- a building is behind it in the reflection too; write-free because the very +-- next thing to read that buffer is the water's own depth test, and a cast +-- that had written to it would punch itself out of the water it is standing +-- beside. +-- +-- Returns the two textures, or nil when there is nothing to hand over: no +-- readable depth canvas on this driver, or no pass open. A caller that gets +-- nil draws its water like ordinary terrain, which is what this mode always +-- did. +-- +-- MUST be paired with endWater, which puts the frame back together. +function Voxel3D.beginWater(paint) + if not (active and canvas and held and held.depth) then return nil end + if not held.mirror then + local ok, c = pcall(love.graphics.newCanvas, held.w, held.h) + if not (ok and c) then return nil end + pcall(c.setFilter, c, "nearest", "nearest") + pcall(c.setWrap, c, "clamp", "clamp") + held.mirror = c + end + love.graphics.setShader() + -- the frame's own depth rides along, so the paint below can test against + -- it; the copy underneath switches the test off rather than detaching it + local ok = pcall(love.graphics.setCanvas, + { held.mirror, depthstencil = held.depth }) + if not ok then + pcall(love.graphics.setCanvas, depthTarget()) + return nil + end + love.graphics.setDepthMode("always", false) + -- COLOUR only. The last two arguments are what keep the depth buffer the + -- frame's rather than this canvas's: cleared here, the water's own depth + -- test a few lines later would find nothing in front of anything and every + -- lake would draw straight through the buildings standing in it. + love.graphics.clear(0, 0, 0, 0, false, false) + -- premultiplied over a cleared target is a straight copy: every channel + -- lands exactly as it stood, including the alpha, so the mirror is the + -- frame rather than the frame composited against something + love.graphics.setBlendMode("alpha", "premultiplied") + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(canvas) + love.graphics.setBlendMode("alpha") + if paint and activeShader then + love.graphics.setDepthMode("lequal", false) + love.graphics.setShader(activeShader) + pcall(paint) + love.graphics.setShader() + end + love.graphics.setDepthMode() + -- and back to the scene canvas WITHOUT its depth: that texture is about + -- to be read + if not pcall(love.graphics.setCanvas, canvas) then + pcall(love.graphics.setCanvas, depthTarget()) + return nil + end + return held.mirror, held.depth +end + +-- Put the frame back: depth reattached, depth test and the scene shader as +-- the pass had them. Safe to call after a beginWater that returned nil. +function Voxel3D.endWater() + if not active then return end + pcall(love.graphics.setCanvas, depthTarget()) + pcall(love.graphics.setDepthMode, "lequal", true) + love.graphics.setColor(1, 1, 1, 1) + if activeShader then love.graphics.setShader(activeShader) end +end + +-- Whether a reflective water pass can run in this frame at all -- there is +-- a depth texture to read. Callers use it to choose between the water +-- shader and an ordinary terrain draw before they start moving canvases. +function Voxel3D.depthReadable() + return (active and held and held.depth) and true or false +end + -- Whether what is drawn next carries the voxel wireframe. false for the -- length of a draw, true to put it back. -- @@ -931,18 +1139,25 @@ function Voxel3D.canvas() return canvas end +-- The bound canvas's pixel size, for a pass that has to work in screen +-- coordinates (the water's reflection marches in them). +function Voxel3D.size() + return canvasW, canvasH +end + -- Drop the GPU objects (window resize, hot reload). function Voxel3D.invalidate() - for name, held in pairs(slots) do - if held.canvas and held.canvas.release then - pcall(held.canvas.release, held.canvas) - end + for name, slotHeld in pairs(slots) do + releaseSlot(slotHeld) slots[name] = nil end canvas, canvasW, canvasH = nil, 0, 0 + held = nil ShadowMap.invalidate() -- the sky is part of this pass and holds a shader of its own Sky.invalidate() + -- and so does the water, for the same reason + V.require("Water").invalidate() -- and the glass masks are textures of this context too GlassMask.invalidate() end diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 01cfa0f..96bd4b8 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -21,6 +21,8 @@ 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 PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -404,17 +406,25 @@ function VoxelScene.prefetch(state) -- 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) + -- 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 = ChunkMesher.peek(state.map, true) + terrain, water = ChunkMesher.pair(state.map, true) end - local nbMesh = {} + local nbMesh, nbWater = {}, {} for i, nb in ipairs(state.neighbors or {}) do - nbMesh[i] = ChunkMesher.request(nb.map, true) - or ChunkMesher.peek(nb.map, false) + 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 + return terrain, nbMesh, water, nbWater end -- Capture every entity's pose for this frame. pose() advances the hop / @@ -490,6 +500,123 @@ 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. + 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 + -- 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. +-- Shared with the overworld battle, which stages its fights on the same +-- lakes and wants the same water under them. +function VoxelScene.drawWater(draws, cast) + local plain = true + 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 the plain draw below (and + -- every pass after it) runs with no shader and no depth test. + Voxel3D.endWater() + end + 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 @@ -540,7 +667,7 @@ end -- 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) + atlasFor, water, nbWater) 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 @@ -551,6 +678,15 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, 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. @@ -593,7 +729,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- 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) + local terrain, nbMesh, water, nbWater = VoxelScene.prefetch(state) if not terrain then return nil end local cam = state.camera @@ -630,7 +766,8 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) end local posed, me = posesOf(state, spriteColors) - castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor) + castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor, + water, nbWater) if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then return nil @@ -642,6 +779,27 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) Mat4.translate(nb.ox, 0, nb.oy)) end + -- and the water over the top of it, reflecting everything just drawn plus + -- the sky the frame opened with (see drawWater) + 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 + -- 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 @@ -689,33 +847,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- 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 - -- 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) - -- 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, - 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) + drawCast(state, posed, atlasFor) -- 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 diff --git a/main.lua b/main.lua index ccef967..95ca933 100644 --- a/main.lua +++ b/main.lua @@ -81,6 +81,7 @@ local OverworldBattle = V.require("OverworldBattle") local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") +local Water = V.require("Water") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -282,6 +283,10 @@ applyFull = function(level) -- the horizon flat. The curve bends the world away from a walking player, -- which fights a fixed diorama framing WorldCurve.setting:setIndex(1, Game) + -- and the water reflecting everything it can: FULL is the diorama at its + -- most photographed, and a lake with the sky and the shoreline in it is + -- most of what makes the model read as being outdoors + Water.setting:setIndex(1, Game) -- and the view fitted to the window opts.zoom = 0 Zoom.applyOptions(opts) @@ -332,6 +337,11 @@ local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style." }, + { Water.setting, + "Reflections on water. FULL adds screen-space reflections of the " + .. "shoreline, the trees and the buildings behind it; SKY is the sky, " + .. "the sun and the moon alone, which is most of the look for a " + .. "fraction of the cost." }, -- `full` marks a row FULL does not take away. FULL owns the diorama's own -- knobs; what a battle is drawn over, and how it is framed, are not that. { OverworldBattle.setting, @@ -366,6 +376,7 @@ mod.options:define(schema) -- 6 T-SHIFT cycle the blur ladder (was 9) -- 7 V-CURVE cycle the horizon bend (new) -- 8 3D-BTL toggle overworld battles (new) +-- 9 WATER cycle the water reflections (new; 9 was T-SHIFT's old key) -- -- Only 6 arrives by the documented route. Game:keypressed answers the -- engine's own display keys FIRST and returns -- 2 COLORS, 3 TILT, 4 ZOOM, @@ -398,6 +409,7 @@ local HOTKEYS = { ["5"] = VoxelGrid.setting, ["7"] = WorldCurve.setting, ["8"] = OverworldBattle.setting, + ["9"] = Water.setting, } do @@ -447,19 +459,19 @@ do return end elseif Pipelines.canToggle("voxel", top, self.overworld) then - -- All three answer to the voxel pass's own free-roam gate -- + -- All four answer to the voxel pass's own free-roam gate -- -- borrowed from the registry rather than restated, so a press -- mid-warp or mid-cutscene is refused for the wireframe exactly when - -- it would be for the mode itself. Two of them parameterise that - -- pass; the third (3D-BTL) decides what a battle is drawn over, and + -- it would be for the mode itself. Three of them parameterise that + -- pass; the fourth (3D-BTL) decides what a battle is drawn over, and -- wants the same gate for a different reason: the answer is read -- when the fight starts, so flipping it from inside one would be a -- switch that appeared to do nothing. claim:cycle(self) -- 8 is one of the two ways staged battles get switched on, and they - -- pin BATTLE LAYOUT to OG (see the rows hook). The other two keys + -- pin BATTLE LAYOUT to OG (see the rows hook). The other keys -- parameterise the pass and leave the layout alone; the guard answers - -- for all three, so nothing here has to know which key it was. + -- for all of them, so nothing here has to know which key it was. if stagedBattles() then OverworldBattle.forceOG(self) end return end @@ -848,7 +860,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.3.1" +mod.exports.version = "1.4.0" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index ef2c15c..92602fa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.3.1", + "version": "1.4.0", "api": 2, "entry": "main.lua", "profile": "content", @@ -15,5 +15,5 @@ "engine_internals" ], "affects_link": false, - "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Registers two render pipelines and claims hotkeys 3, 5, 6, 7 and 8 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands." + "description": "A full 3D diorama overworld: extruded terrain, depth-buffered occlusion, voxel characters and a tilt-shift miniature pass -- and battles fought on the map itself, shot over the shoulder at the nearest clear ground with a slow parallax drift and a depth-of-field pass. Water reflects the sky, the sun, the moon and -- through a screen-space ray march -- the shoreline standing behind it. Registers two render pipelines and claims hotkeys 3, 5, 6, 7, 8 and 9 -- 3 and 5 displace the engine's TILT and GBC FX keys, both still reachable on the OPTIONS menu. Presentational only: it changes what a battle is drawn over, never where anybody stands." } diff --git a/mod.card b/mod.card index d7f9644..af77b03 100644 --- a/mod.card +++ b/mod.card @@ -21,6 +21,7 @@ return { "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)", "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "V-GRID on hotkey 5 and V-CURVE on hotkey 7", + "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday", @@ -30,6 +31,9 @@ return { }, known = { "needs shader and depth-canvas support; without them the rows still cycle but the world stays 2D and battles draw plainly", + "water reflections additionally need a READABLE depth canvas; a driver without one draws the flat animated water this mode always drew", + "WATER on FULL ray-marches the depth buffer per water pixel, so a map that is mostly sea costs real fill rate on a weak GPU -- SKY is the same look minus the ray march, and OFF is the flat water", + "a screen-space reflection can only reflect what is in the frame: a tree just off the top edge is not in the water below it, and a ray that runs off the side fades into the sky rather than ending on a line", "a map with no 3x6 clearing falls back to a 1x4 one, and a map with neither draws the plain battle screen", "the arena is where the CAMERA goes -- nobody is moved, so a fight staged across the map is a shot of that ground, not a trip to it", "the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass", diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 95b969d..c6db4ff 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -298,7 +298,7 @@ local order = {} for i, row in ipairs(grouped) do order[row.id] = i end T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"], "the mode's settings follow its pipeline rows") -T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 3, +T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4, "and sit in one unbroken block, not scattered to the end of the list") T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], "with the engine's own later rows still after them") @@ -379,9 +379,15 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 6, "the options hook added a row per setting") -local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4] -local backRow, daytime = hookedRows[5], hookedRows[6] +T.eq(#hookedRows, 7, "the options hook added a row per setting") +local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] +local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] +T.eq(water.label, "WATER", "the water row carries its label") +T.eq(water.value(), "FULL", + "and defaults to FULL -- reflections are the point of having the row") +water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1) +T.eq(water.value(), "SKY", + "stepping down drops the screen-space march and keeps the sky, sun and moon") T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") T.eq(daytime.value(), "SYNC", "and defaults to SYNC -- no value set follows the clock on the wall") @@ -1482,6 +1488,359 @@ T.eq(Sky.paint(320, 0, skyGrad, 40, 7), false, "and a frame with no height paints nothing at all") end +-- ------- reflections on water +-- +-- Water is the one surface in this mode that cannot be drawn with the rest +-- of the world: it is a mirror, and a mirror needs what it reflects to +-- already be down. So it is lifted out of the terrain mesh at BUILD time and +-- drawn as its own pass. That lift is the load-bearing part -- get it wrong +-- and a lake is either a hole in the world or is drawn twice -- and it is +-- pure geometry, so it is driven here against a hand-drawn map. +do +local Water = run.loader.exports.DRAMATIC_SHAPE.lib.require("Water") +local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") +local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher") +local Structures = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures") +local Shapes = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") +local TileShapeHeights = Shapes.heights() + +-- ------- the ladder +-- +-- Three rungs, not a toggle: the sky half of this costs a handful of +-- instructions and the screen-space half costs a ray march, so a machine +-- that wants the sunset on the lake but not the march has somewhere to sit. +T.eq(Water.setting.values[1], "full", + "FULL is the default -- reflections are the point of having the row") +Water.setting:sync("full") -- the row test above stepped it +T.eq(Water.level(), 2, "and it reads back as the full pass") +T.eq(Water.enabled(), true, "which is on") +Water.setting:sync("sky") +T.eq(Water.level(), 1, "SKY keeps the pass but drops the screen-space march") +T.eq(Water.enabled(), true, "and is still a reflection") +Water.setting:sync("off") +T.eq(Water.level(), 0, "OFF is no pass at all") +T.eq(Water.enabled(), false, + "which is what puts the water back in the ordinary scene shader") +Water.setting:sync("full") + +-- ------- the waves are geometry, not shading -- and they step at 15fps +-- +-- The surface is a heightfield of one-world-pixel columns, each standing a +-- WHOLE number of pixels tall -- a voxel like every other voxel in this +-- mode -- and it advances in STEPS rather than sliding: 15 a second, the +-- cadence hand-drawn pixel art is animated at. A surface built out of whole +-- pixels that crawls smoothly between them gives away that the quantisation +-- is only skin deep. +do +local TerrainAtlas = run.loader.exports.DRAMATIC_SHAPE.lib.require("TerrainAtlas") +local realClock = TerrainAtlas._animFrame +local frame = 0 +TerrainAtlas._animFrame = function() return frame end +local function at(f) + frame = f + return Water._waveTime() +end + +local period = 60 / Water.WAVE_FPS +T.eq(period, 4, "15 steps a second is one every four engine frames") +-- inside one step nothing moves; crossing one, it does +T.eq(at(0), at(period - 1), + "every frame inside one wave step gets the same phase -- the surface " + .. "steps rather than crawling between its own pixels") +T.neq(at(0), at(period), "and the step boundary is where it moves") + +local steps = {} +for f = 0, 59 do steps[at(f)] = true end +local n = 0 +for _ in pairs(steps) do n = n + 1 end +T.eq(n, Water.WAVE_FPS, "which is WAVE_FPS distinct positions in a second") +TerrainAtlas._animFrame = realClock + +-- and the step is worth taking: one world pixel of the dominant train per +-- step, DERIVED from that train rather than tuned beside it, so a change of +-- wavelength moves the speed with it. A step the surface cannot resolve is +-- a smooth crawl wearing a quantised clock. +local t = Water.WAVE_TRAINS[1] +local freq = math.sqrt(t[1] * t[1] + t[2] * t[2]) +local travel = (Water.waveRate() / Water.WAVE_FPS) * math.abs(t[3]) / freq +T.check(math.abs(travel - Water.WAVE_PIXELS_PER_STEP) < 1e-9, + "each step advances the dominant crest by exactly WAVE_PIXELS_PER_STEP " + .. "world pixels, so nothing ever lands half-way between two") + +-- the trains reach the shader as source, off the same table the rate above +-- is derived from -- one list, so the two cannot drift +local trains = Water._trainSource() +T.eq(select(2, trains:gsub("h %+= sin", "")), #Water.WAVE_TRAINS, + "every train in the table is summed by the shader") +T.check(trains:find(("%.4f"):format(t[1]), 1, true) ~= nil, + "at the frequency the table states") + +T.check(Water.WAVE_HEIGHT > -TileShapeHeights.water, + "the crests stand taller than the recess TileShape sinks water into -- " + .. "they are RELIEF inside the quad's own footprint, so a bar that reaches " + .. "above the bank is clipped at the water's edge rather than spilling") +end + +-- ------- the moon on the water is the moon in the sky +-- +-- The reflected disc is drawn by a shader and the painted one by rectangles, +-- so nothing but shared DATA can keep them the same moon. The crater list is +-- pasted into the shader source from Sky's own table, which is the seam that +-- makes "they cannot drift" true rather than merely intended. +local craters = Water._craterSource() +local craterLines = select(2, craters:gsub("crater%(", "")) +T.eq(craterLines, #Sky.MOON_CRATERS, + "the shader gets one crater per crater the painted moon has") +for _, c in ipairs(Sky.MOON_CRATERS) do + T.check(craters:find(("%.4f"):format(c[1]), 1, true) ~= nil, + "and each one at the offset the painted moon puts it at") +end +T.check(craters:find(("%.4f"):format(Sky.CRATER_FRAC), 1, true) ~= nil, + "at the same fraction of the disc's radius") + +-- and the disc is the same SIZE, which is the other half of being the same +-- moon: one function answers for the painted radius and for the angle the +-- reflection subtends it at +local px, cells = Sky.discRadius(288, 7, { moon = true }) +T.eq(cells, Sky.DISC_MIN, + "a small frame floors the disc at its minimum radius in cells") +T.eq(px, Sky.DISC_MIN * 7, "reported in canvas pixels on that cell grid") +T.eq(select(2, Sky.discRadius(288, 7, { glowAmt = 0.9 })), Sky.DISC_MIN + 1, + "and the low sun looms, exactly as the painted one does") +T.eq(select(2, Sky.discRadius(288, 7, { glowAmt = 0.9, moon = true })), + Sky.DISC_MIN, "which is a SUNSET exaggeration -- the moon never looms") + +-- the same band ramp, too: one texture, so the sky on the lake cannot be a +-- different palette from the sky over it +local rampImg, rampCount = Sky.ramp() +T.check(rampImg == nil or rampCount == #Sky.bands(), + "the reflection reads the sky off the very ramp the sky is painted from") + +-- ------- the horizon lean: the reflection has to have something IN it at +-- every rung, not just the one whose horizon is in frame +-- +-- The rungs are named for the camera's tilt off VERTICAL, so at 15 the eye +-- meets the water nearly head-on and the mirror ray points 75 degrees UP -- +-- where the sky's bands are darkest, the sun and moon (squashed to about 6 +-- degrees) are nowhere near, and a screen-space ray leaves the frame in two +-- steps. All three are correct and together they are an empty lake. The lean +-- tips the reflection toward the way the camera looks by however far that +-- camera is from having a horizon in frame. +do +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local wasAngle, wasCam = VoxelState.angle, Voxel3D.camera +Voxel3D.camera = nil + +local lean = {} +for _, deg in ipairs({ 15, 35, 50, 75 }) do + VoxelState.angle = math.rad(deg) + Voxel3D.viewProjection(256, 256, 320, 288) + lean[deg] = { Water.lean(Voxel3D.descent), Voxel3D.descent } + -- the orbit looks NORTH, so the flattened view direction is -Z and level + T.check(math.abs(Voxel3D.lookFlat[3] + 1) < 1e-6, + ("the %d rung looks north along the ground plane"):format(deg)) + T.eq(Voxel3D.lookFlat[2], 0, + "flattened onto it, so the lean can never tip a reflection underground") +end + +-- descent is the SINE of how far below horizontal the view runs, and the +-- rungs are the camera's tilt off vertical -- so the two are complements +for _, deg in ipairs({ 15, 35, 50, 75 }) do + T.check(math.abs(lean[deg][2] - math.cos(math.rad(deg))) < 1e-6, + ("the %d rung descends by cos(%d)"):format(deg, deg)) +end + +T.eq(lean[75][1], 0, + "at the rung whose horizon is in frame there is NO lean -- the one place " + .. "the join can be seen (the waterline, where the lake meets the painted " + .. "sky) is still the exact reflection it always was") +T.check(lean[50][1] > 0, "and it comes in as the camera tips over") +T.check(lean[35][1] >= lean[50][1] and lean[15][1] >= lean[35][1], + "growing with every rung further from the horizon") +T.eq(lean[15][1], 1, + "and complete well before the steepest rung, so every rung under the top " + .. "one aims its reflection where the top one's already lands") + +-- a camera looking dead level has nothing to lean +T.eq(Water.lean(0), 0, "a level camera leans not at all") +T.eq(Water.lean(1), 1, "and one looking straight down leans all the way") +T.eq(Water.lean(Water.LEAN_FROM), 0, + "the ramp starts exactly where the top rung sits, so that rung is the one " + .. "the lean never touches") +T.check(math.abs(math.sin(Water.LEAN_ELEV) - Water.LEAN_FROM) < 1e-12, + "and the elevation it aims at IS that rung's own, stated as the same " + .. "number rather than beside it") + +VoxelState.angle, Voxel3D.camera = wasAngle, wasCam +end + +-- ------- the compiled variants +local plain = Water._source(false) +local gridded = Water._source(true) +T.check(plain:find("#define WAVE_STEPS " .. Water.WAVE_STEPS, 1, true) ~= nil, + "the relief march's step count is compiled in too") +-- the whole surface is answered per COLUMN: the ray picks one, and the art, +-- the shading, the reflection and the dither all read that one rather than +-- the fragment's own place on the flat quad. A smoothly-shaded reflection +-- over hard-edged 8-bit water is two pictures stacked. +T.check(plain:find("floor(waveRaw(q) * waveHeight + 0.5)", 1, true) ~= nil, + "column heights are floored to WHOLE world pixels -- a fractional step is " + .. "a smooth wave with extra arithmetic, not a bar") +-- and the normal is read off the SMOOTH field underneath, which is the +-- difference between a moon on the water and confetti: integer heights give +-- integer differences, so a normal built from them can only point in about +-- five directions and a two-degree disc falls between them +T.check(plain:find("float h = waveRaw(q);", 1, true) ~= nil, + "but the reflection's normal comes off the smooth surface the columns are " + .. "a quantisation of, so the ray sweeps instead of jumping") +T.check(plain:find("waveNormal(vec2 q, float tilt)", 1, true) ~= nil + and plain:find("waveNormal(col,", 1, true) ~= nil, + "still one answer per column, so the surface stays pixel-quantised in " + .. "space while the value it reflects with is continuous") +T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil, + "and the visible column is found by walking the view ray through the " + .. "slab, which is what makes a tall bar hide the short ones behind it") +-- the march's reach grows as one over the ray's descent, so a grazing camera +-- asks for hundreds of world pixels of it from a fixed number of samples -- +-- which stepped over whole crests and smeared the surface into streaks +T.check(plain:find("#define WAVE_STRIDE", 1, true) ~= nil + and plain:find("float maxSpan = float(WAVE_STEPS) * WAVE_STRIDE;", + 1, true) ~= nil, + "and its span is capped to a stride a sample can actually resolve") +T.check(Water.WAVE_STRIDE <= 1, + "which is at most ONE world pixel, because a column is one world pixel " + .. "wide -- a longer stride steps over columns, and which ones it misses " + .. "changes fragment to fragment, which is the peppery noise") +-- and the art is read off the COLUMN rather than by offsetting the +-- fragment's own uv by however far the march happened to travel: one world +-- pixel is one texel, so a column's texel follows from where it stands and +-- two fragments landing on the same column cannot disagree about it +T.check(plain:find("org + (mod(col, 8.0) + 0.5) * texel", 1, true) ~= nil, + "a column's art follows from its own world position, so it cannot swim " + .. "with the camera or speckle between neighbouring fragments") +T.check(plain:find("waveUV(tc, col)", 1, true) ~= nil, + "and the column is what is handed to it") + +-- the wireframe is ruled on the COLUMNS, not on the flat sheet they stand on +T.check(gridded:find("columnSeam(hit, vBent, axis)", 1, true) ~= nil, + "with V-GRID on, the seams outline the column the ray landed on -- every " + .. "voxel of water its own block -- rather than ruling a grid across the " + .. "flat quad underneath and ignoring the bars entirely") +T.check(gridded:find("vec3 w = fwidth(base);", 1, true) ~= nil, + "measured off the smooth plane, because the hit jumps a whole column " + .. "between neighbouring fragments and its own derivative is a step") +T.check(plain:find("march(surf, r)", 1, true) ~= nil, + "the reflection marches from that column, not from the raw fragment") +T.check(plain:find("mod(col.x + col.y, 2.0)", 1, true) ~= nil, + "and the dither's checkerboard is cut from the columns too, so a camera " + .. "pan slides the world through nothing") +T.check(plain:find("#define RAY_STEPS " .. Water.RAY_STEPS, 1, true) ~= nil, + "the march's step count is compiled in -- GLSL wants a constant bound") +T.check(plain:find("VOXEL_GRID", 1, true) ~= nil, + "the wireframe is guarded in the source") +T.check(plain:find("#define VOXEL_GRID", 1, true) == nil, + "and off in the plain variant") +T.check(gridded:find("#define VOXEL_GRID", 1, true) ~= nil, + "so a frame with the seams on gets its own compilation, like the scene " + .. "shader -- a driver that refuses derivatives loses the seams and not " + .. "the water") +T.check(plain:find("//@CRATERS", 1, true) == nil, + "and the crater placeholder is gone by the time a driver sees the source") + +-- ------- the lift itself +-- +-- A pond in a field: four water cells recessed below flat ground. The +-- shipped maps are the real thing but a picture states the invariant +-- exactly, and this one needs no atlas, no GPU and no fixture. +local WATER_TILE, GRASS_TILE = 20, 3 +local pond = { + { GRASS_TILE, GRASS_TILE, GRASS_TILE, GRASS_TILE }, + { GRASS_TILE, WATER_TILE, WATER_TILE, GRASS_TILE }, + { GRASS_TILE, WATER_TILE, WATER_TILE, GRASS_TILE }, + { GRASS_TILE, GRASS_TILE, GRASS_TILE, GRASS_TILE }, +} +local pondMap = { + id = "DS_TEST_POND", + tileset = { id = "DS_TEST_SET", image = "gfx/tilesets/ds_test.png", + tilesPerRow = 16, imageWidth = 128, imageHeight = 48, + blocks = {}, grassTile = -1 }, + def = { width = 1, height = 1, tileset = "DS_TEST_SET" }, + walkable = { [GRASS_TILE] = true }, + waterTiles = { [WATER_TILE] = true }, + doorTiles = {}, + tileAt = function(_, tx, ty) + return pond[(ty % 4) + 1][(tx % 4) + 1] + end, + cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end, + isWaterCell = function(self, cx, cy) + return self:cellTile(cx, cy) == WATER_TILE + end, + isWalkableCell = function(self, cx, cy) + return self:cellTile(cx, cy) == GRASS_TILE + end, + inBounds = function(_, cx, cy) + return cx >= 0 and cy >= 0 and cx < 2 and cy < 2 + end, +} + +-- body-only, so the border ring is out of it and the count is the picture +local _, _, whole = ChunkMesher.geometry(pondMap, true, nil) +Structures.invalidate(pondMap.id) +local landVerts, _, land, waterVerts, _, wet = + ChunkMesher.geometry(pondMap, true, nil, true) + +T.check(wet > 0, "the pond's surface comes out as water quads") +T.eq(land + wet, whole, + "and the split is a MOVE, not a copy: every quad the one-sink build " + .. "emitted is in exactly one of the two") +T.eq(#waterVerts, wet * 4, "the water sink holds whole quads") + +-- every water vertex sits on the recessed plane, which is what says the +-- surface and only the surface was lifted -- the shoreline faces that drop +-- from the ground down to it belong to the GROUND that exposes them, and +-- must stay in the terrain mesh or a lake is ringed by a slit into the sky +local heights = Shapes.heights() +for _, v in ipairs(waterVerts) do + T.check(v[2] == heights.water, + "a water vertex stands on the water plane, not on a shoreline face") +end +local shore = 0 +for _, v in ipairs(landVerts) do + if v[2] < 0 then shore = shore + 1 end +end +T.check(shore > 0, + "and the shoreline bands below ground level stayed with the terrain") + +-- a map with no water at all splits into everything and nothing, rather +-- than into an empty terrain mesh +Structures.invalidate(pondMap.id) +local dry = {} +for y = 1, 4 do + dry[y] = {} + for x = 1, 4 do dry[y][x] = GRASS_TILE end +end +pond = dry +local _, _, dryLand, _, _, dryWet = ChunkMesher.geometry(pondMap, true, nil, + true) +T.check(dryLand > 0, "a map with no water still meshes its ground") +T.eq(dryWet, 0, "and hands back no water surface at all") + +-- ------- and the pairing +-- +-- The terrain mesh and the water lifted out of it are ONE answer: they came +-- from the same build, so a caller must never end up holding a full mesh +-- beside a body build's water (the ring's ponds twice, the body's as holes). +-- pair() is the only way to ask, which is what makes that unpairable. +local mesh, wetMesh = ChunkMesher.pair({ id = "DS_NOT_A_MAP" }, false) +T.eq(mesh, nil, "an unbuilt map pairs to nothing") +T.eq(wetMesh, nil, "on both halves, so a caller cannot half-draw one") + +Structures.invalidate(pondMap.id) +ChunkMesher.invalidate(pondMap.id) +Shapes.invalidate() +end + Voxel.angle = 0 -- ------- overworld battles: where the fight is staged