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)