Files
DramaticShapeVoxelMod/lib/VRRig.lua
T
2026-08-06 19:56:39 -04:00

275 lines
12 KiB
Lua

-- VR: the pose arithmetic -- how a headset eye becomes one of this mod's
-- cameras. Pure math on purpose: no FFI, no OpenXR types, nothing a
-- headless test cannot hold still. Everything device-shaped stays in
-- VRXR/VRGL; everything world-shaped is here.
--
-- Two ways the world can sit around a headset, and they mirror the VOXEL
-- ladder exactly:
--
-- DIORAMA every orbit rung. The map is a tabletop miniature: a point
-- of the world (the view centre) is pinned VIEW_DIST away
-- along the rung's own viewing angle (dioramaAnchor), at the
-- scale that reproduces the flat screen's framing
-- (dioramaScale) -- so at rest the model presents exactly as
-- the standard view does, and the head moves freely around it
-- -- lean in and the town grows, walk around the table and
-- see the far side of the buildings honest occlusion has been
-- hiding.
--
-- FIRST_PERSON the 1ST rung. The player's head is pinned to where the
-- headset started, at FP_SCALE, so a 16-pixel person stands
-- about 1.6 m tall and a cell is a stride. The HMD's own
-- orientation becomes FirstPerson's yaw and pitch, so movement
-- stays "push forward, go where you look" through the same
-- FreeMove the flat screen uses.
--
-- SPACES AND UNITS. OpenXR LOCAL space is metres, +Y up, -Z the way the
-- head faced at session start. World space is world PIXELS, +Y up, +Z
-- south. The two are aligned axis-for-axis -- "away from you" is north --
-- so the whole mapping is one translate-and-scale:
--
-- worldFromXr(p) = pivot + s * (p - anchor)
--
-- with `pivot` a world point, `anchor` the LOCAL-space point pinned to it,
-- and `s` the scale in px/m. An eye's camera is then
--
-- worldFromEye = T(pivot) * S(s) * T(-anchor) * T(pose.pos) * R(pose.q)
-- view = the same chain inverted piece by rigid piece
--
-- and the VIEW deliberately ends in METRES: it un-scales the world, so eye
-- space -- where the projection's near and far live -- is real-world
-- metres whatever the mode's scale. Depth precision and clip planes stay
-- sane at both 10 px/m and 128 px/m.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local VRRig = {}
-- first person's life size: 10 px/m makes a 16 px tile a 1.6 m stride
VRRig.FP_SCALE = 10
-- How far the diorama's pivot sits from the resting head, in metres --
-- the arm's-length viewing distance the anchor and the scale below are
-- both built around.
VRRig.VIEW_DIST = 0.95
-- Where, in LOCAL metres, the diorama's pivot sits: VIEW_DIST away along
-- the RUNG'S OWN viewing angle. The flat screen's camera looks at the
-- world `a` radians off vertical; putting the pivot at (-d cos a) below
-- and (-d sin a) ahead of the resting head reproduces exactly that line
-- of sight -- step onto the 35 rung and the table presents at 35 degrees,
-- onto 75 and it rises toward eye level, easing between them as the rung
-- tween runs.
--
-- `off` is the grab-drag adjustment, in metres of LOCAL travel -- where
-- the player has carried the model to. A bare number is the height alone,
-- which is what the standard mode's one-axis drag has always sent; the
-- DIORAMA modes hand over all three (see lib/Diorama). Positive Y drags
-- the world up: the anchor is the LOCAL point pinned to the pivot, so
-- moving it moves the model with the hand rather than against it.
function VRRig.dioramaAnchor(angleRad, off)
local d = VRRig.VIEW_DIST
local ox, oy, oz = 0, 0, 0
if type(off) == "table" then
ox, oy, oz = off[1] or 0, off[2] or 0, off[3] or 0
elseif type(off) == "number" then
oy = off
end
return { ox,
-d * math.cos(angleRad or 0) + oy,
-d * math.sin(angleRad or 0) + oz }
end
-- The diorama's scale, in world px per metre: the one that makes the
-- table subtend the same field the flat screen frames. The flat camera
-- fits `vh` world pixels in a lens of focal `focal` (Voxel.FOCAL); at
-- VIEW_DIST the same framing needs vh * focal / d pixels to the metre --
-- so the resting head sees the standard view's angle AND its apparent
-- size, and the zoom rows (which change vh) keep working in VR.
function VRRig.dioramaScale(vh, focal)
return math.max(16, (vh or 288) * (focal or 1) / VRRig.VIEW_DIST)
end
-- kept as the test suite's fixed example anchor, and as the fallback for
-- an angle nobody supplied
VRRig.TABLE = { 0, -0.45, -0.75 }
-- ------- the battle mount
--
-- A staged fight snaps the headset to an OVER-THE-SHOULDER seat: the same
-- line the flat battle camera stands on (eye through focus, so the player's
-- mon is near-left and the foe far-right exactly as the flat shot frames
-- them), but pulled in to BATTLE_DIST -- the flat rig is a long lens from
-- fifteen metres back, and a headset's lens is its own eyes, so keeping the
-- distance would shrink the fight to a stage seen from the back row. 66 px
-- is the wide rig's own standing distance: six and a half metres at life
-- scale, close enough to fill the view, far enough to hold both mons in it
-- -- and short enough to stay inside the small rooms the wide rig exists
-- for.
VRRig.BATTLE_DIST = 66
-- Where the head sits for a staged fight, and which way the mapping must
-- turn so that seat FACES it. Returns the pivot (world px -- pin the XR
-- origin here at FP_SCALE) and the yaw for eyeCamera: the flat camera
-- looks along focus - eye, the resting headset looks along XR -Z (world
-- north), and the yaw is what closes that gap.
function VRRig.battleMount(eye, focus)
local dx = eye[1] - focus[1]
local dy = eye[2] - focus[2]
local dz = eye[3] - focus[3]
local len = math.sqrt(dx * dx + dy * dy + dz * dz)
if len < 1e-6 then return { eye[1], eye[2], eye[3] }, 0 end
local k = VRRig.BATTLE_DIST / len
-- Ry(yaw) sends XR forward (0,0,-1) to (-sin yaw, 0, -cos yaw); aiming
-- that along the horizontal of focus - eye solves to atan2 of eye - focus
return { focus[1] + dx * k, focus[2] + dy * k, focus[3] + dz * k },
math.atan2(dx, dz)
end
-- eye-space clip planes, in metres (see the unit note above)
VRRig.NEAR = 0.05
VRRig.FAR = 400
-- ------- one eye's camera
-- Build the placed-camera record for one eye.
--
-- pose { pos = {x,y,z} metres, quat = {x,y,z,w} } (OpenXR LOCAL)
-- fov { angleLeft, angleRight, angleUp, angleDown } signed radians
-- pivot {x,y,z} world px pinned to `anchor`
-- anchor {x,y,z} LOCAL metres (VRRig.TABLE, or 0,0,0 for first person)
-- scale world px per metre
-- yaw optional turn of the whole mapping about +Y, radians: the
-- battle mount faces the resting head at the arena with it.
-- worldFromXr(p) becomes pivot + s * Ry(yaw) * (p - anchor).
-- curveK the world curve this eye is to be drawn with (see WorldCurve);
-- omitted is 0, the curve DECLINED.
--
-- Off by default because standing inside a bent world is what first person
-- already declines on the flat screen, and the battle mount is a placed
-- shot. The DIORAMA modes are the case that wants it and asks for it: the
-- model is a thing being looked AT, so bending it into a little globe is
-- the whole point rather than a broken tabletop -- and it is what the left
-- stick's click throws (see lib/VR). Passed in rather than read here
-- because a rig has no business deciding what a row means.
--
-- Beware the shape of the answer: Voxel3D reads `camera.curve` with `or`,
-- and 0 is TRUE in Lua, so a 0 here really does pin the bend off -- which
-- is exactly why the diorama's curve did nothing until this became a
-- parameter.
--
-- Returns a table shaped for Voxel3D.camera: raw view + proj, the world
-- eye and focus (for setLook, the water's lean, the sky), fov as a
-- vertical span, and that curve.
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale, yaw, curveK)
local px, py, pz = pose.pos[1], pose.pos[2], pose.pos[3]
local q = pose.quat
local R = Mat4.fromQuat(q[1], q[2], q[3], q[4])
-- view = R^T * T(-pos) * T(anchor) * Ry(-yaw) * S(1/s) * T(-pivot)
local view = Mat4.mul(Mat4.transpose(R), Mat4.translate(-px, -py, -pz))
view = Mat4.mul(view, Mat4.translate(anchor[1], anchor[2], anchor[3]))
if yaw and yaw ~= 0 then
view = Mat4.mul(view, Mat4.rotateY(-yaw))
end
view = Mat4.mul(view, Mat4.scale(1 / scale, 1 / scale, 1 / scale))
view = Mat4.mul(view, Mat4.translate(-pivot[1], -pivot[2], -pivot[3]))
local proj = Mat4.fovProjection(fov.angleLeft, fov.angleRight,
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]
-- R's third column is the eye's +Z axis; forward is its negation
local fx, fy, fz = -R[3], -R[7], -R[11]
if yaw and yaw ~= 0 then
local c, s = math.cos(yaw), math.sin(yaw)
ax, az = c * ax + s * az, -s * ax + c * az
fx, fz = c * fx + s * fz, -s * fx + c * fz
end
local ex = pivot[1] + scale * ax
local ey = pivot[2] + scale * ay
local ez = pivot[3] + scale * az
return {
view = view,
proj = proj,
eye = { ex, ey, ez },
focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale },
fov = fov.angleUp - fov.angleDown,
curve = curveK or 0,
skyRay = skyRay,
}
end
-- The WORLD model matrix a hand-held prop stands on: worldFromXr (the
-- same mapping the eyes use -- so the prop is exactly where the hand is,
-- whatever mode the mapping is in) composed with the hand's own tracked
-- pose. A mesh authored in METRES rides it straight: the mapping's scale
-- is what turns metres into world pixels, so the prop keeps its real
-- size in the hand at the diorama's scale and at life scale alike.
--
-- model = T(pivot) * S(s) * Ry(yaw) * T(-anchor) * T(hand.pos) * R(hand.quat)
function VRRig.propMatrix(pose, pivot, anchor, scale, yaw)
local m = Mat4.translate(pivot[1], pivot[2], pivot[3])
m = Mat4.mul(m, Mat4.scale(scale, scale, scale))
if yaw and yaw ~= 0 then m = Mat4.mul(m, Mat4.rotateY(yaw)) end
m = Mat4.mul(m, Mat4.translate(-anchor[1], -anchor[2], -anchor[3]))
m = Mat4.mul(m, Mat4.translate(pose.pos[1], pose.pos[2], pose.pos[3]))
local q = pose.quat
return Mat4.mul(m, Mat4.fromQuat(q[1], q[2], q[3], q[4]))
end
-- The flat compass numbers a head orientation implies, for driving
-- FirstPerson (and through it FreeMove) from the HMD: yaw in this mod's
-- convention (0 south, pi/2 east) and pitch positive-down.
function VRRig.headYawPitch(quat)
local R = Mat4.fromQuat(quat[1], quat[2], quat[3], quat[4])
local fx, fy, fz = -R[3], -R[7], -R[11]
local flat = math.sqrt(fx * fx + fz * fz)
local yaw = flat > 1e-6 and math.atan2(fx, fz) or 0
local pitch = -math.asin(math.max(-1, math.min(1, fy)))
return yaw, pitch
end
-- The two pivots. First person pins the player's head; the diorama pins
-- the view centre at the ground plane. `gh` is the ground height under
-- the player (VoxelScene.groundAt), `eyeH` FirstPerson.EYE_HEIGHT.
function VRRig.fpPivot(pxTopLeft, pyTopLeft, gh, eyeH)
return { pxTopLeft + 8, (gh or 0) + (eyeH or 13), pyTopLeft + 8 }
end
function VRRig.dioramaPivot(cx, cy)
return { cx, 0, cy }
end
return VRRig