From 9a9441899ac98d7804efd4516fde4fa4649b1b5d Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 31 Jul 2026 22:54:16 -0400 Subject: [PATCH 1/6] first pass at water --- CHANGELOG.md | 154 ++++++++++++++ README.md | 17 ++ lib/BattleScene.lua | 47 ++++- lib/ChunkMesher.lua | 96 +++++++-- lib/Sky.lua | 74 ++++++- lib/Voxel3D.lua | 243 ++++++++++++++++++++-- lib/VoxelScene.lua | 204 +++++++++++++++---- main.lua | 24 ++- manifest.json | 4 +- mod.card | 4 + tests/dramatic_shape_test.lua | 367 +++++++++++++++++++++++++++++++++- 11 files changed, 1139 insertions(+), 95 deletions(-) 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 From 8f38aeb36e9bbd7da585b01bd1af08123be45866 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 31 Jul 2026 23:05:54 -0400 Subject: [PATCH 2/6] water updates --- lib/BattleScene.lua | 32 + lib/ShadowMap.lua | 32 +- lib/VoxelScene.lua | 45 +- lib/Water.lua | 1201 +++++++++++++++++++++++++++++++++ tests/dramatic_shape_test.lua | 42 +- tests/mon_shots.lua | 71 ++ tests/pic_dump.lua | 63 ++ tests/water_reflect_probe.lua | 127 ++++ 8 files changed, 1589 insertions(+), 24 deletions(-) create mode 100644 lib/Water.lua create mode 100644 tests/mon_shots.lua create mode 100644 tests/pic_dump.lua create mode 100644 tests/water_reflect_probe.lua diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index d483ed5..2fc3cce 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -259,10 +259,15 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, -- the mons themselves, as the same cards the camera will see. Their alpha -- is the silhouette, so what lands on the ground is the shape of the -- Pokemon rather than a blob standing in for one. + -- marked as the CAST, so a fight staged at the water's edge does not lay a + -- cut-out of a Pokemon across the lake (see ShadowMap.sprites); the arena's + -- own floor still takes them, which is the shadow that matters here + ShadowMap.sprites(true) for _, card in ipairs(cards or {}) do ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model)) end + ShadowMap.sprites(false) ShadowMap.finish(sig) end @@ -309,9 +314,36 @@ end BattleScene.FLASH_COLOR = { 1, 1, 1 } BattleScene.FLASH_STRENGTH = 0.5 +-- ------- the tile clock, while the overworld is not the one drawing +-- +-- Water and flowers animate off TileRenderer's 60Hz counter, and the ENGINE +-- only advances it from OverworldState:drawWorld -- which runs under dialogs +-- and menus, but not under a battle, because a battle draws instead of the +-- overworld rather than over it. So for the length of a staged fight the +-- counter stood still: the water tiles stopped rotating their pixels and the +-- wave field, which is driven off the same number so the two cannot drift +-- (see Water), stopped with them. A lake in the background of a battle was a +-- photograph. +-- +-- Ticked HERE rather than from the mod's update hook, because here is the +-- one place that means "a staged battle is drawing this frame, and the +-- overworld is not". From the update hook the condition would have to be +-- guessed at, and a frame where both ran would double the rate. +local function tickTiles() + local Game = require("src.core.Game") + local ow = Game and Game.overworld + local top = Game and Game.stack and Game.stack:top() + -- during the wipe INTO a battle the overworld can still be the one + -- drawing, and it is ticking the clock itself; two ticks in a frame would + -- run the water at double speed + if top and ow and top == ow then return end + pcall(require("src.render.TileRenderer").tick) +end + function BattleScene.render(state, arena, textures, token) if not (state and state.map and arena) then return nil end if not Voxel3D.available() then return nil end + tickTiles() -- the floor the fight is staged on: normally the player's own, sometimes -- another floor of the same cave or building (see BattleArena) diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index 9798462..18ceffd 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -130,17 +130,23 @@ local SHADER = [[ } #endif #ifdef PIXEL + uniform float sprite; // 1 while the CAST is being drawn; see ShadowMap.sprites vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { // the same alpha discard the main pass uses: a sprite card casts its // silhouette, not its 16x16 bounding box if (Texel(tex, tc).a < 0.5) discard; - // pack into two channels: the high byte in red, the low in green + // pack into two channels: the high byte in red, the low in green. + // Blue says WHAT cast this, which costs a channel that was zero anyway + // and lets a surface decline one kind of caster -- water does, for the + // people (see Water's sunLit). float d = clamp(vDepth, 0.0, 1.0) * 255.0; - return vec4(floor(d) / 255.0, fract(d), 0.0, 1.0); + return vec4(floor(d) / 255.0, fract(d), sprite, 1.0); } #endif ]] +ShadowMap._source = function() return SHADER end -- named for the suite + local shader = nil -- nil = untried, false = unavailable local canvas = nil -- nil = untried, false = unavailable local canvasRes = 0 -- the edge `canvas` was made at @@ -440,6 +446,9 @@ function ShadowMap.begin(cx, cy, vw, vh) love.graphics.setShader(sh) love.graphics.setColor(1, 1, 1, 1) pcall(sh.send, sh, "lightVP", "row", ShadowMap.clipVP) + -- the world until a cast pass says otherwise, reset per pass so one that + -- forgot to put it back cannot leak into the next map's terrain + pcall(sh.send, sh, "sprite", 0) drawing = true ready = false return true @@ -448,6 +457,25 @@ end -- Draw one caster. Same signature as Voxel3D.draw minus the camera-ward -- pull, which is a trick for the VIEW's depth buffer and would drag a -- shadow off whatever throws it. +-- Whether what is drawn next is one of the CAST -- a walker, an authored +-- figure, a battle's Pokemon -- rather than part of the world. false for the +-- length of such a pass, true to put it back. +-- +-- The map records it per texel (the shader's blue channel) so a surface can +-- decline that kind of caster, and exactly one does: water. A character +-- standing at a lake's edge threw a hard cut-out of its own sprite across +-- the surface, which on something showing the sky and the shoreline reads as +-- a sticker rather than as a shadow in the water. Everything else -- ground, +-- roofs, ledges, the characters themselves -- still takes them. +-- +-- Sent rather than branched, so a caller that forgets to put it back only +-- mislabels casters rather than losing them; begin() resets it per pass. +function ShadowMap.sprites(on) + if not drawing then return end + local sh = getShader() + if sh then pcall(sh.send, sh, "sprite", on and 1 or 0) end +end + function ShadowMap.draw(mesh, texture, model) if not (drawing and mesh) then return end local sh = getShader() diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 96bd4b8..b9244fb 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -699,6 +699,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, 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) @@ -720,6 +725,7 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, mirror))) end end + ShadowMap.sprites(false) ShadowMap.finish(sig) end @@ -779,8 +785,30 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) Mat4.translate(nb.ox, 0, nb.oy)) end + -- Without a shadow map (headless, or a driver that could not make the + -- canvas) the old flat decals stand in: ground-only, characters only, + -- but better than a world with nothing under anybody. They go down + -- first, as decals the characters then stand over -- depth-tested + -- against the terrain just drawn (a shadow behind a building stays + -- hidden) but never depth-writing, so the grass pass at the end of the + -- frame still wins its feet-overdraw fights. + if not Voxel3D.shadowsActive() then + Voxel3D.beginShadows() + for _, p in ipairs(posed) do + drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, + p.lift) + end + Voxel3D.endShadows() + end + -- and the water over the top of it, reflecting everything just drawn plus - -- the sky the frame opened with (see drawWater) + -- 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 } @@ -800,21 +828,6 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) 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 - -- first, as decals the characters then stand over -- depth-tested - -- against the terrain just drawn (a shadow behind a building stays - -- hidden) but never depth-writing, so the grass pass at the end of the - -- frame still wins its feet-overdraw fights. - if not Voxel3D.shadowsActive() then - Voxel3D.beginShadows() - for _, p in ipairs(posed) do - drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, - p.lift) - end - Voxel3D.endShadows() - end -- 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 diff --git a/lib/Water.lua b/lib/Water.lua new file mode 100644 index 0000000..96f5368 --- /dev/null +++ b/lib/Water.lua @@ -0,0 +1,1201 @@ +-- Voxel world mode: water, and what it reflects. +-- +-- Every other surface in this mode is opaque and is drawn once, inside the +-- terrain mesh, by the scene shader. Water is neither: it is a MIRROR, and +-- a mirror cannot be drawn until the thing it reflects already exists. So +-- the water surface is lifted out of the terrain mesh at build time +-- (ChunkMesher's water sink) and drawn as its own pass, after the world and +-- before the characters, by the shader below. +-- +-- WHAT IT REFLECTS, in the order the shader resolves them: +-- +-- the sky the reflected direction is put through the SAME 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, dither and display-mode transform +-- the painted sky uses. So the sky in the lake is the sky +-- over it: blue at noon, gold at dusk, navy under the moon, +-- and it meets the painted sky at the waterline with no seam. +-- +-- the sun, hung by ANGLE rather than by screen position, because a +-- the moon reflected body is usually off the top of the frame and a +-- projected point is meaningless out there. The angular +-- radius is Sky.discRadius converted through the camera's own +-- field of view, so the disc on the water is exactly as big +-- as the disc in the sky -- craters, dithered rim, the +-- sunset's loom and all. 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. +-- +-- the world 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 without a seam between them. +-- +-- the cast the walkers, the NPCs, the authored figures and a staged +-- battle's two Pokemon. Awkward, and settled by drawing them +-- twice: Gen 1 draws people OVER the world and water is +-- world, so a surfing player has to composite after the +-- water, and a reflection can only hold what came before it. +-- So they are painted into the reflection copy alone +-- (Voxel3D.beginWater), in the picture the water reflects and +-- not yet in the picture it is drawn into. +-- +-- WHAT IT CANNOT REFLECT is what no screen-space reflection can: anything +-- that is not 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 of the screen fades into the +-- sky rather than ending on a line. +-- +-- THE SURFACE ITSELF is not flat. It is a heightfield of one-world-pixel +-- columns, each standing a whole number of pixels tall and rising and +-- falling as waves, walked by the view ray in the pixel shader -- so the +-- bars occlude each other and show their sides without a single extra +-- vertex. See WAVE_HEIGHT and relief(). +-- +-- THE PASS ITSELF, and why it is shaped this way. The scene canvas carries a +-- READABLE depth canvas (Voxel3D), and a texture cannot be sampled while it +-- is bound as a render target -- so for the length of this pass the depth +-- buffer is DETACHED and the shader does the depth test itself, comparing +-- its own fragment depth against the texture it just stopped writing to. +-- That is the same test the hardware would have run, so a tree in front of a +-- pond still hides it; what it costs is depth WRITES, which water has no use +-- for anyway (it is flat, it never overlaps itself, and everything drawn +-- after it stands on top of it by construction). +-- +-- Falls back all the way down. No readable depth canvas, a driver that will +-- not compile this, or the row set to OFF and the water mesh is simply drawn +-- by the ordinary scene shader -- flat animated water, exactly what the mode +-- drew before any of this existed. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local Sky = V.require("Sky") +local DayNight = V.require("DayNight") +local ShadowMap = V.require("ShadowMap") +local Mat4 = V.require("Mat4") + +local Water = {} + +-- ------- the row +-- +-- Three rungs rather than a toggle, because the two halves of this cost +-- very different things. SKY is a handful of instructions per water pixel +-- and no extra buffers read; FULL adds the screen-space march, which is the +-- part that samples a depth texture twenty-odd times. A phone that wants the +-- sunset on the lake but not the ray march has somewhere to stand. +Water.KEY = "water" +Water.LABEL = "WATER" + +Water.setting = ModSetting.new(Water.KEY, Water.LABEL, + { "full", "sky", "off" }, + { "FULL", "SKY", "OFF" }) + +function Water.level() + local v = Water.setting:get() + if v == "off" then return 0 end + if v == "sky" then return 1 end + return 2 +end + +-- Whether the reflective pass should run at all (either rung above OFF). +function Water.enabled() + return Water.level() > 0 +end + +-- ------- the look, in constants +-- +-- FRESNEL. Water reflects almost nothing looked straight down at and almost +-- everything looked along, which is Schlick's curve -- and taken literally +-- it hands the top rung a mirror and the other four nothing at all. This +-- mode's rungs are named for the camera's tilt off VERTICAL, so 15 is a +-- near-overhead camera meeting the water at 15 degrees off its normal: +-- honest Schlick gives that about 2%, and even a generous floor of 0.14 was +-- invisible. +-- +-- So the floor is lifted a long way above water's true 0.04 and the exponent +-- softened from 5 to 2: the SHAPE is still the honest one -- a low camera +-- still gets much more of it than a high one -- but the bottom of the curve +-- is a pond rather than a painted tile. +Water.FRESNEL_FLOOR = 0.34 +Water.FRESNEL_CEIL = 0.92 +Water.FRESNEL_POWER = 2.0 + +-- THE HORIZON LEAN, which is the other half of why the steeper rungs showed +-- nothing -- and the bigger half. +-- +-- A reflection off flat water points as far ABOVE the horizon as the eye is +-- above the water. At the top rung that is 15 degrees: the reflected ray +-- grazes the sky's pale end, sweeps the sun's own path and travels far +-- enough across the screen for the march to find the shoreline. At the 15 +-- rung it is 75 degrees -- straight up. Up there the sky's bands are at +-- their DARKEST (deep blue over blue water, which is no picture at all), 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 of those are correct, and together they are a lake with +-- nothing in it. +-- +-- So the reflected direction LEANS toward the way this camera is looking, by +-- however far the camera is from having a horizon in frame. That is a +-- deliberate stylisation and it is worth being exact about what it costs and +-- what it does not: +-- +-- at the rung where the horizon IS in frame the lean is ZERO, so the one +-- place the join can actually be seen -- the waterline, where the lake +-- meets the painted sky -- is still the exact reflection it was. +-- +-- at the rungs where the horizon is above the top edge there is no join to +-- break, and what the lean buys is the whole of the effect: the pale bands, +-- the sunset, the moon's path, and a screen-space ray that travels ACROSS +-- the diorama instead of straight out of it. +-- +-- It leans toward an ELEVATION rather than by a weight, and that matters. +-- Mixing the ray a fixed fraction of the way toward horizontal sounds like +-- the same thing and is not: the ray it starts from is different at every +-- rung, so a fixed fraction lands them all somewhere different, and the +-- middle rungs came out worst of all -- further from the sun than the +-- steepest one. Aimed at an elevation, every rung below the top one puts its +-- reflection where the TOP rung puts its own, which is the one place the +-- effect is known to work. +-- +-- Measured off Voxel3D.descent -- the sine of how far below horizontal the +-- view runs -- so it answers for the battle's placed camera too, which has +-- no rung to be asked about. +Water.LEAN_FROM = 0.30 -- descent where the lean starts: the top rung's +Water.LEAN_FULL = 0.55 -- and where it is complete +-- the elevation it aims at: the one the top rung's own reflection sits at, +-- stated as that same descent so the two cannot drift apart +Water.LEAN_ELEV = math.asin(Water.LEAN_FROM) + +function Water.lean(descent) + local span = Water.LEAN_FULL - Water.LEAN_FROM + local t = ((descent or 0) - Water.LEAN_FROM) / span + if t <= 0 then return 0 end + return t < 1 and t or 1 +end + +-- ------- the waves +-- +-- Not a normal map. The surface is a HEIGHTFIELD of one-world-pixel columns +-- -- the same unit every other voxel in this mode is built from, and exactly +-- one texel of the water tile (a tile is 8 texels across 8 world pixels) -- +-- and every column stands at a whole number of pixels. So the water is a +-- field of little square bars rising and falling on their own, which is what +-- water made of pixels should look like from a camera that can see it in 3D. +-- +-- It is drawn without any extra geometry. The mesh is still one flat quad +-- per tile; the columns are found by walking the view ray down through the +-- slab in the pixel shader (relief mapping) and taking the first one it +-- meets. That is what makes them read as SOLID rather than as shading: a +-- tall bar hides the shorter ones behind it, you see the SIDE of the ones +-- facing you, and the whole field parallaxes against the plane as the camera +-- moves. The side faces wear the mesh's own direction shading +-- (Voxel3D.FACE_SHADE, sent in rather than restated) so a wave crest is lit +-- like every other voxel in the world. +-- +-- HEIGHT is in world pixels: the tallest a column may stand above the plane +-- the quad is drawn on, and so both the amplitude and the number of rungs a +-- crest can climb through (five gives six). +-- +-- It is well past the 2px recess TileShape sinks water into, which is a +-- deliberate look rather than an oversight: the crests are RELIEF, drawn +-- inside the water quad's own screen footprint, so a bar that reaches above +-- the shoreline cannot actually spill over the bank -- it is clipped at the +-- water's edge like everything else this pass draws. What it buys is a +-- surface with real swell in it instead of a two-rung terrace. +Water.WAVE_HEIGHT = 5 + +-- ------- the trains +-- +-- Each is { fx, fz, speed, weight }. The vector is the train's DIRECTION and +-- its frequency in one -- the crest runs across it, and two pi over its +-- length is the wavelength in world pixels -- and `speed` is what walks it. +-- +-- The first one dominates, and that weighting is the whole difference +-- between water and soup: a wave has a direction, and its crest is a line +-- running across it for as far as the surface goes. Three trains of equal +-- weight cancel and reinforce in patches instead, and the field comes out as +-- round islands of raised pixels with no travel to them. +-- +-- Long, too: the dominant wavelength is about forty world pixels, five +-- tiles, so a crest is a run of hundreds of columns at one height with a +-- step down either side. Pitched anywhere near a pixel they stop being waves +-- and become static -- every column its own island. +-- +-- Read into the shader source rather than sent as uniforms, so the rate +-- below can be derived from the same numbers the field is built out of. +Water.WAVE_TRAINS = { + { 0.150, 0.062, 1.60, 0.60 }, + { 0.058, 0.132, -1.05, 0.29 }, + { -0.041, 0.033, 0.55, 0.11 }, +} + +-- ------- and the beat they move on +-- +-- The surface does not slide, it advances in STEPS -- 15 a second, off the +-- engine's own frame counter, which is the cadence hand-drawn pixel art is +-- animated at and the reason this reads as art rather than as a simulation +-- someone forgot to stylise. A surface built out of whole pixels that crawls +-- between them smoothly gives away that the quantisation is only skin deep. +Water.WAVE_FPS = 15 + +-- How far the dominant train advances each of those steps, in WORLD PIXELS. +-- One is the honest choice for a stepped surface: the whole field shifts by +-- exactly one pixel per frame, so nothing ever lands half-way between two. +-- The rate below is derived from it rather than tuned beside it, so changing +-- a wavelength moves the speed with it instead of quietly desynchronising. +Water.WAVE_PIXELS_PER_STEP = 1 + +-- Radians of wave phase per second. A train travels `speed / frequency` +-- world pixels per radian of phase, so the phase that moves the dominant one +-- a pixel is its frequency over its speed -- times the step rate. +function Water.waveRate() + local t = Water.WAVE_TRAINS[1] + local freq = math.sqrt(t[1] * t[1] + t[2] * t[2]) + local speed = math.abs(t[3]) + if not (freq > 0 and speed > 0) then return 0 end + return Water.WAVE_PIXELS_PER_STEP * (freq / speed) * Water.WAVE_FPS +end +-- Relief samples down through the slab. With the stride pinned at one world +-- pixel (see WAVE_STRIDE) this is also how FAR the march can see: sixteen +-- samples, sixteen pixels of parallax, which covers the slab at every rung +-- but the very lowest and leaves the rest to fade out honestly. +-- +-- The pass early-outs entirely (see relief) whenever the camera is steep +-- enough that the whole slab projects to under a pixel across, which is most +-- of the ladder -- so the cost of this only lands where it buys something. +Water.WAVE_STEPS = 16 + +-- The furthest one relief sample may travel ACROSS the surface, in world +-- pixels -- which is what bounds how far the march runs in total. +-- +-- The march's reach is the slab's depth over the ray's descent, so it grows +-- without limit as the camera flattens: at the top rung, fragments near the +-- horizon look along the water at a few degrees and the reach runs to +-- hundreds of world pixels. Spread over a fixed number of samples that steps +-- clean over whole crests, and the surface comes apart into streaks running +-- away from the eye. Capping the span is what keeps a sample worth taking; +-- what it costs is parallax on the far water, where the columns are under a +-- pixel across and there was nothing left to see anyway. +-- +-- This is the FLOOR on it. The stride the march actually takes is a SCREEN +-- pixel's worth of surface, which is the only rate that makes sense: +-- +-- up close, a screen pixel is a fraction of a world pixel, so the stride +-- sits on this floor of one world pixel and the march visits every column +-- on its path. It has to: a column is one world pixel wide, a longer +-- stride steps over columns, and which ones it misses changes from +-- fragment to fragment -- neighbouring pixels landing on different columns +-- at different heights wearing different faces. That is peppery noise. +-- +-- far away, a screen pixel already spans several world pixels, so a stride +-- that matches it skips columns the screen could not have resolved anyway. +-- Holding it at one world pixel out there does not buy detail, it just +-- runs out of samples -- and a march that runs out stops part-way down the +-- slab and reports the surface as flat, which is why the lowest rung lost +-- its waves entirely across the whole middle distance. +Water.WAVE_STRIDE = 1 + +-- How far the wave field's own gradient tilts the REFLECTION. A multiplier +-- on the SMOOTH surface's slope, not on the stepped one -- see waveNormal +-- for why that distinction is the whole difference between a moon on the +-- water and confetti. The field's gradient peaks around 0.06 per world +-- pixel, so this lands the steepest faces about twelve degrees off vertical: +-- enough to sweep a low sun or moon into a broken glitter path down the +-- lake, and not so much that the sky's own bands come apart. +Water.WAVE_SLOPE = 3.5 +-- and how far the horizon lean is allowed to open that up, since it squashes +-- the same tilt on its way past (see LEAN_FROM) +Water.WAVE_SLOPE_LEAN = 1.5 + +-- THE MARCH. Steps are in world pixels and lengthen as they go: near the +-- surface the reflection needs precision (a shoreline is a few pixels), far +-- from it reach matters more than accuracy, and a geometric ramp gets both +-- out of one loop. RAY_STEPS is compiled in -- GLSL wants a constant bound. +Water.RAY_STEPS = 24 +Water.RAY_REFINE = 5 -- halvings once a crossing is found +Water.RAY_STEP = 3.0 -- world pixels in the first step +-- and the ratio each step after it. 3 x (1.18^24 - 1) / 0.18 is about 930 +-- world pixels of reach -- three view-heights, past which a reflection is +-- faded out anyway (see the tail fade in march) and the sky is the honest +-- answer: distant water reflects haze, which is what the bands already are. +Water.RAY_GROW = 1.18 +-- How far behind the depth buffer a crossing may land and still count, as a +-- multiple of the depth the step itself covered. A ray that dives far past +-- what it crossed went BEHIND a thin thing rather than hitting it -- the +-- classic screen-space smear, where a tree between the camera and the pond +-- paints itself across the water -- and this is the test that drops it. +Water.RAY_THICK = 1.6 +Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the frame + +-- ------- the shader +-- +-- The scene shader's own vertex path, plus the world position the geometry +-- was actually DRAWN at -- after the world curve, because that is the space +-- the depth buffer holds and therefore the space the march has to walk in. +-- (The curve only ever moves Y, so a fragment's world XZ is the same on both +-- sides of it and the ripple can be measured off this one too.) +local SHADER_SRC = [[ +varying float vShade; +varying vec3 vSun; +varying vec3 vBent; // world position, as drawn + +#ifdef VERTEX +uniform mat4 vp; +uniform mat4 model; +uniform mat4 sunVP; +uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off +attribute float VertexShade; + +vec4 position(mat4 transform_projection, vec4 vertex_position) { + vShade = VertexShade; + vec4 w = model * vertex_position; + vSun = (sunVP * w).xyz; + if (curve.z > 0.0) { + vec2 cd = w.xz - curve.xy; + w.y -= dot(cd, cd) * curve.z; + } + vBent = w.xyz; + return vp * w; +} +#endif + +#ifdef PIXEL +uniform mat4 vp; +uniform vec3 eye; +uniform vec2 screen; // the canvas, in pixels +uniform float cell; // one diorama pixel, in canvas pixels +uniform float pxAngle; // radians of view one screen pixel subtends + +// the sun's own pass, exactly as the scene shader reads it +uniform Image sunMap; +uniform float sunDark; +uniform float sunBias; +uniform vec2 sunTexel; +uniform vec3 dayTint; + +// the frame as it stood before the water went down, and its depth +uniform Image reflectTex; +uniform Image depthTex; + +uniform float rays; // 0 = sky only, 1 = march the screen too +uniform vec3 lookFlat; // the way the horizon lies from this camera +uniform float lean; // and how far the reflection tilts toward it +uniform float leanElev; // the elevation it aims at, in radians +uniform float waveHeight; // the tallest column, in whole world pixels +uniform float waveSlope; // how far a column's neighbours tilt its normal +uniform float waveSlopeLean; // and how far the horizon lean may open that up +uniform float waveT; +uniform vec4 faceShade; // the mesh's own direction shading: E, W, S, N +uniform vec2 atlasSize; // the tileset atlas, in texels +uniform float fresnelFloor; +uniform float fresnelCeil; +uniform float fresnelPower; +uniform float rayStep; +uniform float rayGrow; +uniform float rayThick; +uniform float edgeFade; + +// the sky, as Sky paints it +uniform Image skyRamp; +uniform float skyCount; +uniform float skyEdge; // the sky's bottom, in canvas pixels +uniform float skyStart; // where the checker begins inside a band +uniform float skyOn; // 0 indoors: there is no sky to reflect + +// and what hangs in it +uniform vec3 bodyDir; +uniform float bodyOn; +uniform float bodyMoon; +uniform float bodyAng; // the disc's angular radius, in radians +uniform vec3 bodyCore; +uniform vec3 bodyMain; +uniform vec3 bodyDark; +uniform float glowAmt; +uniform float glowReach; // in radians, like bodyAng +uniform vec3 glowColor; + +#ifdef VOXEL_GRID +uniform float gridDark; +uniform float gridWidth; +#endif + +// ------- the sun's pass (the scene shader's, verbatim) + +// One shadow tap: 1 where the sun reaches, 0 where something blocks it -- +// EXCEPT that water declines one kind of blocker. +// +// The sun pass marks the cast in the blue channel (ShadowMap.sprites), and +// water ignores those. A character standing at a lake's edge laid a hard +// cut-out of its own sprite across the surface, and on something that is +// already showing the sky, the shoreline and the trees behind it, a +// silhouette of somebody reads as a sticker on the water rather than as a +// shadow in it. Everything the WORLD casts -- trees, buildings, cliffs, +// ledges -- still shades it, which is the half that was worth having. +float sunLit(vec2 uv, float z) { + vec4 c = Texel(sunMap, uv); + return max(step(z, c.r + c.g * (1.0 / 255.0)), c.b); +} + +float sunlight(vec3 p) { + if (sunDark <= 0.0) return 1.0; + if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0 || p.z > 1.0) { + return 1.0; + } + vec2 e = min(p.xy, 1.0 - p.xy); + float edge = smoothstep(0.0, 0.06, min(e.x, e.y)); + if (edge <= 0.0) return 1.0; + float z = p.z - sunBias; + float lit = sunLit(p.xy + sunTexel * vec2(-0.5, -0.5), z) + + sunLit(p.xy + sunTexel * vec2( 0.5, -0.5), z) + + sunLit(p.xy + sunTexel * vec2(-0.5, 0.5), z) + + sunLit(p.xy + sunTexel * vec2( 0.5, 0.5), z); + return 1.0 - sunDark * edge * (1.0 - lit * 0.25); +} + +#ifdef VOXEL_GRID +// The wireframe, ruled on the COLUMNS rather than on the flat sheet they +// stand on. +// +// The scene shader reads a mesh's own model space, and for water that is the +// base plane -- so it would draw a grid across a flat sheet and ignore the +// bars entirely, which is the one thing that would give away that they are +// bars. What has to be outlined is what is actually SEEN: the column the ray +// landed on, at the height it landed at, so every voxel of water reads as +// its own block with its own edges. +// +// `p` is that hit; `base` is the smooth plane under it, and the derivative +// comes from THERE. `p` jumps a whole column between neighbouring fragments, +// so fwidth() of it reports a step rather than a scale and every column edge +// would blow out into a band. The plane underneath is smooth, and is the +// same scale in x and z that the columns are built on. +// +// `axis` is the direction the face does not vary along -- the top's own y, +// or a side's x or z. Its distance to the nearest plane is a constant zero, +// and taken at face value it floods the whole face solid; pushed out of +// reach it simply drops out, exactly as the scene shader's own seam handles +// the axis a face's normal points along. +float columnSeam(vec3 p, vec3 base, float axis) { + vec3 w = fwidth(base); + float wide = max(w.x, w.z); + // the plane has no vertical extent of its own to measure, so y borrows + // the horizontal scale -- it sets a line's THICKNESS and nothing else + w.y = wide; + vec3 d = abs(fract(p + 0.5) - 0.5); + vec3 px = d / max(w, vec3(1e-6)); + if (axis < 0.5) { px.y += 1e6; } + else if (axis < 1.5) { px.x += 1e6; } + else { px.z += 1e6; } + float near = min(min(px.x, px.y), px.z); + // Fade out where a column is too small on screen to hold a line at all, + // or the far water turns into a flat wash of seams rather than a grid. + // + // It holds on further than the scene shader's own does. That one is ruling + // seams across whole 16px walls and roofs; these are one world pixel + // apart, so the fade starts biting while the water is still perfectly + // readable -- and at the lowest rung, where the middle distance is most of + // the frame, it took the grid off nearly all of it. Full lines by a pixel + // and a half of screen space, gone under three quarters of one. + float span = 1.0 / max(wide, 1e-6); + float fade = clamp((span - 0.75) * 1.35, 0.0, 1.0); + return fade * clamp(gridWidth * 0.5 + 0.5 - near, 0.0, 1.0); +} +#endif + +// ------- the sky, by direction + +vec3 bandAt(float i) { + return Texel(skyRamp, + vec2((clamp(i, 0.0, skyCount - 1.0) + 0.5) / skyCount, 0.5)).rgb; +} + +// Where a DIRECTION lands on the sky's own gradient, as a band coordinate +// in [0, count]: 0 is straight overhead, count the horizon. +// +// Measured by putting the direction through the very matrix the frame is +// drawn with, as a point at infinity -- which is how Voxel3D finds both the +// vanishing line and the sun's place on the canvas. So the reflected sky and +// the painted sky are answering the same question with the same arithmetic, +// and they agree at the waterline for free at any pitch, fov or zoom. +// +// A direction whose w comes out negative is BEHIND the camera plane, which +// for an upward reflection means near-vertical: the top band, overhead. +float skyPos(vec3 d) { + vec4 c = vp * vec4(d, 0.0); + if (c.w <= 1e-6) return 0.0; + float py = (c.y / c.w * 0.5 + 0.5) * screen.y; + float row = floor(py / cell) * cell; + return clamp(row / max(skyEdge, 1.0), 0.0, 1.0) * skyCount; +} + +// `parity` is the diorama checkerboard this fragment sits on -- the same +// one Sky's own dither is cut from, so the reflected gradient breaks up in +// the same 8-bit way rather than being the one smooth thing in the frame. +vec3 skyAt(vec3 d, float parity) { + float pos = skyPos(d); + float base = min(floor(pos), skyCount - 1.0); + vec3 c = bandAt(base); + if (base < skyCount - 1.0 && (pos - base) > skyStart && parity < 0.5) { + c = bandAt(base + 1.0); + } + return c; +} + +float crater(vec2 p, vec2 c, float r) { + vec2 dd = p - c; + return step(dot(dd, dd), r * r); +} + +// The sun or moon, and the twilight warmth around it, laid over the bands. +// +// By ANGLE, not by screen position: the reflected direction usually +// projects off the top of the frame entirely, where screen distances stop +// meaning anything. bodyAng is Sky.discRadius run back through the camera's +// field of view, so this disc is the same size as the painted one. +vec3 bodyAt(vec3 d, vec3 c, float parity) { + if (bodyOn <= 0.0) return c; + float ang = acos(clamp(dot(d, bodyDir), -1.0, 1.0)); + if (glowAmt > 0.0) { + float g = glowAmt * pow(clamp(1.0 - ang / glowReach, 0.0, 1.0), 2.0); + float lvl = floor(g * 4.0); + if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; } + c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65); + } + if (ang > bodyAng) return c; + float t = ang / bodyAng; + // the dithered rim, exactly as the painted disc keeps one parity of its + // outer ring of cells + if (t > 0.86 && parity < 0.5) return c; + vec3 disc = (t <= 0.5) ? bodyCore : bodyMain; + if (bodyMoon > 0.5) { + // disc-local coordinates: a frame built off world up, so the craters + // sit on the moon the same way round every night + vec3 t1 = normalize(cross(vec3(0.0, 1.0, 0.0), bodyDir)); + vec2 dc = vec2(dot(d, t1), dot(d, cross(bodyDir, t1))) / bodyAng; + float k = 0.0; +//@CRATERS + if (k > 0.0) { disc = bodyDark; } + } + return disc; +} + +// ------- the screen-space march + +// A point as (uv, depth, valid), through the very matrix the frame was drawn +// with. The uv and the depth are the same numbers the hardware wrote -- the +// clip-space Y flip is already baked into `vp`, and a canvas texture's v runs +// the same way its pixel rows do, so one 0.5x+0.5 answers for both. +// +// This is why the march walks in the world as DRAWN rather than as authored: +// the depth buffer holds the curved world, so a straight line in that space +// is the ray, and a straight line in the flat one would bend through it. +vec4 project(vec3 p) { + vec4 c = vp * vec4(p, 1.0); + if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0); + return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0); +} + +// Walk the reflected ray until it passes behind the depth buffer. Returns +// the colour found in .rgb and how much of it to believe in .a -- 0 for a +// ray that left the frame, ran out of steps, or crossed something it went +// straight through rather than landed on. +vec4 march(vec3 origin, vec3 dir) { + vec4 miss = vec4(0.0, 0.0, 0.0, 0.0); + vec3 a = origin; + vec4 pa = project(a); + if (pa.w < 0.5) return miss; + float len = rayStep; + for (int i = 0; i < RAY_STEPS; i++) { + vec3 b = a + dir * len; + vec4 pb = project(b); + if (pb.w < 0.5) return miss; + if (pb.x < 0.0 || pb.x > 1.0 || pb.y < 0.0 || pb.y > 1.0) return miss; + float scene = Texel(depthTex, pb.xy).r; + if (pb.z > scene) { + // how much depth this one step covered: the yardstick for whether + // the crossing is a surface or a thin thing the ray shot past + float span = max(abs(pb.z - pa.z), 1e-7); + if (pb.z - scene > span * rayThick) return miss; + // binary-refine onto the contact + vec3 lo = a; + vec3 hi = b; + for (int k = 0; k < RAY_REFINE; k++) { + vec3 m = (lo + hi) * 0.5; + vec4 pm = project(m); + if (pm.z > Texel(depthTex, pm.xy).r) { hi = m; } else { lo = m; } + } + vec4 hit = project(hi); + if (hit.w < 0.5) return miss; + // Ease out at the frame's rim, where the reflection is about to run + // off the only evidence there is -- and with distance travelled, so a + // long ray hands back to the sky instead of ending on a hard edge. + // + // The distance term is doing two jobs. It hides the march's own tail, + // where the steps are longest and a grazing crossing is least likely + // to be a real surface -- and it is also true: distant water reflects + // haze rather than detail, and the haze is what the bands underneath + // already are. The small floor keeps a genuine far hit as a trace + // rather than deleting it. + vec2 e = min(hit.xy, 1.0 - hit.xy); + float edge = smoothstep(0.0, edgeFade, min(e.x, e.y)); + float far = 1.0 - clamp(float(i) / float(RAY_STEPS), 0.0, 1.0); + return vec4(Texel(reflectTex, hit.xy).rgb, edge * (0.15 + 0.85 * far)); + } + a = b; + pa = pb; + len *= rayGrow; + } + return miss; +} + +// ------- the surface, as a field of pixel-tall columns + +// How high the column at world pixel `q` stands, in WHOLE world pixels. +// +// Whole, because that is what makes them BARS: a column is a voxel like +// every other voxel in this mode, one unit on a side, and a surface that +// stepped in fractions would just be a smooth wave with extra arithmetic. +// Three crossing wave trains, so the field has no readable repeat inside a +// lake's worth of pixels. +// +// The SMOOTH surface underneath, 0 to 1 -- the thing the columns are a +// quantisation of. Summed from Water.WAVE_TRAINS, which is where the trains +// and the reasoning behind their weights live; pasted in rather than sent, +// so the speed derived from those same numbers cannot drift from the field +// they describe. +float waveRaw(vec2 q) { + float h = 0.0; +//@TRAINS + return h * 0.5 + 0.5; +} + +// and the voxel surface: that field, in whole world pixels. +float waveAt(vec2 q) { + if (waveHeight <= 0.0) return 0.0; + return floor(waveRaw(q) * waveHeight + 0.5); +} + +// The tilt this column reflects with -- taken from the SMOOTH field, not +// from the stepped one, and this is the difference between a moon on the +// water and confetti. +// +// Floored heights are integers, so their differences are integers too: a +// column's neighbours are level with it or a whole pixel off, and nothing in +// between. Build the normal out of THOSE and the reflected ray can only ever +// point in about five directions -- straight up, or rotated by twice the +// arctangent of one step, or of two. A flat sky does not mind; the sun and +// the moon are discs barely two degrees across, and a ray that jumps in +// eighteen-degree increments simply steps over them. The lake goes dark and +// the odd column that happens to land dead on flares -- which is exactly +// what "the moon doesn't reflect right" looks like. +// +// The columns are an approximation of a real surface, and light reflects off +// the surface being approximated. So the SHAPE stays quantised -- it is what +// you see, and it is the whole point -- while the normal is read off the +// smooth field the shape is made from. Still one answer per column, because +// `q` is an integer: pixel-quantised in space, continuous in value, which +// puts the glitter path back without softening a single edge. +// +// Forward differences over one pixel: three samples, and the answer only has +// to say which way this piece of the surface leans. +vec3 waveNormal(vec2 q, float tilt) { + float h = waveRaw(q); + float e = waveRaw(q + vec2(1.0, 0.0)) - h; + float s = waveRaw(q + vec2(0.0, 1.0)) - h; + return normalize(vec3(-e * tilt, 1.0, -s * tilt)); +} + + +// Walk the view ray down through the wave slab and return the column it +// actually meets -- RELIEF MAPPING, and the whole reason the bars read as +// solid rather than as a pattern painted on a flat sheet. +// +// The mesh is still one flat quad per tile, so what gets rasterised is the +// point where the ray crosses the BASE plane. The visible surface is +// somewhere above that, and the two differ by more the lower the camera +// sits. So the ray is walked BACKWARD to the top of the slab and then +// stepped down: the first column whose top it falls below is what the eye is +// looking at, and everything shorter behind that column is hidden by it for +// free, because the march simply never reaches it. +// +// A step that lands below a column's top having just ARRIVED in that column +// is looking at its side; one that was already there and fell through is +// looking at its top. That is the whole of the face test, and it is what +// gives a crest a lit face and a shaded one. +// +// `axis` names which way the face it found points -- 0 top, 1 east/west, 2 +// north/south -- because the wireframe needs to know the one direction the +// face does not vary along (see columnSeam). +void relief(vec3 base, vec3 dir, out vec3 hit, out vec2 col, out float face, + out float axis) { + col = floor(base.xz); + hit = base; + face = 1.0; + axis = 0.0; + float dy = -dir.y; + // a ray running level along the surface has no slab to walk through, and + // dividing by its descent would send the start point to infinity + if (waveHeight <= 0.0 || dy < 0.02) return; + float across = length(dir.xz); + float reach = waveHeight / dy; + // How far across the surface the whole slab displaces the answer. Under + // half a pixel it cannot pick a different column than the one already + // under the fragment, so the march would spend its samples arriving where + // it started -- which is exactly the case at the steep rungs, where the + // camera looks nearly straight down the columns and there is no side of a + // bar to see anyway. + float span = reach * across; + if (span < 0.5) { + hit.y = base.y + waveAt(col); + return; + } + // and the other end: `reach` grows as one over the descent, so a grazing + // ray asks for hundreds of world pixels of march from a fixed number of + // samples. + // + // What one sample is worth is a SCREEN pixel of surface, so that is the + // stride (see WAVE_STRIDE). A screen pixel covers this much of the water: + // the distance to the eye times the angle one pixel subtends, opened out + // by the obliquity -- a surface seen edge-on runs away far faster per + // pixel than one seen face-on. Floored at a world pixel, because up close + // a finer stride than the columns themselves buys nothing and skipping + // them costs everything. + float dist = length(base - eye); + float stride = max(WAVE_STRIDE, dist * pxAngle / dy); + float maxSpan = float(WAVE_STEPS) * stride; + if (span > maxSpan) { reach = maxSpan / max(across, 1e-4); } + vec3 top = base - dir * reach; + vec2 wasCol = floor(top.xz); + for (int i = 1; i <= WAVE_STEPS; i++) { + vec3 p = mix(top, base, float(i) / float(WAVE_STEPS)); + vec2 q = floor(p.xz); + float y = base.y + waveAt(q); + if (p.y <= y) { + col = q; + hit = vec3(p.x, y, p.z); + vec2 d = q - wasCol; + if (abs(d.x) + abs(d.y) < 0.5) { + face = 1.0; // fell through the top + axis = 0.0; + } else if (abs(d.x) > abs(d.y)) { + face = (d.x > 0.0) ? faceShade.y : faceShade.x; // west : east + axis = 1.0; + } else { + face = (d.y > 0.0) ? faceShade.w : faceShade.z; // north : south + axis = 2.0; + } + return; + } + wasCol = q; + } +} + +// The water's own art, read at the column the ray landed on rather than at +// the fragment's own place on the flat quad -- otherwise the bars parallax +// away and the pixels they are made of stay behind on the plane. +// +// Read off the COLUMN, not off the fragment. +// +// One world pixel is one atlas texel exactly, and the mesher lays a tile's +// eight texels across its eight world pixels -- so the column at world +// (cx, cz) wears texel (cx mod 8, cz mod 8) and nothing else. That makes the +// lookup exact, and far more importantly STABLE: the art a column shows +// depends only on where that column stands in the world, so it cannot swim +// as the camera moves and two fragments that landed on the same column +// cannot disagree about it. +// +// Offsetting the fragment's own uv by the parallax instead makes the art +// depend on how far the march happened to travel -- and wherever the march +// skipped a column, neighbouring fragments picked texels several apart. That +// is what peppered the surface with noise, and why it cleared up in patches: +// the patches are where the march was not skipping. +// +// The tile origin is the FRAGMENT's, so the lookup can never leave the tile +// this quad was built to sample -- the same bleed the mesher's INSET stops. +vec2 waveUV(vec2 tc, vec2 col) { + vec2 texel = 1.0 / atlasSize; + vec2 tile = 8.0 * texel; + vec2 org = floor(tc / tile) * tile; + return org + (mod(col, 8.0) + 0.5) * texel; +} + +vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + // THE DEPTH TEST, done here because the buffer that would have done it is + // detached for the length of this pass so it can be READ (see the header). + // Same comparison, same buffer, same result: a building in front of a pond + // still hides it. + vec2 uv = sc / screen; + if (gl_FragCoord.z > Texel(depthTex, uv).r) discard; + + // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of + // its own standing a whole number of pixels tall, and the ray decides + // which one it meets -- so what follows is answered per COLUMN and not per + // screen pixel: one colour to a bar, at the resolution the water art is + // drawn at, with no smooth shading anywhere across it. (The depth test + // above is the one thing that stays per fragment: that is the hardware's + // own question and it is asked in screen space.) + vec3 view = normalize(vBent - eye); + vec3 hit; + vec2 col; + float face; + float axis; + relief(vBent, view, hit, col, face, axis); + // and the bar's centre, so a column is sampled and reflected from one + // place rather than from wherever inside it the fragment happened to land + vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5); + + vec4 p = Texel(tex, waveUV(tc, col)); + if (p.a < 0.5) discard; + // `face` is the column's own side shading, which is what makes a crest + // read as a solid thing with a lit flank rather than as a bright patch + vec3 base = p.rgb * vShade * face * sunlight(vSun) * dayTint; + + // the reflection follows the WAVES' own shape -- the tilt this column + // takes from the neighbours it stands beside -- rather than an invented + // wobble, so the sky and the sun break along the bars instead of across + // them. Opened up by the lean, which is about to squash it (see below). + vec3 n = waveNormal(col, waveSlope * (1.0 + lean * waveSlopeLean)); + vec3 r = reflect(view, n); + // the same reflection off a LEVEL surface, which is what the lean below + // moves: the difference between the two is this column's own contribution + vec3 rFlat = reflect(view, vec3(0.0, 1.0, 0.0)); + // The horizon lean (see LEAN_FROM in Water.lua): zero at the rung whose + // horizon is in frame, so the waterline join is untouched; taking the ray + // down to the elevation THAT rung reflects at as the camera tips over, + // where there is no join to break and a straight-up reflection has nothing + // in it. Applied to the sky, the body AND the march, because the same + // seventy-five-degree ray that misses the sun also leaves the frame. + // + // What is leaned is the LEVEL reflection, with this column's own deflection + // put back on top afterwards. Leaning the perturbed ray instead sets its + // elevation outright, which overwrites the very variation the waves are + // there to provide: at full lean every column on the lake reflects the + // same elevation, the sky comes out one flat band and the moon -- a disc + // two degrees wide that the ray now never sweeps past -- vanishes + // completely. Which is exactly what it did. + // + // The ray keeps its own BEARING and is only tipped in elevation, so a + // reflection still points where the water is pointing it -- and a ray so + // near vertical that it has no bearing left borrows the camera's. + if (lean > 0.0) { + float fl = length(rFlat.xz); + vec3 bearing = (fl > 1e-3) ? vec3(rFlat.x / fl, 0.0, rFlat.z / fl) + : lookFlat; + float e = mix(asin(clamp(rFlat.y, -1.0, 1.0)), leanElev, lean); + r = normalize(bearing * cos(e) + vec3(0.0, sin(e), 0.0) + (r - rFlat)); + } + + // The checkerboard the sky's bands and the sunset's glow are dithered on, + // cut from the WATER's own columns rather than from the screen. Same + // reasoning the window glint follows in the scene shader: a pattern + // anchored to the screen has the world sliding through it at zoom speed + // whenever the camera pans, which strobes. Anchored to the surface, + // panning moves nothing and only the waves do. + float parity = mod(col.x + col.y, 2.0); + vec3 refl = base; + if (skyOn > 0.5) { + refl = bodyAt(r, skyAt(r, parity), parity); + } + if (rays > 0.5) { + vec4 hit = march(surf, r); + refl = mix(refl, hit.rgb, hit.a); + } + + // Schlick, floored and softened (see FRESNEL_* in Water.lua): the angle + // still decides, a grazing camera still gets a mirror, and a steep one + // still gets a pond rather than a flat sticker. + float ct = clamp(dot(-view, n), 0.0, 1.0); + float f = fresnelFloor + + (fresnelCeil - fresnelFloor) * pow(1.0 - ct, fresnelPower); + vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0)); + +#ifdef VOXEL_GRID + rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis); +#endif + return vec4(rgb, 1.0) * color; +} +#endif +]] + +-- The crater list is Sky's (Sky.MOON_CRATERS), pasted in as source rather +-- than sent as a uniform array: GLSL ES has no array constructors worth +-- relying on, and the driver bug that cost the sky its bands is exactly +-- what a uniform array of vectors buys. Built from the one list, so the +-- moon on the water can never grow craters the moon in the sky has not. +local function craterSource() + local out = {} + for _, c in ipairs(Sky.MOON_CRATERS) do + out[#out + 1] = (" k += crater(dc, vec2(%.4f, %.4f), %.4f);") + :format(c[1], c[2], Sky.CRATER_FRAC) + end + return table.concat(out, "\n") +end + +Water._craterSource = craterSource -- named for the suite + +-- and the wave trains, pasted in for the same reason: the rate is derived +-- from this table (Water.waveRate), so the field the shader sums has to be +-- the one that table describes rather than a copy of it kept in step by hand +local function trainSource() + local out = {} + for _, t in ipairs(Water.WAVE_TRAINS) do + out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" + .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + end + return table.concat(out, "\n") +end + +Water._trainSource = trainSource -- named for the suite + +local function source(grid) + local src = SHADER_SRC:gsub("//@CRATERS", (craterSource():gsub("%%", "%%%%"))) + src = src:gsub("//@TRAINS", (trainSource():gsub("%%", "%%%%"))) + local head = ("#define RAY_STEPS %d\n#define RAY_REFINE %d\n" + .. "#define WAVE_STEPS %d\n#define WAVE_STRIDE %.1f\n") + :format(Water.RAY_STEPS, Water.RAY_REFINE, Water.WAVE_STEPS, + Water.WAVE_STRIDE) + if grid then head = head .. "#define VOXEL_GRID 1\n" end + return head .. src +end + +Water._source = source -- named for the suite + +-- Two compilations, exactly as Voxel3D keeps two of the scene shader: the +-- wireframe variant needs derivatives, the one thing a driver can refuse, +-- and a refusal must cost the seams on the water and nothing else. +-- nil = untried, false = unavailable. +local shaders = { [false] = nil, [true] = nil } + +function Water.shader(grid) + grid = grid and true or false + if shaders[grid] == nil then + if not (love.graphics and love.graphics.newShader) then + shaders[grid] = false + else + local ok, sh = pcall(love.graphics.newShader, source(grid)) + if not ok and V and V.mod and V.mod.log then + -- once, where it can be read: the fallback is flat water, which is + -- easy to look at and impossible to diagnose without this line + V.mod.log:warn("water shader did not compile: %s -- lakes draw flat", + tostring(sh)) + end + shaders[grid] = (ok and sh) or false + end + end + return shaders[grid] or nil +end + +-- ------- the pass + +local active = nil -- the shader this pass bound, or nil + +-- The ripple phase. Driven by the ENGINE's tile-animation clock, the same +-- 60Hz counter the water tiles rotate on, so the ripple and the art it +-- ripples move off one number rather than drifting against each other. +local function waveTime() + -- lazily, and through the mod namespace: TerrainAtlas reaches into the + -- engine's renderer at load time, and nothing about a settings row should + -- depend on that having happened yet + local ok, frame = pcall(function() + return V.require("TerrainAtlas")._animFrame() + end) + if not (ok and type(frame) == "number") then return 0 end + -- floored to the wave beat (see WAVE_FPS). The engine's counter runs at + -- 60, so this is the frame that step began on. + local period = 60 / math.max(1, Water.WAVE_FPS) + return (math.floor(frame / period) * period / 60) * Water.waveRate() +end + +Water._waveTime = waveTime + +-- Begin the reflective pass. +-- +-- `ctx` is everything the pass cannot work out for itself, all of it already +-- computed by whoever set the camera up this frame: +-- +-- reflect the frame so far, as a texture (Voxel3D.beginWater) +-- depth its depth, likewise +-- vp, eye, curve, screen, cell the camera, as beginScene sent it +-- skyEdge where the sky's bottom is, or nil indoors / with no bands +-- grid whether the voxel wireframe is compiled into this frame +-- +-- Returns false when the pass cannot run, in which case the caller draws the +-- water mesh through the ordinary scene shader instead. +function Water.begin(ctx) + if not (ctx and ctx.reflect and ctx.depth) then return false end + local level = Water.level() + if level <= 0 then return false end + local sh = ctx.grid and Water.shader(true) or nil + if not sh then sh = Water.shader(false) end + if not sh then return false end + + love.graphics.setShader(sh) + love.graphics.setColor(1, 1, 1, 1) + local function send(name, ...) + pcall(sh.send, sh, name, ...) + end + + send("vp", "row", ctx.vp) + send("eye", ctx.eye) + send("curve", ctx.curve) + send("screen", { ctx.screen[1], ctx.screen[2] }) + send("cell", math.max(1, ctx.cell or 1)) + -- how much of the view one screen pixel is worth: what sets the relief + -- march's stride, so a sample is always about a pixel of surface + send("pxAngle", (ctx.fov or 1) / math.max(1, ctx.screen[2])) + send("reflectTex", ctx.reflect) + send("depthTex", ctx.depth) + + -- the sun's pass, sent the same way and for the same reason the scene + -- shader sends it: the sampler is declared either way, and leaving one + -- unbound is a driver-dependent crash rather than a fallback + local map = ShadowMap.active() + send("sunVP", "row", map and ShadowMap.uvVP or Mat4.identity()) + local tex = ShadowMap.texture() + if tex then send("sunMap", tex) end + local Voxel3D = V.require("Voxel3D") + send("sunDark", map and Voxel3D.SHADOW_ALPHA or 0) + send("sunBias", ShadowMap.bias) + local texel = 1 / ShadowMap.res + send("sunTexel", { texel, texel }) + send("dayTint", Voxel3D.tint or { 1, 1, 1 }) + + send("rays", level >= 2 and 1 or 0) + -- the horizon lean, and the direction it leans toward (see Water.lean) + send("lookFlat", ctx.lookFlat or { 0, 0, -1 }) + send("lean", Water.lean(ctx.descent)) + send("leanElev", Water.LEAN_ELEV) + send("waveHeight", Water.WAVE_HEIGHT) + send("waveSlope", Water.WAVE_SLOPE) + send("waveSlopeLean", Water.WAVE_SLOPE_LEAN) + send("waveT", waveTime()) + -- the columns' side faces wear the MESH's own direction shading, sent in + -- rather than restated, so a wave crest is lit like every other voxel + local fs = Voxel3D.FACE_SHADE + send("faceShade", { fs[1], fs[2], fs[5], fs[6] }) -- east, west, south, north + send("fresnelFloor", Water.FRESNEL_FLOOR) + send("fresnelCeil", Water.FRESNEL_CEIL) + send("fresnelPower", Water.FRESNEL_POWER) + send("rayStep", Water.RAY_STEP) + send("rayGrow", Water.RAY_GROW) + send("rayThick", Water.RAY_THICK) + send("edgeFade", Water.EDGE_FADE) + if ctx.grid then + local VoxelGrid = V.require("VoxelGrid") + send("gridDark", VoxelGrid.DARK) + send("gridWidth", VoxelGrid.WIDTH) + end + + Water.sendSky(sh, ctx) + active = sh + return true +end + +-- The sky half of the uniforms: the band ramp, and whatever hangs in it. +-- +-- Split out because it is the part with a "there is none" answer -- indoors, +-- and on any frame whose ramp could not be built -- and that answer has to +-- leave every sampler bound anyway. skyOn 0 reflects the water's own colour +-- back at itself, which is what a pond in a cave does. +function Water.sendSky(sh, ctx) + local function send(name, ...) + pcall(sh.send, sh, name, ...) + end + local ramp, count = Sky.ramp() + local edge = ctx.skyEdge + if not (ramp and count and edge and edge > 0) then + -- the sampler still has to hold something; the ramp is the only image + -- this shader has for the job, so bind the frame copy and switch it off + send("skyRamp", ctx.reflect) + send("skyCount", 1) + send("skyEdge", 1) + send("skyStart", 2) + send("skyOn", 0) + send("bodyOn", 0) + send("glowAmt", 0) + return + end + send("skyRamp", ramp) + send("skyCount", count) + send("skyEdge", edge) + send("skyStart", Sky.DITHER and Sky.DITHER_START or 2) + send("skyOn", 1) + + local body = DayNight.body() + if not body then + send("bodyOn", 0) + send("glowAmt", 0) + return + end + local amt, glowColor = DayNight.glow() + local h = ctx.screen[2] + local cell = math.max(1, ctx.cell or 1) + -- Sky sizes the disc in canvas pixels; out here the reflected body is + -- usually off the top of the frame, where a pixel is not a unit any more + -- -- so it is converted to the ANGLE it subtends through this camera's + -- own field of view, which is the same number wherever it is looked at. + local perRadian = h / math.max(1e-4, ctx.fov or 1) + local rpx = Sky.discRadius(h, cell, + { moon = body.moon, glowAmt = amt }) + local shades = Sky.discShades(body.moon) + local function shade(i) + local c = shades[i] or shades[#shades] or { 255, 255, 255 } + return { c[1] / 255, c[2] / 255, c[3] / 255 } + end + local twilight = (amt or 0) > 0.25 and not body.moon + send("bodyOn", 1) + send("bodyMoon", body.moon and 1 or 0) + send("bodyDir", { body.dx, body.dy, body.dz }) + send("bodyAng", rpx / perRadian) + send("bodyCore", shade(1)) + send("bodyMain", shade(twilight and 3 or 2)) + send("bodyDark", shade(3)) + send("glowAmt", (not body.moon) and (amt or 0) or 0) + send("glowColor", glowColor + and { glowColor[1] / 255, glowColor[2] / 255, glowColor[3] / 255 } + or { 1, 0.88, 0.66 }) + send("glowReach", (ctx.screen[1] * Sky.GLOW_REACH) / perRadian) +end + +-- Draw one water mesh with `model` applied. Mirrors Voxel3D.draw, minus the +-- camera-ward pull (a flat sheet has nothing to lean over) and the separate +-- sun transform (water is terrain: the sun saw the same matrix). +function Water.draw(mesh, texture, model) + if not (active and mesh) then return end + if texture then mesh:setTexture(texture) end + pcall(active.send, active, "model", "row", model or Mat4.identity()) + -- Per draw rather than per pass, and read off the TEXTURE rather than + -- assumed: it is what converts a world pixel of the columns' parallax into + -- the texel of art standing on it, and two maps in one frame can be drawn + -- from atlases of different sizes. + if texture and texture.getDimensions then + local ok, w, h = pcall(texture.getDimensions, texture) + if ok and w and h then + pcall(active.send, active, "atlasSize", { w, h }) + end + end + love.graphics.draw(mesh) +end + +function Water.finish() + active = nil +end + +-- Drop the compiled shaders (window resize, hot reload): they are GPU +-- objects on a context that may not exist any more. +function Water.invalidate() + for k, sh in pairs(shaders) do + if sh and sh.release then pcall(sh.release, sh) end + shaders[k] = nil + end + active = nil +end + +return Water diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index c6db4ff..c9f0d0e 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1675,6 +1675,31 @@ T.check(math.abs(math.sin(Water.LEAN_ELEV) - Water.LEAN_FROM) < 1e-12, VoxelState.angle, Voxel3D.camera = wasAngle, wasCam end +-- ------- people do not shadow water +-- +-- The sun pass is ONE map, so a surface cannot ask what threw a shadow +-- unless the map says -- and it does, in the blue channel, which was zero +-- anyway. Water is the only surface that asks: a character standing at a +-- lake's edge laid a hard cut-out of its own sprite across a surface already +-- showing the sky and the shoreline, which reads as a sticker rather than as +-- a shadow. Everything the world casts still shades it. +do +local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") +T.check(type(ShadowMap.sprites) == "function", + "the sun pass can be told it is drawing the cast rather than the world") +-- inert outside a pass, like every other toggle on it -- a caller that +-- brackets a draw it never made must not send to a shader that is not bound +T.check(pcall(ShadowMap.sprites, true) and pcall(ShadowMap.sprites, false), + "and saying so outside one is harmless") + +local shadowSrc = ShadowMap._source and ShadowMap._source() or nil +if shadowSrc then + T.check(shadowSrc:find("fract(d), sprite", 1, true) ~= nil, + "the marker rides the channel the depth pack left free, so it costs " + .. "nothing: the map is still two channels of depth") +end +end + -- ------- the compiled variants local plain = Water._source(false) local gridded = Water._source(true) @@ -1704,14 +1729,19 @@ T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil, -- 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 +-- a sample is worth a SCREEN pixel of surface, so that is the stride: held +-- at a world pixel up close (finer buys nothing and skipping costs the +-- pepper) and opened out with distance (holding it there just runs the march +-- out of samples part-way down the slab, which flattened the lowest rung's +-- whole middle distance) 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") + and plain:find("max(WAVE_STRIDE, dist * pxAngle / dy)", 1, true) ~= nil, + "the relief stride is a screen pixel's worth of surface, floored at a " + .. "world pixel") 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 that floor is at most ONE world pixel, because a column is one world " + .. "pixel wide -- a longer one 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 diff --git a/tests/mon_shots.lua b/tests/mon_shots.lua new file mode 100644 index 0000000..73cfefb --- /dev/null +++ b/tests/mon_shots.lua @@ -0,0 +1,71 @@ +-- Driver: one overworld-battle screenshot per species, the mon fighting +-- ITSELF -- its back pic on the player's mark and its front pic on the +-- enemy's, so a single frame shows both sprites the 3D mode draws for it. +-- +-- The point is a visual sweep for pic glitches (holes the paper-fill missed, +-- a silhouette cut wrong, a pin that leaves the mon floating), so every shot +-- is staged identically: same map, same cells, same beat -- the battle menu, +-- both HUD panels up. Whatever differs between two shots is the mon. +-- +-- SHOT_DIR=.scratchpad/mon_shots \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mon_shots.lua love . +-- +-- Files land as NNN_species.png in dex order. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/mon_shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + -- every real species the merged data carries, walked in dex order + local species = {} + for id, def in pairs(game.data.pokemon) do + if type(id) == "string" and type(def) == "table" + and def.dex and def.dex >= 1 and def.dex <= 151 then + species[#species + 1] = { id = id, dex = def.dex } + end + end + table.sort(species, function(a, b) return a.dex < b.dex end) + U.log(("%d species"):format(#species)) + + game.save.player.name = "RED" + + for _, s in ipairs(species) do + -- level 50 both sides: high enough that nothing about the staging is + -- species-specific, and a wild battle never awards exp off a menu shot + game.save.party = { Pokemon.new(game.data, s.id, 50) } + + U.teleport(game, "ROUTE_1", 5, 8, "down") + -- let the neighbourhood's meshes land so the first battle frame is the + -- real arena rather than the flat fallback + U.wait(60) + + local battle = BattleState.newWild(game, s.id, 50) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + -- the wipe, then tap through "Wild X appeared!" and the send-out until + -- the battle MENU is actually up -- a fixed tap count lands on whatever + -- beat the intro happened to be on, which is how a shot ends up with the + -- trainer still standing where the mon should be + U.wait(70) + for _ = 1, 200 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + if battle.phase ~= "menu" then + U.log(("STUCK before menu: %s (phase %s)"):format(s.id, tostring(battle.phase))) + end + -- let the send-out slide/ball beat finish so the mon is standing still + U.wait(40) + U.shot(game, ("%s/%03d_%s.png"):format(DIR, s.dex, s.id:lower())) + + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + end + + U.log("done -- " .. DIR) +end diff --git a/tests/pic_dump.lua b/tests/pic_dump.lua new file mode 100644 index 0000000..c6a9714 --- /dev/null +++ b/tests/pic_dump.lua @@ -0,0 +1,63 @@ +-- Driver: dump the exact pic textures a live 3D battle draws, per stage -- +-- the sprite as loaded (raw) and what picImage hands the billboard after the +-- palette bake and BattlePics' paper fill (final). Diagnostic for pics that +-- render with holes: whichever stage the transparency first appears in is +-- the stage that made it. +-- +-- SHOT_DIR=.scratchpad/pic_dump \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/pic_dump.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/pic_dump" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local function save(img, path) + if not img then U.log("NIL image for " .. path) return end + local w, h = img:getDimensions() + local g = love.graphics + local prev = g.getCanvas() + local canvas = g.newCanvas(w, h, { dpiscale = 1 }) + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setBlendMode("replace", "premultiplied") + g.setColor(1, 1, 1, 1) + g.draw(img, 0, 0) + g.setCanvas(prev) + g.setBlendMode("alpha") + local f = assert(io.open(path, "wb")) + f:write(canvas:newImageData():encode("png"):getString()) + f:close() + end + + local SPECIES = os.getenv("PIC_SPECIES") + local list = {} + if SPECIES then + for id in SPECIES:gmatch("[^,%s]+") do list[#list + 1] = id:upper() end + else + list = { "PIKACHU", "SEEL", "BULBASAUR", "MEWTWO" } + end + + for _, id in ipairs(list) do + game.save.party = { Pokemon.new(game.data, id, 50) } + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(30) + local battle = BattleState.newWild(game, id, 50) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(80) + local lo = id:lower() + save(battle.enemy.sprite, ("%s/%s_front_raw.png"):format(DIR, lo)) + save(battle:picImage(battle.enemy.sprite), ("%s/%s_front_final.png"):format(DIR, lo)) + save(battle.player.sprite, ("%s/%s_back_raw.png"):format(DIR, lo)) + save(battle:picImage(battle.player.sprite), ("%s/%s_back_final.png"):format(DIR, lo)) + U.log("dumped " .. id) + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(5) + end + + U.log("done -- " .. DIR) + love.event.quit() +end diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua new file mode 100644 index 0000000..f02040f --- /dev/null +++ b/tests/water_reflect_probe.lua @@ -0,0 +1,127 @@ +-- Driver: WHY is the water not reflecting anything? +-- +-- The reflective pass has several links and every one of them fails quietly +-- back to flat water, which looks exactly like the row being off. This walks +-- the chain in the LIVE game and prints where it stops. +-- +-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/water_reflect_probe.lua lovec . +-- +-- knobs (env): +-- REFL_MAP map id (default PALLET_TOWN) +-- REFL_SPOT "x,y[,facing]" (default 5,6,down) +-- REFL_LEVEL voxel rung (default 5, the 75-degree camera, +-- which is where a reflection is +-- most of what you can see) +-- REFL_TIME daytime pin (day/dusk/...) (default: leave as-is) +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local mapId = os.getenv("REFL_MAP") or "PALLET_TOWN" + local level = math.floor(tonumber(os.getenv("REFL_LEVEL")) or 5) + local sx, sy, facing = (os.getenv("REFL_SPOT") or "5,6,down") + :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)") + facing = (facing ~= "" and facing) or "down" + + local function say(...) print("[water-ssr] " .. string.format(...)) end + + U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing) + U.wait(20) + Pipelines.setLevel("voxel", level) + U.wait(40) -- outlast the camera tween and the build + + local V = game.mods.exports["DRAMATIC_SHAPE"] + V = V and V.lib + if not V then return say("mod exports unreachable -- is it enabled?") end + + local Water = V.require("Water") + local Voxel3D = V.require("Voxel3D") + local ChunkMesher = V.require("ChunkMesher") + local Sky = V.require("Sky") + local DayNight = V.require("DayNight") + + if os.getenv("REFL_TIME") then + DayNight.setting:sync(os.getenv("REFL_TIME")) + U.wait(5) + end + + local ow = game.overworld + local map = ow and ow.map + if not map then return say("no live map") end + + -- 1. is the row on at all? + say("row=%s level=%d", tostring(Water.setting:get()), Water.level()) + if not Water.enabled() then + return say("STOP: WATER is OFF -- press 9, or set the row") + end + + -- 2. is there any water on this map to reflect in? + local terrain, water = ChunkMesher.pair(map, false) + if not terrain then terrain, water = ChunkMesher.pair(map, true) end + say("terrain mesh=%s water mesh=%s", tostring(terrain ~= nil), + tostring(water ~= nil)) + if not terrain then + return say("STOP: no terrain mesh yet -- the build is still cooking") + end + if not water then + say("STOP: this map has no water surface. That is not a fault unless") + say(" you can see a lake: check the tileset's water tiles reach") + say(" TileShape (run voxel_survey.lua for the shape breakdown).") + return + end + + -- 3. did the driver give us a depth texture to read? This is the one + -- hardware requirement the rest of the mode does not already have. + say("depth canvas readable: %s", tostring(Voxel3D.depthReadable())) + if not Voxel3D.depthReadable() then + say("STOP: no readable depth canvas on this driver (depth24/readable).") + say(" The water falls back to the flat scene shader, which is") + say(" exactly what it looked like before this feature existed.") + return + end + + -- 4. did the shader build? Both variants -- the wireframe one needs + -- derivatives, which a driver may refuse on its own. + say("shader plain=%s grid=%s", + tostring(Water.shader(false) ~= nil), tostring(Water.shader(true) ~= nil)) + if not Water.shader(false) then + return say("STOP: the water shader did not compile -- the mod log has " + .. "the driver's own message") + end + + -- 5. is there a sky to reflect, and something hanging in it? + local ramp, count = Sky.ramp() + say("sky ramp=%s bands=%s edge=%s", tostring(ramp ~= nil), tostring(count), + tostring(Voxel3D.skyEdge)) + local body = DayNight.body() + if body then + local amt = DayNight.glow() + local w, h = Voxel3D.size() + local rpx = Sky.discRadius(h, Voxel3D.cell or 1, + { moon = body.moon, glowAmt = amt }) + local ang = rpx / ((h or 1) / math.max(1e-4, Voxel3D.fovY or 1)) + say("body=%s dir=(%.2f, %.2f, %.2f) disc=%.1fpx (%.2f deg)", + body.moon and "moon" or "sun", body.dx, body.dy, body.dz, rpx, + math.deg(ang)) + else + say("body: none in the sky right now (set REFL_TIME=day or =night)") + end + if not ramp then + say("NOTE: no band ramp -- indoors, or the ramp could not be built. The") + say(" sky half of the reflection is off; the ray march still runs.") + end + + -- 6. how much reflection this camera is actually asking for. Both numbers + -- fall out of the rung, and between them they explain every "it only + -- works at 75" report: Fresnel decides how much shows, and the lean + -- decides whether what shows has anything in it. + local f = Water.FRESNEL_FLOOR + (Water.FRESNEL_CEIL - Water.FRESNEL_FLOOR) + * (1 - Voxel3D.descent) ^ Water.FRESNEL_POWER + say("camera: descent %.3f -> fresnel about %.2f, horizon lean %.2f", + Voxel3D.descent, f, Water.lean(Voxel3D.descent)) + say("waves: %d px columns, phase %.2f", Water.WAVE_HEIGHT, Water._waveTime()) + + say("OK: every link is live. The effect is strongest at REFL_LEVEL=5 (the") + say(" 75-degree rung, where Fresnel is highest and the reflection is") + say(" exact) and with a low sun (REFL_TIME=dusk).") +end From 92fef2a37e6b6cc9913199d90a0a8865682af573 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 31 Jul 2026 23:29:39 -0400 Subject: [PATCH 3/6] update for modkit update --- manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/manifest.json b/manifest.json index 92602fa..157448b 100644 --- a/manifest.json +++ b/manifest.json @@ -15,5 +15,6 @@ "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. 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." + "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.", + "github": "DramaticShape/DramaticShapeVoxelMod" } From 98f7419b729466092912a4d0e5bb352648daeed5 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 31 Jul 2026 23:39:05 -0400 Subject: [PATCH 4/6] fix water for android glsl shaders --- .github/workflows/release.yml | 200 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 27 +++++ lib/Voxel3D.lua | 23 ++-- lib/Water.lua | 29 ++++- tests/dramatic_shape_test.lua | 19 ++++ tests/water_reflect_probe.lua | 3 +- 6 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6b36f84 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,200 @@ +name: Release + +# Packs the mod into an installable .zip and publishes it as a GitHub Release, +# once per push to main. +# +# Archive layout: every mod file at the archive root, manifest.json included. +# That is one of the two shapes the game accepts on MODS > Import mod .zip +# (src/mods/LauncherMods.lua locateRoot: manifest at the root, or inside a +# single top-level folder). Nothing else is added, so the archive stays +# installable by hand too. +# +# Versioning, first rule that applies wins: +# 1. the "version" input of a manual run, +# 2. "[release X.Y.Z]" anywhere in the commit message, +# 3. manifest.json's own version, when it is ahead of every existing tag, +# so bumping the manifest is the normal way to cut a release, +# 4. otherwise the newest vX.Y.Z tag with its patch incremented +# (0.2.99 rolls over to 0.3.0). +# Whichever wins is written into the manifest.json inside the archive, so a +# shipped mod never reports a different version than the release it came from. +# +# Generated by: python3 tools/modkit.py add-release-workflow +# MOD_ID below is stamped to this mod's id when the file is copied. + +on: + push: + branches: [master] + paths-ignore: + - '.github/**' + - '**.md' + workflow_dispatch: + inputs: + version: + description: "Exact version to release (e.g. 0.3.0). Leave blank to auto-resolve." + required: false + default: "" + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version + id: ver + env: + DISPATCH_VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json, os, re, subprocess, sys + + SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + def sh(*args): + return subprocess.run(args, capture_output=True, text=True).stdout.strip() + + def parse(text): + m = SEMVER.match(text) + return tuple(int(p) for p in m.groups()) if m else None + + def die(msg): + print(f"::error::{msg}", file=sys.stderr) + raise SystemExit(1) + + with open("manifest.json", encoding="utf-8") as fh: + manifest_version = str(json.load(fh).get("version", "")) + + released = sorted( + v for v in (parse(tag[1:]) for tag in sh("git", "tag", "-l", "v*").splitlines()) if v + ) + latest = released[-1] if released else None + + override = os.environ.get("DISPATCH_VERSION", "").strip() + if not override: + found = re.search(r"\[release\s+(\d+\.\d+\.\d+)\]", sh("git", "log", "-1", "--pretty=%B")) + override = found.group(1) if found else "" + + manifest_ver = parse(manifest_version) + if override: + version = parse(override) or die(f"invalid version override {override!r} (expected X.Y.Z)") + source = "the override" + elif manifest_ver and (latest is None or manifest_ver > latest): + version = manifest_ver + source = "manifest.json" + elif latest: + major, minor, patch = latest + patch += 1 + if patch > 99: + minor, patch = minor + 1, 0 + version = (major, minor, patch) + source = "a patch bump on v%d.%d.%d" % latest + else: + die(f"manifest.json version {manifest_version!r} is not X.Y.Z " + "and there is no vX.Y.Z tag to count from") + + text = "%d.%d.%d" % version + print(f"Releasing {text}, from {source}.", file=sys.stderr) + print(f"version={text}") + print(f"tag=v{text}") + PY + + - name: Refuse to clobber an existing release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.ver.outputs.tag }} + run: | + set -euo pipefail + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::error::Tag $TAG already exists. Pick a different version." + exit 1 + fi + if gh release view "$TAG" >/dev/null 2>&1; then + echo "::error::Release $TAG already exists. Pick a different version." + exit 1 + fi + + - name: Build the mod .zip + env: + VERSION: ${{ steps.ver.outputs.version }} + MOD_ID: "DRAMATIC_SHAPE" + run: | + set -euo pipefail + staging="$RUNNER_TEMP/pkg" + out="$GITHUB_WORKSPACE/dist" + rm -rf "$staging" "$out" + mkdir -p "$staging" "$out" + + git archive HEAD | tar -x -C "$staging" + + rm -rf "$staging/.github" "$staging/.gitattributes" \ + "$staging/.gitignore" "$staging/.luarc.json" + + python3 - "$staging/manifest.json" "$VERSION" <<'PY' + import json, sys + path, version = sys.argv[1], sys.argv[2] + with open(path, encoding="utf-8") as fh: + manifest = json.load(fh) + manifest["version"] = version + with open(path, "w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2, ensure_ascii=False) + fh.write("\n") + PY + + zip_path="$out/${MOD_ID}-${VERSION}.zip" + (cd "$staging" && zip -qr "$zip_path" .) + unzip -l "$zip_path" + + unzip -p "$zip_path" manifest.json > "$RUNNER_TEMP/packed-manifest.json" + python3 - "$RUNNER_TEMP/packed-manifest.json" "$VERSION" <<'PY' + import json, sys + path, expected = sys.argv[1], sys.argv[2] + with open(path, encoding="utf-8") as fh: + version = json.load(fh)["version"] + if version != expected: + raise SystemExit(f"::error::packed manifest says {version}, expected {expected}") + print(f"manifest.json is at the archive root and reports {version}") + PY + + (cd "$out" && sha256sum "${MOD_ID}"-*.zip > sha256sums.txt) + cat "$out/sha256sums.txt" + + - name: Publish GitHub Release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.ver.outputs.version }} + TAG: ${{ steps.ver.outputs.tag }} + MOD_ID: "DRAMATIC_SHAPE" + run: | + set -euo pipefail + + prev="$(git tag -l 'v*' --sort=-v:refname | grep -v "^${TAG}$" | head -1 || true)" + range="${prev:+${prev}..}$GITHUB_SHA" + changes="$(git log --no-merges --pretty='- %s' "$range" | head -50 || true)" + + notes=$'Download the .zip and install it from the game: MODS > Import mod .zip.' + if [ -n "$changes" ]; then + notes+=$'\n\n## Changes\n\n'"$changes" + fi + printf 'Release notes:\n%s\n' "$notes" + + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$VERSION" \ + --notes "$notes" \ + "dist/${MOD_ID}-${VERSION}.zip" \ + "dist/sha256sums.txt" + + echo "Published release $TAG" diff --git a/CHANGELOG.md b/CHANGELOG.md index d03334c..3511684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,33 @@ 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. +### Fixed + +- **On Android the water stayed flat, as if the row were off.** Two GLSL ES + defaults the desktop never exercises, both in the water shader: + + Fragment floats default to **mediump** on GLSL ES, and the vertex stage's + default is highp -- and the water shader is the mod's first to declare the + same uniform, the frame's `vp` matrix, in BOTH stages, one on each default. + GLSL ES refuses to link a uniform whose precision the stages disagree on, + so the whole shader failed to build and the pass fell back -- quietly, by + design -- to the flat water the mode always drew. The pixel stage now lifts + its float default to highp (guarded, so a GPU without fragment highp still + compiles and falls back flat), which settles the link and is also simply + needed: the march works in world coordinates that run to a few thousand, + where fp16 has no fraction left, and the world-position varying is + qualified highp for the same reason the wireframe's has been all along. + Samplers default to **lowp** no matter what floats are set to, so the depth + read is lifted too -- eight bits of depth is a march with nothing to land + on. + + And the readable depth canvas -- the one hardware requirement the rest of + the mode does not already have -- now tries four formats before giving up: + depth24, depth24 riding a stencil (a pairing some mobile drivers will + texture when they refuse the bare format), depth32f, and depth16 as the + floor every GLES3 device can read. Refused all four, the reflections are + lost and nothing else, exactly as before. + ### Known - Screen-space reflections can only reflect what is in the frame. A tree just diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index df2f311..471a688 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -295,15 +295,24 @@ local active = false -- 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. +-- nil where the driver will not make one -- every depth format is optional +-- in GLES and a canvas is the only honest test of any of them, so this asks +-- for several in order of preference: 24 bits, the same 24 riding a stencil +-- (a pairing some mobile drivers will texture when the bare format they +-- refuse), 32-bit float, and 16 as the floor every GLES3 device can read. +-- Refused all four, beginScene falls straight back to the internal buffer, +-- which is exactly the old behaviour minus the reflections. +local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" } + 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 + local c = nil + for _, format in ipairs(DEPTH_FORMATS) do + local ok, made = pcall(love.graphics.newCanvas, w, h, + { format = format, readable = true }) + if ok and made then c = made break end + end + if not 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") diff --git a/lib/Water.lua b/lib/Water.lua index 96f5368..77a32e6 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -345,7 +345,12 @@ Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the fra local SHADER_SRC = [[ varying float vShade; varying vec3 vSun; -varying vec3 vBent; // world position, as drawn +// World position, as drawn -- and a varying that cannot ride GLSL ES's +// mediump fragment default: everything below floors it into columns and +// marches it through the frame's matrices, and a route's coordinates run +// to a few thousand, where fp16 has no fraction left at all. The same +// reasoning the scene shader's vGrid states at length. +varying LOVE_HIGHP_OR_MEDIUMP vec3 vBent; #ifdef VERTEX uniform mat4 vp; @@ -368,6 +373,21 @@ vec4 position(mat4 transform_projection, vec4 vertex_position) { #endif #ifdef PIXEL +// Everything below works in WORLD units through the frame's own matrices, +// and GLSL ES defaults fragment floats to mediump -- fp16, out of fraction +// by a coordinate of two thousand and quantising a depth into steps the +// march falls straight through. Worse than wrong pictures: `vp` is +// declared by BOTH stages, the vertex side's default is highp, and GLSL ES +// refuses to LINK a uniform whose precision the two stages disagree on -- +// which is not broken water but NO water shader at all, the flat fallback +// with nothing in the log. One statement lifts the whole stage; the guard +// keeps the odd GPU without fragment highp compiling, and such a driver +// falls back to flat water exactly as it did before this pass existed. +#ifdef GL_ES +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#endif +#endif uniform mat4 vp; uniform vec3 eye; uniform vec2 screen; // the canvas, in pixels @@ -381,9 +401,12 @@ uniform float sunBias; uniform vec2 sunTexel; uniform vec3 dayTint; -// the frame as it stood before the water went down, and its depth +// the frame as it stood before the water went down, and its depth. The +// depth sampler is qualified because GLSL ES defaults samplers to LOWP no +// matter what floats are set to, and eight bits of depth is a march with +// nothing to land on. The frame copy is honest 8-bit colour and can stay. uniform Image reflectTex; -uniform Image depthTex; +uniform LOVE_HIGHP_OR_MEDIUMP Image depthTex; uniform float rays; // 0 = sky only, 1 = march the screen too uniform vec3 lookFlat; // the way the horizon lies from this camera diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index c9f0d0e..2f8bd76 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1778,6 +1778,25 @@ T.check(gridded:find("#define VOXEL_GRID", 1, true) ~= nil, T.check(plain:find("//@CRATERS", 1, true) == nil, "and the crater placeholder is gone by the time a driver sees the source") +-- ANDROID. GLSL ES defaults fragment floats to mediump and samplers to +-- lowp, and this shader is the one place in the mod where both defaults +-- are fatal: world coordinates run past fp16's fraction, the depth read +-- rounds to steps the march falls straight through, and -- the sharp edge +-- -- `vp` is declared by BOTH stages, whose defaults disagree, which GLSL +-- ES answers by refusing to LINK the shader at all. Flat lakes, empty log. +-- The sky's band ramp is this same lesson learned once already. +T.check(plain:find("precision highp float;", 1, true) ~= nil, + "the pixel stage lifts GLSL ES's mediump default to highp, so the march " + .. "keeps its fraction and the dual-declared vp links at one precision") +T.check(plain:find("GL_FRAGMENT_PRECISION_HIGH", 1, true) ~= nil, + "guarded, so the odd GPU without fragment highp still compiles and " + .. "falls back flat instead of failing loudly") +T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP vec3 vBent", 1, true) ~= nil, + "the world-position varying is qualified like the scene shader's vGrid " + .. "rather than left to the fragment default") +T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP Image depthTex", 1, true) ~= nil, + "and the depth sampler is lifted off lowp, which is eight bits of depth") + -- ------- the lift itself -- -- A pond in a field: four water cells recessed below flat ground. The diff --git a/tests/water_reflect_probe.lua b/tests/water_reflect_probe.lua index f02040f..4dcf36a 100644 --- a/tests/water_reflect_probe.lua +++ b/tests/water_reflect_probe.lua @@ -74,7 +74,8 @@ return function(game) -- hardware requirement the rest of the mode does not already have. say("depth canvas readable: %s", tostring(Voxel3D.depthReadable())) if not Voxel3D.depthReadable() then - say("STOP: no readable depth canvas on this driver (depth24/readable).") + say("STOP: no readable depth canvas on this driver (tried depth24, ") + say(" depth24stencil8, depth32f and depth16).") say(" The water falls back to the flat scene shader, which is") say(" exactly what it looked like before this feature existed.") return From 22b58e27a4d3f7cc24a67cfa4ee2a6739412cfd3 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 00:13:01 -0400 Subject: [PATCH 5/6] fix android water shading --- CHANGELOG.md | 60 ++++++++++++++++++++++------------- lib/Water.lua | 23 ++++++++++++-- tests/dramatic_shape_test.lua | 11 +++++++ 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3511684..952ec1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,30 +149,46 @@ ### Fixed -- **On Android the water stayed flat, as if the row were off.** Two GLSL ES - defaults the desktop never exercises, both in the water shader: +- **On Android the water stayed flat, as if the row were off -- and once it + did draw, it came up in blocks with the haze showing through the holes.** + Three separate faults, every one of them invisible on desktop GL, run down + on a Galaxy Z Fold 7 with the driver's own compiler errors in logcat: - Fragment floats default to **mediump** on GLSL ES, and the vertex stage's - default is highp -- and the water shader is the mod's first to declare the - same uniform, the frame's `vp` matrix, in BOTH stages, one on each default. - GLSL ES refuses to link a uniform whose precision the stages disagree on, - so the whole shader failed to build and the pass fell back -- quietly, by - design -- to the flat water the mode always drew. The pixel stage now lifts - its float default to highp (guarded, so a GPU without fragment highp still - compiles and falls back flat), which settles the link and is also simply - needed: the march works in world coordinates that run to a few thousand, - where fp16 has no fraction left, and the world-position varying is - qualified highp for the same reason the wireframe's has been all along. - Samplers default to **lowp** no matter what floats are set to, so the depth - read is lifted too -- eight bits of depth is a march with nothing to land - on. + **The shader would not build.** Fragment floats default to **mediump** on + GLSL ES while the vertex stage's default is highp, and the water shader is + the mod's first to declare the same uniform -- the frame's `vp` matrix -- + in BOTH stages, one on each default; GLSL ES refuses to link that, and the + pass fell back, quietly and by design, to the flat water the mode always + drew. The pixel stage now lifts its float default to highp (guarded, so a + GPU without fragment highp still compiles and falls back flat), which + settles the link and is also simply needed: the march works in world + coordinates that run to a few thousand, where fp16 has no fraction left. + The world-position varying is qualified highp for the same reason the + wireframe's always was, and the depth sampler too -- samplers default to + **lowp** whatever the floats are set to, and eight bits of depth is a + march with nothing to land on. One wrinkle inside the fix: LOVE's header + forward-declares `effect()` under ITS default, and Samsung's Xclipse + compiler treats a definition whose parameter precisions have drifted from + the prototype's as an illegal overload -- so effect()'s own float + parameters stay pinned to mediump, matching the declaration, and the + maths above them runs highp regardless. - And the readable depth canvas -- the one hardware requirement the rest of - the mode does not already have -- now tries four formats before giving up: - depth24, depth24 riding a stencil (a pairing some mobile drivers will - texture when they refuse the bare format), depth32f, and depth16 as the - floor every GLES3 device can read. Refused all four, the reflections are - lost and nothing else, exactly as before. + **The depth test read the wrong texels.** The shader's own depth test + normalised LOVE's pixel coordinate by the `screen` uniform, which counts + canvas UNITS -- and on a highdpi phone (Android's density here is 2.625) + a canvas holds that many PIXELS per unit, so the lookup ran to 2.6, + clamped, and read edge texels across two thirds of the frame. Water + discarded itself in blocks wherever the mis-read depth landed in front, + and the haze backdrop showed through the holes. The coordinate is now + normalised by `love_ScreenSize.xy` -- the bound canvas's own pixel size, + measured in the same units on every display. + + **And the readable depth canvas** -- the one hardware requirement the + rest of the mode does not already have -- now tries four formats before + giving up: depth24, depth24 riding a stencil (a pairing some mobile + drivers will texture when they refuse the bare format), depth32f, and + depth16 as the floor every GLES3 device can read. Refused all four, the + reflections are lost and nothing else, exactly as before. ### Known diff --git a/lib/Water.lua b/lib/Water.lua index 77a32e6..b7ce4f8 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -849,12 +849,31 @@ vec2 waveUV(vec2 tc, vec2 col) { return org + (mod(col, 8.0) + 0.5) * texel; } -vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { +// The float parameters are pinned to mediump BECAUSE the stage default is +// not: LOVE's own header forward-declares effect() under its default, and +// at least one mobile compiler (Samsung's Xclipse, in so many words) holds +// that a definition whose parameter precisions differ from its prototype's +// is a second function of the same name, and refuses the pair. The params +// can afford it -- the colour is a colour, and tc/sc arrived through +// LOVE's mediump plumbing whatever this signature says -- and the maths +// below runs on the stage default the moment the values touch a local. +vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { // THE DEPTH TEST, done here because the buffer that would have done it is // detached for the length of this pass so it can be READ (see the header). // Same comparison, same buffer, same result: a building in front of a pond // still hides it. - vec2 uv = sc / screen; + // + // Normalised by LOVE's own screen size, not by the `screen` uniform: `sc` + // arrives in canvas PIXELS, and on a highdpi surface (Android's density + // is routinely 2.625) a canvas holds that many pixels per canvas UNIT, + // which is what `screen` counts. Divided by units, uv runs to 2.6 and + // clamps, and the test reads edge texels for two thirds of the frame -- + // discarding water in blocks and letting the haze backdrop through, which + // on a phone looked like lakes with pieces missing. love_ScreenSize.xy is + // the bound canvas's own pixel size, the same units sc is measured in, on + // every display. (`screen` stays in units: skyPos reads it against cell + // and skyEdge, which are unit-measured with it.) + vec2 uv = sc / love_ScreenSize.xy; if (gl_FragCoord.z > Texel(depthTex, uv).r) discard; // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 2f8bd76..27930f3 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1796,6 +1796,17 @@ T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP vec3 vBent", 1, true) ~= nil, .. "rather than left to the fragment default") T.check(plain:find("LOVE_HIGHP_OR_MEDIUMP Image depthTex", 1, true) ~= nil, "and the depth sampler is lifted off lowp, which is eight bits of depth") +T.check(plain:find( + "effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc)", + 1, true) ~= nil, + "effect()'s own floats stay pinned to LOVE's prototype precision -- the " + .. "Xclipse compiler reads a definition that drifted from the forward " + .. "declaration as an illegal overload and refuses the whole shader") +T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil, + "the depth test normalises the pixel coord by the canvas's own pixel " + .. "size -- `screen` counts canvas UNITS, and on a highdpi phone the two " + .. "differ by the density, which clamped the lookup and cut the water " + .. "into blocks") -- ------- the lift itself -- From 980383bb92c42fa7410829344e7bebcb20f337fc Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 1 Aug 2026 00:28:54 -0400 Subject: [PATCH 6/6] update battle water --- CHANGELOG.md | 26 ++++++++++++ lib/BattleScene.lua | 33 ++++++--------- lib/VoxelScene.lua | 5 ++- lib/Water.lua | 80 ++++++++++++++++++++++++++++++----- tests/dramatic_shape_test.lua | 23 +++++++++- 5 files changed, 133 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 952ec1a..172bfd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -147,6 +147,32 @@ 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. +### Changed + +- **The waves arrive in sets now, and a little slower.** Three fixed trains + are an exactly periodic field -- every forty-odd pixels of sea wore the + same crest at the same height, which reads as wallpaper the moment a lake + is bigger than the repeat. Two long-wavelength fields now ride the + dominant train, four to five carrier wavelengths apiece so neither reads + as a wave itself: a SWELL that breathes its amplitude, so a few tall + crests march through and hand over to a lull that is itself moving, and a + BEND that bows its phase, so a crest line curves across the surface + instead of ruling itself over all of it. The two lesser trains stay + plain: they are texture rather than structure, and a third modulator is + the soup the train weights exist to avoid. The step beat comes down from + 15 to 12 a second -- the crests were hurrying, and a big wave is slower + than a walk cycle -- still a clean divisor of the engine's 60, and still + exactly one world pixel of dominant-crest travel per step. + +- **Staged battles draw their water plain, whatever the WATER row says.** + The reflective pass is tuned for the overworld's ladder of cameras; a + battle's camera is PLACED -- low, tilted, framed like a picture -- and + under it the pass read wrong: Fresnel opened all the way up, the leaned + sky landed on bands the framing never shows, and a lake-sized arena came + out as murk wearing the tile art. The battle is a stage set, and stage + water is painted: the flat animated tiles the mode always drew, with the + mons compositing over them like everything else on the set. + ### Fixed - **On Android the water stayed flat, as if the row were off -- and once it diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 2fc3cce..170caac 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -435,31 +435,22 @@ 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 + -- and the water over it -- PLAIN, always: the flat animated tiles, never + -- the reflective pass, whatever the WATER row says. The reflection is + -- tuned for the overworld's ladder of cameras; this shot's is PLACED -- + -- low, tilted and framed like a picture -- and under it the pass reads + -- wrong: Fresnel opens all the way up, the leaned sky lands on bands the + -- framing never shows, and a lake-sized arena comes out as murk wearing + -- the tile art. The battle is a stage set, and stage water is painted. + -- (No mirror also means the mons need no second draw into one -- they + -- just composite over the water below, like everything else on the set.) + if water then Voxel3D.draw(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) } + Voxel3D.draw(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/VoxelScene.lua b/lib/VoxelScene.lua index b9244fb..daa4131 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -580,8 +580,9 @@ end -- 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. +-- 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). function VoxelScene.drawWater(draws, cast) local plain = true if Water.enabled() and Voxel3D.depthReadable() then diff --git a/lib/Water.lua b/lib/Water.lua index b7ce4f8..9b7956c 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -237,14 +237,56 @@ Water.WAVE_TRAINS = { { -0.041, 0.033, 0.55, 0.11 }, } +-- ------- and what keeps them from reading as one pattern +-- +-- Three fixed trains are still an exactly periodic field: every forty-odd +-- pixels of sea wears the same crest at the same height, and a lake's worth +-- of that reads as wallpaper. Real swell varies two ways a sum of sines +-- cannot: waves arrive in SETS -- a few tall ones, then a lull -- and a +-- crest line curves as it runs rather than ruling itself across the whole +-- surface. Both are put back with one long-wavelength field each, riding +-- the DOMINANT train only; the two lesser trains stay plain, because they +-- are texture rather than structure and three modulators is soup again. +-- +-- Both wear the trains' own shape, { fx, fz, speed, x }: a direction whose +-- length is the spatial frequency, a phase rate, and what the field does. +-- Their wavelengths sit four to five times the carrier's, far enough apart +-- that neither reads as a wave itself -- the swell as slow weather over the +-- crests, the bend as the crests' own drift. +-- +-- THE SWELL scales the dominant train's amplitude; `x` is the DEPTH of the +-- deepest lull, as the fraction of the train it takes away. It runs roughly +-- along the carrier's own direction and slower than it, which is a wave +-- group's honest habit (deep-water groups travel at about half the phase +-- speed) -- so sets of crests swell up, march a while, and hand over to a +-- calm patch that is itself moving. +Water.WAVE_SWELL = { 0.0325, 0.0134, 0.55, 0.35 } + +-- THE BEND adds a slow wobble to the dominant train's phase; `x` is the +-- wobble's reach in RADIANS of carrier phase. 1.1 radians against a carrier +-- of about forty pixels bows a crest some seven pixels off its line over +-- the bend's own hundred-and-seventy-five -- a visible curve, not a +-- scribble -- and it runs ACROSS the carrier, which is the direction a +-- crest line actually wanders. What it costs is exactness in waveRate's +-- derivation: the carrier's local frequency now breathes around the number +-- the rate is derived from, so the one-pixel step is the average step +-- rather than every step's. The step CLOCK is untouched; only how far a +-- bowed stretch of crest moves on one tick varies, and by under a pixel. +Water.WAVE_BEND = { -0.0138, 0.0333, 0.35, 1.10 } + -- ------- and the beat they move on -- --- The surface does not slide, it advances in STEPS -- 15 a second, off the --- engine's own frame counter, which is the cadence hand-drawn pixel art is --- animated at and the reason this reads as art rather than as a simulation --- someone forgot to stylise. A surface built out of whole pixels that crawls --- between them smoothly gives away that the quantisation is only skin deep. -Water.WAVE_FPS = 15 +-- The surface does not slide, it advances in STEPS, off the engine's own +-- frame counter -- the move that makes this read as art rather than as a +-- simulation someone forgot to stylise. A surface built out of whole pixels +-- that crawls between them smoothly gives away that the quantisation is +-- only skin deep. +-- +-- 12 a second, a shade under the 15 hand-drawn pixel art is usually +-- animated at: the crests were hurrying, and a big wave is slower than a +-- sprite's walk cycle. Still a clean divisor of the engine's 60, so every +-- step spans the same whole number of frames. +Water.WAVE_FPS = 12 -- How far the dominant train advances each of those steps, in WORLD PIXELS. -- One is the honest choice for a stepped surface: the whole field shifts by @@ -984,12 +1026,30 @@ Water._craterSource = craterSource -- named for the suite -- and the wave trains, pasted in for the same reason: the rate is derived -- from this table (Water.waveRate), so the field the shader sums has to be --- the one that table describes rather than a copy of it kept in step by hand +-- the one that table describes rather than a copy of it kept in step by hand. +-- +-- The dominant train carries the swell and the bend (see WAVE_SWELL): its +-- phase wobbles by the bend field and its amplitude breathes with the swell +-- envelope, both off the same tables the constants above document. One +-- statement per train either way, which is what the suite counts. local function trainSource() local out = {} - for _, t in ipairs(Water.WAVE_TRAINS) do - out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" - .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + for i, t in ipairs(Water.WAVE_TRAINS) do + if i == 1 then + local s = Water.WAVE_SWELL + local b = Water.WAVE_BEND + out[#out + 1] = ( + " h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f\n" + .. " + %.4f * sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f))\n" + .. " * %.4f * (1.0 - %.4f * (0.5 + 0.5 *\n" + .. " sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)));") + :format(t[1], t[2], t[3], + b[4], b[1], b[2], b[3], + t[4], s[4], s[1], s[2], s[3]) + else + out[#out + 1] = (" h += sin(dot(q, vec2(%.4f, %.4f)) + waveT * %.4f)" + .. " * %.4f;"):format(t[1], t[2], t[3], t[4]) + end end return table.concat(out, "\n") end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 27930f3..6bdfb73 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1542,7 +1542,10 @@ local function at(f) end local period = 60 / Water.WAVE_FPS -T.eq(period, 4, "15 steps a second is one every four engine frames") +T.eq(period, 5, "12 steps a second is one every five engine frames") +T.eq(math.floor(period), period, + "and the beat divides the engine's 60 exactly, so every step spans the " + .. "same whole number of 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 " @@ -1575,6 +1578,24 @@ T.eq(select(2, trains:gsub("h %+= sin", "")), #Water.WAVE_TRAINS, T.check(trains:find(("%.4f"):format(t[1]), 1, true) ~= nil, "at the frequency the table states") +-- the variation that keeps three periodic trains from reading as wallpaper: +-- the dominant train's amplitude breathes with the swell and its crests bow +-- with the bend, both pasted from their own tables like the trains are +T.check(trains:find(("%.4f"):format(Water.WAVE_SWELL[1]), 1, true) ~= nil + and trains:find(("%.4f"):format(Water.WAVE_BEND[1]), 1, true) ~= nil, + "the swell and the bend reach the shader off the tables that document " + .. "them, not off copies kept in step by hand") +T.check(Water.WAVE_SWELL[4] > 0 and Water.WAVE_SWELL[4] < 1, + "the swell's deepest lull thins the dominant train without deleting or " + .. "inverting it -- a sea with sets in it, not a sea that turns off") +for _, mod in ipairs({ Water.WAVE_SWELL, Water.WAVE_BEND }) do + local mf = math.sqrt(mod[1] * mod[1] + mod[2] * mod[2]) + T.check(mf * 3.5 < freq, + "a modulator's wavelength sits several times the carrier's, far enough " + .. "apart that it reads as weather over the waves rather than as a " + .. "fourth wave -- which would be the soup the weights exist to avoid") +end + 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 "