DramaticShapeVoxelMod archival mirror · read-only
de54e4ea268b5e4dc0afb71bfbc279da27859b48

fix canopy issue

DramaticShape · 2026-08-08 · parent 0d245c4ccb

fix canopy issue

Changed files

lib/BattleScene.lua           |  9 ++++++++-
 lib/CatchThrow.lua            | 28 +++++++++++++++++++++++++---
 lib/Voxel3D.lua               | 15 ++++++++++++++-
 tests/dramatic_shape_test.lua | 38 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 85 insertions(+), 5 deletions(-)

Diff

diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index a8ffb60..8694046 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -579,7 +579,14 @@ function BattleScene.render(state, arena, textures, token)
   local cap = BattleScene.capture
   if cap and cap.rig then
     local okRig, c, p, fh = pcall(cap.rig, arena, groundY)
-    if okRig and c then cam, pitch, capFrameH = c, p or 0.15, fh end
+    -- The pitch is off STRAIGHT DOWN, like Voxel.angle and like the one
+    -- BattleCam.rig hands back -- the only thing downstream reads it is the
+    -- grass and flower pull below. A seat that declines to say stands in
+    -- for a near-LEVEL one rather than a top-down one, which is what every
+    -- staged seat actually is: the pull grows toward straight down, and a
+    -- default that guessed the wrong end of that would spend tens of world
+    -- pixels of bias on a camera standing two cells from its subject.
+    if okRig and c then cam, pitch, capFrameH = c, p or math.rad(80), fh end
   end
   if not cam then cam, pitch = BattleCam.rig(arena, groundY) end
   cam.fov = BattleScene.letterboxFov(cam.fov, ph, s)
diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua
index 777ec75..b001f15 100644
--- a/lib/CatchThrow.lua
+++ b/lib/CatchThrow.lua
@@ -332,9 +332,25 @@ local function captureRig(arena, groundY)
     focus = { ex, groundY + 8, ez },
     fov = SEAT_FOV,
   }
-  -- the pitch VoxelScene.pull wants: how far below level the seat looks
-  local pitch = math.atan2(SEAT_UP - 8, back + l)
-  return cam, math.max(pitch, 0.05), SEAT_FRAME
+  -- The pitch VoxelScene.pull wants, and it is the angle off STRAIGHT DOWN
+  -- -- the convention Voxel.angle keeps and BattleCam.rig hands back
+  -- (atan2(horizontal run, height over the focus)). This used to answer the
+  -- DEPRESSION below level instead, which is that angle's complement, and
+  -- the two are as far apart as a camera can be: a seat looking nearly
+  -- level read as 0.06 radians, which is what the pull formula means by
+  -- LOOKING STRAIGHT DOWN, so the grass and the flowers were pulled 46
+  -- world pixels camera-ward instead of 6.
+  --
+  -- 46 is the whole distance this seat stands behind the player. The pull
+  -- is a bias along each vertex's own eye ray, harmless while it is short
+  -- of the range -- and past it, it drags geometry THROUGH the lens, where
+  -- the projection turns inside out and one tuft of grass at the eye smears
+  -- across the frame. That was the greenery hanging over the top of a
+  -- capture shot, on a route or a city street with grass rows either side.
+  -- (Voxel3D's vertex stage now clamps the pull to half the range as well,
+  -- so no camera this close can be smeared by a bias again.)
+  local pitch = math.atan2(back + l, math.max(1e-3, SEAT_UP - 8))
+  return cam, pitch, SEAT_FRAME
 end
 
 -- The ring in GB space, centred on the CREATURE. The pinned mark is the
@@ -1063,6 +1079,12 @@ CatchThrow.pickBall = function()
   return owned[1]
 end
 
+-- The seat, named for the suite: it is a pure function of the arena and the
+-- floor height, so the framing and -- the reason it is reachable at all --
+-- the PITCH convention it hands BattleScene can both be asserted without a
+-- battle, a canvas or a game.
+CatchThrow._rig = captureRig
+
 -- ------- session lifecycle
 
 -- The capture table BattleScene consults: one shape, installed by the
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index a44e0c1..5b3d7f9 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -236,8 +236,21 @@ local SHADER = [[
     // (An earlier CPU version translated along the central view axis,
     // which preserved only the screen centre and made off-centre sprites
     // and grass swim against the ground while the camera scrolled.)
+    //
+    // NEVER PAST THE EYE, which is the one way this can stop being a pure
+    // depth bias: a vertex nearer the lens than `pull` is carried through
+    // it and out the other side, where the projection turns inside out and
+    // the thing lands wherever the far side of the frame happens to be --
+    // a single tuft of grass smeared across the whole picture. Impossible
+    // on an orbit rung, where the eye is a screen height away and the pull
+    // is tens of pixels; ordinary for a staged fight's seat, which stands
+    // a couple of cells from what it is looking at, and for a first-person
+    // eye standing in the grass. Half the range is the ceiling: at that
+    // distance nothing is losing a depth fight the other half would win.
     if (pull > 0.0) {
-      w.xyz += normalize(eye - w.xyz) * pull;
+      vec3 toEye = eye - w.xyz;
+      float range = length(toEye);
+      w.xyz += toEye / max(range, 1e-4) * min(pull, range * 0.5);
     }
     return vp * w;
   }
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index ede5de7..7a5a3f0 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -6709,6 +6709,44 @@ end)()
                                mon = { level = 100 } }, 1) >= 1,
     "a trivial catch still pays at least one point")
 
+  -- ------- the capture seat speaks the pull's own language
+  --
+  -- The seat hands BattleScene a pitch, and the only thing downstream reads
+  -- it is the camera-ward pull the grass and flowers are drawn with. That
+  -- angle is measured off STRAIGHT DOWN -- Voxel.angle's convention, and
+  -- BattleCam.rig's -- and this rig used to answer the DEPRESSION below
+  -- level instead, which is its complement. A seat looking nearly level
+  -- therefore read as "straight down", the end of the ladder where the pull
+  -- is at its longest: 46 world pixels of bias handed to a camera standing
+  -- 46 world pixels behind the player. The pull is a shove along each
+  -- vertex's own eye ray, so at that range it carried the grass at the eye
+  -- THROUGH the lens, and a tuft landed smeared across the top of the frame.
+  do
+    local CatchThrow = lib.require("CatchThrow")
+    local BattleCam = lib.require("BattleCam")
+    local VoxelScene = lib.require("VoxelScene")
+    -- two mons two cells apart, the arena laid down unturned
+    local arena = { player = { 100, 200 }, enemy = { 100, 168 },
+                    mid = { 100, 184 }, turn = 0 }
+    local seat, pitch = CatchThrow._rig(arena, 0)
+    T.check(pitch > math.rad(75),
+      ("the capture seat reads as NEARLY LEVEL (%.1f degrees off straight "
+       .. "down), which is what it is"):format(math.deg(pitch)))
+    local _, camPitch = BattleCam.rig(arena, 0, true)
+    T.check(camPitch > math.rad(45) and pitch > math.rad(45),
+      "in the same convention the battle's own rig hands back, so one pull "
+      .. "formula can serve both seats")
+    -- the invariant the bug broke: a bias along the eye ray must never
+    -- reach the eye, or the vertex comes out behind the lens
+    local dx, dz = seat.eye[1] - arena.player[1], seat.eye[3] - arena.player[2]
+    local range = math.sqrt(dx * dx + dz * dz)
+    T.check(VoxelScene.pull(math.max(pitch, 0.05)) < range * 0.5,
+      ("the grass pull (%.1f px) stays well inside the seat's own range to "
+       .. "the player's cell (%.1f px) -- the tufts it is biasing are the "
+       .. "ones standing right there")
+        :format(VoxelScene.pull(math.max(pitch, 0.05)), range))
+  end
+
   -- and the same formula pays TRAINER knockouts under FULL, with the
   -- wild/trainer 1.5 that a catch never sees (a caught Pokemon is always
   -- wild, which is why the catch numbers above are untouched by it)