fixed disconnected shadows
DramaticShape · 2026-07-29 ·
parent f841378330
fixed disconnected shadows
Changed files
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
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()