first pass at water
DramaticShape · 2026-07-31 ·
parent 7f76caa5f6
first pass at water
Changed files
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
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