diff --git a/lib/Diorama.lua b/lib/Diorama.lua new file mode 100644 index 0000000..c63b3b1 --- /dev/null +++ b/lib/Diorama.lua @@ -0,0 +1,304 @@ +-- The DIORAMA modes: Kanto as a model you can pick up. +-- +-- STANDARD VR presents whatever rung the player is on -- the orbit rungs +-- become a tabletop, 1ST stands you inside the world (see lib/VR.lua). +-- DIORAMA is a different promise, and it is one promise rather than a +-- ladder: the world is ALWAYS the model on the table, seen from outside, +-- and what the headset adds is that the model is a THING IN THE ROOM -- +-- grab it, turn it, set it down somewhere else, decide how much of it you +-- want to be holding. +-- +-- Two pieces make that read, and this file owns both. +-- +-- THE VIEWPORT. Everything outside an invisible BOX centred on the view +-- is simply not drawn -- the Final Fantasy Tactics read, a square slab +-- of the world sitting in the air rather than a map running off to a +-- horizon. A square cut with a HARD edge, because a flat world is a +-- thing with sides and the sides are what say so. +-- +-- V-CURVE is what changes its shape. With the bend on, the world is not +-- flat any more -- it is a little globe curling away over its own +-- horizon -- and a square cut through a globe is a lie about what is +-- being looked at. So the box becomes a BALL, and its rim becomes a +-- GRADIENT that dissolves into the sky rather than an edge that +-- guillotines it. One click of the left stick (which throws V-CURVE -- +-- see lib/VR) swaps between the two readings of the same model. +-- +-- A staged fight ignores both and cuts a vertical PILLAR about the +-- arena, which lifts the fight out of the map as a floating disc. +-- +-- (A BASE was built under all this once -- the ground extruded a tile +-- deep, cut to the viewport's shape, wearing Mt Moon's cave floor down +-- its sides -- and it was REMOVED at the user's request. The cut ends at +-- the ground plane now; don't put a plinth back under it.) +-- +-- THE GRIP. Squeeze one and the model follows that hand through the +-- room; squeeze both and it turns with them and the viewport resizes +-- to whatever you open your hands to. All of it is arithmetic on the +-- XR-to-world mapping lib/VRRig already had (an anchor, a yaw and a +-- scale), so nothing about the world's own geometry knows this is +-- happening. +-- +-- DIORAMA-MR is the same mode with the background keyed pure green, for +-- a mixed-reality capture that composites the model into the room the +-- player is actually standing in. +-- +-- Nothing here reaches the flat screen: every field is set by lib/VR for +-- the length of one headset frame and cleared with the session. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Diorama = {} + +-- ------- the viewport +-- +-- The half-size at rest, as a fraction of the view height the flat screen +-- frames. Sized off the VIEW rather than fixed in world pixels so the zoom +-- rows keep meaning what they mean -- a zoomed-in rung frames less world +-- and gets a smaller model, exactly as it frames a smaller picture. +-- +-- The BOX takes half of it, so the square is exactly the view the standard +-- rung would have shown, edge to edge. The BALL takes rather more: a ball +-- inscribed in that square holds noticeably less world (its corners are +-- the four biggest pieces of it), and the point of the V-CURVE throw is to +-- see the same model curl, not to lose a quarter of it. +Diorama.BOX_FRAC = 0.5 +Diorama.BALL_FRAC = 0.62 + +-- How far the grips may open and close it, as a multiplier on that. +Diorama.SCALE_MIN = 0.3 +Diorama.SCALE_MAX = 4 + +-- The rim under V-CURVE, as a fraction of the radius: where the world +-- starts fading and where it has finished. Wide enough to read as a +-- dissolve rather than an edge, narrow enough that the middle of the model +-- is solid. The BOX has no fade at all -- see fadeFor. +Diorama.FADE_FRAC = 0.16 + +-- The staged fight's disc: the arena's own half-length plus an apron, in +-- world pixels (a map cell is 16). The two mons stand three cells apart, +-- so this is a disc about seven cells across -- the fight, the ground it +-- is fought on, and nothing else. +Diorama.ARENA_APRON = 32 + +-- ------- what the live frame is +-- +-- All three set by lib/VR for the length of one headset frame, and by +-- nothing else. `on` is the whole mode's gate; VoxelScene reads it once +-- per frame and every diorama-shaped thing hangs off that read. +Diorama.on = false +Diorama.keyed = false +Diorama.cull = nil -- { x, y, z, r, invFade, kind } + +-- Chroma green, and PURE green deliberately: a keyer wants the one colour +-- nothing in the picture can accidentally be, and no palette this mod can +-- paint the world in reaches 0,255,0. +Diorama.KEY_COLOR = { 0, 1, 0 } + +-- ------- what the grips have done to it +-- +-- Kept across frames (this is where the model IS, as far as the player is +-- concerned) and cleared only when the session ends. `offset` is in LOCAL +-- metres and rides the mapping's anchor, `yaw` turns the mapping, `zoom` +-- multiplies the viewport's radius. +Diorama.offset = { 0, 0, 0 } +Diorama.yaw = 0 +Diorama.zoom = 1 + +function Diorama.reset() + Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil + Diorama.offset = { 0, 0, 0 } + Diorama.yaw, Diorama.zoom = 0, 1 + Diorama.release() +end + +-- Open a diorama frame. `mode` is VR.mode()'s answer; anything that is +-- not a diorama mode closes it. +function Diorama.begin(mode) + Diorama.on = (mode == "diorama" or mode == "diorama-mr") + Diorama.keyed = Diorama.on and mode == "diorama-mr" + if not Diorama.on then Diorama.cull = nil end + return Diorama.on +end + +function Diorama.stop() + Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil +end + +-- What the world's background must be cleared to, or nil to leave the sky +-- alone. Only ever a colour in DIORAMA-MR, and only while a frame is open. +function Diorama.keyColor() + if not (Diorama.on and Diorama.keyed) then return nil end + return Diorama.KEY_COLOR +end + +-- ------- the viewport, as the shaders take it +-- +-- `kind` is the shader's own switch: 0 no cut, 1 the box, 2 the ball, 3 +-- the fight's pillar. `invFade` is one over the fade band in world pixels, +-- so the rim is a single multiply out there -- and a hard edge is simply a +-- band under a pixel wide, which costs the shader no branch of its own. +Diorama.BOX = 1 +Diorama.BALL = 2 +Diorama.PILLAR = 3 + +-- The half-size the viewport stands at right now, for a view `vh` world +-- pixels tall -- the flat framing this rung would have shown -- and for +-- the shape it is currently in. +function Diorama.radius(vh, curved) + local frac = curved and Diorama.BALL_FRAC or Diorama.BOX_FRAC + return math.max(24, (vh or 288) * frac * Diorama.zoom) +end + +-- Whether the world is BENT right now, which is the whole of what decides +-- the viewport's shape: a square cut suits a flat slab of map, and a +-- curved world rolling away over its own horizon wants a ball with a +-- dissolve. Asked of the row rather than remembered, so the V-CURVE the +-- stick click throws (and the "7" key, and the OPTIONS row) all reach it. +function Diorama.curved() + local ok, on = pcall(function() + return V.require("WorldCurve").active() + end) + return ok and on or false +end + +-- The fade band for a cut of half-size `r`: the curve's dissolve, or a +-- hard edge (band 0) for the box. +function Diorama.fadeFor(r, curved) + if not curved then return 0 end + return math.max(1, r * Diorama.FADE_FRAC) +end + +local function volume(kind, x, y, z, r, fade) + return { x = x, y = y, z = z, r = r, + -- a zero band is a hard edge: half a pixel of ramp, which is + -- one pixel of antialiasing rather than a stair + invFade = 1 / math.max(fade or 0, 0.5), kind = kind } +end + +-- The viewport this frame, centred on the world point the model is pinned +-- by: the BOX ordinarily, and the BALL while the world is curved. +function Diorama.viewport(cx, cy, vh) + local curved = Diorama.curved() + local r = Diorama.radius(vh, curved) + Diorama.cull = volume(curved and Diorama.BALL or Diorama.BOX, + cx, 0, cy, r, Diorama.fadeFor(r, curved)) + return Diorama.cull +end + +-- The staged fight's disc: a vertical pillar about the arena's midpoint, +-- wide enough for both mons and their apron. Vertical means UNBOUNDED -- +-- a tree standing on the disc keeps all of its height, which is what +-- makes the cut read as the ground having been lifted out rather than as +-- the world having been sliced through at eye level. +function Diorama.pillar(arena) + if not (arena and arena.mid) then return nil end + local mx, mz = arena.mid[1], arena.mid[2] + local r = Diorama.ARENA_APRON + if arena.player and arena.enemy then + local dx = arena.player[1] - mx + local dz = arena.player[2] - mz + r = r + math.sqrt(dx * dx + dz * dz) + end + -- Round whatever the curve is doing -- a fight is a disc, and a square + -- arena tile floating in the air is not the picture -- and ALWAYS + -- dissolved at the rim, curve or no curve. The box's hard edge is there + -- to say "this is a flat slab of map with sides"; a fight is a thing + -- lifted out of the world and hanging in the air, and a hard edge on it + -- reads as a cookie cutter rather than as a piece of ground. + Diorama.cull = volume(Diorama.PILLAR, mx, 0, mz, r, + Diorama.fadeFor(r, true)) + return Diorama.cull +end + +-- ------- the grips +-- +-- One hand carries the model; two turn it and open the viewport. The +-- gesture is measured as a DELTA per frame rather than from where the +-- squeeze started, so letting go and taking hold again never snaps +-- anything -- the model simply stops following and starts again. + +Diorama.GRIP = 0.6 -- squeezed past this counts as holding on +Diorama.SPREAD_MIN = 0.08 -- hands closer than this give no scale + +local lastOne = nil -- the carrying hand's position, last frame +local lastMid = nil -- both hands' midpoint +local lastAngle = nil -- and the bearing of the line between them +local lastSpread = nil -- and its length + +local function clearGrab() + lastOne, lastMid, lastAngle, lastSpread = nil, nil, nil, nil +end + +Diorama.releaseGrab = clearGrab + +-- Advance the grab from this frame's controller state (lib/VRXR's table: +-- gripL/gripR in 0..1, handl/handr as { pos, quat } when tracked). +-- Returns true while the model is being held. +function Diorama.gesture(ctl) + if not ctl then + clearGrab() + return false + end + local gl, gr = ctl.gripL or 0, ctl.gripR or 0 + local hl = (gl > Diorama.GRIP) and ctl.handl or nil + local hr = (gr > Diorama.GRIP) and ctl.handr or nil + + if hl and hr then + lastOne = nil + local lp, rp = hl.pos, hr.pos + local mid = { (lp[1] + rp[1]) / 2, (lp[2] + rp[2]) / 2, + (lp[3] + rp[3]) / 2 } + local dx, dy, dz = rp[1] - lp[1], rp[2] - lp[2], rp[3] - lp[3] + local spread = math.sqrt(dx * dx + dy * dy + dz * dz) + -- the bearing of the line between the hands, in the same convention + -- the mapping's yaw turns through (see VRRig.eyeCamera): atan2 of the + -- x component over the z one, so a hand-over-hand turn and the model's + -- turn are the same number + local angle = math.atan2(dx, dz) + if lastMid then + for i = 1, 3 do + Diorama.offset[i] = Diorama.offset[i] + (mid[i] - lastMid[i]) + end + end + if lastAngle then + local d = (angle - lastAngle + math.pi) % (2 * math.pi) - math.pi + Diorama.yaw = (Diorama.yaw + d + math.pi) % (2 * math.pi) - math.pi + end + if lastSpread and lastSpread > Diorama.SPREAD_MIN + and spread > Diorama.SPREAD_MIN then + Diorama.zoom = math.max(Diorama.SCALE_MIN, + math.min(Diorama.SCALE_MAX, + Diorama.zoom * (spread / lastSpread))) + end + lastMid, lastAngle, lastSpread = mid, angle, spread + return true + end + + lastMid, lastAngle, lastSpread = nil, nil, nil + local one = hl or hr + if one then + if lastOne then + for i = 1, 3 do + Diorama.offset[i] = Diorama.offset[i] + (one.pos[i] - lastOne[i]) + end + end + lastOne = { one.pos[1], one.pos[2], one.pos[3] } + return true + end + lastOne = nil + return false +end + +-- Nothing here owns a GPU object any more (the base did, and it is gone -- +-- see the header), so this is only the grab's own hand-to-hand state: a +-- window resize or a hot reload should not leave the model following a +-- delta measured against a frame that no longer exists. +function Diorama.release() + clearGrab() +end + +Diorama.invalidate = Diorama.release + +return Diorama diff --git a/tests/diorama_shots.lua b/tests/diorama_shots.lua new file mode 100644 index 0000000..feafb20 --- /dev/null +++ b/tests/diorama_shots.lua @@ -0,0 +1,162 @@ +-- Driver: the DIORAMA mode's own frame, without a headset. +-- +-- The diorama is drawn through VoxelScene's `eyes` path, and that path only +-- ever runs from lib/VR -- so with no OpenXR runtime on the machine there is +-- nothing to look at and nothing to check. This builds ONE eye by hand (the +-- same VRRig mapping the headset would have built), opens a diorama frame, +-- and encodes the eye canvas straight to a PNG. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/diorama_shots.lua \ +-- SHOT_DIR=