water updates
DramaticShape · 2026-07-31 ·
parent 9a9441899a
water updates
Changed files
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(-)
Diff
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