DramaticShapeVoxelMod archival mirror · read-only
6080531b08d00afcd9264bdcbab5c6edc5f0c1c0

add diorama vr modes

DramaticShape · 2026-08-06 · parent 53fff766a4

add diorama vr modes

Changed files

lib/Diorama.lua         | 304 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/diorama_shots.lua | 162 ++++++++++++++++++++++++++++++++++++
 2 files changed, 466 insertions(+)

Diff

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=<dir> lovec.exe .
+--
+-- knobs (env):
+--   SHOT_DIR    output directory under the LOVE save dir (default "diorama")
+--   DIO_MAP     map id                        (default VIRIDIAN_CITY)
+--   DIO_SPOT    "x,y[,facing]"                (default 20,26,up)
+--   DIO_RUNG    the voxel camera rung         (default 5, the 75 one)
+--
+-- It also prints whether each shader the mode touches actually COMPILED,
+-- which is the part a headless suite cannot answer: the cull is new source
+-- in the scene shader, the water shader and both forest ones, and a shader
+-- that will not build fails soft everywhere in this mod -- the picture just
+-- quietly loses a pass.
+return function(game)
+  local U = dofile("tests/drivers/util.lua")
+  local Pipelines = require("src.render.Pipelines")
+
+  local ROOT = os.getenv("SHOT_DIR") or "diorama"
+
+  local handle = game.mods.exports["DRAMATIC_SHAPE"]
+  if not (handle and handle.lib) then
+    print("[dio] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
+    return
+  end
+  local V = handle.lib
+  local Voxel = V.require("VoxelState")
+  local Voxel3D = V.require("Voxel3D")
+  local VoxelScene = V.require("VoxelScene")
+  local VRRig = V.require("VRRig")
+  local Diorama = V.require("Diorama")
+  local Water = V.require("Water")
+  local ChunkMesher = V.require("ChunkMesher")
+  local DayNight = V.require("DayNight")
+  local VR = V.require("VR")
+  local Curve = V.require("WorldCurve")
+
+  local MAP = os.getenv("DIO_MAP") or "VIRIDIAN_CITY"
+  local SPOT = os.getenv("DIO_SPOT") or "20,26,up"
+  local RUNG = math.floor(tonumber(os.getenv("DIO_RUNG")) or 5)
+  local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
+  sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
+  if sf == "" then sf = "up" end
+
+  require("src.world.OverworldController").rollEncounter = function() return nil end
+  DayNight.setting:sync("day")
+  U.teleport(game, MAP, sx, sy, sf)
+  Pipelines.setLevel("voxel", RUNG)
+  Pipelines.setLevel("tiltshift", 0)
+
+  local function settle()
+    for _ = 1, 900 do
+      if ChunkMesher.pending() == 0 then break end
+      U.wait(1)
+    end
+    for _ = 1, 300 do
+      if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then
+        break
+      end
+      U.wait(1)
+    end
+    U.wait(20)
+  end
+  settle()
+  settle()
+
+  print(("[dio] scene shader %s | grid variant %s | water %s")
+    :format(tostring(Voxel3D.shader() ~= nil),
+            tostring(Voxel3D.shader(true) ~= nil),
+            tostring(Water.shader(false) ~= nil)))
+
+  -- one eye, straight ahead from the resting head: the mapping lib/VR would
+  -- have built for this rung, with no pose to read off a runtime
+  local POSE = { pos = { 0, 0, 0 }, quat = { 0, 0, 0, 1 } }
+  local FOV = { angleLeft = -0.8, angleRight = 0.8,
+                angleUp = 0.7, angleDown = -0.7 }
+
+  local function shoot(name, opts)
+    local ow = game.overworld
+    local vw, vh = 320, 288
+    pcall(function() vw, vh = game.renderer:worldViewSize() end)
+    local cx = ow.camera.x + vw / 2
+    local cy = ow.camera.y + vh / 2
+
+    Diorama.begin(opts.mode or "diorama")
+    Diorama.zoom = opts.zoom or 1
+    Diorama.yaw = opts.yaw or 0
+    -- the cut's SHAPE is the V-CURVE row's (box while flat, ball while
+    -- bent), so the driver throws the row rather than asking for a shape
+    Curve.setting:sync(opts.curve or 0)
+    if opts.arena then
+      Diorama.pillar(opts.arena)
+    else
+      Diorama.viewport(cx, cy, vh)
+    end
+    local pivot = VRRig.dioramaPivot(Diorama.cull.x, Diorama.cull.z)
+    local anchor = VRRig.dioramaAnchor(Voxel.angle, Diorama.offset)
+    -- the same framing lib/VR picks: the view ordinarily, the DISC while a
+    -- fight is staged
+    local frame = opts.arena and (Diorama.cull.r * 2.6) or vh
+    local scale = VRRig.dioramaScale(frame, Voxel.FOCAL)
+    -- the bend the diorama asks its eyes for (lib/VR does the same)
+    local curveK = Curve.k(vh)
+    local eyes = {
+      { camera = VRRig.eyeCamera(POSE, FOV, pivot, anchor, scale,
+                                 opts.yaw ~= 0 and opts.yaw or nil, curveK),
+        w = 720, h = 720, slot = "vrL", adopt = false },
+      cx = pivot[1], cy = pivot[3],
+    }
+    VoxelScene.spriteLean = math.rad(75)
+    local ok, out = pcall(VoxelScene.render, ow, 0, 0, vw, vh,
+                          VR.paletteFor, eyes)
+    VoxelScene.spriteLean = nil
+    if not (ok and out and out[1]) then
+      print("[dio] " .. name .. ": no frame (" .. tostring(out) .. ")")
+      Diorama.stop()
+      return
+    end
+    local okE, err = pcall(function()
+      love.filesystem.createDirectory(ROOT)
+      local data = out[1]:newImageData()
+      data:encode("png", ROOT .. "/" .. name .. ".png")
+    end)
+    print("[dio] " .. name
+          .. (okE and " written" or (" ENCODE FAILED: " .. tostring(err))))
+    Diorama.stop()
+  end
+
+  shoot("box", { mode = "diorama" })
+  shoot("box-turned", { mode = "diorama", yaw = math.rad(35) })
+  shoot("box-tight", { mode = "diorama", zoom = 0.5 })
+  shoot("ball-3", { mode = "diorama", curve = 3 })
+  shoot("ball-4", { mode = "diorama", curve = 4 })
+  shoot("ball-5", { mode = "diorama", curve = 5 })
+  shoot("ball-wide", { mode = "diorama", curve = 5, zoom = 1.9 })
+  shoot("keyed", { mode = "diorama-mr" })
+  shoot("keyed-ball", { mode = "diorama-mr", curve = 3 })
+  do
+    -- a fight's disc, cut about the arena the map would actually stage on
+    local BattleArena = V.require("BattleArena")
+    local ow = game.overworld
+    local arena = BattleArena.find(ow.map, ow.player.cellX, ow.player.cellY,
+                                   false)
+    if arena then
+      shoot("arena-disc", { mode = "diorama", arena = arena })
+    else
+      print("[dio] no arena on this map -- disc shot skipped")
+    end
+  end
+
+  Diorama.stop()
+  print("[dio] done; PNGs are under the LOVE save directory / " .. ROOT)
+  love.event.quit()
+end