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= 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