Files
DramaticShapeVoxelMod/lib/VR.lua
T
2026-08-03 16:52:22 -04:00

687 lines
26 KiB
Lua

-- VR: the conductor -- one call per game frame that runs the whole
-- headset side, and the row that switches it on.
--
-- The shape of a VR frame, from the pipeline's update hook (which ticks
-- every frame whatever is on the stack, which is exactly what a headset
-- needs -- the world must keep arriving through menus, dialogs and
-- battles):
--
-- poll the runtime's events (begin the session when it says READY)
-- xrWaitFrame <- BLOCKS until the headset wants a frame;
-- with vsync handed off (set to 0 while the
-- session runs) this is what paces the whole
-- app at headset rate, while FixedStep keeps
-- the game's own logic at its 60 Hz
-- locate the two eyes
-- render the world once per eye (VoxelScene.render's `eyes` path:
-- shared shadow map, shared pose capture, per-eye cameras from VRRig)
-- blit each eye canvas into its swapchain image (VRGL)
-- copy the window's front buffer into the UI quad when a menu, dialog,
-- battle or wipe is what the flat screen is showing
-- xrEndFrame with the projection layer and/or the quad
--
-- WHICH VR YOU GET mirrors the VOXEL ladder, deliberately: on the orbit
-- rungs the world is a TABLETOP DIORAMA pinned below and ahead of where
-- your head started -- lean in, walk around it; on 1ST you stand inside
-- at life scale, the HMD steers FirstPerson's yaw and pitch, and FreeMove
-- walks where you look exactly as it does on the flat screen. A STAGED
-- FIGHT takes the camera from both: the headset snaps -- through a fade
-- to black and back -- to the flat battle's own over-the-shoulder seat
-- (VRRig.battleMount), and returns the same way when the fight ends;
-- the 2D battle screen lights up on the POKEDEX in the tracked left
-- hand (lib/Pokedex.lua) and NO floating panel is submitted at all --
-- the fight itself owns the view. The flat window keeps
-- running as the mirror (left eye when the world is up), so menus stay
-- usable at the desk and every existing input keeps working alongside
-- the XR controllers.
--
-- Failure is a status, never a crash: no runtime, no headset, no GL
-- interop, or a mid-session loss all land back on the flat screen with
-- the reason readable off VR.status().
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local Voxel = V.require("VoxelState")
local Voxel3D = V.require("Voxel3D")
local VoxelScene = V.require("VoxelScene")
local FirstPerson = V.require("FirstPerson")
local BattleCam = V.require("BattleCam")
local VRRig = V.require("VRRig")
local VRXR = V.require("VRXR")
local VRGL = V.require("VRGL")
local Pokedex = V.require("Pokedex")
local VR = {}
-- the row: plain OFF/ON. No hotkey -- the engine's display keys are
-- spoken for, and a headset is not something to toggle by accident.
VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" })
-- Where the diorama's UI panel floats vs first person's. These are the
-- FALLBACK screens: wherever the pokedex is up and lit -- first
-- person's menus, a battle's 2D scene -- no quad is submitted at all
-- (see updateQuad), and these serve only the diorama and the no-tracked-
-- controller case.
local QUAD_DIORAMA = { pos = { 0, 0.1, -1.0 }, width = 0.8 }
local QUAD_FP = { pos = { 0, 0, -1.4 }, width = 1.1 }
local started = false -- start() succeeded this enablement
local failed = nil -- start() failed; wait for a re-toggle
local wasOn = false
local savedVsync = nil
local fboCache = setmetatable({}, { __mode = "k" }) -- canvas -> GL FBO id
local mirrorSrc = nil -- last left-eye canvas, for the window
local mirrorCanvas = nil
local status = "off"
-- the diorama's live adjustments: the right stick's zoom (a multiplier on
-- the model's size) and the grab-drag's height (metres of world travel)
local zoom = 1
local heightOff = 0
local held = {} -- GB buttons this module is holding down
local lastHandY = nil -- the gripping hand's height, last frame
-- First person's SNAP TURN: the right stick flicked left or right steps
-- the whole XR-to-world mapping 45 degrees at a time (a smooth software
-- turn is the classic comfort mistake -- vection with no vestibular
-- signal; a snap is instant and the head does the rest). The offset
-- turns the mapping itself, so the eyes, the walk direction and the
-- pokedex all agree about which way the world now faces.
local SNAP_TURN = math.rad(45)
local fpYawOff = 0 -- accumulated snaps, radians
local snapArmed = true -- re-arms when the stick returns to centre
local function wrapPi(a)
return (a + math.pi) % (2 * math.pi) - math.pi
end
-- The battle snap, made a FADE rather than a cut: when a fight is staged
-- on the world (or stops being), black rises over both eyes, the camera
-- swaps mounts behind it, and black lifts. A teleport inside VR is the
-- one camera move that should never be SEEN happening -- the world
-- sliding to a new seat reads as the room moving.
local FADE_TIME = 0.35 -- seconds each way: out, then back in
local camMode = "explore" -- "explore" (diorama / 1ST) or "battle"
local fadeAlpha = 0 -- the black over the eyes right now
-- The staged fight to look at, if there is one: arena, floor height.
local function battleStage()
local ok, arena, groundY = pcall(function()
return V.require("OverworldBattle").stage()
end)
if not ok then return nil end
return arena, groundY
end
-- the palette closure the engine hands drawWorld; stashed there (see
-- main.lua) because the VR frame renders from update, where no ctx exists
VR.paletteFor = nil
-- Whether this platform can do VR AT ALL: the shipped loader and the GL
-- interop are Win32 (openxr_loader.dll, wgl), so only Windows qualifies.
-- Everywhere else -- Android above all -- the row is not offered on any
-- menu, and a stored vr=true is ignored rather than read: a save that
-- migrated over from the desktop must not leave a phone trying to start
-- an OpenXR session (or silently forcing the battle rows). Headless runs
-- have no love.system and answer true, which costs nothing: enabling VR
-- there stops at VRXR.start like it always did.
function VR.supported()
local ok, os = pcall(function() return love.system.getOS() end)
if not ok or not os then return true end
return os == "Windows"
end
function VR.enabled()
return VR.supported() and VR.setting:get() == true
end
function VR.active()
return started and VRXR.isRunning()
end
function VR.status()
if not VR.enabled() then return "off" end
if failed then return failed end
return VRXR.status()
end
-- Let go of every input this module was holding: the GB buttons pressed
-- through the overlay path, and the synthetic left stick. Runs when the
-- session ends and whenever a frame has no controller state to read.
local function releaseInputs()
local ok, Game = pcall(require, "src.core.Game")
if not ok or not Game.input then return end
for btn in pairs(held) do
pcall(function() Game.input:overlayReleased(btn) end)
held[btn] = nil
end
pcall(function()
Game.input:gamepadaxis(nil, "leftx", 0)
Game.input:gamepadaxis(nil, "lefty", 0)
end)
lastHandY = nil
end
local function shutdown(reason)
if started then
VRXR.stop()
started = false
end
if savedVsync ~= nil then
pcall(love.window.setVSync, savedVsync)
savedVsync = nil
end
-- the placed camera may still be a VR eye's; the orbit must get the
-- pass back clean
Voxel3D.camera = nil
mirrorSrc = nil
releaseInputs()
BattleCam.still = false
VoxelScene.spriteLean = nil
Pokedex.clear()
zoom, heightOff = 1, 0
fpYawOff, snapArmed = 0, true
camMode, fadeAlpha = "explore", 0
status = reason or "off"
end
VR.shutdown = shutdown -- named for the probe driver
-- Whether the flat screen is showing something the world pass cannot: a
-- menu, a dialog, a battle, a transition wipe. The quad and the pokedex's
-- screen both key on it.
local function uiShowing()
local ok, showing = pcall(function()
local Game = require("src.core.Game")
local top = Game.stack and Game.stack:top()
return top ~= Game.overworld
or (Game.overworld and Game.overworld.transitioning) or false
end)
return ok and showing or false
end
-- ------- the pokedex's screen
--
-- What the device in the hand shows during a battle: the flat window --
-- which IS the 2D battle screen for as long as the battle state draws --
-- copied into a canvas the scene pass can texture with, cropped by UV to
-- the battle's own letterbox so the screen wears the GB frame edge to
-- edge. Menus over the battle (the party, the bag) ride along for free:
-- they are the flat screen too, and reading them on the device in your
-- hand is exactly the point.
local dexCanvas = nil
local function dexScreen()
local ok, out = pcall(function()
local ww, wh = love.graphics.getPixelDimensions()
if not (ww and ww > 0 and wh and wh > 0) then return nil end
if not (dexCanvas and dexCanvas:getWidth() == ww
and dexCanvas:getHeight() == wh) then
dexCanvas = love.graphics.newCanvas(ww, wh)
pcall(dexCanvas.setFilter, dexCanvas, "nearest", "nearest")
end
local fbo = fboCache[dexCanvas]
if not fbo then
fbo = VRGL.canvasFBO(dexCanvas)
fboCache[dexCanvas] = fbo
end
if not (fbo and VRGL.copyFrontToCanvas(fbo, ww, wh)) then return nil end
local BattleScene = V.require("BattleScene")
local lx, ly, s = BattleScene.letterbox()
return { dexCanvas,
lx / ww, ly / wh,
(lx + BattleScene.GB_W * s) / ww,
(ly + BattleScene.GB_H * s) / wh }
end)
return ok and out or nil
end
-- ------- the world, once per eye
local function renderWorld(views, ctl)
local ok, Game = pcall(require, "src.core.Game")
local ow = ok and Game.overworld or nil
if not (ow and ow.map and ow.camera and Voxel.active()
and Voxel3D.available()) then
return false
end
local vw, vh = 320, 288
pcall(function() vw, vh = Game.renderer:worldViewSize() end)
-- Whatever the camera does, the CARDS hold the top rung's near-upright
-- lean: a head that roams has no one pitch for them to match, and 75
-- degrees is the pose that reads as "standing" from anywhere. Cleared
-- on shutdown, so the flat screen leans with the rung as ever.
VoxelScene.spriteLean = math.rad(75)
local pivot, anchor, scale, mountYaw
-- Either free-roam rung puts the headset in the player's head: 3RD's boom
-- is a FLAT-SCREEN framing device, and a headset that stands its wearer
-- three cells behind their own body is a well-known way to make people
-- ill. The rung still changes the walk and the cards the same way; only
-- the eye stays where a head belongs.
local fp = FirstPerson.engaged()
local battle, battleFloor
if camMode == "battle" then battle, battleFloor = battleStage() end
if battle then
-- the over-the-shoulder seat the flat battle shot stands in, pulled
-- close enough for a headset's own lens (see VRRig.battleMount), at
-- life scale, turned to face the arena
local rec = BattleCam.rig(battle, battleFloor)
pivot, mountYaw = VRRig.battleMount(rec.eye, rec.focus)
anchor = { 0, 0, 0 }
scale = VRRig.FP_SCALE
elseif fp then
local p = ow.player
local gh = 0
pcall(function() gh = VoxelScene.groundAt(ow.map, p.cellX, p.cellY) end)
pivot = VRRig.fpPivot(p.px, p.py, gh, FirstPerson.EYE_HEIGHT)
anchor = { 0, 0, 0 }
scale = VRRig.FP_SCALE
-- the snap turn is a yaw on the MAPPING, same seam the battle mount
-- turns through
if fpYawOff ~= 0 then mountYaw = fpYawOff end
-- the HMD is the head: its yaw and pitch (plus the snaps) become
-- FirstPerson's, so FreeMove walks where you look and A talks to
-- what you face
local yaw, pitch = VRRig.headYawPitch(views[1].pose.quat)
FirstPerson.yaw = wrapPi(yaw + fpYawOff)
FirstPerson.pitch = math.max(FirstPerson.PITCH_UP,
math.min(FirstPerson.PITCH_DOWN, pitch))
else
-- The table presents the world exactly as the flat screen does at
-- rest: the pivot sits VIEW_DIST away along the RUNG'S own angle
-- (stepping rungs re-tilts the model, easing with the rung tween),
-- at the scale that reproduces the flat framing -- then the player's
-- own adjustments go on top: the stick's zoom, the grip's height.
pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2)
anchor = VRRig.dioramaAnchor(Voxel.angle, heightOff)
scale = VRRig.dioramaScale(vh, Voxel.FOCAL) / zoom
end
-- The pokedex, on the tracked left hand, under this very mapping --
-- but only where it earns its keep: FIRST PERSON, where its screen is
-- every menu, dialog and wipe the flat screen shows (and the floating
-- billboard is retired outright -- see updateQuad), and the BATTLE
-- seat, where its screen is the fight's own 2D scene. The diorama
-- does without: a hand-sized device hovering over a tabletop town is
-- clutter, and the panel serves there. No hand tracked, no device.
local hand = ctl and ctl.handl or nil
if hand and (battle or fp) then
Pokedex.place(hand, pivot, anchor, scale, mountYaw)
if uiShowing() then
local scr = dexScreen()
if scr then
Pokedex.screen(scr[1], scr[2], scr[3], scr[4], scr[5])
end
end
else
Pokedex.clear()
end
local eyes = {}
for i = 1, 2 do
local v = views[i]
eyes[i] = {
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw),
w = v.w, h = v.h,
slot = i == 1 and "vrL" or "vrR",
-- the battle seat is a placed shot, not the first-person rig: the
-- cards keep their stage lean rather than yawing at this eye, and
-- the player's own card stays visible in it
adopt = not battle,
}
end
eyes.cx, eyes.cy = pivot[1], pivot[3]
local okR, canvases = pcall(VoxelScene.render, ow, 0, 0, vw, vh,
VR.paletteFor, eyes)
if not (okR and type(canvases) == "table" and canvases[1] and canvases[2])
then
return false
end
-- the snap's fade, over the finished eyes: plain black at this moment's
-- strength, drawn before the blit so the headset never sees the swap
if fadeAlpha > 0 then
pcall(function()
for i = 1, 2 do
local c = canvases[i]
love.graphics.setCanvas(c)
love.graphics.setColor(0, 0, 0, math.min(1, fadeAlpha))
love.graphics.rectangle("fill", 0, 0, c:getWidth(), c:getHeight())
end
love.graphics.setCanvas()
love.graphics.setColor(1, 1, 1, 1)
end)
end
for i = 1, 2 do
local canvas = canvases[i]
local tex, tw, th = VRXR.acquireEye(i)
if tex then
local fbo = fboCache[canvas]
if not fbo then
fbo = VRGL.canvasFBO(canvas)
fboCache[canvas] = fbo
end
if fbo then
VRGL.blitToTexture(fbo, canvas:getWidth(), canvas:getHeight(),
tex, tw, th)
end
end
VRXR.releaseEye(i)
end
mirrorSrc = canvases[1]
return true
end
-- ------- the UI panel
-- Whether the flat screen is showing something the world pass cannot: a
-- menu, a dialog, a battle, a transition wipe -- or everything, when the
-- world pass is off entirely.
local function wantQuad(worldUp)
if not worldUp then return true end
return uiShowing()
end
local function updateQuad(worldUp, fp)
if not wantQuad(worldUp) then return nil end
-- Wherever the pokedex is up and lit -- first person's menus, the
-- battle seat's 2D fight -- it IS the screen, and no floating
-- billboard is submitted at all. (No tracked left hand still gets
-- the panel: the UI must be readable somewhere.)
if Pokedex.frame and Pokedex.frame.tex then return nil end
local tex, qw, qh = VRXR.acquireQuad()
if not tex then return nil end
local ww, wh = qw, qh
pcall(function() ww, wh = love.graphics.getPixelDimensions() end)
-- The panel wears the GB FRAME, not the window: everything the flat
-- screen has to say lives in the 160x144 letterbox (the world around
-- it is just the mirror's picture). The frame region is blitted OUT
-- of the window and SCALED into the swapchain image -- never copied
-- pixel-for-pixel, because the swapchain's size is fixed at session
-- start and a fullscreened window outgrows it, running the frame (and
-- the START menu flush with its right edge) off the copy. Scaled, the
-- panel shows the identical picture at the identical ratio whatever
-- size the window is. Source coordinates are GL's, origin bottom-left.
local crop = nil
local copied = false
pcall(function()
local BattleScene = V.require("BattleScene")
local lx, ly, s = BattleScene.letterbox()
local wpx = math.ceil(BattleScene.GB_W * s)
local hpx = math.ceil(BattleScene.GB_H * s)
local sx = math.max(0, math.floor(lx))
local sy = math.max(0, math.floor(wh - ly - hpx))
wpx = math.min(wpx, ww - sx)
hpx = math.min(hpx, wh - sy)
if wpx < 1 or hpx < 1 then return end
-- fitted to the swapchain image at the REGION's own aspect: the
-- crop then presents exactly that rect, so the panel's shape is the
-- GB frame's at any window and any swapchain size
local fit = math.min(qw / wpx, qh / hpx)
local dw = math.max(1, math.floor(wpx * fit))
local dh = math.max(1, math.floor(hpx * fit))
if VRGL.copyFrontRegionToTexture(tex, sx, sy, wpx, hpx, dw, dh) then
copied = true
crop = { 0, 0, dw, dh }
end
end)
if not copied then
-- no letterbox to cut (or the blit refused): the old whole-window
-- copy, clamped, is still a readable panel
VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
end
VRXR.releaseQuad()
local base = fp and QUAD_FP or QUAD_DIORAMA
if not crop then return base end
return { pos = base.pos, width = base.width, crop = crop }
end
-- ------- the controllers
--
-- The mapping the mod ships (rebindable in the runtime's own UI):
--
-- both modes left stick moves (through the engine's own stick path,
-- so it grid-walks the diorama and free-walks 1ST);
-- A/B are A/B; either trigger is START; clicking the
-- LEFT stick steps the VOXEL angle ladder exactly as
-- the "3" key (and the pad's SELECT) does.
-- 1ST only right stick left/right SNAP-TURNS 45 degrees a flick.
-- diorama only right stick up/down zooms the model; squeezing a grip
-- and moving that hand up or down drags the whole table
-- with it.
--
-- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) --
-- no controller button does it. VR.leave below stays as the API for it.
-- The left stick click makes EXACTLY the step the "3" key makes: one
-- rung up the VOXEL angle ladder, wrapping, stepping over FULL, clearing
-- TILT and GBC FX in the save -- by calling the very function the key
-- and the pad's SELECT button already share. main.lua installs it below
-- (cycleVoxel is a local of that file); the free-roam gate is the
-- registry's own, inside it, so a click over a menu or mid-warp is a
-- no-op exactly like the key.
VR.cycleVoxel = nil -- cycleVoxel(game), set by main.lua
function VR.stepView()
pcall(function()
if not VR.cycleVoxel then return end
VR.cycleVoxel(require("src.core.Game"))
end)
end
-- Leave VR: the VR row toggled back off and persisted, exactly as if
-- stepped on the OPTIONS menu, so the next update tears the session down
-- and the flat screen takes the picture back. Deliberately bound to NO
-- controller button (a click that ejects you from the headset is a trap
-- mid-fight); kept as the one programmatic door out.
function VR.leave()
pcall(function()
local Game = require("src.core.Game")
VR.setting:setIndex(VR.setting:read() + 1, Game)
end)
end
local function setGB(inp, btn, down)
if down and not held[btn] then
held[btn] = true
inp:overlayPressed(btn)
elseif not down and held[btn] then
held[btn] = nil
inp:overlayReleased(btn)
end
end
local function driveControls(ctl, dt, fp)
if not ctl then
releaseInputs()
return
end
local ok, Game = pcall(require, "src.core.Game")
if not (ok and Game.input) then return end
local inp = Game.input
setGB(inp, "a", ctl.a)
setGB(inp, "b", ctl.b)
setGB(inp, "start", ctl.start)
-- the left stick, through the engine's OWN stick handler: it quantises
-- to the grid d-pad for the diorama, and FirstPerson.moveVector reads
-- the same raw pair for the free walk. OpenXR's +Y is up; the engine's
-- lefty is +down.
inp:gamepadaxis(nil, "leftx", ctl.moveX or 0)
inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0))
if ctl.toggleChanged and ctl.toggle then VR.stepView() end
-- first person's snap turn: a flick of the right stick steps the view
-- 45 degrees, once per flick -- it re-arms only when the stick comes
-- back toward centre, so holding it turns exactly once
if fp and camMode ~= "battle" then
local sx = ctl.lookX or 0
if math.abs(sx) > 0.65 then
if snapArmed then
snapArmed = false
-- increasing yaw turns LEFT in this mod's compass, so a stick
-- pushed right subtracts
fpYawOff = wrapPi(fpYawOff + (sx > 0 and -SNAP_TURN or SNAP_TURN))
end
elseif math.abs(sx) < 0.35 then
snapArmed = true
end
end
if not fp and camMode ~= "battle" then
local zy = ctl.lookY or 0
if math.abs(zy) > 0.15 then
zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6)))
end
-- the grab-drag: while a grip is squeezed, the table follows that
-- hand's height, metre for metre
local gl, gr = ctl.gripL or 0, ctl.gripR or 0
local y = (gr >= gl) and ctl.handrY or ctl.handlY
if math.max(gl, gr) > 0.6 and y then
if lastHandY then
heightOff = math.max(-1.5, math.min(1.5, heightOff + (y - lastHandY)))
end
lastHandY = y
else
lastHandY = nil
end
else
lastHandY = nil
end
end
-- ------- the per-frame drive
function VR.update(dt)
local on = VR.enabled()
if not on then
if wasOn then
shutdown("off")
failed = nil
end
wasOn = false
return
end
if not wasOn then failed = nil end -- a fresh toggle earns a fresh try
wasOn = true
if failed then return end
if not started then
local qw, qh = 1024, 768
pcall(function() qw, qh = love.graphics.getPixelDimensions() end)
if VRXR.start(qw, qh) then
started = true
status = "session created"
print("[DRAMATIC_SHAPE] VR: " .. VRXR.status())
else
failed = VRXR.status()
print("[DRAMATIC_SHAPE] VR unavailable: " .. failed
.. " -- fix that, then toggle the VR row to retry")
return
end
end
if not VRXR.poll() then
-- the runtime took the session away (headset off, runtime shut down)
shutdown("session lost")
failed = "session lost -- toggle VR off and on to retry"
return
end
if not VRXR.isRunning() then return end
-- the headset paces the app now; vsync would fight it
if savedVsync == nil then
savedVsync = 1
pcall(function() savedVsync = love.window.getVSync() end)
pcall(love.window.setVSync, 0)
end
-- the battle camera holds still for as long as a headset is watching:
-- its drift is a flat screen's depth cue, and a swaying picture inside
-- VR reads as the world lurching
BattleCam.still = true
-- The battle snap's fade: while the camera the frame WANTS is not the
-- one it is showing, black rises; at full black the mount swaps; then
-- black lifts. Driven here, on game time, so a fight that ends during
-- the fade just turns it around.
local want = battleStage() and "battle" or "explore"
if want ~= camMode then
fadeAlpha = math.min(1, fadeAlpha + (dt or 0) / FADE_TIME)
if fadeAlpha >= 1 then camMode = want end
else
fadeAlpha = math.max(0, fadeAlpha - (dt or 0) / FADE_TIME)
end
local time, should = VRXR.waitFrame()
if not time then return end
-- the controllers, before the world renders: the frame the toggle
-- flips rungs on should be the frame that renders the new rig. The
-- state is kept in hand for renderWorld too -- the pokedex stands on
-- the same frame's left-hand pose.
local ctl = VRXR.input(time)
driveControls(ctl, dt, FirstPerson.engaged())
local worldUp = false
if should then
local views = VRXR.locateViews(time)
if views then
worldUp = renderWorld(views, ctl)
end
end
local quadPose = updateQuad(worldUp, FirstPerson.engaged())
VRXR.endFrame(time, worldUp or nil, quadPose)
end
-- ------- the window while a headset owns the picture
-- The flat window becomes the mirror: the left eye, fitted to the window.
-- Returns nil when there is nothing to mirror (the caller draws the flat
-- path as ever).
function VR.mirror(sw, sh)
if not (VR.active() and mirrorSrc) then return nil end
if not (mirrorCanvas and mirrorCanvas:getWidth() == sw
and mirrorCanvas:getHeight() == sh) then
local ok, c = pcall(love.graphics.newCanvas, sw, sh)
if not ok then return nil end
mirrorCanvas = c
end
local ok = pcall(function()
love.graphics.setCanvas(mirrorCanvas)
love.graphics.clear(0, 0, 0, 1)
local mw, mh = mirrorSrc:getDimensions()
local s = math.min(sw / mw, sh / mh)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(mirrorSrc, (sw - mw * s) / 2, (sh - mh * s) / 2, 0, s, s)
love.graphics.setCanvas()
end)
pcall(love.graphics.setCanvas)
return ok and mirrorCanvas or nil
end
-- window resize, hot reload: the eye canvases are Voxel3D's and go with
-- its invalidate; ours is the mirror and the FBO ids learned from dead
-- canvases
function VR.invalidate()
if mirrorCanvas and mirrorCanvas.release then
pcall(mirrorCanvas.release, mirrorCanvas)
end
mirrorCanvas, mirrorSrc = nil, nil
if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end
dexCanvas = nil
Pokedex.invalidate()
for k in pairs(fboCache) do fboCache[k] = nil end
end
return VR