update sky rendering, select button changes views

This commit is contained in:
DramaticShape
2026-08-02 12:16:03 -04:00
parent 44f729e8c2
commit 1fa29a831f
10 changed files with 692 additions and 144 deletions
+25 -12
View File
@@ -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
+3 -8
View File
@@ -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
+222
View File
@@ -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
+149 -53
View File
@@ -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 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
float pos = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0) * count;
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
if keep then plot(dx, dy, c) end
end
end
end
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", dx * cell - cell / 2,
dy * cell - cell / 2, cell, cell)
end
end
end
end
g.pop()
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
+24
View File
@@ -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
+128 -45
View File
@@ -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()
+60 -25
View File
@@ -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
+1 -1
View File
@@ -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",
+1
View File
@@ -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",
},
+79
View File
@@ -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)