diff --git a/CHANGELOG.md b/CHANGELOG.md index e71d23e..8a7bc6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,16 @@ # Changelog -## 1.6.0 +## 1.5.2 ### Added +- **SELECT walks the VOXEL ladder.** In free roam, the pad's SELECT + button makes exactly the step hotkey 3 makes -- OFF through the angle + rungs to 1ST and round, stepping over FULL, clearing TILT and GBC FX + on every press like the key does. For the machines with no number row: + the phone's touch pad and a controller. SELECT has no overworld job in + Gen 1 -- its work is all in-menu, and menus keep it untouched. + - **PCVR, in-process, through OpenXR -- with no change to the parent app.** A new **VR** row (OFF / ON, off by default; on the OPTIONS menu and the mod manager's page). The whole stack rides LuaJIT's FFI from @@ -90,17 +97,23 @@ world's real horizon and drop the fixed-slice fallback entirely, so tilting your head slides the frame across a sky that stays put -- and the discs, already projected through each eye's true camera, - stand still over their own azimuth. The horizon holds LEVEL under a - rolled head too: the whole painting runs along the horizon's own - projected axis rather than the canvas's rows, so tipping your head - tips the frame across the horizon instead of hinging the horizon - with your ears -- and the discs' cell art is drawn in that same - frame, so a tipped head no longer spins the moon's face in place. - Under PITCH the gradient stays put as well: the band span's far end - is the 55-degree direction's own projection through the eye's - frustum rather than a pixels-per-radian estimate -- a perspective's - rows are tan-spaced, not angle-spaced, and the linear guess let the - bands slide as the horizon crossed the frame. + stand still over their own azimuth. The gradient is a true SKYBOX: + each eye hands the sky shader its own ray fan (head rotation plus + frustum tangents), and every pixel takes its band -- and its GBC + checker dither -- from the TRUE elevation of its own view ray, so + no motion of the head, pitch, yaw or roll, in the diorama or in + first person, moves a band by a pixel; only the clock recolours + them. The FLAT screen's first person gets the very same treatment: + the placed rig builds its own ray fan from the basis its view is + made of, so mouse-look pitch slides the frame over a sky that + stays put there too, dither and all -- while the orbit rungs keep + their classic frame-hung painting. And the SUN AND MOON are no + longer painted on the frame at all (in first person on the flat + screen included): the cell art is baked to a texture once per + palette and hung on a quad IN THE WORLD, projected through the + camera like any geometry -- no per-frame cell snapping (the + jitter), no pattern squared to the canvas (the face that turned + with the head). - **The VR row exists only where VR can.** Off Windows -- the Android build above all -- the row is absent from the OPTIONS menu and the diff --git a/README.md b/README.md index 5224f77..2536f98 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ menu. | control | does | | --- | --- | -| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → OFF (camera pitch) | +| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → OFF (camera pitch) | +| `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row | | `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 — bend the world over the horizon | @@ -25,13 +26,7 @@ menu. ## VR The **VR** options row (OFF / ON, off by default) drives a PCVR headset -through OpenXR on Windows — SteamVR, Oculus or WMR. On the orbit rungs -the world is a head-tracked tabletop diorama at the rung's own angle; on -**1ST** you stand inside it at life size; a battle snaps you (through a -fade) into the game's own over-the-shoulder shot. Menus float on a -panel wearing the Game Boy frame, and in first person they show up on -the **Pokédex in your left hand** instead. Turning VR off is the VR -row's job — no controller button does it. +through OpenXR on Windows — SteamVR, Oculus or WMR. ### VR controls diff --git a/lib/Pokedex.lua b/lib/Pokedex.lua new file mode 100644 index 0000000..07bba64 --- /dev/null +++ b/lib/Pokedex.lua @@ -0,0 +1,222 @@ +-- VR: the POKEDEX in the player's left hand -- a voxel model of the +-- series' own field guide, strapped to the tracked grip pose, whose +-- screen is a real texture the mod can put a picture on. +-- +-- Why it exists: a staged VR battle needs the 2D battle screen SOMEWHERE +-- -- the text, the menus, the HP bars are the game -- but a flat panel +-- floating square in front of the fight hides the fight. A trainer in +-- the world already has the right prop for "a handheld device with a +-- screen": look down at the Pokedex in your hand to read the battle, +-- look up to watch it happen on the map. +-- +-- THE MODEL is authored here in voxels, in METRES (VOX metres a voxel), +-- around its own centre, front face +Z -- a red slab with the lens, the +-- LEDs, the hinge and a d-pad, and a dark bezel the screen sits proud +-- of. It rides VRRig.propMatrix, the same XR-to-world mapping the eyes +-- use, so it sits exactly where the hand is and keeps its real size in +-- every mode: a hand-sized device over the diorama, the same hand-sized +-- device at life scale in first person and in battle. +-- +-- THE SCREEN is a separate one-quad mesh drawn with its own texture -- +-- whatever canvas the caller hands `Pokedex.screen` (the VR frame hands +-- it the front buffer during a battle, cropped by UV to the battle's own +-- letterbox). No texture leaves the screen dark: a device that is off. +-- +-- Everything here is passive state plus a draw call; VR.lua decides when +-- the frame exists (hand tracked, session live) and VoxelScene's eye +-- pass draws it after the world, so it composites with real depth +-- against everything else. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel3D = V.require("Voxel3D") +local VRRig = V.require("VRRig") + +local Pokedex = {} + +-- one voxel, in metres: a centimetre-ish grid gives the classic chunky +-- read at a believable device size (the body below comes out about +-- 10 x 15 x 2.5 cm) +Pokedex.VOX = 0.011 + +-- Where the device sits relative to the GRIP pose, in metres, and how it +-- is tipped. A full quarter turn forward lays the slab exactly along the +-- controller's own body -- verified in the headset -- so holding the +-- controller IS holding the device: raise your fist and the screen faces +-- you. These two are the whole of the attachment. +Pokedex.OFFSET = { 0, 0.04, -0.02 } +Pokedex.TILT = -math.pi / 2 -- radians about X: 90 degrees forward, + -- flush with the controller + +-- body proportions, in voxels +local W, H, D = 9, 14, 2 + +-- the palette the body's faces point their UVs at, one texel per colour +local COLORS = { + { 200, 40, 48 }, -- 1 body red + { 140, 24, 32 }, -- 2 hinge / shaded red + { 64, 132, 244 }, -- 3 the lens blue + { 208, 228, 255 }, -- 4 lens glint + { 232, 60, 48 }, -- 5 LED red + { 248, 216, 64 }, -- 6 LED yellow + { 72, 200, 96 }, -- 7 LED green + { 46, 46, 54 }, -- 8 bezel / d-pad dark + { 24, 24, 30 }, -- 9 the dark screen (the "off" state's face) +} + +local paletteTex = nil -- one texel per COLORS entry +local bodyMesh = nil +local screenMesh = nil +local screenKey = nil -- the UV rect screenMesh was built for + +local function palette() + if paletteTex then return paletteTex end + if not (love.image and love.image.newImageData + and love.graphics and love.graphics.newImage) then return nil end + local ok, data = pcall(love.image.newImageData, #COLORS, 1) + if not (ok and data) then return nil end + for i, c in ipairs(COLORS) do + pcall(data.setPixel, data, i - 1, 0, + c[1] / 255, c[2] / 255, c[3] / 255, 1) + end + local built, img = pcall(love.graphics.newImage, data) + if not built then return nil end + pcall(img.setFilter, img, "nearest", "nearest") + paletteTex = img + return img +end + +-- Append one solid box's six faces to `verts`/`indices`: position in +-- voxels (relative to the device centre), size in voxels, colour by +-- palette index. Faces carry the mod's own directional shade, so the +-- slab reads as a solid the way every extruded block here does. +local function box(verts, indices, x, y, z, w, h, d, color) + local u = (color - 0.5) / #COLORS + local vox = Pokedex.VOX + local ox, oy, oz = (x - W / 2) * vox, (y - H / 2) * vox, (z - D / 2) * vox + local sx, sy, sz = w * vox, h * vox, d * vox + for face = 1, 6 do + local corners = Voxel3D.FACE_CORNERS[face] + local shade = Voxel3D.FACE_SHADE[face] + local n = #verts / 4 + for _, c in ipairs(corners) do + verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz, + u, 0.5, shade } + end + Voxel3D.pushQuad(indices, n) + end +end + +-- the screen's face on the front, in voxels (10:9, the GB frame's shape), +-- shared by the dark "off" face in the body and the live quad +local SCREEN = { x = 1.2, y = 4.6, w = 6.6, h = 5.94 } + +local function buildBody() + if bodyMesh then return bodyMesh end + local verts, indices = {}, {} + -- the slab, the hinge along the right edge, the lens, the LEDs, the + -- d-pad and two chunky buttons -- the classic cover furniture, one box + -- each on the front face (z = D..) + box(verts, indices, 0, 0, 0, W, H, D, 1) -- body + box(verts, indices, W - 0.7, 0, 0, 0.7, H, D + 0.15, 2) -- hinge + box(verts, indices, 0.6, H - 2.6, D, 2, 2, 0.5, 3) -- lens + box(verts, indices, 0.9, H - 1.3, D + 0.5, 0.6, 0.5, 0.12, 4) -- glint + box(verts, indices, 3.2, H - 1.6, D, 0.8, 0.8, 0.35, 5) -- LEDs + box(verts, indices, 4.5, H - 1.6, D, 0.8, 0.8, 0.35, 6) + box(verts, indices, 5.8, H - 1.6, D, 0.8, 0.8, 0.35, 7) + -- the bezel plate the screen sits in, and the dark screen face itself + -- (what shows when nothing is on: a device that is off, not a hole) + box(verts, indices, SCREEN.x - 0.4, SCREEN.y - 0.4, D, + SCREEN.w + 0.8, SCREEN.h + 0.8, 0.4, 8) + box(verts, indices, SCREEN.x, SCREEN.y, D + 0.4, + SCREEN.w, SCREEN.h, 0.1, 9) + -- d-pad below the screen, two crossed bars, and the A/B buttons + box(verts, indices, 5.6, 1.1, D, 2.1, 0.7, 0.45, 8) + box(verts, indices, 6.3, 0.4, D, 0.7, 2.1, 0.45, 8) + box(verts, indices, 1.2, 0.8, D, 1.1, 1.1, 0.45, 5) + box(verts, indices, 2.9, 0.8, D, 1.1, 1.1, 0.45, 8) + bodyMesh = Voxel3D.newMesh(verts, indices) + return bodyMesh +end + +-- The live screen: one quad a hair proud of the dark face, UV-mapped to +-- `uv` = { u0, v0, u1, v1 } of whatever texture is on it. Rebuilt only +-- when the UV rect moves (a window resize moving the battle letterbox). +local function buildScreen(uv) + local key = table.concat({ uv[1], uv[2], uv[3], uv[4] }, ":") + if screenMesh and screenKey == key then return screenMesh end + local vox = Pokedex.VOX + local x0 = (SCREEN.x - W / 2) * vox + local y0 = (SCREEN.y - H / 2) * vox + local x1 = x0 + SCREEN.w * vox + local y1 = y0 + SCREEN.h * vox + local z = (D / 2 + 0.55) * vox + local u0, v0, u1, v1 = uv[1], uv[2], uv[3], uv[4] + local verts = { + { x0, y0, z, u0, v1, 1 }, { x1, y0, z, u1, v1, 1 }, + { x1, y1, z, u1, v0, 1 }, { x0, y1, z, u0, v0, 1 }, + } + local indices = {} + Voxel3D.pushQuad(indices, 0) + local mesh = Voxel3D.newMesh(verts, indices) + if mesh then + screenMesh, screenKey = mesh, key + end + return mesh +end + +-- ------- the frame's state, set by VR.lua +-- +-- nil = no pokedex this frame (no session, no tracked left hand). +Pokedex.frame = nil + +-- Stand the device on a tracked LEFT-HAND pose under the current +-- XR-to-world mapping (the same pivot/anchor/scale/yaw the eyes got). +function Pokedex.place(pose, pivot, anchor, scale, yaw) + local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw) + m = Mat4.mul(m, Mat4.translate(Pokedex.OFFSET[1], Pokedex.OFFSET[2], + Pokedex.OFFSET[3])) + m = Mat4.mul(m, Mat4.rotateX(Pokedex.TILT)) + Pokedex.frame = { model = m } +end + +-- What the screen shows: a texture and the UV rect of it to fill the +-- screen with. nil for a dark screen. Only meaningful after place(). +function Pokedex.screen(tex, u0, v0, u1, v1) + if Pokedex.frame and tex then + Pokedex.frame.tex = tex + Pokedex.frame.uv = { u0 or 0, v0 or 0, u1 or 1, v1 or 1 } + end +end + +function Pokedex.clear() + Pokedex.frame = nil +end + +-- Draw the device with the scene's own pass (model matrix in world px). +-- Runs inside VoxelScene's drawScene, per eye; no shadow-caster half -- +-- a UI prop should receive the world's light, not throw shade on it. +function Pokedex.draw() + local f = Pokedex.frame + if not f then return end + local body = buildBody() + local pal = palette() + if body and pal then + Voxel3D.draw(body, pal, f.model) + end + if f.tex and f.uv then + local screen = buildScreen(f.uv) + if screen then + Voxel3D.draw(screen, f.tex, f.model) + end + end +end + +-- window resize / hot reload: every GPU object here is derived and cheap +function Pokedex.invalidate() + paletteTex, bodyMesh, screenMesh, screenKey = nil, nil, nil, nil +end + +return Pokedex diff --git a/lib/Sky.lua b/lib/Sky.lua index 545996f..1695aa0 100644 --- a/lib/Sky.lua +++ b/lib/Sky.lua @@ -190,6 +190,14 @@ uniform float start; // where the checker begins inside a band uniform float axisX; // the "toward the ground" direction on the canvas: uniform float axisY; // (0,1) for a level camera; a rolled VR eye tips // it, and edge/top are distances along it +uniform vec3 rayBase; // the eye's ray fan (VRRig eyeCamera.skyRay): a +uniform vec3 rayDu; // canvas point at fractions (u, v) looks along +uniform vec3 rayDv; // base + u*du + v*dv, world axes -- so each pixel + // knows its TRUE elevation and the gradient is a + // real skybox, untouched by any head motion +uniform float raySpan; // radians of elevation the gradient covers +uniform vec2 invSize; // 1/w, 1/h: canvas pixels to fractions +uniform float useRay; // 0 = the flat screen's frame-linear gradient uniform float alpha; uniform float glowAmt; // twilight warmth around the low sun; 0 = none uniform vec2 glowPos; // the sun disc, in canvas pixels @@ -207,9 +215,22 @@ vec3 bandAt(float i) { vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec2 cc0 = floor(sc / cell) * cell; // top of this cell - float row = cc0.x * axisX + cc0.y * axisY; // along the axis - if (row > edge) { discard; } // below the horizon - float pos = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0) * count; + float tn; + if (useRay > 0.5) { + // the cell's own ray, and its true elevation: the sky by ANGLE, so + // no motion of the head -- pitch, yaw or roll -- moves a band + vec2 cc1 = (floor(sc / cell) + 0.5) * cell; + vec3 dir = rayBase + rayDu * (cc1.x * invSize.x) + + rayDv * (cc1.y * invSize.y); + float elev = atan(dir.y, length(dir.xz)); + if (elev < 0.0) { discard; } // below the horizon + tn = 1.0 - clamp(elev / max(raySpan, 0.001), 0.0, 1.0); + } else { + float row = cc0.x * axisX + cc0.y * axisY; // along the axis + if (row > edge) { discard; } // below the horizon + tn = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0); + } + float pos = tn * count; float base = min(floor(pos), count - 1.0); vec3 c = bandAt(base); float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0); @@ -406,40 +427,14 @@ function Sky.discRadius(h, cell, body) return r * cell, r end -local function paintDisc(body, edge, cell, w, h, axis) - local g = love.graphics - if not (body and body.y and g.setScissor) then return end - local shades = Sky.discShades(body.moon) - local twilight = looming(body) - local _, r = Sky.discRadius(h, cell, body) - -- snap the centre to the cell grid, like everything else in this sky - local bx = math.floor(body.x / cell) * cell + cell / 2 - local by = math.floor(body.y / cell) * cell + cell / 2 - -- wholly below the horizon point -- measured along the tilt axis when - -- the caller has one (a rolled VR eye) - local bt = axis and (bx * axis[1] + by * axis[2]) or by - if bt - r * cell > edge then return end +-- One disc's worth of cell art -- shared verbatim by the screen-space +-- painter below (the flat screen) and by the BAKE the VR eyes texture +-- their world-anchored quad with (Sky.discImage). `plot(dx, dy, c)` gets +-- every kept cell in disc-local cell coordinates and its 0..255 colour. +local function discCells(r, moon, shades, twilight, plot) local core = shades[1] local main = shades[twilight and 3 or 2] - local sx, sy, sw, sh = g.getScissor() - -- the scissor is axis-aligned and cannot follow a tilted horizon; under - -- an axis the world drawn after this covers the ground side anyway, so - -- the region opens to the frame and only the cull above clips the disc - if axis then - g.setScissor(0, 0, math.ceil(w), math.ceil(h)) - else - g.setScissor(0, 0, math.ceil(w), math.floor(edge)) - end local craterR = math.max(1, math.floor(r / 5)) - -- The disc's cells are drawn in the HORIZON'S frame, not the canvas's: - -- under an axis (a VR eye that can roll) the whole pattern -- craters, - -- rim dither, the cell grid itself -- is rotated to stay upright over - -- the world, or a tipped head watches the moon's face spin in place. - -- Level cameras rotate by zero and draw exactly what they always drew. - local rot = axis and math.atan2(-axis[1], axis[2]) or 0 - g.push() - g.translate(bx, by) - if rot ~= 0 then g.rotate(rot) end for dy = -r, r do for dx = -r, r do local d = math.sqrt(dx * dx + dy * dy) @@ -447,7 +442,7 @@ local function paintDisc(body, edge, cell, w, h, axis) local c = d <= r * 0.5 and core or main -- dithered rim: the outer ring keeps only one parity of its cells local keep = d <= r - 0.9 or (dx + dy) % 2 == 0 - if body.moon then + if moon then for _, cr in ipairs(MOON_CRATERS) do local cdx = dx - math.floor(cr[1] * r + 0.5) local cdy = dy - math.floor(cr[2] * r + 0.5) @@ -456,19 +451,86 @@ local function paintDisc(body, edge, cell, w, h, axis) end end end - if keep then - g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) - g.rectangle("fill", dx * cell - cell / 2, - dy * cell - cell / 2, cell, cell) - end + if keep then plot(dx, dy, c) end end end end - g.pop() +end + +local function paintDisc(body, edge, cell, w, h) + local g = love.graphics + if not (body and body.y and g.setScissor) then return end + local shades = Sky.discShades(body.moon) + local twilight = looming(body) + local _, r = Sky.discRadius(h, cell, body) + -- snap the centre to the cell grid, like everything else in this sky + local bx = math.floor(body.x / cell) * cell + cell / 2 + local by = math.floor(body.y / cell) * cell + cell / 2 + if by - r * cell > edge then return end -- wholly below the horizon point + local sx, sy, sw, sh = g.getScissor() + g.setScissor(0, 0, math.ceil(w), math.floor(edge)) + discCells(r, body.moon, shades, twilight, function(dx, dy, c) + g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) + g.rectangle("fill", bx + dx * cell - cell / 2, + by + dy * cell - cell / 2, cell, cell) + end) if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end g.setColor(1, 1, 1, 1) end +-- ------- the disc as a TEXTURE, for the VR eyes +-- +-- A VR eye must not paint the disc in screen space at all: a canvas-grid +-- painting re-snaps to different cells every head movement (jitter) and +-- holds its pattern square to the CANVAS (a rolled or pitched head +-- watches the sun's face turn). So the same cell art is baked once into +-- a texture, and Voxel3D hangs it on a quad ANCHORED IN THE WORLD -- +-- projected through the eye's own matrix like any geometry, stable under +-- every head motion. Rebaked only when the palette or the twilight state +-- moves the colours. +local discBake = { key = nil, img = nil } + +Sky.DISC_BAKE_R = 9 -- bake radius, in cells +Sky.DISC_BAKE_PX = 8 -- texture pixels per cell + +function Sky.discImage(moon, twilight) + if not (love.graphics and love.graphics.newCanvas) then return nil end + local shades = Sky.discShades(moon) + local key = (moon and "m" or "s") .. (twilight and "t" or "-") + for i = 1, math.min(3, #shades) do + local c = shades[i] + key = key .. ":" .. c[1] .. "," .. c[2] .. "," .. c[3] + end + if discBake.key == key and discBake.img then return discBake.img end + local r, px = Sky.DISC_BAKE_R, Sky.DISC_BAKE_PX + local size = (2 * r + 1) * px + local ok, canvas = pcall(love.graphics.newCanvas, size, size) + if not (ok and canvas) then return nil end + pcall(canvas.setFilter, canvas, "nearest", "nearest") + local g = love.graphics + local done = pcall(function() + g.push("all") + g.origin() + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + discCells(r, moon, shades, twilight, function(dx, dy, c) + g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) + g.rectangle("fill", (dx + r) * px, (dy + r) * px, px, px) + end) + g.pop() + end) + if not done then return nil end + discBake.key, discBake.img = key, canvas + return canvas +end + +-- Whether this body is the looming low sun, for callers sizing the baked +-- disc (the same exaggeration paintDisc applies through discRadius). +function Sky.discLooming(glowAmt, moon) + return (glowAmt or 0) > 0.25 and not moon +end + -- Paint the sky into the bound canvas, filling it from the top edge down to -- `horizonY` (or to SPAN of the frame when the horizon is out of it). -- @@ -488,20 +550,38 @@ end -- {ax, ay} pointing "toward the ground" on the canvas (Voxel3D.horizonLine), -- with `horizonY` and `top` then read as distances ALONG it rather than as -- rows. nil is the level default. Only the shader path can tilt; the flat --- fallback paints level, which only a headless run ever sees. +-- fallback paints level, which only a headless run ever sees. Under an +-- axis the DISC is not painted here at all -- the VR caller hangs the +-- baked disc (Sky.discImage) in the world instead; `body` still carries +-- the twilight glow into the bands. +-- +-- `ray` makes the gradient a SKYBOX: the eye's own ray fan (the camera +-- record's skyRay, from VRRig.eyeCamera), letting every pixel take its +-- band from its TRUE elevation -- so no motion of the head, on any axis, +-- moves a band; only the clock does. nil keeps the linear frame gradient +-- the flat screen has always painted. -- -- Returns false when there is nothing to paint, in which case the caller's flat -- fill is the whole sky. That fill is the palest band, so a frame that declines -- this looks like a hazy day rather than like a bug. -function Sky.paint(w, h, sky, horizonY, cell, body, top, axis) +function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray) local bands = sky and sky.bands if not (bands and bands[1]) then return false end if not (w and h and w > 0 and h > 0) then return false end local g = love.graphics if not (g and g.rectangle) then return false end - -- along an axis the caller's edge is already the signed distance and - -- has no row to be clamped to; level callers keep the SPAN fallback - local edge = axis and horizonY or Sky.region(h, horizonY) + -- with a ray fan the shader's own per-pixel elevation test is the only + -- boundary and the whole frame goes through it; along an axis the + -- caller's edge is already the signed distance and has no row to be + -- clamped to; level callers keep the SPAN fallback + local edge + if ray then + edge = h + elseif axis then + edge = horizonY + else + edge = Sky.region(h, horizonY) + end if not edge then return false end local alpha = sky[4] or 1 cell = math.max(1, math.floor((cell or 1) + 0.5)) @@ -533,6 +613,14 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis) sh:send("top", math.min(top or 0, edge - 1)) sh:send("axisX", axis and axis[1] or 0) sh:send("axisY", axis and axis[2] or 1) + sh:send("useRay", ray and 1 or 0) + if ray then + sh:send("rayBase", ray.base) + sh:send("rayDu", ray.du) + sh:send("rayDv", ray.dv) + sh:send("raySpan", Sky.ELEV_SPAN) + sh:send("invSize", { 1 / w, 1 / h }) + end sh:send("cell", cell) sh:send("start", Sky.DITHER and Sky.DITHER_START or 2) sh:send("alpha", alpha) @@ -547,9 +635,9 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis) if sent then g.setShader(sh) g.setColor(1, 1, 1, 1) - -- tilted, the sky's reach is not a row: the full frame goes through - -- the shader and the discard is the boundary - local rectH = axis and h or math.min(h, math.ceil(edge)) + -- tilted or rayed, the sky's reach is not a row: the full frame + -- goes through the shader and the discard is the boundary + local rectH = (axis or ray) and h or math.min(h, math.ceil(edge)) g.rectangle("fill", 0, 0, w, rectH) g.setShader() else @@ -557,12 +645,16 @@ function Sky.paint(w, h, sky, horizonY, cell, body, top, axis) end end if not sh then - paintFlat(w, h, bands, axis and math.min(h, edge) or edge, alpha, cell, - math.min(top or 0, edge - 1)) + paintFlat(w, h, bands, (axis or ray) and math.min(h, edge) or edge, + alpha, cell, math.min(top or 0, edge - 1)) end -- the disc goes over the glow, under nothing: plain rectangles, so it is - -- there whether or not the shader built - paintDisc(body, axis and edge or math.min(h, edge), cell, w, h, axis) + -- there whether or not the shader built. NOT under an axis or a ray fan: + -- those cameras hang the baked disc in the world instead (drawWorldDisc, + -- with Sky.discImage) + if not (axis or ray) then + paintDisc(body, math.min(h, edge), cell, w, h) + end g.setColor(1, 1, 1, 1) if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end @@ -578,6 +670,10 @@ function Sky.invalidate() shader = nil if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end cache.ramp, cache.rampFor = nil, nil + if discBake.img and discBake.img.release then + pcall(discBake.img.release, discBake.img) + end + discBake.key, discBake.img = nil, nil end return Sky diff --git a/lib/VRRig.lua b/lib/VRRig.lua index 1b01d1b..7b38c5e 100644 --- a/lib/VRRig.lua +++ b/lib/VRRig.lua @@ -156,6 +156,29 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw) fov.angleUp, fov.angleDown, VRRig.NEAR, VRRig.FAR) + -- The eye's RAY FAN, in world axes: the direction a canvas point + -- (u, v in 0..1, left-to-right and top-to-bottom) looks along is + -- base + u * du + v * dv. The sky reads its per-pixel TRUE elevation + -- off this (a real skybox cannot be painted from any per-frame row + -- mapping -- that is exact only at the view's own azimuth and swims + -- everywhere else). Directions only, so the mapping's scale drops out; + -- the yaw must not (the battle mount and the snap turn swing the world). + local Rw = R + if yaw and yaw ~= 0 then Rw = Mat4.mul(Mat4.rotateY(yaw), R) end + local tl, tr = math.tan(fov.angleLeft), math.tan(fov.angleRight) + local tu, td = math.tan(fov.angleUp), math.tan(fov.angleDown) + -- world columns of the head's rotation: right (X), up (Y), forward (-Z) + local rxc, ryc, rzc = Rw[1], Rw[5], Rw[9] + local uxc, uyc, uzc = Rw[2], Rw[6], Rw[10] + local fxc, fyc, fzc = -Rw[3], -Rw[7], -Rw[11] + local skyRay = { + base = { fxc + rxc * tl + uxc * tu, + fyc + ryc * tl + uyc * tu, + fzc + rzc * tl + uzc * tu }, + du = { rxc * (tr - tl), ryc * (tr - tl), rzc * (tr - tl) }, + dv = { uxc * (td - tu), uyc * (td - tu), uzc * (td - tu) }, + } + -- the eye and its forward, in world pixels: worldFromEye applied to the -- origin and to -Z local ax, ay, az = px - anchor[1], py - anchor[2], pz - anchor[3] @@ -177,6 +200,7 @@ function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw) focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale }, fov = fov.angleUp - fov.angleDown, curve = 0, + skyRay = skyRay, } end diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index aa3cb58..c161aff 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -444,6 +444,13 @@ end -- way either way. Voxel3D.camera = nil +-- This frame's camera RAY FAN, set by viewProjection alongside vp: the +-- world direction a canvas point looks along (see Sky.paint's `ray`). +-- Present for every free-pitch camera -- the VR eyes bring theirs +-- (VRRig.eyeCamera), a placed eye/focus camera gets one built -- and nil +-- for the orbit, whose frame-hung sky is the classic look. +Voxel3D.skyRayLive = nil + -- ------- which way, and how steeply, this camera looks -- -- Two facts about the view direction, set alongside the eye and the focus @@ -495,6 +502,8 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- reason as every other branch here if cam.view and cam.proj then Voxel3D.fovY = cam.fov + -- the VR eyes bring their fan with them (VRRig.eyeCamera) + Voxel3D.skyRayLive = cam.skyRay return Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), cam.proj), cam.view) end local dx = eye[1] - focus[1] @@ -510,6 +519,35 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- the same clip-space Y flip the orbit needs, for the same reason: we -- bypass LOVE's transform_projection and canvas coordinates run Y down proj = Mat4.mul(Mat4.scale(1, -1, 1), proj) + -- The camera's RAY FAN, for the sky's skybox path (Sky.paint's `ray`): + -- a placed camera with a FREE PITCH -- the first-person rig, steered + -- by a mouse on the flat screen -- must not hang its gradient off the + -- frame, or looking up and down drags the bands with the view. Built + -- from the very basis the view below is: forward, the true right, the + -- true up, and the symmetric frustum's tangents. + local upv = cam.up or { 0, 1, 0 } + local fx, fy, fz = -dx / dist, -dy / dist, -dz / dist + local crx = fy * upv[3] - fz * upv[2] + local cry = fz * upv[1] - fx * upv[3] + local crz = fx * upv[2] - fy * upv[1] + local crl = math.sqrt(crx * crx + cry * cry + crz * crz) + if crl > 1e-6 then + crx, cry, crz = crx / crl, cry / crl, crz / crl + local cux = cry * fz - crz * fy + local cuy = crz * fx - crx * fz + local cuz = crx * fy - cry * fx + local tanY = math.tan(cam.fov / 2) + local tanX = tanY * (vw / vh) + Voxel3D.skyRayLive = { + base = { fx - crx * tanX + cux * tanY, + fy - cry * tanX + cuy * tanY, + fz - crz * tanX + cuz * tanY }, + du = { crx * 2 * tanX, cry * 2 * tanX, crz * 2 * tanX }, + dv = { cux * -2 * tanY, cuy * -2 * tanY, cuz * -2 * tanY }, + } + else + Voxel3D.skyRayLive = nil + end -- world up by default, so the horizon stays level -- a placed camera -- that rolled with its own pitch would tip the whole arena. A caller -- may hand its own up: the first-person BLEND does, because its far @@ -518,6 +556,10 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 })) end + -- the orbit: a fixed pitch per rung, and the classic frame-hung sky -- + -- no ray fan wanted + Voxel3D.skyRayLive = nil + local a = Voxel.angle local focal = Voxel.FOCAL local dist = focal * vh @@ -685,6 +727,72 @@ function Voxel3D.skyBody(w, h) } end +-- ------- the VR sky's world-anchored pieces +-- +-- Both exist because a headset showed the shortcuts: a gradient painted +-- off the frame moved with the head that carried the frame, and a +-- screen-space disc re-snapped its cell grid with every head movement +-- and held its face square to the canvas instead of to the world. The +-- gradient's fix rides the camera record itself (skyRay -- see VRRig and +-- Sky's useRay path); the disc's is below. + +-- The sun or moon as a QUAD IN THE WORLD: the baked cell art +-- (Sky.discImage) on a square spanned about the hour's direction, its +-- corners projected through this very eye -- so the disc is pinned to +-- the sky like the terrain is to the ground, stable under every head +-- motion, its face upright over the world. Runs inside beginScene's sky +-- window, before the depth mode is set, so the world draws over it. +local discMesh = nil + +local function drawWorldDisc(w, h) + local b = DayNight.body() + if not (b and b.dy and b.dy > 0.005) then return end + local amt = DayNight.glow() + local img = Sky.discImage(b.moon, Sky.discLooming(amt, b.moon)) + if not img then return end + local m = Voxel3D.vp + if not m then return end + local hl = math.sqrt(b.dx * b.dx + b.dz * b.dz) + if hl < 1e-6 then return end + -- right = horizontal, perpendicular to the direction; up completes it + local rx, rz = b.dz / hl, -b.dx / hl + local ux = -rz * b.dy + local uy = rz * b.dx - rx * b.dz + local uz = rx * b.dy + local ul = math.sqrt(ux * ux + uy * uy + uz * uz) + if ul < 1e-6 then return end + ux, uy, uz = ux / ul, uy / ul, uz / ul + if uy < 0 then ux, uy, uz = -ux, -uy, -uz end + -- apparent size is an ANGLE, the same fraction of the view the flat + -- screen's disc takes of its frame; the low sun looms exactly as there + local ang = Sky.DISC_FRAC * (Voxel3D.fovY or 1) + if Sky.discLooming(amt, b.moon) then ang = ang * 1.4 end + local k = math.tan(ang) + local verts = {} + local corners = { { -1, -1, 0, 1 }, { 1, -1, 1, 1 }, + { 1, 1, 1, 0 }, { -1, 1, 0, 0 } } + for i, c in ipairs(corners) do + local vx = b.dx + (rx * c[1] + ux * c[2]) * k + local vy = b.dy + (uy * c[2]) * k + local vz = b.dz + (rz * c[1] + uz * c[2]) * k + local x = m[1] * vx + m[2] * vy + m[3] * vz + local y = m[5] * vx + m[6] * vy + m[7] * vz + local ww = m[13] * vx + m[14] * vy + m[15] * vz + if ww <= 1e-6 then return end + verts[i] = { (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h, + c[3], c[4] } + end + pcall(function() + if not discMesh then + discMesh = love.graphics.newMesh(4, "fan", "stream") + end + discMesh:setVertices(verts) + discMesh:setTexture(img) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(discMesh) + end) +end + -- ----------------------------------------------------------------- scene -- -- Begin the 3D pass into a `w` x `h` pixel canvas centred on world @@ -739,38 +847,20 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- screen. The sky's dither grid is cut to it, and so is the water's -- -- one number, so the two break up on the same checkerboard. Voxel3D.cell = w / math.max(1, vw or w) - -- A VR eye's sky is ANCHORED IN SPACE, where the flat screen's is glued - -- to the frame. The differences all key off the raw-matrix camera only - -- the VR eyes bring: the horizon must really be in frame for any band - -- to paint (no Sky.SPAN fallback -- that slice pinned to the top of the - -- view is exactly "the sky moves with the headset"); the gradient hangs - -- over a fixed ELEVATION span above the horizon rather than filling up - -- to the frame's edge; the whole painting runs along the horizon's OWN - -- AXIS (horizonLine), so a rolled head sees the horizon hold level in - -- the world instead of hinging with the ears; and the sun or moon was - -- already honest -- skyBody projects the hour's direction through this - -- very eye. - local vrEye = Voxel3D.camera and Voxel3D.camera.view and Voxel3D.camera.proj - and true or false + -- A FREE-PITCH camera's sky is ANCHORED IN SPACE, where the orbit's is + -- glued to the frame. One discriminator: skyRayLive, set by + -- viewProjection above for every camera whose pitch the player steers + -- -- the VR eyes and the flat first-person rig alike. With a fan, the + -- gradient is a SKYBOX (every pixel takes its band, and its GBC + -- checker, from its ray's true elevation -- no motion of the camera + -- moves a band, only the clock recolours them) and the sun or moon + -- hangs in the WORLD (drawWorldDisc). Without one -- the orbit, whose + -- pitch is the rung's -- the classic frame-hung painting stands. + local skyRay = Voxel3D.skyRayLive local hy = Voxel3D.horizonY(h) - local ax, ay, edgeT, topT - if vrEye then - ax, ay, edgeT, topT = Voxel3D.horizonLine(w, h, Sky.ELEV_SPAN) - end - -- any sky in frame at all? the corner most toward the sky must sit - -- above the horizon's line - local skyUp = false - if sky and sky.bands then - if not vrEye then - skyUp = true - elseif edgeT then - local minT = math.min(0, w * ax, h * ay, w * ax + h * ay) - skyUp = edgeT > minT + 1 - end - end - -- and where the sky's bottom edge lands, which is what the reflection + -- where the sky's bottom edge lands, which is what the reflection -- reads its bands against (see Water). nil when nothing painted bands. - Voxel3D.skyEdge = skyUp and Sky.region(h, hy) or nil + Voxel3D.skyEdge = (sky and sky.bands) and Sky.region(h, hy) or nil if sky then love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) -- The sky goes down here, in the one window in this function where a @@ -783,24 +873,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- are the same size as the world's own and follow every resize and zoom. -- The banded sky also hangs the hour's sun or moon (skyBody projects it -- through this very camera); a flat sky has no bands and hangs nothing. - if not vrEye then + if skyRay and sky.bands then + Sky.paint(w, h, sky, nil, Voxel3D.cell, Voxel3D.skyBody(w, h), + nil, nil, skyRay) + drawWorldDisc(w, h) + else Sky.paint(w, h, sky, hy, Voxel3D.cell, sky.bands and Voxel3D.skyBody(w, h) or nil) - elseif skyUp then - -- the gradient's far end: the ELEV_SPAN direction's own projection - -- when the frustum holds it (horizonLine's `top` -- exact, so a - -- pitch slides the frame over bands that stay put), and the - -- pixels-per-radian estimate when it does not - local top = topT - if not (top and top < edgeT - 1) then - local radPerPx = math.max(1e-6, (Voxel3D.fovY or 1) / h) - top = edgeT - Sky.ELEV_SPAN / radPerPx - end - Sky.paint(w, h, sky, edgeT, Voxel3D.cell, - Voxel3D.skyBody(w, h), top, { ax, ay }) end - -- a VR eye with no horizon in frame paints nothing: everything in view - -- is below the horizon, and the haze clear above already filled it else love.graphics.clear(0, 0, 0, 0, true, true) end @@ -1273,6 +1353,9 @@ function Voxel3D.invalidate() end canvas, canvasW, canvasH = nil, 0, 0 held = nil + -- the VR sky's disc mesh belongs to this context like the canvases do + if discMesh and discMesh.release then pcall(discMesh.release, discMesh) end + discMesh = nil ShadowMap.invalidate() -- the sky is part of this pass and holds a shader of its own Sky.invalidate() diff --git a/main.lua b/main.lua index 5c52632..2b07e0f 100644 --- a/main.lua +++ b/main.lua @@ -501,6 +501,29 @@ local HOTKEYS = { ["9"] = Water.setting, } +-- One step of the VOXEL angle ladder: everything a "3" press does, named +-- so the pad's SELECT button (below) can make exactly the same step. The +-- gate is the registry's own; the tilt/GBC FX clearing is the engine work +-- the key has always delegated (see the wrap below for why). +local function cycleVoxel(game) + local Pipelines = require("src.render.Pipelines") + local top = game.stack and game.stack:top() + if not Pipelines.canToggle("voxel", top, game.overworld) then return false end + Pipelines.setLevel("voxel", Voxel.nextHotkeyLevel(Pipelines.level("voxel"))) + Pipelines.syncOptions(game.save.options) + -- 3 is the key that used to turn TILT on and sits next to the one that + -- used to turn GBC FX on, and this mod has taken both away. A player who + -- left either running before enabling the mod would otherwise have no + -- way back to off, and both fight the diorama -- so the VOXEL step + -- clears them on EVERY press, not just the press that switches on. + game.save.options.tilt = 0 + game.save.options.gbcfx = 0 + require("src.render.GBCFX").setLevel(0) + require("src.render.Tilt").setLevel(game.save.options.tilt or 0) + game:writeOptions() + return true +end + do local Game = require("src.core.Game") local Pipelines = require("src.render.Pipelines") @@ -517,32 +540,12 @@ do -- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER), -- so the registry's plain "advance one and wrap" is not what it -- wants; 6 still is. The gate is the registry's own either way. - local stepped = false + -- The whole of 3's step lives in cycleVoxel, because the pad's + -- SELECT button makes the same step (see the handleInput wrap). if key == "3" then - if Pipelines.canToggle("voxel", top, self.overworld) then - Pipelines.setLevel("voxel", - Voxel.nextHotkeyLevel(Pipelines.level("voxel"))) - stepped = true - end - else - stepped = Pipelines.hotkey(key, top, self.overworld) and true - end - if stepped then + if cycleVoxel(self) then return end + elseif Pipelines.hotkey(key, top, self.overworld) then Pipelines.syncOptions(self.save.options) - -- 3 is the key that used to turn TILT on and sits next to the one - -- that used to turn GBC FX on, and this mod has taken both away. - -- A player who left either running before enabling the mod would - -- otherwise have no way back to off, and both fight the diorama: - -- TILT is the flat fake of what this mode does for real, and GBC - -- FX is a full-screen present pass over the top of it. So the - -- VOXEL key clears them on EVERY press, not just the press that - -- switches the mode on -- cycling back round to OFF leaves them - -- off too, which is the state the key is now the only route to. - if key == "3" then - self.save.options.tilt = 0 - self.save.options.gbcfx = 0 - require("src.render.GBCFX").setLevel(0) - end require("src.render.Tilt").setLevel(self.save.options.tilt or 0) self:writeOptions() return @@ -878,6 +881,38 @@ OverworldBattle.install() FirstPerson.install() FreeMove.install() +-- ------- SELECT walks the angle ladder +-- +-- The same step the "3" key makes, on the pad's own button: a phone (and +-- a controller) has no number row, and SELECT has no overworld job in +-- Gen 1 -- its work is all in-menu, which this wrap never sees. The seam +-- is OverworldState:handleInput, the same choke point the free walk +-- replaced: every gate above it -- menus, dialogs, scripted moves, +-- transitions -- already decided the overworld owns the buttons, so a +-- SELECT here is free-roam by construction, exactly like the key. When +-- the step is refused (mid-warp, no 3D pass) the press falls through to +-- the engine's own handling, which is a no-op, as ever. +-- +-- Installed AFTER FreeMove.install, deliberately: its wrap must sit +-- OUTSIDE the free walk's, or first person -- where FreeMove.tick takes +-- the frame and never calls further in -- would eat the button, and the +-- one rung SELECT could not step off of would be 1ST itself. +do + local OverworldState = require("src.world.OverworldController") + if not OverworldState.dramaticShapeSelectHook then + local inner = OverworldState.handleInput + function OverworldState:handleInput(...) + local Game = require("src.core.Game") + local input = Game.input + if input and input.wasPressed and input:wasPressed("select") then + if cycleVoxel(Game) then return end + end + return inner(self, ...) + end + OverworldState.dramaticShapeSelectHook = true + end +end + -- The overworld's own pushBattle is the choke point for a wild encounter or -- a trainer, and it is wrapped. A battle that arrives some other way -- a -- link battle, a script pushing a BattleState directly -- reaches this @@ -979,7 +1014,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.6.0" +mod.exports.version = "1.5.2" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index fdbaac1..b8550d8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.6.0", + "version": "1.5.2", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/mod.card b/mod.card index c860623..1d7ba9b 100644 --- a/mod.card +++ b/mod.card @@ -16,6 +16,7 @@ return { "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", "the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back", "hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded", + "SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched", "on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched", "on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view", }, diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index c709058..40cf0b9 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -1126,6 +1126,34 @@ T.eq(Battles.setting:get(), false, "8 toggles overworld battles off") Game.keypressed(keyGame, "8") T.eq(Battles.setting:get(), true, "and back on") +-- ------- SELECT makes the same step the 3 key does +-- +-- The pad's own button, for the machines with no number row. The wrap +-- sits on OverworldState:handleInput -- free-roam by construction -- and +-- reads the live Game's input, so the fake free-roam shape the key tests +-- built is lent to the Game module for the length of the check. +-- (An anonymous function scope: the main chunk sits AT LuaJIT's +-- 200-active-locals ceiling, so even a named wrapper is one too many.) +;(function() + local OverworldState = require("src.world.OverworldController") + T.eq(OverworldState.dramaticShapeSelectHook, true, + "the SELECT wrap is installed on the overworld input seam") + local hadInput, hadStack = Game.input, Game.stack + local hadOw, hadSave, hadWrite = Game.overworld, Game.save, Game.writeOptions + Game.input = { wasPressed = function(_, b) return b == "select" end } + Game.stack, Game.overworld = keyGame.stack, keyGame.overworld + Game.save, Game.writeOptions = keyGame.save, keyGame.writeOptions + Pipelines.setLevel("voxel", 0) + OverworldState.handleInput({}) + T.eq(Pipelines.levelLabel("voxel"), "15", + "SELECT steps the VOXEL ladder exactly as 3 does") + OverworldState.handleInput({}) + T.eq(Pipelines.levelLabel("voxel"), "35", "and keeps walking it") + Game.input, Game.stack = hadInput, hadStack + Game.overworld, Game.save, Game.writeOptions = hadOw, hadSave, hadWrite + Pipelines.setLevel("voxel", 0) +end)() + -- 3 also clears the two engine modes it displaced. Without this a player -- who left TILT or GBC FX on before enabling the mod has no key left to -- turn them off with, and both fight the diorama -- TILT is the flat fake @@ -3807,6 +3835,32 @@ local wx2, wy2, wz2 = apply(seated.view, 150 - VRRig.FP_SCALE, 10, 200) T.check(near(wx2, 0) and near(wy2, 0) and near(wz2, -1), "the yawed view carries one metre west of the pivot to one metre ahead") +-- the sky's RAY FAN round-trips the eye's own projection: the direction +-- the fan hands a canvas point looks along projects back to that very +-- point, through a rotated head AND a yawed mapping -- one sign wrong +-- anywhere here and the skybox paints sideways or upside down +local rayCam = VRRig.eyeCamera({ pos = { 0.2, 1.1, -0.4 }, + quat = { 0, s, 0, math.cos(math.pi / 4) } }, + fov, { 50, 0, 70 }, { 0, 0, 0 }, 10, + math.pi / 3) +local rm = Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), rayCam.proj), rayCam.view) +local function rayFrac(u, v) + local sr = rayCam.skyRay + local d1 = sr.base[1] + u * sr.du[1] + v * sr.dv[1] + local d2 = sr.base[2] + u * sr.du[2] + v * sr.dv[2] + local d3 = sr.base[3] + u * sr.du[3] + v * sr.dv[3] + local x = rm[1] * d1 + rm[2] * d2 + rm[3] * d3 + local y = rm[5] * d1 + rm[6] * d2 + rm[7] * d3 + local ww = rm[13] * d1 + rm[14] * d2 + rm[15] * d3 + return x / ww * 0.5 + 0.5, y / ww * 0.5 + 0.5 +end +local fu, fv = rayFrac(0.3, 0.8) +T.check(near(fu, 0.3, 1e-4) and near(fv, 0.8, 1e-4), + "the sky's ray fan round-trips the eye's own projection") +local fu2, fv2 = rayFrac(0.9, 0.1) +T.check(near(fu2, 0.9, 1e-4) and near(fv2, 0.1, 1e-4), + "at every corner of the frame alike") + -- ------- the hand prop: the pokedex rides the same mapping as the eyes -- -- propMatrix carries a hand pose through worldFromXr: an identity hand at @@ -3896,6 +3950,31 @@ T.check(near(bex, 96, 1e-3) and near(bey, 10, 1e-3) and near(bez, 192, 1e-3), "and the enemy mark over the enemy's") V3D_.eye = hadEye +-- the FLAT first-person rig's sky fan: a placed eye/focus camera through +-- viewProjection carries a ray fan of its own, and it round-trips that +-- projection exactly like the VR eyes' does -- the flat 1ST sky is a +-- skybox by the same math +local hadCam = V3D_.camera +V3D_.camera = { eye = { 100, 13, 200 }, focus = { 130, 18, 160 }, + fov = 1.1, up = { 0, 1, 0 } } +local pvp = V3D_.viewProjection(0, 0, 320, 288) +local psr = V3D_.skyRayLive +T.check(psr ~= nil, "a placed free-pitch camera carries a ray fan") +local function pFrac(u, v) + local d1 = psr.base[1] + u * psr.du[1] + v * psr.dv[1] + local d2 = psr.base[2] + u * psr.du[2] + v * psr.dv[2] + local d3 = psr.base[3] + u * psr.du[3] + v * psr.dv[3] + local x = pvp[1] * d1 + pvp[2] * d2 + pvp[3] * d3 + local y = pvp[5] * d1 + pvp[6] * d2 + pvp[7] * d3 + local ww = pvp[13] * d1 + pvp[14] * d2 + pvp[15] * d3 + return x / ww * 0.5 + 0.5, y / ww * 0.5 + 0.5 +end +local pu, pv = pFrac(0.25, 0.6) +T.check(near(pu, 0.25, 1e-4) and near(pv, 0.6, 1e-4), + "and it round-trips the placed camera's own projection") +V3D_.camera = hadCam +V3D_.skyRayLive = nil + -- ------- VR owns the battle rows while it is on local VRSet = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR").setting VRSet:sync(true)