From 18857e039abc8e069d5bd96b69067404dcef9aa7 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Wed, 29 Jul 2026 21:51:42 -0400 Subject: [PATCH] fixed disconnected shadows --- CHANGELOG.md | 12 ++++++++++++ lib/BattleScene.lua | 10 +++++++--- lib/ShadowMap.lua | 28 ++++++++++++++++++++++++++++ lib/VoxelScene.lua | 19 ++++++++++++------- tests/dramatic_shape_test.lua | 22 ++++++++++++++++++++++ 5 files changed, 81 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7898802..1654426 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,18 @@ Pokemon go solid white for those frames, silhouette and all, and nothing else in the frame moves. +- **A standing figure's shadow detached from its feet under a low sun.** The + shadow compare forgives `slack` world pixels so lit ground does not acne + against its own texels, and that same forgiveness lit the first `slack` of + every cast shadow -- so the shadow started a bias-width away from the feet, + further the lower the sun reached (the classic peter-panning, invisible at + the old fixed 45 degrees and plain at a day/night golden hour). Sprite + cards -- characters, authored figures, flowers, battle mons -- are now + drawn into the shadow map sunk exactly `slack` down the sun ray + (`ShadowMap.sink`), which cancels the bias for the shadow they throw and + nothing else: no terrain moved, so the acne margin is untouched, and the + shadow root lands back under the feet at every hour. + ### Changed - **The two HP boxes snap to the window's edges during a staged battle.** The diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 5acc21b..ee85fde 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -237,17 +237,21 @@ 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 - ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), nil) + -- thin cards are sunk along the ray (ShadowMap.sink) so their shadows + -- keep contact with their bases instead of starting a bias-width away + ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), + ShadowMap.sink(nil)) for _, nb in ipairs(neighbors) do ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + ShadowMap.sink(Mat4.translate(nb.ox, 0, nb.oy))) end -- 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. for _, card in ipairs(cards or {}) do - ShadowMap.draw(BattleBillboard.mesh(), card.tex, card.model) + ShadowMap.draw(BattleBillboard.mesh(), card.tex, + ShadowMap.sink(card.model)) end ShadowMap.finish(sig) diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index b18188d..d7fea7c 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -362,6 +362,34 @@ local function fit(cx, cy, vw, vh) ShadowMap.bias = ShadowMap.slack / math.max(1, far - near) end +-- A CASTER sunk slightly down the sun ray before it is drawn into the map. +-- +-- The depth compare forgives `slack` world pixels (BIAS + the SLOPE term) +-- so lit surfaces do not acne against their own texels -- but that same +-- forgiveness is what lets the ground right next to a standing figure read +-- as lit: the first `slack` of its shadow falls inside the compare's +-- tolerance, so on screen the shadow starts that far from the feet, and the +-- gap grows as the sun drops (the same bias reaches further horizontally +-- the more oblique the ray). Peter-panning, in the literature; it went +-- unseen while the sun hung at a fixed 45 degrees and shows plainly at a +-- day/night golden hour. +-- +-- Storing the card that much DEEPER along the ray cancels the bias for the +-- shadow it throws -- the root lands back under the feet -- while touching +-- nothing else: the card's own lookup now reads a deeper stored value and +-- is more confidently lit, no terrain moved, and the acne margin the slack +-- exists for is intact. For sprite cards and other thin stand-ins only; +-- sinking the TERRAIN would cancel the bias everywhere, which is the acne. +-- +-- Valid between begin() and finish(), which is when `slack` matches the +-- frustum the map is being drawn with. +function ShadowMap.sink(model) + local f = sunDir() + local s = ShadowMap.slack + return Mat4.mul(Mat4.translate(f[1] * s, f[2] * s, f[3] * s), + model or IDENTITY) +end + -- Whether the map has to be redrawn for `sig` -- a caller-built stamp of -- everything the pass depends on (camera, terrain meshes, every pose). A -- frame that changes none of it reuses the map it already has, which is diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index b0178d7..a6898b4 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -520,20 +520,24 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, 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 - ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil) + -- handful of cutouts per meadow, unlike the grass left out below. + -- Every thin card from here down is SUNK slightly along the ray + -- (ShadowMap.sink) so its shadow keeps contact with its feet instead of + -- starting a bias-width away. + ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), + ShadowMap.sink(nil)) for _, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + ShadowMap.sink(Mat4.translate(nb.ox, 0, nb.oy))) end -- authored figures cast too, for the same reason the flowers do: a -- handful of cards per map, and a person with no shadow reads as pasted on eachFigure(state.map, 0, 0, function(mesh, _, caster) - ShadowMap.draw(mesh, atlasFor(state.map), caster) + ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.sink(caster)) end) for _, nb in ipairs(state.neighbors or {}) do eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster) - ShadowMap.draw(mesh, atlasFor(nb.map), caster) + ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.sink(caster)) end) end for _, p in ipairs(posed) do @@ -542,8 +546,9 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, local mesh = SpriteBillboards.shadowQuad(def, frame) if mesh then ShadowMap.draw(mesh, p.sprite:resolveImage(), - Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0), - mirror)) + ShadowMap.sink( + Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0), + mirror))) end end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 2b59176..3890873 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -2106,6 +2106,28 @@ Voxel3D.tint = { 1, 1, 1 } Voxel3D.vp = nil end +-- ------- a shadow keeps hold of the feet that throw it +-- +-- The depth compare forgives `slack` world pixels so lit ground does not +-- acne, and that forgiveness detaches a standing card's shadow from its +-- feet by the same amount -- worse the lower the sun. Cards are drawn into +-- the map sunk exactly `slack` down the ray, which cancels the bias for the +-- shadow they throw and nothing else. +do +local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") +local Mat4 = run.loader.exports.DRAMATIC_SHAPE.lib.require("Mat4") +local dir = ShadowMap.sunDir() +local m = ShadowMap.sink(nil) +T.check(math.abs(m[4] - dir[1] * ShadowMap.slack) < 1e-9 + and math.abs(m[8] - dir[2] * ShadowMap.slack) < 1e-9 + and math.abs(m[12] - dir[3] * ShadowMap.slack) < 1e-9, + "sink() moves a caster exactly `slack` down the sun ray") +local sunk = ShadowMap.sink(Mat4.translate(10, 0, 6)) +T.check(math.abs(sunk[4] - (10 + dir[1] * ShadowMap.slack)) < 1e-9 + and math.abs(sunk[12] - (6 + dir[3] * ShadowMap.slack)) < 1e-9, + "and composes over the caster's own transform, not instead of it") +end + Pipelines.reset() run.release()