DramaticShapeVoxelMod archival mirror · read-only
18857e039abc8e069d5bd96b69067404dcef9aa7

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()