From 180dd393eaffbb2a9e6d8d7fb559c7e7886f6188 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Fri, 7 Aug 2026 20:52:43 -0400 Subject: [PATCH] add render distance options --- CHANGELOG.md | 35 ++++ README.md | 1 + lib/Diorama.lua | 5 + lib/ForestAtmos.lua | 50 +++--- lib/ViewBox.lua | 321 ++++++++++++++++++++++++++++++++++ lib/Voxel3D.lua | 32 ++-- lib/VoxelScene.lua | 94 +++++++--- lib/Water.lua | 28 +-- main.lua | 20 +++ tests/dramatic_shape_test.lua | 253 +++++++++++++++++++++++++-- 10 files changed, 765 insertions(+), 74 deletions(-) create mode 100644 lib/ViewBox.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c1462..a2d852f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,41 @@ shader as its own variant, so a flat frame -- and a phone above all -- builds and binds exactly what it always did. +- **RENDER DIST: stop drawing the map you cannot see.** The orbit rungs now + cut the world to the ground the camera actually frames, so a connected + map that falls entirely outside it is skipped before it is drawn -- + terrain, water, grass, flowers and its whole shadow pass. At the high + rungs, where the camera is nearly overhead, that is most of the frame's + geometry never submitted. + + **The footprint is not the window.** Tilt the camera and the ground it + frames stops being the flat game's own rectangle and becomes a + trapezoid: reaching much further north, flaring much wider out there, + and pulling in at the near edge. It is derived from the orbit's own + basis rather than guessed -- the frame's corner rays dropped on the + ground plane, in closed form, cross-checked against a ray cast in the + suite -- so the cut can never take a bite out of the picture. The stored + rectangle sits north of the view centre, because the trapezoid does. + + **The row is a real render distance at 75.** Past about 63 degrees + (exactly `atan(2*FOCAL)`) the horizon is inside the frame and "all the + ground on screen" is an infinite answer, so something has to name a + distance. FIT is the closest of the four, WIDE through WIDEST push the + world's edge out, OFF stops cutting. Below that pitch the honest + footprint is already inside the reach and the row does nothing to the + picture at all. + + The cut reaches the shader as the same box the headset's DIORAMA uses -- + rectangular now, with two half-extents, and the diorama passes the same + number twice. Under V-CURVE the rim dissolves rather than cutting, + because a bent world has no straight sides. The sun and the eye ask the + same question about the same maps, so the light can never record a map + the camera did not draw. + + Not on 1ST or 3RD -- the player is standing in the world there -- and + the box opens out and away over the rung tween rather than vanishing on + the frame the rung changed. FULL sets it to FIT. + ## 1.6.2 ### Added diff --git a/README.md b/README.md index 1838040..6ff6991 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ menu. | `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel | | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 → 4 → 5 — bend the world over the horizon; 5 is a half sphere | +| the **RENDER DIST** options row | FIT / WIDE / WIDER / WIDEST / OFF — how much of the map the camera bothers to draw. **FIT** is exactly the ground on screen and no more: the trapezoid a tilted camera really frames, which reaches well north of you and flares wide out there — not the square the flat game shows. A connected map falling entirely outside it is skipped before it is drawn, terrain, water, grass and shadows together, which is most of the frame's geometry at the high rungs. Below about 63° that is all the row does and the picture is untouched; at **75** the camera sees to the horizon, so something has to name a distance — FIT is the closest, the wider rungs push the world's edge out, **OFF** stops cutting. Not on **1ST** or **3RD**: you are standing in the world there, and the box opens out and away as the camera dives in. **FULL** sets it to FIT | | `8`, or the **3D-BTL** options row | 2D-3D A / 2D-3D B / STADIUM A / STADIUM B / OFF — fight in 3D instead of on a white field. **A** stages it on the map, **B** on two discs against the sky; **2D-3D** uses the game's own battle pics and **STADIUM** the Pokémon Stadium battle models | | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | diff --git a/lib/Diorama.lua b/lib/Diorama.lua index 7df22b4..21a73e1 100644 --- a/lib/Diorama.lua +++ b/lib/Diorama.lua @@ -207,6 +207,11 @@ end local function volume(kind, x, y, z, r, fade) return { x = x, y = y, z = z, r = r, + -- The BOX kind is rectangular in the shader, because the flat + -- screen's box is the WINDOW's own footprint and a window is not + -- square (lib/ViewBox). A headset's model has no window to be + -- shaped like, so this one is: the same half-size twice. + rx = r, rz = r, -- a zero band is a hard edge: half a pixel of ramp, which is -- one pixel of antialiasing rather than a stair invFade = 1 / math.max(fade or 0, 0.5), kind = kind } diff --git a/lib/ForestAtmos.lua b/lib/ForestAtmos.lua index ee2ebc0..884f671 100644 --- a/lib/ForestAtmos.lua +++ b/lib/ForestAtmos.lua @@ -51,10 +51,11 @@ local floor, sqrt, min, max = math.floor, math.sqrt, math.min, math.max local ForestAtmos = {} --- The diorama's viewport, as both shaders here take it (see Voxel3D.cull: --- the field is set for a headset's diorama frame and nil for every other, --- where kind 0 means "no cut"). Read through V.require rather than held as --- an upvalue because this file loads before Voxel3D on some paths. +-- The viewport, as both shaders here take it (see Voxel3D.cull: the field +-- is set for a headset's diorama frame and for an orbit rung's window box, +-- and nil for every other, where kind 0 means "no cut"). Read through +-- V.require rather than held as an upvalue because this file loads before +-- Voxel3D on some paths. local function cullAt() local c = V.require("Voxel3D").cull return c and { c.x, c.y, c.z } or { 0, 0, 0 } @@ -65,6 +66,11 @@ local function cullShape() return c and { c.r, c.invFade, c.kind } or { 0, 0, 0 } end +local function cullRect() + local c = V.require("Voxel3D").cull + return c and { c.rx or c.r, c.rz or c.r } or { 0, 0 } +end + -- FULL is the point; LOW halves the march and drops the particles, for -- hardware that minds a per-pixel loop under 4X supersampling. -- @@ -430,24 +436,25 @@ local RAY_SHADER = [[ uniform vec3 sunward; // unit, toward the unseen sun uniform vec2 wind; // leaf-field drift, uv per second uniform float time; - // the diorama's viewport, as the scene shader takes it (see Voxel3D): - // air outside the model is not air, so a sample out there contributes - // nothing and the beams end with the world they fall through + // the viewport, as the scene shader takes it (see Voxel3D): air outside + // the model is not air, so a sample out there contributes nothing and + // the beams end with the world they fall through uniform vec3 cullAt; uniform vec3 cullShape; + uniform vec2 cullRect; // the box's half-extents in x and z float dioramaCull(vec3 p) { if (cullShape.z <= 0.5) return 1.0; vec3 cd = p - cullAt; - float d; - if (cullShape.z < 1.5) { - d = max(abs(cd.x), abs(cd.z)); // the box + float inside; + if (cullShape.z < 1.5) { // the box + inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z)); } else if (cullShape.z < 2.5) { - d = length(cd); // the ball + inside = cullShape.x - length(cd); // the ball } else { - d = length(cd.xz); // the fight's pillar + inside = cullShape.x - length(cd.xz); // the fight's pillar } - return clamp((cullShape.x - d) * cullShape.y, 0.0, 1.0); + return clamp(inside * cullShape.y, 0.0, 1.0); } float sunDepth(vec2 uv) { @@ -621,8 +628,9 @@ local PART_SHADER = [[ #ifdef VERTEX uniform mat4 vp; uniform vec3 curve; - uniform vec3 cullAt; // the diorama's viewport (see Voxel3D.cull): - uniform vec3 cullShape; // a mote outside the model is not in the air + uniform vec3 cullAt; // the viewport (see Voxel3D.cull): a mote + uniform vec3 cullShape; // outside the model is not in the air + uniform vec2 cullRect; // the box's half-extents in x and z uniform vec3 axisR; // the camera's right, world space uniform vec3 axisU; // and its up: the billboard's own frame uniform float time; @@ -646,15 +654,15 @@ local PART_SHADER = [[ // rather than having to cut one in half if (cullShape.z > 0.5) { vec3 cd = base - cullAt; - float cdd; + float inside; if (cullShape.z < 1.5) { - cdd = max(abs(cd.x), abs(cd.z)); + inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z)); } else if (cullShape.z < 2.5) { - cdd = length(cd); + inside = cullShape.x - length(cd); } else { - cdd = length(cd.xz); + inside = cullShape.x - length(cd.xz); } - vGlow *= clamp((cullShape.x - cdd) * cullShape.y, 0.0, 1.0); + vGlow *= clamp(inside * cullShape.y, 0.0, 1.0); } vCorner = AtmosData.xy; vec4 w = vec4(base + axisR * (AtmosData.x * size) @@ -815,6 +823,7 @@ function ForestAtmos.draw(map) Voxel3D.curveK or 0 }) pcall(sh.send, sh, "cullAt", cullAt()) pcall(sh.send, sh, "cullShape", cullShape()) + pcall(sh.send, sh, "cullRect", cullRect()) pcall(sh.send, sh, "screen", { w, h }) pcall(sh.send, sh, "fogW", { f.fog.density, f.fog.heightK, @@ -847,6 +856,7 @@ function ForestAtmos.draw(map) Voxel3D.curveK or 0 }) pcall(psh.send, psh, "cullAt", cullAt()) pcall(psh.send, psh, "cullShape", cullShape()) + pcall(psh.send, psh, "cullRect", cullRect()) pcall(psh.send, psh, "axisR", axisR) pcall(psh.send, psh, "axisU", axisU) pcall(psh.send, psh, "time", ForestAtmos.time) diff --git a/lib/ViewBox.lua b/lib/ViewBox.lua new file mode 100644 index 0000000..f745071 --- /dev/null +++ b/lib/ViewBox.lua @@ -0,0 +1,321 @@ +-- RENDER DIST: how much of the map the orbit rungs bother to draw. +-- +-- lib/Diorama cuts a headset's model out of the world with a box the +-- player opens and closes with their hands. This is the same cut on the +-- flat screen, asked the other way round: not "how much world do I want to +-- be holding" but "how much world can this camera actually SEE" -- so that +-- nothing off screen is drawn, and nothing on screen is missing. +-- +-- THE FOOTPRINT IS NOT THE WINDOW. That is the whole difficulty, and the +-- first cut of this file got it wrong: it took the flat game's own vw-by-vh +-- rectangle about the view centre, which is exactly right at 0 degrees and +-- wrong at every rung the mode actually has. Tilt the camera and the ground +-- it frames stops being that rectangle and becomes a TRAPEZOID -- reaching +-- much further north (the far edge of the frame is further away, so it +-- covers more ground per pixel), flaring much wider there for the same +-- reason, and pulling IN at the south edge, which is nearer the eye than +-- the focus is. A window-sized box cuts the north field and both far +-- corners off a world that is plainly on screen: gaps at the top and down +-- the sides, with sky showing through them. +-- +-- So the footprint is derived from the camera rather than guessed. The +-- orbit is one number (see Voxel3D.viewProjection): eye at distance +-- FOCAL*vh, pitched `a` off straight down, looking at the view centre, +-- with a fov chosen so a straight-down camera frames exactly vh. Cast the +-- frame's own corner rays at the ground plane and the trapezoid falls out +-- in closed form -- see footprint() for the derivation, which is three +-- lines of algebra and no tuning at all. +-- +-- The CUT is still a rectangle (the shader's box kind), so what is stored +-- is the trapezoid's bounding rect: never narrower than the picture, so it +-- can never take a bite out of it. It is off-centre in z, because the +-- trapezoid is -- the box sits north of the view centre at every rung but +-- the top one. +-- +-- THE HORIZON IS WHY THERE IS A ROW AT ALL. Past about 63 degrees (exactly +-- atan(2*FOCAL), where the top of the frame lifts off the ground plane) the +-- trapezoid stops being finite: the camera can see to the horizon, and "all +-- the ground on screen" is an infinite answer. Something has to name a +-- distance, and that is what RENDER DIST names -- MAX_REACH view heights, +-- times the row's own multiplier. Below that pitch the row does nothing to +-- the picture at all, because the honest footprint is already smaller than +-- the reach; at 75 it is what decides where the world ends. +-- +-- AND IT PAYS FOR ITSELF. A cut this file can describe in world pixels is +-- one VoxelScene can test a whole neighbour map against BEFORE drawing it +-- -- see shows() -- so a connected map that lands entirely outside the box +-- costs no terrain mesh, no water, no grass, no flowers and no shadow +-- pass. Most of the win is at the HIGH rungs, where the camera is nearly +-- overhead and the footprint is barely bigger than the window; at 75 it +-- sees half the region and skips almost nothing, which is the truth about +-- that rung rather than a shortcoming of the test. +-- +-- WHAT IT DOES NOT TOUCH. The free-roam rungs (1ST and 3RD): the player is +-- standing IN the world there, and a box around a walking eye is a +-- fog-of-war circle rather than a model on a table. The rung tween into +-- them opens the box out with the blend rather than dropping it on a +-- frame, so diving into a head does not pop the sides away. A headset's +-- frame is not touched either -- lib/Diorama owns the cut there, and VR's +-- STANDARD rungs are a tabletop already. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") + +local ViewBox = {} + +ViewBox.KEY = "viewbox" +-- RENDER DIST rather than a V- name like the rows either side of it: what +-- the player is choosing is HOW MUCH WORLD gets drawn, which is the thing +-- every game with this row calls a render distance. The mode read -- the +-- slab with sides -- is what that buys, not what the row is asking. +ViewBox.LABEL = "RENDER DIST" + +-- The ladder, as a multiplier on the footprint the camera actually frames. +-- Rung 0 is FIT and it is the default, and FIT means exactly that: the +-- ground on screen and no more. It cannot open a gap -- 1.0 times the +-- honest answer is the honest answer -- so the only thing the wider rungs +-- buy below the horizon pitch is margin around a cut nobody can see. +-- +-- Where they DO decide the picture is at 75, and at the tween rungs either +-- side of it, where the footprint is infinite and MAX_REACH below stands in +-- for it: there the ladder is a real render distance and FIT is the closest +-- horizon of the four. +-- +-- Geometric rather than even, for the reason WorldCurve's ladder is: what +-- the player sees change between two rungs is the AREA inside the box, and +-- that goes as the square -- even steps bunch the whole ladder at the +-- near end. +-- +-- The last rung is 0, which is no cut at all. It sits at the TOP because +-- "everything" is where the ladder is going: FIT, wider, wider, wider, +-- all of it. +ViewBox.FRACS = { 1.0, 1.5, 2.25, 3.5, 0 } + +ViewBox.setting = ModSetting.new(ViewBox.KEY, ViewBox.LABEL, + { 0, 1, 2, 3, 4 }, + { "FIT", "WIDE", "WIDER", "WIDEST", "OFF" }) + +-- How far the world may reach when the camera can see the HORIZON and the +-- honest footprint is infinite, in view heights. Generous on purpose: this +-- is a backstop for an unbounded answer, not a curtain to draw across the +-- middle distance, and it wants to land well past the edge of the loaded +-- neighbourhood so the world runs out before the cut does. Multiplied by +-- the row, so a player who can see the seam can push it away. +ViewBox.MAX_REACH = 6 + +-- The rim under V-CURVE, as a fraction of the shorter half-extent, and for +-- the reason Diorama.FADE_FRAC exists: a bent world has no straight sides, +-- so a hard edge across one is a lie about what is being looked at. Flat, +-- the box keeps its hard edge -- that IS the sides. +ViewBox.FADE_FRAC = 0.16 + +-- How far outside the box a map may still have geometry inside it: the +-- border ring ChunkMesher meshes around a body (RING = 3 blocks of 32 +-- world pixels), which is the one thing a map draws beyond its own +-- rectangle. A neighbour kept by this margin that turns out to be entirely +-- outside is drawn and then cut per fragment, which is what would have +-- happened without the test -- the margin can only cost a draw, never a +-- hole. +ViewBox.PAD = 96 + +function ViewBox.level() + return ViewBox.setting:get() or 0 +end + +-- The multiplier in force, or nil for OFF -- which is also every caller's +-- "there is no cut this frame" answer. +function ViewBox.frac() + local f = ViewBox.FRACS[ViewBox.level() + 1] + if not f or f <= 0 then return nil end + return f +end + +-- Whether this rung is one the box is about: an ORBIT rung, which is every +-- level the mode has except OFF (level 0, where there is no 3D pass to cut) +-- and the two free-roam rungs (see the header). +function ViewBox.appliesTo(level) + local ok, applies = pcall(function() + local Voxel = V.require("VoxelState") + local l = level or Voxel.level or 0 + return l > 0 and not Voxel.isFreeCam(l) + end) + return ok and applies or false +end + +-- ------- what the live frame is +-- +-- Set by VoxelScene for the length of one flat frame and cleared with it, +-- exactly as Diorama's is for a headset's. Nothing else writes it, and +-- every reader -- the shader uniforms, the neighbour skip -- hangs off this +-- one field being nil or not. +ViewBox.cull = nil -- { x, y, z, r, rx, rz, invFade, kind } + +local function curved() + local ok, on = pcall(function() + return V.require("WorldCurve").active() + end) + return ok and on or false +end + +-- How far out of the orbit and into a walking head the rung tween has got, +-- 0..1. The box opens out by one over what is LEFT of the orbit, so it has +-- grown past every edge of the frame by the time the eye arrives in the +-- head and the cut is dropped -- rather than the sides vanishing on the +-- frame the rung number changed, which is a pop in the middle of a move. +local function orbitLeft() + local ok, blend = pcall(function() + return V.require("FirstPerson").blendEased() + end) + if not ok or type(blend) ~= "number" then return 1 end + return 1 - math.max(0, math.min(1, blend)) +end + +-- ------- the ground this camera frames +-- +-- The orbit (Voxel3D.viewProjection's else branch) is: eye at distance +-- k = FOCAL*vh, pitched `a` off straight down and due south of the focus; +-- focus on the ground at the view centre; a symmetric frustum whose +-- half-tangents are tanY = 1/(2*FOCAL) vertically and tanY*(vw/vh) +-- horizontally. Screen coordinates run sx, sy in [-1, 1] with sy = +1 the +-- TOP of the frame, which is north. +-- +-- The ray through a screen point is forward + right*sx*tanX + up*sy*tanY, +-- and with the orbit's basis (right = +x, forward = (0, -cos a, -sin a), +-- up = (0, sin a, -cos a)) that comes out as +-- +-- d = ( sx*tanX, -cos a + sy*tanY*sin a, -sin a - sy*tanY*cos a ) +-- +-- Drop it to the ground plane from an eye at height k*cos a and the whole +-- trapezoid collapses to ONE denominator, +-- +-- D(sy) = cos a - sy*tanY*sin a +-- +-- with (the sin^2 + cos^2 cancels most of the algebra away): +-- +-- north of centre : (vh/2) * sy / D(sy) +-- half-width : (vw/2) * cos a / D(sy) +-- +-- because k*tanY is exactly vh/2 and tanX*k is exactly vw/2, whatever FOCAL +-- is. At a = 0 both reduce to vh/2 and vw/2 -- the flat window, which is +-- the case the first cut of this file mistook for all of them. +-- +-- D shrinks as sy climbs, so BOTH grow toward the top of the frame, and +-- both blow up where D reaches zero: sy* = cot(a)/tanY, the row the horizon +-- sits on. Past 63 degrees that row is inside the frame and the answer is +-- infinite -- which is what `reach` is for. +-- +-- Returns three DISTANCES from the view centre, all positive: how far the +-- picture runs north, how far south, and how far to each side. +function ViewBox.footprint(a, vw, vh, reach) + local Voxel = V.require("VoxelState") + local halfW, halfH = (vw or 320) * 0.5, (vh or 288) * 0.5 + local tanY = 1 / (2 * (Voxel.FOCAL or 1)) + -- the orbit never reaches level (75 degrees is the last rung) but a tween + -- reads a live angle, and a cos of zero is a horizon through the middle + -- of the frame rather than a number + local ca = math.max(math.cos(a or 0), 1e-3) + local sa = math.max(math.sin(a or 0), 0) + -- The screen row the far edge is taken at: the TOP of the frame, or the + -- row whose ray lands `reach` out, whichever comes first. Inverting the + -- north formula for sy gives + -- + -- sy = reach*cos a / (vh/2 + reach*tanY*sin a) + -- + -- which is always strictly below the horizon row (it approaches it from + -- underneath as reach grows), so D below is always positive -- with the + -- horizon in frame this never even reaches 1 and the clamp is inert. + local sy = reach * ca / (halfH + reach * tanY * sa) + if sy > 1 then sy = 1 end + local D = ca - sy * tanY * sa + if D < 1e-3 then D = 1e-3 end + return halfH * sy / D, -- north + halfH / (ca + tanY * sa), -- south: the sy = -1 row + halfW * ca / D -- and the widest row is the far one +end + +-- Open the frame's cut: the bounding rectangle of the ground this camera +-- frames, times the row's multiplier. Returns the cut, or nil when this +-- frame has none -- which is the row at OFF, a rung the box is not about, +-- and a camera that has finished its dive into a head. +function ViewBox.frame(cx, cy, vw, vh, level) + ViewBox.cull = nil + local frac = ViewBox.frac() + if not (frac and ViewBox.appliesTo(level)) then return nil end + local left = orbitLeft() + if left <= 0.001 then return nil end + frac = frac / left + local Voxel = V.require("VoxelState") + local north, south, side = ViewBox.footprint( + Voxel.angle or 0, vw, vh, ViewBox.MAX_REACH * (vh or 288)) + north, south, side = north * frac, south * frac, side * frac + -- The rectangle around it. Off-centre in z, because the trapezoid is: + -- the camera looks NORTH from south of its focus, so there is far more + -- picture ahead of the view centre than behind it -- at 35 degrees + -- roughly twice as much, at 75 the whole frame. + -- + -- The same floor Diorama.radius keeps: a box smaller than a couple of + -- tiles is not a viewport, it is a hole the player is standing in. + local rx = math.max(24, side) + local rz = math.max(24, (north + south) * 0.5) + local bent = curved() + local fade = bent and math.max(1, math.min(rx, rz) * ViewBox.FADE_FRAC) or 0 + ViewBox.cull = { + x = cx, y = 0, z = cy - (north - south) * 0.5, + -- `r` is what the ball and the pillar kinds are sized by and the box + -- is not; carried so the cut table has one shape whoever made it + r = math.max(rx, rz), rx = rx, rz = rz, + -- a zero band is a hard edge: half a pixel of ramp, which is one pixel + -- of antialiasing rather than a stair (Diorama says the same) + invFade = 1 / math.max(fade, 0.5), + kind = V.require("Diorama").BOX, + } + return ViewBox.cull +end + +function ViewBox.stop() + ViewBox.cull = nil +end + +-- ------- the coarse half of the cut +-- +-- Whether anything inside the world-pixel rectangle (x0, z0)-(x1, z1) can +-- be inside this frame's box. True whenever there is no box, so a caller +-- may guard every draw with it unconditionally. +function ViewBox.shows(x0, z0, x1, z1) + local c = ViewBox.cull + if not c then return true end + local pad = ViewBox.PAD + return x0 - pad <= c.x + c.rx and x1 + pad >= c.x - c.rx + and z0 - pad <= c.z + c.rz and z1 + pad >= c.z - c.rz +end + +-- The same question about a connected neighbour, in the shape VoxelScene +-- keeps them: { map, ox, oy } with the offset in world pixels and the map's +-- own size in blocks of 32 (the shape prefetch's masks are built from). +function ViewBox.showsMap(nb) + if not (nb and nb.map and nb.map.def) then return true end + return ViewBox.shows(nb.ox or 0, nb.oy or 0, + (nb.ox or 0) + (nb.map.def.width or 0) * 32, + (nb.oy or 0) + (nb.map.def.height or 0) * 32) +end + +-- What the shadow pass has to notice: WHICH neighbours it drew is now a +-- function of the row, and the row is the one input to that the sun's own +-- signature does not already carry (the centre, the view size and the +-- rung are all in it). Widening the box brings a neighbour back into the +-- light's frustum, and a map recorded without it must be redrawn. +function ViewBox.signature() + return ViewBox.frac() or 0 +end + +function ViewBox.row() + return ViewBox.setting:row() +end + +function ViewBox.sync(value) + ViewBox.setting:sync(value) +end + +return ViewBox diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index d7dcefc..b3ddb13 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -176,26 +176,35 @@ local SHADER = [[ uniform vec3 cullAt; // the viewport's centre, in world pixels uniform vec3 cullShape; // half-size, 1/fade, kind: 1 box, 2 ball, // 3 the staged fight's pillar + uniform vec2 cullRect; // the BOX's half-extents in x and z, which + // the round kinds have no use for. Two + // numbers because the flat screen's box is + // the WINDOW's own footprint and a window is + // not square (lib/ViewBox); a headset's is, + // and lib/Diorama sends the same half-size + // twice. // 1 well inside the viewport, 0 outside it, and the rim in between -- // which is a HARD edge for the box (its band is half a pixel wide, so // the ramp is just the antialiasing) and a dissolve for the other two. // - // The box and the pillar are unbounded upward and downward on purpose: - // what is wanted is a square (or round) piece cut OUT OF THE MAP, and a - // cut with a lid would take the tops off the trees standing in it. + // Every kind is unbounded upward and downward on purpose: what is wanted + // is a rectangular (or round) piece cut OUT OF THE MAP, and a cut with a + // lid would take the tops off the trees standing in it. float dioramaCull(vec3 p) { if (cullShape.z <= 0.5) return 1.0; vec3 cd = p - cullAt; - float d; + // how far INSIDE the cut this point is, in world pixels: the nearest + // side for the rectangle, the rim for the two round kinds + float inside; if (cullShape.z < 1.5) { - d = max(abs(cd.x), abs(cd.z)); // the box: a square of map + inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z)); } else if (cullShape.z < 2.5) { - d = length(cd); // the ball, under V-CURVE + inside = cullShape.x - length(cd); // the ball, under V-CURVE } else { - d = length(cd.xz); // the fight's pillar + inside = cullShape.x - length(cd.xz); // the fight's pillar } - return clamp((cullShape.x - d) * cullShape.y, 0.0, 1.0); + return clamp(inside * cullShape.y, 0.0, 1.0); } #endif uniform Image sunMap; @@ -1068,13 +1077,16 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "fogInfo", fog and { fog.density or 0, fog.start or 0, fog.heightK or 0, 0 } or { 0, 0, 0, 0 }) - -- and the diorama's viewport (see Voxel3D.cull), kind 0 when there is - -- none -- which is every frame that is not a headset's diorama + -- and the viewport (see Voxel3D.cull), kind 0 when there is none -- + -- which is every frame that neither a headset's diorama (lib/Diorama) + -- nor an orbit rung's window box (lib/ViewBox) has cut local cull = Voxel3D.cull pcall(sh.send, sh, "cullAt", cull and { cull.x, cull.y, cull.z } or { 0, 0, 0 }) pcall(sh.send, sh, "cullShape", cull and { cull.r, cull.invFade, cull.kind } or { 0, 0, 0 }) + pcall(sh.send, sh, "cullRect", + cull and { cull.rx or cull.r, cull.rz or cull.r } or { 0, 0 }) -- the window glass: the tileset's mask (or the blank -- the sampler is -- declared either way, and unbound is a driver-dependent crash), how lit -- the panes are, and the movement-fed glint as the caller last set it diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 85fcddd..46c9f7e 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -28,6 +28,7 @@ local FirstPerson = V.require("FirstPerson") local BattleBillboard = V.require("BattleBillboard") local Pokedex = V.require("Pokedex") local Diorama = V.require("Diorama") +local ViewBox = V.require("ViewBox") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -623,10 +624,12 @@ local function drawCast(state, posed, atlasFor) ShadowMap.snug(caster)) end) for _, nb in ipairs(state.neighbors or {}) do - eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster) - Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, - ShadowMap.snug(caster)) - end) + if ViewBox.showsMap(nb) then + eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster) + Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, + ShadowMap.snug(caster)) + end) + end end -- and the seams are back on for the terrain art that follows: grass and -- flowers are the world's own drawing, not people @@ -770,6 +773,11 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh) -- and the sprite cards swap frames as it circles them, so a turn on the -- spot re-fits and redraws exactly like a camera move ("" outside 1ST) put(FirstPerson.signature()) + -- and the window box, because WHICH neighbours went into the light is a + -- function of it (see ViewBox.signature): opening the row out brings a + -- map back inside the cut, and a sun map recorded without it would leave + -- that map standing in its own unlit shadow + put(ViewBox.signature()) put(tostring(terrain)) for i = 1, #nbMesh do put(tostring(nbMesh[i])) end for _, p in ipairs(posed) do @@ -803,9 +811,16 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, if not ShadowMap.begin(cx, cy, vw, vh) then return end ShadowMap.draw(terrain, atlasFor(state.map), nil) + -- The window box's coarse cut, here and at every neighbour loop below + -- (lib/ViewBox): a connected map lying entirely outside this frame's + -- viewport has nothing inside it that could reach the picture, so it is + -- not submitted at all. True for every map whenever there is no box, + -- which is every frame the row is OFF and every headset frame. for i, nb in ipairs(state.neighbors or {}) do - ShadowMap.draw(nbMesh[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + if ViewBox.showsMap(nb) then + ShadowMap.draw(nbMesh[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end end -- The water surface, which the terrain mesh no longer carries (it is its -- own reflective pass now -- see Water). The sun still has to see it, or @@ -813,8 +828,10 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, -- far plane answers for the surface a shoreline tree's shadow falls on. ShadowMap.draw(water, atlasFor(state.map), nil) for i, nb in ipairs(state.neighbors or {}) do - ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + if ViewBox.showsMap(nb) then + ShadowMap.draw(nbWater and nbWater[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end end -- flower billboards live outside the terrain mesh (they draw after the -- characters, pulled -- see render), but the sun still sees them: a @@ -825,8 +842,10 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do - ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + if ViewBox.showsMap(nb) then + ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + end end -- From here down it is the CAST, marked as such in the map (see -- ShadowMap.sprites) so water can decline them: everything the world casts @@ -839,9 +858,11 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.snug(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), ShadowMap.snug(caster)) - end) + if ViewBox.showsMap(nb) then + eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster) + ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster)) + end) + end end for _, p in ipairs(posed) do local def = p.sprite.def @@ -960,6 +981,25 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) cx, cy = eyes.cx, eyes.cy end + -- and the ORBIT RUNGS' own viewport (lib/ViewBox): the flat screen's + -- answer to the same question the diorama's box asks -- the map cut to + -- the window that frames it, so a tilted world reads as a model with + -- sides rather than a map running off every edge. Flat frames only: a + -- headset's cut is Diorama's above, and the two must never both be live. + -- + -- After the first-person block, so the box is centred on the camera + -- actually in charge and opens out with a dive into a head rather than + -- vanishing on the frame the rung changed. + -- + -- Ahead of castShadows, deliberately: the sun draws the same neighbours + -- the eye does (both ask ViewBox.showsMap), so a map skipped out here is + -- skipped out there and nothing is left casting a shadow it cannot own. + if not eyes then + Voxel3D.cull = ViewBox.frame(cx, cy, vw, vh) + else + ViewBox.stop() + end + -- A staged fight, seen by the VR eyes: the flat screen draws the battle -- SCREEN while one is up (this pass never runs), but the headset keeps -- looking at the world, so the world had better have the fight on it. @@ -990,9 +1030,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) local function drawScene() Voxel3D.draw(terrain, atlasFor(state.map), nil) + -- the window box's coarse cut, exactly as the sun pass took it: the same + -- test on the same maps, so the light and the eye can never disagree + -- about which neighbours are in this frame (see ViewBox.showsMap) for i, nb in ipairs(state.neighbors or {}) do - Voxel3D.draw(nbMesh[i], atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + if ViewBox.showsMap(nb) then + Voxel3D.draw(nbMesh[i], atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy)) + end end -- Without a shadow map (headless, or a driver that could not make the @@ -1024,7 +1069,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) waterDraws[#waterDraws + 1] = { water, atlasFor(state.map), nil } end for i, nb in ipairs(state.neighbors or {}) do - if nbWater and nbWater[i] then + if nbWater and nbWater[i] and ViewBox.showsMap(nb) then waterDraws[#waterDraws + 1] = { nbWater[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy) } end @@ -1143,8 +1188,10 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) local pull = VoxelScene.pull(lean) Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull) for _, nb in ipairs(state.neighbors or {}) do - Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), pull) + if ViewBox.showsMap(nb) then + Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy), pull) + end end -- flower billboards: pulled like the characters and the grass, MINUS -- the depth of 8 world pixels along the view (8 sin a -- the camera @@ -1162,9 +1209,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil, fpull, ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do - Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy), fpull, - ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + if ViewBox.showsMap(nb) then + Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), + Mat4.translate(nb.ox, 0, nb.oy), fpull, + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + end end -- The map's atmosphere -- god rays down from the invisible canopy, and @@ -1208,10 +1257,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes) end -- drawScene - -- the two diorama fields are this function's for the length of this + -- the viewport fields are this function's for the length of this -- function, whichever way it leaves (see where they are set) local function done(result) Voxel3D.cull, Voxel3D.keyColor = nil, nil + ViewBox.stop() return result end diff --git a/lib/Water.lua b/lib/Water.lua index d11a6c5..1feef1d 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -463,26 +463,27 @@ uniform float pxAngle; // radians of view one screen pixel subtends // sample to get back to the screen. Declared in both stages, like `vp`, and // both are highp here. uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off -// The diorama's viewport, exactly as the scene shader takes it: centre in -// world pixels, then half-size / one-over-fade / kind (0 off, 1 box, 2 -// ball, 3 the staged fight's pillar). Water is world like anything else, -// and a lake left lying outside the model would be the one thing floating -// in the sky. +// The viewport, exactly as the scene shader takes it: centre in world +// pixels, then half-size / one-over-fade / kind (0 off, 1 box, 2 ball, 3 +// the staged fight's pillar), plus the box's two half-extents. Water is +// world like anything else, and a lake left lying outside the model would +// be the one thing floating in the sky. uniform vec3 cullAt; uniform vec3 cullShape; +uniform vec2 cullRect; float dioramaCull(vec3 p) { if (cullShape.z <= 0.5) return 1.0; vec3 cd = p - cullAt; - float d; + float inside; if (cullShape.z < 1.5) { - d = max(abs(cd.x), abs(cd.z)); + inside = min(cullRect.x - abs(cd.x), cullRect.y - abs(cd.z)); } else if (cullShape.z < 2.5) { - d = length(cd); + inside = cullShape.x - length(cd); } else { - d = length(cd.xz); + inside = cullShape.x - length(cd.xz); } - return clamp((cullShape.x - d) * cullShape.y, 0.0, 1.0); + return clamp(inside * cullShape.y, 0.0, 1.0); } // How far the bend has pushed the world down at world XZ `q` -- the vertex @@ -1274,12 +1275,15 @@ function Water.begin(ctx) send("vp", "row", ctx.vp) send("eye", ctx.eye) send("curve", ctx.curve) - -- the diorama's viewport, as beginScene sent it to the scene shader; - -- kind 0 -- every frame that is not a headset's diorama -- is "no cut" + -- the viewport, as beginScene sent it to the scene shader; kind 0 -- + -- every frame neither the diorama nor the orbit's box has cut -- is + -- "no cut" local cull = V.require("Voxel3D").cull send("cullAt", cull and { cull.x, cull.y, cull.z } or { 0, 0, 0 }) send("cullShape", cull and { cull.r, cull.invFade, cull.kind } or { 0, 0, 0 }) + send("cullRect", cull and { cull.rx or cull.r, cull.rz or cull.r } + or { 0, 0 }) send("screen", { ctx.screen[1], ctx.screen[2] }) send("cell", math.max(1, ctx.cell or 1)) -- how much of the view one screen pixel is worth: what sets the relief diff --git a/main.lua b/main.lua index 25f1e3a..2501e96 100644 --- a/main.lua +++ b/main.lua @@ -85,6 +85,7 @@ local TiltShift = V.require("TiltShift") local ChunkMesher = V.require("ChunkMesher") local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") +local ViewBox = V.require("ViewBox") local OverworldBattle = V.require("OverworldBattle") local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") @@ -373,6 +374,11 @@ applyFull = function(level) -- the horizon flat. The curve bends the world away from a walking player, -- which fights a fixed diorama framing WorldCurve.setting:setIndex(1, Game) + -- and the world cut to the window it is framed in (lib/ViewBox). FULL is + -- the model-on-a-table read and the sides are most of what makes it one: + -- a slab of Kanto with edges, rather than a map whose corners happen to + -- fall off the frame. + ViewBox.setting:setIndex(1, Game) -- and the water reflecting everything it can: FULL is the diorama at its -- most photographed, and a lake with the sky and the shoreline in it is -- most of what makes the model read as being outdoors @@ -433,6 +439,20 @@ local SETTINGS = { .. "headset's DIORAMA, where the world is a model being looked at " .. "rather than walked around in -- 5 curls it into a half sphere, a " .. "town on top of its own little planet." }, + { ViewBox.setting, + "How much of the map the camera bothers to draw. FIT is exactly the " + .. "ground on screen and no more -- the shape a tilted camera really " + .. "frames, which reaches well north of you and flares wide out there, " + .. "not the square the flat game shows. So a connected map that falls " + .. "entirely outside it is skipped before it is drawn, terrain, water, " + .. "grass and shadows together, which is most of the frame's geometry " + .. "at the high rungs. Below about 63 degrees that is all the row does " + .. "and the picture is untouched. At 75 the camera can see all the way " + .. "to the horizon, so something has to name a distance: FIT is the " + .. "closest, WIDE through WIDEST push the world's edge further out, " + .. "and OFF stops cutting entirely. Not on 1ST or 3RD -- you are " + .. "standing in the world there -- and the box opens out and away as " + .. "the camera dives in." }, { Water.setting, "Reflections on water. FULL adds screen-space reflections of the " .. "shoreline, the trees and the buildings behind it; SKY is the sky, " diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 4a7ecfa..f8c9edb 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -142,6 +142,7 @@ T.check(not fullIds["pipeline:tiltshift"], "FULL takes T-SHIFT off the menu -- it owns the blur") T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID") T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE") +T.check(not fullIds["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST") T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME") -- but the battle rows survive it: they are not knobs on the look, and FULL @@ -319,7 +320,8 @@ local order = {} for i, row in ipairs(grouped) do order[row.id] = i end T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"], "the mode's settings follow its pipeline rows") -T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 5, +-- V-GRID, V-CURVE, RENDER DIST, WATER, FOREST FX, then 3D-BTL +T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 6, "and sit in one unbroken block, not scattered to the end of the list") T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], "with the engine's own later rows still after them") @@ -404,21 +406,26 @@ local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, -- (nothing to store, nothing for the mod manager to persist) and is offered -- on every platform, saying WHERE? rather than IMPORT where there is no file -- dialog to open -T.eq(#hookedRows, 11, "the options hook added a row per setting, plus the " +T.eq(#hookedRows, 12, "the options hook added a row per setting, plus the " .. "STADIUM ROM action row") -local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] -local battles, backRow, daytime = hookedRows[6], hookedRows[7], hookedRows[8] --- the FOREST FX row is hookedRows[5] and AA hookedRows[9]; both are read --- where they are used rather than named here, because this chunk is one --- main function and has 200 local slots to spend +local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5] +local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[9] +-- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6] and AA +-- hookedRows[10]; all three are read where they are used rather than named +-- here, because this chunk is one main function and has 200 local slots to +-- spend +T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label") +T.eq(hookedRows[4].value(), "FIT", + "and defaults to FIT -- the cut IS the window the flat game already " + .. "framed, which is the whole claim the row makes") T.eq(water.label, "WATER", "the water row carries its label") T.eq(water.value(), "FULL", "and defaults to FULL -- reflections are the point of having the row") water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1) T.eq(water.value(), "SKY", "stepping down drops the screen-space march and keeps the sky, sun and moon") -T.eq(hookedRows[5].label, "FOREST FX", "the atmosphere row carries its label") -T.eq(hookedRows[5].value(), "FULL", +T.eq(hookedRows[6].label, "FOREST FX", "the atmosphere row carries its label") +T.eq(hookedRows[6].value(), "FULL", "and defaults to FULL -- it only spends anything on a map with an " .. "atmosphere entry, which is one forest today") T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") @@ -517,7 +524,7 @@ do local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local aaGame = { save = { options = {} }, mods = { modOptions = {} } } -local aa = hookedRows[9] +local aa = hookedRows[10] T.eq(aa.label, "AA", "the anti-aliasing row carries its label") T.eq(aa.value(), "OFF", "and starts off -- supersampling is a cost knob, and a mod must not spend " @@ -6042,6 +6049,232 @@ T.eq(Pack.load(126).staticPose, false, T.eq(pikachu.staticPose, false, "and a packed-stream species does too") end)() +-- ------- RENDER DIST: the orbit rungs cut to the window that frames them +-- +-- The flat screen's answer to the same question the headset's diorama box +-- asks. Two halves, and this asserts both: the CUT the shader takes (a +-- rectangle, because a window is not square), and the SKIP it buys -- a +-- connected map lying entirely outside the box is never submitted. +;(function() + +local lib = run.loader.exports.DRAMATIC_SHAPE.lib +local VB = lib.require("ViewBox") +local Dio = lib.require("Diorama") +local VS = lib.require("VoxelState") +local Curve = lib.require("WorldCurve") +local boxWas, curveWas = VB.setting:get(), Curve.setting:get() +local angleWas = VS.angle +Curve.setting:sync(0) + +-- ------- the ladder +T.eq(VB.setting.values[1], 0, + "FIT is rung 0, so an unset save lands on the cut rather than off it") +T.eq(VB.setting.labels[1], "FIT", "and FIT is what the row calls it") +T.eq(VB.setting.labels[#VB.setting.labels], "OFF", + "OFF is the TOP of the ladder -- 'how much world' ending in all of it, " + .. "rather than a switch beside the sizes") +for i = 2, #VB.FRACS - 1 do + T.check(VB.FRACS[i] > VB.FRACS[i - 1], + "the ladder opens out at rung " .. (i - 1)) +end +T.eq(VB.FRACS[1], 1.0, + "and FIT is exactly one window: the cut is the framing the flat game " + .. "already had, not one this file made up") + +-- ------- which rungs it is about +VB.setting:sync(0) +T.eq(VB.appliesTo(0), false, "OFF has no 3D pass to cut") +T.check(VB.appliesTo(VS.FULL_LEVEL), "FULL is cut -- it IS the model read") +T.check(VB.appliesTo(2) and VB.appliesTo(3) and VB.appliesTo(4) + and VB.appliesTo(5), + "and so is every orbit rung: 15, 35, 50 and 75") +T.eq(VB.appliesTo(VS.FP_LEVEL), false, + "1ST is not: the player is standing IN the world, and a box round a " + .. "walking eye is a fog-of-war circle rather than a model on a table") +T.eq(VB.appliesTo(VS.TP_LEVEL), false, "nor 3RD, which is the same rig") + +-- ------- the footprint IS NOT the window +-- +-- The bug this replaced: the box was the flat game's own vw-by-vh +-- rectangle, which is the ground a camera frames at 0 degrees and at no +-- rung the mode actually has. Every tilted rung frames a TRAPEZOID that +-- reaches further north and flares wider out there, and a window-sized box +-- cut the top and both sides off a world plainly on screen -- gaps, with +-- sky showing through them. +-- +-- So the footprint is checked against a RAY CAST through the orbit's own +-- basis: the frame's corner rays dropped on the ground plane. If the closed +-- form and the rays ever disagree, the picture has a hole in it. +local REACH = VB.MAX_REACH * 288 +local function castFootprint(a) + local tanY = 1 / (2 * VS.FOCAL) + local tanX = tanY * (320 / 288) + local k = VS.FOCAL * 288 + local ca, sa = math.cos(a), math.sin(a) + local n, s, x = -1e18, -1e18, 0 + for i = 0, 200 do + local sy = -1 + 2 * i / 200 + -- forward (0,-ca,-sa), true up (0,sa,-ca), right (1,0,0) + local dy = -ca + sy * tanY * sa + local dz = -sa - sy * tanY * ca + if dy < -1e-9 then + local t = (k * ca) / -dy -- eye height over the drop + local wz, wx = k * sa + t * dz, t * tanX + if -wz > n then n = -wz end + if wz > s then s = wz end + if wx > x then x = wx end + end + end + return n, s, x +end +for _, deg in ipairs({ 0, 15, 35, 50 }) do + local n, s, x = castFootprint(math.rad(deg)) + local fn, fs, fx = VB.footprint(math.rad(deg), 320, 288, REACH) + T.check(math.abs(fn - n) < 1 and math.abs(fs - s) < 1 + and math.abs(fx - x) < 1, + ("%d degrees: the footprint is the ground the rays actually land on " + .. "(north %.0f/%.0f, south %.0f/%.0f, side %.0f/%.0f)") + :format(deg, fn, n, fs, s, fx, x)) +end +-- straight down is the ONE case the old window-sized box got right, which +-- is why it survived being wrong everywhere else +do + local n, s, x = VB.footprint(0, 320, 288, REACH) + T.check(math.abs(n - 144) < 1e-6 and math.abs(s - 144) < 1e-6 + and math.abs(x - 160) < 1e-6, + "at 0 degrees it reduces to the flat window, exactly") +end +-- and the shape of the error the gaps were: north grows with the tilt, +-- south SHRINKS (the bottom of the frame is nearer than the focus), and +-- the sides flare with the far field +do + local n15, s15, x15 = VB.footprint(math.rad(15), 320, 288, REACH) + local n50, s50, x50 = VB.footprint(math.rad(50), 320, 288, REACH) + T.check(n15 > 144 and n50 > n15, + "every tilted rung reaches further north than the window did -- which " + .. "is the gap that used to open along the top") + T.check(s15 < 144, + "and less far south, because the bottom of the frame is nearer the eye " + .. "than the focus is") + T.check(x15 > 160 and x50 > x15, + "and wider at the sides, because the far field is further away -- the " + .. "other gap") + T.check(s50 < n50 * 0.5, + "so the picture is mostly AHEAD of the view centre, and the box has to " + .. "sit north of it rather than around it") +end +-- past atan(2*FOCAL) -- about 63 degrees -- the horizon is inside the frame +-- and the honest answer is infinite. That is the whole reason there is a +-- row: something has to name a distance. +do + local n75 = VB.footprint(math.rad(75), 320, 288, REACH) + T.check(math.abs(n75 - REACH) < 1, + "at 75 the footprint is unbounded and the reach stands in for it") + local near = VB.footprint(math.rad(75), 320, 288, REACH / 2) + T.check(near < n75, "and a shorter reach brings the world's edge in") +end + +-- ------- the cut itself +VS.angle = math.rad(35) +local box = VB.frame(100, 200, 320, 288, 3) +T.eq(box.kind, Dio.BOX, "the cut is the shader's BOX kind, like the diorama's") +T.eq(box.x, 100, "centred on the view in x, which the trapezoid is") +T.check(box.z < 200, + "and NORTH of it in z, which the trapezoid also is -- a box centred on " + .. "the view centre is the bug that cut the top off") +do + local n, s, x = VB.footprint(math.rad(35), 320, 288, REACH) + T.check(math.abs(box.rz - (n + s) * 0.5) < 1e-6, + "the half-depth spans the footprint from its south edge to its north") + T.check(math.abs(box.z - (200 - (n - s) * 0.5)) < 1e-6, + "and the centre sits exactly between them") + T.check(math.abs(box.rx - x) < 1e-6, "with the far field's own half-width") + T.check(box.rz > 144 and box.rx > 160, + "both bigger than the window at every tilted rung, so the cut can " + .. "never take a bite out of the picture") +end +T.check(box.rx ~= box.rz, + "which is the whole reason the box kind carries two half-extents: a " + .. "square cut on a wide frame either loses the sides or overshoots") +T.check(1 / box.invFade <= 0.5, + "with a HARD edge while the world is flat: that IS the sides") +-- and the diorama's own box still passes the same number twice, so one +-- shader branch serves both +local sq = Dio.viewport(0, 0, 288) +T.eq(sq.rx, sq.r, "a headset's box is square in x") +T.eq(sq.rz, sq.r, "and in z -- no window to be shaped like") + +-- the row opens it out, and OFF stops cutting entirely +VB.setting:sync(1) +local wide = VB.frame(100, 200, 320, 288, 3) +T.check(wide.rx > box.rx and wide.rz > box.rz, + "a wider rung reaches further in both directions") +VB.setting:sync(#VB.setting.values - 1) +T.eq(VB.setting:get(), 4, "the top rung is OFF") +T.eq(VB.frame(100, 200, 320, 288, 3), nil, "and OFF cuts nothing at all") +T.eq(VB.cull, nil, "leaving no viewport on the module for a pass to inherit") + +-- a bent world has no straight sides, so the rim dissolves under V-CURVE -- +-- the same call Diorama.fadeFor makes for the same reason +VB.setting:sync(0) +Curve.setting:sync(3) +local bent = VB.frame(0, 0, 320, 288, 3) +T.check(1 / bent.invFade > 1, + "V-CURVE softens the box's rim: a hard edge across a bent world is a " + .. "lie about what is being looked at") +Curve.setting:sync(0) + +-- ------- the coarse half: whole maps skipped before they are drawn +VB.setting:sync(0) +VS.angle = math.rad(35) +local cut = VB.frame(0, 0, 320, 288, 3) +-- a 10x9-block map (Pallet's shape) at the origin is under the box +local here = { map = { def = { width = 10, height = 9 } }, ox = 0, oy = 0 } +T.check(VB.showsMap(here), "the map under the box is drawn") +-- the same map pushed a long way north-east cannot reach the picture +local far = { map = { def = { width = 10, height = 9 } }, + ox = 4000, oy = 4000 } +T.eq(VB.showsMap(far), false, + "a connected map entirely outside the box is skipped -- no terrain, no " + .. "water, no grass, no flowers and no shadow pass") +-- the test is taken against the CUT's own edge, which is the trapezoid's +-- and not the window's -- the border ring is meshed outside a map's own +-- rectangle, so a map that stops just short still has trees in frame +T.check(VB.showsMap({ map = { def = { width = 1, height = 1 } }, + ox = cut.x + cut.rx + VB.PAD - 8, oy = cut.z }), + "a map just outside the edge is kept for its border ring, which is " + .. "meshed beyond its own rectangle") +T.eq(VB.showsMap({ map = { def = { width = 1, height = 1 } }, + ox = cut.x + cut.rx + VB.PAD + 64, oy = cut.z }), false, + "and dropped once even the ring cannot reach") +-- and the skip follows the trapezoid north: a map the OLD window-sized box +-- would have thrown away is still on screen at a tilted rung +T.check(VB.showsMap({ map = { def = { width = 4, height = 4 } }, + ox = -64, oy = -200 }), + "a map north of the view centre, past where a window-sized box ended, " + .. "is kept -- the camera is looking straight at it") +VB.stop() +T.check(VB.showsMap(far), + "with no box open every map is drawn, so a caller may guard " + .. "unconditionally -- which is every headset frame and every OFF rung") +T.check(VB.showsMap(nil), "and a malformed neighbour is never skipped") + +-- ------- the sun has to notice the row +-- +-- WHICH neighbours went into the shadow map is now a function of the box, +-- and the box is the one input the sun's signature did not already carry. +VB.setting:sync(0) +local sigFit = VB.signature() +VB.setting:sync(2) +T.neq(VB.signature(), sigFit, + "opening the row out changes the sun's signature, so a map recorded " + .. "without a neighbour is redrawn with it") + +Curve.setting:sync(curveWas) +VB.setting:sync(boxWas) +VS.angle = angleWas +end)() + Pipelines.reset() run.release()