mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 08:01:09 +02:00
add vr
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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.
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OpenXRLoaderPackageRoot>$(MSBuildThisFileDirectory)..\..\</OpenXRLoaderPackageRoot>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user