diff --git a/CHANGELOG.md b/CHANGELOG.md index 3823961..85cffb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,223 @@ # Changelog +## 1.5.2 + +### Added + +- **The Pokédex in hand is a quarter larger.** Its voxel pitch went from + 1.1 cm to 1.375 cm (the body from about 10x15 cm to about 12x19 cm), + because the screen carries every menu in first person and was + squint-small at the old size. The attachment -- flush along the left + controller -- is unchanged. + +- **Left stick click steps the VOXEL ladder.** In VR the click now makes + exactly the step the "3" key (and the pad's SELECT) makes -- the same + function, handed across, so the ladder walk, the FULL step-over and + the TILT/GBC FX clearing can never drift from the key's. It used to + toggle first/third person against a remembered return rung. + +- **The floating panel shows the same picture at every window size.** + The GB-frame region used to be copied into the headset's panel + pixel-for-pixel, and the panel's swapchain image has a fixed size -- + so a window scaled past it (fullscreen above all) ran the frame off + the copy's edge and cut the START menu out of the panel. The region + is now blitted OUT of the window and SCALED into the swapchain image + at the frame's own aspect: identical picture, identical near-square + ratio, whatever size or shape the window takes. + +- **Menus stay inside the GB frame while a headset is live.** The + engine's new zoom-aware anchoring docks the START menu to the + WINDOW's edge -- and both VR screens (the floating panel and the + Pokédex) crop the window to the near-square GB frame, so a docked + menu was cropped away with the border it hugged. While a headset is + live the mod answers the engine's own "hold the anchors" predicate + with yes, and every menu blits where it was drawn: the START menu's + classic slot, flush with the frame's right edge, which is the right + edge of everything the headset shows. + +- **The sky's dither is glued to the sky.** The gradient's bands were + already read by true elevation, but the GBC checker between them kept + SCREEN-cell parity -- so a head's pitch or roll slid the world-fixed + band edges over a screen-fixed checkerboard and the whole gradient + shimmered as the pattern recomputed. The ray path is now a computed + SKYBOX: each pixel's ray lands in a cell of the sky's own angular + grid (azimuth columns, elevation rows, sized to match the diorama's + pixel grid on screen), and the band, the checker's parity and the + twilight glow -- now measured by the angle to the sun's own direction + -- are all answered from that cell's centre. The screen grid + quantises nothing, so the picture behaves exactly like a + nearest-filtered texture on a dome: its cells slide smoothly with the + world, no motion of the head recomputes the pattern, and only the + clock moves the sky. + +- **VR works from an installed release.** The OpenXR loader used to be + looked for only against the working directory and the game's source -- + right for the dev tree, wrong for a release install, where importing + the mod lands it in the game's SAVE DIRECTORY (or keeps it zipped) and + the VR row silently failed to start. The search now also asks the + mount that actually holds the mod and the save directory itself; and + if nothing on disk answers -- the mod imported as an archive -- the + DLL is copied once out of the mod into the save directory and loaded + from there, so every install shape reaches the headset. + +- **SELECT walks the VOXEL ladder.** In free roam, the pad's SELECT + button makes exactly the step hotkey 3 makes -- OFF through the angle + rungs to 1ST and round, stepping over FULL, clearing TILT and GBC FX + on every press like the key does. For the machines with no number row: + the phone's touch pad and a controller. SELECT has no overworld job in + Gen 1 -- its work is all in-menu, and menus keep it untouched. + +- **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** steps the VOXEL angle ladder exactly as the "3" + key does. In the diorama: **right stick up/down** zooms the + model, and **squeezing a grip** while moving that hand up or down + drags the whole table with it. No controller button leaves VR -- + both directions belong to the VR row alone, so no mid-fight click + can eject you from the headset. + + - **Battles happen ON the world -- from the flat game's own seat.** + When a staged fight starts, the headset fades to black and comes + back seated in the flat battle's over-the-shoulder shot: on the same + camera line (your mon near-left, the foe far-right), pulled in to + the wide rig's standing distance at life scale, turned to face the + arena -- and fades back to wherever you were when the fight ends. + Both mons stand on their arena cells in the VR eyes' own view, yawed + per eye, casting real shadows, wearing the hit flash -- and the MOVE + ANIMATIONS play out there with them: the engine's own effects layer, + caught on a canvas and stood on a billboard that faces the eye the + way the mon cards do (effects are 2D drawings, and a drawing must + face the eye that is looking), with the classic layout's two slot + marks pinned where each arena cell lands on that plane along the + eye's own ray -- so a burst authored at a slot sits exactly over the + mon standing in for it, per eye, and a projectile crossing the frame + crosses the arena. (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. + + - **A voxel POKEDEX along your left controller.** A hand-authored + voxel model of the series' own field guide -- red slab, lens, LEDs, + hinge, d-pad, dark screen bezel -- laid flush along the left + controller's grip pose (a full quarter turn forward, so holding the + controller is holding the device) through the same XR-to-world + mapping as the eyes, at its real hand size wherever the camera is. + It rides in FIRST PERSON and in the BATTLE seat; the diorama does + without it -- a hand-sized device hovering over a tabletop town is + clutter, and the floating panel serves there. In first person its + screen carries EVERYTHING the flat screen shows -- menus, dialogs, + shops, wipes -- and the floating billboard is retired outright: + raise your hand to read, lower it to play. In a staged fight the + screen is the 2D battle -- text, menus, HP bars, and any party or + bag screen opened over it -- and there too the floating billboard is + gone entirely: the fight owns the view, the reading is in the hand. + (No tracked left controller still gets the floating panel -- the UI + must be readable somewhere.) Drawn by the scene's own pass with real + depth, per eye; dark when nothing is showing. + + - **The floating panel wears the GB frame.** The quad used to show + the whole window -- monitor-wide, mostly mirror -- and now crops to + the 160x144 letterbox where everything the flat screen has to say + actually lives, so the panel presents near-square (10:9) at 1:1 + pixel aspect. To keep a battle's HUDs inside that frame, the HUD + blocks stay in their classic GB slots for as long as a headset is + live instead of snapping out to the window's edges. + + - **The sky is anchored in space.** On the flat screen the band + gradient hangs off the frame; inside a headset that meant the sky + (and the sun and moon with it) rode the player's head. The VR eyes + now hang the gradient over a fixed slice of elevation above the + world's real horizon and drop the fixed-slice fallback entirely, so + tilting your head slides the frame across a sky that stays put -- + and the discs, already projected through each eye's true camera, + stand still over their own azimuth. The gradient is a true SKYBOX: + each eye hands the sky shader its own ray fan (head rotation plus + frustum tangents), and every pixel takes its band -- and its GBC + checker dither -- from the TRUE elevation of its own view ray, so + no motion of the head, pitch, yaw or roll, in the diorama or in + first person, moves a band by a pixel; only the clock recolours + them. The FLAT screen's first person gets the very same treatment: + the placed rig builds its own ray fan from the basis its view is + made of, so mouse-look pitch slides the frame over a sky that + stays put there too, dither and all -- while the orbit rungs keep + their classic frame-hung painting. And the SUN AND MOON are no + longer painted on the frame at all (in first person on the flat + screen included): the cell art is baked to a texture once per + palette and hung on a quad IN THE WORLD, projected through the + camera like any geometry -- no per-frame cell snapping (the + jitter), no pattern squared to the canvas (the face that turned + with the head). + + - **The VR row exists only where VR can.** Off Windows -- the Android + build above all -- the row is absent from the OPTIONS menu and the + mod manager's page both (the loader and the GL interop are Win32), + and a stored vr=true that migrated over in a save is ignored instead + of read, so a phone never tries to start a session or force the + battle rows. + + - **VR owns the battle rows.** While the VR row is ON, staged battles + are REQUIRED (3D-BTL answers ON whatever it was set to) and BACK + SPRITES is held OFF -- the battle seat, the pokedex screen and the + effects plane all assume both mons standing on the world -- and both + rows leave the OPTIONS menu for the duration, because a switch that + decides nothing reads as broken. Both come back, at their stored + values, the moment VR goes off. + + - **First person snap-turns.** Flicking the right stick left or right + steps the view 45 degrees, once per flick (it re-arms at centre) -- + a snap rather than a smooth spin, because smooth software yaw is + the classic VR comfort mistake. The turn steps the XR-to-world + mapping itself, so the eyes, the walk direction and the pokedex in + your hand all agree about which way the world now faces. + + - **The cast holds one pose for the headset.** Sprite cards lean back + by the camera pitch on the flat screen; a head that roams the table + has no one pitch to match, so every VR frame leans the cards at the + top rung's near-upright 75 degrees instead -- whatever orbit rung + the ladder is on -- and the flat screen keeps leaning with the rung. + + - **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 + keyboard, mouse and pad input keeps working alongside the XR + controllers. + + - 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 ### Added diff --git a/README.md b/README.md index 2ec0b45..f20aa3d 100644 --- a/README.md +++ b/README.md @@ -3,55 +3,7 @@ A mod for the [Pokémon Gen 1 Recompilation Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project). -The overworld as a 3D diorama. Terrain is extruded into real geometry, -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. +The overworld as a voxelized 3D diorama. Also supports experimental first-person and VR. ## Controls @@ -60,7 +12,8 @@ menu. | control | does | | --- | --- | -| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → OFF (camera pitch) | +| `3`, or the **VOXEL** options row | OFF → 15 → 35 → 50 → 75 → 1ST → OFF (camera pitch) | +| `SELECT` (pad / touch) | the same step as `3` — for the machines with no number row | | `5`, or the **V-GRID** options row | OFF / ON — a one-pixel wireframe on every voxel | | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | @@ -70,18 +23,45 @@ menu. | 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 | -**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. +## VR -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. +The **VR** options row (OFF / ON, off by default) drives a PCVR headset +through OpenXR on Windows — SteamVR, Oculus or WMR. -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. \ No newline at end of file +### VR controls + +Suggested onto Touch, Index and WMR controllers (rebindable in the +runtime's own binding UI); pad, keyboard and mouse all keep working +alongside. + +| control | does | +| --- | --- | +| left stick | move — grid-walks the diorama, free-walks 1ST | +| A / B (X / Y on the left hand) | A / B | +| either trigger | START | +| left stick click | step the VOXEL angle ladder (same as the "3" key) | +| right stick up / down | *diorama only* — zoom the model | +| right stick left / right | *1ST only* — snap-turn 45° | +| grip squeeze + raise / lower that hand | *diorama only* — drag the table's height | +| head | *1ST and battles* — look; FreeMove walks where you look | +| left hand | *1ST and battles* — the Pokédex: menus, dialogs and the 2D battle screen on its screen | + +## Licenses + +This mod redistributes one third-party binary: + +- **`assets/vr/openxr_loader.dll`** — the Khronos OpenXR loader + (version 1.0.10.2, x64, unmodified), © The Khronos Group Inc., + licensed under the **Apache License 2.0**. The full license text ships + alongside the DLL at + [`assets/vr/LICENSE-openxr_loader.txt`](assets/vr/LICENSE-openxr_loader.txt), + as the license requires; keep the two files together if you + redistribute this mod. Source: + [KhronosGroup/OpenXR-SDK](https://github.com/KhronosGroup/OpenXR-SDK). + +Everything else in this mod is original to it, except that the voxel +geometry and shape profiles are derived from the tile and sprite data of +the original game, as documented by the +[pret/pokered](https://github.com/pret/pokered) disassembly. No ROM +data, artwork or audio is included; the mod reads the assets the host +game already has. \ No newline at end of file diff --git a/assets/vr/LICENSE-openxr_loader.txt b/assets/vr/LICENSE-openxr_loader.txt new file mode 100644 index 0000000..05e19ac --- /dev/null +++ b/assets/vr/LICENSE-openxr_loader.txt @@ -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 diff --git a/assets/vr/openxr_loader.dll b/assets/vr/openxr_loader.dll new file mode 100644 index 0000000..8b1726a Binary files /dev/null and b/assets/vr/openxr_loader.dll differ diff --git a/lib/BattleCam.lua b/lib/BattleCam.lua index 07bc78e..09448a5 100644 --- a/lib/BattleCam.lua +++ b/lib/BattleCam.lua @@ -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.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 function BattleCam.reset() @@ -160,12 +168,14 @@ function BattleCam.rig(arena, groundY) local R = BattleCam.rigFor(arena) 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) -- 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 - local k = 1 + BattleCam.PAN_DOLLY - * phase(BattleCam.t, BattleCam.DOLLY_PERIOD) + local k = BattleCam.still and 1 + or 1 + BattleCam.PAN_DOLLY + * phase(BattleCam.t, BattleCam.DOLLY_PERIOD) local dx = (R.side * c - R.back * s) * k local dz = (R.side * s + R.back * c) * k diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 3938f89..19adad0 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -210,6 +210,91 @@ end BattleScene.monCards = monCards +-- The MOVE-ANIMATION layer's place in the world: a BILLBOARD facing the +-- eye, for the GB-frame effects texture OverworldBattle.animTexture +-- renders (the engine's own drawAnimLayer, caught on a canvas). +-- +-- Effects are 2D drawings like the pics, and the pics' answer holds for +-- them too: a drawing must FACE the eye that is looking (the mon cards +-- yaw toward it per eye -- see monMatrix). So the frame stands on the +-- arena's midpoint, yawed at the eye like the cards are, and the classic +-- layout's two slot marks are pinned where each CELL lands on that plane +-- along this very eye's own ray -- so from the eye that is looking, a +-- burst authored at a slot sits exactly over the mon standing in for it, +-- and a projectile crossing the frame crosses the arena. The vertical +-- scale is the mon cards' own (FULL_W / FULL_PIC), so an effect is sized +-- like the pics it plays over. +-- +-- An eye standing (nearly) ON the arena's axis sees the two cells in +-- line and the pinning degenerates; the frame then falls back to the +-- fixed plane through both cells, which that eye views edge-on anyway. +-- +-- Reads Voxel3D.eye at CALL time, like the cards -- call it per eye. +-- Returns the model matrix for BattleBillboard's unit card (x -0.5..0.5, +-- y 0..1 up, v flipped), or nil where the anchors are degenerate. +function BattleScene.fxCard(arena, groundY, anchors) + local p, e = anchors.player, anchors.enemy + local dgb = e[1] - p[1] + if math.abs(dgb) < 1 then return nil end + local GW, GH = BattleScene.GB_W, BattleScene.GB_H + local Px, Py, Pz = arena.player[1], groundY, arena.player[2] + local Ex, Ey, Ez = arena.enemy[1], groundY, arena.enemy[2] + local s = BattleBillboard.FULL_W / BattleBillboard.FULL_PIC + local Mx, My, Mz = (Px + Ex) / 2, groundY, (Pz + Ez) / 2 + + local eye = Voxel3D.eye + local yaw = BattleBillboard.yawToward(Mx, Mz, eye) + local nx, nz = math.sin(yaw), math.cos(yaw) -- out of the frame, at the eye + local rx, rz = math.cos(yaw), -math.sin(yaw) -- the frame's own right + + -- where a world point sits ON the billboard, as (right, up) coordinates + -- about the midpoint: slid along the eye's ray onto the plane, so the + -- mark and the mon line up from exactly the seat that is looking + local function inPlane(qx_, qy_, qz_) + if eye then + local dqx, dqy, dqz = qx_ - eye[1], qy_ - eye[2], qz_ - eye[3] + local denom = dqx * nx + dqz * nz + if math.abs(denom) > 1e-6 then + local t = ((Mx - eye[1]) * nx + (Mz - eye[3]) * nz) / denom + qx_ = eye[1] + dqx * t + qy_ = eye[2] + dqy * t + qz_ = eye[3] + dqz * t + end + end + return (qx_ - Mx) * rx + (qz_ - Mz) * rz, qy_ - My + end + local pax, pay = inPlane(Px, Py, Pz) + local eax, eay = inPlane(Ex, Ey, Ez) + + if math.abs(eax - pax) < 4 then + -- edge-on: the fixed plane through both cells, world-axis mapping + local ux = (Ex - Px) / dgb + local uy = (Ey - Py - s * (p[2] - e[2])) / dgb + local uz = (Ez - Pz) / dgb + local cx = Px + ux * (0.5 * GW - p[1]) + local cy = Py + uy * (0.5 * GW - p[1]) + s * (p[2] - GH) + local cz = Pz + uz * (0.5 * GW - p[1]) + local nl = math.sqrt(ux * ux + uz * uz) + local fx, fz = 0, 1 + if nl > 1e-9 then fx, fz = uz / nl, -ux / nl end + return { ux * GW, 0, fx, cx, + uy * GW, s * GH, 0, cy, + uz * GW, 0, fz, cz, + 0, 0, 0, 1 } + end + + -- in-plane travel per GB pixel of frame x, solved so both marks land: + -- inPlane(gb) = (pax, pay) + U * (gbx - p.x) + (0, s) * (p.y - gby) + local ux = (eax - pax) / dgb + local uy = (eay - pay - s * (p[2] - e[2])) / dgb + local cxp = pax + ux * (0.5 * GW - p[1]) + local cyp = pay + uy * (0.5 * GW - p[1]) + s * (p[2] - GH) + return { rx * ux * GW, 0, nx, Mx + rx * cxp, + uy * GW, s * GH, 0, My + cyp, + rz * ux * GW, 0, nz, Mz + rz * cxp, + 0, 0, 0, 1 } +end + -- The sun has to see the mons too, or they stand on the ground without -- putting anything on it. They are the one thing in this scene that MOVES, -- so `token` -- a counter the caller bumps whenever a pic could have changed diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua index d1019b4..b997da0 100644 --- a/lib/FirstPerson.lua +++ b/lib/FirstPerson.lua @@ -164,6 +164,16 @@ function FirstPerson.cardBlend() return ease(FirstPerson.blend) 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: -- 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 diff --git a/lib/Mat4.lua b/lib/Mat4.lua index a08d0ca..fe0c72e 100644 --- a/lib/Mat4.lua +++ b/lib/Mat4.lua @@ -6,9 +6,11 @@ -- here -- translation in the fourth column, m[4]/m[8]/m[12]. -- -- Only what the renderer actually needs: a perspective projection (the --- camera), an orthographic one (the sun's shadow pass), a look-based view, --- and the translate/rotateY/scale a model matrix is built from. No general --- inverse, no quaternions. +-- camera), an orthographic one (the sun's shadow pass), an asymmetric one +-- (a headset's per-eye frustum), a look-based view, a quaternion rotation +-- (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 = {} @@ -62,6 +64,45 @@ function Mat4.rotateX(a) 0, 0, 0, 1 } 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]). function Mat4.perspective(fovY, aspect, near, far) local f = 1 / math.tan(fovY / 2) diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 78fdfff..50eb08c 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -68,7 +68,18 @@ OverworldBattle.setting = ModSetting.new(OverworldBattle.KEY, OverworldBattle.LABEL, { true, false }, { "ON", "OFF" }) +-- Whether the VR row is ON -- read lazily, because VR requires modules +-- that sit above this one. While it is, this mode stops being optional: +-- the headset's battle seat, the pokedex screen and the effects plane +-- all assume a fight standing on the world, and a white-field battle +-- inside a headset is exactly the flat screen VR exists to replace. +local function vrOn() + local ok, vr = pcall(V.require, "VR") + return ok and vr and vr.enabled and vr.enabled() or false +end + function OverworldBattle.enabled() + if vrOn() then return true end return OverworldBattle.setting:get() and true or false end @@ -98,9 +109,12 @@ OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY, -- Gated on 3D-BTL rather than read alone: with staged battles off there is no -- staged shot for a back pic to be pinned in FRONT of, and the engine's own --- battle screen already draws exactly this. +-- battle screen already draws exactly this. And held OFF under VR: the +-- headset stands both mons on the world -- a flat back pic pinned to the +-- 2D frame would keep your own mon off the arena the battle seat looks at. function OverworldBattle.backPinned() if not OverworldBattle.enabled() then return false end + if vrOn() then return false end return OverworldBattle.backSetting:get() and true or false end @@ -473,6 +487,23 @@ function OverworldBattle.update(dt) -- inside somebody else's frame means putting the frame back afterwards. local okTex, textures = pcall(OverworldBattle.textures, session.battle) 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 + -- and the move-animation layer, for the same eyes -- rendered only + -- while a headset is actually watching, because only the VR world + -- pass draws it (the flat screen has the animations in-frame already) + session.animTex = nil + local okVR, vrOn = pcall(function() + local vr = V.require("VR") + return vr.active and vr.active() or false + end) + if okVR and vrOn and session.battle then + local okA, anim = pcall(OverworldBattle.animTexture, session.battle) + if okA then session.animTex = anim end + end session.token = (session.token or 0) + 1 local ok, shot = pcall(BattleScene.render, session.state, session.arena, textures, session.token) @@ -531,6 +562,104 @@ function OverworldBattle.shot() return nil 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 + +-- The live session's BATTLE STATE, once the pushed battle has been met +-- (session.battle fills in from the stack in update). The VR quad reads +-- it to tell "the battle screen is on top" from "a menu is over the +-- battle" -- the UI-only panel is right for the first and wrong for the +-- second. nil with no session, a broken one, or a battle not yet pushed. +function OverworldBattle.battle() + if not (session and not session.broken) then return nil end + return session.battle +end + +-- The move-animation layer as a texture: the engine's own drawAnimLayer, +-- rendered UNSHIFTED (slot-authored coordinates) into a GB-sized +-- transparent canvas of its own. This is what stands the effects up in +-- the VR eyes' world -- see worldAnim below -- the same move the pics +-- made through sideTexture: let the engine draw what it always draws, +-- catch it on a canvas, stand the canvas in the scene. +local animLayer = nil +-- the engine's own drawAnimLayer, captured by install(). Declared HERE, +-- above the function that reads it: a local declared further down the +-- chunk would leave this function reading a global of the same name -- +-- nil forever, and the effects silently absent from the eyes (the bug +-- this comment is the tombstone of). +local innerAnim = nil + +function OverworldBattle.animTexture(battle) + if not (innerAnim and battle) then return nil end + if not (love.graphics and love.graphics.newCanvas) then return nil end + if not animLayer then + local ok, c = pcall(love.graphics.newCanvas, + BattleScene.GB_W, BattleScene.GB_H) + if not (ok and c) then return nil end + pcall(c.setFilter, c, "nearest", "nearest") + animLayer = c + end + local g = love.graphics + local prevCanvas = g.getCanvas() + local ok = pcall(function() + g.push("all") + g.origin() + g.setCanvas(animLayer) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + g.setColor(1, 1, 1, 1) + innerAnim(battle, false) + g.pop() + end) + if not ok then pcall(g.pop, g) end + if prevCanvas then pcall(g.setCanvas, g, prevCanvas) + else pcall(g.setCanvas, g) end + return ok and animLayer or nil +end + +-- The staged fight's effects, for the VR eyes: the animation layer plus +-- the plane to stand it on (BattleScene.fxCard -- anchored so a hit +-- authored at a slot lands on the mon standing in for that slot). nil +-- while nothing is staged or no layer was rendered this frame. +function OverworldBattle.worldAnim() + if not (session and session.arena and not session.broken) then return nil end + local tex = session.animTex + 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) + local model = BattleScene.fxCard(session.arena, groundY, + OverworldBattle.ANCHOR) + if not model then return nil end + return tex, model +end + +-- Where the staged fight STANDS -- the arena and its floor height -- for a +-- camera that wants to look at it rather than draw it (the VR battle +-- mount). Answered as soon as the stage exists, textures or not: the +-- camera should be seated behind the fade before the first pic lands. +-- nil whenever no fight is staged on the world. +function OverworldBattle.stage() + if not (session and session.arena and not session.broken) then return nil end + local host = (session.state and session.state.map) or nil + if not host then return nil end + return session.arena, BattleScene.groundY(host, session.arena) +end + function OverworldBattle.invalidate() BattleDOF.invalidate() BattleHud.invalidate() @@ -693,6 +822,8 @@ local texturing = nil local texCanvas = {} local innerPics = nil -- captured by install() local innerHUDs = nil -- likewise, for the snapped HUD layer +-- (innerAnim, their sibling, is declared up beside animTexture, which +-- sits earlier in the chunk than this group and must see the local) local function texCanvasFor(side) local c = texCanvas[side] @@ -971,7 +1102,7 @@ function OverworldBattle.install() -- give them. They ride the average, which is where the pair's centre went -- -- a few pixels at most, and it keeps a hit landing on the mon it is -- aimed at instead of drifting off it. - local innerAnim = BattleState.drawAnimLayer + innerAnim = BattleState.drawAnimLayer function BattleState:drawAnimLayer(colorized) local shot = self.dramaticShapeShot if not shot then return innerAnim(self, colorized) end @@ -1120,6 +1251,13 @@ function OverworldBattle.snapHUDs(battle, shot) if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then return false end + -- With a headset live the HUDs stay IN the GB frame -- the classic + -- slots, on the glass drawHudPanels lays for the unsnapped path. Both + -- of VR's battle screens (the floating panel and the pokedex's) crop + -- to the letterbox, and a block snapped out to the window's edge would + -- be cropped away with the window around it. + local okV, vr = pcall(V.require, "VR") + if okV and vr and vr.active and vr.active() then return false end local slide = (battle.introSlide or 0) * 4 local rects, bandX = OverworldBattle.snapRects(shot) local enemy, player = OverworldBattle.hudLive(battle, slide) diff --git a/lib/Pokedex.lua b/lib/Pokedex.lua new file mode 100644 index 0000000..242bf70 --- /dev/null +++ b/lib/Pokedex.lua @@ -0,0 +1,223 @@ +-- VR: the POKEDEX in the player's left hand -- a voxel model of the +-- series' own field guide, strapped to the tracked grip pose, whose +-- screen is a real texture the mod can put a picture on. +-- +-- Why it exists: a staged VR battle needs the 2D battle screen SOMEWHERE +-- -- the text, the menus, the HP bars are the game -- but a flat panel +-- floating square in front of the fight hides the fight. A trainer in +-- the world already has the right prop for "a handheld device with a +-- screen": look down at the Pokedex in your hand to read the battle, +-- look up to watch it happen on the map. +-- +-- THE MODEL is authored here in voxels, in METRES (VOX metres a voxel), +-- around its own centre, front face +Z -- a red slab with the lens, the +-- LEDs, the hinge and a d-pad, and a dark bezel the screen sits proud +-- of. It rides VRRig.propMatrix, the same XR-to-world mapping the eyes +-- use, so it sits exactly where the hand is and keeps its real size in +-- every mode: a hand-sized device over the diorama, the same hand-sized +-- device at life scale in first person and in battle. +-- +-- THE SCREEN is a separate one-quad mesh drawn with its own texture -- +-- whatever canvas the caller hands `Pokedex.screen` (the VR frame hands +-- it the front buffer during a battle, cropped by UV to the battle's own +-- letterbox). No texture leaves the screen dark: a device that is off. +-- +-- Everything here is passive state plus a draw call; VR.lua decides when +-- the frame exists (hand tracked, session live) and VoxelScene's eye +-- pass draws it after the world, so it composites with real depth +-- against everything else. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel3D = V.require("Voxel3D") +local VRRig = V.require("VRRig") + +local Pokedex = {} + +-- one voxel, in metres: a centimetre-ish grid gives the classic chunky +-- read at a device you can read a battle off (the body below comes out +-- about 12 x 19 x 3 cm -- a quarter up from the first, believable size, +-- because the screen carries every menu and was squint-small in hand) +Pokedex.VOX = 0.011 * 1.25 + +-- Where the device sits relative to the GRIP pose, in metres, and how it +-- is tipped. A full quarter turn forward lays the slab exactly along the +-- controller's own body -- verified in the headset -- so holding the +-- controller IS holding the device: raise your fist and the screen faces +-- you. These two are the whole of the attachment. +Pokedex.OFFSET = { 0, 0.04, -0.02 } +Pokedex.TILT = -math.pi / 2 -- radians about X: 90 degrees forward, + -- flush with the controller + +-- body proportions, in voxels +local W, H, D = 9, 14, 2 + +-- the palette the body's faces point their UVs at, one texel per colour +local COLORS = { + { 200, 40, 48 }, -- 1 body red + { 140, 24, 32 }, -- 2 hinge / shaded red + { 64, 132, 244 }, -- 3 the lens blue + { 208, 228, 255 }, -- 4 lens glint + { 232, 60, 48 }, -- 5 LED red + { 248, 216, 64 }, -- 6 LED yellow + { 72, 200, 96 }, -- 7 LED green + { 46, 46, 54 }, -- 8 bezel / d-pad dark + { 24, 24, 30 }, -- 9 the dark screen (the "off" state's face) +} + +local paletteTex = nil -- one texel per COLORS entry +local bodyMesh = nil +local screenMesh = nil +local screenKey = nil -- the UV rect screenMesh was built for + +local function palette() + if paletteTex then return paletteTex end + if not (love.image and love.image.newImageData + and love.graphics and love.graphics.newImage) then return nil end + local ok, data = pcall(love.image.newImageData, #COLORS, 1) + if not (ok and data) then return nil end + for i, c in ipairs(COLORS) do + pcall(data.setPixel, data, i - 1, 0, + c[1] / 255, c[2] / 255, c[3] / 255, 1) + end + local built, img = pcall(love.graphics.newImage, data) + if not built then return nil end + pcall(img.setFilter, img, "nearest", "nearest") + paletteTex = img + return img +end + +-- Append one solid box's six faces to `verts`/`indices`: position in +-- voxels (relative to the device centre), size in voxels, colour by +-- palette index. Faces carry the mod's own directional shade, so the +-- slab reads as a solid the way every extruded block here does. +local function box(verts, indices, x, y, z, w, h, d, color) + local u = (color - 0.5) / #COLORS + local vox = Pokedex.VOX + local ox, oy, oz = (x - W / 2) * vox, (y - H / 2) * vox, (z - D / 2) * vox + local sx, sy, sz = w * vox, h * vox, d * vox + for face = 1, 6 do + local corners = Voxel3D.FACE_CORNERS[face] + local shade = Voxel3D.FACE_SHADE[face] + local n = #verts / 4 + for _, c in ipairs(corners) do + verts[#verts + 1] = { ox + c[1] * sx, oy + c[2] * sy, oz + c[3] * sz, + u, 0.5, shade } + end + Voxel3D.pushQuad(indices, n) + end +end + +-- the screen's face on the front, in voxels (10:9, the GB frame's shape), +-- shared by the dark "off" face in the body and the live quad +local SCREEN = { x = 1.2, y = 4.6, w = 6.6, h = 5.94 } + +local function buildBody() + if bodyMesh then return bodyMesh end + local verts, indices = {}, {} + -- the slab, the hinge along the right edge, the lens, the LEDs, the + -- d-pad and two chunky buttons -- the classic cover furniture, one box + -- each on the front face (z = D..) + box(verts, indices, 0, 0, 0, W, H, D, 1) -- body + box(verts, indices, W - 0.7, 0, 0, 0.7, H, D + 0.15, 2) -- hinge + box(verts, indices, 0.6, H - 2.6, D, 2, 2, 0.5, 3) -- lens + box(verts, indices, 0.9, H - 1.3, D + 0.5, 0.6, 0.5, 0.12, 4) -- glint + box(verts, indices, 3.2, H - 1.6, D, 0.8, 0.8, 0.35, 5) -- LEDs + box(verts, indices, 4.5, H - 1.6, D, 0.8, 0.8, 0.35, 6) + box(verts, indices, 5.8, H - 1.6, D, 0.8, 0.8, 0.35, 7) + -- the bezel plate the screen sits in, and the dark screen face itself + -- (what shows when nothing is on: a device that is off, not a hole) + box(verts, indices, SCREEN.x - 0.4, SCREEN.y - 0.4, D, + SCREEN.w + 0.8, SCREEN.h + 0.8, 0.4, 8) + box(verts, indices, SCREEN.x, SCREEN.y, D + 0.4, + SCREEN.w, SCREEN.h, 0.1, 9) + -- d-pad below the screen, two crossed bars, and the A/B buttons + box(verts, indices, 5.6, 1.1, D, 2.1, 0.7, 0.45, 8) + box(verts, indices, 6.3, 0.4, D, 0.7, 2.1, 0.45, 8) + box(verts, indices, 1.2, 0.8, D, 1.1, 1.1, 0.45, 5) + box(verts, indices, 2.9, 0.8, D, 1.1, 1.1, 0.45, 8) + bodyMesh = Voxel3D.newMesh(verts, indices) + return bodyMesh +end + +-- The live screen: one quad a hair proud of the dark face, UV-mapped to +-- `uv` = { u0, v0, u1, v1 } of whatever texture is on it. Rebuilt only +-- when the UV rect moves (a window resize moving the battle letterbox). +local function buildScreen(uv) + local key = table.concat({ uv[1], uv[2], uv[3], uv[4] }, ":") + if screenMesh and screenKey == key then return screenMesh end + local vox = Pokedex.VOX + local x0 = (SCREEN.x - W / 2) * vox + local y0 = (SCREEN.y - H / 2) * vox + local x1 = x0 + SCREEN.w * vox + local y1 = y0 + SCREEN.h * vox + local z = (D / 2 + 0.55) * vox + local u0, v0, u1, v1 = uv[1], uv[2], uv[3], uv[4] + local verts = { + { x0, y0, z, u0, v1, 1 }, { x1, y0, z, u1, v1, 1 }, + { x1, y1, z, u1, v0, 1 }, { x0, y1, z, u0, v0, 1 }, + } + local indices = {} + Voxel3D.pushQuad(indices, 0) + local mesh = Voxel3D.newMesh(verts, indices) + if mesh then + screenMesh, screenKey = mesh, key + end + return mesh +end + +-- ------- the frame's state, set by VR.lua +-- +-- nil = no pokedex this frame (no session, no tracked left hand). +Pokedex.frame = nil + +-- Stand the device on a tracked LEFT-HAND pose under the current +-- XR-to-world mapping (the same pivot/anchor/scale/yaw the eyes got). +function Pokedex.place(pose, pivot, anchor, scale, yaw) + local m = VRRig.propMatrix(pose, pivot, anchor, scale, yaw) + m = Mat4.mul(m, Mat4.translate(Pokedex.OFFSET[1], Pokedex.OFFSET[2], + Pokedex.OFFSET[3])) + m = Mat4.mul(m, Mat4.rotateX(Pokedex.TILT)) + Pokedex.frame = { model = m } +end + +-- What the screen shows: a texture and the UV rect of it to fill the +-- screen with. nil for a dark screen. Only meaningful after place(). +function Pokedex.screen(tex, u0, v0, u1, v1) + if Pokedex.frame and tex then + Pokedex.frame.tex = tex + Pokedex.frame.uv = { u0 or 0, v0 or 0, u1 or 1, v1 or 1 } + end +end + +function Pokedex.clear() + Pokedex.frame = nil +end + +-- Draw the device with the scene's own pass (model matrix in world px). +-- Runs inside VoxelScene's drawScene, per eye; no shadow-caster half -- +-- a UI prop should receive the world's light, not throw shade on it. +function Pokedex.draw() + local f = Pokedex.frame + if not f then return end + local body = buildBody() + local pal = palette() + if body and pal then + Voxel3D.draw(body, pal, f.model) + end + if f.tex and f.uv then + local screen = buildScreen(f.uv) + if screen then + Voxel3D.draw(screen, f.tex, f.model) + end + end +end + +-- window resize / hot reload: every GPU object here is derived and cheap +function Pokedex.invalidate() + paletteTex, bodyMesh, screenMesh, screenKey = nil, nil, nil, nil +end + +return Pokedex diff --git a/lib/Sky.lua b/lib/Sky.lua index 79431a4..54d5d63 100644 --- a/lib/Sky.lua +++ b/lib/Sky.lua @@ -78,6 +78,19 @@ Sky.DITHER_START = 0.6 -- ladder instead of changing character rung by rung. Sky.SPAN = 0.23 +-- How much ELEVATION the gradient spans above the horizon, in radians, for +-- a caller that anchors the sky IN SPACE rather than to the frame (the VR +-- eyes -- see Voxel3D.beginScene). On the flat screen the bands run from +-- the top edge of the frame down to the horizon, which is right for a +-- camera whose pitch is the rung's: the frame IS the window on the sky. +-- A headset's frame is wherever the head points, so glueing the zenith +-- band to its top edge drags the whole gradient around with the head. An +-- anchored caller instead hangs the gradient over a fixed slice of sky -- +-- horizon to ELEV_SPAN up -- and hands paint() the canvas row that span's +-- top lands on this frame (the `top` argument), so tilting the head slides +-- the frame across a sky that stays put. +Sky.ELEV_SPAN = math.rad(55) + -- ------- the bands -- -- Top first, each a { r, g, b } in 0..1, as the display mode has them. @@ -168,12 +181,34 @@ local SHADER_SRC = [[ uniform Image ramp; // the bands, one texel each, top of the sky first uniform float count; // how many texels wide that ramp is uniform float edge; // the sky's bottom, in canvas pixels +uniform float top; // where the deepest band begins, in canvas pixels -- + // 0 glues the gradient to the frame (the flat + // screen); an anchored caller passes the row its + // fixed elevation span starts on, often negative uniform float cell; // the diorama's pixel size, in canvas pixels uniform float start; // where the checker begins inside a band +uniform float axisX; // the "toward the ground" direction on the canvas: +uniform float axisY; // (0,1) for a level camera; a rolled VR eye tips + // it, and edge/top are distances along it +uniform vec3 rayBase; // the eye's ray fan (VRRig eyeCamera.skyRay): a +uniform vec3 rayDu; // canvas point at fractions (u, v) looks along +uniform vec3 rayDv; // base + u*du + v*dv, world axes -- so each pixel + // knows its TRUE elevation and the gradient is a + // real skybox, untouched by any head motion +uniform float raySpan; // radians of elevation the gradient covers +uniform vec2 invSize; // 1/w, 1/h: canvas pixels to fractions +uniform float useRay; // 0 = the flat screen's frame-linear gradient +uniform float cellAng; // one checker cell in RADIANS (ray path): the + // dither's own grid, laid on azimuth/elevation so + // the pattern is glued to the SKY -- a screen-cell + // parity flips under every head motion and the + // whole gradient shimmers uniform float alpha; uniform float glowAmt; // twilight warmth around the low sun; 0 = none -uniform vec2 glowPos; // the sun disc, in canvas pixels -uniform float glowInvR; // 1 / the glow's reach +uniform vec2 glowPos; // the sun disc, in canvas pixels (flat path) +uniform float glowInvR; // 1 / the glow's reach in pixels (flat path) +uniform vec3 glowDir; // the sun's world direction (ray path) +uniform float glowInvA; // 1 / the glow's reach in radians (ray path) uniform vec3 glowColor; // Band `i`, read from its own texel centre. The index is clamped rather than @@ -186,22 +221,62 @@ vec3 bandAt(float i) { } vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { - float row = floor(sc.y / cell) * cell; // top of this cell row - float pos = min(row / max(edge, 1.0), 1.0) * count; + float tn; + float parity; + float glowD = 2.0; // past the reach + if (useRay > 0.5) { + // A SKYBOX, computed instead of stored: the pixel's own ray lands in + // a cell of the sky's angular grid (azimuth columns and elevation + // rows, cellAng square), and EVERYTHING -- the band, the checker's + // parity, the glow -- is answered from that cell's centre. The + // screen grid quantises nothing here; that is the point. A screen + // quantisation of similar pitch laid under the sky grid beats + // against it (moire), and every subpixel head motion re-snaps the + // beat -- the fizz. Sampled per pixel, the picture is exactly a + // nearest-filtered texture on a dome: its cells slide smoothly with + // the world and no motion of the head recomputes the pattern. The + // one seam, where azimuth wraps behind the camera, is a single cell + // column of a dither pattern. + vec3 dir = rayBase + rayDu * (sc.x * invSize.x) + + rayDv * (sc.y * invSize.y); + float elev = atan(dir.y, length(dir.xz)); + float ei = floor(elev / cellAng); // elevation row + if (ei < 0.0) { discard; } // below the horizon + float ai = floor(atan(dir.x, dir.z) / cellAng); // azimuth column + float elc = (ei + 0.5) * cellAng; // the row's centre + tn = 1.0 - clamp(elc / max(raySpan, 0.001), 0.0, 1.0); + parity = mod(ai + ei, 2.0); + if (glowAmt > 0.0) { + // the glow by the angle between the CELL's centre direction and + // the sun's own, so its rings are pinned to the same sky grid + float azc = (ai + 0.5) * cellAng; + vec3 cd = vec3(cos(elc) * sin(azc), sin(elc), cos(elc) * cos(azc)); + glowD = acos(clamp(dot(cd, glowDir), -1.0, 1.0)) * glowInvA; + } + } else { + vec2 cc0 = floor(sc / cell) * cell; // top of this cell + float row = cc0.x * axisX + cc0.y * axisY; // along the axis + if (row > edge) { discard; } // below the horizon + tn = clamp((row - top) / max(edge - top, 1.0), 0.0, 1.0); + parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0); + if (glowAmt > 0.0) { + vec2 cc = (floor(sc / cell) + 0.5) * cell; + glowD = length(cc - glowPos) * glowInvR; + } + } + float pos = tn * count; float base = min(floor(pos), count - 1.0); vec3 c = bandAt(base); - float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0); if (base < count - 1.0 && (pos - base) > start) { if (parity < 0.5) { c = bandAt(base + 1.0); } } // The sunset's warmth, radiating from the disc: posterised to a few rungs // and checker-dithered between them -- the same 8-bit move as the bands, // so the glow reads as painted light rather than as a smooth airbrush -- - // and measured cell-to-cell, so its rings ride the diorama's own grid. + // measured cell-to-cell on the flat frame and angle-to-angle on the + // skybox, so its rings ride whichever grid the checker itself is on. if (glowAmt > 0.0) { - vec2 cc = (floor(sc / cell) + 0.5) * cell; - float d = length(cc - glowPos) * glowInvR; - float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0); + float g = glowAmt * pow(clamp(1.0 - glowD, 0.0, 1.0), 2.0); float lvl = floor(g * 4.0); if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; } c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65); @@ -310,13 +385,14 @@ Sky._getShader = getShader -- named for the suite -- The flat fallback: the same bands as solid rectangles, no checker, on the same -- quantised edges. For a driver that could not compile the shader -- which is -- also every headless run. -local function paintFlat(w, h, bands, edge, alpha, cell) +local function paintFlat(w, h, bands, edge, alpha, cell, top) local g = love.graphics local n = #bands + local span = edge - (top or 0) local prev = 0 for i = 1, n do local cut = (i == n) and math.min(h, math.ceil(edge)) - or math.floor(i / n * edge / cell + 0.5) * cell + or math.floor(((top or 0) + i / n * span) / cell + 0.5) * cell cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge)))) if cut > prev then local c = bands[i] @@ -383,6 +459,36 @@ function Sky.discRadius(h, cell, body) return r * cell, r end +-- One disc's worth of cell art -- shared verbatim by the screen-space +-- painter below (the flat screen) and by the BAKE the VR eyes texture +-- their world-anchored quad with (Sky.discImage). `plot(dx, dy, c)` gets +-- every kept cell in disc-local cell coordinates and its 0..255 colour. +local function discCells(r, moon, shades, twilight, plot) + local core = shades[1] + local main = shades[twilight and 3 or 2] + local craterR = math.max(1, math.floor(r / 5)) + for dy = -r, r do + for dx = -r, r do + local d = math.sqrt(dx * dx + dy * dy) + if d <= r + 0.1 then + local c = d <= r * 0.5 and core or main + -- dithered rim: the outer ring keeps only one parity of its cells + local keep = d <= r - 0.9 or (dx + dy) % 2 == 0 + if moon then + for _, cr in ipairs(MOON_CRATERS) do + local cdx = dx - math.floor(cr[1] * r + 0.5) + local cdy = dy - math.floor(cr[2] * r + 0.5) + if cdx * cdx + cdy * cdy <= craterR * craterR then + c = shades[3] + end + end + end + if keep then plot(dx, dy, c) end + end + end + end +end + local function paintDisc(body, edge, cell, w, h) local g = love.graphics if not (body and body.y and g.setScissor) then return end @@ -393,39 +499,70 @@ local function paintDisc(body, edge, cell, w, h) local bx = math.floor(body.x / cell) * cell + cell / 2 local by = math.floor(body.y / cell) * cell + cell / 2 if by - r * cell > edge then return end -- wholly below the horizon point - local core = shades[1] - local main = shades[twilight and 3 or 2] local sx, sy, sw, sh = g.getScissor() g.setScissor(0, 0, math.ceil(w), math.floor(edge)) - local craterR = math.max(1, math.floor(r / 5)) - for dy = -r, r do - for dx = -r, r do - local d = math.sqrt(dx * dx + dy * dy) - if d <= r + 0.1 then - local c = d <= r * 0.5 and core or main - -- dithered rim: the outer ring keeps only one parity of its cells - local keep = d <= r - 0.9 or (dx + dy) % 2 == 0 - if body.moon then - for _, cr in ipairs(MOON_CRATERS) do - local cdx = dx - math.floor(cr[1] * r + 0.5) - local cdy = dy - math.floor(cr[2] * r + 0.5) - if cdx * cdx + cdy * cdy <= craterR * craterR then - c = shades[3] - end - end - end - if keep then - g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) - g.rectangle("fill", bx + dx * cell - cell / 2, - by + dy * cell - cell / 2, cell, cell) - end - end - end - end + discCells(r, body.moon, shades, twilight, function(dx, dy, c) + g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) + g.rectangle("fill", bx + dx * cell - cell / 2, + by + dy * cell - cell / 2, cell, cell) + end) if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end g.setColor(1, 1, 1, 1) end +-- ------- the disc as a TEXTURE, for the VR eyes +-- +-- A VR eye must not paint the disc in screen space at all: a canvas-grid +-- painting re-snaps to different cells every head movement (jitter) and +-- holds its pattern square to the CANVAS (a rolled or pitched head +-- watches the sun's face turn). So the same cell art is baked once into +-- a texture, and Voxel3D hangs it on a quad ANCHORED IN THE WORLD -- +-- projected through the eye's own matrix like any geometry, stable under +-- every head motion. Rebaked only when the palette or the twilight state +-- moves the colours. +local discBake = { key = nil, img = nil } + +Sky.DISC_BAKE_R = 9 -- bake radius, in cells +Sky.DISC_BAKE_PX = 8 -- texture pixels per cell + +function Sky.discImage(moon, twilight) + if not (love.graphics and love.graphics.newCanvas) then return nil end + local shades = Sky.discShades(moon) + local key = (moon and "m" or "s") .. (twilight and "t" or "-") + for i = 1, math.min(3, #shades) do + local c = shades[i] + key = key .. ":" .. c[1] .. "," .. c[2] .. "," .. c[3] + end + if discBake.key == key and discBake.img then return discBake.img end + local r, px = Sky.DISC_BAKE_R, Sky.DISC_BAKE_PX + local size = (2 * r + 1) * px + local ok, canvas = pcall(love.graphics.newCanvas, size, size) + if not (ok and canvas) then return nil end + pcall(canvas.setFilter, canvas, "nearest", "nearest") + local g = love.graphics + local done = pcall(function() + g.push("all") + g.origin() + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + discCells(r, moon, shades, twilight, function(dx, dy, c) + g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) + g.rectangle("fill", (dx + r) * px, (dy + r) * px, px, px) + end) + g.pop() + end) + if not done then return nil end + discBake.key, discBake.img = key, canvas + return canvas +end + +-- Whether this body is the looming low sun, for callers sizing the baked +-- disc (the same exaggeration paintDisc applies through discRadius). +function Sky.discLooming(glowAmt, moon) + return (glowAmt or 0) > 0.25 and not moon +end + -- Paint the sky into the bound canvas, filling it from the top edge down to -- `horizonY` (or to SPAN of the frame when the horizon is out of it). -- @@ -436,16 +573,47 @@ end -- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding -- along; nil hangs nothing and warms nothing. -- +-- `top` anchors the gradient in space rather than to the frame: the canvas +-- row band 1 starts on (often negative -- above the frame), from a caller +-- that mapped a fixed elevation span to its own camera (see ELEV_SPAN). +-- nil or 0 is the flat screen's behaviour: zenith band at the top edge. +-- +-- `axis` tips the whole painting to a rolled camera's true horizon: a unit +-- {ax, ay} pointing "toward the ground" on the canvas (Voxel3D.horizonLine), +-- with `horizonY` and `top` then read as distances ALONG it rather than as +-- rows. nil is the level default. Only the shader path can tilt; the flat +-- fallback paints level, which only a headless run ever sees. Under an +-- axis the DISC is not painted here at all -- the VR caller hangs the +-- baked disc (Sky.discImage) in the world instead; `body` still carries +-- the twilight glow into the bands. +-- +-- `ray` makes the gradient a SKYBOX: the eye's own ray fan (the camera +-- record's skyRay, from VRRig.eyeCamera), letting every pixel take its +-- band from its TRUE elevation -- so no motion of the head, on any axis, +-- moves a band; only the clock does. nil keeps the linear frame gradient +-- the flat screen has always painted. +-- -- Returns false when there is nothing to paint, in which case the caller's flat -- fill is the whole sky. That fill is the palest band, so a frame that declines -- this looks like a hazy day rather than like a bug. -function Sky.paint(w, h, sky, horizonY, cell, body) +function Sky.paint(w, h, sky, horizonY, cell, body, top, axis, ray) local bands = sky and sky.bands if not (bands and bands[1]) then return false end if not (w and h and w > 0 and h > 0) then return false end local g = love.graphics if not (g and g.rectangle) then return false end - local edge = Sky.region(h, horizonY) + -- with a ray fan the shader's own per-pixel elevation test is the only + -- boundary and the whole frame goes through it; along an axis the + -- caller's edge is already the signed distance and has no row to be + -- clamped to; level callers keep the SPAN fallback + local edge + if ray then + edge = h + elseif axis then + edge = horizonY + else + edge = Sky.region(h, horizonY) + end if not edge then return false end local alpha = sky[4] or 1 cell = math.max(1, math.floor((cell or 1) + 0.5)) @@ -464,6 +632,26 @@ function Sky.paint(w, h, sky, horizonY, cell, body) if g.setBlendMode then g.setBlendMode("alpha") end local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0 + -- the skybox glow needs the sun's world DIRECTION (skyBody carries it); + -- a body without one has nothing to measure angles against, so no glow + if ray and glowAmt > 0 and not (body and body.dx) then glowAmt = 0 end + -- the world direction a canvas fraction (u, v) looks along, normalised + -- -- for sizing the angular checker and the glow's angular reach below + local function rayDirAt(u, v) + local b, du, dv = ray.base, ray.du, ray.dv + local x = b[1] + du[1] * u + dv[1] * v + local y = b[2] + du[2] * u + dv[2] * v + local z = b[3] + du[3] * u + dv[3] * v + local l = math.sqrt(x * x + y * y + z * z) + if l < 1e-9 then return 0, 0, -1 end + return x / l, y / l, z / l + end + local function rayAngle(u0, v0, u1, v1) + local ax, ay, az = rayDirAt(u0, v0) + local bx, by, bz = rayDirAt(u1, v1) + local d = ax * bx + ay * by + az * bz + return math.acos(math.max(-1, math.min(1, d))) + end local sh = getShader() local ramp = sh and rampFor(bands) if not ramp then sh = nil end -- no ramp, no gradient: paint it flat @@ -474,30 +662,67 @@ function Sky.paint(w, h, sky, horizonY, cell, body) sh:send("ramp", ramp) sh:send("count", #bands) sh:send("edge", edge) + sh:send("top", math.min(top or 0, edge - 1)) + sh:send("axisX", axis and axis[1] or 0) + sh:send("axisY", axis and axis[2] or 1) + sh:send("useRay", ray and 1 or 0) + if ray then + sh:send("rayBase", ray.base) + sh:send("rayDu", ray.du) + sh:send("rayDv", ray.dv) + sh:send("raySpan", Sky.ELEV_SPAN) + sh:send("invSize", { 1 / w, 1 / h }) + -- the angular checker's cell: the angle one dither cell spans at + -- the frame's centre, so the sky-glued grid comes out the same + -- size on screen as the diorama's own pixel grid + sh:send("cellAng", + math.max(1e-4, rayAngle(0.5, 0, 0.5, 1) * cell / h)) + end sh:send("cell", cell) sh:send("start", Sky.DITHER and Sky.DITHER_START or 2) sh:send("alpha", alpha) sh:send("glowAmt", glowAmt) if glowAmt > 0 then local gc = body.glowColor or { 248, 224, 168 } - sh:send("glowPos", { body.x, body.y }) - sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH)) + if ray then + -- the glow in ANGLES: its direction is the sun's own, and its + -- reach is the same fraction of the view the pixel reach was + -- of the frame, so the two paths agree on how wide it looks + local dx, dy, dz = body.dx, body.dy, body.dz + local l = math.sqrt(dx * dx + dy * dy + dz * dz) + sh:send("glowDir", { dx / l, dy / l, dz / l }) + sh:send("glowInvA", 1 / math.max( + 1e-3, rayAngle(0, 0.5, 1, 0.5) * Sky.GLOW_REACH)) + else + sh:send("glowPos", { body.x, body.y }) + sh:send("glowInvR", 1 / math.max(1, w * Sky.GLOW_REACH)) + end sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 }) end end) if sent then g.setShader(sh) g.setColor(1, 1, 1, 1) - g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge))) + -- tilted or rayed, the sky's reach is not a row: the full frame + -- goes through the shader and the discard is the boundary + local rectH = (axis or ray) and h or math.min(h, math.ceil(edge)) + g.rectangle("fill", 0, 0, w, rectH) g.setShader() else sh = nil end end - if not sh then paintFlat(w, h, bands, edge, alpha, cell) end + if not sh then + paintFlat(w, h, bands, (axis or ray) and math.min(h, edge) or edge, + alpha, cell, math.min(top or 0, edge - 1)) + end -- the disc goes over the glow, under nothing: plain rectangles, so it is - -- there whether or not the shader built - paintDisc(body, math.min(h, edge), cell, w, h) + -- there whether or not the shader built. NOT under an axis or a ray fan: + -- those cameras hang the baked disc in the world instead (drawWorldDisc, + -- with Sky.discImage) + if not (axis or ray) then + paintDisc(body, math.min(h, edge), cell, w, h) + end g.setColor(1, 1, 1, 1) if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end @@ -513,6 +738,10 @@ function Sky.invalidate() shader = nil if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end cache.ramp, cache.rampFor = nil, nil + if discBake.img and discBake.img.release then + pcall(discBake.img.release, discBake.img) + end + discBake.key, discBake.img = nil, nil end return Sky diff --git a/lib/VR.lua b/lib/VR.lua new file mode 100644 index 0000000..e00e149 --- /dev/null +++ b/lib/VR.lua @@ -0,0 +1,681 @@ +-- VR: the conductor -- one call per game frame that runs the whole +-- headset side, and the row that switches it on. +-- +-- The shape of a VR frame, from the pipeline's update hook (which ticks +-- every frame whatever is on the stack, which is exactly what a headset +-- needs -- the world must keep arriving through menus, dialogs and +-- battles): +-- +-- poll the runtime's events (begin the session when it says READY) +-- xrWaitFrame <- BLOCKS until the headset wants a frame; +-- with vsync handed off (set to 0 while the +-- session runs) this is what paces the whole +-- app at headset rate, while FixedStep keeps +-- the game's own logic at its 60 Hz +-- locate the two eyes +-- render the world once per eye (VoxelScene.render's `eyes` path: +-- shared shadow map, shared pose capture, per-eye cameras from VRRig) +-- blit each eye canvas into its swapchain image (VRGL) +-- copy the window's front buffer into the UI quad when a menu, dialog, +-- battle or wipe is what the flat screen is showing +-- xrEndFrame with the projection layer and/or the quad +-- +-- WHICH VR YOU GET mirrors the VOXEL ladder, deliberately: on the orbit +-- rungs the world is a TABLETOP DIORAMA pinned below and ahead of where +-- your head started -- lean in, walk around it; on 1ST you stand inside +-- at life scale, the HMD steers FirstPerson's yaw and pitch, and FreeMove +-- walks where you look exactly as it does on the flat screen. A STAGED +-- FIGHT takes the camera from both: the headset snaps -- through a fade +-- to black and back -- to the flat battle's own over-the-shoulder seat +-- (VRRig.battleMount), and returns the same way when the fight ends; +-- the 2D battle screen lights up on the POKEDEX in the tracked left +-- hand (lib/Pokedex.lua) and NO floating panel is submitted at all -- +-- the fight itself owns the view. The flat window keeps +-- running as the mirror (left eye when the world is up), so menus stay +-- usable at the desk and every existing input keeps working alongside +-- the XR controllers. +-- +-- Failure is a status, never a crash: no runtime, no headset, no GL +-- interop, or a mid-session loss all land back on the flat screen with +-- the reason readable off VR.status(). + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local Voxel = V.require("VoxelState") +local Voxel3D = V.require("Voxel3D") +local VoxelScene = V.require("VoxelScene") +local FirstPerson = V.require("FirstPerson") +local BattleCam = V.require("BattleCam") +local VRRig = V.require("VRRig") +local VRXR = V.require("VRXR") +local VRGL = V.require("VRGL") +local Pokedex = V.require("Pokedex") + +local VR = {} + +-- the row: plain OFF/ON. No hotkey -- the engine's display keys are +-- spoken for, and a headset is not something to toggle by accident. +VR.setting = ModSetting.new("vr", "VR", { false, true }, { "OFF", "ON" }) + +-- Where the diorama's UI panel floats vs first person's. These are the +-- FALLBACK screens: wherever the pokedex is up and lit -- first +-- person's menus, a battle's 2D scene -- no quad is submitted at all +-- (see updateQuad), and these serve only the diorama and the no-tracked- +-- controller case. +local QUAD_DIORAMA = { pos = { 0, 0.1, -1.0 }, width = 0.8 } +local QUAD_FP = { pos = { 0, 0, -1.4 }, width = 1.1 } + +local started = false -- start() succeeded this enablement +local failed = nil -- start() failed; wait for a re-toggle +local wasOn = false +local savedVsync = nil +local fboCache = setmetatable({}, { __mode = "k" }) -- canvas -> GL FBO id +local mirrorSrc = nil -- last left-eye canvas, for the window +local mirrorCanvas = nil +local status = "off" + +-- the diorama's live adjustments: the right stick's zoom (a multiplier on +-- the model's size) and the grab-drag's height (metres of world travel) +local zoom = 1 +local heightOff = 0 +local held = {} -- GB buttons this module is holding down +local lastHandY = nil -- the gripping hand's height, last frame + +-- First person's SNAP TURN: the right stick flicked left or right steps +-- the whole XR-to-world mapping 45 degrees at a time (a smooth software +-- turn is the classic comfort mistake -- vection with no vestibular +-- signal; a snap is instant and the head does the rest). The offset +-- turns the mapping itself, so the eyes, the walk direction and the +-- pokedex all agree about which way the world now faces. +local SNAP_TURN = math.rad(45) +local fpYawOff = 0 -- accumulated snaps, radians +local snapArmed = true -- re-arms when the stick returns to centre + +local function wrapPi(a) + return (a + math.pi) % (2 * math.pi) - math.pi +end + +-- The battle snap, made a FADE rather than a cut: when a fight is staged +-- on the world (or stops being), black rises over both eyes, the camera +-- swaps mounts behind it, and black lifts. A teleport inside VR is the +-- one camera move that should never be SEEN happening -- the world +-- sliding to a new seat reads as the room moving. +local FADE_TIME = 0.35 -- seconds each way: out, then back in +local camMode = "explore" -- "explore" (diorama / 1ST) or "battle" +local fadeAlpha = 0 -- the black over the eyes right now + +-- The staged fight to look at, if there is one: arena, floor height. +local function battleStage() + local ok, arena, groundY = pcall(function() + return V.require("OverworldBattle").stage() + end) + if not ok then return nil end + return arena, groundY +end + +-- the palette closure the engine hands drawWorld; stashed there (see +-- main.lua) because the VR frame renders from update, where no ctx exists +VR.paletteFor = nil + +-- Whether this platform can do VR AT ALL: the shipped loader and the GL +-- interop are Win32 (openxr_loader.dll, wgl), so only Windows qualifies. +-- Everywhere else -- Android above all -- the row is not offered on any +-- menu, and a stored vr=true is ignored rather than read: a save that +-- migrated over from the desktop must not leave a phone trying to start +-- an OpenXR session (or silently forcing the battle rows). Headless runs +-- have no love.system and answer true, which costs nothing: enabling VR +-- there stops at VRXR.start like it always did. +function VR.supported() + local ok, os = pcall(function() return love.system.getOS() end) + if not ok or not os then return true end + return os == "Windows" +end + +function VR.enabled() + return VR.supported() and VR.setting:get() == true +end + +function VR.active() + return started and VRXR.isRunning() +end + +function VR.status() + if not VR.enabled() then return "off" end + if failed then return failed end + return VRXR.status() +end + +-- Let go of every input this module was holding: the GB buttons pressed +-- through the overlay path, and the synthetic left stick. Runs when the +-- session ends and whenever a frame has no controller state to read. +local function releaseInputs() + local ok, Game = pcall(require, "src.core.Game") + if not ok or not Game.input then return end + for btn in pairs(held) do + pcall(function() Game.input:overlayReleased(btn) end) + held[btn] = nil + end + pcall(function() + Game.input:gamepadaxis(nil, "leftx", 0) + Game.input:gamepadaxis(nil, "lefty", 0) + end) + lastHandY = nil +end + +local function shutdown(reason) + if started then + VRXR.stop() + started = false + end + if savedVsync ~= nil then + pcall(love.window.setVSync, savedVsync) + savedVsync = nil + end + -- the placed camera may still be a VR eye's; the orbit must get the + -- pass back clean + Voxel3D.camera = nil + mirrorSrc = nil + releaseInputs() + BattleCam.still = false + VoxelScene.spriteLean = nil + Pokedex.clear() + zoom, heightOff = 1, 0 + fpYawOff, snapArmed = 0, true + camMode, fadeAlpha = "explore", 0 + status = reason or "off" +end + +VR.shutdown = shutdown -- named for the probe driver + +-- Whether the flat screen is showing something the world pass cannot: a +-- menu, a dialog, a battle, a transition wipe. The quad and the pokedex's +-- screen both key on it. +local function uiShowing() + local ok, showing = pcall(function() + local Game = require("src.core.Game") + local top = Game.stack and Game.stack:top() + return top ~= Game.overworld + or (Game.overworld and Game.overworld.transitioning) or false + end) + return ok and showing or false +end + +-- ------- the pokedex's screen +-- +-- What the device in the hand shows during a battle: the flat window -- +-- which IS the 2D battle screen for as long as the battle state draws -- +-- copied into a canvas the scene pass can texture with, cropped by UV to +-- the battle's own letterbox so the screen wears the GB frame edge to +-- edge. Menus over the battle (the party, the bag) ride along for free: +-- they are the flat screen too, and reading them on the device in your +-- hand is exactly the point. +local dexCanvas = nil + +local function dexScreen() + local ok, out = pcall(function() + local ww, wh = love.graphics.getPixelDimensions() + if not (ww and ww > 0 and wh and wh > 0) then return nil end + if not (dexCanvas and dexCanvas:getWidth() == ww + and dexCanvas:getHeight() == wh) then + dexCanvas = love.graphics.newCanvas(ww, wh) + pcall(dexCanvas.setFilter, dexCanvas, "nearest", "nearest") + end + local fbo = fboCache[dexCanvas] + if not fbo then + fbo = VRGL.canvasFBO(dexCanvas) + fboCache[dexCanvas] = fbo + end + if not (fbo and VRGL.copyFrontToCanvas(fbo, ww, wh)) then return nil end + local BattleScene = V.require("BattleScene") + local lx, ly, s = BattleScene.letterbox() + return { dexCanvas, + lx / ww, ly / wh, + (lx + BattleScene.GB_W * s) / ww, + (ly + BattleScene.GB_H * s) / wh } + end) + return ok and out or nil +end + +-- ------- the world, once per eye + +local function renderWorld(views, ctl) + local ok, Game = pcall(require, "src.core.Game") + local ow = ok and Game.overworld or nil + if not (ow and ow.map and ow.camera and Voxel.active() + and Voxel3D.available()) then + return false + end + local vw, vh = 320, 288 + pcall(function() vw, vh = Game.renderer:worldViewSize() end) + + -- Whatever the camera does, the CARDS hold the top rung's near-upright + -- lean: a head that roams has no one pitch for them to match, and 75 + -- degrees is the pose that reads as "standing" from anywhere. Cleared + -- on shutdown, so the flat screen leans with the rung as ever. + VoxelScene.spriteLean = math.rad(75) + + local pivot, anchor, scale, mountYaw + local fp = FirstPerson.engaged() + local battle, battleFloor + if camMode == "battle" then battle, battleFloor = battleStage() end + if battle then + -- the over-the-shoulder seat the flat battle shot stands in, pulled + -- close enough for a headset's own lens (see VRRig.battleMount), at + -- life scale, turned to face the arena + local rec = BattleCam.rig(battle, battleFloor) + pivot, mountYaw = VRRig.battleMount(rec.eye, rec.focus) + anchor = { 0, 0, 0 } + scale = VRRig.FP_SCALE + elseif fp then + local p = ow.player + local gh = 0 + pcall(function() gh = VoxelScene.groundAt(ow.map, p.cellX, p.cellY) end) + pivot = VRRig.fpPivot(p.px, p.py, gh, FirstPerson.EYE_HEIGHT) + anchor = { 0, 0, 0 } + scale = VRRig.FP_SCALE + -- the snap turn is a yaw on the MAPPING, same seam the battle mount + -- turns through + if fpYawOff ~= 0 then mountYaw = fpYawOff end + -- the HMD is the head: its yaw and pitch (plus the snaps) become + -- FirstPerson's, so FreeMove walks where you look and A talks to + -- what you face + local yaw, pitch = VRRig.headYawPitch(views[1].pose.quat) + FirstPerson.yaw = wrapPi(yaw + fpYawOff) + FirstPerson.pitch = math.max(FirstPerson.PITCH_UP, + math.min(FirstPerson.PITCH_DOWN, pitch)) + else + -- The table presents the world exactly as the flat screen does at + -- rest: the pivot sits VIEW_DIST away along the RUNG'S own angle + -- (stepping rungs re-tilts the model, easing with the rung tween), + -- at the scale that reproduces the flat framing -- then the player's + -- own adjustments go on top: the stick's zoom, the grip's height. + pivot = VRRig.dioramaPivot(ow.camera.x + vw / 2, ow.camera.y + vh / 2) + anchor = VRRig.dioramaAnchor(Voxel.angle, heightOff) + scale = VRRig.dioramaScale(vh, Voxel.FOCAL) / zoom + end + + -- The pokedex, on the tracked left hand, under this very mapping -- + -- but only where it earns its keep: FIRST PERSON, where its screen is + -- every menu, dialog and wipe the flat screen shows (and the floating + -- billboard is retired outright -- see updateQuad), and the BATTLE + -- seat, where its screen is the fight's own 2D scene. The diorama + -- does without: a hand-sized device hovering over a tabletop town is + -- clutter, and the panel serves there. No hand tracked, no device. + local hand = ctl and ctl.handl or nil + if hand and (battle or fp) then + Pokedex.place(hand, pivot, anchor, scale, mountYaw) + if uiShowing() then + local scr = dexScreen() + if scr then + Pokedex.screen(scr[1], scr[2], scr[3], scr[4], scr[5]) + end + end + else + Pokedex.clear() + end + + local eyes = {} + for i = 1, 2 do + local v = views[i] + eyes[i] = { + camera = VRRig.eyeCamera(v.pose, v.fov, pivot, anchor, scale, mountYaw), + w = v.w, h = v.h, + slot = i == 1 and "vrL" or "vrR", + -- the battle seat is a placed shot, not the first-person rig: the + -- cards keep their stage lean rather than yawing at this eye, and + -- the player's own card stays visible in it + adopt = not battle, + } + end + eyes.cx, eyes.cy = pivot[1], pivot[3] + + local okR, canvases = pcall(VoxelScene.render, ow, 0, 0, vw, vh, + VR.paletteFor, eyes) + if not (okR and type(canvases) == "table" and canvases[1] and canvases[2]) + then + return false + end + + -- the snap's fade, over the finished eyes: plain black at this moment's + -- strength, drawn before the blit so the headset never sees the swap + if fadeAlpha > 0 then + pcall(function() + for i = 1, 2 do + local c = canvases[i] + love.graphics.setCanvas(c) + love.graphics.setColor(0, 0, 0, math.min(1, fadeAlpha)) + love.graphics.rectangle("fill", 0, 0, c:getWidth(), c:getHeight()) + end + love.graphics.setCanvas() + love.graphics.setColor(1, 1, 1, 1) + end) + end + + for i = 1, 2 do + local canvas = canvases[i] + local tex, tw, th = VRXR.acquireEye(i) + if tex then + local fbo = fboCache[canvas] + if not fbo then + fbo = VRGL.canvasFBO(canvas) + fboCache[canvas] = fbo + end + if fbo then + VRGL.blitToTexture(fbo, canvas:getWidth(), canvas:getHeight(), + tex, tw, th) + end + end + VRXR.releaseEye(i) + end + mirrorSrc = canvases[1] + return true +end + +-- ------- the UI panel + +-- Whether the flat screen is showing something the world pass cannot: a +-- menu, a dialog, a battle, a transition wipe -- or everything, when the +-- world pass is off entirely. +local function wantQuad(worldUp) + if not worldUp then return true end + return uiShowing() +end + +local function updateQuad(worldUp, fp) + if not wantQuad(worldUp) then return nil end + -- Wherever the pokedex is up and lit -- first person's menus, the + -- battle seat's 2D fight -- it IS the screen, and no floating + -- billboard is submitted at all. (No tracked left hand still gets + -- the panel: the UI must be readable somewhere.) + if Pokedex.frame and Pokedex.frame.tex then return nil end + local tex, qw, qh = VRXR.acquireQuad() + if not tex then return nil end + local ww, wh = qw, qh + pcall(function() ww, wh = love.graphics.getPixelDimensions() end) + -- The panel wears the GB FRAME, not the window: everything the flat + -- screen has to say lives in the 160x144 letterbox (the world around + -- it is just the mirror's picture). The frame region is blitted OUT + -- of the window and SCALED into the swapchain image -- never copied + -- pixel-for-pixel, because the swapchain's size is fixed at session + -- start and a fullscreened window outgrows it, running the frame (and + -- the START menu flush with its right edge) off the copy. Scaled, the + -- panel shows the identical picture at the identical ratio whatever + -- size the window is. Source coordinates are GL's, origin bottom-left. + local crop = nil + local copied = false + pcall(function() + local BattleScene = V.require("BattleScene") + local lx, ly, s = BattleScene.letterbox() + local wpx = math.ceil(BattleScene.GB_W * s) + local hpx = math.ceil(BattleScene.GB_H * s) + local sx = math.max(0, math.floor(lx)) + local sy = math.max(0, math.floor(wh - ly - hpx)) + wpx = math.min(wpx, ww - sx) + hpx = math.min(hpx, wh - sy) + if wpx < 1 or hpx < 1 then return end + -- fitted to the swapchain image at the REGION's own aspect: the + -- crop then presents exactly that rect, so the panel's shape is the + -- GB frame's at any window and any swapchain size + local fit = math.min(qw / wpx, qh / hpx) + local dw = math.max(1, math.floor(wpx * fit)) + local dh = math.max(1, math.floor(hpx * fit)) + if VRGL.copyFrontRegionToTexture(tex, sx, sy, wpx, hpx, dw, dh) then + copied = true + crop = { 0, 0, dw, dh } + end + end) + if not copied then + -- no letterbox to cut (or the blit refused): the old whole-window + -- copy, clamped, is still a readable panel + VRGL.copyFrontBuffer(tex, math.min(qw, ww), math.min(qh, wh)) + end + VRXR.releaseQuad() + local base = fp and QUAD_FP or QUAD_DIORAMA + if not crop then return base end + return { pos = base.pos, width = base.width, crop = crop } +end + +-- ------- the controllers +-- +-- The mapping the mod ships (rebindable in the runtime's own UI): +-- +-- both modes left stick moves (through the engine's own stick path, +-- so it grid-walks the diorama and free-walks 1ST); +-- A/B are A/B; either trigger is START; clicking the +-- LEFT stick steps the VOXEL angle ladder exactly as +-- the "3" key (and the pad's SELECT) does. +-- 1ST only right stick left/right SNAP-TURNS 45 degrees a flick. +-- diorama only right stick up/down zooms the model; squeezing a grip +-- and moving that hand up or down drags the whole table +-- with it. +-- +-- Leaving VR is the VR row's job alone (OPTIONS menu or the manager) -- +-- no controller button does it. VR.leave below stays as the API for it. + +-- The left stick click makes EXACTLY the step the "3" key makes: one +-- rung up the VOXEL angle ladder, wrapping, stepping over FULL, clearing +-- TILT and GBC FX in the save -- by calling the very function the key +-- and the pad's SELECT button already share. main.lua installs it below +-- (cycleVoxel is a local of that file); the free-roam gate is the +-- registry's own, inside it, so a click over a menu or mid-warp is a +-- no-op exactly like the key. +VR.cycleVoxel = nil -- cycleVoxel(game), set by main.lua + +function VR.stepView() + pcall(function() + if not VR.cycleVoxel then return end + VR.cycleVoxel(require("src.core.Game")) + end) +end + +-- Leave VR: the VR row toggled back off and persisted, exactly as if +-- stepped on the OPTIONS menu, so the next update tears the session down +-- and the flat screen takes the picture back. Deliberately bound to NO +-- controller button (a click that ejects you from the headset is a trap +-- mid-fight); kept as the one programmatic door out. +function VR.leave() + pcall(function() + local Game = require("src.core.Game") + VR.setting:setIndex(VR.setting:read() + 1, Game) + end) +end + +local function setGB(inp, btn, down) + if down and not held[btn] then + held[btn] = true + inp:overlayPressed(btn) + elseif not down and held[btn] then + held[btn] = nil + inp:overlayReleased(btn) + end +end + +local function driveControls(ctl, dt, fp) + if not ctl then + releaseInputs() + return + end + local ok, Game = pcall(require, "src.core.Game") + if not (ok and Game.input) then return end + local inp = Game.input + + setGB(inp, "a", ctl.a) + setGB(inp, "b", ctl.b) + setGB(inp, "start", ctl.start) + + -- the left stick, through the engine's OWN stick handler: it quantises + -- to the grid d-pad for the diorama, and FirstPerson.moveVector reads + -- the same raw pair for the free walk. OpenXR's +Y is up; the engine's + -- lefty is +down. + inp:gamepadaxis(nil, "leftx", ctl.moveX or 0) + inp:gamepadaxis(nil, "lefty", -(ctl.moveY or 0)) + + if ctl.toggleChanged and ctl.toggle then VR.stepView() end + + -- first person's snap turn: a flick of the right stick steps the view + -- 45 degrees, once per flick -- it re-arms only when the stick comes + -- back toward centre, so holding it turns exactly once + if fp and camMode ~= "battle" then + local sx = ctl.lookX or 0 + if math.abs(sx) > 0.65 then + if snapArmed then + snapArmed = false + -- increasing yaw turns LEFT in this mod's compass, so a stick + -- pushed right subtracts + fpYawOff = wrapPi(fpYawOff + (sx > 0 and -SNAP_TURN or SNAP_TURN)) + end + elseif math.abs(sx) < 0.35 then + snapArmed = true + end + end + + if not fp and camMode ~= "battle" then + local zy = ctl.lookY or 0 + if math.abs(zy) > 0.15 then + zoom = math.max(0.35, math.min(4, zoom * math.exp(zy * (dt or 0) * 1.6))) + end + -- the grab-drag: while a grip is squeezed, the table follows that + -- hand's height, metre for metre + local gl, gr = ctl.gripL or 0, ctl.gripR or 0 + local y = (gr >= gl) and ctl.handrY or ctl.handlY + if math.max(gl, gr) > 0.6 and y then + if lastHandY then + heightOff = math.max(-1.5, math.min(1.5, heightOff + (y - lastHandY))) + end + lastHandY = y + else + lastHandY = nil + end + else + lastHandY = nil + end +end + +-- ------- the per-frame drive + +function VR.update(dt) + local on = VR.enabled() + if not on then + if wasOn then + shutdown("off") + failed = nil + end + wasOn = false + return + end + + if not wasOn then failed = nil end -- a fresh toggle earns a fresh try + wasOn = true + if failed then return end + + if not started then + local qw, qh = 1024, 768 + pcall(function() qw, qh = love.graphics.getPixelDimensions() end) + if VRXR.start(qw, qh) then + started = true + status = "session created" + print("[DRAMATIC_SHAPE] VR: " .. VRXR.status()) + else + failed = VRXR.status() + print("[DRAMATIC_SHAPE] VR unavailable: " .. failed + .. " -- fix that, then toggle the VR row to retry") + return + end + end + + if not VRXR.poll() then + -- the runtime took the session away (headset off, runtime shut down) + shutdown("session lost") + failed = "session lost -- toggle VR off and on to retry" + return + end + if not VRXR.isRunning() then return end + + -- the headset paces the app now; vsync would fight it + if savedVsync == nil then + savedVsync = 1 + pcall(function() savedVsync = love.window.getVSync() end) + pcall(love.window.setVSync, 0) + end + + -- the battle camera holds still for as long as a headset is watching: + -- its drift is a flat screen's depth cue, and a swaying picture inside + -- VR reads as the world lurching + BattleCam.still = true + + -- The battle snap's fade: while the camera the frame WANTS is not the + -- one it is showing, black rises; at full black the mount swaps; then + -- black lifts. Driven here, on game time, so a fight that ends during + -- the fade just turns it around. + local want = battleStage() and "battle" or "explore" + if want ~= camMode then + fadeAlpha = math.min(1, fadeAlpha + (dt or 0) / FADE_TIME) + if fadeAlpha >= 1 then camMode = want end + else + fadeAlpha = math.max(0, fadeAlpha - (dt or 0) / FADE_TIME) + end + + local time, should = VRXR.waitFrame() + if not time then return end + + -- the controllers, before the world renders: the frame the toggle + -- flips rungs on should be the frame that renders the new rig. The + -- state is kept in hand for renderWorld too -- the pokedex stands on + -- the same frame's left-hand pose. + local ctl = VRXR.input(time) + driveControls(ctl, dt, FirstPerson.engaged()) + + local worldUp = false + if should then + local views = VRXR.locateViews(time) + if views then + worldUp = renderWorld(views, ctl) + end + end + local quadPose = updateQuad(worldUp, FirstPerson.engaged()) + VRXR.endFrame(time, worldUp or nil, quadPose) +end + +-- ------- the window while a headset owns the picture + +-- The flat window becomes the mirror: the left eye, fitted to the window. +-- Returns nil when there is nothing to mirror (the caller draws the flat +-- path as ever). +function VR.mirror(sw, sh) + if not (VR.active() and mirrorSrc) then return nil end + if not (mirrorCanvas and mirrorCanvas:getWidth() == sw + and mirrorCanvas:getHeight() == sh) then + local ok, c = pcall(love.graphics.newCanvas, sw, sh) + if not ok then return nil end + mirrorCanvas = c + end + local ok = pcall(function() + love.graphics.setCanvas(mirrorCanvas) + love.graphics.clear(0, 0, 0, 1) + local mw, mh = mirrorSrc:getDimensions() + local s = math.min(sw / mw, sh / mh) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(mirrorSrc, (sw - mw * s) / 2, (sh - mh * s) / 2, 0, s, s) + love.graphics.setCanvas() + end) + pcall(love.graphics.setCanvas) + return ok and mirrorCanvas or nil +end + +-- window resize, hot reload: the eye canvases are Voxel3D's and go with +-- its invalidate; ours is the mirror and the FBO ids learned from dead +-- canvases +function VR.invalidate() + if mirrorCanvas and mirrorCanvas.release then + pcall(mirrorCanvas.release, mirrorCanvas) + end + mirrorCanvas, mirrorSrc = nil, nil + if dexCanvas and dexCanvas.release then pcall(dexCanvas.release, dexCanvas) end + dexCanvas = nil + Pokedex.invalidate() + for k in pairs(fboCache) do fboCache[k] = nil end +end + +return VR diff --git a/lib/VRGL.lua b/lib/VRGL.lua new file mode 100644 index 0000000..bbfdf97 --- /dev/null +++ b/lib/VRGL.lua @@ -0,0 +1,237 @@ +-- 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 + +-- Blit a REGION of the window's front buffer into a GL texture (an XR +-- swapchain image), SCALED to (dw, dh) at the texture's origin. This is +-- the panel's route: the whole-window copy above is pixel-for-pixel, so +-- a window larger than the swapchain image simply ran off its edges -- +-- fullscreen cut the GB frame's own menu off the panel. A scaled blit +-- has no such cliff: the letterbox region lands whole at the texture's +-- own resolution whatever size the window is. Source coordinates are GL +-- window space, origin bottom-left; LINEAR, because the region rarely +-- matches the target size exactly and dropped rows read worse than a +-- soft one. Restores the read buffer and framebuffer LOVE believes in. +function VRGL.copyFrontRegionToTexture(tex, sx, sy, sw, sh, dw, dh) + 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.READ_FRAMEBUFFER, 0) + gl.glReadBuffer(GL.FRONT) + ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, fbo) + ext.glFramebufferTexture2D(GL.DRAW_FRAMEBUFFER, GL.COLOR_ATTACHMENT0, + GL.TEXTURE_2D, tex, 0) + ext.glBlitFramebuffer(sx, sy, sx + sw, sy + sh, 0, 0, dw, dh, + GL.COLOR_BUFFER_BIT, GL.LINEAR) + ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) + gl.glReadBuffer(GL.BACK) + end) + if not ok then + pcall(function() + ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) + gl.glReadBuffer(GL.BACK) + end) + end + return ok +end + +-- Copy the window's front buffer into a LOVE CANVAS (by its FBO id, from +-- canvasFBO), flipped so the canvas reads top-down exactly like the +-- window: what LOVE then draws from that canvas at (0,0) is the screen, +-- row for row. The battle's VR quad is the caller: it needs the screen as +-- something LOVE can CUT UP (scissored cutouts of the UI), not just as a +-- finished texture -- copyFrontBuffer above is for the finished case. +function VRGL.copyFrontToCanvas(dstFBO, w, h) + if not ready then return false end + local ok = pcall(function() + ext.glBindFramebuffer(GL.READ_FRAMEBUFFER, 0) + gl.glReadBuffer(GL.FRONT) + ext.glBindFramebuffer(GL.DRAW_FRAMEBUFFER, dstFBO) + ext.glBlitFramebuffer(0, 0, w, h, 0, h, w, 0, + GL.COLOR_BUFFER_BIT, GL.NEAREST) + ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) + gl.glReadBuffer(GL.BACK) + end) + if not ok then + pcall(function() + ext.glBindFramebuffer(GL.FRAMEBUFFER, 0) + 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 diff --git a/lib/VRRig.lua b/lib/VRRig.lua new file mode 100644 index 0000000..7b38c5e --- /dev/null +++ b/lib/VRRig.lua @@ -0,0 +1,248 @@ +-- 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 } + +-- ------- the battle mount +-- +-- A staged fight snaps the headset to an OVER-THE-SHOULDER seat: the same +-- line the flat battle camera stands on (eye through focus, so the player's +-- mon is near-left and the foe far-right exactly as the flat shot frames +-- them), but pulled in to BATTLE_DIST -- the flat rig is a long lens from +-- fifteen metres back, and a headset's lens is its own eyes, so keeping the +-- distance would shrink the fight to a stage seen from the back row. 66 px +-- is the wide rig's own standing distance: six and a half metres at life +-- scale, close enough to fill the view, far enough to hold both mons in it +-- -- and short enough to stay inside the small rooms the wide rig exists +-- for. +VRRig.BATTLE_DIST = 66 + +-- Where the head sits for a staged fight, and which way the mapping must +-- turn so that seat FACES it. Returns the pivot (world px -- pin the XR +-- origin here at FP_SCALE) and the yaw for eyeCamera: the flat camera +-- looks along focus - eye, the resting headset looks along XR -Z (world +-- north), and the yaw is what closes that gap. +function VRRig.battleMount(eye, focus) + local dx = eye[1] - focus[1] + local dy = eye[2] - focus[2] + local dz = eye[3] - focus[3] + local len = math.sqrt(dx * dx + dy * dy + dz * dz) + if len < 1e-6 then return { eye[1], eye[2], eye[3] }, 0 end + local k = VRRig.BATTLE_DIST / len + -- Ry(yaw) sends XR forward (0,0,-1) to (-sin yaw, 0, -cos yaw); aiming + -- that along the horizontal of focus - eye solves to atan2 of eye - focus + return { focus[1] + dx * k, focus[2] + dy * k, focus[3] + dz * k }, + math.atan2(dx, dz) +end + +-- 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 +-- yaw optional turn of the whole mapping about +Y, radians: the +-- battle mount faces the resting head at the arena with it. +-- worldFromXr(p) becomes pivot + s * Ry(yaw) * (p - anchor). +-- +-- 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, yaw) + 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) * Ry(-yaw) * 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])) + if yaw and yaw ~= 0 then + view = Mat4.mul(view, Mat4.rotateY(-yaw)) + end + 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's RAY FAN, in world axes: the direction a canvas point + -- (u, v in 0..1, left-to-right and top-to-bottom) looks along is + -- base + u * du + v * dv. The sky reads its per-pixel TRUE elevation + -- off this (a real skybox cannot be painted from any per-frame row + -- mapping -- that is exact only at the view's own azimuth and swims + -- everywhere else). Directions only, so the mapping's scale drops out; + -- the yaw must not (the battle mount and the snap turn swing the world). + local Rw = R + if yaw and yaw ~= 0 then Rw = Mat4.mul(Mat4.rotateY(yaw), R) end + local tl, tr = math.tan(fov.angleLeft), math.tan(fov.angleRight) + local tu, td = math.tan(fov.angleUp), math.tan(fov.angleDown) + -- world columns of the head's rotation: right (X), up (Y), forward (-Z) + local rxc, ryc, rzc = Rw[1], Rw[5], Rw[9] + local uxc, uyc, uzc = Rw[2], Rw[6], Rw[10] + local fxc, fyc, fzc = -Rw[3], -Rw[7], -Rw[11] + local skyRay = { + base = { fxc + rxc * tl + uxc * tu, + fyc + ryc * tl + uyc * tu, + fzc + rzc * tl + uzc * tu }, + du = { rxc * (tr - tl), ryc * (tr - tl), rzc * (tr - tl) }, + dv = { uxc * (td - tu), uyc * (td - tu), uzc * (td - tu) }, + } + + -- the eye and its forward, in world pixels: worldFromEye applied to the + -- origin and to -Z + local ax, ay, az = px - anchor[1], py - anchor[2], 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] + if yaw and yaw ~= 0 then + local c, s = math.cos(yaw), math.sin(yaw) + ax, az = c * ax + s * az, -s * ax + c * az + fx, fz = c * fx + s * fz, -s * fx + c * fz + end + local ex = pivot[1] + scale * ax + local ey = pivot[2] + scale * ay + local ez = pivot[3] + scale * az + + 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, + skyRay = skyRay, + } +end + +-- The WORLD model matrix a hand-held prop stands on: worldFromXr (the +-- same mapping the eyes use -- so the prop is exactly where the hand is, +-- whatever mode the mapping is in) composed with the hand's own tracked +-- pose. A mesh authored in METRES rides it straight: the mapping's scale +-- is what turns metres into world pixels, so the prop keeps its real +-- size in the hand at the diorama's scale and at life scale alike. +-- +-- model = T(pivot) * S(s) * Ry(yaw) * T(-anchor) * T(hand.pos) * R(hand.quat) +function VRRig.propMatrix(pose, pivot, anchor, scale, yaw) + local m = Mat4.translate(pivot[1], pivot[2], pivot[3]) + m = Mat4.mul(m, Mat4.scale(scale, scale, scale)) + if yaw and yaw ~= 0 then m = Mat4.mul(m, Mat4.rotateY(yaw)) end + m = Mat4.mul(m, Mat4.translate(-anchor[1], -anchor[2], -anchor[3])) + m = Mat4.mul(m, Mat4.translate(pose.pos[1], pose.pos[2], pose.pos[3])) + local q = pose.quat + return Mat4.mul(m, Mat4.fromQuat(q[1], q[2], q[3], q[4])) +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 diff --git a/lib/VRXR.lua b/lib/VRXR.lua new file mode 100644 index 0000000..454f7c4 --- /dev/null +++ b/lib/VRXR.lua @@ -0,0 +1,1052 @@ +-- 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. The dev tree answers to +-- the working directory or the source directory -- but an INSTALLED +-- release is neither: importing a mod there lands it in the game's SAVE +-- DIRECTORY, so the mount that actually holds the mod +-- (getRealDirectory) and the save directory itself are both tried too. +local DLL_REL = "assets/vr/openxr_loader.dll" + +local function loaderCandidates() + local rel = (V.path or "mods/DramaticShapeVoxelMod") .. "/" .. DLL_REL + local list = { rel } + local okR, real = pcall(function() + return love.filesystem.getRealDirectory(rel) + end) + if okR and type(real) == "string" then + list[#list + 1] = real .. "/" .. rel + end + local ok, src = pcall(function() return love.filesystem.getSource() end) + if ok and type(src) == "string" then list[#list + 1] = src .. "/" .. rel end + local okS, save = pcall(function() + return love.filesystem.getSaveDirectory() + end) + if okS and type(save) == "string" then list[#list + 1] = save .. "/" .. rel end + list[#list + 1] = "openxr_loader" -- last resort: the system search path + return list +end + +VRXR._loaderCandidates = loaderCandidates -- named for the suite + +-- The escape hatch for a mod imported as an ARCHIVE (or mounted anywhere +-- else ffi.load cannot see): copy the DLL out of the mount into the save +-- directory -- a real folder on every platform -- and load it from +-- there. Written once and reused; rewritten only if the size changed (a +-- mod update shipping a new loader). +local EXTRACTED = "dramatic_shape_openxr_loader.dll" + +local function extractLoader() + local okB, bytes = pcall(function() + return V.mod and V.mod.read and V.mod:read(DLL_REL) + end) + if not (okB and type(bytes) == "string" and #bytes > 0) then return nil end + local ok, path = pcall(function() + local have = love.filesystem.getInfo and love.filesystem.getInfo(EXTRACTED) + if not (have and have.size == #bytes) then + if not love.filesystem.write(EXTRACTED, bytes) then return nil end + end + local save = love.filesystem.getSaveDirectory() + if type(save) ~= "string" then return nil end + return save .. "/" .. EXTRACTED + end) + return ok and path or nil +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 + -- nothing loadable in place: extract a copy to the save directory + local extracted = extractLoader() + if extracted then + local ok, lib = pcall(ffi.load, extracted) + if ok then return lib end + errs[#errs + 1] = tostring(lib) + end + error("openxr_loader.dll not loadable from the mod, the save directory " + .. "or the system: " .. 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"), + 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.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.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.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 + -- and the whole pose when the orientation is valid too + -- (bit 0x1): the hand-held pokedex stands on it + if fl % 2 >= 1 then + local p, q = loc.pose.position, loc.pose.orientation + o[name] = { pos = { p.x, p.y, p.z }, + quat = { q.x, q.y, q.z, q.w } } + end + 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, +-- crop = {x, y, w, h} or nil }. Either may be nil. `crop` shows only +-- that sub-rect of the quad's texture -- GL image coordinates, origin +-- bottom-left, exactly as the front-buffer copy landed the window -- and +-- the panel's aspect follows the rect (the GB letterbox's near-square +-- frame) instead of the whole texture's. +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 + local cr = quadOpts.crop + if cr then + quadLayer[0].subImage.imageRect.offset.x = cr[1] + quadLayer[0].subImage.imageRect.offset.y = cr[2] + quadLayer[0].subImage.imageRect.extent.width = cr[3] + quadLayer[0].subImage.imageRect.extent.height = cr[4] + else + quadLayer[0].subImage.imageRect.extent.width = quad.w + quadLayer[0].subImage.imageRect.extent.height = quad.h + end + 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 + if cr then + quadLayer[0].size.height = w * (cr[4] / math.max(1, cr[3])) + else + quadLayer[0].size.height = w * (quad.h / quad.w) + end + 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 diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 1749402..fb5ef87 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -424,6 +424,12 @@ end -- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k 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 -- 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 @@ -438,6 +444,13 @@ end -- way either way. Voxel3D.camera = nil +-- This frame's camera RAY FAN, set by viewProjection alongside vp: the +-- world direction a canvas point looks along (see Sky.paint's `ray`). +-- Present for every free-pitch camera -- the VR eyes bring theirs +-- (VRRig.eyeCamera), a placed eye/focus camera gets one built -- and nil +-- for the orbit, whose frame-hung sky is the classic look. +Voxel3D.skyRayLive = nil + -- ------- which way, and how steeply, this camera looks -- -- Two facts about the view direction, set alongside the eye and the focus @@ -484,6 +497,15 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- question about which way this camera looks, and only these two answer it Voxel3D.focus = 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 + -- the VR eyes bring their fan with them (VRRig.eyeCamera) + Voxel3D.skyRayLive = cam.skyRay + return Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), cam.proj), cam.view) + end local dx = eye[1] - focus[1] local dy = eye[2] - focus[2] local dz = eye[3] - focus[3] @@ -497,6 +519,35 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- the same clip-space Y flip the orbit needs, for the same reason: we -- bypass LOVE's transform_projection and canvas coordinates run Y down proj = Mat4.mul(Mat4.scale(1, -1, 1), proj) + -- The camera's RAY FAN, for the sky's skybox path (Sky.paint's `ray`): + -- a placed camera with a FREE PITCH -- the first-person rig, steered + -- by a mouse on the flat screen -- must not hang its gradient off the + -- frame, or looking up and down drags the bands with the view. Built + -- from the very basis the view below is: forward, the true right, the + -- true up, and the symmetric frustum's tangents. + local upv = cam.up or { 0, 1, 0 } + local fx, fy, fz = -dx / dist, -dy / dist, -dz / dist + local crx = fy * upv[3] - fz * upv[2] + local cry = fz * upv[1] - fx * upv[3] + local crz = fx * upv[2] - fy * upv[1] + local crl = math.sqrt(crx * crx + cry * cry + crz * crz) + if crl > 1e-6 then + crx, cry, crz = crx / crl, cry / crl, crz / crl + local cux = cry * fz - crz * fy + local cuy = crz * fx - crx * fz + local cuz = crx * fy - cry * fx + local tanY = math.tan(cam.fov / 2) + local tanX = tanY * (vw / vh) + Voxel3D.skyRayLive = { + base = { fx - crx * tanX + cux * tanY, + fy - cry * tanX + cuy * tanY, + fz - crz * tanX + cuz * tanY }, + du = { crx * 2 * tanX, cry * 2 * tanX, crz * 2 * tanX }, + dv = { cux * -2 * tanY, cuy * -2 * tanY, cuz * -2 * tanY }, + } + else + Voxel3D.skyRayLive = nil + end -- world up by default, so the horizon stays level -- a placed camera -- that rolled with its own pitch would tip the whole arena. A caller -- may hand its own up: the first-person BLEND does, because its far @@ -505,6 +556,10 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 })) end + -- the orbit: a fixed pitch per rung, and the classic frame-hung sky -- + -- no ray fan wanted + Voxel3D.skyRayLive = nil + local a = Voxel.angle local focal = Voxel.FOCAL local dist = focal * vh @@ -572,6 +627,56 @@ function Voxel3D.horizonY(h) return (y / w * 0.5 + 0.5) * h end +-- The horizon as a LINE rather than a row, for a camera that can ROLL -- +-- a VR eye. A head tipped sideways tips the true horizon across the +-- canvas, and a sky painted in flat rows then visibly hinges with the +-- head. So: project the flat forward direction (a point ON the vanishing +-- line) and the same direction nudged a hair of world-up (a point just +-- above it); the difference is the canvas direction "down toward the +-- ground", perpendicular to the horizon however the head is tipped. +-- +-- Returns (ax, ay, edge, top): a unit axis in canvas pixels pointing from +-- sky toward ground, the horizon's signed distance along it -- a pixel at +-- canvas (x, y) is above the horizon while x*ax + y*ay < edge -- and, +-- when `elev` (radians) is given, the distance the direction that far +-- ABOVE the horizon projects to. `top` is what pins the gradient's far +-- end to a real direction in the sky: extrapolating it linearly from a +-- pixels-per-radian estimate left the bands sliding as a pitch moved the +-- horizon through the frame, because a perspective's rows are tan-spaced, +-- not angle-spaced. nil `top` (the elevated direction is outside this +-- frustum's forward hemisphere) leaves the caller its estimate. nil +-- everything with no horizon in front of this camera. +function Voxel3D.horizonLine(w, h, elev) + local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus + if not (m and eye and focus and w and h and h > 0) then return nil end + local dx = focus[1] - eye[1] + local dz = focus[3] - eye[3] + local len = math.sqrt(dx * dx + dz * dz) + if len < 1e-6 then return nil end + dx, dz = dx / len, dz / len + local function proj(vx, vy, vz) + local x = m[1] * vx + m[2] * vy + m[3] * vz + local y = m[5] * vx + m[6] * vy + m[7] * vz + local ww = m[13] * vx + m[14] * vy + m[15] * vz + if ww <= 1e-6 then return nil end + return (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h + end + local qx, qy = proj(dx, 0, dz) + if not qx then return nil end + local rx, ry = proj(dx, 0.02, dz) + if not rx then return nil end + local ax, ay = qx - rx, qy - ry + local al = math.sqrt(ax * ax + ay * ay) + if al < 1e-6 then ax, ay = 0, 1 else ax, ay = ax / al, ay / al end + local top = nil + if elev then + local ce, se = math.cos(elev), math.sin(elev) + local tx, ty = proj(dx * ce, se, dz * ce) + if tx then top = tx * ax + ty * ay end + end + return ax, ay, qx * ax + qy * ay, top +end + -- ------- the hour's light -- -- What the scene shader multiplies every surface by (see dayTint in the @@ -616,12 +721,83 @@ function Voxel3D.skyBody(w, h) return { x = (x / ww * 0.5 + 0.5) * w, y = (y / ww * 0.5 + 0.5) * h, + -- the body's WORLD direction, for the skybox path: a ray-fan caller + -- measures the twilight glow by the angle between a pixel's ray and + -- this, so the glow is pinned to the sky like the bands are (see + -- Sky.paint's glowDir) + dx = b.dx, dy = b.dy, dz = b.dz, moon = b.moon, glowAmt = amt, glowColor = color, } end +-- ------- the VR sky's world-anchored pieces +-- +-- Both exist because a headset showed the shortcuts: a gradient painted +-- off the frame moved with the head that carried the frame, and a +-- screen-space disc re-snapped its cell grid with every head movement +-- and held its face square to the canvas instead of to the world. The +-- gradient's fix rides the camera record itself (skyRay -- see VRRig and +-- Sky's useRay path); the disc's is below. + +-- The sun or moon as a QUAD IN THE WORLD: the baked cell art +-- (Sky.discImage) on a square spanned about the hour's direction, its +-- corners projected through this very eye -- so the disc is pinned to +-- the sky like the terrain is to the ground, stable under every head +-- motion, its face upright over the world. Runs inside beginScene's sky +-- window, before the depth mode is set, so the world draws over it. +local discMesh = nil + +local function drawWorldDisc(w, h) + local b = DayNight.body() + if not (b and b.dy and b.dy > 0.005) then return end + local amt = DayNight.glow() + local img = Sky.discImage(b.moon, Sky.discLooming(amt, b.moon)) + if not img then return end + local m = Voxel3D.vp + if not m then return end + local hl = math.sqrt(b.dx * b.dx + b.dz * b.dz) + if hl < 1e-6 then return end + -- right = horizontal, perpendicular to the direction; up completes it + local rx, rz = b.dz / hl, -b.dx / hl + local ux = -rz * b.dy + local uy = rz * b.dx - rx * b.dz + local uz = rx * b.dy + local ul = math.sqrt(ux * ux + uy * uy + uz * uz) + if ul < 1e-6 then return end + ux, uy, uz = ux / ul, uy / ul, uz / ul + if uy < 0 then ux, uy, uz = -ux, -uy, -uz end + -- apparent size is an ANGLE, the same fraction of the view the flat + -- screen's disc takes of its frame; the low sun looms exactly as there + local ang = Sky.DISC_FRAC * (Voxel3D.fovY or 1) + if Sky.discLooming(amt, b.moon) then ang = ang * 1.4 end + local k = math.tan(ang) + local verts = {} + local corners = { { -1, -1, 0, 1 }, { 1, -1, 1, 1 }, + { 1, 1, 1, 0 }, { -1, 1, 0, 0 } } + for i, c in ipairs(corners) do + local vx = b.dx + (rx * c[1] + ux * c[2]) * k + local vy = b.dy + (uy * c[2]) * k + local vz = b.dz + (rz * c[1] + uz * c[2]) * k + local x = m[1] * vx + m[2] * vy + m[3] * vz + local y = m[5] * vx + m[6] * vy + m[7] * vz + local ww = m[13] * vx + m[14] * vy + m[15] * vz + if ww <= 1e-6 then return end + verts[i] = { (x / ww * 0.5 + 0.5) * w, (y / ww * 0.5 + 0.5) * h, + c[3], c[4] } + end + pcall(function() + if not discMesh then + discMesh = love.graphics.newMesh(4, "fan", "stream") + end + discMesh:setVertices(verts) + discMesh:setTexture(img) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(discMesh) + end) +end + -- ----------------------------------------------------------------- scene -- -- Begin the 3D pass into a `w` x `h` pixel canvas centred on world @@ -676,10 +852,20 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- screen. The sky's dither grid is cut to it, and so is the water's -- -- one number, so the two break up on the same checkerboard. Voxel3D.cell = w / math.max(1, vw or w) - -- and where the sky's bottom edge lands, which is what the reflection + -- A FREE-PITCH camera's sky is ANCHORED IN SPACE, where the orbit's is + -- glued to the frame. One discriminator: skyRayLive, set by + -- viewProjection above for every camera whose pitch the player steers + -- -- the VR eyes and the flat first-person rig alike. With a fan, the + -- gradient is a SKYBOX (every pixel takes its band, and its GBC + -- checker, from its ray's true elevation -- no motion of the camera + -- moves a band, only the clock recolours them) and the sun or moon + -- hangs in the WORLD (drawWorldDisc). Without one -- the orbit, whose + -- pitch is the rung's -- the classic frame-hung painting stands. + local skyRay = Voxel3D.skyRayLive + local hy = Voxel3D.horizonY(h) + -- where the sky's bottom edge lands, which is what the reflection -- reads its bands against (see Water). nil when nothing painted bands. - Voxel3D.skyEdge = (sky and sky.bands) - and Sky.region(h, Voxel3D.horizonY(h)) or nil + Voxel3D.skyEdge = (sky and sky.bands) and Sky.region(h, hy) or nil if sky then love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) -- The sky goes down here, in the one window in this function where a @@ -692,8 +878,14 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- are the same size as the world's own and follow every resize and zoom. -- The banded sky also hangs the hour's sun or moon (skyBody projects it -- through this very camera); a flat sky has no bands and hangs nothing. - Sky.paint(w, h, sky, Voxel3D.horizonY(h), Voxel3D.cell, - sky.bands and Voxel3D.skyBody(w, h) or nil) + if skyRay and sky.bands then + Sky.paint(w, h, sky, nil, Voxel3D.cell, Voxel3D.skyBody(w, h), + nil, nil, skyRay) + drawWorldDisc(w, h) + else + Sky.paint(w, h, sky, hy, Voxel3D.cell, + sky.bands and Voxel3D.skyBody(w, h) or nil) + end else love.graphics.clear(0, 0, 0, 0, true, true) end @@ -1166,6 +1358,9 @@ function Voxel3D.invalidate() end canvas, canvasW, canvasH = nil, 0, 0 held = nil + -- the VR sky's disc mesh belongs to this context like the canvases do + if discMesh and discMesh.release then pcall(discMesh.release, discMesh) end + discMesh = nil ShadowMap.invalidate() -- the sky is part of this pass and holds a shader of its own Sky.invalidate() diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index 48c719d..19179a2 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -25,6 +25,8 @@ local Water = V.require("Water") local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") local FirstPerson = V.require("FirstPerson") +local BattleBillboard = V.require("BattleBillboard") +local Pokedex = V.require("Pokedex") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -261,21 +263,32 @@ end -- carries one pose into the other -- the lean eases out as the yaw eases in -- -- and cardBlend is zero for every camera that is not the first-person -- rig, the battle's placed shot included, so nothing else moves. +-- The pitch the sprite cards lean back by -- normally the rung's own +-- camera angle, overridable in radians. VR sets the override to the top +-- rung's 75 degrees for every diorama and battle frame: a table watched +-- from a freely moving head has no one camera pitch for the cards to +-- match, and the near-upright top-rung lean is the pose that reads as +-- "standing" from anywhere around it. nil (the default, and the flat +-- screen always) leans with the rung as ever. +VoxelScene.spriteLean = nil + +local function leanAngle() + return VoxelScene.spriteLean or V.require("VoxelState").angle +end + local function billboardMatrix(px, py, y, mirror) - local Voxel = V.require("VoxelState") local b = FirstPerson.cardBlend() local m = Mat4.translate(px + 8, y, py + 8) if b > 0 then m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b)) end - m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) + m = Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b))) if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end return Mat4.mul(m, Mat4.translate(-8, 0, 0)) end local function billboardPull() - local Voxel = V.require("VoxelState") - return VoxelScene.pull(math.max(Voxel.angle, 0.05)) + return VoxelScene.pull(math.max(leanAngle(), 0.05)) end -- An authored FIGURE's card -- a person the tileset draws INTO a piece of @@ -294,7 +307,6 @@ end -- his edge would swing him off his seat. The width rode in on the record -- for exactly this (ChunkMesher.buildFigureMeshes). local function figureMatrix(f, offX, offZ) - local Voxel = V.require("VoxelState") local b = FirstPerson.cardBlend() local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0) local m = Mat4.translate(wx, f.y, wz) @@ -304,7 +316,7 @@ local function figureMatrix(f, offX, offZ) m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b)) m = Mat4.mul(m, Mat4.translate(-half, 0, 0)) end - return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) + return Mat4.mul(m, Mat4.rotateX((leanAngle() - math.pi / 2) * (1 - b))) end -- What the sun sees: the same card UNLEANED and flattened, exactly as @@ -772,9 +784,12 @@ end -- 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. 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 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.begin(cx, cy, vw, vh) then return end @@ -835,12 +850,24 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, mirror))) 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.finish(sig) 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), -- return nil: the engine keeps the 2D path for the frame and -- Voxel.ready holds the camera tween at flat, so the switch waits @@ -890,8 +917,30 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- 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 -- 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 -- ground THIS camera sees (a no-op at blend zero): the orbit's fit @@ -899,11 +948,13 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- but a head free to face south. local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) 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 - return nil - end + -- Everything between beginScene and endScene, as one function: the flat + -- path runs it once, a VR frame runs it once PER EYE -- same posed + -- 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) for i, nb in ipairs(state.neighbors or {}) do @@ -991,14 +1042,54 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. 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 + -- and the MOVE ANIMATIONS, standing on the same arena: the + -- engine's own effects layer on the plane through both cells + -- (BattleScene.fxCard), pulled a little harder than the mons so + -- a burst plays over the card it is bursting on + local okA, fxTex, fxModel = pcall(function() + return V.require("OverworldBattle").worldAnim() + end) + if okA and fxTex and fxModel then + Voxel3D.draw(BattleBillboard.mesh(), fxTex, fxModel, + BattleBillboard.PULL + 6) + end + Voxel3D.seams(true) + Voxel3D.glass(true) + end + end -- tall grass last, pulled camera-ward exactly as far as the characters -- were (same per-vertex shader bias, so grass never drifts either): -- relative depth between a walker and the tuft row south of their feet -- is preserved, so the row still overdraws feet -- the 3D version of -- the GB's grass-over-feet trick -- while grass keeps losing to the -- buildings it genuinely stands behind (far deeper than the pull). - local Voxel = V.require("VoxelState") - local pull = VoxelScene.pull(math.max(Voxel.angle, 0.05)) + -- the same angle the cards leaned by (leanAngle honours VR's override), + -- so the tuft rows keep exactly the characters' own depth handicap + local lean = math.max(leanAngle(), 0.05) + local pull = VoxelScene.pull(lean) Voxel3D.draw(ChunkMesher.grass(state.map), atlasFor(state.map), nil, pull) for _, nb in ipairs(state.neighbors or {}) do Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map), @@ -1014,7 +1105,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- lands behind the card and the player obscures the patch they stand -- ON, while the nearest flower of the cell south (+20) stays in front -- and keeps overdrawing their feet. - local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05))) + local fpull = math.max(0, pull - 8 * math.sin(lean)) -- flowers are snugged casters too, so they read their own shadowing -- through the same snugged transform the sun stored them with Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil, @@ -1025,7 +1116,48 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end - return Voxel3D.endScene() + -- The VR pokedex in the player's left hand, last of all: a prop over + -- the world drawn with real depth, so leaning it into a wall still + -- occludes honestly. Its frame only exists while a session is live and + -- the left hand is tracked (VR.lua sets it), so every flat frame skips + -- this in one field read. No wireframe and no glass, like the cast: + -- the device is a drawing riding the scene, not part of the terrain. + if Pokedex.frame then + Voxel3D.glass(false) + Voxel3D.seams(false) + Pokedex.draw() + Voxel3D.seams(true) + Voxel3D.glass(true) + end + + 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 return VoxelScene diff --git a/main.lua b/main.lua index 6f50f7c..1fff18a 100644 --- a/main.lua +++ b/main.lua @@ -92,6 +92,7 @@ local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") +local VR = V.require("VR") -- Forward declaration: the voxel pipeline's update hook (registered below) -- 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 -- while voxel mode is OFF still invalidates what is cached. 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 local Game = require("src.core.Game") local ow = Game and Game.overworld @@ -211,6 +219,19 @@ mod.content.render_pipelines:register("voxel", { end, 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 -- draws composited on top, anchored through the same camera the 3D -- pass used (ctx.drawFx below). The scene renders at the window's @@ -247,6 +268,7 @@ mod.content.render_pipelines:register("voxel", { OverworldBattle.invalidate() AntiAlias.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh + VR.invalidate() -- the mirror, and FBO ids of dead canvases end, }) @@ -373,10 +395,14 @@ local SETTINGS = { .. "fraction of the cost." }, -- `full` marks a row FULL does not take away. FULL owns the diorama's own -- knobs; what a battle is drawn over, and how it is framed, are not that. + -- Off the OPTIONS menu while VR is on: the headset REQUIRES staged + -- battles (OverworldBattle.enabled answers true regardless of this row) + -- and forbids back sprites (backPinned answers false), so both rows + -- decide nothing there and a dead switch on the menu reads as broken. { OverworldBattle.setting, "Fight on the map: the battle draws over the nearest clear ground, " .. "shot over the shoulder with a slow parallax drift.", - full = true }, + when = function() return not VR.enabled() end, full = true }, -- Only offered while a fight can actually be staged on the map: with 3D-BTL -- off the engine draws the classic screen, which is this row's ON already, -- and a row that no longer decides anything is worse than no row. @@ -384,7 +410,8 @@ local SETTINGS = { "Keep your own Pokemon on the battle menu, seen from behind in its " .. "original slot, instead of standing it on the map facing the foe. " .. "The foe is still out there on its own tile.", - when = function() return stagedBattles() end, full = true }, + when = function() return stagedBattles() and not VR.enabled() end, + full = true }, { DayNight.setting, "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " @@ -405,11 +432,29 @@ local SETTINGS = { .. "pixels in each direction and 4X twice, which makes this the most " .. "expensive row in the mod.", 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.", + -- on Windows the row stays even when a runtime is missing (the console + -- says why); off Windows -- mobile above all -- there is no VR to have + -- and the row does not exist + when = function() return VR.supported() end, full = true }, } local schema = {} -for i, entry in ipairs(SETTINGS) do - schema[i] = entry[1]:schema(entry[2]) +for _, entry in ipairs(SETTINGS) do + -- the VR row is absent from the mod manager's page too where the + -- platform cannot do VR at all -- the OPTIONS menu's `when` gates are + -- situational (a row hidden for now), this one is existential + if entry[1] ~= VR.setting or VR.supported() then + schema[#schema + 1] = entry[1]:schema(entry[2]) + end end mod.options:define(schema) @@ -456,6 +501,34 @@ local HOTKEYS = { ["9"] = Water.setting, } +-- One step of the VOXEL angle ladder: everything a "3" press does, named +-- so the pad's SELECT button (below) can make exactly the same step. The +-- gate is the registry's own; the tilt/GBC FX clearing is the engine work +-- the key has always delegated (see the wrap below for why). +local function cycleVoxel(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 false end + Pipelines.setLevel("voxel", Voxel.nextHotkeyLevel(Pipelines.level("voxel"))) + Pipelines.syncOptions(game.save.options) + -- 3 is the key that used to turn TILT on and sits next to the one that + -- used to turn GBC FX on, and this mod has taken both away. A player who + -- left either running before enabling the mod would otherwise have no + -- way back to off, and both fight the diorama -- so the VOXEL step + -- clears them on EVERY press, not just the press that switches on. + game.save.options.tilt = 0 + game.save.options.gbcfx = 0 + require("src.render.GBCFX").setLevel(0) + require("src.render.Tilt").setLevel(game.save.options.tilt or 0) + game:writeOptions() + return true +end + +-- The VR stick click makes this same step (VR.stepView): the function is +-- a local of this file, so the handoff is explicit rather than a +-- reimplementation drifting out of date in lib/VR.lua. +VR.cycleVoxel = cycleVoxel + do local Game = require("src.core.Game") local Pipelines = require("src.render.Pipelines") @@ -472,32 +545,12 @@ do -- 3 walks the ANGLE rungs and steps over FULL (Voxel.HOTKEY_ORDER), -- so the registry's plain "advance one and wrap" is not what it -- wants; 6 still is. The gate is the registry's own either way. - local stepped = false + -- The whole of 3's step lives in cycleVoxel, because the pad's + -- SELECT button makes the same step (see the handleInput wrap). if key == "3" then - if Pipelines.canToggle("voxel", top, self.overworld) then - Pipelines.setLevel("voxel", - Voxel.nextHotkeyLevel(Pipelines.level("voxel"))) - stepped = true - end - else - stepped = Pipelines.hotkey(key, top, self.overworld) and true - end - if stepped then + if cycleVoxel(self) then return end + elseif Pipelines.hotkey(key, top, self.overworld) then Pipelines.syncOptions(self.save.options) - -- 3 is the key that used to turn TILT on and sits next to the one - -- that used to turn GBC FX on, and this mod has taken both away. - -- A player who left either running before enabling the mod would - -- otherwise have no way back to off, and both fight the diorama: - -- TILT is the flat fake of what this mode does for real, and GBC - -- FX is a full-screen present pass over the top of it. So the - -- VOXEL key clears them on EVERY press, not just the press that - -- switches the mode on -- cycling back round to OFF leaves them - -- off too, which is the state the key is now the only route to. - if key == "3" then - self.save.options.tilt = 0 - self.save.options.gbcfx = 0 - require("src.render.GBCFX").setLevel(0) - end require("src.render.Tilt").setLevel(self.save.options.tilt or 0) self:writeOptions() return @@ -772,12 +825,16 @@ do function OptionsMenu:update(dt) local before = Pipelines.level("voxel") local hadBattles = OverworldBattle.enabled() + -- the VR row hides the two battle rows while it is on, so stepping + -- it changes the LIST exactly the way 3D-BTL does + local hadVR = VR.enabled() local wasOn = idAt(self, self.index) inner(self, dt) local after = Pipelines.level("voxel") local crossedFull = after ~= before and (Voxel.isFull(before) or Voxel.isFull(after)) - if crossedFull or OverworldBattle.enabled() ~= hadBattles then + if crossedFull or OverworldBattle.enabled() ~= hadBattles + or VR.enabled() ~= hadVR then local rebuilt = OptionsMenu.new(self.game) self.rows = rebuilt.rows -- Follow the row the cursor was ON rather than the slot it was in: @@ -829,6 +886,63 @@ OverworldBattle.install() FirstPerson.install() FreeMove.install() +-- ------- SELECT walks the angle ladder +-- +-- The same step the "3" key makes, on the pad's own button: a phone (and +-- a controller) has no number row, and SELECT has no overworld job in +-- Gen 1 -- its work is all in-menu, which this wrap never sees. The seam +-- is OverworldState:handleInput, the same choke point the free walk +-- replaced: every gate above it -- menus, dialogs, scripted moves, +-- transitions -- already decided the overworld owns the buttons, so a +-- SELECT here is free-roam by construction, exactly like the key. When +-- the step is refused (mid-warp, no 3D pass) the press falls through to +-- the engine's own handling, which is a no-op, as ever. +-- +-- Installed AFTER FreeMove.install, deliberately: its wrap must sit +-- OUTSIDE the free walk's, or first person -- where FreeMove.tick takes +-- the frame and never calls further in -- would eat the button, and the +-- one rung SELECT could not step off of would be 1ST itself. +do + local OverworldState = require("src.world.OverworldController") + if not OverworldState.dramaticShapeSelectHook then + local inner = OverworldState.handleInput + function OverworldState:handleInput(...) + local Game = require("src.core.Game") + local input = Game.input + if input and input.wasPressed and input:wasPressed("select") then + if cycleVoxel(Game) then return end + end + return inner(self, ...) + end + OverworldState.dramaticShapeSelectHook = true + end +end + +-- ------- edge-anchored menus stay in the GB frame while a headset is live +-- +-- The engine's zoom-aware anchoring (Renderer:setUIAnchor) docks the START +-- menu to the WINDOW's top-right edge. Both VR screens -- the floating +-- panel and the Pokedex -- crop the window to the GB frame, so a menu at +-- the window's edge is cropped away with the border it docked to. The +-- engine's own answer to "a state composes its screen, keep every element +-- inside it" is uiAnchorHold, computed per frame from this predicate; a +-- live headset is exactly that situation for the WHOLE window, so the +-- predicate answers yes for as long as one is. Held menus blit where they +-- were drawn in the 160x144 canvas -- the START menu's 9,0 x 11 slot is +-- already flush with the frame's right edge, which is the right edge of +-- what the headset sees. Off-headset frames fall through untouched. +do + local Game = require("src.core.Game") + if not Game.dramaticShapeAnchorHold then + local inner = Game.uiAnchorsHeldInStack + function Game.uiAnchorsHeldInStack(stack) + if VR.active() then return true end + return inner(stack) + end + Game.dramaticShapeAnchorHold = true + end +end + -- The overworld's own pushBattle is the choke point for a wild encounter or -- a trainer, and it is wrapped. A battle that arrives some other way -- a -- link battle, a script pushing a BattleState directly -- reaches this @@ -930,7 +1044,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.5.0" +mod.exports.version = "1.5.2" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 8ea928d..b8550d8 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.5.0", + "version": "1.5.2", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/mod.card b/mod.card index 08ec65a..307bdde 100644 --- a/mod.card +++ b/mod.card @@ -16,6 +16,7 @@ return { "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", "the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back", "hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded", + "SELECT in free roam steps the VOXEL ladder exactly as hotkey 3 does -- the button has no overworld job in Gen 1, and phones and pads have no number row; menus keep it untouched", "on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched", "on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view", }, @@ -26,6 +27,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", "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", + "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, a staged battle snapping the headset (through a fade to black) into the flat game's own over-the-shoulder seat at life scale, a voxel Pokedex flush along the left controller in first person and in battles (menus, dialogs and the 2D battle screen on its screen; the diorama does without it), the sky and its sun and moon anchored in space (bands, GBC dither and twilight glow alike -- nothing in the sky reacts to the head), the floating panel wearing the GB frame near-square rather than the whole monitor-wide window (scaled into the headset, so the picture and its ratio are identical at every window size, fullscreen included), 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 steps the VOXEL angle ladder exactly as the 3 key and SELECT do; in 1ST the right stick snap-turns 45 degrees a flick; in the diorama the right stick zooms and a squeezed grip drags the table's height; no controller button leaves VR -- that is the VR row's job", "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", "a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode", @@ -44,10 +47,17 @@ 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", "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", + "VR is Windows x64 only (the shipped loader and the Win32 GL binding): on any other platform -- mobile above all -- the VR row is absent from the OPTIONS menu and the manager's page both, and a stored vr=true carried over in a save is ignored. On Windows it renders the scene once per eye (heavy with WATER FULL or AA up); pad/keyboard/mouse keep working alongside the XR controllers. The loader DLL is found wherever the mod was put -- the dev tree, an installed release's save directory, or an imported archive, from which it is copied once into the save directory so the FFI has a real disk path", + "VR on and off are both the VR row's job (options menu or manager); no controller button does either", + "the Pokedex needs a TRACKED left controller; without one there is no device in hand and the floating panel carries the UI as before", + "while the VR row is ON, 3D-BTL is held ON and BACK SPRITES held OFF (the headset's battle staging assumes both), and both rows leave the OPTIONS menu until VR goes off -- their stored values come back with them", + "while a headset is live the battle HUDs keep their classic in-frame slots instead of snapping to the window's edges, and the engine's edge-anchored menus (the START menu above all) are held inside the frame the same way, on the flat mirror too -- both VR screens crop to the GB frame, and a block at the window's edge would be cropped away with it", + "VR swapchains prefer plain RGBA8; a runtime that only offers sRGB shows slightly lifted colours", }, }, credits = { { 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 }, } diff --git a/oxr.zip b/oxr.zip new file mode 100644 index 0000000..78297e4 Binary files /dev/null and b/oxr.zip differ diff --git a/oxr/.signature.p7s b/oxr/.signature.p7s new file mode 100644 index 0000000..6c7fcd1 Binary files /dev/null and b/oxr/.signature.p7s differ diff --git a/oxr/OpenXR.Loader.nuspec b/oxr/OpenXR.Loader.nuspec new file mode 100644 index 0000000..0e2bc75 --- /dev/null +++ b/oxr/OpenXR.Loader.nuspec @@ -0,0 +1,18 @@ + + + + OpenXR.Loader + 1.0.10.2 + Khronos Group + Khronos Group + false + Apache-2.0 + https://licenses.nuget.org/Apache-2.0 + https://github.com/KhronosGroup/OpenXR-SDK + Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application + native khronos openxr loader headers + + + + + \ No newline at end of file diff --git a/oxr/[Content_Types].xml b/oxr/[Content_Types].xml new file mode 100644 index 0000000..1c6015a --- /dev/null +++ b/oxr/[Content_Types].xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/oxr/_rels/.rels b/oxr/_rels/.rels new file mode 100644 index 0000000..09ea63c --- /dev/null +++ b/oxr/_rels/.rels @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/oxr/build/native/OpenXR.Loader.props b/oxr/build/native/OpenXR.Loader.props new file mode 100644 index 0000000..048f045 --- /dev/null +++ b/oxr/build/native/OpenXR.Loader.props @@ -0,0 +1,5 @@ + + + $(MSBuildThisFileDirectory)..\..\ + + diff --git a/oxr/build/native/OpenXR.Loader.targets b/oxr/build/native/OpenXR.Loader.targets new file mode 100644 index 0000000..dc3b966 --- /dev/null +++ b/oxr/build/native/OpenXR.Loader.targets @@ -0,0 +1,35 @@ + + + + + + $(OpenXRLoaderPackageRoot)native\$(Platform)_uwp\release + + + + + $(OpenXRLoaderPackageRoot)native\$(Platform)\release + + + + + + + %(AdditionalDependencies);$(OpenXRLoaderBinaryRoot)\lib\openxr_loader.lib + + + + + + + %(Filename)%(Extension) + PreserveNewest + true + + + + + + + + diff --git a/oxr/native/Win32/release/bin/openxr_loader.dll b/oxr/native/Win32/release/bin/openxr_loader.dll new file mode 100644 index 0000000..0e867cb Binary files /dev/null and b/oxr/native/Win32/release/bin/openxr_loader.dll differ diff --git a/oxr/native/Win32/release/lib/openxr_loader.lib b/oxr/native/Win32/release/lib/openxr_loader.lib new file mode 100644 index 0000000..e5a3459 Binary files /dev/null and b/oxr/native/Win32/release/lib/openxr_loader.lib differ diff --git a/oxr/native/Win32_uwp/release/bin/openxr_loader.dll b/oxr/native/Win32_uwp/release/bin/openxr_loader.dll new file mode 100644 index 0000000..cc73198 Binary files /dev/null and b/oxr/native/Win32_uwp/release/bin/openxr_loader.dll differ diff --git a/oxr/native/Win32_uwp/release/lib/openxr_loader.lib b/oxr/native/Win32_uwp/release/lib/openxr_loader.lib new file mode 100644 index 0000000..e5a3459 Binary files /dev/null and b/oxr/native/Win32_uwp/release/lib/openxr_loader.lib differ diff --git a/oxr/native/arm/release/bin/openxr_loader.dll b/oxr/native/arm/release/bin/openxr_loader.dll new file mode 100644 index 0000000..10e9636 Binary files /dev/null and b/oxr/native/arm/release/bin/openxr_loader.dll differ diff --git a/oxr/native/arm/release/lib/openxr_loader.lib b/oxr/native/arm/release/lib/openxr_loader.lib new file mode 100644 index 0000000..114246f Binary files /dev/null and b/oxr/native/arm/release/lib/openxr_loader.lib differ diff --git a/oxr/native/arm64/release/bin/openxr_loader.dll b/oxr/native/arm64/release/bin/openxr_loader.dll new file mode 100644 index 0000000..b3aeaa9 Binary files /dev/null and b/oxr/native/arm64/release/bin/openxr_loader.dll differ diff --git a/oxr/native/arm64/release/lib/openxr_loader.lib b/oxr/native/arm64/release/lib/openxr_loader.lib new file mode 100644 index 0000000..fc0b848 Binary files /dev/null and b/oxr/native/arm64/release/lib/openxr_loader.lib differ diff --git a/oxr/native/arm64_uwp/release/bin/openxr_loader.dll b/oxr/native/arm64_uwp/release/bin/openxr_loader.dll new file mode 100644 index 0000000..3a21989 Binary files /dev/null and b/oxr/native/arm64_uwp/release/bin/openxr_loader.dll differ diff --git a/oxr/native/arm64_uwp/release/lib/openxr_loader.lib b/oxr/native/arm64_uwp/release/lib/openxr_loader.lib new file mode 100644 index 0000000..fc0b848 Binary files /dev/null and b/oxr/native/arm64_uwp/release/lib/openxr_loader.lib differ diff --git a/oxr/native/arm_uwp/release/bin/openxr_loader.dll b/oxr/native/arm_uwp/release/bin/openxr_loader.dll new file mode 100644 index 0000000..241aae1 Binary files /dev/null and b/oxr/native/arm_uwp/release/bin/openxr_loader.dll differ diff --git a/oxr/native/arm_uwp/release/lib/openxr_loader.lib b/oxr/native/arm_uwp/release/lib/openxr_loader.lib new file mode 100644 index 0000000..114246f Binary files /dev/null and b/oxr/native/arm_uwp/release/lib/openxr_loader.lib differ diff --git a/oxr/native/x64/release/bin/openxr_loader.dll b/oxr/native/x64/release/bin/openxr_loader.dll new file mode 100644 index 0000000..8b1726a Binary files /dev/null and b/oxr/native/x64/release/bin/openxr_loader.dll differ diff --git a/oxr/native/x64/release/lib/openxr_loader.lib b/oxr/native/x64/release/lib/openxr_loader.lib new file mode 100644 index 0000000..b877246 Binary files /dev/null and b/oxr/native/x64/release/lib/openxr_loader.lib differ diff --git a/oxr/native/x64_uwp/release/bin/openxr_loader.dll b/oxr/native/x64_uwp/release/bin/openxr_loader.dll new file mode 100644 index 0000000..dd499c3 Binary files /dev/null and b/oxr/native/x64_uwp/release/bin/openxr_loader.dll differ diff --git a/oxr/native/x64_uwp/release/lib/openxr_loader.lib b/oxr/native/x64_uwp/release/lib/openxr_loader.lib new file mode 100644 index 0000000..b877246 Binary files /dev/null and b/oxr/native/x64_uwp/release/lib/openxr_loader.lib differ diff --git a/oxr/package/services/metadata/core-properties/948f85fa57f345119150f5525085c62c.psmdcp b/oxr/package/services/metadata/core-properties/948f85fa57f345119150f5525085c62c.psmdcp new file mode 100644 index 0000000..d52de4f --- /dev/null +++ b/oxr/package/services/metadata/core-properties/948f85fa57f345119150f5525085c62c.psmdcp @@ -0,0 +1,9 @@ + + + Khronos Group + Khronos OpenXR loader and headers required to build a Win32 or UWP OpenXR application + OpenXR.Loader + 1.0.10.2 + native khronos openxr loader headers + NuGet, Version=5.4.0.3, Culture=neutral, PublicKeyToken=31bf3856ad364e35;Microsoft Windows NT 6.2.9200.0;.NET Framework 4.7.2 + \ No newline at end of file diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 032b297..a6fb494 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -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 -- 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") +-- 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 -- be reached while FULL owns it, so a value changed underneath (the mod @@ -384,7 +387,7 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { 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 battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] -- the AA row is hookedRows[8]; it is read in its own block below, because @@ -1123,6 +1126,34 @@ T.eq(Battles.setting:get(), false, "8 toggles overworld battles off") Game.keypressed(keyGame, "8") T.eq(Battles.setting:get(), true, "and back on") +-- ------- SELECT makes the same step the 3 key does +-- +-- The pad's own button, for the machines with no number row. The wrap +-- sits on OverworldState:handleInput -- free-roam by construction -- and +-- reads the live Game's input, so the fake free-roam shape the key tests +-- built is lent to the Game module for the length of the check. +-- (An anonymous function scope: the main chunk sits AT LuaJIT's +-- 200-active-locals ceiling, so even a named wrapper is one too many.) +;(function() + local OverworldState = require("src.world.OverworldController") + T.eq(OverworldState.dramaticShapeSelectHook, true, + "the SELECT wrap is installed on the overworld input seam") + local hadInput, hadStack = Game.input, Game.stack + local hadOw, hadSave, hadWrite = Game.overworld, Game.save, Game.writeOptions + Game.input = { wasPressed = function(_, b) return b == "select" end } + Game.stack, Game.overworld = keyGame.stack, keyGame.overworld + Game.save, Game.writeOptions = keyGame.save, keyGame.writeOptions + Pipelines.setLevel("voxel", 0) + OverworldState.handleInput({}) + T.eq(Pipelines.levelLabel("voxel"), "15", + "SELECT steps the VOXEL ladder exactly as 3 does") + OverworldState.handleInput({}) + T.eq(Pipelines.levelLabel("voxel"), "35", "and keeps walking it") + Game.input, Game.stack = hadInput, hadStack + Game.overworld, Game.save, Game.writeOptions = hadOw, hadSave, hadWrite + Pipelines.setLevel("voxel", 0) +end)() + -- 3 also clears the two engine modes it displaced. Without this a player -- who left TILT or GBC FX on before enabling the mod has no key left to -- turn them off with, and both fight the diorama -- TILT is the flat fake @@ -3643,6 +3674,483 @@ FirstPerson.blend = 0 VoxelState.reset() 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") + +-- ------- the battle mount: the over-the-shoulder seat, faced right +-- +-- The seat sits BATTLE_DIST along the flat battle camera's own line from +-- its aim point, and the yaw is whatever turns XR forward (north) onto +-- that line's look direction. +local seat, byaw = VRRig.battleMount({ 100, 35, 332 }, { 100, 2, 200 }) +local sdx = seat[1] - 100 +local sdy = seat[2] - 2 +local sdz = seat[3] - 200 +T.check(near(math.sqrt(sdx * sdx + sdy * sdy + sdz * sdz), + VRRig.BATTLE_DIST, 1e-3), + "the battle seat sits BATTLE_DIST from the aim point") +T.check(near(byaw, 0), + "a camera due south of its focus looks north -- XR forward, yaw 0") +local eyaw = select(2, VRRig.battleMount({ 150, 10, 200 }, { 100, 10, 200 })) +T.check(near(eyaw, math.pi / 2), + "a camera east of its focus turns the mapping a quarter toward west") + +-- an eye seated with that yaw really faces the arena: an identity head at +-- the seat comes out looking WEST, and the view agrees to the metre +local seated = VRRig.eyeCamera(pose, fov, { 150, 10, 200 }, { 0, 0, 0 }, + VRRig.FP_SCALE, math.pi / 2) +T.check(near(seated.eye[1], 150) and near(seated.eye[3], 200), + "the yawed mapping still pins the resting head to the pivot") +T.check(seated.focus[1] < seated.eye[1] - 1, + "and turns its gaze west, toward the focus it was seated against") +local wx2, wy2, wz2 = apply(seated.view, 150 - VRRig.FP_SCALE, 10, 200) +T.check(near(wx2, 0) and near(wy2, 0) and near(wz2, -1), + "the yawed view carries one metre west of the pivot to one metre ahead") + +-- the sky's RAY FAN round-trips the eye's own projection: the direction +-- the fan hands a canvas point looks along projects back to that very +-- point, through a rotated head AND a yawed mapping -- one sign wrong +-- anywhere here and the skybox paints sideways or upside down +local rayCam = VRRig.eyeCamera({ pos = { 0.2, 1.1, -0.4 }, + quat = { 0, s, 0, math.cos(math.pi / 4) } }, + fov, { 50, 0, 70 }, { 0, 0, 0 }, 10, + math.pi / 3) +local rm = Mat4.mul(Mat4.mul(Mat4.scale(1, -1, 1), rayCam.proj), rayCam.view) +local function rayFrac(u, v) + local sr = rayCam.skyRay + local d1 = sr.base[1] + u * sr.du[1] + v * sr.dv[1] + local d2 = sr.base[2] + u * sr.du[2] + v * sr.dv[2] + local d3 = sr.base[3] + u * sr.du[3] + v * sr.dv[3] + local x = rm[1] * d1 + rm[2] * d2 + rm[3] * d3 + local y = rm[5] * d1 + rm[6] * d2 + rm[7] * d3 + local ww = rm[13] * d1 + rm[14] * d2 + rm[15] * d3 + return x / ww * 0.5 + 0.5, y / ww * 0.5 + 0.5 +end +local fu, fv = rayFrac(0.3, 0.8) +T.check(near(fu, 0.3, 1e-4) and near(fv, 0.8, 1e-4), + "the sky's ray fan round-trips the eye's own projection") +local fu2, fv2 = rayFrac(0.9, 0.1) +T.check(near(fu2, 0.9, 1e-4) and near(fv2, 0.1, 1e-4), + "at every corner of the frame alike") + +-- ------- the hand prop: the pokedex rides the same mapping as the eyes +-- +-- propMatrix carries a hand pose through worldFromXr: an identity hand at +-- the LOCAL origin lands ON the pivot, offsets scale by px-per-metre, and +-- the battle mount's yaw turns the prop with the whole mapping. +local handPose = { pos = { 0, 0, 0 }, quat = { 0, 0, 0, 1 } } +local pm = VRRig.propMatrix(handPose, { 500, 20, 700 }, { 0, 0, 0 }, 10) +local hx, hy, hz = apply(pm, 0, 0, 0) +T.check(near(hx, 500) and near(hy, 20) and near(hz, 700), + "an identity hand at the origin puts the prop on the pivot") +local hx2, hy2, hz2 = apply(pm, 0.1, 0.2, 0) +T.check(near(hx2, 501) and near(hy2, 22) and near(hz2, 700), + "prop-local metres scale to world pixels through the mapping") +local pmYaw = VRRig.propMatrix(handPose, { 500, 20, 700 }, { 0, 0, 0 }, + 10, math.pi / 2) +local yx, yy, yz = apply(pmYaw, 0, 0, -1) +T.check(near(yx, 490) and near(yy, 20) and near(yz, 700), + "the battle mount's yaw turns the prop with the mapping: local " + .. "forward comes out west") + +-- and the pokedex module holds its shape headless: no frame until VR +-- places one, placement builds a model matrix, clear() takes it away +local Dex = run.loader.exports.DRAMATIC_SHAPE.lib.require("Pokedex") +T.eq(Dex.frame, nil, "no session, no pokedex frame") +Dex.place(handPose, { 500, 20, 700 }, { 0, 0, 0 }, 10) +T.check(Dex.frame ~= nil and type(Dex.frame.model) == "table" + and #Dex.frame.model == 16, + "placing the pokedex on a hand pose builds its world model matrix") +T.check(Dex.frame.tex == nil, + "and the screen stays dark until something is put on it") +T.check(pcall(Dex.draw), + "drawing headless is a clean no-op -- no meshes, no crash") +Dex.clear() +T.eq(Dex.frame, nil, "clear() takes the device away") +T.check(type(Dex.VOX) == "number" and Dex.VOX > 0, + "the voxel size is a named, tunable number") +T.check(near(Dex.TILT, -math.pi / 2), + "the device lies a full quarter turn forward, flush with the controller") + +-- the sprite lean override and the anchored sky's knobs exist, unset and +-- set respectively, and an unstaged world offers no battle mount +local VS_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelScene") +T.eq(VS_.spriteLean, nil, + "the sprite lean override ships unset -- the flat screen leans with the rung") +local Sky_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") +T.check(type(Sky_.ELEV_SPAN) == "number" and Sky_.ELEV_SPAN > 0, + "the anchored sky hangs its gradient over a fixed elevation span") +local OB_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") +T.eq(OB_.stage(), nil, "no staged fight, no battle mount") +T.eq(OB_.battle(), nil, "and no battle state for the UI panel to cut up") + +-- ------- the effects plane: solved like the camera was, anchors on cells +-- +-- fxCard's whole contract is that the classic layout's two slot marks land +-- on the two arena cells, so an effect authored at a slot bursts on the +-- mon standing in for it. +local BS_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleScene") +local fxArena = { player = { 96, 240 }, enemy = { 96, 192 } } +-- eyeless first (fxCard reads Voxel3D.eye at call time, and earlier +-- sections leave one behind): straight down the arena's axis the frame +-- is the fixed plane through both cells, marks exactly on them +local V3D_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local hadEye = V3D_.eye +V3D_.eye = nil +local fxm = BS_.fxCard(fxArena, 10, OB_.ANCHOR) +T.check(type(fxm) == "table" and #fxm == 16, "the effects plane has a model") +local pa, ea = OB_.ANCHOR.player, OB_.ANCHOR.enemy +local fpx, fpy, fpz = apply(fxm, pa[1] / 160 - 0.5, 1 - pa[2] / 144, 0) +T.check(near(fpx, 96, 1e-3) and near(fpy, 10, 1e-3) and near(fpz, 240, 1e-3), + "the player slot's mark lands on the player's cell, at the floor") +local fex, fey, fez = apply(fxm, ea[1] / 160 - 0.5, 1 - ea[2] / 144, 0) +T.check(near(fex, 96, 1e-3) and near(fey, 10, 1e-3) and near(fez, 192, 1e-3), + "and the enemy slot's mark on the enemy's cell") + +-- and BILLBOARDED at an eye: seated east of the arena the frame turns +-- square to it, and the marks still land on the cells -- this eye's own +-- rays are what pinned them +V3D_.eye = { 200, 20, 216 } +local fxb = BS_.fxCard(fxArena, 10, OB_.ANCHOR) +T.check(near(fxb[3], 1, 1e-3) and near(fxb[11], 0, 1e-3), + "the effects frame faces the eye in the east") +local bpx, bpy, bpz = apply(fxb, pa[1] / 160 - 0.5, 1 - pa[2] / 144, 0) +T.check(near(bpx, 96, 1e-3) and near(bpy, 10, 1e-3) and near(bpz, 240, 1e-3), + "billboarded, the player mark still sits over the player's cell") +local bex, bey, bez = apply(fxb, ea[1] / 160 - 0.5, 1 - ea[2] / 144, 0) +T.check(near(bex, 96, 1e-3) and near(bey, 10, 1e-3) and near(bez, 192, 1e-3), + "and the enemy mark over the enemy's") +V3D_.eye = hadEye + +-- the FLAT first-person rig's sky fan: a placed eye/focus camera through +-- viewProjection carries a ray fan of its own, and it round-trips that +-- projection exactly like the VR eyes' does -- the flat 1ST sky is a +-- skybox by the same math +local hadCam = V3D_.camera +V3D_.camera = { eye = { 100, 13, 200 }, focus = { 130, 18, 160 }, + fov = 1.1, up = { 0, 1, 0 } } +local pvp = V3D_.viewProjection(0, 0, 320, 288) +local psr = V3D_.skyRayLive +T.check(psr ~= nil, "a placed free-pitch camera carries a ray fan") +local function pFrac(u, v) + local d1 = psr.base[1] + u * psr.du[1] + v * psr.dv[1] + local d2 = psr.base[2] + u * psr.du[2] + v * psr.dv[2] + local d3 = psr.base[3] + u * psr.du[3] + v * psr.dv[3] + local x = pvp[1] * d1 + pvp[2] * d2 + pvp[3] * d3 + local y = pvp[5] * d1 + pvp[6] * d2 + pvp[7] * d3 + local ww = pvp[13] * d1 + pvp[14] * d2 + pvp[15] * d3 + return x / ww * 0.5 + 0.5, y / ww * 0.5 + 0.5 +end +local pu, pv = pFrac(0.25, 0.6) +T.check(near(pu, 0.25, 1e-4) and near(pv, 0.6, 1e-4), + "and it round-trips the placed camera's own projection") +V3D_.camera = hadCam +V3D_.skyRayLive = nil + +-- ------- VR owns the battle rows while it is on +local VRSet = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR").setting +VRSet:sync(true) +OB_.setting:sync(false) +OB_.backSetting:sync(true) +T.eq(OB_.enabled(), true, "VR on forces staged battles whatever the row says") +T.eq(OB_.backPinned(), false, "and holds back sprites off") +VRSet:sync(false) +T.eq(OB_.enabled(), false, "VR off hands the row back to its stored value") +OB_.setting:sync(true) +T.eq(OB_.backPinned(), true, "and back sprites return at theirs") +OB_.backSetting:sync(false) + +-- 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") +T.eq(type(VRMod.supported), "function", + "the platform gate exists -- off Windows the row is not offered at all") +T.eq(VRMod.supported(), true, + "and a headless run (no love.system) counts as supported, harmlessly") + +-- the loader search covers every install shape: the mod-relative path +-- first, the system name last, and (with a filesystem to ask) the real +-- mount and the save directory in between +local VRXR_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("VRXR") +local cands = VRXR_._loaderCandidates() +T.check(#cands >= 2, "the loader has candidates to try") +T.check(cands[1]:find("assets/vr/openxr_loader%.dll") ~= nil, + "the first is the mod's own path") +T.eq(cands[#cands], "openxr_loader", + "and the system search path is the last resort") +T.eq(type(VRMod.leave), "function", + "VR.leave stays as the programmatic door out -- no controller button " + .. "is wired to it") +end +vrRigSection() + +-- ------- the skybox's checker and glow are the sky's own, not the screen's +-- +-- The bands were already read by angle, but the DITHER between them kept +-- screen-cell parity: a world-fixed band edge sliding over a screen-fixed +-- checkerboard recomputes the pattern with every head motion -- the +-- shimmer. Now the ray path lays the checker (and the twilight glow's +-- rings) on azimuth/elevation cells, and these pin what it sends. +;(function() + local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") + local realGraphics, realImage = love.graphics, love.image + local sent = {} + local fakeShader = { + send = function(_, name, a, b, c, d) sent[name] = { a, b, c, d } end, + } + local function fakeImage(w, h) + return { + getWidth = function() return w end, + getHeight = function() return h end, + getDimensions = function() return w, h end, + setPixel = function() end, + setFilter = function() end, + setWrap = function() end, + } + end + love.image = { newImageData = function(w, h) return fakeImage(w, h) end } + love.graphics = { + getShader = function() return nil end, + setShader = function() end, + getDepthMode = function() return "lequal", true end, + setDepthMode = function() end, + setColor = function() end, + newShader = function() return fakeShader end, + newImage = function(data) return data end, + rectangle = function() end, + } + Sky.invalidate() -- rebuild the shader and ramp through the fakes + + local grad = { bands = { { 8, 8, 16 }, { 48, 64, 96 }, { 96, 128, 160 } } } + -- a level fan looking north: base + u*du + v*dv, spanning 2*atan(0.5) + -- both ways at depth 1 -- easy angles to pin the sends against + local fan = { base = { -0.5, 0.5, -1 }, du = { 1, 0, 0 }, + dv = { 0, -1, 0 } } + local span = 2 * math.atan(0.5) + local body = { x = 160, y = 40, dx = 0, dy = 0.5, dz = -1, + glowAmt = 0.5, glowColor = { 248, 224, 168 } } + T.eq(Sky.paint(320, 288, grad, nil, 7, body, nil, nil, fan), true, + "the skybox paints with a ray fan and a bodied glow") + T.check(sent.cellAng and sent.cellAng[1] > 0, + "the checker gets an ANGULAR cell: the dither grid is laid on " + .. "azimuth and elevation, so no head motion reslides it") + T.check(math.abs(sent.cellAng[1] - span * 7 / 288) < 1e-6, + "sized so the sky's grid matches the diorama's pixel grid on screen") + T.check(sent.glowDir ~= nil, "the glow gets the sun's world direction") + local gd = sent.glowDir[1] + local gl = math.sqrt(gd[1] ^ 2 + gd[2] ^ 2 + gd[3] ^ 2) + T.check(math.abs(gl - 1) < 1e-6 and math.abs(gd[2] - 0.4472) < 1e-3, + "normalised, the hour's own") + T.check(math.abs(sent.glowInvA[1] - 1 / (span * Sky.GLOW_REACH)) < 1e-6, + "and an angular reach cut from the view the way the pixel reach was") + T.eq(sent.glowPos, nil, + "the screen-space glow stays the flat frame's path alone") + + sent = {} + fakeShader.send = function(_, name, a, b, c, d) sent[name] = { a, b, c, d } end + local bare = { x = 160, y = 40, glowAmt = 0.5, + glowColor = { 248, 224, 168 } } + T.eq(Sky.paint(320, 288, grad, nil, 7, bare, nil, nil, fan), true, + "a body with no world direction still paints") + T.eq(sent.glowAmt[1], 0, + "but offers no glow -- there is no direction to measure angles against") + T.eq(sent.glowDir, nil, "and no direction is sent") + + love.graphics, love.image = realGraphics, realImage + Sky.invalidate() + + -- ------- a live headset holds every menu inside the GB frame + -- + -- The engine's zoom-aware anchoring docks the START menu to the WINDOW's + -- edge; both VR screens crop the window to the GB frame, so a docked + -- menu is cropped away with the border it hugged. The wrap answers the + -- engine's own uiAnchorsHeldInStack predicate with yes while a headset + -- is live, which blits every menu where it was drawn -- the START + -- menu's slot is already flush with the frame's right edge. + local Game = require("src.core.Game") + local VRMod = run.loader.exports.DRAMATIC_SHAPE.lib.require("VR") + T.eq(Game.dramaticShapeAnchorHold, true, + "the anchor-hold wrap installed at load, once") + T.eq(Game.uiAnchorsHeldInStack({ states = {} }), false, + "with no headset the engine's own answer stands: an empty stack docks") + local innerActive = VRMod.active + VRMod.active = function() return true end + T.eq(Game.uiAnchorsHeldInStack({ states = {} }), true, + "a live headset holds anchors -- menus stay inside the GB frame, " + .. "which is all either VR screen shows") + VRMod.active = innerActive + T.eq(Game.uiAnchorsHeldInStack({ states = {} }), false, + "and hands the predicate back when the headset is gone") + T.eq(Game.uiAnchorsHeldInStack({ states = { { holdsUIAnchors = true } } }), + true, "a self-composing state still holds them on its own") + + -- and the panel's route to the headset is the SCALED region blit: the + -- pixel-for-pixel copy ran the GB frame off a swapchain image smaller + -- than the window (fullscreen cut the menu), so the scaled seam must + -- exist for updateQuad to reach for first + local VRGL_ = run.loader.exports.DRAMATIC_SHAPE.lib.require("VRGL") + T.eq(type(VRGL_.copyFrontRegionToTexture), "function", + "the letterbox reaches the panel scaled, not pixel-for-pixel") + + -- ------- the stick click IS the "3" key + -- + -- Left stick click makes exactly the step the key (and SELECT) makes: + -- the very same function, handed across from main.lua, so the ladder + -- walk, the FULL step-over and the TILT/GBC FX clearing can never + -- drift. Through the same fixture the key tests lend. + T.eq(VRMod.cycleVoxel ~= nil, true, + "main.lua hands its cycleVoxel to the stick click") + local hadStack2, hadOw2 = Game.stack, Game.overworld + local hadSave2, hadWrite2 = Game.save, Game.writeOptions + Game.stack, Game.overworld = keyGame.stack, keyGame.overworld + Game.save, Game.writeOptions = keyGame.save, keyGame.writeOptions + Pipelines.setLevel("voxel", 0) + VRMod.stepView() + T.eq(Pipelines.levelLabel("voxel"), "15", + "the stick click steps the VOXEL ladder exactly as 3 does") + VRMod.stepView() + T.eq(Pipelines.levelLabel("voxel"), "35", "and keeps walking it") + keyGame.save.options.tilt = 2 + require("src.render.Tilt").setLevel(2) + VRMod.stepView() + T.eq(keyGame.save.options.tilt, 0, + "each click clears TILT in the save, exactly as each keypress does") + Game.stack, Game.overworld = hadStack2, hadOw2 + Game.save, Game.writeOptions = hadSave2, hadWrite2 + Pipelines.setLevel("voxel", 0) +end)() + Pipelines.reset() run.release() diff --git a/tests/vr_probe.lua b/tests/vr_probe.lua new file mode 100644 index 0000000..356f764 --- /dev/null +++ b/tests/vr_probe.lua @@ -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