This commit is contained in:
DramaticShape
2026-08-02 02:42:31 -04:00
parent 3f7210bfcd
commit f1063abd0f
42 changed files with 2570 additions and 84 deletions
+63
View File
@@ -1,5 +1,68 @@
# Changelog # Changelog
## 1.6.0
### Added
- **PCVR, in-process, through OpenXR -- with no change to the parent
app.** A new **VR** row (OFF / ON, off by default; on the OPTIONS menu
and the mod manager's page). The whole stack rides LuaJIT's FFI from
inside the mod: the Khronos OpenXR loader ships in `assets/vr/` (with
its Apache-2.0 license alongside), the session binds to LOVE's own
OpenGL context, each eye is rendered by the mod's existing scene pass
under a placed camera built from the tracked pose, and the finished
canvases are blitted straight into the runtime's swapchain images.
Works with any Windows OpenXR runtime (SteamVR, Oculus, WMR).
- **What you see mirrors the VOXEL ladder.** On the orbit rungs the
world is a TABLETOP DIORAMA hung at the RUNG'S own viewing angle and
at the scale that reproduces the flat screen's framing -- step onto
35 and the model presents at 35 degrees, onto 75 and it rises toward
eye level, easing between rungs; lean in and the town grows, walk
around the table and honest occlusion shows you the far side of the
buildings. On **1ST** you stand inside the world at life size (a
tile is a stride), the headset steers the same yaw and pitch the
flat screen's mouse does, and FreeMove walks where you look.
- **VR controllers.** One OpenXR action set, suggested onto Touch,
Index and WMR (plus the khr/simple fallback), rebindable in the
runtime's own UI. In 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, and **clicking
the left stick** toggles first/third person (returning to the orbit
rung you left). In the diorama: **right stick up/down** zooms the
model, **clicking the right stick** cycles the viewing angle through
the orbit rungs (the table re-tilts on the rung's own tween), and
**squeezing a grip** while moving that hand up or down drags the
whole table with it.
- **Battles happen ON the world.** While a staged fight runs, the
headset keeps looking at the map -- both mons stand on their arena
cells in the VR eyes' own view, yawed per eye, casting real shadows,
wearing the hit flash -- while the battle UI reads from the floating
panel. (The flat screen keeps its composed battle shot, untouched.)
And the battle camera's parallax drift holds still while a headset
is watching: the sway is a flat screen's depth cue, and inside VR it
read as the world lurching -- most of all on the floating panel.
- **Menus, dialogs, battles and wipes float on a panel** (an OpenXR
quad layer fed from the window), so everything the flat screen can
show, the headset can read. The window itself becomes the VR mirror
-- the left eye, fitted to the window -- and every existing input
keeps working; v1 deliberately has no XR controller bindings.
- The two eyes share one shadow map, one pose capture and one glint
step per frame (VoxelScene.render's `eyes` path), so they can never
disagree about anything but their viewpoint. xrWaitFrame paces the
app at headset rate while FixedStep keeps game logic at its own 60
Hz; vsync is handed off while the session runs and put back after.
- Failure is a status, never a crash: no runtime, no headset, no GL
interop, or a session lost mid-play all land back on the flat screen
with the reason printed (`XR_ERROR_FORM_FACTOR_UNAVAILABLE` means
"plug the headset in, then toggle the row"). Needs the mod running
from a real folder (the FFI cannot load a DLL out of an archive).
## 1.5.0 ## 1.5.0
### Added ### Added
+2 -66
View File
@@ -3,55 +3,7 @@
A mod for the [Pokémon Gen 1 Recompilation A mod for the [Pokémon Gen 1 Recompilation
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project). Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
The overworld as a 3D diorama. Terrain is extruded into real geometry, The overworld as a voxelized 3D diorama. Also supports experimental first-person and VR.
occlusion comes from a depth buffer rather than a y-sort, characters stand
as leaning sprite slabs, a shadow map throws real cast shadows across
whatever they land on, and an optional tilt-shift pass sells the
miniature-model look.
Water is a surface rather than a texture lying in a hole. It is a field of
one-pixel-wide voxel columns, each standing a whole number of pixels tall and
rising and falling as waves — found by walking the view ray through them in
the shader, so a crest hides what is behind it and shows you its lit side,
with no extra geometry anywhere.
And it reflects. The sky it stands under, in the same bands, the same dither
and off the same clock, so the lake and the sky above it meet at the
waterline with no seam. The sun or moon hanging in it, at the size the
painted disc is drawn, craters and all. Whoever is standing beside it —
walkers, NPCs, the two Pokémon in a staged battle. And on **FULL**, a
screen-space ray march adds the rest of what is on screen: the shoreline, the
trees behind it, the buildings across the bay. How much of it shows is
Fresnel, so the top rung is a mirror and a looking-straight-down rung is a
pond, off the same water.
And battles fought on that world rather than on a white field. When
something picks a fight the map's NPCs are culled, the engine's own wipe
plays over the empty map, and the battle draws over the nearest patch of
clear ground — shot over the shoulder, the player's mon low and left and
the enemy high and right, with a slow parallax drift behind them and a
depth-of-field pass that keeps both of them sharp.
And the whole thing from inside. The ladder's top rung, **1ST**, dives the
camera into the player's own head: free look on the mouse (captured while
the rung is on — left click is A, right click is B), the right stick, or a
touch dragged across open screen; free movement that goes where you look,
at any angle, sliding along walls — the left stick's raw deflection, the
touch d-pad's true vector, or WASD as forward/backpedal/strafe. NPCs turn
to face the eye wearing the frame their pose shows *this* viewer — walk
behind someone and you see their back — and the sky, the shadows and the
water reflections all carry over, because the head rides the same placed
camera the battle shot proved out.
Presentational, with one deliberate exception. Every rung but 1ST changes
what the world *looks* like and nothing about what it *is*; the battle
arena is where the **camera** goes, not where anybody goes. 1ST replaces
the grid walk with a free one while it is selected — but even there the
game is untouched: the walk asks the engine's own collision the same
questions a grid step asks, keeps the player's cell synced, and runs the
engine's own landing pipeline per cell crossed, so warps, encounters,
ledges, gates and scripts all fire exactly as themselves. Step off the
rung and the grid walk is back.
## Controls ## Controls
@@ -68,20 +20,4 @@ menu.
| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it |
| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone | | the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
on the world whether or not the free-roam camera is pitched over.
Two of the engine's own rows are taken away while this mod is installed:
**TILT**, which is the flat fake of what this mode does for real, and **GBC
FX**, a full-screen present pass over the top of the diorama. Both are held at
off rather than merely hidden — a row that is not there cannot switch off a
value an older save arrived with. Uninstall and both come back, at whatever
they were last set to.
Everything the battle screen draws as a box — the two HUD blocks, the text
box and the menus over it — sits on frosted glass rather than on the white
field it used to have behind it: the world underneath, blurred and laid back
down translucent, with the ink flipping white where the ground it lands on is
dark. Nothing the engine draws inside a box moves; only the paper is gone.
+188
View File
@@ -0,0 +1,188 @@
openxr_loader.dll -- the Khronos OpenXR loader (x64, unmodified)
Version 1.0.10.2, from the "OpenXR.Loader" NuGet package published by
The Khronos Group. Source: https://github.com/KhronosGroup/OpenXR-SDK
Copyright (c) The Khronos Group Inc.
Licensed under the Apache License, Version 2.0 (the "License"); the full
text of the License follows, as its terms require a copy to accompany
redistribution.
-----------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Binary file not shown.
+13 -3
View File
@@ -124,6 +124,14 @@ BattleCam.PAN_PERIOD = 26 -- seconds for one there-and-back
BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction BattleCam.PAN_DOLLY = 0.02 -- how far the eye breathes, as a fraction
BattleCam.DOLLY_PERIOD = 37 BattleCam.DOLLY_PERIOD = 37
-- Hold the rig perfectly still (VR sets this while a session runs). The
-- drift exists to give a FLAT screen the depth cue the picture cannot
-- have; a headset gets real parallax from the player's own head, and a
-- picture that sways on its own inside VR reads as the world lurching --
-- on the floating panel especially, where the battle screen is watched
-- from a fixed seat.
BattleCam.still = false
BattleCam.t = 0 BattleCam.t = 0
function BattleCam.reset() function BattleCam.reset()
@@ -160,12 +168,14 @@ function BattleCam.rig(arena, groundY)
local R = BattleCam.rigFor(arena) local R = BattleCam.rigFor(arena)
local mx, mz = arena.mid[1], arena.mid[2] local mx, mz = arena.mid[1], arena.mid[2]
local yaw = BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD) local yaw = BattleCam.still and 0
or BattleCam.PAN_YAW * phase(BattleCam.t, BattleCam.PAN_PERIOD)
local c, s = math.cos(yaw), math.sin(yaw) local c, s = math.cos(yaw), math.sin(yaw)
-- the breath scales the whole offset, height included, so the eye moves -- the breath scales the whole offset, height included, so the eye moves
-- along its own line to the arena and the pitch of the shot never changes -- along its own line to the arena and the pitch of the shot never changes
local k = 1 + BattleCam.PAN_DOLLY local k = BattleCam.still and 1
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD) or 1 + BattleCam.PAN_DOLLY
* phase(BattleCam.t, BattleCam.DOLLY_PERIOD)
local dx = (R.side * c - R.back * s) * k local dx = (R.side * c - R.back * s) * k
local dz = (R.side * s + R.back * c) * k local dz = (R.side * s + R.back * c) * k
+10
View File
@@ -164,6 +164,16 @@ function FirstPerson.cardBlend()
return ease(FirstPerson.blend) return ease(FirstPerson.blend)
end end
-- A VR eye stepping into the rig's shoes: the VR pass builds its own
-- placed cameras (one per eye) and hands each one here as it draws, so
-- everything keyed to "the first-person rig is drawing" -- the billboard
-- yaw, the frame remap, the hidden player card -- answers for that eye.
-- In the diorama (blend 0) adoption is inert: cardBlend still reports
-- zero and the cards keep their lean.
function FirstPerson.adoptVReye(record)
rig = record
end
-- Whether the player's own card should be left out of the camera draw: -- Whether the player's own card should be left out of the camera draw:
-- deep enough into the blend that the card would fill the lens from -- deep enough into the blend that the card would fill the lens from
-- inside. The sun pass keeps drawing it either way -- a first-person -- inside. The sun pass keeps drawing it either way -- a first-person
+44 -3
View File
@@ -6,9 +6,11 @@
-- here -- translation in the fourth column, m[4]/m[8]/m[12]. -- here -- translation in the fourth column, m[4]/m[8]/m[12].
-- --
-- Only what the renderer actually needs: a perspective projection (the -- Only what the renderer actually needs: a perspective projection (the
-- camera), an orthographic one (the sun's shadow pass), a look-based view, -- camera), an orthographic one (the sun's shadow pass), an asymmetric one
-- and the translate/rotateY/scale a model matrix is built from. No general -- (a headset's per-eye frustum), a look-based view, a quaternion rotation
-- inverse, no quaternions. -- (a headset's pose), and the translate/rotateY/scale a model matrix is
-- built from. No general inverse -- the VR view inverts its rigid pieces
-- one at a time.
local Mat4 = {} local Mat4 = {}
@@ -62,6 +64,45 @@ function Mat4.rotateX(a)
0, 0, 0, 1 } 0, 0, 0, 1 }
end end
-- The rotation a unit quaternion describes, row-major. The VR rig is what
-- needs it: an OpenXR eye pose arrives as position + orientation
-- quaternion, and both the eye's transform and its inverse (the view) are
-- built from this.
function Mat4.fromQuat(x, y, z, w)
local xx, yy, zz = x * x, y * y, z * z
local xy, xz, yz = x * y, x * z, y * z
local wx, wy, wz = w * x, w * y, w * z
return { 1 - 2 * (yy + zz), 2 * (xy - wz), 2 * (xz + wy), 0,
2 * (xy + wz), 1 - 2 * (xx + zz), 2 * (yz - wx), 0,
2 * (xz - wy), 2 * (yz + wx), 1 - 2 * (xx + yy), 0,
0, 0, 0, 1 }
end
-- Transpose. For a pure rotation this IS the inverse, which is how the VR
-- view matrix is assembled without a general 4x4 inverse.
function Mat4.transpose(m)
return { m[1], m[5], m[9], m[13],
m[2], m[6], m[10], m[14],
m[3], m[7], m[11], m[15],
m[4], m[8], m[12], m[16] }
end
-- Right-handed perspective from an OpenXR-style asymmetric field of view:
-- four signed HALF-ANGLES off the view axis (left and down negative), onto
-- GL clip space (z in [-1, 1]). A headset's per-eye frustum is off-centre
-- -- the nose side is narrower than the temple side -- so the symmetric
-- perspective() above cannot express it.
function Mat4.fovProjection(angleLeft, angleRight, angleUp, angleDown,
near, far)
local l, r = math.tan(angleLeft), math.tan(angleRight)
local u, d = math.tan(angleUp), math.tan(angleDown)
local w, h, dz = r - l, u - d, near - far
return { 2 / w, 0, (r + l) / w, 0,
0, 2 / h, (u + d) / h, 0,
0, 0, (far + near) / dz, (2 * far * near) / dz,
0, 0, -1, 0 }
end
-- Right-handed perspective onto GL clip space (z in [-1, 1]). -- Right-handed perspective onto GL clip space (z in [-1, 1]).
function Mat4.perspective(fovY, aspect, near, far) function Mat4.perspective(fovY, aspect, near, far)
local f = 1 / math.tan(fovY / 2) local f = 1 / math.tan(fovY / 2)
+22
View File
@@ -473,6 +473,11 @@ function OverworldBattle.update(dt)
-- inside somebody else's frame means putting the frame back afterwards. -- inside somebody else's frame means putting the frame back afterwards.
local okTex, textures = pcall(OverworldBattle.textures, session.battle) local okTex, textures = pcall(OverworldBattle.textures, session.battle)
if not okTex then textures = nil end if not okTex then textures = nil end
-- stashed for the VR eye pass, which stands these same pics on the map
-- in ITS view of the world (VoxelScene's eyes path). Stashed HERE
-- because rendering them binds canvases, which the eye pass -- mid-scene
-- when it wants them -- must never do; reading a stashed canvas is free.
session.textures = textures
session.token = (session.token or 0) + 1 session.token = (session.token or 0) + 1
local ok, shot = pcall(BattleScene.render, session.state, session.arena, local ok, shot = pcall(BattleScene.render, session.state, session.arena,
textures, session.token) textures, session.token)
@@ -531,6 +536,23 @@ function OverworldBattle.shot()
return nil return nil
end end
-- The staged fight's WORLD-side pieces, for a pass that stands the mons in
-- its own view of the map rather than in the arena's composed shot -- the
-- VR eyes. Returns the two cards as BattleScene.monCards builds them (yawed
-- toward whatever Voxel3D.eye is at CALL time, so a per-eye caller gets
-- per-eye cards), the live textures table (for the hit-flash flag), and the
-- token the shadow signature keys on. nil while nothing is staged, the
-- arena is broken, or the pics have not been rendered yet.
function OverworldBattle.worldCards()
if not (session and session.arena and not session.broken) then return nil end
local tex = session.textures
if not tex then return nil end
local host = (session.state and session.state.map) or nil
if not host then return nil end
local groundY = BattleScene.groundY(host, session.arena)
return BattleScene.monCards(session.arena, groundY, tex), tex, session.token
end
function OverworldBattle.invalidate() function OverworldBattle.invalidate()
BattleDOF.invalidate() BattleDOF.invalidate()
BattleHud.invalidate() BattleHud.invalidate()
+464
View File
@@ -0,0 +1,464 @@
-- 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. 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 --
-- v1 has no XR controller bindings on purpose.
--
-- 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 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
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), the grab-drag's height (metres of world travel), and
-- the orbit rung the view toggle returns to from first person
local zoom = 1
local heightOff = 0
local lastOrbit = 4 -- the 50-degree rung, a sane middle
local held = {} -- GB buttons this module is holding down
local lastHandY = nil -- the gripping hand's height, last frame
-- 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
function VR.enabled()
return 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
zoom, heightOff = 1, 0
status = reason or "off"
end
VR.shutdown = shutdown -- named for the probe driver
-- ------- the world, once per eye
local function renderWorld(views)
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)
local pivot, anchor, scale
local fp = FirstPerson.engaged()
if 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 HMD is the head: its yaw and pitch 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 = yaw
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
local eyes = {}
for i = 1, 2 do
local v = views[i]
eyes[i] = {
camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale),
w = v.w, h = v.h,
slot = i == 1 and "vrL" or "vrR",
adopt = true,
}
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
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
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
local function updateQuad(worldUp, fp)
if not wantQuad(worldUp) 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)
VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh))
VRXR.releaseQuad()
return fp and QUAD_FP or QUAD_DIORAMA
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 toggles first/third person.
-- diorama only right stick up/down zooms the model; clicking the
-- RIGHT stick cycles the viewing angle through the orbit
-- rungs; squeezing a grip and moving that hand up or
-- down drags the whole table with it.
-- Flip between the 1ST rung and the last orbit rung, through the same
-- gate and plumbing the keyboard hotkey uses.
function VR.toggleView()
pcall(function()
local Game = require("src.core.Game")
local Pipelines = require("src.render.Pipelines")
local top = Game.stack and Game.stack:top()
if not Pipelines.canToggle("voxel", top, Game.overworld) then return end
local level = Pipelines.level("voxel")
if Voxel.isFirstPerson(level) then
Pipelines.setLevel("voxel", lastOrbit)
else
if level > 0 and not Voxel.isFull(level) then lastOrbit = level end
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
end
if Game.save and Game.save.options then
Pipelines.syncOptions(Game.save.options)
pcall(Game.writeOptions, Game)
end
end)
end
-- Step the diorama's presentation angle through the orbit rungs (the
-- anchor re-derives from the rung's angle, so the table re-tilts on the
-- rung's own tween). FULL counts as its 35-degree twin, like the hotkey.
function VR.cycleAngle()
pcall(function()
local Game = require("src.core.Game")
local Pipelines = require("src.render.Pipelines")
local top = Game.stack and Game.stack:top()
if not Pipelines.canToggle("voxel", top, Game.overworld) then return end
local level = Pipelines.level("voxel")
if Voxel.isFirstPerson(level) then return end
local order = { 2, 3, 4, 5 }
local at = nil
for i, rung in ipairs(order) do
if rung == level then at = i break end
end
if not at then
local deg = Voxel.ANGLES_DEG[level + 1]
for i, rung in ipairs(order) do
if Voxel.ANGLES_DEG[rung + 1] == deg then at = i break end
end
end
Pipelines.setLevel("voxel", order[at and (at % #order + 1) or 1])
if Game.save and Game.save.options then
Pipelines.syncOptions(Game.save.options)
pcall(Game.writeOptions, Game)
end
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.toggleView() end
if not fp 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
if ctl.angleChanged and ctl.angle then VR.cycleAngle() 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
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
driveControls(VRXR.input(time), dt, FirstPerson.engaged())
local worldUp = false
if should then
local views = VRXR.locateViews(time)
if views then
worldUp = renderWorld(views)
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
for k in pairs(fboCache) do fboCache[k] = nil end
end
return VR
+174
View File
@@ -0,0 +1,174 @@
-- VR: the raw OpenGL this mod is otherwise proud to never need.
--
-- OpenXR hands over its swapchain images as GL TEXTURE IDS, and LOVE never
-- exposes the GL names behind its own canvases -- so getting a rendered
-- eye from a love Canvas into a headset means dropping below LOVE for a
-- few calls a frame: discover the canvas's framebuffer, blit it into the
-- swapchain texture, and put the pipeline back exactly as LOVE believes it
-- to be. Everything here is that, and only that.
--
-- Three rules keep this safe:
--
-- discovery over spelunking. The canvas's FBO id is read from the
-- driver with documented queries (bind the canvas THROUGH LOVE, ask
-- GL_DRAW_FRAMEBUFFER_BINDING) rather than from LOVE's internals, so a
-- LOVE patch cannot move it out from under us.
--
-- restore what LOVE caches. LOVE tracks the bound framebuffer and skips
-- redundant binds, so raw binds must end back at the exact binding LOVE
-- thinks is current -- the default framebuffer, 0, since every call
-- here runs between LOVE passes -- or LOVE's next draw lands in ours.
--
-- pcall at the rim, ffi inside. The FFI setup can fail (headless, a GL
-- context without FBO entry points); it fails ONCE, at load(), and
-- callers see `nil, reason` rather than an error mid-frame.
local VRGL = {}
local ffi = nil
local gl = nil -- opengl32 exports (GL 1.1 + wgl)
local ext = {} -- post-1.1 entry points via wglGetProcAddress
local ready = false
local reason = nil
local fbo = nil -- our scratch framebuffer, made once
local GL = {
FRAMEBUFFER = 0x8D40,
READ_FRAMEBUFFER = 0x8CA8,
DRAW_FRAMEBUFFER = 0x8CA9,
DRAW_FRAMEBUFFER_BINDING = 0x8CA6,
COLOR_ATTACHMENT0 = 0x8CE0,
COLOR_BUFFER_BIT = 0x4000,
NEAREST = 0x2600,
LINEAR = 0x2601,
TEXTURE_2D = 0x0DE1,
FRONT = 0x0404,
BACK = 0x0405,
}
VRGL.GL = GL
local CDEF = [[
typedef void (__stdcall *PROC)();
void* wglGetCurrentDC(void);
void* wglGetCurrentContext(void);
PROC wglGetProcAddress(const char*);
unsigned int glGetError(void);
void glGetIntegerv(unsigned int pname, int* params);
void glReadBuffer(unsigned int mode);
void glFlush(void);
void glCopyTexSubImage2D(unsigned int target, int level, int xoffset,
int yoffset, int x, int y, int width, int height);
void glBindTexture(unsigned int target, unsigned int texture);
typedef void (__stdcall *pfn_glBindFramebuffer)(unsigned int, unsigned int);
typedef void (__stdcall *pfn_glGenFramebuffers)(int, unsigned int*);
typedef void (__stdcall *pfn_glDeleteFramebuffers)(int, const unsigned int*);
typedef void (__stdcall *pfn_glFramebufferTexture2D)(unsigned int,
unsigned int, unsigned int, unsigned int, int);
typedef void (__stdcall *pfn_glBlitFramebuffer)(int, int, int, int,
int, int, int, int, unsigned int, unsigned int);
]]
-- One-time FFI setup. Idempotent, and every path out records why it
-- stopped, so VR's status line can say something better than "no".
function VRGL.load()
if ready then return true end
if reason then return false, reason end
local ok, err = pcall(function()
ffi = require("ffi")
-- cdef survives a reload; redefinition is the only error worth eating
pcall(ffi.cdef, CDEF)
gl = ffi.load("opengl32")
local function proc(name, typ)
local p = gl.wglGetProcAddress(name)
if p == nil then error(name .. " not exposed by this GL context", 0) end
return ffi.cast(typ, p)
end
ext.glBindFramebuffer = proc("glBindFramebuffer", "pfn_glBindFramebuffer")
ext.glGenFramebuffers = proc("glGenFramebuffers", "pfn_glGenFramebuffers")
ext.glDeleteFramebuffers =
proc("glDeleteFramebuffers", "pfn_glDeleteFramebuffers")
ext.glFramebufferTexture2D =
proc("glFramebufferTexture2D", "pfn_glFramebufferTexture2D")
ext.glBlitFramebuffer = proc("glBlitFramebuffer", "pfn_glBlitFramebuffer")
end)
if not ok then
reason = "GL interop unavailable: " .. tostring(err)
return false, reason
end
ready = true
return true
end
-- The window's device and GL contexts, which the OpenXR session binds to.
function VRGL.contexts()
if not VRGL.load() then return nil, nil end
return gl.wglGetCurrentDC(), gl.wglGetCurrentContext()
end
-- The GL framebuffer behind a LOVE canvas. Bound through LOVE (so LOVE's
-- own cache stays truthful), read from the driver, then released.
function VRGL.canvasFBO(canvas)
if not VRGL.load() then return nil end
local id = nil
local ok = pcall(function()
love.graphics.setCanvas(canvas)
local out = ffi.new("int[1]")
gl.glGetIntegerv(GL.DRAW_FRAMEBUFFER_BINDING, out)
id = out[0]
love.graphics.setCanvas()
end)
pcall(love.graphics.setCanvas)
return ok and id or nil
end
-- Blit a LOVE canvas's pixels into a GL texture (an XR swapchain image),
-- flipped vertically on the way: LOVE renders its canvases y-down, GL
-- textures composite y-up, and the blit is the one place the two meet.
--
-- `srcFBO` comes from canvasFBO (cache it -- it is stable for the
-- canvas's lifetime). Ends with framebuffer 0 bound, which is the binding
-- LOVE believes in between its passes.
function VRGL.blitToTexture(srcFBO, sw, sh, tex, tw, th)
if not ready then return false end
local ok = pcall(function()
if not fbo then
local out = ffi.new("unsigned int[1]")
ext.glGenFramebuffers(1, out)
fbo = out[0]
end
ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo)
ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0,
GL.TEXTURE_2D, tex, 0)
ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, srcFBO)
ext.glBlitFramebuffer(0, sh, sw, 0, 0, 0, tw, th,
GL.COLOR_BUFFER_BIT, GL.LINEAR)
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
end)
if not ok then pcall(function() ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) end) end
return ok
end
-- Copy the WINDOW's currently displayed image (the front buffer -- the
-- back buffer's contents are undefined after a swap) into a GL texture:
-- the UI quad the headset floats in front of the world. Restores the read
-- buffer to BACK, the default LOVE never changes.
function VRGL.copyFrontBuffer(tex, w, h)
if not ready then return false end
local ok = pcall(function()
ext.glBindFramebuffer(GL.FRAMEBUFFER, 0)
gl.glReadBuffer(GL.FRONT)
gl.glBindTexture(GL.TEXTURE_2D, tex)
gl.glCopyTexSubImage2D(GL.TEXTURE_2D, 0, 0, 0, 0, 0, w, h)
gl.glBindTexture(GL.TEXTURE_2D, 0)
gl.glReadBuffer(GL.BACK)
end)
if not ok then pcall(function() gl.glReadBuffer(GL.BACK) end) end
return ok
end
function VRGL.status()
if ready then return "ok" end
return reason or "not loaded"
end
return VRGL
+162
View File
@@ -0,0 +1,162 @@
-- VR: the pose arithmetic -- how a headset eye becomes one of this mod's
-- cameras. Pure math on purpose: no FFI, no OpenXR types, nothing a
-- headless test cannot hold still. Everything device-shaped stays in
-- VRXR/VRGL; everything world-shaped is here.
--
-- Two ways the world can sit around a headset, and they mirror the VOXEL
-- ladder exactly:
--
-- DIORAMA every orbit rung. The map is a tabletop miniature: a point
-- of the world (the view centre) is pinned VIEW_DIST away
-- along the rung's own viewing angle (dioramaAnchor), at the
-- scale that reproduces the flat screen's framing
-- (dioramaScale) -- so at rest the model presents exactly as
-- the standard view does, and the head moves freely around it
-- -- lean in and the town grows, walk around the table and
-- see the far side of the buildings honest occlusion has been
-- hiding.
--
-- FIRST_PERSON the 1ST rung. The player's head is pinned to where the
-- headset started, at FP_SCALE, so a 16-pixel person stands
-- about 1.6 m tall and a cell is a stride. The HMD's own
-- orientation becomes FirstPerson's yaw and pitch, so movement
-- stays "push forward, go where you look" through the same
-- FreeMove the flat screen uses.
--
-- SPACES AND UNITS. OpenXR LOCAL space is metres, +Y up, -Z the way the
-- head faced at session start. World space is world PIXELS, +Y up, +Z
-- south. The two are aligned axis-for-axis -- "away from you" is north --
-- so the whole mapping is one translate-and-scale:
--
-- worldFromXr(p) = pivot + s * (p - anchor)
--
-- with `pivot` a world point, `anchor` the LOCAL-space point pinned to it,
-- and `s` the scale in px/m. An eye's camera is then
--
-- worldFromEye = T(pivot) * S(s) * T(-anchor) * T(pose.pos) * R(pose.q)
-- view = the same chain inverted piece by rigid piece
--
-- and the VIEW deliberately ends in METRES: it un-scales the world, so eye
-- space -- where the projection's near and far live -- is real-world
-- metres whatever the mode's scale. Depth precision and clip planes stay
-- sane at both 10 px/m and 128 px/m.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Mat4 = V.require("Mat4")
local VRRig = {}
-- first person's life size: 10 px/m makes a 16 px tile a 1.6 m stride
VRRig.FP_SCALE = 10
-- How far the diorama's pivot sits from the resting head, in metres --
-- the arm's-length viewing distance the anchor and the scale below are
-- both built around.
VRRig.VIEW_DIST = 0.95
-- Where, in LOCAL metres, the diorama's pivot sits: VIEW_DIST away along
-- the RUNG'S OWN viewing angle. The flat screen's camera looks at the
-- world `a` radians off vertical; putting the pivot at (-d cos a) below
-- and (-d sin a) ahead of the resting head reproduces exactly that line
-- of sight -- step onto the 35 rung and the table presents at 35 degrees,
-- onto 75 and it rises toward eye level, easing between them as the rung
-- tween runs. `heightOff` is the grab-drag adjustment, in metres of world
-- travel (positive drags the world up).
function VRRig.dioramaAnchor(angleRad, heightOff)
local d = VRRig.VIEW_DIST
return { 0,
-d * math.cos(angleRad or 0) + (heightOff or 0),
-d * math.sin(angleRad or 0) }
end
-- The diorama's scale, in world px per metre: the one that makes the
-- table subtend the same field the flat screen frames. The flat camera
-- fits `vh` world pixels in a lens of focal `focal` (Voxel.FOCAL); at
-- VIEW_DIST the same framing needs vh * focal / d pixels to the metre --
-- so the resting head sees the standard view's angle AND its apparent
-- size, and the zoom rows (which change vh) keep working in VR.
function VRRig.dioramaScale(vh, focal)
return math.max(16, (vh or 288) * (focal or 1) / VRRig.VIEW_DIST)
end
-- kept as the test suite's fixed example anchor, and as the fallback for
-- an angle nobody supplied
VRRig.TABLE = { 0, -0.45, -0.75 }
-- eye-space clip planes, in metres (see the unit note above)
VRRig.NEAR = 0.05
VRRig.FAR = 400
-- ------- one eye's camera
-- Build the placed-camera record for one eye.
--
-- pose { pos = {x,y,z} metres, quat = {x,y,z,w} } (OpenXR LOCAL)
-- fov { angleLeft, angleRight, angleUp, angleDown } signed radians
-- pivot {x,y,z} world px pinned to `anchor`
-- anchor {x,y,z} LOCAL metres (VRRig.TABLE, or 0,0,0 for first person)
-- scale world px per metre
--
-- Returns a table shaped for Voxel3D.camera: raw view + proj, the world
-- eye and focus (for setLook, the water's lean, the sky), fov as a
-- vertical span, and the curve declined -- a bent tabletop reads as a
-- broken model, and first person already declines it on the flat screen.
function VRRig.eyeCamera(pose, fov, pivot, anchor, scale)
local px, py, pz = pose.pos[1], pose.pos[2], pose.pos[3]
local q = pose.quat
local R = Mat4.fromQuat(q[1], q[2], q[3], q[4])
-- view = R^T * T(-pos) * T(anchor) * S(1/s) * T(-pivot)
local view = Mat4.mul(Mat4.transpose(R), Mat4.translate(-px, -py, -pz))
view = Mat4.mul(view, Mat4.translate(anchor[1], anchor[2], anchor[3]))
view = Mat4.mul(view, Mat4.scale(1 / scale, 1 / scale, 1 / scale))
view = Mat4.mul(view, Mat4.translate(-pivot[1], -pivot[2], -pivot[3]))
local proj = Mat4.fovProjection(fov.angleLeft, fov.angleRight,
fov.angleUp, fov.angleDown,
VRRig.NEAR, VRRig.FAR)
-- the eye and its forward, in world pixels: worldFromEye applied to the
-- origin and to -Z
local ex = pivot[1] + scale * (px - anchor[1])
local ey = pivot[2] + scale * (py - anchor[2])
local ez = pivot[3] + scale * (pz - anchor[3])
-- R's third column is the eye's +Z axis; forward is its negation
local fx, fy, fz = -R[3], -R[7], -R[11]
return {
view = view,
proj = proj,
eye = { ex, ey, ez },
focus = { ex + fx * scale, ey + fy * scale, ez + fz * scale },
fov = fov.angleUp - fov.angleDown,
curve = 0,
}
end
-- The flat compass numbers a head orientation implies, for driving
-- FirstPerson (and through it FreeMove) from the HMD: yaw in this mod's
-- convention (0 south, pi/2 east) and pitch positive-down.
function VRRig.headYawPitch(quat)
local R = Mat4.fromQuat(quat[1], quat[2], quat[3], quat[4])
local fx, fy, fz = -R[3], -R[7], -R[11]
local flat = math.sqrt(fx * fx + fz * fz)
local yaw = flat > 1e-6 and math.atan2(fx, fz) or 0
local pitch = -math.asin(math.max(-1, math.min(1, fy)))
return yaw, pitch
end
-- The two pivots. First person pins the player's head; the diorama pins
-- the view centre at the ground plane. `gh` is the ground height under
-- the player (VoxelScene.groundAt), `eyeH` FirstPerson.EYE_HEIGHT.
function VRRig.fpPivot(pxTopLeft, pyTopLeft, gh, eyeH)
return { pxTopLeft + 8, (gh or 0) + (eyeH or 13), pyTopLeft + 8 }
end
function VRRig.dioramaPivot(cx, cy)
return { cx, 0, cy }
end
return VRRig
+986
View File
@@ -0,0 +1,986 @@
-- VR: the OpenXR binding -- instance, session, swapchains and the frame
-- loop, spoken to the runtime directly over LuaJIT's FFI so the parent
-- app never has to know VR exists.
--
-- The loader (assets/vr/openxr_loader.dll, the Khronos-shipped x64 build)
-- finds whatever runtime the player has -- SteamVR, the Oculus runtime,
-- WMR -- through the OS's normal OpenXR plumbing. This module drives the
-- exact ceremony the spec requires and nothing else:
--
-- instance (with XR_KHR_opengl_enable) -> system (an HMD) ->
-- graphics-requirements call (mandatory before the session) ->
-- session bound to LOVE's own GL context (the HDC/HGLRC the window is
-- already rendering with, via VRGL) -> LOCAL reference space ->
-- one swapchain per eye at the runtime's recommended size, plus one
-- more for the UI quad -> per frame: poll events, wait/begin, locate
-- views, hand textures out to be blitted into, end with layers.
--
-- Everything FFI-shaped lives HERE: cdata never leaks to callers. Poses
-- and fovs come out as plain Lua tables (VRRig's shape); textures come
-- out as GL ids for VRGL. Every entry point is pcall-guarded and failure
-- is a status string, never an error -- a machine with no runtime, no
-- headset or no GL interop reports why and the mod plays flat.
--
-- What this deliberately does NOT do (v1): XR input (play with the pad,
-- keyboard or mouse -- they all still work with a headset on), depth
-- layers, and Linux/Android graphics bindings (the cdef binds Win32 GL,
-- the one platform LOVE + this loader serve together).
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local VRGL = V.require("VRGL")
local VRXR = {}
local ffi = nil
local xr = nil -- the loader library
local C = nil -- ffi namespace shortcut
-- ------- state
local status = "not started"
local instance, systemId, session, space = nil, nil, nil, nil
local views = nil -- XrViewConfigurationView[2] (sizes)
local eyes = {} -- per eye: { swapchain, images={texids}, w, h }
local quad = nil -- { swapchain, images, w, h }
local sessionState = 0
local running = false -- xrBeginSession happened
local viewsBuf = nil -- XrView[2], refreshed per locate
local lastViews = nil -- lua copies of the located views
-- ------- constants (openxr.h, core 1.0 + XR_KHR_opengl_enable)
local XR = {
TYPE_INSTANCE_CREATE_INFO = 3,
TYPE_SYSTEM_GET_INFO = 4,
TYPE_VIEW_LOCATE_INFO = 6,
TYPE_VIEW = 7,
TYPE_SESSION_CREATE_INFO = 8,
TYPE_SWAPCHAIN_CREATE_INFO = 9,
TYPE_SESSION_BEGIN_INFO = 10,
TYPE_VIEW_STATE = 11,
TYPE_FRAME_END_INFO = 12,
TYPE_EVENT_DATA_BUFFER = 16,
TYPE_EVENT_DATA_SESSION_STATE_CHANGED = 18,
TYPE_FRAME_WAIT_INFO = 33,
TYPE_COMPOSITION_LAYER_PROJECTION = 35,
TYPE_COMPOSITION_LAYER_QUAD = 36,
TYPE_REFERENCE_SPACE_CREATE_INFO = 37,
TYPE_VIEW_CONFIGURATION_VIEW = 41,
TYPE_FRAME_STATE = 44,
TYPE_FRAME_BEGIN_INFO = 46,
TYPE_COMPOSITION_LAYER_PROJECTION_VIEW = 48,
TYPE_SWAPCHAIN_IMAGE_ACQUIRE_INFO = 55,
TYPE_SWAPCHAIN_IMAGE_WAIT_INFO = 56,
TYPE_SWAPCHAIN_IMAGE_RELEASE_INFO = 57,
TYPE_ACTION_STATE_BOOLEAN = 23,
TYPE_ACTION_STATE_FLOAT = 24,
TYPE_ACTION_STATE_VECTOR2F = 25,
TYPE_ACTION_SET_CREATE_INFO = 28,
TYPE_ACTION_CREATE_INFO = 29,
TYPE_ACTION_SPACE_CREATE_INFO = 38,
TYPE_SPACE_LOCATION = 42,
TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING = 51,
TYPE_ACTION_STATE_GET_INFO = 58,
TYPE_SESSION_ACTION_SETS_ATTACH_INFO = 60,
TYPE_ACTIONS_SYNC_INFO = 61,
TYPE_GRAPHICS_BINDING_OPENGL_WIN32_KHR = 1000023000,
TYPE_SWAPCHAIN_IMAGE_OPENGL_KHR = 1000023004,
TYPE_GRAPHICS_REQUIREMENTS_OPENGL_KHR = 1000023005,
ACTION_TYPE_BOOLEAN = 1,
ACTION_TYPE_FLOAT = 2,
ACTION_TYPE_VECTOR2F = 3,
ACTION_TYPE_POSE = 4,
SPACE_LOCATION_POSITION_VALID_BIT = 0x2,
FORM_FACTOR_HEAD_MOUNTED_DISPLAY = 1,
VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO = 2,
REFERENCE_SPACE_TYPE_LOCAL = 2,
ENVIRONMENT_BLEND_MODE_OPAQUE = 1,
EYE_VISIBILITY_BOTH = 0,
SESSION_STATE_READY = 2,
SESSION_STATE_STOPPING = 6,
SESSION_STATE_EXITING = 8,
SESSION_STATE_LOSS_PENDING = 7,
SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT = 0x1,
SWAPCHAIN_USAGE_TRANSFER_DST_BIT = 0x10,
SWAPCHAIN_USAGE_SAMPLED_BIT = 0x20,
INFINITE_DURATION = 0x7fffffffffffffffLL,
API_VERSION_1_0 = 0x0001000000000000ULL, -- XR_MAKE_VERSION(1, 0, 0)
GL_RGBA8 = 0x8058,
GL_SRGB8_ALPHA8 = 0x8C43,
}
VRXR.XR = XR
-- the failures a player can actually act on, by name (values verified
-- against openxr.h -- the probe once printed a bare number because two of
-- these were guessed wrong)
local RESULT_NAMES = {
[0] = "XR_SUCCESS",
[-2] = "XR_ERROR_RUNTIME_FAILURE",
[-4] = "XR_ERROR_API_VERSION_UNSUPPORTED",
[-6] = "XR_ERROR_INITIALIZATION_FAILED",
[-9] = "XR_ERROR_EXTENSION_NOT_PRESENT (runtime has no OpenGL support)",
[-26] = "XR_ERROR_SWAPCHAIN_FORMAT_UNSUPPORTED",
[-34] = "XR_ERROR_FORM_FACTOR_UNSUPPORTED",
[-35] = "XR_ERROR_FORM_FACTOR_UNAVAILABLE"
.. " (headset not connected or not ready)",
[-38] = "XR_ERROR_GRAPHICS_DEVICE_INVALID",
[-50] = "XR_ERROR_GRAPHICS_REQUIREMENTS_CALL_MISSING",
[-51] = "XR_ERROR_RUNTIME_UNAVAILABLE (no OpenXR runtime installed)",
}
local function resultName(r)
return RESULT_NAMES[tonumber(r)] or ("XrResult " .. tonumber(r))
end
local CDEF = [[
typedef uint64_t XrVersion; typedef uint64_t XrFlags64;
typedef uint64_t XrSystemId; typedef int64_t XrTime;
typedef int64_t XrDuration; typedef uint32_t XrBool32;
typedef int32_t XrResult; typedef int32_t XrStructureType;
typedef struct XrInstance_T* XrInstance;
typedef struct XrSession_T* XrSession;
typedef struct XrSpace_T* XrSpace;
typedef struct XrSwapchain_T* XrSwapchain;
typedef struct XrApplicationInfo {
char applicationName[128]; uint32_t applicationVersion;
char engineName[128]; uint32_t engineVersion; XrVersion apiVersion;
} XrApplicationInfo;
typedef struct XrInstanceCreateInfo {
XrStructureType type; const void* next; XrFlags64 createFlags;
XrApplicationInfo applicationInfo;
uint32_t enabledApiLayerCount; const char* const* enabledApiLayerNames;
uint32_t enabledExtensionCount; const char* const* enabledExtensionNames;
} XrInstanceCreateInfo;
typedef struct XrSystemGetInfo {
XrStructureType type; const void* next; int32_t formFactor;
} XrSystemGetInfo;
typedef struct XrGraphicsRequirementsOpenGLKHR {
XrStructureType type; void* next;
XrVersion minApiVersionSupported; XrVersion maxApiVersionSupported;
} XrGraphicsRequirementsOpenGLKHR;
typedef struct XrGraphicsBindingOpenGLWin32KHR {
XrStructureType type; const void* next; void* hDC; void* hGLRC;
} XrGraphicsBindingOpenGLWin32KHR;
typedef struct XrSessionCreateInfo {
XrStructureType type; const void* next; XrFlags64 createFlags;
XrSystemId systemId;
} XrSessionCreateInfo;
typedef struct XrVector3f { float x, y, z; } XrVector3f;
typedef struct XrQuaternionf { float x, y, z, w; } XrQuaternionf;
typedef struct XrPosef { XrQuaternionf orientation; XrVector3f position; } XrPosef;
typedef struct XrReferenceSpaceCreateInfo {
XrStructureType type; const void* next; int32_t referenceSpaceType;
XrPosef poseInReferenceSpace;
} XrReferenceSpaceCreateInfo;
typedef struct XrViewConfigurationView {
XrStructureType type; void* next;
uint32_t recommendedImageRectWidth; uint32_t maxImageRectWidth;
uint32_t recommendedImageRectHeight; uint32_t maxImageRectHeight;
uint32_t recommendedSwapchainSampleCount; uint32_t maxSwapchainSampleCount;
} XrViewConfigurationView;
typedef struct XrSwapchainCreateInfo {
XrStructureType type; const void* next; XrFlags64 createFlags;
XrFlags64 usageFlags; int64_t format; uint32_t sampleCount;
uint32_t width; uint32_t height; uint32_t faceCount; uint32_t arraySize;
uint32_t mipCount;
} XrSwapchainCreateInfo;
typedef struct XrSwapchainImageOpenGLKHR {
XrStructureType type; void* next; uint32_t image;
} XrSwapchainImageOpenGLKHR;
typedef struct XrEventDataBuffer {
XrStructureType type; const void* next; uint8_t varying[4000];
} XrEventDataBuffer;
typedef struct XrEventDataSessionStateChanged {
XrStructureType type; const void* next; XrSession session;
int32_t state; XrTime time;
} XrEventDataSessionStateChanged;
typedef struct XrFrameWaitInfo { XrStructureType type; const void* next; } XrFrameWaitInfo;
typedef struct XrFrameState {
XrStructureType type; void* next; XrTime predictedDisplayTime;
XrDuration predictedDisplayPeriod; XrBool32 shouldRender;
} XrFrameState;
typedef struct XrFrameBeginInfo { XrStructureType type; const void* next; } XrFrameBeginInfo;
typedef struct XrViewLocateInfo {
XrStructureType type; const void* next; int32_t viewConfigurationType;
XrTime displayTime; XrSpace space;
} XrViewLocateInfo;
typedef struct XrViewState { XrStructureType type; void* next; XrFlags64 viewStateFlags; } XrViewState;
typedef struct XrFovf { float angleLeft, angleRight, angleUp, angleDown; } XrFovf;
typedef struct XrView { XrStructureType type; void* next; XrPosef pose; XrFovf fov; } XrView;
typedef struct XrSwapchainImageAcquireInfo { XrStructureType type; const void* next; } XrSwapchainImageAcquireInfo;
typedef struct XrSwapchainImageWaitInfo {
XrStructureType type; const void* next; XrDuration timeout;
} XrSwapchainImageWaitInfo;
typedef struct XrSwapchainImageReleaseInfo { XrStructureType type; const void* next; } XrSwapchainImageReleaseInfo;
typedef struct XrOffset2Di { int32_t x, y; } XrOffset2Di;
typedef struct XrExtent2Di { int32_t width, height; } XrExtent2Di;
typedef struct XrRect2Di { XrOffset2Di offset; XrExtent2Di extent; } XrRect2Di;
typedef struct XrSwapchainSubImage {
XrSwapchain swapchain; XrRect2Di imageRect; uint32_t imageArrayIndex;
} XrSwapchainSubImage;
typedef struct XrCompositionLayerProjectionView {
XrStructureType type; const void* next; XrPosef pose; XrFovf fov;
XrSwapchainSubImage subImage;
} XrCompositionLayerProjectionView;
typedef struct XrCompositionLayerProjection {
XrStructureType type; const void* next; XrFlags64 layerFlags;
XrSpace space; uint32_t viewCount;
const XrCompositionLayerProjectionView* views;
} XrCompositionLayerProjection;
typedef struct XrExtent2Df { float width, height; } XrExtent2Df;
typedef struct XrCompositionLayerQuad {
XrStructureType type; const void* next; XrFlags64 layerFlags;
XrSpace space; int32_t eyeVisibility; XrSwapchainSubImage subImage;
XrPosef pose; XrExtent2Df size;
} XrCompositionLayerQuad;
typedef struct XrSessionBeginInfo {
XrStructureType type; const void* next; int32_t primaryViewConfigurationType;
} XrSessionBeginInfo;
typedef struct XrFrameEndInfo {
XrStructureType type; const void* next; XrTime displayTime;
int32_t environmentBlendMode; uint32_t layerCount;
const void* const* layers;
} XrFrameEndInfo;
XrResult xrCreateInstance(const XrInstanceCreateInfo*, XrInstance*);
XrResult xrDestroyInstance(XrInstance);
XrResult xrGetSystem(XrInstance, const XrSystemGetInfo*, XrSystemId*);
XrResult xrGetInstanceProcAddr(XrInstance, const char*, void**);
XrResult xrCreateSession(XrInstance, const XrSessionCreateInfo*, XrSession*);
XrResult xrDestroySession(XrSession);
XrResult xrCreateReferenceSpace(XrSession, const XrReferenceSpaceCreateInfo*, XrSpace*);
XrResult xrEnumerateViewConfigurationViews(XrInstance, XrSystemId, int32_t,
uint32_t, uint32_t*, XrViewConfigurationView*);
XrResult xrEnumerateSwapchainFormats(XrSession, uint32_t, uint32_t*, int64_t*);
XrResult xrCreateSwapchain(XrSession, const XrSwapchainCreateInfo*, XrSwapchain*);
XrResult xrEnumerateSwapchainImages(XrSwapchain, uint32_t, uint32_t*, void*);
XrResult xrAcquireSwapchainImage(XrSwapchain, const XrSwapchainImageAcquireInfo*, uint32_t*);
XrResult xrWaitSwapchainImage(XrSwapchain, const XrSwapchainImageWaitInfo*);
XrResult xrReleaseSwapchainImage(XrSwapchain, const XrSwapchainImageReleaseInfo*);
XrResult xrBeginSession(XrSession, const XrSessionBeginInfo*);
XrResult xrEndSession(XrSession);
XrResult xrPollEvent(XrInstance, XrEventDataBuffer*);
XrResult xrWaitFrame(XrSession, const XrFrameWaitInfo*, XrFrameState*);
XrResult xrBeginFrame(XrSession, const XrFrameBeginInfo*);
XrResult xrLocateViews(XrSession, const XrViewLocateInfo*, XrViewState*,
uint32_t, uint32_t*, XrView*);
XrResult xrEndFrame(XrSession, const XrFrameEndInfo*);
typedef XrResult (__stdcall *PFN_xrGetOpenGLGraphicsRequirementsKHR)(
XrInstance, XrSystemId, XrGraphicsRequirementsOpenGLKHR*);
typedef struct XrActionSet_T* XrActionSet;
typedef struct XrAction_T* XrAction;
typedef uint64_t XrPath;
typedef struct XrActionSetCreateInfo {
XrStructureType type; const void* next; char actionSetName[64];
char localizedActionSetName[128]; uint32_t priority;
} XrActionSetCreateInfo;
typedef struct XrActionCreateInfo {
XrStructureType type; const void* next; char actionName[64];
int32_t actionType; uint32_t countSubactionPaths;
const XrPath* subactionPaths; char localizedActionName[128];
} XrActionCreateInfo;
typedef struct XrActionSuggestedBinding {
XrAction action; XrPath binding;
} XrActionSuggestedBinding;
typedef struct XrInteractionProfileSuggestedBinding {
XrStructureType type; const void* next; XrPath interactionProfile;
uint32_t countSuggestedBindings;
const XrActionSuggestedBinding* suggestedBindings;
} XrInteractionProfileSuggestedBinding;
typedef struct XrSessionActionSetsAttachInfo {
XrStructureType type; const void* next; uint32_t countActionSets;
const XrActionSet* actionSets;
} XrSessionActionSetsAttachInfo;
typedef struct XrActiveActionSet {
XrActionSet actionSet; XrPath subactionPath;
} XrActiveActionSet;
typedef struct XrActionsSyncInfo {
XrStructureType type; const void* next; uint32_t countActiveActionSets;
const XrActiveActionSet* activeActionSets;
} XrActionsSyncInfo;
typedef struct XrActionStateGetInfo {
XrStructureType type; const void* next; XrAction action;
XrPath subactionPath;
} XrActionStateGetInfo;
typedef struct XrActionStateBoolean {
XrStructureType type; void* next; XrBool32 currentState;
XrBool32 changedSinceLastSync; XrTime lastChangeTime; XrBool32 isActive;
} XrActionStateBoolean;
typedef struct XrActionStateFloat {
XrStructureType type; void* next; float currentState;
XrBool32 changedSinceLastSync; XrTime lastChangeTime; XrBool32 isActive;
} XrActionStateFloat;
typedef struct XrVector2f { float x, y; } XrVector2f;
typedef struct XrActionStateVector2f {
XrStructureType type; void* next; XrVector2f currentState;
XrBool32 changedSinceLastSync; XrTime lastChangeTime; XrBool32 isActive;
} XrActionStateVector2f;
typedef struct XrActionSpaceCreateInfo {
XrStructureType type; const void* next; XrAction action;
XrPath subactionPath; XrPosef poseInActionSpace;
} XrActionSpaceCreateInfo;
typedef struct XrSpaceLocation {
XrStructureType type; void* next; XrFlags64 locationFlags; XrPosef pose;
} XrSpaceLocation;
XrResult xrStringToPath(XrInstance, const char*, XrPath*);
XrResult xrCreateActionSet(XrInstance, const XrActionSetCreateInfo*, XrActionSet*);
XrResult xrCreateAction(XrActionSet, const XrActionCreateInfo*, XrAction*);
XrResult xrSuggestInteractionProfileBindings(XrInstance,
const XrInteractionProfileSuggestedBinding*);
XrResult xrAttachSessionActionSets(XrSession, const XrSessionActionSetsAttachInfo*);
XrResult xrSyncActions(XrSession, const XrActionsSyncInfo*);
XrResult xrGetActionStateBoolean(XrSession, const XrActionStateGetInfo*,
XrActionStateBoolean*);
XrResult xrGetActionStateFloat(XrSession, const XrActionStateGetInfo*,
XrActionStateFloat*);
XrResult xrGetActionStateVector2f(XrSession, const XrActionStateGetInfo*,
XrActionStateVector2f*);
XrResult xrCreateActionSpace(XrSession, const XrActionSpaceCreateInfo*, XrSpace*);
XrResult xrLocateSpace(XrSpace, XrSpace, XrTime, XrSpaceLocation*);
]]
-- ------- plumbing
local function fail(what, r)
status = what .. ": " .. (r and resultName(r) or "error")
return nil
end
local function check(r, what)
if tonumber(r) ~= 0 then
error(what .. " failed: " .. resultName(r), 0)
end
end
-- Where the loader DLL might really be on disk. mod.path is
-- PHYSFS-relative; ffi.load needs an OS path, so try it against the
-- process's working directory and against the game's source directory.
local function loaderCandidates()
local rel = (V.path or "mods/DramaticShapeVoxelMod")
.. "/assets/vr/openxr_loader.dll"
local list = { rel }
local ok, src = pcall(function() return love.filesystem.getSource() end)
if ok and type(src) == "string" then list[#list + 1] = src .. "/" .. rel end
list[#list + 1] = "openxr_loader" -- last resort: the system search path
return list
end
local function loadLoader()
ffi = require("ffi")
pcall(ffi.cdef, CDEF)
C = ffi
local errs = {}
for _, path in ipairs(loaderCandidates()) do
local ok, lib = pcall(ffi.load, path)
if ok then return lib end
errs[#errs + 1] = tostring(lib)
end
error("openxr_loader.dll not loadable (is the mod running from a real "
.. "folder, not an archive?): " .. table.concat(errs, " | "), 0)
end
local function makeSwapchain(w, h, format)
local info = ffi.new("XrSwapchainCreateInfo")
info.type = XR.TYPE_SWAPCHAIN_CREATE_INFO
info.usageFlags = XR.SWAPCHAIN_USAGE_COLOR_ATTACHMENT_BIT
+ XR.SWAPCHAIN_USAGE_TRANSFER_DST_BIT
+ XR.SWAPCHAIN_USAGE_SAMPLED_BIT
info.format = format
info.sampleCount = 1
info.width, info.height = w, h
info.faceCount, info.arraySize, info.mipCount = 1, 1, 1
local out = ffi.new("XrSwapchain[1]")
check(xr.xrCreateSwapchain(session, info, out), "xrCreateSwapchain")
local chain = out[0]
local count = ffi.new("uint32_t[1]")
check(xr.xrEnumerateSwapchainImages(chain, 0, count, nil),
"xrEnumerateSwapchainImages(count)")
local imgs = ffi.new("XrSwapchainImageOpenGLKHR[?]", count[0])
for i = 0, count[0] - 1 do
imgs[i].type = XR.TYPE_SWAPCHAIN_IMAGE_OPENGL_KHR
end
check(xr.xrEnumerateSwapchainImages(chain, count[0], count, imgs),
"xrEnumerateSwapchainImages")
local texids = {}
for i = 0, count[0] - 1 do texids[i + 1] = tonumber(imgs[i].image) end
return { swapchain = chain, images = texids, w = w, h = h }
end
-- The swapchain format: LOVE's canvases are plain RGBA8, so ask for that
-- and take sRGB8 only when it is all the runtime offers (colours come out
-- slightly lifted there; documented rather than corrected in v1).
local function pickFormat()
local count = ffi.new("uint32_t[1]")
check(xr.xrEnumerateSwapchainFormats(session, 0, count, nil),
"xrEnumerateSwapchainFormats(count)")
local fmts = ffi.new("int64_t[?]", count[0])
check(xr.xrEnumerateSwapchainFormats(session, count[0], count, fmts),
"xrEnumerateSwapchainFormats")
local has = {}
for i = 0, count[0] - 1 do has[tonumber(fmts[i])] = true end
if has[XR.GL_RGBA8] then return XR.GL_RGBA8 end
if has[XR.GL_SRGB8_ALPHA8] then return XR.GL_SRGB8_ALPHA8 end
return tonumber(fmts[0])
end
-- ------- input: one action set, the mod's fixed verbs
--
-- OpenXR input is semantic: the app declares ACTIONS (move, A, grip...),
-- suggests where they live on the controllers it knows, and the runtime
-- owns the actual binding (and lets the player rebind in its own UI).
-- The suggestions below cover Touch, Index and WMR sticks-and-buttons
-- style, plus the khr/simple fallback every runtime accepts. Input is
-- OPTIONAL: a runtime that refuses any of this leaves `input` nil and VR
-- stays view-only with the desk's own controls.
local input = nil -- { set, A = {name->XrAction}, spaces, active }
local function toPath(str)
local out = ffi.new("XrPath[1]")
check(xr.xrStringToPath(instance, str, out), "xrStringToPath " .. str)
return out[0]
end
local function makeAction(set, name, atype, localized)
local info = ffi.new("XrActionCreateInfo")
info.type = XR.TYPE_ACTION_CREATE_INFO
ffi.copy(info.actionName, name)
info.actionType = atype
ffi.copy(info.localizedActionName, localized)
local out = ffi.new("XrAction[1]")
check(xr.xrCreateAction(set, info, out), "xrCreateAction " .. name)
return out[0]
end
local function setupInput()
local sci = ffi.new("XrActionSetCreateInfo")
sci.type = XR.TYPE_ACTION_SET_CREATE_INFO
ffi.copy(sci.actionSetName, "gameplay")
ffi.copy(sci.localizedActionSetName, "Gameplay")
local setOut = ffi.new("XrActionSet[1]")
check(xr.xrCreateActionSet(instance, sci, setOut), "xrCreateActionSet")
local set = setOut[0]
local A = {
move = makeAction(set, "move", XR.ACTION_TYPE_VECTOR2F, "Move"),
look = makeAction(set, "look", XR.ACTION_TYPE_VECTOR2F, "Zoom / Look"),
a = makeAction(set, "btn_a", XR.ACTION_TYPE_BOOLEAN, "A"),
b = makeAction(set, "btn_b", XR.ACTION_TYPE_BOOLEAN, "B"),
start = makeAction(set, "btn_start", XR.ACTION_TYPE_BOOLEAN, "Start"),
toggle = makeAction(set, "viewtoggle", XR.ACTION_TYPE_BOOLEAN,
"First / Third Person"),
angle = makeAction(set, "angle", XR.ACTION_TYPE_BOOLEAN,
"Diorama Angle"),
gripl = makeAction(set, "grip_l", XR.ACTION_TYPE_FLOAT, "Left Grip"),
gripr = makeAction(set, "grip_r", XR.ACTION_TYPE_FLOAT, "Right Grip"),
handl = makeAction(set, "hand_l", XR.ACTION_TYPE_POSE, "Left Hand"),
handr = makeAction(set, "hand_r", XR.ACTION_TYPE_POSE, "Right Hand"),
}
local function suggest(profile, list)
local arr = ffi.new("XrActionSuggestedBinding[?]", #list)
for i, p in ipairs(list) do
arr[i - 1].action = p[1]
arr[i - 1].binding = toPath(p[2])
end
local info = ffi.new("XrInteractionProfileSuggestedBinding")
info.type = XR.TYPE_INTERACTION_PROFILE_SUGGESTED_BINDING
info.interactionProfile = toPath(profile)
info.countSuggestedBindings = #list
info.suggestedBindings = arr
check(xr.xrSuggestInteractionProfileBindings(instance, info),
"suggest " .. profile)
end
-- the sticks-and-buttons layout Touch and Index share
local sticks = {
{ A.move, "/user/hand/left/input/thumbstick" },
{ A.look, "/user/hand/right/input/thumbstick" },
{ A.a, "/user/hand/right/input/a/click" },
{ A.b, "/user/hand/right/input/b/click" },
{ A.a, "/user/hand/left/input/x/click" },
{ A.b, "/user/hand/left/input/y/click" },
{ A.start, "/user/hand/left/input/trigger/value" },
{ A.start, "/user/hand/right/input/trigger/value" },
{ A.toggle, "/user/hand/left/input/thumbstick/click" },
{ A.angle, "/user/hand/right/input/thumbstick/click" },
{ A.gripl, "/user/hand/left/input/squeeze/value" },
{ A.gripr, "/user/hand/right/input/squeeze/value" },
{ A.handl, "/user/hand/left/input/grip/pose" },
{ A.handr, "/user/hand/right/input/grip/pose" },
}
-- Index has X/Y on no hand -- its A/B exist on BOTH -- so the two X/Y
-- rows are swapped for left A/B there
local index = {}
for i, p in ipairs(sticks) do index[i] = p end
index[5] = { A.a, "/user/hand/left/input/a/click" }
index[6] = { A.b, "/user/hand/left/input/b/click" }
pcall(suggest, "/interaction_profiles/oculus/touch_controller", sticks)
pcall(suggest, "/interaction_profiles/valve/index_controller", index)
pcall(suggest, "/interaction_profiles/microsoft/motion_controller", {
{ A.move, "/user/hand/left/input/thumbstick" },
{ A.look, "/user/hand/right/input/thumbstick" },
{ A.a, "/user/hand/right/input/trackpad/click" },
{ A.b, "/user/hand/left/input/trackpad/click" },
{ A.start, "/user/hand/left/input/trigger/value" },
{ A.start, "/user/hand/right/input/trigger/value" },
{ A.toggle, "/user/hand/left/input/thumbstick/click" },
{ A.angle, "/user/hand/right/input/thumbstick/click" },
{ A.gripl, "/user/hand/left/input/squeeze/click" },
{ A.gripr, "/user/hand/right/input/squeeze/click" },
{ A.handl, "/user/hand/left/input/grip/pose" },
{ A.handr, "/user/hand/right/input/grip/pose" },
})
pcall(suggest, "/interaction_profiles/khr/simple_controller", {
{ A.a, "/user/hand/right/input/select/click" },
{ A.a, "/user/hand/left/input/select/click" },
{ A.start, "/user/hand/right/input/menu/click" },
{ A.start, "/user/hand/left/input/menu/click" },
})
local function handSpace(action)
local info = ffi.new("XrActionSpaceCreateInfo")
info.type = XR.TYPE_ACTION_SPACE_CREATE_INFO
info.action = action
info.poseInActionSpace.orientation.w = 1
local out = ffi.new("XrSpace[1]")
check(xr.xrCreateActionSpace(session, info, out), "xrCreateActionSpace")
return out[0]
end
local spaces = { handl = handSpace(A.handl), handr = handSpace(A.handr) }
local sets = ffi.new("XrActionSet[1]")
sets[0] = set
local att = ffi.new("XrSessionActionSetsAttachInfo")
att.type = XR.TYPE_SESSION_ACTION_SETS_ATTACH_INFO
att.countActionSets = 1
att.actionSets = sets
check(xr.xrAttachSessionActionSets(session, att),
"xrAttachSessionActionSets")
local active = ffi.new("XrActiveActionSet[1]")
active[0].actionSet = set
input = { set = set, A = A, spaces = spaces, active = active }
end
-- Sync and read this frame's controller state as one plain table, or nil
-- when there is none to read (no input attached, session unfocused) --
-- which callers treat as everything released.
function VRXR.input(time)
if not (running and input) then return nil end
local out = nil
pcall(function()
local si = ffi.new("XrActionsSyncInfo")
si.type = XR.TYPE_ACTIONS_SYNC_INFO
si.countActiveActionSets = 1
si.activeActionSets = input.active
if tonumber(xr.xrSyncActions(session, si)) ~= 0 then return end
local gi = ffi.new("XrActionStateGetInfo")
gi.type = XR.TYPE_ACTION_STATE_GET_INFO
local function readBool(action)
gi.action = action
local st = ffi.new("XrActionStateBoolean")
st.type = XR.TYPE_ACTION_STATE_BOOLEAN
xr.xrGetActionStateBoolean(session, gi, st)
return st.currentState ~= 0, st.changedSinceLastSync ~= 0
end
local function readVec2(action)
gi.action = action
local st = ffi.new("XrActionStateVector2f")
st.type = XR.TYPE_ACTION_STATE_VECTOR2F
xr.xrGetActionStateVector2f(session, gi, st)
return st.currentState.x, st.currentState.y
end
local function readFloat(action)
gi.action = action
local st = ffi.new("XrActionStateFloat")
st.type = XR.TYPE_ACTION_STATE_FLOAT
xr.xrGetActionStateFloat(session, gi, st)
return st.currentState
end
local A = input.A
local o = {}
o.moveX, o.moveY = readVec2(A.move)
o.lookX, o.lookY = readVec2(A.look)
o.a, o.aChanged = readBool(A.a)
o.b, o.bChanged = readBool(A.b)
o.start, o.startChanged = readBool(A.start)
o.toggle, o.toggleChanged = readBool(A.toggle)
o.angle, o.angleChanged = readBool(A.angle)
o.gripL = readFloat(A.gripl)
o.gripR = readFloat(A.gripr)
-- hand heights in LOCAL metres, for the grab-drag; absent when the
-- runtime has no valid position for a hand this frame
local loc = ffi.new("XrSpaceLocation")
for name, spc in pairs(input.spaces) do
loc.type = XR.TYPE_SPACE_LOCATION
loc.next = nil
loc.locationFlags = 0
if tonumber(xr.xrLocateSpace(spc, space, time, loc)) == 0 then
local fl = tonumber(loc.locationFlags) or 0
if fl % 4 >= XR.SPACE_LOCATION_POSITION_VALID_BIT then
o[name .. "Y"] = loc.pose.position.y
end
end
end
out = o
end)
return out
end
-- ------- lifecycle
-- Bring the whole stack up, or say exactly why not. Never throws; call
-- again after fixing whatever status() names.
function VRXR.start(quadW, quadH)
if session then return true end
local ok, err = pcall(function()
if not VRGL.load() then error(VRGL.status(), 0) end
xr = xr or loadLoader()
-- instance, asking only for the one extension this rides on
local extName = ffi.new("const char*[1]")
local extStr = ffi.new("char[?]", 32, "XR_KHR_opengl_enable")
extName[0] = extStr
local ici = ffi.new("XrInstanceCreateInfo")
ici.type = XR.TYPE_INSTANCE_CREATE_INFO
ffi.copy(ici.applicationInfo.applicationName, "DramaticShapeVoxelMod")
ici.applicationInfo.applicationVersion = 1
ffi.copy(ici.applicationInfo.engineName, "LOVE")
ici.applicationInfo.engineVersion = 11
ici.applicationInfo.apiVersion = XR.API_VERSION_1_0
ici.enabledExtensionCount = 1
ici.enabledExtensionNames = extName
local inst = ffi.new("XrInstance[1]")
check(xr.xrCreateInstance(ici, inst), "xrCreateInstance")
instance = inst[0]
-- a headset
local sgi = ffi.new("XrSystemGetInfo")
sgi.type = XR.TYPE_SYSTEM_GET_INFO
sgi.formFactor = XR.FORM_FACTOR_HEAD_MOUNTED_DISPLAY
local sys = ffi.new("XrSystemId[1]")
check(xr.xrGetSystem(instance, sgi, sys), "xrGetSystem")
systemId = sys[0]
-- the spec REQUIRES this call before the session may exist
local pfn = ffi.new("void*[1]")
check(xr.xrGetInstanceProcAddr(instance,
"xrGetOpenGLGraphicsRequirementsKHR", pfn),
"xrGetInstanceProcAddr")
local reqs = ffi.new("XrGraphicsRequirementsOpenGLKHR")
reqs.type = XR.TYPE_GRAPHICS_REQUIREMENTS_OPENGL_KHR
check(ffi.cast("PFN_xrGetOpenGLGraphicsRequirementsKHR", pfn[0])(
instance, systemId, reqs), "xrGetOpenGLGraphicsRequirementsKHR")
-- the session, married to LOVE's own GL context. The binding is
-- allocated as a one-element ARRAY: LuaJIT decays arrays to pointers
-- on assignment, which is what chaining it into `next` needs -- a
-- bare struct would not convert.
local hdc, hglrc = VRGL.contexts()
if hdc == nil or hglrc == nil then
error("no current GL context to bind (is a window up?)", 0)
end
local binding = ffi.new("XrGraphicsBindingOpenGLWin32KHR[1]")
binding[0].type = XR.TYPE_GRAPHICS_BINDING_OPENGL_WIN32_KHR
binding[0].hDC, binding[0].hGLRC = hdc, hglrc
local sci = ffi.new("XrSessionCreateInfo")
sci.type = XR.TYPE_SESSION_CREATE_INFO
sci.next = binding
sci.systemId = systemId
local ses = ffi.new("XrSession[1]")
check(xr.xrCreateSession(instance, sci, ses), "xrCreateSession")
session = ses[0]
-- LOCAL space: origin where the head was, +Y up, -Z ahead
local rsci = ffi.new("XrReferenceSpaceCreateInfo")
rsci.type = XR.TYPE_REFERENCE_SPACE_CREATE_INFO
rsci.referenceSpaceType = XR.REFERENCE_SPACE_TYPE_LOCAL
rsci.poseInReferenceSpace.orientation.w = 1
local spc = ffi.new("XrSpace[1]")
check(xr.xrCreateReferenceSpace(session, rsci, spc),
"xrCreateReferenceSpace")
space = spc[0]
-- the two eyes at the runtime's recommended size
local count = ffi.new("uint32_t[1]")
check(xr.xrEnumerateViewConfigurationViews(instance, systemId,
XR.VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, 0, count, nil),
"xrEnumerateViewConfigurationViews(count)")
if count[0] < 2 then error("stereo view configuration missing", 0) end
views = ffi.new("XrViewConfigurationView[?]", count[0])
for i = 0, count[0] - 1 do
views[i].type = XR.TYPE_VIEW_CONFIGURATION_VIEW
end
check(xr.xrEnumerateViewConfigurationViews(instance, systemId,
XR.VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO, count[0], count, views),
"xrEnumerateViewConfigurationViews")
local format = pickFormat()
for i = 1, 2 do
eyes[i] = makeSwapchain(views[i - 1].recommendedImageRectWidth,
views[i - 1].recommendedImageRectHeight,
format)
end
quad = makeSwapchain(quadW or 1024, quadH or 768, format)
viewsBuf = ffi.new("XrView[2]")
end)
if not ok then
VRXR.stop()
status = tostring(err)
return false
end
-- controllers, in their own pcall: a runtime that refuses the action
-- system costs the controllers and nothing else -- the head still
-- tracks and the desk's own inputs still play
local okIn, errIn = pcall(setupInput)
if not okIn then
input = nil
print("[DRAMATIC_SHAPE] VR controllers unavailable: " .. tostring(errIn))
end
status = "session created"
return true
end
-- Tear the whole stack down (setting toggled off, or a fatal state).
function VRXR.stop()
running = false
if session then pcall(function() xr.xrDestroySession(session) end) end
if instance then pcall(function() xr.xrDestroyInstance(instance) end) end
session, space, instance, systemId = nil, nil, nil, nil
eyes, quad, views, viewsBuf, lastViews = {}, nil, nil, nil, nil
input = nil -- its handles died with the instance
end
-- ------- the frame loop
-- Pump the runtime's events: begin the session when it says READY, end it
-- when it says STOPPING. Returns false when the session is gone for good.
function VRXR.poll()
if not session then return false end
local alive = true
pcall(function()
-- a one-element array, so the retype cast below has a pointer to
-- decay from (ffi.cast does not take a bare struct's address)
local buf = ffi.new("XrEventDataBuffer[1]")
while true do
buf[0].type = XR.TYPE_EVENT_DATA_BUFFER
buf[0].next = nil
if tonumber(xr.xrPollEvent(instance, buf)) ~= 0 then break end
if buf[0].type == XR.TYPE_EVENT_DATA_SESSION_STATE_CHANGED then
local ev = ffi.cast("XrEventDataSessionStateChanged*", buf)
sessionState = tonumber(ev.state)
if sessionState == XR.SESSION_STATE_READY and not running then
local sbi = ffi.new("XrSessionBeginInfo")
sbi.type = XR.TYPE_SESSION_BEGIN_INFO
sbi.primaryViewConfigurationType =
XR.VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO
if tonumber(xr.xrBeginSession(session, sbi)) == 0 then
running = true
status = "running"
end
elseif sessionState == XR.SESSION_STATE_STOPPING then
pcall(function() xr.xrEndSession(session) end)
running = false
status = "stopped by runtime"
elseif sessionState == XR.SESSION_STATE_EXITING
or sessionState == XR.SESSION_STATE_LOSS_PENDING then
alive = false
end
end
end
end)
if not alive then VRXR.stop() end
return session ~= nil
end
function VRXR.isRunning()
return running
end
-- Block until the runtime wants the next frame; returns its predicted
-- display time and whether rendering is worth doing, or nil off-session.
function VRXR.waitFrame()
if not running then return nil end
local time, should = nil, false
local ok = pcall(function()
local fs = ffi.new("XrFrameState")
fs.type = XR.TYPE_FRAME_STATE
check(xr.xrWaitFrame(session, nil, fs), "xrWaitFrame")
check(xr.xrBeginFrame(session, nil), "xrBeginFrame")
time = fs.predictedDisplayTime
should = fs.shouldRender ~= 0
end)
if not ok then return nil end
return time, should
end
-- Where each eye is and what it sees, at `time`, as plain tables (and
-- kept as cdata for the layer submit). nil when tracking is not live.
function VRXR.locateViews(time)
if not running then return nil end
local out = nil
pcall(function()
local vli = ffi.new("XrViewLocateInfo")
vli.type = XR.TYPE_VIEW_LOCATE_INFO
vli.viewConfigurationType = XR.VIEW_CONFIGURATION_TYPE_PRIMARY_STEREO
vli.displayTime = time
vli.space = space
local vs = ffi.new("XrViewState")
vs.type = XR.TYPE_VIEW_STATE
local count = ffi.new("uint32_t[1]")
viewsBuf[0].type, viewsBuf[1].type = XR.TYPE_VIEW, XR.TYPE_VIEW
check(xr.xrLocateViews(session, vli, vs, 2, count, viewsBuf),
"xrLocateViews")
if count[0] < 2 then return end
out = {}
for i = 1, 2 do
local v = viewsBuf[i - 1]
out[i] = {
pose = {
pos = { v.pose.position.x, v.pose.position.y, v.pose.position.z },
quat = { v.pose.orientation.x, v.pose.orientation.y,
v.pose.orientation.z, v.pose.orientation.w },
},
fov = { angleLeft = v.fov.angleLeft, angleRight = v.fov.angleRight,
angleUp = v.fov.angleUp, angleDown = v.fov.angleDown },
w = eyes[i].w, h = eyes[i].h,
}
end
end)
lastViews = out
return out
end
-- Acquire eye `i`'s next texture for writing. Pair with releaseEye.
function VRXR.acquireEye(i)
local e = eyes[i]
if not (running and e) then return nil end
local tex = nil
pcall(function()
local idx = ffi.new("uint32_t[1]")
check(xr.xrAcquireSwapchainImage(e.swapchain, nil, idx),
"xrAcquireSwapchainImage")
local wi = ffi.new("XrSwapchainImageWaitInfo")
wi.type = XR.TYPE_SWAPCHAIN_IMAGE_WAIT_INFO
wi.timeout = XR.INFINITE_DURATION
check(xr.xrWaitSwapchainImage(e.swapchain, wi), "xrWaitSwapchainImage")
tex = e.images[idx[0] + 1]
end)
return tex, e.w, e.h
end
function VRXR.releaseEye(i)
local e = eyes[i]
if not (running and e) then return end
pcall(function() xr.xrReleaseSwapchainImage(e.swapchain, nil) end)
end
function VRXR.acquireQuad()
if not (running and quad) then return nil end
local tex = nil
pcall(function()
local idx = ffi.new("uint32_t[1]")
check(xr.xrAcquireSwapchainImage(quad.swapchain, nil, idx),
"xrAcquireSwapchainImage(quad)")
local wi = ffi.new("XrSwapchainImageWaitInfo")
wi.type = XR.TYPE_SWAPCHAIN_IMAGE_WAIT_INFO
wi.timeout = XR.INFINITE_DURATION
check(xr.xrWaitSwapchainImage(quad.swapchain, wi), "xrWaitSwapchainImage")
tex = quad.images[idx[0] + 1]
end)
return tex, quad.w, quad.h
end
function VRXR.releaseQuad()
if not (running and quad) then return end
pcall(function() xr.xrReleaseSwapchainImage(quad.swapchain, nil) end)
end
-- Close the frame. `world` submits the projection layer from the LAST
-- locateViews (both eyes assumed blitted); `quadOpts` floats the UI panel:
-- { pos = {x,y,z}, quat = {x,y,z,w}, width = metres }. Either may be nil.
function VRXR.endFrame(time, world, quadOpts)
if not running then return end
pcall(function()
-- every layer is a one-element ARRAY so it decays to a pointer when
-- stored into the layer list; the locals also anchor the cdata
-- against collection until xrEndFrame has read it
local layers = {}
local projViews, proj, quadLayer
if world and lastViews then
projViews = ffi.new("XrCompositionLayerProjectionView[2]")
for i = 0, 1 do
projViews[i].type = XR.TYPE_COMPOSITION_LAYER_PROJECTION_VIEW
projViews[i].pose = viewsBuf[i].pose
projViews[i].fov = viewsBuf[i].fov
projViews[i].subImage.swapchain = eyes[i + 1].swapchain
projViews[i].subImage.imageRect.extent.width = eyes[i + 1].w
projViews[i].subImage.imageRect.extent.height = eyes[i + 1].h
end
proj = ffi.new("XrCompositionLayerProjection[1]")
proj[0].type = XR.TYPE_COMPOSITION_LAYER_PROJECTION
proj[0].space = space
proj[0].viewCount = 2
proj[0].views = projViews
layers[#layers + 1] = proj
end
if quadOpts and quad then
quadLayer = ffi.new("XrCompositionLayerQuad[1]")
quadLayer[0].type = XR.TYPE_COMPOSITION_LAYER_QUAD
quadLayer[0].space = space
quadLayer[0].eyeVisibility = XR.EYE_VISIBILITY_BOTH
quadLayer[0].subImage.swapchain = quad.swapchain
quadLayer[0].subImage.imageRect.extent.width = quad.w
quadLayer[0].subImage.imageRect.extent.height = quad.h
local p, q = quadOpts.pos or { 0, 0, -1 }, quadOpts.quat or { 0, 0, 0, 1 }
quadLayer[0].pose.position.x = p[1]
quadLayer[0].pose.position.y = p[2]
quadLayer[0].pose.position.z = p[3]
quadLayer[0].pose.orientation.x = q[1]
quadLayer[0].pose.orientation.y = q[2]
quadLayer[0].pose.orientation.z = q[3]
quadLayer[0].pose.orientation.w = q[4]
local w = quadOpts.width or 1.0
quadLayer[0].size.width = w
quadLayer[0].size.height = w * (quad.h / quad.w)
layers[#layers + 1] = quadLayer
end
local arr = ffi.new("const void*[?]", math.max(1, #layers))
for i = 1, #layers do arr[i - 1] = layers[i] end
local fei = ffi.new("XrFrameEndInfo")
fei.type = XR.TYPE_FRAME_END_INFO
fei.displayTime = time
fei.environmentBlendMode = XR.ENVIRONMENT_BLEND_MODE_OPAQUE
fei.layerCount = #layers
fei.layers = arr
xr.xrEndFrame(session, fei)
end)
end
function VRXR.quadSize()
if quad then return quad.w, quad.h end
return nil
end
function VRXR.status()
return status
end
return VRXR
+13
View File
@@ -424,6 +424,12 @@ end
-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil, -- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil,
-- up = {x,y,z} or nil }. -- up = {x,y,z} or nil }.
-- --
-- A caller with matrices of its own -- the VR eyes, whose view comes from
-- a tracked pose and whose projection is an off-centre frustum no
-- eye/focus/fov triple can express -- sets `view` and `proj` instead, and
-- the eye/focus fields stay for everything that reasons about the camera
-- rather than projecting with it (setLook, the sky, the water's lean).
--
-- The orbit is the free-roam camera and it is described entirely by ONE -- The orbit is the free-roam camera and it is described entirely by ONE
-- number, the pitch, because that is all a camera following the player over -- number, the pitch, because that is all a camera following the player over
-- their own map ever needs. A staged shot -- the overworld battle's -- their own map ever needs. A staged shot -- the overworld battle's
@@ -484,6 +490,13 @@ function Voxel3D.viewProjection(cx, cy, vw, vh)
-- question about which way this camera looks, and only these two answer it -- question about which way this camera looks, and only these two answer it
Voxel3D.focus = focus Voxel3D.focus = focus
setLook(eye, focus) setLook(eye, focus)
-- a camera that brought its own matrices (a VR eye) projects with
-- them; only the clip-space Y flip is added, for the same canvas
-- reason as every other branch here
if cam.view and cam.proj then
Voxel3D.fovY = cam.fov
return Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), cam.proj), cam.view)
end
local dx = eye[1] - focus[1] local dx = eye[1] - focus[1]
local dy = eye[2] - focus[2] local dy = eye[2] - focus[2]
local dz = eye[3] - focus[3] local dz = eye[3] - focus[3]
+103 -9
View File
@@ -25,6 +25,7 @@ local Water = V.require("Water")
local VoxelGrid = V.require("VoxelGrid") local VoxelGrid = V.require("VoxelGrid")
local DayNight = V.require("DayNight") local DayNight = V.require("DayNight")
local FirstPerson = V.require("FirstPerson") local FirstPerson = V.require("FirstPerson")
local BattleBillboard = V.require("BattleBillboard")
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map") local Map = require("src.world.Map")
@@ -772,9 +773,12 @@ end
-- left out on purpose: thousands of tufts would cast a speckle no bigger -- left out on purpose: thousands of tufts would cast a speckle no bigger
-- than the pixels it lands on, at the cost of the mesh being drawn twice. -- than the pixels it lands on, at the cost of the mesh being drawn twice.
local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
atlasFor, water, nbWater) atlasFor, water, nbWater, battleCards, battleToken)
if not ShadowMap.available() then return end if not ShadowMap.available() then return end
local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh) local sig = shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
-- a staged fight's pics move every frame the animation does, and the sun
-- has to follow them (VR frames only; see render)
if battleToken then sig = sig .. "|btl" .. tostring(battleToken) end
if not ShadowMap.stale(sig) then return end if not ShadowMap.stale(sig) then return end
if not ShadowMap.begin(cx, cy, vw, vh) then return end if not ShadowMap.begin(cx, cy, vw, vh) then return end
@@ -835,12 +839,24 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
mirror))) mirror)))
end end
end end
-- a staged fight's mons (VR frames only): the same cards the eye pass
-- stands on the arena, snugged like every thin card, marked as the cast
-- so the water can decline them like everybody else's silhouette
for _, card in ipairs(battleCards or {}) do
ShadowMap.draw(BattleBillboard.mesh(), card.tex, ShadowMap.snug(card.model))
end
ShadowMap.sprites(false) ShadowMap.sprites(false)
ShadowMap.finish(sig) ShadowMap.finish(sig)
end end
function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- Render the world. Without `eyes`, one frame into one canvas -- the flat
-- path every rung has always taken. With `eyes` -- a list of
-- { camera, w, h, slot, adopt } records, plus optional cx/cy for the
-- scene centre -- the same frame is drawn once per entry and the list of
-- canvases comes back: the VR path, two eyes over one shared shadow map,
-- pose capture and glint step.
function VoxelScene.render(state, w, h, vw, vh, paletteFor, eyes)
-- With nothing cached at all (the first frame of a fresh toggle), -- With nothing cached at all (the first frame of a fresh toggle),
-- return nil: the engine keeps the 2D path for the frame and -- return nil: the engine keeps the 2D path for the frame and
-- Voxel.ready holds the camera tween at flat, so the switch waits -- Voxel.ready holds the camera tween at flat, so the switch waits
@@ -890,8 +906,30 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- exactly what it always was. The scene centre it returns walks from -- exactly what it always was. The scene centre it returns walks from
-- the orbit's view centre into the head, so the curve's focus and the -- the orbit's view centre into the head, so the curve's focus and the
-- depth reference follow the camera actually in charge. -- depth reference follow the camera actually in charge.
local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh) --
if fpRig then cx, cy = fpCx, fpCy end -- A VR frame skips all of it: the caller brought its own cameras, and
-- its own idea of the scene centre with them.
if not eyes then
local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh)
if fpRig then cx, cy = fpCx, fpCy end
elseif eyes.cx then
cx, cy = eyes.cx, eyes.cy
end
-- A staged fight, seen by the VR eyes: the flat screen draws the battle
-- SCREEN while one is up (this pass never runs), but the headset keeps
-- looking at the world, so the world had better have the fight on it.
-- Fetched per frame for the sun, and again per EYE in drawScene, because
-- the cards yaw toward whichever eye is asking.
local battleCards, battleTex, battleToken = nil, nil, nil
if eyes then
local okB, cards, tex, token = pcall(function()
return V.require("OverworldBattle").worldCards()
end)
if okB and cards then
battleCards, battleTex, battleToken = cards, tex, token
end
end
-- The sun's box, pushed along the first-person look so it covers the -- The sun's box, pushed along the first-person look so it covers the
-- ground THIS camera sees (a no-op at blend zero): the orbit's fit -- ground THIS camera sees (a no-op at blend zero): the orbit's fit
@@ -899,11 +937,13 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- but a head free to face south. -- but a head free to face south.
local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh)
castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor, castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor,
water, nbWater) water, nbWater, battleCards, battleToken)
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then -- Everything between beginScene and endScene, as one function: the flat
return nil -- path runs it once, a VR frame runs it once PER EYE -- same posed
end -- list, same shadow map, same glint, so the two eyes can never disagree
-- about anything but their viewpoint.
local function drawScene()
Voxel3D.draw(terrain, atlasFor(state.map), nil) Voxel3D.draw(terrain, atlasFor(state.map), nil)
for i, nb in ipairs(state.neighbors or {}) do for i, nb in ipairs(state.neighbors or {}) do
@@ -991,6 +1031,33 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
-- character genuinely behind a building is far deeper and loses the -- character genuinely behind a building is far deeper and loses the
-- test, so buildings and trees really occlude. -- test, so buildings and trees really occlude.
drawCast(state, posed, atlasFor) drawCast(state, posed, atlasFor)
-- The staged fight's mons, standing on their arena cells in THIS eye's
-- view (VR frames only; battleTex is nil otherwise). Rebuilt per eye
-- because the cards yaw toward the eye that is looking. No wireframe
-- and no glass on them for the reasons BattleBillboard and the battle
-- pass each argue: the cards are not on the voxel grid, and their
-- texcoords mean nothing to the tileset's pane mask. The hit flash
-- rides the same flatten the battle pass uses, held short of solid.
if battleTex then
local okB, cards = pcall(function()
return V.require("OverworldBattle").worldCards()
end)
if okB and cards then
local BattleScene = V.require("BattleScene")
Voxel3D.glass(false)
Voxel3D.seams(false)
if battleTex.flash then
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
end
for _, card in ipairs(cards) do
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL)
end
if battleTex.flash then Voxel3D.flatten(nil) end
Voxel3D.seams(true)
Voxel3D.glass(true)
end
end
-- tall grass last, pulled camera-ward exactly as far as the characters -- tall grass last, pulled camera-ward exactly as far as the characters
-- were (same per-vertex shader bias, so grass never drifts either): -- were (same per-vertex shader bias, so grass never drifts either):
-- relative depth between a walker and the tuft row south of their feet -- relative depth between a walker and the tuft row south of their feet
@@ -1025,7 +1092,34 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end end
return Voxel3D.endScene() end -- drawScene
if not eyes then
if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then
return nil
end
drawScene()
return Voxel3D.endScene()
end
-- The VR frame: the same scene once per eye, each into its own named
-- canvas slot under its own placed camera. `adopt` hands the eye's
-- record to FirstPerson as the live rig, which is what turns the
-- billboards toward THIS eye in first person (cardBlend keys on rig
-- identity -- see FirstPerson) and leaves them leaning in the diorama,
-- where the blend is zero.
local out = {}
for i, eye in ipairs(eyes) do
Voxel3D.camera = eye.camera
if eye.adopt then FirstPerson.adoptVReye(eye.camera) end
if not Voxel3D.beginScene(eye.w, eye.h, cx, cy, vw, vh,
skyFor(state.map), eye.slot) then
return nil
end
drawScene()
out[i] = Voxel3D.endScene()
end
return out
end end
return VoxelScene return VoxelScene
+33 -1
View File
@@ -92,6 +92,7 @@ local Water = V.require("Water")
local AntiAlias = V.require("AntiAlias") local AntiAlias = V.require("AntiAlias")
local FirstPerson = V.require("FirstPerson") local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove") local FreeMove = V.require("FreeMove")
local VR = V.require("VR")
-- Forward declaration: the voxel pipeline's update hook (registered below) -- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives. -- calls this, and it is defined further down with the settings it drives.
@@ -200,6 +201,13 @@ mod.content.render_pipelines:register("voxel", {
-- them announces it. Ahead of the active() gate, so switching it -- them announces it. Ahead of the active() gate, so switching it
-- while voxel mode is OFF still invalidates what is cached. -- while voxel mode is OFF still invalidates what is cached.
voidFill.check() voidFill.check()
-- The whole VR frame -- session lifecycle, xrWaitFrame's pacing, both
-- eye renders, the layer submit -- rides this hook, because it is the
-- one tick that runs through menus, dialogs and battles, which is
-- what a headset needs the world (or at least the UI panel) to do.
-- Ahead of the active() gate: with the mode off, the headset still
-- shows the flat screen on the floating panel.
VR.update(dt)
if not Voxel.active() then return end if not Voxel.active() then return end
local Game = require("src.core.Game") local Game = require("src.core.Game")
local ow = Game and Game.overworld local ow = Game and Game.overworld
@@ -211,6 +219,19 @@ mod.content.render_pipelines:register("voxel", {
end, end,
drawWorld = function(ctx) drawWorld = function(ctx)
-- the palette closure, stashed for the VR frame: it renders from the
-- update hook, where no ctx exists to carry one
VR.paletteFor = ctx.paletteFor
-- With a headset running, the window's world pass becomes the MIRROR
-- -- the left eye, fitted to the window -- rather than a third full
-- render of the scene. Everything else about the frame (the UI the
-- engine composites over this) is unchanged, which is exactly what
-- the headset's floating panel photographs.
if VR.active() then
local sw, sh = sceneSize(ctx)
local m = VR.mirror(sw, sh)
if m then return m end
end
-- Terrain and characters are geometry; the field FX stay ordinary 2D -- Terrain and characters are geometry; the field FX stay ordinary 2D
-- draws composited on top, anchored through the same camera the 3D -- draws composited on top, anchored through the same camera the 3D
-- pass used (ctx.drawFx below). The scene renders at the window's -- pass used (ctx.drawFx below). The scene renders at the window's
@@ -247,6 +268,7 @@ mod.content.render_pipelines:register("voxel", {
OverworldBattle.invalidate() OverworldBattle.invalidate()
AntiAlias.invalidate() AntiAlias.invalidate()
ChunkMesher.invalidate() -- no map id = every cached mesh ChunkMesher.invalidate() -- no map id = every cached mesh
VR.invalidate() -- the mirror, and FBO ids of dead canvases
end, end,
}) })
@@ -405,6 +427,16 @@ local SETTINGS = {
.. "pixels in each direction and 4X twice, which makes this the most " .. "pixels in each direction and 4X twice, which makes this the most "
.. "expensive row in the mod.", .. "expensive row in the mod.",
full = true }, full = true },
-- `full` for the same reason as AA: not a knob on the look, a question
-- about the hardware on the desk.
{ VR.setting,
"PCVR through OpenXR (SteamVR, Oculus, WMR). The diorama becomes a "
.. "tabletop model your head moves around; the 1ST rung stands you "
.. "inside the world at life size, looking where the headset looks. "
.. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime "
.. "and the mod running from a real folder; without them the row stays "
.. "and the game stays flat, with the reason on the console.",
full = true },
} }
local schema = {} local schema = {}
@@ -930,7 +962,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
return DayNight.tod() return DayNight.tod()
end) end)
mod.exports.version = "1.5.0" mod.exports.version = "1.6.0"
-- exposed so a companion mod can pin its own tiles' shapes or read the -- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout -- camera without reaching into this mod's file layout
mod.exports.lib = V mod.exports.lib = V
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "DRAMATIC_SHAPE", "id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod", "name": "Dramatic Shape Voxel Mod",
"version": "1.5.0", "version": "1.6.0",
"api": 2, "api": 2,
"entry": "main.lua", "entry": "main.lua",
"profile": "content", "profile": "content",
+5
View File
@@ -26,6 +26,8 @@ return {
"WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march",
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map", "3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
"BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map", "BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
"VR options row (OFF / ON, off by default): PCVR through OpenXR on Windows -- the diorama as a head-tracked tabletop model presented at the rung's own angle and framing on the orbit rungs, life-size first person on 1ST, staged battles standing on the map in both, menus on a floating panel, the window as mirror; needs a runtime (SteamVR/Oculus/WMR) and the mod on a real folder",
"VR controllers (Touch/Index/WMR, rebindable in the runtime): left stick moves, A/B are A/B, either trigger is START, left stick click toggles first/third person; in the diorama the right stick zooms, right stick click cycles the angle, and a squeezed grip drags the table's height",
"a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday", "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday",
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp", "an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
"a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode", "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
@@ -44,10 +46,13 @@ return {
"1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge", "1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge",
"in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land", "in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land",
"rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it", "rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it",
"VR is Windows x64 only (the shipped loader and the Win32 GL binding), renders the scene once per eye (heavy with WATER FULL or AA up), has no XR controller bindings in v1 (pad/keyboard/mouse still work), and cannot start from a mounted .love archive -- the FFI needs the loader DLL on a real disk path",
"VR swapchains prefer plain RGBA8; a runtime that only offers sRGB shows slightly lifted colours",
}, },
}, },
credits = { credits = {
{ who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" }, { who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" },
{ who = "The Khronos Group", for_ = "the OpenXR loader shipped unmodified in assets/vr (Apache-2.0; full license text alongside the DLL)" },
}, },
compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 }, compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 },
} }
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>OpenXR.Loader</id>
<version>1.0.10.2</version>
<authors>Khronos Group</authors>
<owners>Khronos Group</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<license type="expression">Apache-2.0</license>
<licenseUrl>https://licenses.nuget.org/Apache-2.0</licenseUrl>
<projectUrl>https://github.com/KhronosGroup/OpenXR-SDK</projectUrl>
<description>Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application</description>
<tags>native khronos openxr loader headers</tags>
<dependencies>
<dependency id="OpenXR.Headers" version="1.0.10.2" />
</dependencies>
</metadata>
</package>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml" />
<Default Extension="psmdcp" ContentType="application/vnd.openxmlformats-package.core-properties+xml" />
<Default Extension="props" ContentType="application/octet" />
<Default Extension="targets" ContentType="application/octet" />
<Default Extension="dll" ContentType="application/octet" />
<Default Extension="lib" ContentType="application/octet" />
<Default Extension="nuspec" ContentType="application/octet" />
</Types>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Type="http://schemas.microsoft.com/packaging/2010/07/manifest" Target="/OpenXR.Loader.nuspec" Id="R0D169365D22F5E6F" />
<Relationship Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="/package/services/metadata/core-properties/948f85fa57f345119150f5525085c62c.psmdcp" Id="R10D7CDDCA5670667" />
</Relationships>
+5
View File
@@ -0,0 +1,5 @@
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<OpenXRLoaderPackageRoot>$(MSBuildThisFileDirectory)..\..\</OpenXRLoaderPackageRoot>
</PropertyGroup>
</Project>
+35
View File
@@ -0,0 +1,35 @@
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Choose>
<When Condition="'$(ApplicationType)|$(ApplicationTypeRevision)' == 'Windows Store|10.0'">
<PropertyGroup>
<OpenXRLoaderBinaryRoot>$(OpenXRLoaderPackageRoot)native\$(Platform)_uwp\release</OpenXRLoaderBinaryRoot>
</PropertyGroup>
</When>
<Otherwise>
<PropertyGroup>
<OpenXRLoaderBinaryRoot>$(OpenXRLoaderPackageRoot)native\$(Platform)\release</OpenXRLoaderBinaryRoot>
</PropertyGroup>
</Otherwise>
</Choose>
<ItemDefinitionGroup>
<Link>
<AdditionalDependencies>%(AdditionalDependencies);$(OpenXRLoaderBinaryRoot)\lib\openxr_loader.lib</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<!-- Copy the OpenXR loader DLL to the output directory and include in packaging -->
<ItemGroup Condition="'$(OpenXRSkipLoaderCopy)'!='true'">
<None Include="$(OpenXRLoaderBinaryRoot)\bin\openxr_loader.dll">
<Link>%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<DeploymentContent>true</DeploymentContent>
</None>
</ItemGroup>
<Target Name="EnsurePropsImported" BeforeTargets="PrepareForBuild">
<Error Condition="'$(OpenXRLoaderPackageRoot)'==''" Text="OpenXRLoaderPackageRoot property missing. Project is malformed. Try removing and re-adding the NuGet reference." />
</Target>
</Project>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<coreProperties xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://schemas.openxmlformats.org/package/2006/metadata/core-properties">
<dc:creator>Khronos Group</dc:creator>
<dc:description>Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application</dc:description>
<dc:identifier>OpenXR.Loader</dc:identifier>
<version>1.0.10.2</version>
<keywords>native khronos openxr loader headers</keywords>
<lastModifiedBy>NuGet, Version=5.4.0.3, Culture=neutral, PublicKeyToken=31bf3856ad364e35;Microsoft Windows NT 6.2.9200.0;.NET Framework 4.7.2</lastModifiedBy>
</coreProperties>
+144 -1
View File
@@ -150,6 +150,9 @@ T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
-- and AA, for the opposite reason: it is not a knob on the look at all, it is -- and AA, for the opposite reason: it is not a knob on the look at all, it is
-- what the look COSTS, and only the player knows what their machine can carry -- what the look COSTS, and only the player knows what their machine can carry
T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns") T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns")
-- VR survives FULL on AA's reasoning: whether a headset is on the desk is
-- not the diorama's to decide
T.check(fullIds["DRAMATIC_SHAPE:vr"], "and VR, likewise the hardware's question")
-- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot -- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot
-- be reached while FULL owns it, so a value changed underneath (the mod -- be reached while FULL owns it, so a value changed underneath (the mod
@@ -384,7 +387,7 @@ end
Pipelines.setLevel("voxel", 2) Pipelines.setLevel("voxel", 2)
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } }) { data = Data }, { { id = "text_speed" } })
T.eq(#hookedRows, 8, "the options hook added a row per setting") T.eq(#hookedRows, 9, "the options hook added a row per setting")
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4]
local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7]
-- the AA row is hookedRows[8]; it is read in its own block below, because -- the AA row is hookedRows[8]; it is read in its own block below, because
@@ -3643,6 +3646,146 @@ FirstPerson.blend = 0
VoxelState.reset() VoxelState.reset()
end end
-- ------- the VR rig's arithmetic
--
-- VRRig is the deliberately pure half of the VR stack: headset poses in,
-- placed cameras out, with no FFI anywhere -- so the suite can hold a
-- synthetic head still and check the world lands where the design says.
-- (The FFI half -- VRXR, VRGL -- is exercised by tests/vr_probe.lua
-- against a real runtime, which a headless suite cannot be.)
-- an immediately-run function rather than a bare do-block: the main chunk
-- is brushing LuaJIT's 200-active-locals ceiling, and a function scope
-- keeps this section's locals off the chunk's own count
local function vrRigSection()
local VRRig = run.loader.exports.DRAMATIC_SHAPE.lib.require("VRRig")
local Mat4 = run.loader.exports.DRAMATIC_SHAPE.lib.require("Mat4")
local function near(a, b, eps) return math.abs(a - b) < (eps or 1e-5) end
-- a Mat4 applied to a point, for reading results back out
local function apply(m, x, y, z)
return m[1] * x + m[2] * y + m[3] * z + m[4],
m[5] * x + m[6] * y + m[7] * z + m[8],
m[9] * x + m[10] * y + m[11] * z + m[12]
end
-- identity quaternion, head at the LOCAL origin: the eye lands ON the
-- pivot offset by the anchor, and the view carries a world point at the
-- pivot to `anchor` metres in eye space
local pose = { pos = { 0, 0, 0 }, quat = { 0, 0, 0, 1 } }
local fov = { angleLeft = -0.7, angleRight = 0.7,
angleUp = 0.6, angleDown = -0.6 }
local pivot = { 1000, 0, 2000 }
local S = 128 -- an arbitrary table scale; eyeCamera takes any
local cam = VRRig.eyeCamera(pose, fov, pivot, VRRig.TABLE, S)
T.check(near(cam.eye[1], 1000 - VRRig.TABLE[1] * S)
and near(cam.eye[2], -VRRig.TABLE[2] * S)
and near(cam.eye[3], 2000 - VRRig.TABLE[3] * S),
"the diorama eye stands the table's offset from the pivot, scaled")
T.eq(cam.curve, 0, "a VR camera declines the world curve outright")
T.check(near(cam.fov, 1.2), "fov is the vertical angular span")
-- the view ends in METRES: the pivot itself lands at `anchor` in eye
-- space, however big the scale is
local vx, vy, vz = apply(cam.view, pivot[1], pivot[2], pivot[3])
T.check(near(vx, VRRig.TABLE[1]) and near(vy, VRRig.TABLE[2])
and near(vz, VRRig.TABLE[3]),
"the view un-scales the world: the pivot sits at the table offset, "
.. "in metres")
-- a point one metre of world east of the pivot lands one metre east in
-- eye space -- the scale cancels end to end
local ex, ey, ez = apply(cam.view, pivot[1] + S, pivot[2], pivot[3])
T.check(near(ex - vx, 1) and near(ey, vy) and near(ez, vz),
"one scale's worth of world east is one metre east in eye space")
-- ------- the diorama presents at the rung's own angle and framing
--
-- The anchor sits VIEW_DIST along the rung's viewing angle: at 35 degrees
-- mostly below the head, at 75 mostly ahead of it -- so the resting head
-- looks at the model along exactly the line the flat camera uses.
local a35 = VRRig.dioramaAnchor(math.rad(35), 0)
T.check(near(a35[2], -VRRig.VIEW_DIST * math.cos(math.rad(35)))
and near(a35[3], -VRRig.VIEW_DIST * math.sin(math.rad(35))),
"the 35-degree anchor hangs the table down-and-ahead at the rung's angle")
local a75 = VRRig.dioramaAnchor(math.rad(75), 0)
T.check(a75[3] < a35[3] and a75[2] > a35[2],
"75 degrees brings the table up toward eye level and further out")
T.check(near(VRRig.dioramaAnchor(math.rad(35), 0.25)[2], a35[2] + 0.25),
"the grab-drag height rides the anchor straight up")
-- and the scale reproduces the flat screen's framing: vh world pixels
-- subtend the flat lens's field at VIEW_DIST
local sc = VRRig.dioramaScale(432, 1.0)
T.check(near(sc, 432 / VRRig.VIEW_DIST),
"the diorama scale is the flat framing carried to arm's length")
-- first person: anchor at the origin pins the head to the pivot exactly
local fpCam = VRRig.eyeCamera(pose, fov, { 108, 13, 208 }, { 0, 0, 0 },
VRRig.FP_SCALE)
T.check(near(fpCam.eye[1], 108) and near(fpCam.eye[2], 13)
and near(fpCam.eye[3], 208),
"first person pins the start-of-session head to the player's head")
-- head motion moves the eye by metres-times-scale
local moved = VRRig.eyeCamera({ pos = { 0.1, 0.2, -0.3 },
quat = { 0, 0, 0, 1 } },
fov, { 108, 13, 208 }, { 0, 0, 0 },
VRRig.FP_SCALE)
T.check(near(moved.eye[1], 109) and near(moved.eye[2], 15)
and near(moved.eye[3], 205),
"a headset step moves the eye by metres times the scale")
-- with no rotation, the eye looks along LOCAL -Z, which is world NORTH
T.check(fpCam.focus[3] < fpCam.eye[3] - 1,
"an identity orientation looks north, the LOCAL -Z convention")
-- headYawPitch round-trips this mod's conventions: identity looks north
-- (yaw pi), and a quarter turn about +Y (counterclockwise from above)
-- swings the forward from north to WEST (yaw -pi/2)
local yaw, pitch = VRRig.headYawPitch({ 0, 0, 0, 1 })
T.check(near(math.abs(yaw), math.pi) and near(pitch, 0),
"an identity head faces north, level")
local s = math.sin(math.pi / 4)
yaw = VRRig.headYawPitch({ 0, s, 0, math.cos(math.pi / 4) })
T.check(near(yaw, -math.pi / 2),
"a quarter turn about +Y faces west -- the compass agrees with the "
.. "right-hand rule")
-- the asymmetric projection: a point ON the left frustum plane lands at
-- clip x = -w, one on the up plane at clip y = +w
local proj = Mat4.fovProjection(-0.5, 0.3, 0.4, -0.2, 0.1, 100)
local px_, py_, pz_ = nil, nil, nil
local function clip(m, x, y, z)
local cx = m[1] * x + m[2] * y + m[3] * z + m[4]
local cy = m[5] * x + m[6] * y + m[7] * z + m[8]
local cw = m[13] * x + m[14] * y + m[15] * z + m[16]
return cx / cw, cy / cw
end
local lx = math.tan(-0.5) * 2 -- on the left plane, 2 units out
local cxL = clip(proj, lx, 0, -2)
T.check(near(cxL, -1, 1e-4), "the left fov angle lands on clip x = -1")
local uy = math.tan(0.4) * 2
local _, cyU = clip(proj, 0, uy, -2)
T.check(near(cyU, 1, 1e-4), "the up fov angle lands on clip y = +1")
-- Mat4.fromQuat: a quarter turn about Y takes +X to -Z
local R = Mat4.fromQuat(0, s, 0, math.cos(math.pi / 4))
local rx, ry, rz = apply(R, 1, 0, 0)
T.check(near(rx, 0) and near(ry, 0) and near(rz, -1),
"fromQuat: a quarter turn about +Y carries east into north")
-- and the VR row exists, shaped like every other mod setting
local VRMod = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR")
T.eq(VRMod.setting.key, "vr", "the VR row persists under its own key")
T.eq(VRMod.setting:get(), false, "and ships OFF")
T.eq(VRMod.status(), "off", "with the status agreeing")
T.check(not VRMod.active(), "no session without a runtime, and no crash")
end
vrRigSection()
Pipelines.reset() Pipelines.reset()
run.release() run.release()
+61
View File
@@ -0,0 +1,61 @@
-- Scratch driver: exercise the VR stack against the real machine -- the
-- FFI cdefs compile, the loader DLL loads, LOVE's GL context and canvas
-- framebuffers are discoverable, and the OpenXR instance either comes up
-- (headset present) or reports exactly why not (no runtime / no HMD).
-- With a headset connected and the runtime up, it holds a VR session open
-- for ten seconds of frames: diorama first, then the 1ST rung.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/vr_probe.lua \
-- lovec.exe .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[vr] DRAMATIC_SHAPE mod not loaded")
return love.event.quit()
end
local V = handle.lib
local VR = V.require("VR")
local VRGL = V.require("VRGL")
local Voxel = V.require("VoxelState")
U.teleport(game, "PALLET_TOWN", 10, 12, "down")
Pipelines.setLevel("voxel", 2)
U.wait(30)
-- the GL floor the whole thing stands on
local okGL = VRGL.load()
print("[vr] GL interop: " .. tostring(okGL) .. " -- " .. VRGL.status())
local hdc, hglrc = VRGL.contexts()
print("[vr] wgl contexts: " .. tostring(hdc) .. " / " .. tostring(hglrc))
local okC, c = pcall(love.graphics.newCanvas, 64, 64)
if okC then
print("[vr] canvas FBO discovery: " .. tostring(VRGL.canvasFBO(c)))
end
-- the OpenXR stack, driven by the same row the player would use
VR.setting:sync(true)
for _ = 1, 300 do
U.wait(1)
if VR.active() or VR.status():find("XR_ERROR") then break end
end
print("[vr] status: " .. VR.status())
print("[vr] active: " .. tostring(VR.active()))
if VR.active() then
print("[vr] holding a diorama session for ~5s of frames")
U.wait(450)
print("[vr] switching to 1ST for ~5s of frames")
Pipelines.setLevel("voxel", Voxel.FP_LEVEL)
U.wait(450)
print("[vr] still active: " .. tostring(VR.active())
.. " -- " .. VR.status())
end
VR.setting:sync(false)
U.wait(10)
print("[vr] after toggle off: " .. VR.status())
love.event.quit()
end