DramaticShapeVoxelMod archival mirror · read-only
lib/Diorama.lua

Diorama.lua

lib/Diorama.lua14.2 KB10064421a73e1731
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344
-- 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

-- ------- which way a staged fight lies on the table
--
-- The disc's bearing while a battle is up: the player's own hand-turn, with
-- the ARENA's quarter turn taken back out of it.
--
-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
-- the promise that field makes everywhere else is that turning it changes the
-- GROUND under the fight and never the fight itself -- the two Pokemon land
-- on the same marks, seen the same way round. Every other camera keeps that
-- promise by construction: the flat shot and the standard VR mount are both
-- built from BattleCam's eye, which turns with the arena, so the composition
-- follows it round.
--
-- This one is not built from that eye. It is a disc of map lifted onto the
-- table, and its bearing is the arena's bearing in the WORLD -- so a fight
-- staged on a turned arena arrived on the table lying across the head that
-- was looking at it, while the same fight on an unturned one faced properly.
-- Same fight, same composition everywhere else, sideways here.
--
-- So the turn comes back out. Subtracted, matching the sign the standard
-- mount already lands on: its yaw is atan2 of (eye - focus), and rotating
-- that pair by +turn takes the bearing to (bearing - turn). One rule, two
-- seats.
--
-- The hand-turn stays on top of it, because that is the player moving the
-- model and is theirs to keep.
function Diorama.battleYaw(arena)
  local turn = (arena and arena.turn) or 0
  if turn == 0 then return Diorama.yaw end
  local yaw = Diorama.yaw - math.rad(turn)
  -- kept in (-pi, pi] like the grips leave it, so nothing downstream has to
  -- care which way round it came
  return (yaw + math.pi) % (2 * math.pi) - math.pi
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,
           -- The BOX kind is rectangular in the shader, because the flat
           -- screen's box is the WINDOW's own footprint and a window is not
           -- square (lib/ViewBox). A headset's model has no window to be
           -- shaped like, so this one is: the same half-size twice.
           rx = r, rz = 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