mirror of
https://github.com/mitch030504/Wiicompiled_VR_Frame.git
synced 2026-10-06 01:00:14 +02:00
Merge remote-tracking branch 'upstream/openxr-work' into codex/standalone-selective-integration
# Conflicts: # docs/quest-port.md
This commit is contained in:
commit
0e52cfb726
141 files changed
+14235
-1090
No files matched your search
+1
-1
@@ -17,7 +17,7 @@ Build from the repository root on Windows:
|
||||
|
||||
```powershell
|
||||
./Launcher/Build-Installer.ps1 -OutputDirectory Launcher/dist-vr
|
||||
./Launcher/Verify-Release.ps1 -Tag v0.2.41 -SetupPath (Resolve-Path Launcher/dist-vr/WiiCompiled-Setup.exe)
|
||||
./Launcher/Verify-Release.ps1 -Tag v0.2.43 -SetupPath (Resolve-Path Launcher/dist-vr/WiiCompiled-Setup.exe)
|
||||
./Launcher/dist-vr/WiiCompiled-Setup.exe --self-test
|
||||
./Launcher/Test-Recompilation.ps1 -StageDirectory build/vr-synthetic-validation
|
||||
dotnet test translator/Translator.sln -c Release
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Builds the pinned Dawn DLL with Aurora's native Windows Vulkan/OpenXR bridge.
|
||||
# Run on a maintainer machine with VS 2022 C++ tools, CMake, Git and Python 3.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$WorkDirectory = (Join-Path $PSScriptRoot 'artifacts\dawn-vulkan-build'),
|
||||
[string]$Destination = (Join-Path $PSScriptRoot 'artifacts\dawn-vulkan'),
|
||||
# Source of the DirectX shader compiler DLLs, which Dawn's install does not produce.
|
||||
[string]$StockDawnDirectory = (Join-Path $PSScriptRoot 'artifacts\dependencies\dawn_prebuilt'),
|
||||
[string]$Python = 'python',
|
||||
[int]$Jobs = 8
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version 3.0
|
||||
$revision = '13abc3bc8ea2d3c2050f9e77a12d012108ceee24'
|
||||
$archiveHash = '713bea5b92d4f6c5175752fd7cbf1c3c5ce36598ff5dd98685d8a1216614ebba'
|
||||
$WorkDirectory = [IO.Path]::GetFullPath($WorkDirectory)
|
||||
$Destination = [IO.Path]::GetFullPath($Destination)
|
||||
[IO.Directory]::CreateDirectory($WorkDirectory) | Out-Null
|
||||
$archive = Join-Path $WorkDirectory 'dawn-source.tar.gz'
|
||||
if (-not (Test-Path -LiteralPath $archive)) {
|
||||
Invoke-WebRequest "https://github.com/google/dawn/archive/$revision.tar.gz" -OutFile $archive -UseBasicParsing
|
||||
}
|
||||
if ((Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant() -ne $archiveHash) {
|
||||
throw 'Dawn source archive does not match the pinned SHA-256.'
|
||||
}
|
||||
$source = Join-Path $WorkDirectory "dawn-$revision"
|
||||
if (-not (Test-Path -LiteralPath $source)) {
|
||||
# Single quotes inside: Windows PowerShell drops the inner double quotes when it builds a
|
||||
# native command line, and Python then reads filter=data as a name.
|
||||
& $Python -c "import sys,tarfile; tarfile.open(sys.argv[1]).extractall(sys.argv[2], filter='data')" $archive $WorkDirectory
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Dawn source extraction failed (Python 3.12+ required).' }
|
||||
}
|
||||
$patch = Join-Path $PSScriptRoot '..\aurora-main\patches\dawn\apply.py'
|
||||
& $Python $patch $source
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Applying the Aurora Dawn bridge failed.' }
|
||||
$build = Join-Path $WorkDirectory 'build'
|
||||
& cmake -S $source -B $build -G 'Visual Studio 17 2022' -A x64 `
|
||||
"-DPython3_EXECUTABLE=$Python" -DDAWN_FETCH_DEPENDENCIES=ON `
|
||||
-DDAWN_BUILD_MONOLITHIC_LIBRARY=SHARED -DDAWN_ENABLE_INSTALL=ON `
|
||||
-DDAWN_BUILD_SAMPLES=OFF -DDAWN_BUILD_TESTS=OFF -DDAWN_BUILD_BENCHMARKS=OFF `
|
||||
-DTINT_BUILD_TESTS=OFF -DTINT_BUILD_CMD_TOOLS=OFF -DDAWN_USE_GLFW=OFF `
|
||||
-DDAWN_ENABLE_D3D11=OFF -DDAWN_ENABLE_D3D12=ON -DDAWN_ENABLE_VULKAN=ON `
|
||||
-DDAWN_ENABLE_DESKTOP_GL=OFF -DDAWN_ENABLE_OPENGLES=OFF "-DCMAKE_INSTALL_PREFIX=$Destination"
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Dawn configuration failed.' }
|
||||
& cmake --build $build --config Release --parallel $Jobs
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Dawn build failed.' }
|
||||
& cmake --install $build --config Release
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Dawn installation failed.' }
|
||||
$dll = Join-Path $Destination 'bin\webgpu_dawn.dll'
|
||||
if (-not (Test-Path -LiteralPath $dll)) { throw "Dawn DLL missing: $dll" }
|
||||
# Dawn's own install stages only its DLL, but this package replaces dawn_prebuilt wholesale and
|
||||
# LocalBuild.ps1 copies the DirectX shader compiler out of it into the product. Dawn loads those
|
||||
# two at run time rather than importing them, so leaving them out breaks D3D12 shader compilation
|
||||
# long after installation instead of failing here.
|
||||
foreach ($name in @('dxcompiler.dll', 'dxil.dll')) {
|
||||
if (Test-Path -LiteralPath (Join-Path $Destination "bin\$name")) { continue }
|
||||
$stock = Join-Path $StockDawnDirectory "bin\$name"
|
||||
if (-not (Test-Path -LiteralPath $stock)) {
|
||||
throw "The pinned Dawn package has no $name; point -StockDawnDirectory at the prepared dawn_prebuilt."
|
||||
}
|
||||
Copy-Item -LiteralPath $stock -Destination (Join-Path $Destination 'bin')
|
||||
}
|
||||
[ordered]@{
|
||||
SourceRevision = $revision
|
||||
AuroraVulkanAbi = 1
|
||||
DllSha256 = (Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $Destination 'aurora-vulkan.json') -Encoding UTF8
|
||||
Write-Host "Custom Dawn package ready: $Destination"
|
||||
@@ -99,7 +99,7 @@ Assert-File (Join-Path $portableTools 'Ninja\ninja.exe') 'Portable Ninja'
|
||||
# to compile (launcher/Prepare-NativePrebuilt.ps1).
|
||||
# Kept in step with InstalledLayout.DependencyNames by Test-PinnedFacts.ps1: the installed host
|
||||
# refuses to call a toolkit complete unless every one of these directories is present.
|
||||
$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','libusb','native_prebuilt','openxr','png','SDL','sqlite3','tracy','xxhash','zlib','zstd')
|
||||
$requiredDependencies = @('abseil-cpp','cppwinrt','dawn_prebuilt','fmt','freetype','imgui','libusb','native_prebuilt','openxr','png','SDL','sqlite3','tracy','vulkan_headers','xxhash','zlib','zstd')
|
||||
$missingSources = @($requiredDependencies | Where-Object { $_ -ne 'native_prebuilt' } |
|
||||
Where-Object { -not (Test-Path -LiteralPath (Join-Path $dependencySources $_) -PathType Container) })
|
||||
if ($missingSources.Count -gt 0) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Version>0.2.41</Version>
|
||||
<Version>0.2.43</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -9,7 +9,9 @@ param(
|
||||
[string]$Destination,
|
||||
# Windows metadata source for cppwinrt.exe: 'local' (this machine's WinMetadata), 'sdk', or an
|
||||
# installed SDK version such as 10.0.26100.0.
|
||||
[string]$CppWinRtInput = 'local'
|
||||
[string]$CppWinRtInput = 'local',
|
||||
# Output of Build-DawnVulkan.ps1. Rebuild native_prebuilt after changing Dawn.
|
||||
[string]$DawnVulkanPackage
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -27,6 +29,11 @@ $runtimeCMake = Join-Path $repoRoot 'runtime\CMakeLists.txt'
|
||||
# FETCHCONTENT_SOURCE_DIR_<UPPERCASE NAME> (NativeBuildFlags.ps1), so the names are the declared
|
||||
# FetchContent names, not the upstream project names.
|
||||
$packages = @(
|
||||
[pscustomobject]@{
|
||||
Name = 'vulkan_headers'; File = 'vulkan-headers-015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz'
|
||||
Uris = @('https://github.com/KhronosGroup/Vulkan-Headers/archive/015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz')
|
||||
Pins = @(@{ File = $runtimeCMake; Text = 'Vulkan-Headers/archive/015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz' })
|
||||
},
|
||||
[pscustomobject]@{
|
||||
Name = 'SDL'; File = 'SDL3-3.4.4.tar.gz'
|
||||
Uris = @('https://github.com/libsdl-org/SDL/releases/download/release-3.4.4/SDL3-3.4.4.tar.gz')
|
||||
@@ -175,6 +182,22 @@ function Expand-Package([string]$Archive, [string]$Target) {
|
||||
foreach ($package in $packages) {
|
||||
Assert-Pinned $package
|
||||
$target = Join-Path $Destination $package.Name
|
||||
if ($package.Name -eq 'dawn_prebuilt' -and $DawnVulkanPackage) {
|
||||
$custom = [IO.Path]::GetFullPath($DawnVulkanPackage)
|
||||
$manifest = Get-Content -LiteralPath (Join-Path $custom 'aurora-vulkan.json') -Raw | ConvertFrom-Json
|
||||
$dll = Join-Path $custom 'bin\webgpu_dawn.dll'
|
||||
if ($manifest.SourceRevision -ne '13abc3bc8ea2d3c2050f9e77a12d012108ceee24' -or
|
||||
$manifest.AuroraVulkanAbi -ne 1 -or
|
||||
(Get-FileHash -LiteralPath $dll -Algorithm SHA256).Hash.ToLowerInvariant() -ne $manifest.DllSha256) {
|
||||
throw 'Custom Dawn package provenance or DLL hash does not match.'
|
||||
}
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
throw "Use a fresh dependency destination for custom Dawn: $target already exists."
|
||||
}
|
||||
Copy-Item -LiteralPath $custom -Destination $target -Recurse
|
||||
Write-Host 'Prepared custom Dawn with Windows Vulkan OpenXR support'
|
||||
continue
|
||||
}
|
||||
if (Test-Path -LiteralPath $target -PathType Container) { continue }
|
||||
Expand-Package (Get-Archive $package) $target
|
||||
Write-Host "Prepared $($package.Name)"
|
||||
|
||||
@@ -27,7 +27,7 @@ internal static class InstalledLayout
|
||||
public static readonly string[] DependencyNames =
|
||||
[
|
||||
"abseil-cpp", "cppwinrt", "dawn_prebuilt", "fmt", "freetype", "imgui", "libusb", "native_prebuilt",
|
||||
"openxr", "png", "SDL", "sqlite3", "tracy", "xxhash", "zlib", "zstd"
|
||||
"openxr", "png", "SDL", "sqlite3", "tracy", "vulkan_headers", "xxhash", "zlib", "zstd"
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
WiiCompiled has an opt-in OpenXR rendering path. The first functional backend is Windows D3D12.
|
||||
It asks the OpenXR runtime for the required GPU before Aurora creates Dawn, then copies each eye
|
||||
on that same D3D12 device and queue into the acquired OpenXR swapchain images. Eye submission
|
||||
stays on the GPU; there is no CPU texture readback and no second graphics device.
|
||||
stays on the GPU; there is no CPU texture readback and no second graphics device. Windows Vulkan is
|
||||
an opt-in second binding built on the same design: the OpenXR runtime creates Dawn's Vulkan instance
|
||||
and device (`XR_KHR_vulkan_enable2`) and eyes are copied on that same queue. It needs a custom Dawn
|
||||
build; see [Windows Vulkan](#windows-vulkan).
|
||||
|
||||
This is an experimental renderer, not yet a release-ready VR mode.
|
||||
|
||||
@@ -11,14 +14,17 @@ This is an experimental renderer, not yet a release-ready VR mode.
|
||||
|
||||
- A Windows OpenXR runtime selected as the system's active runtime.
|
||||
- A connected headset supported by that runtime.
|
||||
- A D3D12-capable GPU and driver accepted by both OpenXR and Dawn.
|
||||
- A D3D12-capable GPU and driver accepted by both OpenXR and Dawn, or for the opt-in Vulkan
|
||||
binding a Vulkan 1.1+ driver plus the custom Dawn described under [Windows Vulkan](#windows-vulkan).
|
||||
- A build made with `MKW_ENABLE_OPENXR=ON`, which defaults on for Windows and off elsewhere while
|
||||
the Vulkan bridge remains capability-gated.
|
||||
|
||||
For managed installation, use [WheelWizard VR](https://github.com/iChris4/WheelWizard_VR/releases/latest)
|
||||
and enable **Settings → Other → WiiCompiled (beta) → Enable WiiCompiled OpenXR VR (beta)**.
|
||||
The launcher sets `vr.enabled=true`, `vr.required=false`, and `video.graphics_api="d3d12"` before
|
||||
each VR launch, preserving other preferences. Its portable configuration lives at
|
||||
The launcher sets `vr.enabled=true` and `vr.required=false` before each VR launch, preserving other
|
||||
preferences. Its **Graphics API** row picks the binding, DirectX 12 or Vulkan, and keeps that choice;
|
||||
any other value is repaired to `d3d12` at launch, because OpenXR refuses the rest. Its portable
|
||||
configuration lives at
|
||||
`RecompVR/UserData/Config.toml` beneath WheelWizard's data folder. Normal graphics settings remain
|
||||
in `Recomp/UserData/Config.toml`. Both backends use the normal installation's effective NAND.
|
||||
|
||||
@@ -39,19 +45,29 @@ world_units_per_meter = 500.0
|
||||
hud_distance_meters = 2.0
|
||||
hud_width_meters = 2.4
|
||||
hud_virtual_screen = true
|
||||
flat_screen = false
|
||||
stop_at_display_copy = true
|
||||
skip_copy_clears = true
|
||||
first_person = false
|
||||
first_person_units_per_meter = 30.0
|
||||
first_person_head_up_meters = 3.0
|
||||
first_person_toggle_click = true
|
||||
first_person_seat = "cockpit"
|
||||
cockpit_units_per_meter = 100.0
|
||||
first_person_units_per_meter = 50.0
|
||||
first_person_head_up_meters = 1.5
|
||||
first_person_head_forward_meters = 0.0
|
||||
first_person_head_right_meters = 0.0
|
||||
first_person_hide_driver = true
|
||||
first_person_hidden_model = 0
|
||||
first_person_rotation = "yaw"
|
||||
first_person_rotation = "yaw_pitch"
|
||||
steering_wheel = true
|
||||
native_steering_wheel = true
|
||||
hand_steering = true
|
||||
performance_level = "boost"
|
||||
```
|
||||
|
||||
The seven `wheel_*` hand-steering tuning keys are described in
|
||||
[Steering wheel and hand steering](#steering-wheel-and-hand-steering).
|
||||
|
||||
To play this installation on the desktop instead, set `enabled = false`, close the game completely,
|
||||
and start it again. These settings are read only at launch. The in-game F10 settings bar also
|
||||
exposes the enable switch, but a restart is still required.
|
||||
@@ -124,6 +140,24 @@ and 0.8 on the Quest, whose mobile GPU needs the headroom.
|
||||
launch and govern both the menu screen and the in-race 2D screen, so 2D content keeps its place
|
||||
across the transition. `hud_virtual_screen` decides whether the race's 2D layer uses that screen;
|
||||
it is live and can be flipped from the F10 settings bar.
|
||||
`flat_screen` (default off) keeps races on that same flat screen, as the menus are, instead of
|
||||
immersive stereo: the whole race, 3D world and HUD alike, is the game's own picture on the quad, as
|
||||
in DolphinXR's Flat Screen mode. The first-person camera, hand steering, the lean-back angle, VR
|
||||
frame interpolation and `hud_virtual_screen` shape only the immersive race view, so none of them
|
||||
apply while it is on; the right-thumbstick first-person toggle is ignored rather than changing the saved
|
||||
setting. It is live, as **F10 → VR → Flat Screen mode** (the headset panel's VR tab) and the Quest
|
||||
launcher's Settings page, and turning it on or off mid-race switches on the next frame through the
|
||||
presentation policy's safety generation.
|
||||
`passthrough` (Quest only, default on) shows the room through the headset's cameras around the
|
||||
menu screen and every other virtual screen, instead of black: an `XR_FB_passthrough`
|
||||
reconstruction layer submitted under the screen's quad, as PPSSPP VR does, with the blend mode
|
||||
left `OPAQUE`. An immersive race never shows it, and the cameras are paused for the race; a race in
|
||||
`flat_screen` is a virtual screen like the menus, so the room shows around it too. It is
|
||||
live, from the headset panel's VR tab or the launcher's Settings page. The app declares
|
||||
`com.oculus.feature.PASSTHROUGH`, without which Horizon OS composites nothing for that layer.
|
||||
So that the room frames the picture rather than black bands, the Quest's menu quad shows only the
|
||||
part of its eye-sized image Aurora draws into (the desktop snapshot, and the in-eye settings
|
||||
panel's rectangle), at the same size per pixel, so nothing moves.
|
||||
`stop_at_display_copy` ends eye replay at the final `GXCopyDisp`, matching the frame shown on the
|
||||
desktop. `skip_copy_clears` independently suppresses the EFB reset performed after a copy. Both
|
||||
default on and can be changed live from the F10 settings bar for diagnostics.
|
||||
@@ -143,6 +177,17 @@ thread (`runtime/src/vr/openxr_input.cpp`), which feeds a virtual SDL gamepad th
|
||||
to a port like any other. `controller_mode` decides what the game finds on that port, and is live
|
||||
from **F10 > VR > VR controllers**; the game sees a change as a controller reconnection.
|
||||
|
||||
The pacing thread only publishes that gamepad; the game thread writes it to SDL where it already
|
||||
polls controllers (`OpenXRApplyControllerState`, called from `PAD__Read_HLE` and the overlay's
|
||||
per-frame work). SDL holds its joystick lock for the length of a device enumeration, and the
|
||||
Bluetooth Wii Remote rescan (**F10 > Controller settings > Keep scanning**, `wii_continuous_scan`,
|
||||
off by default) makes SDL close and reopen every HID device twice per scan. Measured at 15 ms on a
|
||||
plain desk and over 200 ms with a Lighthouse setup's dongles on the bus, which is why the pacing
|
||||
thread must not wait on it: a frame it holds open that long costs the compositor every display slot
|
||||
that passes, and `[xr-diag]` reports it as a stalled, late frame with skipped display slots. That
|
||||
rescan still pauses the *game* thread for as long, so leave it off unless a real Wii Remote is in
|
||||
use.
|
||||
|
||||
`"wii_remote"`, the default, presents them as a Wii Remote with a Nunchuk, the way DolphinXR's
|
||||
OpenXR Wii Remote does, with buttons adapted from its default `OpenXR Wii Remote` profile for the
|
||||
Touch controllers. The port is served through KPAD like a Bluetooth remote
|
||||
@@ -152,19 +197,23 @@ Touch controllers. The port is served through KPAD like a Bluetooth remote
|
||||
| --- | --- |
|
||||
| Right A | A |
|
||||
| Right trigger | B |
|
||||
| Right B | C (look behind) |
|
||||
| Right stick up / down | 1 / 2 |
|
||||
| Left X | − |
|
||||
| Left menu | + |
|
||||
| Left stick | Nunchuk stick |
|
||||
| Left trigger | Z |
|
||||
| Left grip | C |
|
||||
| Left Y | Settings panel (not a Wii button) |
|
||||
| Either grip | Takes hold of the wheel (not a Wii button) |
|
||||
| Right stick click | First-person camera on / off (not a Wii button) |
|
||||
| Right controller motion and aim | Wii Remote accelerometer and pointer |
|
||||
| Left controller motion | Nunchuk accelerometer |
|
||||
|
||||
Analog inputs count as pressed past half travel. Right B, right stick left / right and the stick
|
||||
clicks are unbound, and no controller button presses HOME. The game's Wii Remote rumble vibrates
|
||||
both controllers, subject to the ordinary controller-vibration switch.
|
||||
Analog inputs count as pressed past half travel. The grips, right stick left / right and the left
|
||||
stick click press no Wii button, and nothing presses HOME. C sits on right B rather than a grip
|
||||
because hand steering holds a grip down for a whole corner, and C is the game's look-behind. The
|
||||
game's Wii Remote rumble vibrates both controllers, subject to the ordinary controller-vibration
|
||||
switch.
|
||||
|
||||
**Motion.** Each XR frame the aim and grip poses are located at the measured current time
|
||||
(`XR_KHR_win32_convert_performance_counter_time`, `XR_KHR_convert_timespec_time` on Android), not
|
||||
@@ -181,7 +230,7 @@ with the screen the renderer is showing, and the point it meets is where the cur
|
||||
is nothing to recenter. On the menu screen that is the quad layer, `hud_width_meters` across with
|
||||
the eye texture's aspect, and the pointer spans the game picture inside it (Aurora letterboxes the
|
||||
desktop image into the quad and the picture into the desktop image, so a 4:3 picture keeps its
|
||||
pillarboxes). During a race it is the 2D layer's screen, `hud_distance_meters` ahead of the latched
|
||||
pillarboxes; the Quest crops the quad to the desktop image without changing where it is). During a race it is the 2D layer's screen, `hud_distance_meters` ahead of the latched
|
||||
race origin and turned by the lean-back angle, with the picture's aspect. With
|
||||
`hud_virtual_screen = false` the race's 2D layer has no fixed place and the pointer is off. The
|
||||
game's own pointer switch (`KPADEnableDpd` / `KPADDisableDpd`) is honoured as well.
|
||||
@@ -197,11 +246,17 @@ Raw IR camera dots in `KPADGetUnifiedWpadStatus` stay invalid; the game reads th
|
||||
**Settings in the headset.** Left Y opens the settings panel described below; while it is open the
|
||||
controllers operate the panel and the game sees them idle.
|
||||
|
||||
**Hand steering.** With `hand_steering` on, in the first-person cockpit, a grip squeezed near the
|
||||
steering wheel takes hold of it, and while held the wheel steers through the Nunchuk stick's X axis;
|
||||
see [Steering wheel and hand
|
||||
steering](#steering-wheel-and-hand-steering). Turning the wheel moves the controllers, and the game's
|
||||
own motion detection still reads them, so a sharp enough turn can read as a shake.
|
||||
|
||||
`"gamepad"` keeps the controllers one ordinary gamepad read through PAD as a GameCube controller:
|
||||
A/B → South/East, X/Y → West/North, index triggers → trigger axes, grips → shoulders, thumbsticks
|
||||
→ sticks (clicks → stick buttons), left menu → Start. Every binding in the F10 controller menu
|
||||
applies. Left Y is GameCube Y here, so clicking both thumbsticks together opens the settings panel
|
||||
instead.
|
||||
instead. The right thumbstick click on its own still toggles the first-person camera.
|
||||
|
||||
Bindings are suggested for `oculus/touch_controller` (Quest 2, 3 and Pro) and
|
||||
`khr/simple_controller`. `mkw_vr_wii_remote_tests` checks the accelerometer frame, the pointer
|
||||
@@ -236,24 +291,75 @@ of you.
|
||||
How it is drawn: `settings_overlay.cpp` builds the panel with a second Dear ImGui context of its own,
|
||||
a 1440 × 1080 canvas at twice the desktop menu's scale with its own font atlas, fed by the pointer
|
||||
that `openxr_input.cpp` publishes through `vr/openxr_settings_panel.h`. Aurora renders that draw data
|
||||
into a panel texture once per sealed frame and lays it over each eye after the eye is finished
|
||||
(`aurora-main/lib/stereo_overlay.cpp`): through the eye's frustum and `viewFromCenter` onto the
|
||||
screen rectangle for an immersive eye (including headset-rate interpolated eyes, which reuse the
|
||||
texture), and as a centred rectangle on a virtual-screen eye image. The eye images the OpenXR
|
||||
backends already submit carry it, so no extra swapchain or composition layer is involved. The
|
||||
ImGui backend keeps a single projection uniform, so the panel's pass is submitted on its own command
|
||||
buffer before the desktop's ImGui pass of the same frame is recorded.
|
||||
into a panel texture once per sealed frame (`aurora-main/lib/stereo_overlay.cpp`). The ImGui backend
|
||||
keeps a single projection uniform, so the panel's pass is submitted on its own command buffer before
|
||||
the desktop's ImGui pass of the same frame is recorded.
|
||||
|
||||
The panel is shown as a compositor quad layer of its own, submitted over the scene's projection or
|
||||
menu quad layer. The compositor samples the 1440 × 1080 canvas directly, so its text stays sharp
|
||||
whatever `render_scale` gives the eyes. Every backend (D3D12, Windows Vulkan, Quest) makes the
|
||||
panel's swapchain pair the first time the panel opens (two 1440 × 1080 swapchains, plus two shared
|
||||
buffers on the Quest) and keeps it for the session. Until then nothing is allocated, and while the
|
||||
panel is closed nothing is copied or submitted. While it is open, each frame hands Aurora one more
|
||||
target after the eyes: the stereo bridge copies the panel texture into it with the eyes (or a
|
||||
transparent image on a frame where the panel is not drawn). The layer follows the eyes' swapchain
|
||||
pairing: the image a frame wrote is shown only once that frame is submitted, so a cancelled frame
|
||||
never shows an unwritten panel. The quad hangs exactly where the pointer's hits are tested
|
||||
(`SettingsPanelScreen` in `openxr_integration.cpp`). ImGui's premultiplied output is blended with
|
||||
`XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT`, and Aurora leaves the panel out of the eyes
|
||||
(`aurora_set_stereo_panel_layer`).
|
||||
|
||||
If a backend cannot make the panel's swapchains, it logs that once and the panel is drawn into the
|
||||
eye images instead: through the eye's frustum and `viewFromCenter` onto the screen rectangle for an
|
||||
immersive eye (including headset-rate interpolated eyes, which reuse the texture), and as a centred
|
||||
rectangle on a virtual-screen eye image. On the Quest, `adb shell setprop
|
||||
debug.wiicompiled.panel_layer 0` switches to that path at run time, to compare the two.
|
||||
|
||||
Measured on a Quest 3 (base game, a Grand Prix start with the player idle, `render_scale = 0.8`,
|
||||
60 FPS, eight interleaved rounds per state), the layer costs nothing while the panel is closed. While
|
||||
it is open, the app's GPU time is 10.5 ms per frame with the layer, against 9.7 ms drawn into the eyes
|
||||
(9.4 ms closed). GPU load is 74% against 67%, and the compositor's time 1.05 ms against 0.75 ms. Game
|
||||
and headset frame rates did not change. The compositor redraws the layer at display rate, so the
|
||||
panel stays steady even when the game drops frames.
|
||||
|
||||
`mkw_vr_settings_panel_tests` covers the panel button in both controller modes, the release latch,
|
||||
selection, scrolling and the canvas mapping; `gx_fifo_tests` covers where the panel lands in each eye.
|
||||
selection, scrolling and the canvas mapping; `gx_fifo_tests` covers where the panel lands in each eye
|
||||
on the fallback path. `mkw_openxr_replay_tests` and `mkw_openxr_vulkan_replay_tests` cover the layer:
|
||||
nothing made before the panel opens, the panel image of a cancelled frame never shown, no layer while
|
||||
the panel is closed or has no place yet, and render-first pacing.
|
||||
|
||||
## The first-person camera
|
||||
|
||||
By default the headset sits where Mario Kart's own chase camera sits, and `world_units_per_meter`
|
||||
of 500 presents the race as a small diorama on a table. Turning on `first_person` moves the camera
|
||||
to the local driver's head instead, and switches the world scale to
|
||||
`first_person_units_per_meter`, whose default of 30 is what makes the race read life-size from the
|
||||
seat. It is a matter of taste rather than a property of the game, so the F10 bar exposes it.
|
||||
to the local driver's head instead, at one of two seats:
|
||||
|
||||
- `first_person_seat = "cockpit"`, the default, sits you at the driver's own eyes, behind the
|
||||
steering wheel, at a life-size scale, so the wheel or handlebar is within reach of your hands.
|
||||
The eye is measured once per race from the character's head bone, while the kart drives straight,
|
||||
undamaged and at normal size, and then frozen; until then the bind pose, or the vehicle's authored
|
||||
seat height, stands in. It is kept at least 0.45 m behind the wheel so a long face or a
|
||||
leaned-forward riding pose cannot put it over the controls. The world scale is
|
||||
`cockpit_units_per_meter` (default 100) multiplied by the character's eye height over 100 units,
|
||||
so tall characters sit at a comparable height, and by the player's current size, so a lightning
|
||||
strike or a mega mushroom resizes the view, the wheel and the grab reach together. The seat
|
||||
follows the simulation's position and driving direction, never the animated chassis, so damage
|
||||
spins and tricks do not throw it around.
|
||||
- `first_person_seat = "custom"` places the head at `first_person_head_up_meters` and its two
|
||||
companions in the kart's own frame, at `first_person_units_per_meter`.
|
||||
|
||||
Both are a matter of taste rather than properties of the game, so the F10 bar exposes them.
|
||||
|
||||
**Toggling it from a controller.** Clicking the right thumbstick turns first person on or off
|
||||
exactly as the F10 checkbox does, and the choice is saved the same way. It works on the VR
|
||||
controllers in either presentation (the right controller gives a short tick), and on any other
|
||||
gamepad while VR is running. A click counts on release, and only if the left thumbstick stayed up
|
||||
and the settings panel stayed closed throughout, so clicking both thumbsticks to open the panel in
|
||||
gamepad mode never toggles the camera. A gamepad whose right thumbstick click is bound to a
|
||||
GameCube control on its port, as a button or in an input expression, keeps it for the game instead.
|
||||
Toggled in a menu, the change applies from the next race. `first_person_toggle_click = false`, or
|
||||
the F10 checkbox under the camera toggle, turns the click off. `mkw_vr_camera_toggle_tests` covers
|
||||
the click rule.
|
||||
|
||||
The kart is selected through the game's local-screen-to-racer mapping, including online races
|
||||
where your racer is not slot zero. First person requires a locally controlled racer; spectating
|
||||
@@ -267,20 +373,26 @@ own per-eye delta. The kart's *physics* pose is used deliberately, not the anima
|
||||
animated frame would bob and lurch the camera.
|
||||
|
||||
`first_person_rotation` decides where the view's orientation comes from, mirroring DolphinXR's
|
||||
camera-anchor modes. `"yaw"`, the default, keeps the horizon level through a chase-camera tilt or a
|
||||
banked corner. `"yaw_pitch"` adds the kart's climb, so a slope or a wheelie tips the view while a
|
||||
banked corner still never rolls it. `"full"` takes the kart's whole orientation, banking included.
|
||||
camera-anchor modes. `"yaw"` keeps the horizon level through a chase-camera tilt or a banked corner.
|
||||
`"yaw_pitch"`, the default, adds the kart's climb, so a slope or a wheelie tips the view while a
|
||||
banked corner still never rolls it: sitting in the cockpit, the vehicle's own climb reads as the
|
||||
ground rising rather than as the view tipping. `"full"` takes the kart's whole orientation, banking included.
|
||||
All three are the same construction from a forward and an up axis, differing only in which pair
|
||||
they take: pairing a forward with world up is what removes roll. The headset always adds free look
|
||||
on top of whichever is chosen, and only the translation onto the head is common to all three.
|
||||
|
||||
The head's place in the kart is `first_person_head_up_meters` and its two companions, measured in
|
||||
the kart's own frame; the F10 sliders exist because the comfortable value is a matter of taste and
|
||||
is best judged from inside the headset.
|
||||
In the cockpit, `"yaw"` takes the kart's own driving direction rather than the chase camera's
|
||||
lagging heading, from the level seat frame, which also damps a damage spin. `"yaw_pitch"`, the
|
||||
default, and `"full"` take the kart's live orientation about that same seat, keeping only its
|
||||
stabilised position, so a wheelie, a slope or a spin moves the view with the vehicle. With the custom seat, the head's place in the kart is
|
||||
`first_person_head_up_meters` and its two companions, measured in the kart's own frame; the F10
|
||||
sliders exist because the comfortable value is a matter of taste and is best judged from inside the
|
||||
headset.
|
||||
|
||||
The mode engages only in a single-screen race, the same content that already qualifies for
|
||||
immersive stereo. Menus, split-screen, and the virtual-screen fallback are unaffected, and so is
|
||||
the desktop mirror, which keeps showing the game's ordinary third-person view. If the kart or
|
||||
immersive stereo. Menus, split-screen, `flat_screen`, and the virtual-screen fallback are
|
||||
unaffected, and so is the desktop mirror, which keeps showing the game's ordinary third-person
|
||||
view. If the kart or
|
||||
camera cannot be read the camera stays where the game put it rather than guessing.
|
||||
|
||||
Your own driver sits exactly where your eyes are, so their head would fill the view.
|
||||
@@ -301,6 +413,95 @@ wide head turn in first person can reveal the edge of what the game decided to d
|
||||
rest of the race instrumentation, the object offsets this reads are specific to the project's
|
||||
supported PAL `RMCP01` translation.
|
||||
|
||||
## Steering wheel and hand steering
|
||||
|
||||
Ported from [heurazy's mario-kart-wii-VR-port](https://github.com/heurazy/mario-kart-wii-VR-port)
|
||||
(GPL-3.0-or-later). It applies to the cockpit seat.
|
||||
|
||||
**The wheel turns.** With `steering_wheel = true` (the default) the kart's steering wheel or the
|
||||
bike's handlebar turns with your steering: the left stick's deflection at the full-lock angle
|
||||
(`wheel_kart_degrees` 90, `wheel_bike_degrees` 45), eased so a flicked stick does not snap it round,
|
||||
or the hands' own angle while they hold it. `native_steering_wheel = true` turns the vehicle's own
|
||||
model. Karts bake the wheel into the body, so at the race draw boundary the runtime decodes the
|
||||
body's MDL0 position arrays, turns only the disc around the authored hand grips on a copy, and hands
|
||||
the copy to the GX thread; Aurora substitutes it into the draws that bind that array with the
|
||||
player's own model-view matrix (`aurora_set_native_wheel_vertices`), checking each changed vertex's
|
||||
matrix slot, so an opponent sharing the asset and other joints of the same draw are untouched. The
|
||||
guest's own vertices are never written, and the copies are dropped after the frame's draws. Bikes
|
||||
turn their handle part in the game already; its copy is only re-seated on the cockpit frame so the
|
||||
bars stay with your hands while the bike banks. The wheel rides in the same frame as the view: the
|
||||
level seat for `"yaw"`, the kart's own orientation for `"yaw_pitch"` and `"full"`. While no draw takes
|
||||
the copy (for 30 frames running; the race's opening pan does this) a separate VR wheel stands in,
|
||||
which is also what `native_steering_wheel = false` draws. The copy keeps being published, so the
|
||||
vehicle's own wheel returns as soon as draws take it again, and the log notes both switches.
|
||||
|
||||
The substitution is decided per draw, and a draw that folds into a neighbour renders through that
|
||||
neighbour's array binding, so only draws that reached the same decision may merge. Deciding this
|
||||
per array instead, and so refusing to merge every primitive that binds the vehicle's array, cost 6 ms
|
||||
of GPU time a frame on a Quest 3 (a race frame has 228 such primitives, recorded once and replayed in
|
||||
the mono pass and both eyes) and took a 56 FPS race down to 42. `debug.wiicompiled.fpslog 1` reports
|
||||
the draw calls a frame and the primitives merged away, which is where that shows up first.
|
||||
|
||||
The copy is matched against the race camera's view (`RaceCamera::GetViewMtx` with no dolly offset),
|
||||
because the scene camera is only set once the draws run. The log reports, once a second, how far
|
||||
that view is from the scene camera at the seal (`[mkw-vr] cockpit: race camera view vs scene view`)
|
||||
and how many draws took the copy; the F10 bar shows the same under the steering-wheel settings.
|
||||
Aurora adds a line after about half a second, five seconds and a minute of copies
|
||||
(`Native steering wheel: N sets; draws binding a replaced array ...`) counting the draws that bound a
|
||||
copied array, those that bound one outside the window it was set for, the matches, and how far the
|
||||
closest position matrix was from the expected one; the first such line with a bound draw also prints
|
||||
both matrices.
|
||||
|
||||
**Hand steering.** `hand_steering` (on by default, and in WheelWizard's OpenXR VR settings and the
|
||||
Quest launcher's Settings > VR, beside the seat)
|
||||
lets you take hold of the wheel or handlebar with the tracked controllers. It costs nothing until a
|
||||
grip actually takes hold: until then the stick steers as it always has. Squeeze a grip near it:
|
||||
past 55 % squeeze, within `wheel_grab_distance` metres of its plane (default 0.35) and near the rim,
|
||||
or near a bar end, scaled by `wheel_grab_assist`. Once taken, only letting go of the grip releases
|
||||
it. One hand steers by its angle around the hub; two hands steer by the line between them, so leaning
|
||||
or moving both arms together does not steer, and a hand joining, leaving or crossing the hub keeps
|
||||
the steering where it was. Turning past full lock is kept, so retracing the gesture returns to the
|
||||
same centre, while the game's steering saturates at full lock. `wheel_response` scales how quickly
|
||||
the wheel follows, `wheel_tracking_grace` (seconds) how long a hand that loses tracking keeps hold,
|
||||
and `wheel_haptics` gives a short pulse on grab and release.
|
||||
|
||||
While the wheel is held it replaces the left stick's X axis, in both the Wii Remote and the gamepad
|
||||
presentation, and the game keeps its own steering curve. The stick's Y axis still aims items, and a
|
||||
holding grip no longer reaches the game (a shoulder on the gamepad; the Wii Remote presentation
|
||||
leaves the grips unbound for this reason); the triggers, A and the right stick are unchanged. Releasing both grips gives steering back
|
||||
to the stick. The settings panel withholds the wheel like any other input.
|
||||
|
||||
**A USB wheel.** With a USB wheel and pedals set up (see the README), the wheel drives the race as
|
||||
player 1's GameCube controller. The cockpit's wheel follows its calibrated steering, at the same
|
||||
full-lock angle as the stick (`wheel_kart_degrees`, `wheel_bike_degrees`), and hand steering steps
|
||||
aside while it drives.
|
||||
|
||||
**Hands and the separate wheel.** Hands are drawn while hand steering is on: the runtime's own hand
|
||||
mesh where it offers one (`XR_EXT_hand_tracking` and `XR_FB_hand_tracking_mesh`, requested only when
|
||||
hand steering is on at launch), otherwise procedural gloves that curl with the squeeze. A Quest 3
|
||||
offers that mesh without the app declaring hand tracking, and the log says which is drawn
|
||||
(`[mkw-vr] cockpit hands:`). Both close their fingers towards the palm: the mesh's joints point
|
||||
-Z towards the fingertip and +Y out of the back of the hand, so flexion is negative about the
|
||||
joint's own X, on both hands. They and the
|
||||
separate VR wheel or handlebar travel with the stereo packet in metres in the seated frame, and each
|
||||
eye draws them inside the scene's pass just before the first 2D-layer draw, depth-tested with the
|
||||
world's own depth mapping, so the kart and the track hide them. Visible cockpit samples
|
||||
also mark one stencil bit; virtual-screen draws test that bit for zero, so even depth-disabled
|
||||
HUD elements and black screen effects cannot paint over the hands. Only stereo eye targets
|
||||
use `Depth24PlusStencil8`; desktop/EFB depth stays unchanged. The mask is cleared once per
|
||||
eye replay and retained across its passes. This adds no draw, full-screen copy, or render pass;
|
||||
eye-format pipeline siblings share shader modules and are cached when recording the game
|
||||
frame (`aurora-main/lib/gfx/cockpit.hpp`). The anchor also carries the frame's exact world scale
|
||||
(`aurora_set_stereo_scene_anchor_scaled`), and Aurora rescales each eye's head translation to it, so
|
||||
a scale change between the XR packet and the frame cannot misplace the hands.
|
||||
|
||||
The guest offsets involved (driver, movement, damage, grip frames, bike handle, driver bones and
|
||||
their world matrices) are PAL `RMCP01` constants listed with the leaf getter or constructor that
|
||||
proves each in `runtime/src/vr/mkw_vr_first_person.cpp`. `mkw_steering_wheel_tests`,
|
||||
`mkw_vr_cockpit_tests` and `mkw_vr_hand_steering_tests` cover the grab model, the seat and wheel
|
||||
geometry and the hand-off to the game; `gx_fifo_tests` covers the per-draw substitution and the
|
||||
overlay geometry, and `cockpit_gpu_smoke` its depth test on a real GPU.
|
||||
|
||||
## Presentation policy
|
||||
|
||||
The runtime deliberately fails safe instead of guessing which Mario Kart camera is active:
|
||||
@@ -311,6 +512,10 @@ The runtime deliberately fails safe instead of guessing which Mario Kart camera
|
||||
confirm exactly one distinct race camera for the current GX frame.
|
||||
- Leaving the race or observing zero or multiple cameras immediately returns presentation to the
|
||||
virtual screen. Session/runtime loss safely tears down XR and continues on the desktop mirror.
|
||||
- `flat_screen` clears the policy's `immersive_races`, so a race stays on the virtual screen
|
||||
however complete the observations are. Changing it advances the safety generation like any
|
||||
other change of presentation, and the pacing thread still treats that race as a race: pipeline
|
||||
caches are not stored mid-race on the virtual screen either.
|
||||
|
||||
Aurora records the original GX frame once and replays it for both OpenXR eyes. Perspective GX draws
|
||||
receive asymmetric headset projections, while the game's 2D layer goes on a fixed virtual screen
|
||||
@@ -320,6 +525,33 @@ short-lived immutable stereo packet. Each sealed GX frame and immersive packet c
|
||||
policy-generation tag; a mismatch is rendered in mono and the acquired XR frame is canceled, so an
|
||||
asynchronous menu/race transition cannot replay race transforms over unsafe content.
|
||||
|
||||
With interpolation off, PC (D3D12 and Windows Vulkan) and standalone (Android Vulkan) pace render-first:
|
||||
the pacing thread locates views for an estimated display time (two periods past the last
|
||||
prediction), hands Aurora a packet without leaving a compositor frame open, and waits for
|
||||
rendering. A 50 ms stall repeats the retained layer; cancellation also advances a keep-alive
|
||||
cycle to refresh timing. Once rendering is submitted, the thread calls xrWaitFrame and
|
||||
xrBeginFrame, completes backend-specific copy/release work, and ends the frame using the
|
||||
packet's original render poses with the current compositor display time.
|
||||
|
||||
Android Vulkan renders into shared buffers and copies them into newly acquired XR images afterward.
|
||||
Both PC bindings acquire images from their non-retained swapchain pair before rendering; Aurora
|
||||
queues the copy on the session's queue before reporting completion. PC therefore needs no additional
|
||||
copy in the short compositor cycle. Pending images remain acquired and separate from the
|
||||
retained pair until completion or confirmed cancellation before encoding. GPU failure still
|
||||
requires the existing queue-drain teardown. Rendered poses keep the session/reference-space
|
||||
serials recorded when the packet was prepared, so changes during rendering invalidate them.
|
||||
|
||||
VR interpolation keeps the frame-first order on both backends because it renders for the
|
||||
frame's own predicted display time. The log announces `OpenXR D3D12 pacing: render-first` (or
|
||||
`OpenXR Vulkan pacing: …` on the Vulkan binding) or
|
||||
`frame-first (VR interpolation)` on each transition. For PC testing, disable **VR** frame
|
||||
interpolation for a race capture; changing desktop interpolation alone does not select this
|
||||
path. Menus use render-first even when VR interpolation is configured for races. Compare the
|
||||
new diagnostic `open`, `end-gap`, `late`, and stage timings against a frame-first capture on
|
||||
the same course and settings. Shorter `open` alone does not prove fewer black frames: rendering
|
||||
and xrWaitFrame still take time outside that interval. Hardware testing is needed to measure
|
||||
latency, runtime throttling and visible blackouts.
|
||||
|
||||
With VR interpolation enabled, Aurora retains each sealed race's command stream and matched
|
||||
previous/current transform uniforms. New OpenXR packets wake the frame worker between game
|
||||
frames. It interpolates at the requested display time, then applies that packet's head pose and
|
||||
@@ -339,7 +571,8 @@ the pacing thread continues submitting the last completed layer. A stall alone n
|
||||
desktop fallback after 250 ms.
|
||||
|
||||
Before the first valid image, when OpenXR requests no rendering, or after a session/reference-space
|
||||
change invalidates the retained content, frames can still have no layers. Actual runtime or GPU
|
||||
change invalidates the retained content, frames can still have no layers (on the Quest outside a
|
||||
race, only the passthrough layer while `passthrough` is on, so a recenter does not flash black). Actual runtime or GPU
|
||||
submission failures retain the safe teardown path. This does not detect black images rendered by
|
||||
the game itself, and cannot keep submitting if the entire process or XR runtime is suspended.
|
||||
All OpenXR session and swapchain calls remain on their owning thread.
|
||||
@@ -401,22 +634,49 @@ When it is on, `console.log` receives lines tagged `[runtime] [xr-diag]`
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `Hz`, `cycles` | Display rate from the predicted display period; compositor cycles (xrWaitFrame/xrEndFrame pairs, repeats included). |
|
||||
| `predicted-rate`, `cycles` | Reciprocal of the runtime's predicted display period, **not necessarily physical headset refresh rate**; compositor cycles (xrWaitFrame/xrEndFrame pairs, repeats included). |
|
||||
| `skipped-slots` | Display slots the predicted display time jumped over: the runtime throttled or dropped frames. |
|
||||
| `late` | Frames whose xrEndFrame came after their predicted display time (needs `XR_KHR_win32_convert_performance_counter_time` or `XR_KHR_convert_timespec_time`). |
|
||||
| `layers new/repeat/empty` | Cycles ending with a newly rendered layer, the retained layer again, or no layer at all (black). |
|
||||
| `discarded`, `layer-rejected` | Retained layers dropped by a session or reference-space change; rendered layers not submitted (invalid pose or views, failed release). |
|
||||
| `wait-frame`, `open`, `end-call` | Time blocked in xrWaitFrame, from xrBeginFrame to xrEndFrame, and inside xrEndFrame. |
|
||||
| `end-margin`, `end-gap` | Predicted display time minus the xrEndFrame time; interval between xrEndFrame calls. |
|
||||
| `pickup`, `render` | Stereo packet published until Aurora's frame worker takes it (without interpolation this includes waiting for the next 60 Hz game frame); taken until the eye copy is submitted. |
|
||||
| `pickup`, `render` | Stereo packet published until Aurora's frame worker takes it (without interpolation this includes waiting for the next 60 Hz game frame); taken until the pacing thread observes the submission result. These are CPU wall times for completed submissions, **not GPU timestamps**; canceled packets are measured separately below. |
|
||||
| `acquire`, `release` | Swapchain image acquire+wait and release. |
|
||||
| `keepalive` | Retained-layer repeats while Aurora was still encoding past the 50 ms keep-alive. |
|
||||
| `packet-unused`, `packet-rejected`, `submit-failed` | Packets no game frame took within 50 ms; packets Aurora took but rendered mono (content tag or transform check); failed stereo copies. |
|
||||
| `packet-unused`, `packet-rejected`, `submit-failed` | Packets not picked up before cancellation; packets picked up but not encoded by the bridge before cancellation (the precise rejection cause is not known); failed stereo copies. |
|
||||
| `interp-skip` | Cycles the VR interpolation rate cap chose not to render. |
|
||||
| `frames immersive/screen` | Cycles per presentation mode; `not-rendered` counts cycles without views to render. |
|
||||
| `no-orientation`, `no-position` | Cycles whose head orientation or position was not valid. |
|
||||
| `suppressed` | Event lines dropped by the rate limit. |
|
||||
|
||||
- **Stage timings.** A separate `[xr-diag] stages ms` line accompanies each nonempty
|
||||
window, independently of the event rate limit. Each field is `median/worst` in ms;
|
||||
`@cycle=N,t=Ts` identifies the worst call's diagnostic cycle and completion time
|
||||
since logging/session reset. The main summary also includes the last `cycle` and `t`.
|
||||
Cycle 0 is before the first wait; work between cycles belongs to the previous cycle.
|
||||
These are wall times, including time the OS did not schedule the thread. Nested
|
||||
measurements (notably `sync-actions` within `input-sync`) must not be added together.
|
||||
|
||||
| Stage | What it isolates |
|
||||
| --- | --- |
|
||||
| `poll-events`, `begin-call`, `locate-views` | Event polling, the xrBeginFrame call itself, and xrLocateViews. |
|
||||
| `input-sync`, `sync-actions` | Complete input update and its xrSyncActions call. |
|
||||
| `publish`, `withdraw` | Packet construction/publication and withdrawal, including mutex waits. |
|
||||
| `set-targets` | D3D12/Vulkan bridge target registration, including its mutex wait. |
|
||||
| `submission-wait` | Actual time waiting for a render result, including timeout paths; compare against the requested 50 ms. |
|
||||
| `cancel` | Bridge cancellation attempt, whether it succeeds or fails. |
|
||||
| `cancel-age` | Publication to cancellation, including packets never picked up. |
|
||||
| `cancel-pickup`, `cancel-after-pickup` | Publication to pickup and pickup to cancellation for consumed, canceled packets. |
|
||||
|
||||
For a blackout report, enable logging before entering a race, reproduce the blackout,
|
||||
and export the logs immediately afterward. Include the approximate time and whether
|
||||
both eyes and the desktop mirror went black. Check `frames immersive` is nonzero for
|
||||
an immersive-race capture. A large stage maximum identifies where the pacing thread
|
||||
spent time, but cannot distinguish API blocking from OS scheduling without a system
|
||||
trace. No empty layers does not rule out black image contents or compositor/display
|
||||
problems. This instrumentation does not change frame pacing or inspect image pixels.
|
||||
|
||||
- **Event lines.** At most 8 per second; the rest are counted in `suppressed`. They report late
|
||||
frames, skipped display slots, stalls (more than 2.5 display periods, and at least 25 ms, between
|
||||
xrEndFrame calls), empty frames and their reason, discarded retained layers, rejected layers,
|
||||
@@ -438,11 +698,58 @@ The current `console.log` is copied through a shared-read stream while it is sti
|
||||
The copy runs on SDL's dialog thread (`runtime/src/log_export.cpp`), and the outcome is shown under
|
||||
the button. `mkw_openxr_diagnostics_tests` and `mkw_log_export_tests` cover both without a headset.
|
||||
|
||||
## Windows Vulkan
|
||||
|
||||
`video.graphics_api = "vulkan"` selects a second Windows binding, `runtime/src/vr/openxr_vulkan_win32.cpp`,
|
||||
with the same pacing thread, retained-layer protocol and policy as D3D12
|
||||
(`runtime/include/vr/openxr_windows.h` picks the backend at startup). It is opt-in. It has raced on
|
||||
a headset (SteamVR/OpenXR with a PlayStation VR2): immersive projection held the headset's full
|
||||
90 Hz with no skipped display slots, and a 646-second session recorded no rejected or discarded
|
||||
layers and no failed submissions. Other runtimes are still unexercised.
|
||||
|
||||
**Why a custom Dawn.** The pinned prebuilt Dawn DLL exposes no native Vulkan device, so
|
||||
`aurora-main/patches/dawn` adds a small versioned C ABI to the pinned Dawn source
|
||||
(`aurora_dawn_vulkan_abi.h`, `AURORA_DAWN_VULKAN_ABI = 1`): hooks that let the OpenXR runtime
|
||||
create Dawn's `VkInstance`, choose the physical device and create the `VkDevice`; wrapping of a
|
||||
borrowed `VkImage` as a Dawn texture; the release barrier back to `COLOR_ATTACHMENT_OPTIMAL`; a
|
||||
device-guard lock; and a queue drain. `Launcher/Build-DawnVulkan.ps1` builds that DLL from the pinned
|
||||
revision on a machine with Visual Studio 2022, Python 3.12+ and CMake, and writes `aurora-vulkan.json`
|
||||
(revision, ABI, DLL hash). `Launcher/Prepare-Dependencies.ps1 -DawnVulkanPackage <dir>` installs it as
|
||||
`dawn_prebuilt` in a fresh dependency destination after checking that provenance; re-harvest
|
||||
`native_prebuilt` afterwards because the archives are pinned to the Dawn DLL hash. The runtime
|
||||
build also fetches Vulkan headers (`vulkan_headers` dependency).
|
||||
|
||||
**Startup.** `QueryGraphicsRequirements` loads `xrGetVulkanGraphicsRequirements2KHR`,
|
||||
`xrCreateVulkanInstanceKHR`, `xrCreateVulkanDeviceKHR` and `xrGetVulkanGraphicsDevice2KHR`, then
|
||||
installs the hooks in Dawn. Without the custom DLL it fails with *"requires the custom Dawn library
|
||||
with Aurora Vulkan ABI 1"* and the game continues on the desktop renderer. During
|
||||
`aurora_initialize` the runtime creates Dawn's instance (Vulkan 1.2 is requested when the loader
|
||||
and runtime allow it, so that timeline semaphores, which PC runtimes create on the application's
|
||||
device, are a core feature the device hook can enable) and device on the runtime's physical GPU.
|
||||
`BindAurora` confirms that Dawn's physical device is the one the runtime selected, creates the
|
||||
session on Dawn's graphics queue, picks the sRGB sibling of Aurora's UNORM colour format (with
|
||||
`XR_SWAPCHAIN_USAGE_MUTABLE_FORMAT_BIT`) so the compositor decodes the gamma-encoded bytes, and
|
||||
enables the bridge.
|
||||
|
||||
**Frames.** Each acquired XR image is wrapped once as a Dawn texture and reused. Aurora's frame
|
||||
worker records the eye copies into its own command buffer; immediately after `queue.Submit`, still
|
||||
under Aurora's submit mutex, the bridge appends the release barrier through Dawn's queue and
|
||||
publishes the token that the pacing thread's `WaitForSubmission` consumes. The runtime may use
|
||||
the VkQueue only inside `xrBeginFrame`, `xrEndFrame`, `xrAcquireSwapchainImage` and
|
||||
`xrReleaseSwapchainImage`, so `OpenXRRuntime::LockGraphicsQueue` holds Dawn's device guard around
|
||||
exactly those four calls and never across `xrWaitFrame` or `xrWaitSwapchainImage`.
|
||||
|
||||
**Tests.** `mkw_openxr_vulkan_replay_tests` compiles the real backend against the deterministic
|
||||
compositor of the D3D12 replay tests, including the queue-guard requirement on acquire and release.
|
||||
`vulkan_native_bridge_smoke` (aurora, `AURORA_GPU_SMOKE_TESTS=ON`, real GPU, no headset) drives the
|
||||
custom DLL's ABI through three borrowed-image copy/readback cycles; run it with that DLL beside it.
|
||||
|
||||
## Backend status
|
||||
|
||||
| Backend | Status |
|
||||
| --- | --- |
|
||||
| Windows D3D12 | Implemented: same-adapter, same-device asynchronous OpenXR submission. |
|
||||
| Windows Vulkan | Implemented, opt-in (`video.graphics_api = "vulkan"`): the runtime creates Dawn's Vulkan instance and device through `XR_KHR_vulkan_enable2`, eyes are copied on the same queue, and Dawn's device guard is held around the four queue-touching OpenXR calls. Needs the custom Dawn from `Launcher/Build-DawnVulkan.ps1`. Raced on SteamVR/PSVR2 at the headset's full rate; other runtimes unexercised. See [Windows Vulkan](#windows-vulkan). |
|
||||
| Android Vulkan (Meta Quest) | Implemented and running on a Quest 3: the OpenXR side owns its own Vulkan device (`XR_KHR_vulkan_enable2`, `XR_KHR_vulkan_enable` fallback) and shares eyes with Dawn through `AHardwareBuffer`s ordered by sync-fd fences. Controllers arrive through OpenXR actions as a virtual SDL gamepad. See `docs/quest-port.md`. |
|
||||
| Linux Vulkan | Not wired. The pinned Dawn package does not expose a native Vulkan device, and the AHardwareBuffer bridge is Android-only; a dma-buf/opaque-fd variant of the same design would cover desktop Linux. |
|
||||
| Other platforms | Not wired yet. |
|
||||
@@ -507,9 +814,13 @@ ends, including mid-frame flushes, so live setting changes cannot invalidate pen
|
||||
Lifecycle events and performance (about 43 game FPS) are still open. Apple visionOS packaging
|
||||
is not implemented.
|
||||
- Scene-specific comfort options, culling fixes and replay/spectator classification are future work.
|
||||
- The headset settings panel is drawn into the eye images rather than submitted as its own quad
|
||||
layer, so its text is resampled once more than a compositor layer's would be. It has no laser
|
||||
beam, only the cursor on the panel itself, and text fields cannot be typed into without a keyboard.
|
||||
- Hand steering works on a Quest 3 (2026-09-22): the kart's own wheel animated (228 draws a frame,
|
||||
the race camera's view matching the scene's exactly) and the wheel can be grabbed and turned. In
|
||||
that race the driver's eye was never calibrated, so the fallback placed the wheel centre about
|
||||
13 cm above eye level. Bikes and Quacker, and the PC, are still unvalidated. Hand steering needs
|
||||
analog grips (Touch); the simple controller profile cannot grab.
|
||||
- The headset settings panel has no laser beam, only the cursor on the panel itself, and text fields
|
||||
cannot be typed into without a keyboard.
|
||||
- The desktop window remains available as a mirror/fallback.
|
||||
|
||||
OpenXR diagnostics are written to the normal run log under
|
||||
|
||||
@@ -50,10 +50,13 @@ The graphics layer is built on
|
||||
Play at several times the console's resolution.
|
||||
|
||||
**Experimental OpenXR VR.**
|
||||
Windows builds can render through a D3D12 OpenXR runtime without CPU readback. Menus and
|
||||
Windows builds can render through an OpenXR runtime on D3D12, or on Vulkan with a custom Dawn
|
||||
build, without CPU readback. Menus and
|
||||
unsupported scenes appear as a head-locked virtual screen; a validated single-camera race switches
|
||||
to immersive stereo rendering. VR is opt-in and falls back to the normal desktop renderer if the
|
||||
runtime or headset is unavailable. See [`OPENXR.md`](OPENXR.md) for setup, configuration, and the
|
||||
runtime or headset is unavailable. In first person you sit in the cockpit, where the steering wheel
|
||||
or handlebar turns with your steering, and hand steering by heurazy lets you grab it with the
|
||||
tracked controllers and turn it. See [`OPENXR.md`](OPENXR.md) for setup, configuration, and the
|
||||
current limitations.
|
||||
|
||||
**Music ducking.**
|
||||
@@ -97,6 +100,38 @@ Known limitations of the Wii Remote path:
|
||||
- Turn the Wii Remote support off in that menu if you use a Mayflash DolphinBar, which already
|
||||
presents the remote as a regular gamepad.
|
||||
|
||||
**USB steering wheels and pedals.**
|
||||
Ported from heurazy's [mario-kart-wii-VR-port](https://github.com/heurazy/mario-kart-wii-VR-port).
|
||||
Open **F10 > Controllers > USB wheel and pedals (player 1)**; it is also in the headset's settings
|
||||
panel. Pick the steering device and axis and record full left, full right and centre, then each
|
||||
pedal's released and fully pressed positions. Assign the right paddle to drift and the left paddle to
|
||||
items; trick, confirm, pause and back are optional. Any wheel SDL sees as a joystick works this way,
|
||||
with no gamepad mapping: separate USB pedals, reversed axes and combined pedal axes (select the same
|
||||
axis for both pedals) all calibrate the same. The settings are saved in `PhysicalWheel.toml` beside
|
||||
`Config.toml`.
|
||||
|
||||
The wheel is player 1's GameCube controller. Press its confirm button at the title screen so the game
|
||||
uses a GameCube controller; its D-pad, confirm and back then work the menus. In a race it owns
|
||||
steering and the pedals. The brake pedal brakes, then reverses, and beats the accelerator and drift.
|
||||
In VR, the cockpit's wheel turns with it and hand steering steps aside. Setting the VR controllers to
|
||||
**Gamepad** keeps them for menus, pause and item aiming alongside the wheel. Light vibration is
|
||||
optional, off by default, capped at 15 % and follows the game's own rumble. No centering spring or
|
||||
steering force is requested.
|
||||
|
||||
Logitech wheels (G29, G920, G923, G27, G25, Driving Force GT, PRO Racing Wheel) are recognised by SDL
|
||||
as wheels and marked "(wheel)" in the device list. This has not been tried on a physical wheel yet:
|
||||
- Install Logitech G HUB (Logitech Gaming Software for a G27 or G25). Without the driver a Logitech
|
||||
wheel starts in a compatibility mode, typically with a smaller rotation range and both pedals on
|
||||
one axis. A G920 or G923 for Xbox also starts as an Xbox controller, which the game would read as
|
||||
an ordinary pad.
|
||||
- Set a G29's mode switch to PS3 on PC.
|
||||
- Full lock is wherever you record full left and right. Recording them a quarter turn each way
|
||||
(90°) matches the VR cockpit's wheel, or lower the operating range in G HUB.
|
||||
- A Driving Force Shifter's gears reach the game as buttons of the wheel and can be assigned like
|
||||
any other. A gear stays pressed while it is engaged: on the item button it keeps the item held
|
||||
behind you until you shift back to neutral. The clutch is not used.
|
||||
- Turn on the centering spring in G HUB if you want the wheel to self-centre.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Windows 10 or 11, 64-bit
|
||||
@@ -131,7 +166,8 @@ Saves and Miis use the normal installation's effective NAND; Retro Rewind retain
|
||||
XML-directed saves and ghosts. Graphics, VR preferences, caches, and compiled binaries stay separate.
|
||||
Uninstalling either backend in WheelWizard VR preserves configuration and shared progress.
|
||||
|
||||
Managed VR launches enable OpenXR with D3D12. If the runtime or headset is unavailable, the game
|
||||
Managed VR launches enable OpenXR with D3D12; the Vulkan binding is opt-in through
|
||||
`video.graphics_api` (see [OPENXR.md](OPENXR.md)). If the runtime or headset is unavailable, the game
|
||||
continues on the desktop and displays the failure briefly; **F10 → VR** retains the explanation.
|
||||
See [OpenXR configuration](OPENXR.md) and [distribution and validation](DISTRIBUTION.md).
|
||||
|
||||
@@ -232,6 +268,8 @@ All translated output is verified against real hardware behavior and most import
|
||||
aurora's Direct3D, Vulkan and OpenGL backends.
|
||||
- **[OpenXR](https://www.khronos.org/openxr/)** - the Khronos cross-platform API used by the
|
||||
experimental VR renderer.
|
||||
- **heurazy** - the VR cockpit's turning steering wheel and hand steering, ported from
|
||||
**[mario-kart-wii-VR-port](https://github.com/heurazy/mario-kart-wii-VR-port)** (GPL-3.0).
|
||||
- **[Dolphin Emulator](https://github.com/dolphin-emu/dolphin)** - an invaluable reference for Wii
|
||||
hardware behavior during development, plus the source of the free DSP coefficient ROM and the
|
||||
unmodified default WiiConnect24 bootstrap tree bundled with the runtime.
|
||||
|
||||
@@ -71,6 +71,23 @@ The runtime's Riivolution patch handling is a port of Dolphin's
|
||||
(both marked `SPDX-License-Identifier: GPL-2.0-or-later`).
|
||||
Source: <https://github.com/dolphin-emu/dolphin>
|
||||
|
||||
### heurazy's mario-kart-wii-VR-port - GPL-3.0-or-later
|
||||
|
||||
- Source: <https://github.com/heurazy/mario-kart-wii-VR-port>
|
||||
- Author: heurazy
|
||||
- License: GNU General Public License v3.0 or later, the same license as WiiCompiled.
|
||||
|
||||
The VR cockpit's steering wheel and hand steering are ported from this project: the grab-and-turn
|
||||
model (`runtime/include/vr/steering_wheel.h`), the native wheel vertex rotation
|
||||
(`runtime/include/vr/native_wheel_mesh.h`), the level seat (`runtime/include/vr/cockpit_stabilizer.h`),
|
||||
the runtime hand-mesh loader (`runtime/include/vr/openxr_hand_mesh.h`), the per-draw substitution
|
||||
(`aurora-main/include/aurora/native_wheel_match.hpp`, `aurora-main/lib/gx/native_wheel.hpp`), the
|
||||
cockpit overlay renderer (`aurora-main/lib/gfx/cockpit.hpp`), their tests, and the seat, eye and
|
||||
wheel geometry and guest reads in `runtime/src/vr/mkw_vr_first_person.cpp` and
|
||||
`runtime/include/vr/mkw_vr_first_person.h`. The USB wheel and pedal support is ported from it too
|
||||
(`runtime/include/physical_wheel.h`, `runtime/src/physical_wheel.cpp` and their test). The files
|
||||
carry that attribution in their headers.
|
||||
|
||||
### pugixml - MIT
|
||||
|
||||
Copyright (c) 2006-2025 Arseny Kapoulkine.
|
||||
@@ -241,6 +258,12 @@ Not code, but the documentation this project depends on:
|
||||
- [Retro Rewind](https://wiki.tockdom.com/wiki/Retro_Rewind) by ZPL - the mod distribution this
|
||||
project can build as a static profile. No Retro Rewind content is redistributed here; users
|
||||
supply their own copy.
|
||||
- The references heurazy's mario-kart-wii-VR-port credits for the cockpit, none of whose source is
|
||||
compiled into this repository:
|
||||
[AnimalCrossing-VR-MR-Standalone](https://github.com/heurazy/AnimalCrossing-VR-MR-Standalone)
|
||||
(OpenXR hand meshes), [Cyberpunk VR port](https://github.com/dariulone/cyberpunk-vr-port)
|
||||
(squeeze-to-grab steering) and [Pulsar](https://github.com/MelgMKW/Pulsar) (Mario Kart Wii class
|
||||
layouts).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -524,6 +524,19 @@ function Invoke-QuestGameBuild {
|
||||
$sources[$slot] = @($slotSources | ForEach-Object { if ($_.EndsWith('.S')) { & $toElf $_ } else { $_ } })
|
||||
}
|
||||
if ($sources.translated.Count -eq 0) { throw "No translated shards in $shards" }
|
||||
# Online play needs the Retro-WFC payload translated into the mod (translate-mod
|
||||
# --retro-wfc-payload). Without it the mod downloads the payload at run time and jumps into
|
||||
# code that was never translated: the game crashes on entering Retro Rewind WFC.
|
||||
if ($Product -eq 'retro_rewind') {
|
||||
$dataPatches = @($sources.Values | ForEach-Object { $_ } |
|
||||
Where-Object { [IO.Path]::GetFileName($_) -eq 'mod_data_patches.cpp' })
|
||||
if ($dataPatches.Count -ne 1 -or
|
||||
-not (Select-String -LiteralPath $dataPatches[0] -Pattern 'kRetroWfcInitializerAddress' -SimpleMatch -Quiet)) {
|
||||
throw ('This Retro Rewind translation has no Retro-WFC payload, so online play would crash. ' +
|
||||
'Translate the mod again with its payload (translate-mod --retro-wfc-payload, or repair ' +
|
||||
'Retro Rewind in WiiCompiled with the payload download on), then build again.')
|
||||
}
|
||||
}
|
||||
foreach ($slot in $sources.Keys) {
|
||||
if ($sources[$slot].Count -eq 0) { throw "The $slot sources of a $Product game are missing from $shards" }
|
||||
}
|
||||
|
||||
@@ -101,8 +101,8 @@ android {
|
||||
// Quest 2 ships Android 10 (API 29); AHardwareBuffer/Vulkan 1.1 need 26+.
|
||||
minSdk = 29
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "0.2.0-quest"
|
||||
versionCode = 4
|
||||
versionName = "0.4.0-quest"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
for ((name, value) in discPins) {
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
<uses-feature android:name="android.hardware.vulkan.version" android:required="true" android:version="0x00401000" />
|
||||
<uses-feature android:name="android.hardware.vr.headtracking" android:required="true" android:version="1" />
|
||||
<uses-feature android:name="android.software.xr.api.openxr" android:required="true" />
|
||||
<!-- XR_FB_passthrough around the menu screen: without this flag Horizon OS keeps the app
|
||||
passthrough-disabled and the passthrough layer composites nothing. No camera permission. -->
|
||||
<uses-feature android:name="com.oculus.feature.PASSTHROUGH" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ object GameStorage {
|
||||
|
||||
fun modDirectory(context: Context): File = File(gameRoot(context), MOD_DIRECTORY)
|
||||
|
||||
/** Mods imported on the Patches page, one folder each, as WheelWizard keeps them on a computer. */
|
||||
const val MODS_DIRECTORY = "Mods"
|
||||
|
||||
/** The pack's own Code.pul, which is what a modded game and a mod translation both need. */
|
||||
fun modCodePul(context: Context): File = File(modDirectory(context), "Binaries/Code.pul")
|
||||
|
||||
@@ -40,6 +43,11 @@ object GameStorage {
|
||||
fun modContentReady(context: Context, profile: GameProfile): Boolean =
|
||||
!profile.modPack || modCodePul(context).isFile
|
||||
|
||||
fun modsDirectory(context: Context): File = File(gameRoot(context), MODS_DIRECTORY)
|
||||
|
||||
/** The pack's Patches folder, which its Riivolution XML maps onto the disc; refilled from Mods at each start. */
|
||||
fun patchesDirectory(context: Context): File = File(modDirectory(context), "Patches")
|
||||
|
||||
fun configFile(context: Context): File = File(gameRoot(context), "Config.toml")
|
||||
|
||||
fun logsDirectory(context: Context): File = File(gameRoot(context), "Logs")
|
||||
@@ -78,7 +86,7 @@ object GameStorage {
|
||||
// Written line by line: trimIndent runs after interpolation, so an interpolated line
|
||||
// would take the indent off every other one.
|
||||
val lines = mutableListOf(
|
||||
"# WiiCompiled Quest configuration. Edit with the launcher, the in-game panel or adb pull/push.",
|
||||
"# WiiCompiled Quest configuration. Edit with the launcher or the in-game panel. After an adb push, run chmod 664 on it or the app can no longer save settings.",
|
||||
"[paths]",
|
||||
"dvd_root = \"${discDirectory(context).absolutePath}\"",
|
||||
)
|
||||
|
||||
@@ -35,7 +35,8 @@ import org.wiicompiled.quest.GameStorage
|
||||
* game kit (assets/game_kit) into private storage;
|
||||
* 2. download the Android NDK files the build needs from Google, checked against the pins the
|
||||
* toolchain carries (ndk.json);
|
||||
* 3. translate main.dol and StaticR.rel: translate-recursive, generate-data-init, emit-build-shards;
|
||||
* 3. translate main.dol and StaticR.rel: translate-recursive, generate-data-init, emit-build-shards
|
||||
* (Retro Rewind adds translate-mod, with the Retro-WFC payload downloaded from rwfc.net);
|
||||
* 4. compile the generated sources with the kit's flags, several at a time;
|
||||
* 5. link them with the kit's objects and archives (kit.json link.lld);
|
||||
* 6. install libmain.so and its game.json like an imported game.
|
||||
@@ -65,6 +66,12 @@ object GameBuild {
|
||||
private const val TRANSLATOR_THREADS = 4
|
||||
private const val EXPECTED_TRANSLATION_SECONDS = 600.0
|
||||
private const val COMPILE_MEMORY_BYTES = 700L * 1024 * 1024
|
||||
// WiiCompiled Setup's fixed endpoint, size cap and staging layout
|
||||
// (Launcher/WiiCompiled.Setup.Common/RetroWfcPayload.cs), which validate-retro-wfc-payload expects.
|
||||
private const val RETRO_WFC_PAYLOAD_URL = "https://rwfc.net/api/wfc/payload?g=RMCPD00"
|
||||
private const val RETRO_WFC_PAYLOAD_MAX_BYTES = 16 * 1024 * 1024
|
||||
private const val RETRO_WFC_DIRECTORY = "retro-wfc"
|
||||
private const val RETRO_WFC_PAYLOAD_FILE = "binary/payload.RMCPD00.bin"
|
||||
|
||||
/** Builds [profile]'s game. Null on success, otherwise the message to show. */
|
||||
fun run(context: Context, profile: GameProfile, reporter: Reporter, cancelled: () -> Boolean, finishing: () -> Unit): String? {
|
||||
@@ -279,13 +286,82 @@ object GameBuild {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Retro-WFC payload Retro Rewind's online play runs. translate-mod lowers it into the
|
||||
* mod; without it the mod downloads the payload while connecting and jumps into code that
|
||||
* was never translated. Retried once, like Setup's download.
|
||||
*/
|
||||
fun downloadRetroWfcPayload(): File {
|
||||
val file = File(workspace, "$RETRO_WFC_DIRECTORY/$RETRO_WFC_PAYLOAD_FILE")
|
||||
file.parentFile?.mkdirs()
|
||||
log.line("Downloading the Retro-WFC payload from $RETRO_WFC_PAYLOAD_URL")
|
||||
var failure: IOException? = null
|
||||
for (attempt in 1..2) {
|
||||
if (cancelled()) throw InterruptedIOException("Build cancelled")
|
||||
try {
|
||||
file.writeBytes(fetchRetroWfcPayload())
|
||||
log.line("Retro-WFC payload: ${file.length()} bytes, sha256 ${BuildRecipe.hex(sha256(file))}")
|
||||
return file
|
||||
} catch (e: IOException) {
|
||||
// A socket timeout is an InterruptedIOException too, which run() takes for a cancel.
|
||||
failure = e
|
||||
log.line("Retro-WFC payload download attempt $attempt failed: $e")
|
||||
if (attempt == 1) Thread.sleep(1_000)
|
||||
}
|
||||
}
|
||||
if (cancelled()) throw InterruptedIOException("Build cancelled")
|
||||
throw IOException(
|
||||
"Retro Rewind's online play needs the Retro-WFC payload from rwfc.net, which could not be " +
|
||||
"downloaded (${failure?.message}). Check the headset's internet connection, then build again.",
|
||||
)
|
||||
}
|
||||
|
||||
fun fetchRetroWfcPayload(): ByteArray {
|
||||
val connection = URL(RETRO_WFC_PAYLOAD_URL).openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 30_000
|
||||
// A redirect would fetch from a target other than the fixed endpoint; Setup refuses it too.
|
||||
connection.instanceFollowRedirects = false
|
||||
connection.setRequestProperty("Accept-Encoding", "identity")
|
||||
val code = connection.responseCode
|
||||
if (code != HttpURLConnection.HTTP_OK) throw IOException("rwfc.net answered $code")
|
||||
connection.inputStream.use { input ->
|
||||
val bytes = java.io.ByteArrayOutputStream()
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
bytes.write(buffer, 0, read)
|
||||
if (bytes.size() > RETRO_WFC_PAYLOAD_MAX_BYTES) throw IOException("the payload is unexpectedly large")
|
||||
}
|
||||
return bytes.toByteArray()
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/** Translates the disc unless this workspace already holds a translation of the same inputs. */
|
||||
fun translate(tools: ToolProcess, identity: String): String? {
|
||||
val generated = File(workspace, "generated")
|
||||
val provenance = File(generated, "translation-provenance.txt")
|
||||
// Fetched before anything else, so an unreachable server fails the build before the
|
||||
// long base translation rather than after it.
|
||||
val payload = if (profile.modPack) downloadRetroWfcPayload() else null
|
||||
if (payload != null) {
|
||||
translator(
|
||||
tools, "checking the Retro-WFC payload",
|
||||
"validate-retro-wfc-payload", "--directory", File(workspace, RETRO_WFC_DIRECTORY).absolutePath,
|
||||
)?.let { return it }
|
||||
}
|
||||
// A modded game needs a base translation that knows this Code.pul, so the pack's own
|
||||
// identity is part of what the stored translation is reused for.
|
||||
val modIdentity = if (profile.modPack) BuildRecipe.hex(sha256(GameStorage.modCodePul(context))) else ""
|
||||
// identity (and the payload's) is part of what the stored translation is reused for.
|
||||
val modIdentity = if (payload != null) {
|
||||
"${BuildRecipe.hex(sha256(GameStorage.modCodePul(context)))} ${BuildRecipe.hex(sha256(payload))}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
val expected = "$identity ${profile.id} ${BuildConfig.DISC_DOL_SHA256} ${BuildConfig.DISC_REL_SHA256} $modIdentity"
|
||||
val shards = File(generated, "build_shards/shards.cmake")
|
||||
if (provenance.isFile && provenance.readText() == expected && shards.isFile) {
|
||||
@@ -327,7 +403,7 @@ object GameBuild {
|
||||
}?.let { return it }
|
||||
|
||||
// Retro Rewind's own code: its Code.pul translated against the base translation, as
|
||||
// Launcher/LocalBuild.ps1 does on a PC. Online play needs a payload this cannot fetch.
|
||||
// Launcher/LocalBuild.ps1 does on a PC, with the Retro-WFC payload for online play.
|
||||
val modOutput = "build/mods/retro_rewind_full_cpp"
|
||||
if (profile.modPack) {
|
||||
report(350, Step.Translate, 2, steps)
|
||||
@@ -345,7 +421,7 @@ object GameBuild {
|
||||
"--code-pul", GameStorage.modCodePul(context).absolutePath,
|
||||
"--mod-root", GameStorage.modDirectory(context).absolutePath,
|
||||
"--mod-name", "Retro Rewind", "--region", "P", "--out", modOutput,
|
||||
"--prefer-cached-inputs", "--emit-cpp", "--skip-retro-wfc",
|
||||
"--prefer-cached-inputs", "--emit-cpp", "--retro-wfc-payload", payload!!.absolutePath,
|
||||
"--threads", TRANSLATOR_THREADS.toString(),
|
||||
)?.let { return it }
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* The app's entry point on the headset: a 2D panel modelled on the PC launcher (WheelWizard VR),
|
||||
* with a Home page that sets up and starts the game and a Settings page that edits Config.toml.
|
||||
* with a Home page that sets up and starts the game, a Patches page that imports mods for Retro
|
||||
* Rewind, and a Settings page that edits Config.toml.
|
||||
*
|
||||
* The APK carries no game code. Playing needs two things the player owns: the game files (DATA,
|
||||
* extracted from their disc image here or on a PC) and the game itself (libmain.so, built from
|
||||
@@ -41,14 +42,16 @@ import org.wiicompiled.quest.R
|
||||
*/
|
||||
class LauncherActivity : Activity() {
|
||||
|
||||
private enum class Page { Home, Settings }
|
||||
private enum class Page { Home, Patches, Settings }
|
||||
|
||||
/** What Home's main and secondary buttons do. */
|
||||
private enum class Action { Play, Resume, SelectDisc, ImportGame, BuildGame, DownloadModPack, Reset }
|
||||
|
||||
private lateinit var navHome: View
|
||||
private lateinit var navPatches: View
|
||||
private lateinit var navSettings: View
|
||||
private lateinit var homePage: View
|
||||
private lateinit var patchesView: View
|
||||
private lateinit var settingsView: View
|
||||
private lateinit var trails: WheelTrailsView
|
||||
private lateinit var playButton: View
|
||||
@@ -64,6 +67,7 @@ class LauncherActivity : Activity() {
|
||||
private lateinit var homeTitle: TextView
|
||||
private lateinit var gameToggle: LinearLayout
|
||||
private lateinit var settings: SettingsPage
|
||||
private lateinit var patches: PatchesPage
|
||||
|
||||
/** The games this APK carries a kit for, and the one the player picked. */
|
||||
private val profiles: List<GameProfile> by lazy { GameProfile.available(this) }
|
||||
@@ -71,6 +75,8 @@ class LauncherActivity : Activity() {
|
||||
|
||||
private var page = Page.Home
|
||||
private var launching = false
|
||||
/** Play is copying the enabled mods into Retro Rewind's Patches folder before starting it. */
|
||||
private var installingPatches = false
|
||||
private var trailsAway = true
|
||||
private var setupKind: Class<*>? = null
|
||||
private var mainAction = Action.Play
|
||||
@@ -95,8 +101,10 @@ class LauncherActivity : Activity() {
|
||||
}
|
||||
|
||||
navHome = findViewById(R.id.nav_home)
|
||||
navPatches = findViewById(R.id.nav_patches)
|
||||
navSettings = findViewById(R.id.nav_settings)
|
||||
homePage = findViewById(R.id.page_home)
|
||||
patchesView = findViewById(R.id.page_patches)
|
||||
settingsView = findViewById(R.id.page_settings)
|
||||
trails = findViewById(R.id.home_trails)
|
||||
playButton = findViewById(R.id.home_play)
|
||||
@@ -129,11 +137,15 @@ class LauncherActivity : Activity() {
|
||||
downloadModPack = ::downloadModPack,
|
||||
resetInstallation = ::resetInstallation,
|
||||
)
|
||||
patches = PatchesPage(this, patchesView) {
|
||||
openPicker(REQUEST_PATCH_FILES, multiple = true, noPicker = R.string.patches_no_picker)
|
||||
}
|
||||
savedInstanceState?.getString(KEY_TAB)?.let { name ->
|
||||
SettingsPage.Tab.entries.firstOrNull { it.name == name }?.let(settings::select)
|
||||
}
|
||||
|
||||
navHome.setOnClickListener { showPage(Page.Home) }
|
||||
navPatches.setOnClickListener { showPage(Page.Patches) }
|
||||
navSettings.setOnClickListener { showPage(Page.Settings) }
|
||||
playButton.setOnClickListener { perform(mainAction) }
|
||||
secondary.setOnClickListener { secondaryAction?.let(::perform) }
|
||||
@@ -182,8 +194,16 @@ class LauncherActivity : Activity() {
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
val uri = data?.data ?: return
|
||||
if (resultCode != RESULT_OK) return
|
||||
if (resultCode != RESULT_OK || data == null) return
|
||||
if (requestCode == REQUEST_PATCH_FILES) {
|
||||
// Several files arrive as clip data, a single one as the data URI.
|
||||
val clip = data.clipData
|
||||
val uris = if (clip != null) (0 until clip.itemCount).map { clip.getItemAt(it).uri } else listOfNotNull(data.data)
|
||||
showPage(Page.Patches)
|
||||
patches.importPicked(uris)
|
||||
return
|
||||
}
|
||||
val uri = data.data ?: return
|
||||
val task = when (requestCode) {
|
||||
REQUEST_DISC_IMAGE -> GameSetup.Task.ExtractDisc
|
||||
REQUEST_GAME_PACKAGE -> GameSetup.Task.ImportPackage
|
||||
@@ -197,8 +217,10 @@ class LauncherActivity : Activity() {
|
||||
private fun showPage(target: Page) {
|
||||
page = target
|
||||
navHome.isSelected = target == Page.Home
|
||||
navPatches.isSelected = target == Page.Patches
|
||||
navSettings.isSelected = target == Page.Settings
|
||||
homePage.visibility = if (target == Page.Home) View.VISIBLE else View.GONE
|
||||
patchesView.visibility = if (target == Page.Patches) View.VISIBLE else View.GONE
|
||||
settingsView.visibility = if (target == Page.Settings) View.VISIBLE else View.GONE
|
||||
if (target == Page.Home) {
|
||||
trailsAway = true
|
||||
@@ -209,6 +231,7 @@ class LauncherActivity : Activity() {
|
||||
private fun refresh() {
|
||||
when (page) {
|
||||
Page.Home -> refreshHome()
|
||||
Page.Patches -> patches.refresh()
|
||||
Page.Settings -> settings.refresh()
|
||||
}
|
||||
}
|
||||
@@ -278,10 +301,14 @@ class LauncherActivity : Activity() {
|
||||
setup is GameSetup.State.Finishing -> getString(R.string.home_finishing)
|
||||
else -> getString(label(mainAction))
|
||||
}
|
||||
if (installingPatches) {
|
||||
playButton.isEnabled = false
|
||||
playText.setText(R.string.home_installing_patches)
|
||||
}
|
||||
secondary.visibility = if (secondaryAction != null) View.VISIBLE else View.GONE
|
||||
secondaryAction?.let { secondary.setText(label(it)) }
|
||||
|
||||
progress.visibility = if (settingUp) View.VISIBLE else View.GONE
|
||||
progress.visibility = if (settingUp || installingPatches) View.VISIBLE else View.GONE
|
||||
progress.isIndeterminate = setup !is GameSetup.State.Working
|
||||
if (setup is GameSetup.State.Working && setup.total > 0) {
|
||||
progress.progress = (setup.done * progress.max / setup.total).toInt()
|
||||
@@ -335,9 +362,10 @@ class LauncherActivity : Activity() {
|
||||
showBanner(getString(R.string.home_mod_needed_message), warning = true)
|
||||
gameStatus == GameLibrary.Status.Stale -> showBanner(getString(R.string.home_game_stale), warning = true)
|
||||
gameStatus == GameLibrary.Status.Missing && discStatus == GameStorage.DiscStatus.Missing ->
|
||||
showBanner(getString(R.string.home_setup_intro), warning = false)
|
||||
showBanner(getString(R.string.home_setup_intro) + discMd5Note(), warning = false)
|
||||
gameStatus == GameLibrary.Status.Missing -> showBanner(getString(R.string.home_game_missing), warning = false)
|
||||
discStatus == GameStorage.DiscStatus.Missing -> showBanner(getString(R.string.home_data_missing, disc), warning = false)
|
||||
discStatus == GameStorage.DiscStatus.Missing ->
|
||||
showBanner(getString(R.string.home_data_missing, disc) + discMd5Note(), warning = false)
|
||||
else -> showBanner(null)
|
||||
}
|
||||
|
||||
@@ -500,6 +528,9 @@ class LauncherActivity : Activity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** The clean disc's .iso hash, for the banners that ask for a disc image. */
|
||||
private fun discMd5Note(): String = "\n\n" + getString(R.string.disc_md5_note, getString(R.string.disc_md5))
|
||||
|
||||
private fun showBanner(text: String?, warning: Boolean = false) {
|
||||
if (text == null) {
|
||||
dataBanner.visibility = View.GONE
|
||||
@@ -549,18 +580,19 @@ class LauncherActivity : Activity() {
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun openPicker(requestCode: Int) {
|
||||
// Disc images and game files have no reliable MIME type, so every file is offered
|
||||
// and the task checks the name and contents.
|
||||
private fun openPicker(requestCode: Int, multiple: Boolean = false, noPicker: Int = R.string.home_no_picker) {
|
||||
// Disc images, game files and mod files have no reliable MIME type, so every file is
|
||||
// offered and the task checks the name and contents.
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
.setType("*/*")
|
||||
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple)
|
||||
try {
|
||||
@Suppress("DEPRECATION")
|
||||
startActivityForResult(intent, requestCode)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Log.w(TAG, "No document picker", e)
|
||||
Toast.makeText(this, R.string.home_no_picker, Toast.LENGTH_LONG).show()
|
||||
Toast.makeText(this, noPicker, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +621,66 @@ class LauncherActivity : Activity() {
|
||||
Toast.makeText(this, R.string.home_quest1_launch_from_library, Toast.LENGTH_LONG).show()
|
||||
return
|
||||
}
|
||||
if (launching) {
|
||||
return
|
||||
}
|
||||
// Resume only brings the running game back, and its files must not change under it.
|
||||
if (profile.modPack && !isGameRunning()) {
|
||||
preparePatches()
|
||||
return
|
||||
}
|
||||
startGame()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retro Rewind reads its pack's Patches folder, so the enabled mods are copied into it before
|
||||
* every start, as the PC launcher does (ModsLaunchService.PrepareModsForLaunch). With none
|
||||
* enabled, a folder that still holds files is only cleared if the player says so.
|
||||
*/
|
||||
private fun preparePatches() {
|
||||
val mods = ModLibrary.load(GameStorage.modsDirectory(this))
|
||||
when {
|
||||
ModLibrary.shouldAskToClear(mods, GameStorage.patchesDirectory(this)) ->
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.patches_clear_title)
|
||||
.setMessage(R.string.patches_clear_message)
|
||||
.setPositiveButton(R.string.patches_delete) { _, _ -> installPatches(mods, clear = true) }
|
||||
.setNegativeButton(R.string.patches_keep) { _, _ -> startGame() }
|
||||
.show()
|
||||
mods.any { it.enabled } -> installPatches(mods, clear = false)
|
||||
else -> startGame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun installPatches(mods: List<ModLibrary.Mod>, clear: Boolean) {
|
||||
if (launching) return
|
||||
launching = true
|
||||
installingPatches = true
|
||||
refreshHome()
|
||||
val modsDir = GameStorage.modsDirectory(this)
|
||||
val patchesDir = GameStorage.patchesDirectory(this)
|
||||
ModLibrary.background({ ModLibrary.prepareForLaunch(modsDir, patchesDir, mods, clear) }) { result ->
|
||||
installingPatches = false
|
||||
launching = false
|
||||
if (isDestroyed) return@background
|
||||
val error = result.getOrElse { it.message ?: it.toString() }
|
||||
if (error != null) {
|
||||
Log.w(TAG, "Mods could not be installed: $error")
|
||||
refreshHome()
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.home_patches_failed)
|
||||
.setMessage(error)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
return@background
|
||||
}
|
||||
Log.i(TAG, "Patches folder ready: ${mods.count { it.enabled }} of ${mods.size} mods enabled")
|
||||
refreshHome()
|
||||
startGame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startGame() {
|
||||
if (launching) {
|
||||
return
|
||||
}
|
||||
@@ -634,6 +726,7 @@ class LauncherActivity : Activity() {
|
||||
const val PROCESS_EXIT_GRACE_MS = 1000L
|
||||
const val REQUEST_DISC_IMAGE = 1
|
||||
const val REQUEST_GAME_PACKAGE = 2
|
||||
const val REQUEST_PATCH_FILES = 3
|
||||
const val PREFERENCES = "launcher"
|
||||
const val KEY_LAST_DROPPED_IMPORT = "lastDroppedImport"
|
||||
const val EXTRA_DEBUG_BUILD_GAME = "org.wiicompiled.quest.debug.BUILD_GAME"
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
/**
|
||||
* The mods imported on the Patches page, kept the way WheelWizard VR keeps them on a computer
|
||||
* (Features/Mods): one folder per mod under Mods/, holding the mod's files and a `<name>.ini` with
|
||||
* its state, so a Mods folder copied from one launcher reads the same in the other.
|
||||
*
|
||||
* Mods only change Retro Rewind. Before it starts, [plan] and [sync] flatten the enabled mods into
|
||||
* the pack's Patches folder, which the pack's Riivolution XML maps onto the disc (/patches, /sound),
|
||||
* as the PC launcher's ModsLaunchService does before every Retro Rewind launch. A file two mods
|
||||
* both carry comes from the one higher in the list, which is the one with the lower priority.
|
||||
*/
|
||||
object ModLibrary {
|
||||
|
||||
/** One imported mod, as its `.ini` describes it. Author and ModID only come from the PC's mod browser. */
|
||||
data class Mod(
|
||||
val title: String,
|
||||
val enabled: Boolean,
|
||||
val priority: Int,
|
||||
val author: String = NO_ID,
|
||||
val modId: Int = -1,
|
||||
)
|
||||
|
||||
/** One file picked for an import: its display name and how to read it. */
|
||||
class Source(val name: String, val open: () -> InputStream)
|
||||
|
||||
enum class NameProblem { Empty, Exists, IllegalCharacters }
|
||||
|
||||
private const val SECTION = "Mod"
|
||||
private const val NO_ID = "-1"
|
||||
|
||||
/** ModManager._illegalChars plus Windows' invalid file name characters, so a name travels to the PC. */
|
||||
private val ILLEGAL_NAME_CHARACTERS = ".~/\\<>:\"|?*".toSet()
|
||||
|
||||
/** Archives the PC unpacks with SharpCompress; without it, only .zip can be opened here. */
|
||||
private val UNSUPPORTED_ARCHIVES = listOf(".7z", ".rar")
|
||||
|
||||
// Metadata
|
||||
|
||||
/** Every mod under [modsDir] (`<name>/<name>.ini`), in list order: by priority, top first. */
|
||||
fun load(modsDir: File): List<Mod> =
|
||||
modsDir.listFiles { file -> file.isDirectory }
|
||||
.orEmpty()
|
||||
.mapNotNull { folder ->
|
||||
val ini = File(folder, "${folder.name}.ini")
|
||||
if (!ini.isFile) return@mapNotNull null
|
||||
runCatching { parseIni(ini.readText()) }.getOrNull()
|
||||
}
|
||||
.sortedWith(compareBy<Mod> { it.priority }.thenBy { it.title.lowercase(Locale.ROOT) })
|
||||
|
||||
/**
|
||||
* Reads what Mod.LoadFromIniAsync reads, with its defaults: enabled unless it says otherwise,
|
||||
* priority 0 and ModID -1 when absent. Null without a name, which the PC skips as well.
|
||||
*/
|
||||
fun parseIni(text: String): Mod? {
|
||||
val values = mutableMapOf<String, String>()
|
||||
var section = ""
|
||||
for (raw in text.removePrefix("").lineSequence()) {
|
||||
val line = raw.trim()
|
||||
when {
|
||||
line.isEmpty() || line.startsWith(";") || line.startsWith("#") -> Unit
|
||||
line.startsWith("[") && line.endsWith("]") -> section = line.substring(1, line.length - 1).trim()
|
||||
section.equals(SECTION, ignoreCase = true) && '=' in line ->
|
||||
values[line.substringBefore('=').trim().lowercase(Locale.ROOT)] = line.substringAfter('=').trim()
|
||||
}
|
||||
}
|
||||
val title = values["name"]?.takeIf { it.isNotBlank() } ?: return null
|
||||
return Mod(
|
||||
title = title,
|
||||
// bool.TryParse: either word in any case, and anything else keeps the default.
|
||||
enabled = when (values["isenabled"]?.lowercase(Locale.ROOT)) {
|
||||
"false" -> false
|
||||
else -> true
|
||||
},
|
||||
priority = values["priority"]?.toIntOrNull() ?: 0,
|
||||
author = values["author"] ?: NO_ID,
|
||||
modId = values["modid"]?.toIntOrNull() ?: -1,
|
||||
)
|
||||
}
|
||||
|
||||
/** What Mod.SaveToIniAsync writes: the same keys, with .NET's True/False. */
|
||||
fun iniText(mod: Mod): String = buildString {
|
||||
append("[").append(SECTION).append("]\n")
|
||||
append("Name = ").append(mod.title).append('\n')
|
||||
append("Author = ").append(mod.author).append('\n')
|
||||
append("ModID = ").append(mod.modId).append('\n')
|
||||
append("IsEnabled = ").append(if (mod.enabled) "True" else "False").append('\n')
|
||||
append("Priority = ").append(mod.priority).append('\n')
|
||||
}
|
||||
|
||||
fun save(modsDir: File, mod: Mod) {
|
||||
val folder = folder(modsDir, mod)
|
||||
if (!folder.isDirectory) throw IOException("The folder of ${mod.title} is missing: ${folder.absolutePath}")
|
||||
val ini = File(folder, "${mod.title}.ini")
|
||||
val temporary = File(folder, "${mod.title}.ini.tmp")
|
||||
temporary.writeText(iniText(mod))
|
||||
if (!temporary.renameTo(ini)) {
|
||||
temporary.delete()
|
||||
ini.writeText(iniText(mod))
|
||||
}
|
||||
}
|
||||
|
||||
fun folder(modsDir: File, mod: Mod): File = File(modsDir, mod.title)
|
||||
|
||||
/** ModManager.ValidateModName: a new name must be non-empty, unused (any case) and a valid folder name. */
|
||||
fun validateName(name: String, mods: List<Mod>): NameProblem? {
|
||||
val trimmed = name.trim()
|
||||
return when {
|
||||
trimmed.isEmpty() -> NameProblem.Empty
|
||||
mods.any { it.title.equals(trimmed, ignoreCase = true) } -> NameProblem.Exists
|
||||
trimmed.any { it in ILLEGAL_NAME_CHARACTERS || it.code < 32 } -> NameProblem.IllegalCharacters
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** A starting name for the import dialog: the file's name without its extensions, made valid. */
|
||||
fun suggestName(fileName: String, mods: List<Mod>): String {
|
||||
val base = fileName.substringAfterLast('/').substringBefore('.').trim()
|
||||
.map { if (it in ILLEGAL_NAME_CHARACTERS || it.code < 32) ' ' else it }
|
||||
.joinToString("").trim()
|
||||
return base.takeIf { validateName(it, mods) == null } ?: ""
|
||||
}
|
||||
|
||||
// Changes
|
||||
|
||||
/**
|
||||
* ModManager.ImportModFilesAsync: the picked files become one new mod, enabled, below every
|
||||
* existing one. A picked .zip is unpacked into it, as the PC does with a mod it downloads.
|
||||
* The files are gathered beside the Mods folder's other entries and only moved into place once
|
||||
* all are in, so a failed import leaves nothing behind.
|
||||
*/
|
||||
fun import(modsDir: File, title: String, sources: List<Source>, mods: List<Mod>): Mod {
|
||||
val name = title.trim()
|
||||
validateName(name, mods)?.let { throw IOException("The name $name cannot be used ($it).") }
|
||||
if (sources.isEmpty()) throw IOException("No files were chosen.")
|
||||
sources.firstOrNull { source -> UNSUPPORTED_ARCHIVES.any { source.name.endsWith(it, ignoreCase = true) } }?.let {
|
||||
throw IOException("${it.name} is an archive this headset cannot open. Unpack it on a computer and import its files, or import a .zip.")
|
||||
}
|
||||
modsDir.mkdirs()
|
||||
val staging = File(modsDir, ".$name.importing")
|
||||
staging.deleteRecursively()
|
||||
if (!staging.mkdirs()) throw IOException("Could not create ${staging.absolutePath}")
|
||||
try {
|
||||
for (source in sources) {
|
||||
val fileName = source.name.substringAfterLast('/').substringAfterLast('\\')
|
||||
if (fileName.isBlank() || fileName == "." || fileName == "..") throw IOException("A chosen file has no usable name.")
|
||||
if (fileName.endsWith(".zip", ignoreCase = true)) {
|
||||
unpack(source, staging)
|
||||
} else {
|
||||
source.open().use { input -> File(staging, fileName).outputStream().use { input.copyTo(it) } }
|
||||
}
|
||||
}
|
||||
if (staging.walkTopDown().none { it.isFile }) throw IOException("There was nothing to import.")
|
||||
val mod = Mod(name, enabled = true, priority = (mods.maxOfOrNull { it.priority } ?: 0) + 1)
|
||||
val target = folder(modsDir, mod)
|
||||
// No mod has this name, so a folder under it is what an interrupted import left.
|
||||
target.deleteRecursively()
|
||||
if (!staging.renameTo(target)) throw IOException("Could not move the mod into ${target.absolutePath}")
|
||||
save(modsDir, mod)
|
||||
return mod
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
/** Unpacks a picked .zip into [destination], refusing entries that would land outside it. */
|
||||
private fun unpack(source: Source, destination: File) {
|
||||
// ZipFile reads the central directory, which every zip has; streaming fails on some.
|
||||
val archive = File(destination, ".archive.zip")
|
||||
try {
|
||||
source.open().use { input -> archive.outputStream().use { input.copyTo(it) } }
|
||||
ZipFile(archive).use { zip ->
|
||||
for (entry in zip.entries()) {
|
||||
if (entry.isDirectory) continue
|
||||
val relative = entry.name.replace('\\', '/').trimStart('/')
|
||||
if (relative.isEmpty() || relative.split('/').any { it == ".." }) {
|
||||
throw IOException("${source.name} has a file outside its own folder (${entry.name}).")
|
||||
}
|
||||
val file = File(destination, relative)
|
||||
file.parentFile?.mkdirs()
|
||||
zip.getInputStream(entry).use { input -> file.outputStream().use { input.copyTo(it) } }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
archive.delete()
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(modsDir: File, mod: Mod) {
|
||||
val folder = folder(modsDir, mod)
|
||||
if (folder.exists() && !folder.deleteRecursively()) throw IOException("Could not delete ${folder.absolutePath}")
|
||||
}
|
||||
|
||||
/** ModManager.RenameModAsync: the folder and its `.ini` take the new name. */
|
||||
fun rename(modsDir: File, mod: Mod, newTitle: String, mods: List<Mod>): Mod {
|
||||
val name = newTitle.trim()
|
||||
if (name == mod.title) return mod
|
||||
validateName(name, mods)?.let { throw IOException("The name $name cannot be used ($it).") }
|
||||
val renamed = mod.copy(title = name)
|
||||
val from = folder(modsDir, mod)
|
||||
val to = folder(modsDir, renamed)
|
||||
if (!from.renameTo(to)) throw IOException("Could not rename ${from.absolutePath}")
|
||||
File(to, "${mod.title}.ini").delete()
|
||||
save(modsDir, renamed)
|
||||
return renamed
|
||||
}
|
||||
|
||||
/**
|
||||
* ModManager.DecreasePriorityAsync (up) and IncreasePriorityAsync (down): the mod swaps
|
||||
* priorities with its neighbour. Returns the two changed mods, or nothing at either end.
|
||||
*/
|
||||
fun move(mods: List<Mod>, mod: Mod, up: Boolean): List<Mod> {
|
||||
val neighbour = if (up) {
|
||||
mods.filter { it.priority < mod.priority }.maxByOrNull { it.priority }
|
||||
} else {
|
||||
mods.filter { it.priority > mod.priority }.minByOrNull { it.priority }
|
||||
} ?: return emptyList()
|
||||
return listOf(mod.copy(priority = neighbour.priority), neighbour.copy(priority = mod.priority))
|
||||
}
|
||||
|
||||
// Launch
|
||||
|
||||
/**
|
||||
* ModsLaunchService.PrepareModsForLaunch: the file name each enabled mod's files take in the
|
||||
* Patches folder, and where each comes from. Mods are walked from the bottom of the list up and
|
||||
* a later one replaces an earlier one's file, so the top of the list wins. Names compare
|
||||
* without case, as on the PC and on the headset's shared storage.
|
||||
*/
|
||||
fun plan(modsDir: File, mods: List<Mod>): Map<String, File> {
|
||||
val files = LinkedHashMap<String, Pair<String, File>>()
|
||||
for (mod in mods.sortedWith(compareByDescending<Mod> { it.priority }.thenByDescending { it.title.lowercase(Locale.ROOT) })) {
|
||||
if (!mod.enabled) continue
|
||||
val folder = folder(modsDir, mod)
|
||||
if (!folder.isDirectory) continue
|
||||
val metadata = File(folder, "${mod.title}.ini").absolutePath
|
||||
val contents = folder.walkTopDown()
|
||||
.filter { it.isFile && !it.absolutePath.equals(metadata, ignoreCase = true) }
|
||||
.sortedBy { it.relativeTo(folder).path.lowercase(Locale.ROOT) }
|
||||
for (file in contents) {
|
||||
val name = launchName(mod.priority, file.name)
|
||||
val key = name.lowercase(Locale.ROOT)
|
||||
files[key] = (files[key]?.first ?: name) to file
|
||||
}
|
||||
}
|
||||
return files.values.associate { it }
|
||||
}
|
||||
|
||||
/**
|
||||
* ModsLaunchService.GetLaunchPatchFileName: a modding archive (`<name>.<tag>.szs`) is prefixed
|
||||
* with its mod's priority, so Pulsar can resolve two mods patching the same archive.
|
||||
*/
|
||||
fun launchName(priority: Int, fileName: String): String {
|
||||
if (!isModdingArchive(fileName)) return fileName
|
||||
return "$priority.${stripPriorityPrefix(fileName)}"
|
||||
}
|
||||
|
||||
private fun isModdingArchive(fileName: String): Boolean {
|
||||
if (!fileName.endsWith(".szs", ignoreCase = true)) return false
|
||||
val stem = fileName.substring(0, fileName.length - ".szs".length)
|
||||
val separator = stem.lastIndexOf('.')
|
||||
return separator > 0 && separator + 1 < stem.length
|
||||
}
|
||||
|
||||
private fun stripPriorityPrefix(fileName: String): String {
|
||||
val digits = fileName.takeWhile { it.isDigit() }.length
|
||||
return if (digits > 0 && digits < fileName.length && fileName[digits] == '.') fileName.substring(digits + 1) else fileName
|
||||
}
|
||||
|
||||
/** ModsLaunchService.ShouldAskToClearTargetFolder: no mod enabled, yet the Patches folder holds files. */
|
||||
fun shouldAskToClear(mods: List<Mod>, patchesDir: File): Boolean =
|
||||
mods.none { it.enabled } && patchesDir.listFiles { file -> file.isFile }.orEmpty().isNotEmpty()
|
||||
|
||||
/**
|
||||
* ModsLaunchService.CopyFinalFiles: the Patches folder ends up holding exactly [plan]'s files.
|
||||
* Loose files no mod provides go; a file whose size and time already match is not copied again.
|
||||
*/
|
||||
fun sync(patchesDir: File, plan: Map<String, File>) {
|
||||
patchesDir.mkdirs()
|
||||
if (!patchesDir.isDirectory) throw IOException("Could not create ${patchesDir.absolutePath}")
|
||||
val wanted = plan.keys.map { it.lowercase(Locale.ROOT) }.toSet()
|
||||
for (file in patchesDir.listFiles { file -> file.isFile }.orEmpty()) {
|
||||
if (file.name.lowercase(Locale.ROOT) !in wanted && !file.delete()) {
|
||||
throw IOException("Could not remove ${file.absolutePath}")
|
||||
}
|
||||
}
|
||||
for ((name, source) in plan) {
|
||||
val target = File(patchesDir, name)
|
||||
if (target.isFile && target.length() == source.length() && target.lastModified() == source.lastModified()) continue
|
||||
source.copyTo(target, overwrite = true)
|
||||
// Without the time, every start would copy everything again; that is all it costs.
|
||||
target.setLastModified(source.lastModified())
|
||||
}
|
||||
}
|
||||
|
||||
/** ModsLaunchService with the folder-clearing answer: null on success, otherwise the message. */
|
||||
fun prepareForLaunch(modsDir: File, patchesDir: File, mods: List<Mod>, clear: Boolean): String? = try {
|
||||
when {
|
||||
mods.any { it.enabled } -> sync(patchesDir, plan(modsDir, mods))
|
||||
clear && patchesDir.exists() && !patchesDir.deleteRecursively() ->
|
||||
throw IOException("Could not clear ${patchesDir.absolutePath}")
|
||||
}
|
||||
null
|
||||
} catch (e: IOException) {
|
||||
e.message ?: e.toString()
|
||||
}
|
||||
|
||||
// Background work
|
||||
|
||||
private val worker = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "ModLibrary").apply { isDaemon = true } }
|
||||
private val main by lazy { Handler(Looper.getMainLooper()) }
|
||||
|
||||
/** True while an import runs; imports and launch preparation take turns on one thread. */
|
||||
@Volatile
|
||||
var importing = false
|
||||
private set
|
||||
|
||||
/** Runs [work] off the main thread, one task at a time, and hands its result to [done] on the main thread. */
|
||||
fun <T> background(work: () -> T, done: (Result<T>) -> Unit) {
|
||||
worker.execute {
|
||||
val result = runCatching(work)
|
||||
main.post { done(result) }
|
||||
}
|
||||
}
|
||||
|
||||
fun importInBackground(modsDir: File, title: String, sources: List<Source>, done: (Result<Mod>) -> Unit) {
|
||||
importing = true
|
||||
background({ import(modsDir, title, sources, load(modsDir)) }) { result ->
|
||||
importing = false
|
||||
done(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.content.res.ColorStateList
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.PopupMenu
|
||||
import android.widget.Switch
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import kotlin.math.roundToInt
|
||||
import org.wiicompiled.quest.GameStorage
|
||||
import org.wiicompiled.quest.R
|
||||
|
||||
/**
|
||||
* The launcher's Patches page, WheelWizard's mods page (Views/Pages/ModsPage.axaml.cs) without its
|
||||
* mod browser: Import turns picked files into a named mod, and each mod can be switched on or off,
|
||||
* moved up or down the list, renamed or deleted. What the list means for the game happens at
|
||||
* Play, in [ModLibrary.prepareForLaunch].
|
||||
*/
|
||||
class PatchesPage(
|
||||
private val activity: Activity,
|
||||
root: View,
|
||||
private val pickFiles: () -> Unit,
|
||||
) {
|
||||
|
||||
private val empty: View = root.findViewById(R.id.patches_empty)
|
||||
private val content: View = root.findViewById(R.id.patches_content)
|
||||
private val rows: LinearLayout = root.findViewById(R.id.patches_rows)
|
||||
private val count: TextView = root.findViewById(R.id.patches_count)
|
||||
private val enableAll: Switch = root.findViewById(R.id.patches_enable_all)
|
||||
private val headerImport: TextView = root.findViewById(R.id.patches_import)
|
||||
private val importButtons = listOf(headerImport, root.findViewById<TextView>(R.id.patches_empty_import))
|
||||
|
||||
private val modsDir: File get() = GameStorage.modsDirectory(activity)
|
||||
|
||||
/** The list as read for the rows on screen, top first. */
|
||||
private var mods: List<ModLibrary.Mod> = emptyList()
|
||||
|
||||
init {
|
||||
for (button in importButtons) {
|
||||
button.setOnClickListener { if (!ModLibrary.importing) pickFiles() }
|
||||
}
|
||||
styleSwitch(enableAll)
|
||||
// A click, not a checked change: refresh() sets the switch without meaning to change every mod.
|
||||
enableAll.setOnClickListener { setAllEnabled(enableAll.isChecked) }
|
||||
root.findViewById<View>(R.id.patches_enable_all_label).setOnClickListener {
|
||||
enableAll.toggle()
|
||||
setAllEnabled(enableAll.isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds the list from the Mods folder, which adb or the PC launcher's files may have changed. */
|
||||
fun refresh() {
|
||||
mods = ModLibrary.load(modsDir)
|
||||
val hasMods = mods.isNotEmpty()
|
||||
// As on the PC, the page's own Import moves to the top bar once there is a list.
|
||||
empty.visibility = if (hasMods) View.GONE else View.VISIBLE
|
||||
content.visibility = if (hasMods) View.VISIBLE else View.GONE
|
||||
headerImport.visibility = if (hasMods) View.VISIBLE else View.GONE
|
||||
count.text = mods.size.toString()
|
||||
enableAll.isChecked = mods.all { it.enabled }
|
||||
|
||||
val importing = ModLibrary.importing
|
||||
for (button in importButtons) {
|
||||
button.isEnabled = !importing
|
||||
button.alpha = if (importing) 0.45f else 1f
|
||||
button.setText(if (importing) R.string.patches_importing else R.string.patches_import)
|
||||
}
|
||||
|
||||
rows.removeAllViews()
|
||||
if (!hasMods) return
|
||||
rows.addView(
|
||||
note(),
|
||||
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = dp(12)
|
||||
},
|
||||
)
|
||||
mods.forEachIndexed { index, mod ->
|
||||
rows.addView(
|
||||
row(mod, index),
|
||||
LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
if (index > 0) topMargin = dp(3)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The files the player picked for Import: asks for the mod's name, then copies them in the background. */
|
||||
fun importPicked(uris: List<Uri>) {
|
||||
if (uris.isEmpty() || ModLibrary.importing) return
|
||||
mods = ModLibrary.load(modsDir)
|
||||
val sources = uris.map { uri ->
|
||||
ModLibrary.Source(displayName(uri)) {
|
||||
activity.contentResolver.openInputStream(uri) ?: throw IOException("Cannot read $uri")
|
||||
}
|
||||
}
|
||||
// Typing on a headset is slow, so a single file offers its own name to start from.
|
||||
val suggested = if (sources.size == 1) ModLibrary.suggestName(sources[0].name, mods) else ""
|
||||
nameDialog(R.string.patches_name_title, null, suggested, R.string.patches_import, { ModLibrary.validateName(it, mods) }) { name ->
|
||||
Log.i(TAG, "Importing ${sources.size} file(s) as mod $name")
|
||||
ModLibrary.importInBackground(modsDir, name, sources) { result ->
|
||||
if (activity.isDestroyed) return@importInBackground
|
||||
refresh()
|
||||
result.onSuccess { mod ->
|
||||
Toast.makeText(activity, activity.getString(R.string.patches_installed, mod.title), Toast.LENGTH_LONG).show()
|
||||
}.onFailure { failure ->
|
||||
Log.w(TAG, "Mod import failed", failure)
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle(R.string.patches_import_failed)
|
||||
.setMessage(failure.message ?: failure.toString())
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun row(mod: ModLibrary.Mod, index: Int): View {
|
||||
val last = mods.size - 1
|
||||
val toggle = Switch(activity).apply {
|
||||
isChecked = mod.enabled
|
||||
styleSwitch(this)
|
||||
contentDescription = mod.title
|
||||
setOnCheckedChangeListener { _, checked -> setModEnabled(mod, checked) }
|
||||
}
|
||||
val title = TextView(activity).apply {
|
||||
text = mod.title
|
||||
setTextColor(activity.getColor(R.color.neutral_100))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
|
||||
isSingleLine = true
|
||||
ellipsize = TextUtils.TruncateAt.END
|
||||
}
|
||||
return LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
minimumHeight = dp(58)
|
||||
setPadding(dp(14), dp(6), dp(8), dp(6))
|
||||
background = activity.getDrawable(
|
||||
when {
|
||||
last == 0 -> R.drawable.bg_row_single
|
||||
index == 0 -> R.drawable.bg_row_top
|
||||
index == last -> R.drawable.bg_row_bottom
|
||||
else -> R.drawable.bg_row_middle
|
||||
},
|
||||
)
|
||||
addView(toggle)
|
||||
addView(title, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
marginStart = dp(14)
|
||||
marginEnd = dp(8)
|
||||
})
|
||||
addView(iconButton(R.drawable.ic_chevron_up, R.string.patches_move_up, index > 0) { move(mod, up = true) })
|
||||
addView(iconButton(R.drawable.ic_chevron_down, R.string.patches_move_down, index < last) { move(mod, up = false) })
|
||||
addView(iconButton(R.drawable.ic_more, R.string.patches_more, true) { anchor -> showMenu(anchor, mod) })
|
||||
setOnClickListener { toggle.toggle() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun iconButton(icon: Int, description: Int, enabled: Boolean, onClick: (View) -> Unit) = ImageView(activity).apply {
|
||||
setImageResource(icon)
|
||||
imageTintList = ColorStateList.valueOf(activity.getColor(R.color.neutral_300))
|
||||
background = activity.getDrawable(R.drawable.bg_nav_item)
|
||||
contentDescription = activity.getString(description)
|
||||
setPadding(dp(10), dp(10), dp(10), dp(10))
|
||||
isEnabled = enabled
|
||||
alpha = if (enabled) 1f else 0.3f
|
||||
setOnClickListener(onClick)
|
||||
layoutParams = LinearLayout.LayoutParams(dp(42), dp(42))
|
||||
}
|
||||
|
||||
private fun note() = LinearLayout(activity).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
background = activity.getDrawable(R.drawable.bg_banner_info)
|
||||
setPadding(dp(14), dp(10), dp(14), dp(10))
|
||||
addView(
|
||||
TextView(activity).apply {
|
||||
setText(R.string.patches_note)
|
||||
setTextColor(activity.getColor(R.color.neutral_300))
|
||||
setTextSize(TypedValue.COMPLEX_UNIT_SP, 13f)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun showMenu(anchor: View, mod: ModLibrary.Mod) {
|
||||
PopupMenu(activity, anchor).apply {
|
||||
menu.add(0, MENU_RENAME, 0, R.string.patches_rename)
|
||||
menu.add(0, MENU_DELETE, 1, R.string.patches_delete)
|
||||
setOnMenuItemClickListener { item ->
|
||||
when (item.itemId) {
|
||||
MENU_RENAME -> rename(mod)
|
||||
MENU_DELETE -> delete(mod)
|
||||
}
|
||||
true
|
||||
}
|
||||
show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setModEnabled(mod: ModLibrary.Mod, enabled: Boolean) {
|
||||
// Saved in place rather than rebuilt, so the switch keeps its animation.
|
||||
val changed = mod.copy(enabled = enabled)
|
||||
if (!change { ModLibrary.save(modsDir, changed) }) {
|
||||
refresh()
|
||||
return
|
||||
}
|
||||
mods = mods.map { if (it.title == mod.title) changed else it }
|
||||
enableAll.isChecked = mods.all { it.enabled }
|
||||
}
|
||||
|
||||
private fun setAllEnabled(enabled: Boolean) {
|
||||
change {
|
||||
for (mod in mods) {
|
||||
if (mod.enabled != enabled) ModLibrary.save(modsDir, mod.copy(enabled = enabled))
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun move(mod: ModLibrary.Mod, up: Boolean) {
|
||||
change { ModLibrary.move(mods, mod, up).forEach { ModLibrary.save(modsDir, it) } }
|
||||
refresh()
|
||||
}
|
||||
|
||||
private fun rename(mod: ModLibrary.Mod) {
|
||||
nameDialog(
|
||||
R.string.patches_rename_title,
|
||||
activity.getString(R.string.patches_rename_message, mod.title),
|
||||
mod.title,
|
||||
R.string.patches_rename,
|
||||
{ name -> if (name.trim() == mod.title) null else ModLibrary.validateName(name, mods) },
|
||||
) { name ->
|
||||
change { ModLibrary.rename(modsDir, mod, name, mods) }
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private fun delete(mod: ModLibrary.Mod) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle(activity.getString(R.string.patches_delete_title, mod.title))
|
||||
.setMessage(R.string.patches_delete_message)
|
||||
.setPositiveButton(R.string.patches_delete) { _, _ ->
|
||||
change { ModLibrary.delete(modsDir, mod) }
|
||||
refresh()
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
/** Runs one change to the Mods folder; false, with the reason shown, when it failed. */
|
||||
private fun change(edit: () -> Unit): Boolean = try {
|
||||
edit()
|
||||
true
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Mod change failed", e)
|
||||
Toast.makeText(activity, activity.getString(R.string.patches_change_failed, e.message ?: e.toString()), Toast.LENGTH_LONG).show()
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* WheelWizard's TextInputWindow: a name field that keeps the dialog open, with the reason shown,
|
||||
* until the name is one [validate] accepts.
|
||||
*/
|
||||
private fun nameDialog(
|
||||
title: Int,
|
||||
message: String?,
|
||||
initial: String,
|
||||
positive: Int,
|
||||
validate: (String) -> ModLibrary.NameProblem?,
|
||||
onAccept: (String) -> Unit,
|
||||
) {
|
||||
val input = EditText(activity).apply {
|
||||
setText(initial)
|
||||
setSelection(text.length)
|
||||
setHint(R.string.patches_name_hint)
|
||||
setTextColor(activity.getColor(R.color.neutral_100))
|
||||
setHintTextColor(activity.getColor(R.color.neutral_500))
|
||||
isSingleLine = true
|
||||
imeOptions = EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
val container = FrameLayout(activity).apply {
|
||||
setPadding(dp(22), dp(8), dp(22), 0)
|
||||
addView(input)
|
||||
}
|
||||
val dialog = AlertDialog.Builder(activity)
|
||||
.setTitle(title)
|
||||
.apply { message?.let { setMessage(it) } }
|
||||
.setView(container)
|
||||
.setPositiveButton(positive, null)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
fun accept() {
|
||||
val problem = validate(input.text.toString())
|
||||
if (problem != null) {
|
||||
input.error = activity.getString(
|
||||
when (problem) {
|
||||
ModLibrary.NameProblem.Empty -> R.string.patches_name_empty
|
||||
ModLibrary.NameProblem.Exists -> R.string.patches_name_exists
|
||||
ModLibrary.NameProblem.IllegalCharacters -> R.string.patches_name_illegal
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
dialog.dismiss()
|
||||
onAccept(input.text.toString().trim())
|
||||
}
|
||||
input.setOnEditorActionListener { _, action, _ ->
|
||||
if (action == EditorInfo.IME_ACTION_DONE) accept()
|
||||
action == EditorInfo.IME_ACTION_DONE
|
||||
}
|
||||
dialog.setOnShowListener {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { accept() }
|
||||
input.requestFocus()
|
||||
}
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun displayName(uri: Uri): String {
|
||||
runCatching {
|
||||
activity.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) cursor.getString(0)?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
}
|
||||
return uri.lastPathSegment?.substringAfterLast('/')?.takeIf { it.isNotBlank() } ?: "file"
|
||||
}
|
||||
|
||||
private fun styleSwitch(switch: Switch) {
|
||||
switch.thumbTintList = checkedColors(R.color.neutral_50, R.color.neutral_300)
|
||||
switch.trackTintList = checkedColors(R.color.primary_400, R.color.neutral_600)
|
||||
}
|
||||
|
||||
private fun checkedColors(checked: Int, unchecked: Int) = ColorStateList(
|
||||
arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()),
|
||||
intArrayOf(activity.getColor(checked), activity.getColor(unchecked)),
|
||||
)
|
||||
|
||||
private fun dp(value: Int): Int = (value * activity.resources.displayMetrics.density).roundToInt()
|
||||
|
||||
private companion object {
|
||||
const val TAG = "WiiCompiledLauncher"
|
||||
const val MENU_RENAME = 1
|
||||
const val MENU_DELETE = 2
|
||||
}
|
||||
}
|
||||
@@ -114,18 +114,28 @@ class SettingsPage(
|
||||
}
|
||||
|
||||
private fun buildVr() {
|
||||
val firstPerson = { c: TomlConfig -> c.bool("vr", "first_person") ?: false }
|
||||
// Flat Screen mode keeps races on the menu screen, which none of the race view rows reach.
|
||||
val immersive = { c: TomlConfig -> !(c.bool("vr", "flat_screen") ?: false) }
|
||||
val firstPerson = { c: TomlConfig -> immersive(c) && (c.bool("vr", "first_person") ?: false) }
|
||||
// The steering wheel and hand steering belong to the cockpit seat.
|
||||
val cockpit = { c: TomlConfig -> firstPerson(c) && stringIndex(c, "vr", "first_person_seat", SEATS) == 0 }
|
||||
section(R.string.section_vr_camera) {
|
||||
toggle(
|
||||
R.string.vr_flat_screen, R.string.vr_flat_screen_helper,
|
||||
read = { !immersive(it) },
|
||||
write = { c, value -> c.setBool("vr", "flat_screen", value) },
|
||||
)
|
||||
choice(
|
||||
R.string.vr_camera, R.string.vr_camera_helper,
|
||||
listOf(R.string.vr_camera_chase, R.string.vr_camera_first_person),
|
||||
read = { if (firstPerson(it)) 1 else 0 },
|
||||
read = { if (it.bool("vr", "first_person") == true) 1 else 0 },
|
||||
write = { c, index -> c.setBool("vr", "first_person", index == 1) },
|
||||
enabledIf = immersive,
|
||||
)
|
||||
choice(
|
||||
R.string.vr_rotation, R.string.vr_rotation_helper,
|
||||
listOf(R.string.vr_rotation_yaw, R.string.vr_rotation_yaw_pitch, R.string.vr_rotation_full),
|
||||
read = { stringIndex(it, "vr", "first_person_rotation", ROTATIONS) },
|
||||
read = { stringIndex(it, "vr", "first_person_rotation", ROTATIONS, ROTATION_DEFAULT) },
|
||||
write = { c, index -> c.setString("vr", "first_person_rotation", ROTATIONS[index]) },
|
||||
enabledIf = firstPerson,
|
||||
)
|
||||
@@ -151,11 +161,26 @@ class SettingsPage(
|
||||
},
|
||||
enabledIf = firstPerson,
|
||||
)
|
||||
choice(
|
||||
R.string.vr_seat, R.string.vr_seat_helper,
|
||||
listOf(R.string.vr_seat_cockpit, R.string.vr_seat_custom),
|
||||
read = { stringIndex(it, "vr", "first_person_seat", SEATS) },
|
||||
write = { c, index -> c.setString("vr", "first_person_seat", SEATS[index]) },
|
||||
enabledIf = firstPerson,
|
||||
)
|
||||
// heurazy's grab-and-turn wheel: runtime_config.h's kVrHandSteeringDefault is on.
|
||||
toggle(
|
||||
R.string.vr_hand_steering, R.string.vr_hand_steering_helper,
|
||||
read = { it.bool("vr", "hand_steering") ?: true },
|
||||
write = { c, value -> c.setBool("vr", "hand_steering", value) },
|
||||
enabledIf = cockpit,
|
||||
)
|
||||
slider(
|
||||
R.string.vr_lean_back, R.string.vr_lean_back_helper, -45.0, 45.0, 1.0,
|
||||
read = { number(it, "vr", "lean_back_degrees", -45.0, 45.0, 0.0) },
|
||||
format = { "%.0f°".format(it) },
|
||||
write = { c, value -> c.setFloat("vr", "lean_back_degrees", value) },
|
||||
enabledIf = immersive,
|
||||
)
|
||||
}
|
||||
section(R.string.section_vr_headset) {
|
||||
@@ -187,6 +212,7 @@ class SettingsPage(
|
||||
R.string.vr_hud_screen, R.string.vr_hud_screen_helper,
|
||||
read = { it.bool("vr", "hud_virtual_screen") ?: true },
|
||||
write = { c, value -> c.setBool("vr", "hud_virtual_screen", value) },
|
||||
enabledIf = immersive,
|
||||
)
|
||||
slider(
|
||||
R.string.vr_hud_distance, R.string.vr_hud_distance_helper, 0.5, 5.0, 0.1,
|
||||
@@ -200,6 +226,11 @@ class SettingsPage(
|
||||
format = { "%.1f m".format(it) },
|
||||
write = { c, value -> c.setFloat("vr", "hud_width_meters", value) },
|
||||
)
|
||||
toggle(
|
||||
R.string.vr_passthrough, R.string.vr_passthrough_helper,
|
||||
read = { it.bool("vr", "passthrough") ?: true },
|
||||
write = { c, value -> c.setBool("vr", "passthrough", value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +258,11 @@ class SettingsPage(
|
||||
read = { it.bool("video", "skip_unready_pipelines") ?: true },
|
||||
write = { c, value -> c.setBool("video", "skip_unready_pipelines", value) },
|
||||
)
|
||||
toggle(
|
||||
R.string.graphics_gx_thread, R.string.graphics_gx_thread_helper,
|
||||
read = { it.bool("video", "gx_thread") ?: true },
|
||||
write = { c, value -> c.setBool("video", "gx_thread", value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +326,7 @@ class SettingsPage(
|
||||
action(R.string.about_extract, R.string.about_extract_helper, R.string.home_select_disc, enabled = idle) {
|
||||
selectDiscImage()
|
||||
}
|
||||
info(R.string.about_disc_md5, activity.getString(R.string.disc_md5), stacked = true)
|
||||
// One row per game this app carries a kit for, so both are visible at once.
|
||||
for (profile in GameProfile.available(activity)) {
|
||||
val manifest = GameLibrary.manifest(activity, profile)
|
||||
@@ -356,6 +393,7 @@ class SettingsPage(
|
||||
}
|
||||
section(R.string.section_about_credits) {
|
||||
info(R.string.about_credit_title_vr, activity.getString(R.string.about_credit_vr), stacked = true)
|
||||
info(R.string.about_credit_title_hand_steering, activity.getString(R.string.about_credit_hand_steering), stacked = true)
|
||||
info(R.string.about_credit_title_wiicompiled, activity.getString(R.string.about_credit_wiicompiled), stacked = true)
|
||||
info(R.string.about_credit_title_retro_rewind, activity.getString(R.string.about_credit_retro_rewind), stacked = true)
|
||||
info(R.string.about_credit_title_wheel_wizard, activity.getString(R.string.about_credit_wheel_wizard), stacked = true)
|
||||
@@ -676,6 +714,11 @@ class SettingsPage(
|
||||
|
||||
private companion object {
|
||||
val ROTATIONS = listOf("yaw", "yaw_pitch", "full")
|
||||
|
||||
/** runtime_config.h's kVrFirstPersonRotationDefault. */
|
||||
val ROTATION_DEFAULT = ROTATIONS.indexOf("yaw_pitch")
|
||||
// The runtime's default ("cockpit") first.
|
||||
val SEATS = listOf("cockpit", "custom")
|
||||
// The runtime's default ("boost") first: an absent key reads as index 0.
|
||||
val PERFORMANCE_LEVELS = listOf("boost", "sustained_high", "sustained_low", "power_savings", "default")
|
||||
val CONTROLLER_MODES = listOf("wii_remote", "gamepad")
|
||||
@@ -697,8 +740,9 @@ class SettingsPage(
|
||||
config.number(section, key)?.takeIf { it in min..max } ?: default
|
||||
|
||||
/** Unrecognised strings fall back to the first (default) option, as in the runtime. */
|
||||
fun stringIndex(config: TomlConfig, section: String, key: String, values: List<String>): Int =
|
||||
values.indexOf(config.string(section, key)).coerceAtLeast(0)
|
||||
fun stringIndex(config: TomlConfig, section: String, key: String, values: List<String>,
|
||||
default: Int = 0): Int =
|
||||
values.indexOf(config.string(section, key)).takeIf { it >= 0 } ?: default
|
||||
|
||||
fun resolution(config: TomlConfig): Double =
|
||||
config.number("video", "resolution_multiplier")?.takeIf { it in SUPPORTED_RESOLUTIONS } ?: 1.0
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2.5"
|
||||
android:pathData="M6,15L12,9L18,15" />
|
||||
</vector>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's FileImport icon (Font Awesome, 512x512). -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M128 64c0-35.3 28.7-64 64-64L352 0l0 128c0 17.7 14.3 32 32 32l128 0 0 288c0 35.3-28.7 64-64 64l-256 0c-35.3 0-64-28.7-64-64l0-112 174.1 0-39 39c-9.4 9.4-9.4 24.6 0 33.9s24.6 9.4 33.9 0l80-80c9.4-9.4 9.4-24.6 0-33.9l-80-80c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l39 39L128 288l0-224zm0 224l0 48L24 336c-13.3 0-24-10.7-24-24s10.7-24 24-24l104 0zM512 128l-128 0L384 0 512 128z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's ContextDots icon (Font Awesome, 128x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:translateX="192">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's CubesStacked icon (Font Awesome, 448x512), centred on a square. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="512"
|
||||
android:viewportHeight="512">
|
||||
<group android:translateX="32">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M192 64l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32zM82.7 207c-15.3 8.8-20.5 28.4-11.7 43.7l32 55.4c8.8 15.3 28.4 20.5 43.7 11.7l55.4-32c15.3-8.8 20.5-28.4 11.7-43.7l-32-55.4c-8.8-15.3-28.4-20.5-43.7-11.7L82.7 207zM288 192c-17.7 0-32 14.3-32 32l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0zm64 160c-17.7 0-32 14.3-32 32l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0zM160 384l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0c-17.7 0-32 14.3-32 32zM32 352c-17.7 0-32 14.3-32 32l0 64c0 17.7 14.3 32 32 32l64 0c17.7 0 32-14.3 32-32l0-64c0-17.7-14.3-32-32-32l-64 0z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -61,6 +61,20 @@
|
||||
android:text="@string/launcher_nav_home" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/nav_patches"
|
||||
style="@style/Launcher.NavItem">
|
||||
|
||||
<ImageView
|
||||
style="@style/Launcher.NavIcon"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_patches" />
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.NavLabel"
|
||||
android:text="@string/launcher_nav_patches" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/nav_settings"
|
||||
style="@style/Launcher.NavItem">
|
||||
@@ -105,6 +119,11 @@
|
||||
android:id="@+id/page_home"
|
||||
layout="@layout/page_home" />
|
||||
|
||||
<include
|
||||
android:id="@+id/page_patches"
|
||||
layout="@layout/page_patches"
|
||||
android:visibility="gone" />
|
||||
|
||||
<include
|
||||
android:id="@+id/page_settings"
|
||||
layout="@layout/page_settings"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- WheelWizard's Patches page (Views/Pages/ModsPage.axaml) without its mod browser. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="24dp"
|
||||
android:paddingEnd="24dp">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="64dp">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.PageTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="bottom"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/launcher_nav_patches" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_import"
|
||||
style="@style/Launcher.ImportButton"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_empty"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="56dp"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_patches"
|
||||
android:tint="@color/neutral_600" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:text="@string/patches_empty_title"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="20sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center"
|
||||
android:maxWidth="440dp"
|
||||
android:text="@string/patches_empty_message"
|
||||
android:textColor="@color/neutral_400"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_empty_import"
|
||||
style="@style/Launcher.ImportButton"
|
||||
android:layout_marginTop="18dp"
|
||||
android:minWidth="190dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
style="@style/Launcher.SectionLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/patches_mods" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_count"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:background="@drawable/bg_pill"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="2dp"
|
||||
android:paddingEnd="10dp"
|
||||
android:paddingBottom="2dp"
|
||||
android:textColor="@color/neutral_300"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/patches_enable_all_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/patches_enable_all"
|
||||
android:textColor="@color/neutral_200"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<Switch
|
||||
android:id="@+id/patches_enable_all"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/neutral_700" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:fadeScrollbars="false"
|
||||
android:scrollbarStyle="outsideOverlay">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/patches_rows"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="28dp" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
</LinearLayout>
|
||||
@@ -8,6 +8,7 @@
|
||||
<!-- Launcher shell -->
|
||||
<string name="launcher_section_general">General</string>
|
||||
<string name="launcher_nav_home">Home</string>
|
||||
<string name="launcher_nav_patches">Patches</string>
|
||||
<string name="launcher_nav_settings">Settings</string>
|
||||
<string name="launcher_version">v%1$s</string>
|
||||
|
||||
@@ -97,6 +98,38 @@
|
||||
<string name="about_reset_helper">Removes the game files, and if you tick them the built games and the Retro Rewind pack, so they can be set up again. Saves and settings stay.</string>
|
||||
<string name="game_storage_failed">The game folder could not be prepared: %1$s</string>
|
||||
|
||||
<!-- Patches: WheelWizard's mods page, its wording from the PC launcher's en.yml -->
|
||||
<string name="patches_import">Import</string>
|
||||
<string name="patches_importing">Importing…</string>
|
||||
<string name="patches_empty_title">No mods found</string>
|
||||
<string name="patches_empty_message">Mods can alter how the game works. Start importing your first mod by clicking the button below.\n\nAs on the computer, mods change Retro Rewind. Import the mod\'s files, or the .zip it came in.</string>
|
||||
<string name="patches_mods">Mods</string>
|
||||
<string name="patches_enable_all">Enable all</string>
|
||||
<string name="patches_note">Each time Retro Rewind starts, the enabled mods are copied into its Patches folder. When two mods replace the same file, the one higher in the list wins.</string>
|
||||
<string name="patches_move_up">Move up</string>
|
||||
<string name="patches_move_down">Move down</string>
|
||||
<string name="patches_more">More</string>
|
||||
<string name="patches_rename">Rename</string>
|
||||
<string name="patches_delete">Delete</string>
|
||||
<string name="patches_keep">Keep</string>
|
||||
<string name="patches_name_title">Mod name:</string>
|
||||
<string name="patches_name_hint">Enter mod name…</string>
|
||||
<string name="patches_rename_title">Enter new name</string>
|
||||
<string name="patches_rename_message">Changing name from: %1$s</string>
|
||||
<string name="patches_name_empty">Mod name cannot be empty.</string>
|
||||
<string name="patches_name_exists">Mod name already exists.</string>
|
||||
<string name="patches_name_illegal">Mod name contains illegal characters.</string>
|
||||
<string name="patches_installed">Mod \'%1$s\' installed successfully.</string>
|
||||
<string name="patches_import_failed">The mod could not be imported</string>
|
||||
<string name="patches_change_failed">The mod could not be changed: %1$s</string>
|
||||
<string name="patches_delete_title">Are you sure you want to delete %1$s?</string>
|
||||
<string name="patches_delete_message">Deleting is permanent and cannot be undone.</string>
|
||||
<string name="patches_no_picker">This headset has no file picker to choose mod files with.</string>
|
||||
<string name="patches_clear_title">Mods found</string>
|
||||
<string name="patches_clear_message">You are about to launch the game without mods. Do you want to clear your Patches folder? If you keep it, the game starts with the patches it holds still active.</string>
|
||||
<string name="home_installing_patches">Installing mods…</string>
|
||||
<string name="home_patches_failed">The mods could not be installed</string>
|
||||
|
||||
<!-- Settings shell -->
|
||||
<string name="settings_tab_vr">VR</string>
|
||||
<string name="settings_tab_graphics">Graphics</string>
|
||||
@@ -110,6 +143,7 @@
|
||||
<string name="about_app_name">WiiCompiled OpenXR VR</string>
|
||||
<string name="about_app_summary">Mario Kart Wii recompiled to native code, with an OpenXR renderer, running on this headset. No game code and no game data come with it: each game is built from the disc you own.</string>
|
||||
<string name="about_credit_vr">Meta Quest port and the OpenXR VR renderer by iChris4.</string>
|
||||
<string name="about_credit_hand_steering">The cockpit\'s turning steering wheel and hand steering by heurazy, from mario-kart-wii-VR-port (GPL v3).</string>
|
||||
<string name="about_credit_wiicompiled">WiiCompiled, the static recompilation this is built on, by patchzyy and the static recompilation community.</string>
|
||||
<string name="about_credit_retro_rewind">Retro Rewind by ZPL and team, the mod distribution this app can download and play.</string>
|
||||
<string name="about_credit_wheel_wizard">Wheel Wizard by Patchzy and WantToBeeMe, the PC launcher this panel is modelled on.</string>
|
||||
@@ -122,6 +156,7 @@
|
||||
<string name="about_licence_text">GNU General Public License v3. Not affiliated with, endorsed by, or associated with Nintendo. Mario Kart Wii is a trademark of Nintendo.</string>
|
||||
<string name="about_kit">Game kit</string>
|
||||
<string name="about_credit_title_vr">Meta Quest and VR</string>
|
||||
<string name="about_credit_title_hand_steering">Steering wheel and hand steering</string>
|
||||
<string name="about_credit_title_wiicompiled">WiiCompiled</string>
|
||||
<string name="about_credit_title_retro_rewind">Retro Rewind</string>
|
||||
<string name="about_credit_title_wheel_wizard">Wheel Wizard</string>
|
||||
@@ -139,6 +174,8 @@
|
||||
|
||||
<!-- VR tab -->
|
||||
<string name="section_vr_camera">Camera</string>
|
||||
<string name="vr_flat_screen">Flat Screen mode</string>
|
||||
<string name="vr_flat_screen_helper">Plays races on the same flat screen as the menus instead of all around you. The camera settings below do not apply while it is on.</string>
|
||||
<string name="vr_camera">Camera</string>
|
||||
<string name="vr_camera_helper">Ride behind the kart like the game, or sit in the driver\'s seat.</string>
|
||||
<string name="vr_camera_chase">Chase camera</string>
|
||||
@@ -153,6 +190,12 @@
|
||||
<string name="vr_hide_nothing">Nothing</string>
|
||||
<string name="vr_hide_driver">Driver</string>
|
||||
<string name="vr_hide_driver_and_kart">Driver and kart</string>
|
||||
<string name="vr_seat">Seat</string>
|
||||
<string name="vr_seat_helper">The cockpit puts you at the driver\'s eyes, life-size, with the steering wheel or handlebar turning within reach. Custom uses the head offsets from the in-headset settings.</string>
|
||||
<string name="vr_seat_cockpit">Cockpit</string>
|
||||
<string name="vr_seat_custom">Custom</string>
|
||||
<string name="vr_hand_steering">Hand steering</string>
|
||||
<string name="vr_hand_steering_helper">In the cockpit, squeeze a grip near the steering wheel or handlebar to grab it, and turn it to steer. Releasing both grips gives steering back to the stick. Hand steering by heurazy.</string>
|
||||
<string name="vr_lean_back">Lean back angle</string>
|
||||
<string name="vr_lean_back_helper">Tilts the race view back for playing reclined. 0 applies no tilt.</string>
|
||||
|
||||
@@ -178,6 +221,8 @@
|
||||
<string name="vr_hud_distance_helper">How far the menu screen and race HUD sit in front of you.</string>
|
||||
<string name="vr_hud_width">Screen width</string>
|
||||
<string name="vr_hud_width_helper">How wide the menu screen and race HUD are.</string>
|
||||
<string name="vr_passthrough">Passthrough around the menu screen</string>
|
||||
<string name="vr_passthrough_helper">Shows your room through the headset\'s cameras around the menus instead of black. Races stay fully virtual, except in Flat Screen mode.</string>
|
||||
|
||||
<!-- Graphics tab -->
|
||||
<string name="section_graphics">Rendering</string>
|
||||
@@ -190,6 +235,8 @@
|
||||
<string name="graphics_bloom_helper">Bloom\'s bright glow reads poorly in a headset, so it starts off.</string>
|
||||
<string name="graphics_skip_unready">Prevent shader stutters</string>
|
||||
<string name="graphics_skip_unready_helper">Skips a draw for a moment while its shader compiles instead of pausing the game.</string>
|
||||
<string name="graphics_gx_thread">Graphics thread</string>
|
||||
<string name="graphics_gx_thread_helper">Prepares the drawing on a second CPU core so busy scenes keep their speed. Turn off only to compare against the single-threaded path. Takes effect on the next launch.</string>
|
||||
|
||||
<!-- Controls tab -->
|
||||
<string name="section_controls">Controllers</string>
|
||||
@@ -213,7 +260,7 @@
|
||||
<string name="controls_map_z">Z</string>
|
||||
<string name="controls_map_z_value">Left trigger</string>
|
||||
<string name="controls_map_c">C</string>
|
||||
<string name="controls_map_c_value">Left grip</string>
|
||||
<string name="controls_map_c_value">Right B</string>
|
||||
<string name="controls_map_pointer">Pointer and motion</string>
|
||||
<string name="controls_map_pointer_value">Aim and move the right controller</string>
|
||||
<string name="controls_map_panel">Settings panel</string>
|
||||
@@ -240,6 +287,10 @@
|
||||
<string name="about_logs">Logs</string>
|
||||
<string name="about_extract">Extract from disc image</string>
|
||||
<string name="about_extract_helper">Replaces DATA with the files of your own PAL disc image.</string>
|
||||
<!-- MD5 of the clean PAL (RMCP01) disc as a plain .iso (Redump). -->
|
||||
<string name="disc_md5" translatable="false">E7B1FF1FABB0789482CE2CB0661D986E</string>
|
||||
<string name="disc_md5_note">Your disc image must be the clean PAL game: as a .iso file its MD5 hash is %1$s. Compressed images (WBFS, RVZ…) have a different hash.</string>
|
||||
<string name="about_disc_md5">Clean PAL .iso MD5</string>
|
||||
<string name="about_game">Game</string>
|
||||
<string name="about_game_ready">Built by %1$s on %2$s</string>
|
||||
<string name="about_game_missing">Not installed yet</string>
|
||||
|
||||
@@ -70,6 +70,24 @@
|
||||
<item name="android:duplicateParentState">true</item>
|
||||
</style>
|
||||
|
||||
<!-- The Patches page's Import, WheelWizard's Default button with its FileImport icon. -->
|
||||
<style name="Launcher.ImportButton">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">40dp</item>
|
||||
<item name="android:gravity">center</item>
|
||||
<item name="android:paddingStart">16dp</item>
|
||||
<item name="android:paddingEnd">18dp</item>
|
||||
<item name="android:background">@drawable/bg_button_secondary</item>
|
||||
<item name="android:drawableStart">@drawable/ic_file_import</item>
|
||||
<item name="android:drawablePadding">10dp</item>
|
||||
<item name="android:drawableTint">@color/neutral_100</item>
|
||||
<item name="android:text">@string/patches_import</item>
|
||||
<item name="android:textColor">@color/neutral_100</item>
|
||||
<item name="android:textSize">15sp</item>
|
||||
<item name="android:clickable">true</item>
|
||||
<item name="android:focusable">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Launcher.Tab">
|
||||
<item name="android:layout_width">wrap_content</item>
|
||||
<item name="android:layout_height">44dp</item>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package org.wiicompiled.quest.launcher
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/** The Patches page keeps mods as WheelWizard does and fills the Patches folder as it does at launch. */
|
||||
class ModLibraryTest {
|
||||
|
||||
private fun temp(): File = Files.createTempDirectory("mods").toFile()
|
||||
|
||||
private fun source(name: String, text: String) = ModLibrary.Source(name) { text.byteInputStream() }
|
||||
|
||||
private fun zip(name: String, vararg entries: Pair<String, String>): ModLibrary.Source {
|
||||
val bytes = ByteArrayOutputStream().also { out ->
|
||||
ZipOutputStream(out).use { zip ->
|
||||
for ((path, text) in entries) {
|
||||
zip.putNextEntry(ZipEntry(path))
|
||||
zip.write(text.toByteArray())
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}.toByteArray()
|
||||
return ModLibrary.Source(name) { bytes.inputStream() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsTheIniThePcWrites() {
|
||||
// IniParser's output: CRLF, spaces around '=', .NET's True/False, sometimes a BOM.
|
||||
val text = "[Mod]\r\nName = Fast Karts\r\nAuthor = someone\r\nModID = 4242\r\nIsEnabled = False\r\nPriority = 3\r\n"
|
||||
assertEquals(ModLibrary.Mod("Fast Karts", enabled = false, priority = 3, author = "someone", modId = 4242), ModLibrary.parseIni(text))
|
||||
// Absent values take the PC's defaults; a nameless file is no mod.
|
||||
assertEquals(ModLibrary.Mod("Solo", enabled = true, priority = 0), ModLibrary.parseIni("[Mod]\nName=Solo\n"))
|
||||
assertNull(ModLibrary.parseIni("[Mod]\nPriority = 1\n"))
|
||||
val mod = ModLibrary.Mod("Round Trip", enabled = true, priority = 7, author = "a", modId = 9)
|
||||
assertEquals(mod, ModLibrary.parseIni(ModLibrary.iniText(mod)))
|
||||
assertTrue(ModLibrary.iniText(mod).contains("IsEnabled = True"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun namesFollowThePcRules() {
|
||||
val mods = listOf(ModLibrary.Mod("Taken", true, 1))
|
||||
assertEquals(ModLibrary.NameProblem.Empty, ModLibrary.validateName(" ", mods))
|
||||
assertEquals(ModLibrary.NameProblem.Exists, ModLibrary.validateName("taken", mods))
|
||||
assertEquals(ModLibrary.NameProblem.IllegalCharacters, ModLibrary.validateName("v1.2", mods))
|
||||
assertEquals(ModLibrary.NameProblem.IllegalCharacters, ModLibrary.validateName("a:b", mods))
|
||||
assertNull(ModLibrary.validateName(" New Mod ", mods))
|
||||
assertEquals("Fire Kart", ModLibrary.suggestName("Fire Kart.tag.szs", mods))
|
||||
assertEquals("", ModLibrary.suggestName("Taken.zip", mods))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun importsLooseFilesAndZipsIntoOneMod() {
|
||||
val modsDir = temp()
|
||||
val first = ModLibrary.import(modsDir, " Karts ", listOf(source("Common.szs", "c"), zip("pack.zip", "Patches/Menu.szs" to "m")), emptyList())
|
||||
assertEquals(ModLibrary.Mod("Karts", enabled = true, priority = 1), first)
|
||||
assertEquals("c", File(modsDir, "Karts/Common.szs").readText())
|
||||
assertEquals("m", File(modsDir, "Karts/Patches/Menu.szs").readText())
|
||||
assertTrue(File(modsDir, "Karts/Karts.ini").isFile)
|
||||
// A new mod goes below every existing one, and nothing is left of the staging folder.
|
||||
val second = ModLibrary.import(modsDir, "Music", listOf(source("a.brstm", "a")), ModLibrary.load(modsDir))
|
||||
assertEquals(2, second.priority)
|
||||
assertEquals(listOf("Karts", "Music"), ModLibrary.load(modsDir).map { it.title })
|
||||
assertEquals(setOf("Karts", "Music"), modsDir.list()!!.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refusesWhatItCannotImportAndLeavesNothing() {
|
||||
val modsDir = temp()
|
||||
for (sources in listOf(listOf(zip("evil.zip", "../escape.szs" to "x")), listOf(source("mod.rar", "r")), listOf(zip("empty.zip", "folder/" to "")))) {
|
||||
try {
|
||||
ModLibrary.import(modsDir, "Bad", sources, emptyList())
|
||||
fail("imported ${sources.map { it.name }}")
|
||||
} catch (expected: IOException) {
|
||||
}
|
||||
}
|
||||
assertFalse(File(modsDir.parentFile, "escape.szs").exists())
|
||||
assertEquals(0, modsDir.list()!!.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun movingSwapsPriorityWithTheNeighbour() {
|
||||
val mods = listOf(ModLibrary.Mod("A", true, 1), ModLibrary.Mod("B", true, 2), ModLibrary.Mod("C", true, 5))
|
||||
assertEquals(listOf(ModLibrary.Mod("B", true, 1), ModLibrary.Mod("A", true, 2)), ModLibrary.move(mods, mods[1], up = true))
|
||||
assertEquals(listOf(ModLibrary.Mod("B", true, 5), ModLibrary.Mod("C", true, 2)), ModLibrary.move(mods, mods[1], up = false))
|
||||
assertTrue(ModLibrary.move(mods, mods[0], up = true).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameMovesTheFolderAndItsIni() {
|
||||
val modsDir = temp()
|
||||
val mod = ModLibrary.import(modsDir, "Old", listOf(source("x.szs", "x")), emptyList())
|
||||
val renamed = ModLibrary.rename(modsDir, mod, "New", ModLibrary.load(modsDir))
|
||||
assertEquals(listOf(renamed), ModLibrary.load(modsDir))
|
||||
assertTrue(File(modsDir, "New/x.szs").isFile)
|
||||
assertFalse(File(modsDir, "New/Old.ini").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modArchivesTakeTheirModsPriority() {
|
||||
assertEquals("3.Kart.tag.szs", ModLibrary.launchName(3, "Kart.tag.szs"))
|
||||
assertEquals("3.Kart.tag.szs", ModLibrary.launchName(3, "12.Kart.tag.szs"))
|
||||
assertEquals("Common.szs", ModLibrary.launchName(3, "Common.szs"))
|
||||
assertEquals("track.brstm", ModLibrary.launchName(3, "track.brstm"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theTopOfTheListWinsAndDisabledModsAreLeftOut() {
|
||||
val modsDir = temp()
|
||||
val top = ModLibrary.import(modsDir, "Top", listOf(source("Common.szs", "top"), source("Top.brstm", "t")), emptyList())
|
||||
val bottom = ModLibrary.import(modsDir, "Bottom", listOf(source("COMMON.szs", "bottom"), source("Bottom.brstm", "b")), listOf(top))
|
||||
val off = ModLibrary.Mod("Off", enabled = false, priority = 3)
|
||||
File(modsDir, "Off").mkdirs()
|
||||
File(modsDir, "Off/Off.brstm").writeText("o")
|
||||
ModLibrary.save(modsDir, off)
|
||||
|
||||
val plan = ModLibrary.plan(modsDir, ModLibrary.load(modsDir))
|
||||
assertEquals(setOf("common.szs", "top.brstm", "bottom.brstm"), plan.keys.map { it.lowercase() }.toSet())
|
||||
assertEquals("top", plan.entries.single { it.key.equals("common.szs", ignoreCase = true) }.value.readText())
|
||||
assertEquals(listOf(top, bottom, off), ModLibrary.load(modsDir))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncLeavesExactlyThePlanInPatches() {
|
||||
val modsDir = temp()
|
||||
val mod = ModLibrary.import(modsDir, "Mod", listOf(source("New.szs", "new")), emptyList())
|
||||
val patches = File(temp(), "Patches").apply { mkdirs() }
|
||||
File(patches, "Stale.szs").writeText("stale")
|
||||
File(patches, "keep-folder").mkdirs()
|
||||
|
||||
ModLibrary.sync(patches, ModLibrary.plan(modsDir, listOf(mod)))
|
||||
assertEquals(setOf("New.szs", "keep-folder"), patches.list()!!.toSet())
|
||||
assertEquals("new", File(patches, "New.szs").readText())
|
||||
|
||||
// Nothing enabled: the folder is only cleared when the player agrees.
|
||||
val disabled = listOf(mod.copy(enabled = false))
|
||||
assertTrue(ModLibrary.shouldAskToClear(disabled, patches))
|
||||
assertNull(ModLibrary.prepareForLaunch(modsDir, patches, disabled, clear = false))
|
||||
assertTrue(File(patches, "New.szs").isFile)
|
||||
assertNull(ModLibrary.prepareForLaunch(modsDir, patches, disabled, clear = true))
|
||||
assertFalse(patches.exists())
|
||||
assertFalse(ModLibrary.shouldAskToClear(disabled, patches))
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ if (AURORA_ENABLE_GX)
|
||||
# OpenXR-enabled runtime can fail back to desktop mode at runtime instead
|
||||
# of producing unresolved interop references.
|
||||
if (CMAKE_SYSTEM_NAME STREQUAL Windows)
|
||||
target_sources(aurora_core PRIVATE lib/webgpu/d3d12_interop.cpp)
|
||||
target_sources(aurora_core PRIVATE lib/webgpu/d3d12_interop.cpp lib/webgpu/vulkan_win32_interop.cpp)
|
||||
endif ()
|
||||
# Android/Vulkan counterpart: the AHardwareBuffer stereo bridge. The file
|
||||
# compiles to C ABI stubs on every other platform so the runtime's OpenXR
|
||||
|
||||
@@ -116,6 +116,54 @@ typedef enum {
|
||||
// accepted through aurora_end_frame_tagged() with an exact matching tag.
|
||||
#define AURORA_STEREO_CONTENT_TAG_UNKNOWN UINT64_MAX
|
||||
|
||||
/**
|
||||
* VR cockpit overlay: tracked hands and, when the vehicle's own wheel cannot be
|
||||
* animated, a synthetic steering wheel or handlebar. Everything is in metres
|
||||
* in a seated frame (+X right, +Y up, -Z forward) whose origin is the headset's
|
||||
* immersive base position. Aurora draws it per eye after the scene, depth-tested
|
||||
* against the scene with the scene's own depth mapping.
|
||||
*/
|
||||
typedef struct {
|
||||
bool tracked;
|
||||
bool held;
|
||||
float squeeze;
|
||||
float seatFromGrip[12];
|
||||
} AuroraCockpitHand;
|
||||
|
||||
typedef struct {
|
||||
bool active;
|
||||
float wheelAngle;
|
||||
// The vehicle's own wheel is animated in the scene, so no synthetic wheel is drawn.
|
||||
bool nativeWheel;
|
||||
bool bike;
|
||||
float handlebarRadius;
|
||||
// World units per metre used to build this packet's eye transforms.
|
||||
float unitsPerMeter;
|
||||
float seatFromHandlebar[12];
|
||||
float eyeFromSeat[AURORA_STEREO_EYE_COUNT][12];
|
||||
AuroraCockpitHand hands[2];
|
||||
} AuroraCockpit;
|
||||
|
||||
typedef struct {
|
||||
float position[3];
|
||||
int16_t joints[4];
|
||||
float weights[4];
|
||||
} AuroraVRHandVertex;
|
||||
|
||||
/**
|
||||
* Shows the headset settings panel (aurora_imgui_set_stereo_overlay) as the
|
||||
* OpenXR backend's own compositor quad layer instead of drawing it into the
|
||||
* eyes, so the eye resolution no longer limits its text. The backend then asks
|
||||
* for the panel image as an extra stereo target. Any thread.
|
||||
*/
|
||||
void aurora_set_stereo_panel_layer(bool enabled);
|
||||
|
||||
// Copies optional runtime-provided hand meshes (XR_FB_hand_tracking_mesh, 26
|
||||
// joints). Null clears to the procedural glove. Bind poses: x,y,z,w,px,py,pz.
|
||||
void aurora_set_vr_hand_mesh(uint32_t hand, const AuroraVRHandVertex* vertices, uint32_t vertexCount,
|
||||
const uint16_t* indices, uint32_t indexCount, const float* bindPoses,
|
||||
const int32_t* parents, uint32_t jointCount);
|
||||
|
||||
/**
|
||||
* Stereo data for one sealed GX frame. frameToken is opaque to Aurora and is
|
||||
* forwarded unchanged to the internal stereo output sink. contentTag must
|
||||
@@ -130,6 +178,8 @@ typedef struct {
|
||||
// Predicted display time converted to std::chrono::steady_clock nanoseconds.
|
||||
// Zero disables temporal interpolation for this packet.
|
||||
uint64_t displayTimeNanos;
|
||||
// Optional; inactive when zero-initialised.
|
||||
AuroraCockpit cockpit;
|
||||
} AuroraStereoFrame;
|
||||
|
||||
/**
|
||||
@@ -218,6 +268,17 @@ void aurora_end_frame();
|
||||
// Seal the current frame with an opaque application safety tag. Aurora rejects
|
||||
// an immersive provider packet unless its contentTag matches this exact frame.
|
||||
void aurora_end_frame_tagged(uint64_t contentTag);
|
||||
// aurora_end_frame_tagged() plus the host-owned ImGui frame to present with it (the handle from
|
||||
// aurora_imgui_host_frame_end(), which this call consumes; NULL presents no host ImGui frame).
|
||||
void aurora_end_frame_ex(uint64_t contentTag, void* imguiFrame);
|
||||
// When the host pumps SDL events itself (aurora_update() on the window's thread) and drives
|
||||
// begin/end frame from another thread, this stops those calls from pumping events.
|
||||
void aurora_set_host_event_pump(bool hostPumps);
|
||||
typedef void (*AuroraFrameLogCallback)(char* buffer, uint32_t bufferSize, double windowSeconds,
|
||||
uint32_t frames);
|
||||
// Called with each five-second frame-rate log window (where that log is enabled); a non-empty
|
||||
// buffer is logged as one extra line.
|
||||
void aurora_set_frame_log_callback(AuroraFrameLogCallback callback);
|
||||
/**
|
||||
* Relocates the immersive camera for the frame about to be sealed.
|
||||
*
|
||||
@@ -236,10 +297,28 @@ void aurora_end_frame_tagged(uint64_t contentTag);
|
||||
* provider, which cannot know which frame will consume its packet.
|
||||
*/
|
||||
void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]);
|
||||
// As above, also naming the world units per metre the anchor was built with.
|
||||
// The sealed frame then owns that scale: each eye's head/IPD translation is
|
||||
// rescaled from the packet's AuroraCockpit::unitsPerMeter to it.
|
||||
void aurora_set_stereo_scene_anchor_scaled(const float anchorFromScene[12], float unitsPerMeter);
|
||||
// Select Player 1's subview for immersive replay of 2-4 local screens.
|
||||
// Producer-thread, per-frame metadata, consumed by the next end_frame call.
|
||||
// One (the default) keeps full-frame replay. Desktop rendering is unaffected.
|
||||
void aurora_set_stereo_local_player_count(uint32_t count);
|
||||
/**
|
||||
* VR native steering wheel: replacement position arrays for the local vehicle.
|
||||
*
|
||||
* GX (command processor) thread only, in order with the frame's draws: a host
|
||||
* that runs GX on its own thread must post these there. `source` is the host
|
||||
* pointer the game binds with GXSetArray; `replacement` is copied (at most 64
|
||||
* KiB) and applies solely to draws that bind that array with a position matrix
|
||||
* equal to `modelView` (row-major 3x4), so shared opponent models stay intact.
|
||||
* Clearing reports how many draws the previous set matched.
|
||||
*/
|
||||
void aurora_clear_native_wheel_vertices(void);
|
||||
void aurora_set_native_wheel_vertices(const void* source, const void* replacement, uint32_t size,
|
||||
const float* modelView);
|
||||
uint32_t aurora_native_wheel_draw_count(void);
|
||||
typedef void (*AuroraFrameWorkerWaitCallback)();
|
||||
// Called from the producer thread at bounded intervals while Aurora waits for
|
||||
// the asynchronous frame worker. The callback must not enter Aurora.
|
||||
|
||||
@@ -64,6 +64,17 @@ bool aurora_d3d12_set_stereo_targets(uint64_t frameToken,
|
||||
const AuroraD3D12StereoTarget* targets,
|
||||
uint32_t targetCount);
|
||||
|
||||
/**
|
||||
* The same, plus the headset settings panel's quad-layer image when `panel` is
|
||||
* not null (aurora_set_stereo_panel_layer): Aurora copies the panel into it, or
|
||||
* a transparent image while the panel is not showing, with the eyes and under
|
||||
* the same completion callback.
|
||||
*/
|
||||
bool aurora_d3d12_set_stereo_targets_with_panel(uint64_t frameToken,
|
||||
const AuroraD3D12StereoTarget* targets,
|
||||
uint32_t targetCount,
|
||||
const AuroraD3D12StereoTarget* panel);
|
||||
|
||||
/**
|
||||
* Withdraws frameToken only while its target has not been encoded. This is
|
||||
* safe to race with Aurora's frame worker: false means the worker already owns
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
// Versioned C ABI: no STL objects or compiler-specific C++ symbols cross the
|
||||
// MSVC Dawn DLL / LLVM-MinGW runtime boundary. Vulkan handles are borrowed.
|
||||
#define AURORA_DAWN_VULKAN_ABI 1
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct {
|
||||
void* userdata;
|
||||
int32_t (*createInstance)(void*, void* getProc, const void* info, const void* allocator, void** instance);
|
||||
int32_t (*createDevice)(void*, void* getProc, void* physical, const void* info, const void* allocator, void** device);
|
||||
int32_t (*getPhysicalDevice)(void*, void* instance, void** physical);
|
||||
} AuroraDawnVulkanHooks;
|
||||
typedef struct {
|
||||
void* instance;
|
||||
void* physicalDevice;
|
||||
void* device;
|
||||
uint32_t queueFamily;
|
||||
uint32_t queueIndex;
|
||||
} AuroraDawnVulkanHandles;
|
||||
typedef uint32_t (*AuroraDawnVulkanVersionFn)(void);
|
||||
typedef int (*AuroraDawnVulkanConfigureFn)(const AuroraDawnVulkanHooks*);
|
||||
typedef int (*AuroraDawnVulkanHandlesFn)(void* device, AuroraDawnVulkanHandles*);
|
||||
typedef void* (*AuroraDawnVulkanWrapFn)(void* device, const void* textureDescriptor, uint64_t image);
|
||||
typedef int (*AuroraDawnVulkanReleaseFn)(void* device, void* const* textures, uint32_t count);
|
||||
typedef void* (*AuroraDawnVulkanLockFn)(void* device);
|
||||
typedef void (*AuroraDawnVulkanUnlockFn)(void* guard);
|
||||
typedef int (*AuroraDawnVulkanDrainFn)(void* device);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -25,6 +25,14 @@ ImTextureID aurora_imgui_add_texture(uint32_t width, uint32_t height, const void
|
||||
// producer before the frame is sealed, and leave the draw data untouched until the frame worker is
|
||||
// done with that frame (aurora_wait_for_frame_worker). Null hides the panel.
|
||||
void aurora_imgui_set_stereo_overlay(ImDrawData* drawData, float widthFraction);
|
||||
// Host-owned ImGui frames for the desktop overlay. Begin starts the next frame on the calling
|
||||
// thread (which must be the window's thread, since the SDL backend reads the window there); end
|
||||
// renders it and returns a handle to a private copy of its draw data, which aurora_end_frame_ex()
|
||||
// consumes. A handle that is never presented is freed with aurora_imgui_host_frame_release().
|
||||
// From the first begin on, aurora no longer starts ImGui frames itself.
|
||||
void aurora_imgui_host_frame_begin(void);
|
||||
void* aurora_imgui_host_frame_end(void);
|
||||
void aurora_imgui_host_frame_release(void* imguiFrame);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <span>
|
||||
|
||||
namespace aurora {
|
||||
// Every referenced position that changes must belong to the local body matrix.
|
||||
// Unchanged positions may use other joints in the same indexed draw (wing/engine).
|
||||
inline bool NativeWheelDrawMatches(std::span<const uint8_t> original,
|
||||
std::span<const uint8_t> replacement, uint32_t positionStride,
|
||||
std::span<const uint8_t> vertices, uint32_t vertexStride, uint32_t positionOffset,
|
||||
uint32_t indexBytes, uint16_t matchingMatrices) {
|
||||
if(!positionStride || !vertexStride || (indexBytes!=1 && indexBytes!=2) ||
|
||||
positionOffset>=vertexStride || indexBytes>vertexStride-positionOffset ||
|
||||
original.size()!=replacement.size() || vertices.size()%vertexStride || !matchingMatrices) return false;
|
||||
bool changed=false;
|
||||
for(size_t start=0;start<vertices.size();start+=vertexStride) {
|
||||
const auto* vertex=vertices.data()+start;
|
||||
const uint32_t index=indexBytes==1 ? vertex[positionOffset]
|
||||
: (uint32_t(vertex[positionOffset])<<8)|vertex[positionOffset+1];
|
||||
const size_t offset=size_t(index)*positionStride;
|
||||
if(offset>original.size() || positionStride>original.size()-offset) return false;
|
||||
if(std::memcmp(original.data()+offset,replacement.data()+offset,positionStride)==0) continue;
|
||||
const uint32_t matrix=vertex[0]/3u;
|
||||
if(vertex[0]%3u || matrix>=16 || !(matchingMatrices&(1u<<matrix))) return false;
|
||||
changed=true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,8 @@ extern "C" {
|
||||
*/
|
||||
|
||||
enum { AURORA_VULKAN_STEREO_MAX_TARGETS = 2 };
|
||||
// The eyes, then the settings panel's quad-layer image when one was given.
|
||||
enum { AURORA_VULKAN_STEREO_MAX_RELEASES = AURORA_VULKAN_STEREO_MAX_TARGETS + 1 };
|
||||
|
||||
/**
|
||||
* Borrowed facts about Aurora's Dawn Vulkan device. colorVkFormat is the
|
||||
@@ -109,6 +111,18 @@ bool aurora_vulkan_set_stereo_targets(uint64_t frameToken,
|
||||
const AuroraVulkanStereoTarget* targets,
|
||||
uint32_t targetCount);
|
||||
|
||||
/**
|
||||
* The same, plus the headset settings panel's quad-layer buffer when `panel` is
|
||||
* not null (aurora_set_stereo_panel_layer): Aurora copies the panel into it, or
|
||||
* a transparent image while the panel is not showing, with the eyes. Its
|
||||
* release entry follows the eyes' in the submitted callback, whose
|
||||
* releaseCount then counts it too.
|
||||
*/
|
||||
bool aurora_vulkan_set_stereo_targets_with_panel(uint64_t frameToken,
|
||||
const AuroraVulkanStereoTarget* targets,
|
||||
uint32_t targetCount,
|
||||
const AuroraVulkanStereoTarget* panel);
|
||||
|
||||
/**
|
||||
* Withdraws frameToken only while its targets have not been encoded. Semantics
|
||||
* match aurora_d3d12_cancel_stereo_targets: false means the worker already
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
#include <aurora/dawn_vulkan_abi.h>
|
||||
#include <aurora/d3d12_interop.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
// Same pending-target/callback contract as D3D12. resource carries a VkImage
|
||||
// encoded as a pointer-sized value; colorDxgiFormat carries a VkFormat.
|
||||
bool aurora_vulkan_win32_configure(const AuroraDawnVulkanHooks* hooks);
|
||||
bool aurora_vulkan_win32_get_handles(AuroraDawnVulkanHandles* handles, int64_t* colorFormat);
|
||||
bool aurora_vulkan_win32_enable(AuroraD3D12StereoSubmittedCallback submitted, void* userdata);
|
||||
bool aurora_vulkan_win32_set_targets(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count);
|
||||
// Plus the headset settings panel's quad-layer image when panel is not null, as
|
||||
// aurora_d3d12_set_stereo_targets_with_panel.
|
||||
bool aurora_vulkan_win32_set_targets_with_panel(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count,
|
||||
const AuroraD3D12StereoTarget* panel);
|
||||
bool aurora_vulkan_win32_cancel(uint64_t token);
|
||||
bool aurora_vulkan_win32_disable();
|
||||
void* aurora_vulkan_win32_lock_queue();
|
||||
void aurora_vulkan_win32_unlock_queue(void* guard);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+184
-30
@@ -28,6 +28,7 @@
|
||||
#include "gfx/pipeline_cache.hpp"
|
||||
#endif
|
||||
#if defined(__ANDROID__)
|
||||
#include <pthread.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
#include "system_info.hpp"
|
||||
@@ -69,6 +70,9 @@ AuroraConfig g_config;
|
||||
uint32_t g_sdlCustomEventsStart;
|
||||
char g_gameName[4];
|
||||
std::atomic<AuroraFrameWorkerWaitCallback> g_frameWorkerWaitCallback{nullptr};
|
||||
// aurora_set_host_event_pump(): the host pumps SDL itself, from the window's thread.
|
||||
std::atomic_bool g_hostEventPump{false};
|
||||
std::atomic<AuroraFrameLogCallback> g_frameLogCallback{nullptr};
|
||||
// Presentation schedule for the frame being sealed, set by the producer. Jobs carry absolute
|
||||
// deadlines derived from it, so the presenter cannot drift. Zero means present when ready.
|
||||
std::atomic<uint64_t> g_presentScheduleBaseNanos{0};
|
||||
@@ -109,6 +113,9 @@ struct StereoSceneAnchor {
|
||||
};
|
||||
bool active = false;
|
||||
uint32_t localPlayerCount = 1;
|
||||
// World units per metre the anchor was built with, or zero when the packet's
|
||||
// own scale applies (aurora_set_stereo_scene_anchor_scaled).
|
||||
float unitsPerMeter = 0.f;
|
||||
};
|
||||
// Producer thread only, between aurora_set_stereo_scene_anchor() and the seal
|
||||
// that consumes it. Cleared at every seal so a producer that stops publishing
|
||||
@@ -266,7 +273,8 @@ enum class ImGuiFramePolicy {
|
||||
bool begin_frame_impl(bool pumpEvents, ImGuiFramePolicy imguiPolicy = ImGuiFramePolicy::Immediate,
|
||||
bool* imguiNewFrameOwed = nullptr) noexcept;
|
||||
bool begin_frame_render_state_impl(ImGuiFramePolicy imguiPolicy, bool* imguiNewFrameOwed) noexcept;
|
||||
void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag, const StereoSceneAnchor& sceneAnchor) noexcept;
|
||||
void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag, const StereoSceneAnchor& sceneAnchor,
|
||||
imgui::HostFramePtr hostImGuiFrame) noexcept;
|
||||
|
||||
// The two publication points of a frame-worker cycle, cleared together under `mutex`. Sealed:
|
||||
// producer-shared renderer state is free again. Done: slots encoded, presented, ImGui restarted.
|
||||
@@ -288,6 +296,7 @@ struct FrameWorkerState {
|
||||
// belong to that exact queued frame, not to the producer's next frame.
|
||||
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||
StereoSceneAnchor sceneAnchor{};
|
||||
imgui::HostFramePtr hostImGuiFrame;
|
||||
// Readiness is polled thousands of times per frame, so these flags double as a publication
|
||||
// barrier. `sealed` is released before `ready`, and both are cleared under `mutex`.
|
||||
std::atomic_bool sealed{true};
|
||||
@@ -338,7 +347,7 @@ bool frame_worker_requested() noexcept {
|
||||
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
// Returns false when a stop request was observed mid-cycle.
|
||||
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
||||
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag, imgui::HostFramePtr hostImGuiFrame,
|
||||
const StereoSceneAnchor& sceneAnchor) noexcept;
|
||||
void run_retained_stereo_frame(gfx::SealedFrame& sealedFrame) noexcept;
|
||||
#endif
|
||||
@@ -350,6 +359,9 @@ void frame_worker_main() noexcept {
|
||||
}
|
||||
#if defined(__ANDROID__)
|
||||
g_frameWorkerNativeThreadId.store(static_cast<uint32_t>(gettid()), std::memory_order_release);
|
||||
// A thread inherits its creator's name, and the producer that starts this
|
||||
// worker may itself be a named thread; profiles should tell the two apart.
|
||||
pthread_setname_np(pthread_self(), "aurora worker");
|
||||
#endif
|
||||
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
@@ -361,6 +373,7 @@ void frame_worker_main() noexcept {
|
||||
for (;;) {
|
||||
uint64_t contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||
StereoSceneAnchor sceneAnchor{};
|
||||
imgui::HostFramePtr hostImGuiFrame;
|
||||
bool stereoOnly = false;
|
||||
{
|
||||
std::unique_lock lock(g_frameWorker.mutex);
|
||||
@@ -377,6 +390,7 @@ void frame_worker_main() noexcept {
|
||||
g_frameWorker.contentTag = AURORA_STEREO_CONTENT_TAG_UNKNOWN;
|
||||
sceneAnchor = g_frameWorker.sceneAnchor;
|
||||
g_frameWorker.sceneAnchor = {};
|
||||
hostImGuiFrame = std::move(g_frameWorker.hostImGuiFrame);
|
||||
g_frameWorker.jobPending = false;
|
||||
}
|
||||
|
||||
@@ -396,7 +410,7 @@ void frame_worker_main() noexcept {
|
||||
g_frameWorker.cv.notify_all();
|
||||
continue;
|
||||
}
|
||||
if (!run_frame_worker_cycle(sealedFrame, contentTag, sceneAnchor)) {
|
||||
if (!run_frame_worker_cycle(sealedFrame, contentTag, std::move(hostImGuiFrame), sceneAnchor)) {
|
||||
break;
|
||||
}
|
||||
#else
|
||||
@@ -619,7 +633,7 @@ void ensure_stereo_eye_target(uint32_t eyeIndex, uint32_t width, uint32_t height
|
||||
const uint32_t samples = webgpu::g_graphicsConfig.msaaSamples;
|
||||
if (target.color.texture && target.requestedWidth == width && target.requestedHeight == height &&
|
||||
target.samples == samples && target.colorFormat == webgpu::g_graphicsConfig.surfaceConfiguration.format &&
|
||||
target.depthFormat == webgpu::g_graphicsConfig.depthFormat) {
|
||||
target.depthFormat == wgpu::TextureFormat::Depth24PlusStencil8) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -629,19 +643,21 @@ void ensure_stereo_eye_target(uint32_t eyeIndex, uint32_t width, uint32_t height
|
||||
target.resolvedColor = webgpu::create_render_texture(target.color.size.width, target.color.size.height, false);
|
||||
}
|
||||
|
||||
// The cockpit uses one stencil bit to survive later depth-disabled HUD draws.
|
||||
// Keep this attachment eye-only; native EFB depth sampling is unchanged.
|
||||
const wgpu::TextureDescriptor depthDescriptor{
|
||||
.label = eyeIndex == 0 ? "Stereo left eye depth" : "Stereo right eye depth",
|
||||
.usage = wgpu::TextureUsage::RenderAttachment,
|
||||
.dimension = wgpu::TextureDimension::e2D,
|
||||
.size = target.color.size,
|
||||
.format = webgpu::g_graphicsConfig.depthFormat,
|
||||
.format = wgpu::TextureFormat::Depth24PlusStencil8,
|
||||
.mipLevelCount = 1,
|
||||
.sampleCount = samples,
|
||||
};
|
||||
target.depth.texture = g_device.CreateTexture(&depthDescriptor);
|
||||
target.depth.view = target.depth.texture.CreateView();
|
||||
target.depth.size = target.color.size;
|
||||
target.depth.format = webgpu::g_graphicsConfig.depthFormat;
|
||||
target.depth.format = wgpu::TextureFormat::Depth24PlusStencil8;
|
||||
target.requestedWidth = width;
|
||||
target.requestedHeight = height;
|
||||
target.samples = samples;
|
||||
@@ -702,6 +718,27 @@ std::optional<AuroraStereoFrame> request_stereo_frame(uint32_t logicalFrame, uin
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
// The cockpit overlay is optional: a bad one is dropped, never the frame.
|
||||
if (frame.cockpit.active) {
|
||||
const auto& cockpit = frame.cockpit;
|
||||
bool valid = finite(&cockpit.wheelAngle, 1) && finite(&cockpit.handlebarRadius, 1) &&
|
||||
finite(&cockpit.unitsPerMeter, 1) && cockpit.unitsPerMeter > 0.f &&
|
||||
finite(cockpit.seatFromHandlebar, 12);
|
||||
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||
valid = valid && finite(cockpit.eyeFromSeat[eye], 12);
|
||||
}
|
||||
for (const auto& hand : cockpit.hands) {
|
||||
valid = valid && finite(&hand.squeeze, 1) && finite(hand.seatFromGrip, 12);
|
||||
}
|
||||
if (!valid) {
|
||||
static bool cockpitRejectionLogged = false;
|
||||
if (!cockpitRejectionLogged) {
|
||||
cockpitRejectionLogged = true;
|
||||
Log.warn("Stereo frame {} carries a non-finite VR cockpit; drawing it without the cockpit", logicalFrame);
|
||||
}
|
||||
frame.cockpit = {};
|
||||
}
|
||||
}
|
||||
return frame;
|
||||
}
|
||||
|
||||
@@ -709,6 +746,16 @@ gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input,
|
||||
Mat3x4<float> anchorFromScene;
|
||||
std::memcpy(&anchorFromScene, sceneAnchor.anchorFromScene.data(), sizeof(anchorFromScene));
|
||||
gfx::StereoReplayFrame replay{};
|
||||
replay.cockpit = input.cockpit;
|
||||
// The sealed guest frame owns its scale. The packet may have been sampled
|
||||
// just before a change of scale (a character swap, a lightning strike), so
|
||||
// only its head/IPD translation is rescaled to the frame's.
|
||||
const float frameUnits = sceneAnchor.active && sceneAnchor.unitsPerMeter > 0.f ? sceneAnchor.unitsPerMeter
|
||||
: input.cockpit.unitsPerMeter;
|
||||
const float unitRatio = input.cockpit.unitsPerMeter > 0.f && frameUnits > 0.f
|
||||
? frameUnits / input.cockpit.unitsPerMeter
|
||||
: 1.f;
|
||||
replay.cockpit.unitsPerMeter = frameUnits;
|
||||
for (uint32_t eye = 0; eye < AURORA_STEREO_EYE_COUNT; ++eye) {
|
||||
ensure_stereo_eye_target(eye, input.eyes[eye].width, input.eyes[eye].height);
|
||||
const auto& owned = g_stereoEyeTargets[eye];
|
||||
@@ -723,9 +770,15 @@ gfx::StereoReplayFrame make_stereo_replay_frame(const AuroraStereoFrame& input,
|
||||
.copySourceDepthView = owned.depth.view,
|
||||
.size = owned.color.size,
|
||||
.msaaSamples = webgpu::g_graphicsConfig.msaaSamples,
|
||||
.depthFormat = owned.depth.format,
|
||||
};
|
||||
std::memcpy(&view.projection, input.eyes[eye].projection, sizeof(view.projection));
|
||||
std::memcpy(&view.viewFromCenter, input.eyes[eye].viewFromCenter, sizeof(view.viewFromCenter));
|
||||
if (unitRatio != 1.f) {
|
||||
view.viewFromCenter.m0[3] *= unitRatio;
|
||||
view.viewFromCenter.m1[3] *= unitRatio;
|
||||
view.viewFromCenter.m2[3] *= unitRatio;
|
||||
}
|
||||
// World draws already carry the recorded camera, so they need the anchor
|
||||
// folded in; the virtual screen is authored in the anchored camera's space
|
||||
// and keeps viewFromCenter.
|
||||
@@ -756,6 +809,7 @@ void encode_virtual_screen_eye(wgpu::CommandEncoder& encoder, const webgpu::Pres
|
||||
.label = eyeIndex == 0 ? "Virtual screen left eye" : "Virtual screen right eye",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::VirtualScreen),
|
||||
};
|
||||
{
|
||||
const auto pass = encoder.BeginRenderPass(&descriptor);
|
||||
@@ -1274,6 +1328,7 @@ bool present_presentation_job(const PresentationJob& job) {
|
||||
.label = "Presentation copy pass",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::Present),
|
||||
};
|
||||
const auto pass = encoder.BeginRenderPass(&renderPassDescriptor);
|
||||
pass.SetPipeline(webgpu::g_CopyPipeline);
|
||||
@@ -1554,7 +1609,7 @@ void publish_stereo_screen_aspects(const webgpu::PresentSource& presentSource, c
|
||||
// gfx::begin_frame() may already have cleared the display-copy override.
|
||||
void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, const webgpu::PresentSource& presentSource,
|
||||
const PresentationImage& image, bool includeImGui,
|
||||
MirrorPlan plan = MirrorPlan::Mono) {
|
||||
MirrorPlan plan = MirrorPlan::Mono, const ImDrawData* hostImGuiData = nullptr) {
|
||||
ZoneScoped;
|
||||
auto viewport = webgpu::calculate_present_viewport(image.texture.size.width, image.texture.size.height,
|
||||
presentSource.size.width, presentSource.size.height);
|
||||
@@ -1576,6 +1631,7 @@ void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, const web
|
||||
.label = "Interpolation snapshot pass",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::Snapshot),
|
||||
};
|
||||
const auto pass = encoder.BeginRenderPass(&renderPassDescriptor);
|
||||
const auto imageWidth = static_cast<float>(image.texture.size.width);
|
||||
@@ -1627,11 +1683,16 @@ void encode_presentation_snapshot(const wgpu::CommandEncoder& encoder, const web
|
||||
.label = "Snapshot ImGui pass",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::Snapshot),
|
||||
};
|
||||
const auto pass = encoder.BeginRenderPass(&renderPassDescriptor);
|
||||
pass.SetViewport(0.f, 0.f, static_cast<float>(image.texture.size.width),
|
||||
static_cast<float>(image.texture.size.height), 0.f, 1.f);
|
||||
imgui::render(pass);
|
||||
if (hostImGuiData != nullptr) {
|
||||
imgui::render(pass, hostImGuiData);
|
||||
} else {
|
||||
imgui::render(pass);
|
||||
}
|
||||
pass.End();
|
||||
}
|
||||
}
|
||||
@@ -1674,7 +1735,7 @@ bool begin_frame_impl(bool pumpEvents, ImGuiFramePolicy imguiPolicy, bool* imgui
|
||||
ZoneScoped;
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
webgpu::fail_if_device_lost();
|
||||
if (pumpEvents) {
|
||||
if (pumpEvents && !g_hostEventPump.load(std::memory_order_acquire)) {
|
||||
window::pump_events();
|
||||
}
|
||||
const bool surfaceReconfigurePending = g_surfaceReconfigurePending.load(std::memory_order_acquire);
|
||||
@@ -1731,7 +1792,9 @@ bool begin_frame_render_state_impl(ImGuiFramePolicy imguiPolicy, bool* imguiNewF
|
||||
std::lock_guard gpuLock(g_rendererGpuMutex);
|
||||
// Note the debt before gfx::begin_frame() can fail: the synchronous path always started the
|
||||
// ImGui frame here, and the runtime's retry loop depends on that pairing.
|
||||
if (imguiPolicy == ImGuiFramePolicy::Immediate) {
|
||||
if (imgui::host_frames_active()) {
|
||||
// The host starts its own ImGui frames (imgui::host_frame_begin).
|
||||
} else if (imguiPolicy == ImGuiFramePolicy::Immediate) {
|
||||
imgui::new_frame(window::get_window_size());
|
||||
} else if (imguiNewFrameOwed != nullptr) {
|
||||
*imguiNewFrameOwed = true;
|
||||
@@ -1767,8 +1830,13 @@ struct SealedFrameContext {
|
||||
std::optional<AuroraStereoFrame> stereoInput;
|
||||
bool retainStereo = false;
|
||||
imgui::StereoOverlay stereoOverlay;
|
||||
imgui::HostFramePtr imguiFrame;
|
||||
};
|
||||
|
||||
const ImDrawData* host_imgui_data(const SealedFrameContext& ctx) noexcept {
|
||||
return ctx.imguiFrame ? imgui::host_frame_draw_data(*ctx.imguiFrame) : nullptr;
|
||||
}
|
||||
|
||||
// Worker-owned scene state. A separate buffer generation check protects against
|
||||
// synchronous EFB submissions overwriting the retained frame's GPU data.
|
||||
struct RetainedStereoContext {
|
||||
@@ -1827,8 +1895,11 @@ void run_retained_stereo_frame(gfx::SealedFrame& sealedFrame) noexcept {
|
||||
// Phase 1: everything that touches producer-shared renderer state. Needs g_rendererGpuMutex and
|
||||
// a FIFO already drained into the recorded pass list.
|
||||
void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, uint64_t contentTag,
|
||||
const StereoSceneAnchor& sceneAnchor) {
|
||||
const StereoSceneAnchor& sceneAnchor, imgui::HostFramePtr hostImGuiFrame) {
|
||||
ZoneScopedN("Seal frame");
|
||||
// Every pass this cycle encodes, from the seal's probe blits to the final eye, is timed under
|
||||
// one frame; encode_sealed_frame resolves it on its last submission.
|
||||
gfx::gpu_timing_begin_frame();
|
||||
const auto encoderDescriptor = wgpu::CommandEncoderDescriptor{
|
||||
.label = "Redraw encoder",
|
||||
};
|
||||
@@ -1882,7 +1953,12 @@ void seal_frame_locked(gfx::SealedFrame& sealedFrame, SealedFrameContext& ctx, u
|
||||
ctx.presentSource = webgpu::current_present_source();
|
||||
// ImGui draw lists are built once per frame and replayed by each slot's ImGui pass, which is why
|
||||
// the next ImGui frame cannot start until the encode phase is done.
|
||||
imgui::render_frame_data();
|
||||
if (hostImGuiFrame) {
|
||||
// The host closed its own ImGui frame and handed over a copy of the draw data.
|
||||
ctx.imguiFrame = std::move(hostImGuiFrame);
|
||||
} else {
|
||||
imgui::render_frame_data();
|
||||
}
|
||||
// The headset panel's draw data follows the same rule on the host's side.
|
||||
ctx.stereoOverlay = imgui::latch_stereo_overlay();
|
||||
// Drop the sealed frame's lazy RAM-readback requests while the producer is still excluded; it
|
||||
@@ -1974,7 +2050,7 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
||||
for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) {
|
||||
gfx::render(sealedFrame, encoder, static_cast<int32_t>(interpolatedFrame), false);
|
||||
auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight);
|
||||
encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, mirrorPlan);
|
||||
encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, mirrorPlan, host_imgui_data(ctx));
|
||||
presentationJobs.push_back({
|
||||
.image = std::move(image),
|
||||
.logicalFrame = ctx.logicalFrame,
|
||||
@@ -1988,14 +2064,25 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
||||
|
||||
// A demanded CPU-visible EFB readback submits a prefix of the frame, so replaying the resumed
|
||||
// stream would mutate an already-rendered EFB. Render once, then duplicate into the slots.
|
||||
gfx::render(sealedFrame, encoder, -1, !immersiveReplay && !ctx.retainStereo);
|
||||
//
|
||||
// On a headset an immersive frame's native render is never presented: the eyes replay the draws
|
||||
// themselves and only sample the EFB copies it resolves. So it stops after the last pass that
|
||||
// produces one of those copies (never the display copy), which on a Quest 3 was 4 to 6 ms of a
|
||||
// 12 ms GPU frame spent on a 1280x720 image nobody saw. A pending CPU readback or a frame
|
||||
// capture still gets the whole image.
|
||||
int32_t nativeRenderLastPass = INT32_MAX;
|
||||
if (headsetOnly && immersiveReplay && !gfx::efb_ram::has_pending() &&
|
||||
g_captureFrame.load(std::memory_order_acquire) == UINT32_MAX) {
|
||||
nativeRenderLastPass = gfx::last_pass_feeding_replay(sealedFrame);
|
||||
}
|
||||
gfx::render(sealedFrame, encoder, -1, !immersiveReplay && !ctx.retainStereo, nativeRenderLastPass);
|
||||
// The copy targets now hold this frame's resolves, so queue their readbacks on the same encoder;
|
||||
// completion is harvested in gfx::after_submit, never waited on here.
|
||||
gfx::efb_ram::encode_async_downloads(encoder);
|
||||
if (!ctx.replayInterpolatedFrames) {
|
||||
for (uint32_t interpolatedFrame = 0; interpolatedFrame < ctx.interpolatedFrameCount; ++interpolatedFrame) {
|
||||
auto image = acquire_presentation_image(interpolatedFrame, ctx.snapshotWidth, ctx.snapshotHeight);
|
||||
encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, mirrorPlan);
|
||||
encode_presentation_snapshot(encoder, ctx.presentSource, *image, true, mirrorPlan, host_imgui_data(ctx));
|
||||
presentationJobs.push_back({
|
||||
.image = std::move(image),
|
||||
.logicalFrame = ctx.logicalFrame,
|
||||
@@ -2035,7 +2122,7 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
||||
// showing. Black re-clears it below, once the eyes have taken their copy.
|
||||
const bool virtualScreenNeedsMono = stereoOutput && !immersiveReplay;
|
||||
encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true,
|
||||
virtualScreenNeedsMono ? MirrorPlan::Mono : mirrorPlan);
|
||||
virtualScreenNeedsMono ? MirrorPlan::Mono : mirrorPlan, host_imgui_data(ctx));
|
||||
if (stereoOutput) {
|
||||
publish_stereo_screen_aspects(ctx.presentSource, finalImage->texture.size, immersiveReplay);
|
||||
}
|
||||
@@ -2057,7 +2144,8 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
||||
stereo_overlay::composite_flat(encoder, output.view, output.size, eye);
|
||||
}
|
||||
if (mirrorPlan == MirrorPlan::Black && !headsetOnly) {
|
||||
encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, MirrorPlan::Black);
|
||||
encode_presentation_snapshot(encoder, ctx.presentSource, *finalImage, true, MirrorPlan::Black,
|
||||
host_imgui_data(ctx));
|
||||
}
|
||||
}
|
||||
if (stereoOutput) {
|
||||
@@ -2070,7 +2158,9 @@ std::vector<PresentationJob> encode_sealed_frame(gfx::SealedFrame& sealedFrame,
|
||||
.presentAt = slotPresentDeadline(ctx.interpolatedFrameCount),
|
||||
.interpolated = false,
|
||||
});
|
||||
gfx::gpu_timing_end_frame(encoder);
|
||||
submitEncodedSlot(encoder, pendingStereoSink ? &*pendingStereoSink : nullptr);
|
||||
gfx::gpu_timing_after_submit();
|
||||
|
||||
// A group that finished encoding past its anchor slides forward by whole display periods, never
|
||||
// per slot. The cursor keeps two groups off one anchor, which bursts then holds for a period.
|
||||
@@ -2180,11 +2270,22 @@ void record_frame_telemetry() {
|
||||
{
|
||||
// `adb shell setprop debug.wiicompiled.fpslog 1` before launch logs the game's rendered frame rate every five
|
||||
// seconds. The headset compositor's own log (logcat tag VrApi) repeats frames, so it cannot show this.
|
||||
static const bool fpsLog = android_debug::property_int("debug.wiicompiled.fpslog", 0) == 1;
|
||||
static const bool fpsLog = [] {
|
||||
const bool on = android_debug::property_int("debug.wiicompiled.fpslog", 0) == 1;
|
||||
// The same switch turns on the per-pass GPU timestamps reported below the frame-rate line.
|
||||
gfx::gpu_timing_set_enabled(on);
|
||||
return on;
|
||||
}();
|
||||
if (fpsLog) {
|
||||
static auto windowStart = std::chrono::steady_clock::now();
|
||||
static uint32_t windowFrames = 0;
|
||||
// Draw calls are what a recorded frame costs three times over (mono and both eyes), so they belong beside the
|
||||
// GPU timings: an overlay that stops draws merging shows up here long before it shows up as a frame rate.
|
||||
static uint64_t windowDraws = 0;
|
||||
static uint64_t windowMerged = 0;
|
||||
++windowFrames;
|
||||
windowDraws += gfx::g_stats.drawCallCount;
|
||||
windowMerged += gfx::g_stats.mergedDrawCallCount;
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const std::chrono::duration<double> elapsed = now - windowStart;
|
||||
if (elapsed.count() >= 5.0) {
|
||||
@@ -2200,9 +2301,25 @@ void record_frame_telemetry() {
|
||||
const double encode = msPerFrame(g_workerEncodeNs);
|
||||
Log.info("Game frame rate {:.1f} FPS ({} frames in {:.2f} s); per frame the producer waited {:.2f} ms for "
|
||||
"DONE and {:.2f} ms for SEALED; the worker spent {:.2f} ms sealing, {:.2f} ms waiting for the "
|
||||
"prepare permit, {:.2f} ms preparing the next frame and {:.2f} ms encoding",
|
||||
"prepare permit, {:.2f} ms preparing the next frame and {:.2f} ms encoding; {:.0f} draw calls a "
|
||||
"frame ({:.0f} primitives merged away)",
|
||||
windowFrames / elapsed.count(), windowFrames, elapsed.count(), waitDone, waitSealed, seal,
|
||||
permitWait, prepare, encode);
|
||||
permitWait, prepare, encode,
|
||||
static_cast<double>(windowDraws) / std::max(windowFrames, 1u),
|
||||
static_cast<double>(windowMerged) / std::max(windowFrames, 1u));
|
||||
windowDraws = 0;
|
||||
windowMerged = 0;
|
||||
if (const std::string gpuTiming = gfx::gpu_timing_report(); !gpuTiming.empty()) {
|
||||
Log.info("{}", gpuTiming);
|
||||
}
|
||||
if (const auto frameLog = g_frameLogCallback.load(std::memory_order_acquire)) {
|
||||
char extra[512];
|
||||
extra[0] = '\0';
|
||||
frameLog(extra, sizeof(extra), elapsed.count(), windowFrames);
|
||||
if (extra[0] != '\0') {
|
||||
Log.info("{}", extra);
|
||||
}
|
||||
}
|
||||
windowStart = now;
|
||||
windowFrames = 0;
|
||||
}
|
||||
@@ -2214,7 +2331,7 @@ void record_frame_telemetry() {
|
||||
|
||||
// One complete frame-worker cycle. Desktop and headset interpolation both
|
||||
// release the producer after sealing, before encoding their extra scene views.
|
||||
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
||||
bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag, imgui::HostFramePtr hostImGuiFrame,
|
||||
const StereoSceneAnchor& sceneAnchor) noexcept {
|
||||
ZoneScopedN("Frame worker cycle");
|
||||
webgpu::fail_if_device_lost();
|
||||
@@ -2226,7 +2343,7 @@ bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
||||
auto stretchStarted = std::chrono::steady_clock::now();
|
||||
{
|
||||
std::lock_guard gpuLock(g_rendererGpuMutex);
|
||||
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor);
|
||||
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor, std::move(hostImGuiFrame));
|
||||
}
|
||||
g_workerSealNs.fetch_add(elapsedNs(stretchStarted), std::memory_order_relaxed);
|
||||
stretchStarted = std::chrono::steady_clock::now();
|
||||
@@ -2283,11 +2400,11 @@ bool run_frame_worker_cycle(gfx::SealedFrame& sealedFrame, uint64_t contentTag,
|
||||
// Synchronous frame submission: seal, encode and present inline on the calling thread. Used when
|
||||
// the frame worker is disabled (RenderDoc captures) and on the boot path.
|
||||
void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag,
|
||||
const StereoSceneAnchor& sceneAnchor) noexcept {
|
||||
const StereoSceneAnchor& sceneAnchor, imgui::HostFramePtr hostImGuiFrame) noexcept {
|
||||
ZoneScoped;
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
webgpu::fail_if_device_lost();
|
||||
if (pumpEvents) {
|
||||
if (pumpEvents && !g_hostEventPump.load(std::memory_order_acquire)) {
|
||||
window::pump_events();
|
||||
}
|
||||
gfx::SealedFrame sealedFrame;
|
||||
@@ -2298,7 +2415,7 @@ void end_frame_impl(bool pumpEvents, bool drainFifo, uint64_t contentTag,
|
||||
if (drainFifo) {
|
||||
gx::fifo::drain();
|
||||
}
|
||||
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor);
|
||||
seal_frame_locked(sealedFrame, ctx, contentTag, sceneAnchor, std::move(hostImGuiFrame));
|
||||
presentationJobs = encode_sealed_frame(sealedFrame, ctx);
|
||||
}
|
||||
publish_presentations(std::move(presentationJobs), ctx.interpolationActive);
|
||||
@@ -2324,7 +2441,9 @@ bool begin_frame() noexcept {
|
||||
ensure_frame_worker_started();
|
||||
// SDL needs event pumping on the window-owning producer thread, and the worker passes
|
||||
// pumpEvents=false, so keep it here even when the fast path returns early.
|
||||
window::pump_events();
|
||||
if (!g_hostEventPump.load(std::memory_order_acquire)) {
|
||||
window::pump_events();
|
||||
}
|
||||
bool waitForSurfacePreparation = false;
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
// A surface mutation can legitimately fail preparation, and optimistic success would let GX/ImGui
|
||||
@@ -2374,7 +2493,7 @@ bool begin_frame() noexcept {
|
||||
return prepared;
|
||||
}
|
||||
|
||||
void end_frame(uint64_t contentTag) noexcept {
|
||||
void end_frame(uint64_t contentTag, imgui::HostFramePtr hostImGuiFrame) noexcept {
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
webgpu::fail_if_device_lost();
|
||||
#endif
|
||||
@@ -2385,7 +2504,7 @@ void end_frame(uint64_t contentTag) noexcept {
|
||||
g_pendingStereoLocalPlayerCount = 1;
|
||||
g_pendingSceneAnchor = {};
|
||||
if (!frame_worker_requested()) {
|
||||
end_frame_impl(true, true, contentTag, sceneAnchor);
|
||||
end_frame_impl(true, true, contentTag, sceneAnchor, std::move(hostImGuiFrame));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2407,6 +2526,7 @@ void end_frame(uint64_t contentTag) noexcept {
|
||||
g_frameWorker.ready.store(false, std::memory_order_release);
|
||||
g_frameWorker.contentTag = contentTag;
|
||||
g_frameWorker.sceneAnchor = sceneAnchor;
|
||||
g_frameWorker.hostImGuiFrame = std::move(hostImGuiFrame);
|
||||
g_frameWorker.jobPending = true;
|
||||
g_frameWorker.prepareAllowed = false;
|
||||
}
|
||||
@@ -2509,11 +2629,45 @@ AuroraInfo aurora_initialize(int argc, char* argv[], const AuroraConfig* config)
|
||||
void aurora_shutdown() { aurora::shutdown(); }
|
||||
const AuroraEvent* aurora_update() { return aurora::update(); }
|
||||
bool aurora_begin_frame() { return aurora::begin_frame(); }
|
||||
void aurora_end_frame() { aurora::end_frame(AURORA_STEREO_CONTENT_TAG_UNKNOWN); }
|
||||
void aurora_end_frame_tagged(uint64_t contentTag) { aurora::end_frame(contentTag); }
|
||||
void aurora_end_frame() { aurora::end_frame(AURORA_STEREO_CONTENT_TAG_UNKNOWN, {}); }
|
||||
void aurora_end_frame_tagged(uint64_t contentTag) { aurora::end_frame(contentTag, {}); }
|
||||
void aurora_end_frame_ex(uint64_t contentTag, void* imguiFrame) {
|
||||
aurora::imgui::HostFramePtr frame;
|
||||
if (imguiFrame != nullptr) {
|
||||
auto* holder = static_cast<aurora::imgui::HostFramePtr*>(imguiFrame);
|
||||
frame = std::move(*holder);
|
||||
delete holder;
|
||||
}
|
||||
aurora::end_frame(contentTag, std::move(frame));
|
||||
}
|
||||
void aurora_set_host_event_pump(bool hostPumps) {
|
||||
aurora::g_hostEventPump.store(hostPumps, std::memory_order_release);
|
||||
}
|
||||
void aurora_set_frame_log_callback(AuroraFrameLogCallback callback) {
|
||||
aurora::g_frameLogCallback.store(callback, std::memory_order_release);
|
||||
}
|
||||
extern "C" void aurora_imgui_host_frame_begin(void) {
|
||||
#ifdef AURORA_ENABLE_GX
|
||||
// ImGui's WebGPU backend creates its device objects lazily from new_frame.
|
||||
std::lock_guard gpuLock(aurora::g_rendererGpuMutex);
|
||||
#endif
|
||||
aurora::imgui::host_frame_begin(aurora::window::get_window_size());
|
||||
}
|
||||
extern "C" void* aurora_imgui_host_frame_end(void) { return new aurora::imgui::HostFramePtr(aurora::imgui::host_frame_end()); }
|
||||
extern "C" void aurora_imgui_host_frame_release(void* imguiFrame) {
|
||||
delete static_cast<aurora::imgui::HostFramePtr*>(imguiFrame);
|
||||
}
|
||||
void aurora_set_stereo_scene_anchor(const float anchorFromScene[12]) {
|
||||
aurora::set_stereo_scene_anchor(anchorFromScene);
|
||||
}
|
||||
void aurora_set_stereo_scene_anchor_scaled(const float anchorFromScene[12], float unitsPerMeter) {
|
||||
aurora::set_stereo_scene_anchor(anchorFromScene);
|
||||
uint32_t bits = 0;
|
||||
std::memcpy(&bits, &unitsPerMeter, sizeof(bits));
|
||||
if (aurora::g_pendingSceneAnchor.active && (bits & 0x7f800000u) != 0x7f800000u && unitsPerMeter > 0.f) {
|
||||
aurora::g_pendingSceneAnchor.unitsPerMeter = unitsPerMeter;
|
||||
}
|
||||
}
|
||||
void aurora_set_stereo_local_player_count(uint32_t count) {
|
||||
aurora::g_pendingStereoLocalPlayerCount = count >= 1 && count <= 4 ? count : 1;
|
||||
}
|
||||
|
||||
@@ -10,10 +10,68 @@
|
||||
|
||||
#include "../../gfx/common.hpp"
|
||||
#include "../../gx/fifo.hpp"
|
||||
#include "../../gx/native_wheel.hpp"
|
||||
|
||||
// GX-thread entry points for the VR native steering wheel (native_wheel.hpp).
|
||||
// The runtime posts these in order with the frame's draws.
|
||||
extern "C" void aurora_clear_native_wheel_vertices() {
|
||||
// Clearing an empty set reports nothing, so a host that clears both before and after a frame's draws keeps
|
||||
// that frame's count.
|
||||
if (aurora::gx::nativeWheelArrays.empty()) return;
|
||||
aurora::gx::fifo::drain();
|
||||
aurora::gx::nativeWheelLastMatches.store(aurora::gx::nativeWheelMatches);
|
||||
aurora::gx::nativeWheelMatches = 0;
|
||||
aurora::gx::nativeWheelPreviousSources.clear();
|
||||
for (const auto& array : aurora::gx::nativeWheelArrays)
|
||||
aurora::gx::nativeWheelPreviousSources.push_back(array.source);
|
||||
aurora::gx::native_wheel_report();
|
||||
aurora::gx::nativeWheelArrays.clear();
|
||||
aurora::gx::nativeWheelLastDecision = nullptr;
|
||||
aurora::gx::nativeWheelLastDrawCommand = nullptr;
|
||||
}
|
||||
extern "C" uint32_t aurora_native_wheel_draw_count() { return aurora::gx::nativeWheelLastMatches.load(); }
|
||||
extern "C" void aurora_set_native_wheel_vertices(const void* source, const void* replacement, uint32_t size,
|
||||
const float* modelView) {
|
||||
if (!source || !replacement || !modelView || !size || size > 65536) return;
|
||||
aurora::gx::NativeWheelArray array;
|
||||
array.source = source;
|
||||
const auto* bytes = static_cast<const uint8_t*>(replacement);
|
||||
array.bytes.assign(bytes, bytes + size);
|
||||
std::memcpy(array.modelView.data(), modelView, sizeof(float) * 12);
|
||||
aurora::gx::nativeWheelArrays.push_back(std::move(array));
|
||||
// The vector may have moved its elements, and a set changes what a draw resolves to in any case, so the decision
|
||||
// the merge test compares against is dropped rather than left pointing into the old storage.
|
||||
aurora::gx::nativeWheelLastDecision = nullptr;
|
||||
aurora::gx::nativeWheelLastDrawCommand = nullptr;
|
||||
}
|
||||
|
||||
// Single definition for the `Log` that gx.hpp declares for this directory.
|
||||
aurora::Module Log("aurora::gx");
|
||||
|
||||
namespace aurora::gx {
|
||||
// Called as a set is cleared: a line at about half a second, five seconds and
|
||||
// a minute of sets, with the matrices on the first report that saw a draw.
|
||||
void native_wheel_report() {
|
||||
auto& diagnostics=nativeWheelDiagnostics;
|
||||
++diagnostics.sets;
|
||||
++nativeWheelClears;
|
||||
if(nativeWheelClears!=30 && nativeWheelClears!=300 && nativeWheelClears!=3600) return;
|
||||
::Log.info("Native steering wheel: {} sets; draws binding a replaced array {} ({} with a larger range, {} outside a "
|
||||
"set); matched {}; closest position matrix off by {} ({} matrices)",
|
||||
diagnostics.sets,diagnostics.boundDraws,diagnostics.oversizeDraws,diagnostics.outsideDraws,
|
||||
diagnostics.matchedDraws,diagnostics.bestError,diagnostics.bestIndexed?"indexed":"current");
|
||||
if(diagnostics.boundDraws!=0 && nativeWheelReports++==0) {
|
||||
const auto& m=diagnostics.bestMatrix;
|
||||
const auto& e=diagnostics.expected;
|
||||
::Log.info("Native steering wheel: closest [{} {} {} {} | {} {} {} {} | {} {} {} {}] expected [{} {} {} {} | {} {} "
|
||||
"{} {} | {} {} {} {}]",
|
||||
m[0],m[1],m[2],m[3],m[4],m[5],m[6],m[7],m[8],m[9],m[10],m[11],
|
||||
e[0],e[1],e[2],e[3],e[4],e[5],e[6],e[7],e[8],e[9],e[10],e[11]);
|
||||
}
|
||||
diagnostics={};
|
||||
}
|
||||
} // namespace aurora::gx
|
||||
|
||||
static void GXWriteString(const char* label) {
|
||||
auto length = strlen(label);
|
||||
|
||||
|
||||
@@ -549,6 +549,13 @@ void GXCopyTex(void* dest, GXBool clear) {
|
||||
.clearAlpha = true,
|
||||
.clearDepth = false,
|
||||
}),
|
||||
.stereoPipeline = aurora::stereo_frame_provider_active() ? aurora::gfx::pipeline_ref(aurora::gfx::clear::PipelineConfig{
|
||||
.msaaSamples = aurora::gfx::get_sample_count(),
|
||||
.clearColor = false,
|
||||
.clearAlpha = true,
|
||||
.clearDepth = false,
|
||||
.stereoStencil = true,
|
||||
}) : 0,
|
||||
.color = wgpu::Color{0.f, 0.f, 0.f, g_gxState.dstAlpha / 255.f},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ fn fs_main() -> FragmentOutput {
|
||||
.targets = &colorTarget,
|
||||
};
|
||||
const wgpu::DepthStencilState depthStencil{
|
||||
.format = g_graphicsConfig.depthFormat,
|
||||
.format = config.stereoStencil ? wgpu::TextureFormat::Depth24PlusStencil8 : g_graphicsConfig.depthFormat,
|
||||
.depthWriteEnabled = config.clearDepth,
|
||||
.depthCompare = wgpu::CompareFunction::Always,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
namespace aurora::gfx::clear {
|
||||
struct DrawData {
|
||||
PipelineRef pipeline;
|
||||
PipelineRef stereoPipeline = 0;
|
||||
Range uniformRange;
|
||||
wgpu::Color color;
|
||||
float depth = 0.f;
|
||||
@@ -18,14 +19,14 @@ struct DrawData {
|
||||
ClipRect scissor{};
|
||||
};
|
||||
|
||||
constexpr uint32_t ClearPipelineConfigVersion = 3;
|
||||
constexpr uint32_t ClearPipelineConfigVersion = 4;
|
||||
struct PipelineConfig {
|
||||
uint32_t version = ClearPipelineConfigVersion;
|
||||
uint32_t msaaSamples = 1;
|
||||
bool clearColor = true;
|
||||
bool clearAlpha = true;
|
||||
bool clearDepth = true;
|
||||
uint8_t _pad = 0;
|
||||
bool stereoStencil = false;
|
||||
};
|
||||
static_assert(std::has_unique_object_representations_v<PipelineConfig>);
|
||||
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
//
|
||||
// VR cockpit overlay: the synthetic steering wheel or handlebar (used when the
|
||||
// vehicle's own wheel cannot be animated) and the tracked hands, drawn per eye
|
||||
// in metres against the replayed scene's depth. See OPENXR.md, "Steering wheel
|
||||
// and hand steering".
|
||||
#pragma once
|
||||
#include "common.hpp"
|
||||
#include "../webgpu/gpu.hpp"
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
namespace aurora::gfx::cockpit {
|
||||
using V = std::array<float, 3>;
|
||||
using M = std::array<float, 12>;
|
||||
inline V add(V a, V b) { return {a[0]+b[0], a[1]+b[1], a[2]+b[2]}; }
|
||||
inline V sub(V a, V b) { return {a[0]-b[0], a[1]-b[1], a[2]-b[2]}; }
|
||||
inline V mul(V a, float b) { return {a[0]*b, a[1]*b, a[2]*b}; }
|
||||
inline float dot(V a, V b) { return a[0]*b[0]+a[1]*b[1]+a[2]*b[2]; }
|
||||
inline V cross(V a, V b) { return {a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]}; }
|
||||
inline V norm(V a) { return mul(a, 1/std::sqrt(std::max(dot(a,a), 1e-10f))); }
|
||||
inline V point(const float* m, V p) {
|
||||
return {m[0]*p[0]+m[1]*p[1]+m[2]*p[2]+m[3], m[4]*p[0]+m[5]*p[1]+m[6]*p[2]+m[7],
|
||||
m[8]*p[0]+m[9]*p[1]+m[10]*p[2]+m[11]};
|
||||
}
|
||||
inline M identity() { return {1,0,0,0,0,1,0,0,0,0,1,0}; }
|
||||
inline M compose(const M& a, const M& b) {
|
||||
M result{};
|
||||
for(int r=0;r<3;++r) {
|
||||
for(int c=0;c<3;++c) for(int k=0;k<3;++k) result[r*4+c]+=a[r*4+k]*b[k*4+c];
|
||||
result[r*4+3]=a[r*4+3];
|
||||
for(int k=0;k<3;++k) result[r*4+3]+=a[r*4+k]*b[k*4+3];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
inline M inverse(const M& m) {
|
||||
M out=identity();
|
||||
for(int r=0;r<3;++r) for(int c=0;c<3;++c) out[r*4+c]=m[c*4+r];
|
||||
const auto p=point(out.data(), {-m[3],-m[7],-m[11]});
|
||||
out[3]=p[0];out[7]=p[1];out[11]=p[2];return out;
|
||||
}
|
||||
inline M from_pose(const float* p) {
|
||||
const float x=p[0],y=p[1],z=p[2],w=p[3];
|
||||
return {1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w),p[4],
|
||||
2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w),p[5],
|
||||
2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y),p[6]};
|
||||
}
|
||||
struct HandMesh {
|
||||
std::vector<AuroraVRHandVertex> vertices;
|
||||
std::vector<uint16_t> indices;
|
||||
std::array<M,26> bind{}, inverseBind{};
|
||||
std::array<int32_t,26> parents{};
|
||||
};
|
||||
inline std::mutex meshMutex;
|
||||
inline std::array<std::shared_ptr<const HandMesh>,2> meshes;
|
||||
struct Vertex { V position, color; };
|
||||
inline void triangle(std::vector<Vertex>& vertices, V a, V b, V c, V color) {
|
||||
const V normal=norm(cross(sub(b,a),sub(c,a)));
|
||||
const float light=0.55f+0.45f*std::abs(dot(normal,norm({0.3f,0.8f,0.5f})));
|
||||
color=mul(color,light);
|
||||
vertices.insert(vertices.end(),{{a,color},{b,color},{c,color}});
|
||||
}
|
||||
inline void tube(std::vector<Vertex>& v, V a, V b, float radius, V color, int sides=8) {
|
||||
const auto direction=norm(sub(b,a));
|
||||
const auto u=norm(cross(direction,std::abs(direction[1])<0.9f?V{0,1,0}:V{1,0,0}));
|
||||
const auto w=cross(direction,u);
|
||||
for(int i=0;i<sides;++i) {
|
||||
const float t=float(i)*6.2831853f/sides, t1=float(i+1)*6.2831853f/sides;
|
||||
const V o=mul(add(mul(u,std::cos(t)),mul(w,std::sin(t))),radius);
|
||||
const V p=mul(add(mul(u,std::cos(t1)),mul(w,std::sin(t1))),radius);
|
||||
triangle(v,add(a,o),add(b,o),add(b,p),color);
|
||||
triangle(v,add(a,o),add(b,p),add(a,p),color);
|
||||
triangle(v,a,add(a,p),add(a,o),color);
|
||||
triangle(v,b,add(b,o),add(b,p),color);
|
||||
}
|
||||
}
|
||||
inline void ellipsoid(std::vector<Vertex>& vertices,V center,V radii,V color) {
|
||||
const auto surface=[&](int ring,int segment) {
|
||||
const float latitude=float(ring)*3.14159265f/6,longitude=float(segment)*6.2831853f/12;
|
||||
return add(center,{radii[0]*std::sin(latitude)*std::cos(longitude),radii[1]*std::cos(latitude),
|
||||
radii[2]*std::sin(latitude)*std::sin(longitude)});
|
||||
};
|
||||
for(int ring=0;ring<6;++ring) for(int segment=0;segment<12;++segment) {
|
||||
const auto a=surface(ring,segment),b=surface(ring+1,segment),c=surface(ring+1,segment+1),d=surface(ring,segment+1);
|
||||
if(ring>0) triangle(vertices,a,b,d,color);
|
||||
if(ring<5) triangle(vertices,b,c,d,color);
|
||||
}
|
||||
}
|
||||
// Rounded palm and individually articulated fingers, in the controller's grip
|
||||
// space as OpenXR defines it: the origin is the palm centroid, -Z runs up the
|
||||
// tube the curled fingers form (little finger towards thumb), and +X is normal
|
||||
// to the palm - *away* from it on the left hand, *into* it on the right. That
|
||||
// asymmetry is what makes both grips carry the same orientation when the hands
|
||||
// hold a wheel symmetrically, so the fingers run along -Y on both, and it is
|
||||
// the geometry across the palm that mirrors: fingers close towards +X on the
|
||||
// left hand and -X on the right, with the thumb on the same side. Building the
|
||||
// fingers on any other axis bends them out of the back of the hand (seen on a
|
||||
// Quest 3 on 2026-09-22) or, for the right hand alone, points them at the
|
||||
// player (seen on the PC on 2026-09-23).
|
||||
inline void glove(std::vector<Vertex>& v, const AuroraCockpitHand& hand, int side) {
|
||||
const size_t start=v.size();
|
||||
const V white{0.91f,0.95f,1.0f};
|
||||
const float palm=side==0?1.0f:-1.0f; // hand 0 is the left one
|
||||
const float curl=std::clamp(hand.held?0.85f:hand.squeeze,0.0f,1.0f);
|
||||
// Thin through the palm's normal, a little wider across the knuckles than
|
||||
// the palm is long.
|
||||
ellipsoid(v,{0,0,0},{0.018f,0.043f,0.041f},white);
|
||||
for(int finger=0;finger<4;++finger) {
|
||||
// Index finger nearest the thumb (-Z), little finger last.
|
||||
V a{0.0f,-0.030f,-0.025f+finger*0.017f};
|
||||
const float length=finger==0||finger==3?0.021f:0.026f;
|
||||
for(int joint=0;joint<3;++joint) {
|
||||
const float angle=curl*(0.55f+joint*0.8f);
|
||||
V b=add(a,{palm*std::sin(angle)*length,-std::cos(angle)*length,0.0f});
|
||||
tube(v,a,b,0.008f,white);
|
||||
ellipsoid(v,b,{0.008f,0.008f,0.008f},white);a=b;
|
||||
}
|
||||
}
|
||||
// Thumb: out of the palm's thumb side, closing across the fingers.
|
||||
const V thumbKnuckle{palm*0.026f,-0.034f,-0.030f};
|
||||
tube(v,{palm*0.010f,-0.012f,-0.034f},thumbKnuckle,0.010f,white);
|
||||
tube(v,thumbKnuckle,{palm*(0.030f+0.014f*curl),-(0.052f-0.016f*curl),-0.020f},0.009f,white);
|
||||
for(size_t i=start;i<v.size();++i) v[i].position=point(hand.seatFromGrip,v[i].position);
|
||||
}
|
||||
inline void runtime_hand(std::vector<Vertex>& out, const AuroraCockpitHand& hand, const HandMesh& mesh) {
|
||||
std::array<M,26> posed{}, skin{};
|
||||
std::array<bool,26> done{};
|
||||
const float curl=std::clamp(hand.held?0.85f:hand.squeeze,0.0f,1.0f);
|
||||
// Bind hierarchy is supplied by the runtime. Root and wrist stay rigid;
|
||||
// finger joints curl locally when controllers provide squeeze input.
|
||||
for(int pass=0;pass<26;++pass) for(int j=0;j<26;++j) {
|
||||
if(done[j]) continue;
|
||||
const int parent=mesh.parents[j];
|
||||
if(parent>=0&&parent<26&&!done[parent]) continue;
|
||||
M local=parent>=0&&parent<26?compose(mesh.inverseBind[parent],mesh.bind[j]):mesh.bind[j];
|
||||
const bool fingerJoint=j>=2 && j!=6 && j!=11 && j!=16 && j!=21;
|
||||
if(fingerJoint) {
|
||||
// OpenXR joints point -Z toward the fingertip and +Y out of the back
|
||||
// of the hand. Flexion is therefore negative about local X, for both
|
||||
// hands; positive angles bend the fingers backward on runtime meshes.
|
||||
const float a=-curl*(j<6?0.3f:0.75f),c=std::cos(a),s=std::sin(a);
|
||||
local=compose(local,M{1,0,0,0,0,c,-s,0,0,s,c,0});
|
||||
}
|
||||
posed[j]=parent>=0&&parent<26?compose(posed[parent],local):local;
|
||||
skin[j]=compose(mesh.inverseBind[1],compose(posed[j],mesh.inverseBind[j]));
|
||||
done[j]=true;
|
||||
}
|
||||
std::vector<V> points(mesh.vertices.size());
|
||||
for(size_t i=0;i<points.size();++i) {
|
||||
const auto& v=mesh.vertices[i]; V p{}; float total=0;
|
||||
for(int w=0;w<4;++w) if(v.joints[w]>=0&&v.joints[w]<26&&done[v.joints[w]]&&v.weights[w]>0) {
|
||||
p=add(p,mul(point(skin[v.joints[w]].data(),{v.position[0],v.position[1],v.position[2]}),v.weights[w]));
|
||||
total+=v.weights[w];
|
||||
}
|
||||
if(total>0) p=mul(p,1/total);
|
||||
p=add(p,{0,0,0.04f}); // wrist behind the controller grip/palm origin.
|
||||
points[i]=point(hand.seatFromGrip,p);
|
||||
}
|
||||
for(size_t i=0;i+2<mesh.indices.size();i+=3)
|
||||
triangle(out,points[mesh.indices[i]],points[mesh.indices[i+1]],points[mesh.indices[i+2]],{0.91f,0.95f,1.0f});
|
||||
}
|
||||
inline void build_geometry(const AuroraCockpit& cockpit, std::vector<Vertex>& vertices) {
|
||||
vertices.clear();vertices.reserve(12000);
|
||||
// The visible radius and position must match runtime/vr/steering_wheel.h.
|
||||
if (!cockpit.nativeWheel && cockpit.bike) {
|
||||
const float c=std::cos(cockpit.wheelAngle),s=std::sin(cockpit.wheelAngle);
|
||||
const auto barPoint=[&](float x,float y,float z) {
|
||||
return point(cockpit.seatFromHandlebar,{c*x+s*y,-s*x+c*y,z});
|
||||
};
|
||||
const float radius=cockpit.handlebarRadius;
|
||||
tube(vertices,barPoint(-radius,0,0),barPoint(radius,0,0),0.013f,{0.45f,0.48f,0.52f});
|
||||
for(float side:{-1.0f,1.0f})
|
||||
tube(vertices,barPoint(side*std::max(radius-0.10f,0.0f),0,0),barPoint(side*radius,0,0),0.024f,{0.12f,0.18f,0.19f});
|
||||
tube(vertices,barPoint(0,0,-0.13f),barPoint(0,0,0),0.023f,{0.12f,0.65f,0.61f});
|
||||
} else if (!cockpit.nativeWheel) {
|
||||
const auto rim=[&](float angle) -> V { return {0.18f*std::cos(angle),-0.30f+0.18f*std::sin(angle),-0.42f}; };
|
||||
for(int i=0;i<64;++i) {
|
||||
const float angle=float(i)*6.2831853f/64-cockpit.wheelAngle;
|
||||
const V color=i>=15&&i<=17?V{0.2f,0.9f,0.8f}:V{0.14f,0.17f,0.20f};
|
||||
tube(vertices,rim(angle),rim(angle+6.2831853f/64),0.016f,color,6);
|
||||
}
|
||||
for(float a : {0.0f,3.14159265f,4.71238898f})
|
||||
tube(vertices,{0,-0.30f,-0.42f},rim(a-cockpit.wheelAngle),0.011f,{0.45f,0.48f,0.52f});
|
||||
tube(vertices,{0,-0.30f,-0.445f},{0,-0.30f,-0.395f},0.035f,{0.12f,0.65f,0.61f},16);
|
||||
}
|
||||
std::array<std::shared_ptr<const HandMesh>,2> current;
|
||||
{ std::lock_guard lock(meshMutex);current=meshes; }
|
||||
for(int side=0;side<2;++side) if(cockpit.hands[side].tracked) {
|
||||
if(current[side]) runtime_hand(vertices,cockpit.hands[side],*current[side]);
|
||||
else glove(vertices,cockpit.hands[side],side);
|
||||
}
|
||||
}
|
||||
inline std::vector<Vertex> geometry(const AuroraCockpit& cockpit) {
|
||||
std::vector<Vertex> result;build_geometry(cockpit,result);return result;
|
||||
}
|
||||
inline std::atomic<uint64_t> meshRevision{1};
|
||||
inline std::vector<Vertex> frameVertices;
|
||||
inline AuroraCockpit cachedCockpit{};
|
||||
inline uint64_t cachedMeshRevision=0;
|
||||
inline wgpu::RenderPipeline pipeline;
|
||||
struct SceneDepth {
|
||||
float z=0, constant=0;
|
||||
bool valid=false;
|
||||
};
|
||||
inline uint32_t pipelineSamples=0;
|
||||
inline bool pipelineReversedDepth=false;
|
||||
inline wgpu::TextureFormat pipelineFormat{}, pipelineDepthFormat{};
|
||||
inline std::array<wgpu::Buffer,2> vertexBuffers;
|
||||
inline std::array<uint64_t,2> vertexCapacity{};
|
||||
inline void shutdown() { pipeline=nullptr;pipelineSamples=0;vertexBuffers={};vertexCapacity={};cachedMeshRevision=0;frameVertices.clear(); }
|
||||
inline void render(wgpu::CommandEncoder& cmd,const StereoReplayFrame& frame,uint32_t eye,SceneDepth sceneDepth={},
|
||||
const wgpu::RenderPassEncoder* existingPass=nullptr) {
|
||||
if(!frame.cockpit.active || !sceneDepth.valid) return;
|
||||
using namespace webgpu;
|
||||
const auto& target=frame.eyes[eye].target;
|
||||
const auto format=g_graphicsConfig.surfaceConfiguration.format;
|
||||
// The guest can reverse its viewport depth independently of Aurora's
|
||||
// global reversed-Z convention. The final 1/d coefficient is authoritative.
|
||||
const bool reversedDepth=sceneDepth.constant>0;
|
||||
if(!pipeline||pipelineSamples!=target.msaaSamples||pipelineFormat!=format||pipelineReversedDepth!=reversedDepth||pipelineDepthFormat!=target.depthFormat) {
|
||||
wgpu::ShaderSourceWGSL source{};
|
||||
source.code=R"(
|
||||
struct Out { @builtin(position) position: vec4f, @location(0) color: vec3f };
|
||||
@vertex fn vs(@location(0) position: vec4f, @location(1) color: vec3f) -> Out {
|
||||
var o: Out; o.position=position; o.color=color; return o;
|
||||
}
|
||||
@fragment fn fs(i: Out) -> @location(0) vec4f { return vec4f(i.color,1); }
|
||||
)";
|
||||
wgpu::ShaderModuleDescriptor md{};md.nextInChain=&source;md.label="VR cockpit hands and wheel";
|
||||
auto shader=g_device.CreateShaderModule(&md);
|
||||
const wgpu::VertexAttribute attrs[]={{.format=wgpu::VertexFormat::Float32x4,.offset=0,.shaderLocation=0},
|
||||
{.format=wgpu::VertexFormat::Float32x3,.offset=16,.shaderLocation=1}};
|
||||
const wgpu::VertexBufferLayout layout{.arrayStride=28,.attributeCount=2,.attributes=attrs};
|
||||
const wgpu::ColorTargetState color{.format=format};
|
||||
const wgpu::FragmentState fragment{.module=shader,.entryPoint="fs",.targetCount=1,.targets=&color};
|
||||
const bool stencil=target.depthFormat==wgpu::TextureFormat::Depth24PlusStencil8;
|
||||
const wgpu::StencilFaceState mark{.compare=wgpu::CompareFunction::Always,
|
||||
.passOp=stencil?wgpu::StencilOperation::Replace:wgpu::StencilOperation::Keep};
|
||||
const wgpu::DepthStencilState depth{.format=target.depthFormat,.depthWriteEnabled=true,
|
||||
.depthCompare=reversedDepth?wgpu::CompareFunction::GreaterEqual:wgpu::CompareFunction::LessEqual,
|
||||
.stencilFront=mark,.stencilBack=mark,.stencilReadMask=1,.stencilWriteMask=stencil?1u:0u};
|
||||
wgpu::RenderPipelineDescriptor desc{};desc.label="VR cockpit";
|
||||
desc.vertex={.module=shader,.entryPoint="vs",.bufferCount=1,.buffers=&layout};
|
||||
desc.fragment=&fragment;desc.depthStencil=&depth;desc.multisample.count=target.msaaSamples;
|
||||
desc.primitive.topology=wgpu::PrimitiveTopology::TriangleList;
|
||||
pipeline=g_device.CreateRenderPipeline(&desc);pipelineSamples=target.msaaSamples;pipelineFormat=format;
|
||||
pipelineReversedDepth=reversedDepth;pipelineDepthFormat=target.depthFormat;
|
||||
}
|
||||
const auto revision=meshRevision.load();
|
||||
if(cachedMeshRevision!=revision || std::memcmp(&cachedCockpit,&frame.cockpit,sizeof(AuroraCockpit))!=0) {
|
||||
build_geometry(frame.cockpit,frameVertices);
|
||||
cachedCockpit=frame.cockpit;cachedMeshRevision=revision;
|
||||
}
|
||||
const auto& vertices=frameVertices;
|
||||
if(vertices.empty()) return;
|
||||
struct ClipVertex { float p[4]; V color; };
|
||||
static std::vector<ClipVertex> clip;
|
||||
clip.resize(vertices.size());
|
||||
const auto& projection=frame.eyes[eye].projection;
|
||||
for(size_t i=0;i<clip.size();++i) {
|
||||
const auto p=point(frame.cockpit.eyeFromSeat[eye],vertices[i].position);
|
||||
// The original race near plane can sit beyond a close hand. Keep that
|
||||
// hand at the nearest representable depth instead of clipping it away.
|
||||
const float z=sceneDepth.z*p[2]+sceneDepth.constant/std::max(frame.cockpit.unitsPerMeter,0.001f);
|
||||
clip[i]={{projection.m0[0]*p[0]+projection.m0[2]*p[2],projection.m1[1]*p[1]+projection.m1[2]*p[2],
|
||||
std::clamp(z,0.0f,std::max(-p[2],0.0f)),-p[2]},vertices[i].color};
|
||||
}
|
||||
const uint64_t bytes=clip.size()*sizeof(ClipVertex);
|
||||
if (!vertexBuffers[eye] || vertexCapacity[eye]<bytes) {
|
||||
vertexCapacity[eye]=(bytes+65535)&~uint64_t(65535);
|
||||
const wgpu::BufferDescriptor bd{.label="VR cockpit vertices",.usage=wgpu::BufferUsage::Vertex|wgpu::BufferUsage::CopyDst,
|
||||
.size=vertexCapacity[eye]};
|
||||
vertexBuffers[eye]=g_device.CreateBuffer(&bd);
|
||||
}
|
||||
auto& buffer=vertexBuffers[eye];
|
||||
g_queue.WriteBuffer(buffer,0,clip.data(),bytes);
|
||||
const wgpu::RenderPassColorAttachment attachment{.view=target.colorView,.resolveTarget=target.resolveView,
|
||||
.loadOp=wgpu::LoadOp::Load,.storeOp=wgpu::StoreOp::Store};
|
||||
const wgpu::RenderPassDepthStencilAttachment depth{.view=target.depthView,.depthLoadOp=wgpu::LoadOp::Load,
|
||||
.depthStoreOp=wgpu::StoreOp::Store,.depthClearValue=1.0f,
|
||||
.stencilLoadOp=target.depthFormat==wgpu::TextureFormat::Depth24PlusStencil8?wgpu::LoadOp::Load:wgpu::LoadOp::Undefined,
|
||||
.stencilStoreOp=target.depthFormat==wgpu::TextureFormat::Depth24PlusStencil8?wgpu::StoreOp::Store:wgpu::StoreOp::Undefined};
|
||||
const wgpu::RenderPassDescriptor pd{.label="VR cockpit overlay",.colorAttachmentCount=1,.colorAttachments=&attachment,.depthStencilAttachment=&depth};
|
||||
auto pass=existingPass?*existingPass:cmd.BeginRenderPass(&pd);
|
||||
pass.SetViewport(0,0,float(target.size.width),float(target.size.height),0,1);
|
||||
pass.SetScissorRect(0,0,target.size.width,target.size.height);
|
||||
// Mark only depth-visible samples; later virtual-screen draws test for zero.
|
||||
pass.SetStencilReference(1);
|
||||
pass.SetPipeline(pipeline);pass.SetVertexBuffer(0,buffer);pass.Draw(clip.size());
|
||||
pass.SetStencilReference(0);
|
||||
if(!existingPass) pass.End();
|
||||
}
|
||||
} // namespace aurora::gfx::cockpit
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "../gx/pipeline.hpp"
|
||||
#include "pipeline_cache.hpp"
|
||||
#include "stereo_replay.hpp"
|
||||
#include "cockpit.hpp"
|
||||
#include "tex_copy_conv.hpp"
|
||||
#include "tex_palette_conv.hpp"
|
||||
#include "texture_replacement.hpp"
|
||||
@@ -158,6 +159,9 @@ uint32_t g_mergedDrawCallCount = 0;
|
||||
|
||||
using CommandList = std::vector<Command>;
|
||||
struct RenderPass {
|
||||
// The world depth mapping of this pass's last full-view perspective draw, for
|
||||
// the VR cockpit overlay (set by prepare_stereo_replay_uniforms).
|
||||
cockpit::SceneDepth cockpitDepth{};
|
||||
wgpu::TextureView colorView;
|
||||
wgpu::TextureView resolveView; // MSAA resolve target; null if msaaSamples == 1
|
||||
wgpu::TextureView depthView;
|
||||
@@ -733,6 +737,13 @@ void resolve_pass(TextureHandle texture, ClipRect rect, bool clearColor, bool cl
|
||||
.clearAlpha = clearAlpha,
|
||||
.clearDepth = clearDepth,
|
||||
}),
|
||||
.stereoPipeline = aurora::stereo_frame_provider_active() ? pipeline_ref(clear::PipelineConfig{
|
||||
.msaaSamples = msaaSamples,
|
||||
.clearColor = clearColor,
|
||||
.clearAlpha = clearAlpha,
|
||||
.clearDepth = clearDepth,
|
||||
.stereoStencil = true,
|
||||
}) : 0,
|
||||
.color =
|
||||
wgpu::Color{
|
||||
.r = clearColorValue.x(),
|
||||
@@ -1057,6 +1068,7 @@ void initialize() {
|
||||
}
|
||||
|
||||
void shutdown() {
|
||||
cockpit::shutdown();
|
||||
shutdown_pipeline_cache();
|
||||
gx::clear_shader_module_cache();
|
||||
efb_ram::shutdown();
|
||||
@@ -1512,6 +1524,7 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
|
||||
std::array<uint8_t, gx::MaxUniformSize> sourceUniform;
|
||||
std::array<uint8_t, gx::MaxUniformSize> eyeUniform;
|
||||
for (auto& pass : g_renderPasses) {
|
||||
pass.cockpitDepth = {};
|
||||
if (!pass.efbTarget) {
|
||||
continue;
|
||||
}
|
||||
@@ -1541,6 +1554,19 @@ static bool prepare_stereo_replay_uniforms(const StereoReplayFrame& stereoFrame,
|
||||
std::memcpy(sourceUniform.data(), g_uniforms.data() + draw.uniformRange.offset, draw.uniformRange.size);
|
||||
Mat4x4<float> gameProjection;
|
||||
std::memcpy(&gameProjection, sourceUniform.data() + layout.projectionOffset, sizeof(gameProjection));
|
||||
// The VR cockpit overlay (hands, synthetic wheel) is drawn in metres and
|
||||
// depth-tested against the world, so it needs the world's own depth
|
||||
// mapping: the backend depth row of a full-view world draw, with this
|
||||
// viewport's depth range folded in because the overlay draws with 0..1.
|
||||
// Camera-attached effects share the camera's projection, so any full-view
|
||||
// perspective draw describes the same mapping.
|
||||
if (layout.perspective && !layout.nativeEfbEffect && gameProjection.m2[3] != 0.0f &&
|
||||
drawViewport.width >= displayRegion.width * 0.9f && drawViewport.height >= displayRegion.height * 0.9f) {
|
||||
const auto row = stereo_replay::backend_ndc_depth_row(gameProjection);
|
||||
const float low = std::clamp(std::min(drawViewport.znear, drawViewport.zfar), 0.f, 1.f);
|
||||
const float high = std::clamp(std::max(drawViewport.znear, drawViewport.zfar), 0.f, 1.f);
|
||||
pass.cockpitDepth = {row[2] * (high - low) - low, row[3] * (high - low), true};
|
||||
}
|
||||
// Only a genuinely affine projection carries its NDC position in its clip
|
||||
// position, which is what the virtual screen reprojection consumes. GX
|
||||
// tracks the projection type separately from the matrix, so a 2D draw
|
||||
@@ -1724,12 +1750,22 @@ struct RenderInvocation {
|
||||
uint32_t localPlayerCount = 1;
|
||||
// Inclusive index of the last pass to replay; -1 replays every pass.
|
||||
int32_t replayLastPass = -1;
|
||||
// Inclusive index of the last pass that does render work; texture bakes still run for the
|
||||
// passes after it. See last_pass_feeding_replay.
|
||||
int32_t renderLastPass = INT32_MAX;
|
||||
bool finalize = true;
|
||||
bool replayOnlyEfb = false;
|
||||
bool skipCopyClears = false;
|
||||
bool encodeTextureBakes = true;
|
||||
bool encodeResolves = true;
|
||||
bool captureDepth = true;
|
||||
// VR cockpit overlay, drawn inside the scene's pass just before the first
|
||||
// virtual-screen draw so the 2D layer's depth cannot hide it (see render_stereo_eye).
|
||||
const StereoReplayFrame* cockpitFrame = nullptr;
|
||||
wgpu::CommandEncoder* cockpitEncoder = nullptr;
|
||||
cockpit::SceneDepth cockpitDepth{};
|
||||
bool* cockpitDrawn = nullptr;
|
||||
bool* sceneDrawn = nullptr;
|
||||
};
|
||||
|
||||
static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vector<RenderPass>& passes, u32 idx,
|
||||
@@ -1740,6 +1776,9 @@ static void render_impl(std::vector<RenderPass>& renderPasses, wgpu::CommandEnco
|
||||
ZoneScoped;
|
||||
// Palette conversions, MSAA resolves and EFB copies depend on sealed frame state, not on the
|
||||
// interpolation weight, so encode them on the native render and let replay slots sample them.
|
||||
// Eye textures are reused; discard the previous frame's mask, then retain it
|
||||
// across guest passes even if the HUD clears or replaces guest depth.
|
||||
bool stencilInitialized = false;
|
||||
for (u32 i = 0; i < renderPasses.size(); ++i) {
|
||||
const auto& passInfo = renderPasses[i];
|
||||
if (invocation.replayLastPass >= 0 && i > static_cast<u32>(invocation.replayLastPass)) {
|
||||
@@ -1755,6 +1794,11 @@ static void render_impl(std::vector<RenderPass>& renderPasses, wgpu::CommandEnco
|
||||
tex_palette_conv::run(cmd, conv);
|
||||
}
|
||||
}
|
||||
if (static_cast<int32_t>(i) > invocation.renderLastPass) {
|
||||
// Nothing after the last replay-feeding resolve is shown or sampled on a headset; the
|
||||
// bakes above are all these passes owe the eye replays.
|
||||
continue;
|
||||
}
|
||||
const bool hasRenderWork = passInfo.clearColor || passInfo.clearDepth || !passInfo.commands.empty();
|
||||
if (i == renderPasses.size() - 1) {
|
||||
ASSERT(!passInfo.resolveTarget, "Final render pass must not have resolve target");
|
||||
@@ -1787,19 +1831,30 @@ static void render_impl(std::vector<RenderPass>& renderPasses, wgpu::CommandEnco
|
||||
},
|
||||
},
|
||||
};
|
||||
const bool stereoStencil = overrideTarget &&
|
||||
invocation.target->depthFormat == wgpu::TextureFormat::Depth24PlusStencil8;
|
||||
const wgpu::RenderPassDepthStencilAttachment depthStencilAttachment{
|
||||
.view = depthView,
|
||||
.depthLoadOp = passInfo.clearDepth && !dropCopyClear ? wgpu::LoadOp::Clear : wgpu::LoadOp::Load,
|
||||
.depthStoreOp = wgpu::StoreOp::Store,
|
||||
.depthClearValue = passInfo.clearDepthValue,
|
||||
.stencilLoadOp = stereoStencil ? (stencilInitialized ? wgpu::LoadOp::Load : wgpu::LoadOp::Clear) : wgpu::LoadOp::Undefined,
|
||||
.stencilStoreOp = stereoStencil ? wgpu::StoreOp::Store : wgpu::StoreOp::Undefined,
|
||||
.stencilClearValue = 0,
|
||||
};
|
||||
const GpuTimingCategory timingCategory = invocation.stereoEye == 0 ? GpuTimingCategory::EyeLeft
|
||||
: invocation.stereoEye == 1 ? GpuTimingCategory::EyeRight
|
||||
: invocation.interpolatedFrame >= 0 ? GpuTimingCategory::Interpolated
|
||||
: GpuTimingCategory::Mono;
|
||||
const wgpu::RenderPassDescriptor renderPassDescriptor{
|
||||
.label = render_pass_label(i),
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
.depthStencilAttachment = &depthStencilAttachment,
|
||||
.timestampWrites = gpu_timing_pass(timingCategory),
|
||||
};
|
||||
|
||||
if (stereoStencil) stencilInitialized = true;
|
||||
auto pass = cmd.BeginRenderPass(&renderPassDescriptor);
|
||||
render_pass_impl(pass, renderPasses, i, invocation);
|
||||
pass.End();
|
||||
@@ -1908,15 +1963,28 @@ void seal_frame(SealedFrame& out) noexcept {
|
||||
g_currentRenderPass = UINT32_MAX;
|
||||
}
|
||||
|
||||
void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame, bool finalize) {
|
||||
void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame, bool finalize,
|
||||
int32_t nativeRenderLastPass) {
|
||||
render_impl(frame.data().passes, cmd,
|
||||
RenderInvocation{
|
||||
.interpolatedFrame = interpolatedFrame,
|
||||
.renderLastPass = nativeRenderLastPass,
|
||||
.finalize = finalize,
|
||||
.encodeTextureBakes = interpolatedFrame < 0,
|
||||
});
|
||||
}
|
||||
|
||||
int32_t last_pass_feeding_replay(const SealedFrame& frame) noexcept {
|
||||
const auto& passes = frame.data().passes;
|
||||
int32_t last = -1;
|
||||
for (size_t i = 0; i < passes.size(); ++i) {
|
||||
if (passes[i].resolveTarget && !passes[i].displayCopyResolve) {
|
||||
last = static_cast<int32_t>(i);
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
bool has_late_stereo_replay(const SealedFrame& frame) noexcept {
|
||||
const auto& data = frame.data().stereo;
|
||||
return data.generation != 0 && data.generation == g_replayBufferGeneration.load(std::memory_order_acquire) &&
|
||||
@@ -1999,6 +2067,18 @@ void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const Ster
|
||||
// The eye is a fresh per-frame attachment, not the reused EFB, so replaying
|
||||
// past that copy blanks the very image the game presented.
|
||||
const int32_t lastPass = get_stereo_stop_at_display_copy() ? displaySource.lastDisplayCopyPass : -1;
|
||||
cockpit::SceneDepth cockpitDepth{};
|
||||
for (size_t i = 0; i < frame.data().passes.size(); ++i) {
|
||||
if (lastPass >= 0 && i > static_cast<size_t>(lastPass)) {
|
||||
break;
|
||||
}
|
||||
if (frame.data().passes[i].cockpitDepth.valid) {
|
||||
cockpitDepth = frame.data().passes[i].cockpitDepth;
|
||||
}
|
||||
}
|
||||
bool cockpitDrawn = false;
|
||||
bool sceneDrawn = false;
|
||||
const bool cockpitActive = stereoFrame.cockpit.active && cockpitDepth.valid;
|
||||
render_impl(frame.data().passes, cmd,
|
||||
RenderInvocation{
|
||||
.stereoEye = eye,
|
||||
@@ -2012,7 +2092,17 @@ void render_stereo_eye(SealedFrame& frame, wgpu::CommandEncoder& cmd, const Ster
|
||||
.encodeTextureBakes = false,
|
||||
.encodeResolves = false,
|
||||
.captureDepth = false,
|
||||
.cockpitFrame = cockpitActive ? &stereoFrame : nullptr,
|
||||
.cockpitEncoder = &cmd,
|
||||
.cockpitDepth = cockpitDepth,
|
||||
.cockpitDrawn = &cockpitDrawn,
|
||||
.sceneDrawn = &sceneDrawn,
|
||||
});
|
||||
// A frame without a virtual-screen draw after its world still gets the
|
||||
// overlay, in a pass of its own over the finished eye.
|
||||
if (cockpitActive && !cockpitDrawn) {
|
||||
cockpit::render(cmd, stereoFrame, eye, cockpitDepth);
|
||||
}
|
||||
}
|
||||
|
||||
void render(wgpu::CommandEncoder& cmd, int32_t interpolatedFrame, bool finalize) {
|
||||
@@ -2028,6 +2118,209 @@ void render(wgpu::CommandEncoder& cmd, int32_t interpolatedFrame, bool finalize)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Per-pass GPU timing (see common.hpp) -------------------------------------------------------
|
||||
namespace {
|
||||
constexpr uint32_t kGpuTimingSlots = 4;
|
||||
constexpr uint32_t kGpuTimingPairs = 62;
|
||||
constexpr uint32_t kGpuTimingQueries = 2 * kGpuTimingPairs;
|
||||
|
||||
struct GpuTimingSlot {
|
||||
wgpu::QuerySet querySet;
|
||||
wgpu::Buffer resolve;
|
||||
wgpu::Buffer readback;
|
||||
std::array<wgpu::PassTimestampWrites, kGpuTimingPairs> writes{};
|
||||
std::array<GpuTimingCategory, kGpuTimingPairs> categories{};
|
||||
uint32_t pairs = 0;
|
||||
bool open = false; // between the frame's begin and end
|
||||
bool reading = false; // readback in flight or mapped
|
||||
bool mapped = false; // the callback ran; the encoding thread unmaps on reuse
|
||||
};
|
||||
|
||||
std::atomic<bool> g_gpuTimingEnabled{false};
|
||||
std::array<GpuTimingSlot, kGpuTimingSlots> g_gpuTimingSlots;
|
||||
uint32_t g_gpuTimingNextSlot = 0;
|
||||
int32_t g_gpuTimingCurrent = -1;
|
||||
bool g_gpuTimingReady = false;
|
||||
// Guards the totals below and every slot's reading/mapped flags: the map callback may run on
|
||||
// whichever thread processes Dawn's events.
|
||||
std::mutex g_gpuTimingMutex;
|
||||
std::array<uint64_t, static_cast<size_t>(GpuTimingCategory::Count)> g_gpuTimingTotalsNs{};
|
||||
uint64_t g_gpuTimingSpanNs = 0;
|
||||
uint32_t g_gpuTimingFrames = 0;
|
||||
uint32_t g_gpuTimingSkipped = 0;
|
||||
|
||||
bool gpu_timing_create_slots() {
|
||||
if (g_gpuTimingReady) {
|
||||
return true;
|
||||
}
|
||||
if (!webgpu::g_timestampQueriesSupported || !webgpu::g_device) {
|
||||
return false;
|
||||
}
|
||||
for (auto& slot : g_gpuTimingSlots) {
|
||||
const wgpu::QuerySetDescriptor querySetDescriptor{
|
||||
.label = "GPU timing queries",
|
||||
.type = wgpu::QueryType::Timestamp,
|
||||
.count = kGpuTimingQueries,
|
||||
};
|
||||
slot.querySet = webgpu::g_device.CreateQuerySet(&querySetDescriptor);
|
||||
const wgpu::BufferDescriptor resolveDescriptor{
|
||||
.label = "GPU timing resolve",
|
||||
.usage = wgpu::BufferUsage::QueryResolve | wgpu::BufferUsage::CopySrc,
|
||||
.size = kGpuTimingQueries * sizeof(uint64_t),
|
||||
};
|
||||
slot.resolve = webgpu::g_device.CreateBuffer(&resolveDescriptor);
|
||||
const wgpu::BufferDescriptor readbackDescriptor{
|
||||
.label = "GPU timing readback",
|
||||
.usage = wgpu::BufferUsage::MapRead | wgpu::BufferUsage::CopyDst,
|
||||
.size = kGpuTimingQueries * sizeof(uint64_t),
|
||||
};
|
||||
slot.readback = webgpu::g_device.CreateBuffer(&readbackDescriptor);
|
||||
}
|
||||
g_gpuTimingReady = true;
|
||||
return true;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void gpu_timing_set_enabled(bool enabled) noexcept { g_gpuTimingEnabled.store(enabled, std::memory_order_relaxed); }
|
||||
bool gpu_timing_enabled() noexcept { return g_gpuTimingEnabled.load(std::memory_order_relaxed); }
|
||||
|
||||
void gpu_timing_begin_frame() noexcept {
|
||||
g_gpuTimingCurrent = -1;
|
||||
if (!gpu_timing_enabled() || !gpu_timing_create_slots()) {
|
||||
return;
|
||||
}
|
||||
const uint32_t index = g_gpuTimingNextSlot;
|
||||
g_gpuTimingNextSlot = (g_gpuTimingNextSlot + 1) % kGpuTimingSlots;
|
||||
auto& slot = g_gpuTimingSlots[index];
|
||||
{
|
||||
std::lock_guard lock(g_gpuTimingMutex);
|
||||
if (slot.reading && !slot.mapped) {
|
||||
++g_gpuTimingSkipped; // the GPU is more than a ring behind; leave this frame untimed
|
||||
return;
|
||||
}
|
||||
if (slot.mapped) {
|
||||
slot.readback.Unmap();
|
||||
slot.mapped = false;
|
||||
}
|
||||
slot.reading = false;
|
||||
}
|
||||
slot.pairs = 0;
|
||||
slot.open = true;
|
||||
g_gpuTimingCurrent = static_cast<int32_t>(index);
|
||||
}
|
||||
|
||||
const wgpu::PassTimestampWrites* gpu_timing_pass(GpuTimingCategory category) noexcept {
|
||||
if (g_gpuTimingCurrent < 0) {
|
||||
return nullptr;
|
||||
}
|
||||
auto& slot = g_gpuTimingSlots[static_cast<size_t>(g_gpuTimingCurrent)];
|
||||
if (!slot.open || slot.pairs >= kGpuTimingPairs) {
|
||||
return nullptr;
|
||||
}
|
||||
const uint32_t i = slot.pairs++;
|
||||
slot.writes[i] = wgpu::PassTimestampWrites{
|
||||
.querySet = slot.querySet,
|
||||
.beginningOfPassWriteIndex = 2 * i,
|
||||
.endOfPassWriteIndex = 2 * i + 1,
|
||||
};
|
||||
slot.categories[i] = category;
|
||||
return &slot.writes[i];
|
||||
}
|
||||
|
||||
void gpu_timing_end_frame(wgpu::CommandEncoder& encoder) noexcept {
|
||||
if (g_gpuTimingCurrent < 0) {
|
||||
return;
|
||||
}
|
||||
auto& slot = g_gpuTimingSlots[static_cast<size_t>(g_gpuTimingCurrent)];
|
||||
slot.open = false;
|
||||
if (slot.pairs == 0) {
|
||||
g_gpuTimingCurrent = -1;
|
||||
return;
|
||||
}
|
||||
const uint32_t queries = 2 * slot.pairs;
|
||||
encoder.ResolveQuerySet(slot.querySet, 0, queries, slot.resolve, 0);
|
||||
encoder.CopyBufferToBuffer(slot.resolve, 0, slot.readback, 0, queries * sizeof(uint64_t));
|
||||
}
|
||||
|
||||
void gpu_timing_after_submit() noexcept {
|
||||
if (g_gpuTimingCurrent < 0) {
|
||||
return;
|
||||
}
|
||||
const uint32_t index = static_cast<uint32_t>(g_gpuTimingCurrent);
|
||||
g_gpuTimingCurrent = -1;
|
||||
auto& slot = g_gpuTimingSlots[index];
|
||||
const uint32_t pairs = slot.pairs;
|
||||
{
|
||||
std::lock_guard lock(g_gpuTimingMutex);
|
||||
slot.reading = true;
|
||||
slot.mapped = false;
|
||||
}
|
||||
slot.readback.MapAsync(
|
||||
wgpu::MapMode::Read, 0, 2 * pairs * sizeof(uint64_t), wgpu::CallbackMode::AllowSpontaneous,
|
||||
[index, pairs](wgpu::MapAsyncStatus status, wgpu::StringView) {
|
||||
auto& slot = g_gpuTimingSlots[index];
|
||||
std::lock_guard lock(g_gpuTimingMutex);
|
||||
if (status != wgpu::MapAsyncStatus::Success) {
|
||||
slot.reading = false;
|
||||
return;
|
||||
}
|
||||
const auto* stamps =
|
||||
static_cast<const uint64_t*>(slot.readback.GetConstMappedRange(0, 2 * pairs * sizeof(uint64_t)));
|
||||
if (stamps != nullptr) {
|
||||
uint64_t first = UINT64_MAX;
|
||||
uint64_t last = 0;
|
||||
for (uint32_t i = 0; i < pairs; ++i) {
|
||||
const uint64_t begin = stamps[2 * i];
|
||||
const uint64_t end = stamps[2 * i + 1];
|
||||
if (end < begin) {
|
||||
continue;
|
||||
}
|
||||
g_gpuTimingTotalsNs[static_cast<size_t>(slot.categories[i])] += end - begin;
|
||||
first = std::min(first, begin);
|
||||
last = std::max(last, end);
|
||||
}
|
||||
if (last > first) {
|
||||
g_gpuTimingSpanNs += last - first;
|
||||
}
|
||||
++g_gpuTimingFrames;
|
||||
}
|
||||
slot.mapped = true;
|
||||
});
|
||||
}
|
||||
|
||||
std::string gpu_timing_report() {
|
||||
std::lock_guard lock(g_gpuTimingMutex);
|
||||
if (g_gpuTimingFrames == 0 && g_gpuTimingSkipped == 0) {
|
||||
return {};
|
||||
}
|
||||
static constexpr std::array<const char*, static_cast<size_t>(GpuTimingCategory::Count)> kNames{
|
||||
"mono", "eyeL", "eyeR", "interp", "screen", "panel", "efbcopy", "palette", "peek", "snapshot", "present"};
|
||||
std::string text;
|
||||
if (g_gpuTimingFrames != 0) {
|
||||
const double frames = g_gpuTimingFrames;
|
||||
uint64_t sum = 0;
|
||||
text += fmt::format("GPU ms/frame over {} frames: passes-span={:.2f}", g_gpuTimingFrames,
|
||||
static_cast<double>(g_gpuTimingSpanNs) / 1e6 / frames);
|
||||
for (size_t i = 0; i < kNames.size(); ++i) {
|
||||
if (g_gpuTimingTotalsNs[i] == 0) {
|
||||
continue;
|
||||
}
|
||||
sum += g_gpuTimingTotalsNs[i];
|
||||
text += fmt::format(" {}={:.2f}", kNames[i], static_cast<double>(g_gpuTimingTotalsNs[i]) / 1e6 / frames);
|
||||
}
|
||||
const uint64_t between = g_gpuTimingSpanNs > sum ? g_gpuTimingSpanNs - sum : 0;
|
||||
text += fmt::format(" between-passes={:.2f}", static_cast<double>(between) / 1e6 / frames);
|
||||
}
|
||||
if (g_gpuTimingSkipped != 0) {
|
||||
text += fmt::format(" (untimed frames: {})", g_gpuTimingSkipped);
|
||||
}
|
||||
g_gpuTimingTotalsNs.fill(0);
|
||||
g_gpuTimingSpanNs = 0;
|
||||
g_gpuTimingFrames = 0;
|
||||
g_gpuTimingSkipped = 0;
|
||||
return text;
|
||||
}
|
||||
|
||||
void after_submit() noexcept {
|
||||
depth_peek::after_submit();
|
||||
efb_ram::after_submit();
|
||||
@@ -2223,6 +2516,24 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
|
||||
draw.gx.interpolatedUniformRanges[invocation.interpolatedFrame].size != 0) {
|
||||
uniformOverride = &draw.gx.interpolatedUniformRanges[invocation.interpolatedFrame];
|
||||
}
|
||||
// Draw against world depth and mark visible cockpit samples before HUD
|
||||
// depth replaces it. The screen pipelines reject those stencil samples.
|
||||
if (invocation.cockpitFrame != nullptr && overrideTarget) {
|
||||
if (draw.gx.uniformReplayLayout.perspective) {
|
||||
*invocation.sceneDrawn = true;
|
||||
}
|
||||
if (virtualScreenDraw && *invocation.sceneDrawn && !*invocation.cockpitDrawn) {
|
||||
cockpit::render(*invocation.cockpitEncoder, *invocation.cockpitFrame, invocation.stereoEye,
|
||||
invocation.cockpitDepth, &pass);
|
||||
*invocation.cockpitDrawn = true;
|
||||
encodeState = {};
|
||||
encodeState.boundTextureBindGroup = gx::g_emptyTextureBindGroup.Get();
|
||||
pass.SetBindGroup(0, g_staticBindGroup);
|
||||
pass.SetBindGroup(2, gx::g_emptyTextureBindGroup);
|
||||
scissorStateKnown = false;
|
||||
viewportStateKnown = false;
|
||||
}
|
||||
}
|
||||
// Such a draw no longer lands where the game aimed it, while the
|
||||
// recorded scissor still describes the rectangle it occupied on the flat
|
||||
// frame (Mario Kart clips the item roulette that way). Honouring that
|
||||
@@ -2240,10 +2551,15 @@ static void render_pass_impl(const wgpu::RenderPassEncoder& pass, const std::vec
|
||||
apply_viewport(fullEyeDraw);
|
||||
}
|
||||
gx::render(draw.gx, pass, encodeState, renderPasses[idx].requireReadyPipelines, uniformOverride,
|
||||
virtualScreenDraw ? draw.gx.exactScreenDepthPipeline : 0);
|
||||
overrideTarget && invocation.target->depthFormat == wgpu::TextureFormat::Depth24PlusStencil8
|
||||
? (virtualScreenDraw ? draw.gx.stereoScreenPipeline : draw.gx.stereoPipeline)
|
||||
: (virtualScreenDraw ? draw.gx.exactScreenDepthPipeline : 0));
|
||||
} break;
|
||||
case ShaderType::Clear: {
|
||||
auto clearDraw = draw.clear;
|
||||
if (overrideTarget && invocation.target->depthFormat == wgpu::TextureFormat::Depth24PlusStencil8) {
|
||||
clearDraw.pipeline = clearDraw.stereoPipeline;
|
||||
}
|
||||
if (multiplayer) {
|
||||
const auto& sc = clearDraw.scissor;
|
||||
if (clearDraw.copyClear ||
|
||||
@@ -2478,3 +2794,32 @@ void aurora_pop_debug_group() {
|
||||
}
|
||||
|
||||
const AuroraStats* aurora_get_stats() { return &aurora::gfx::g_stats; }
|
||||
|
||||
void aurora_set_vr_hand_mesh(uint32_t hand, const AuroraVRHandVertex* vertices, uint32_t vertexCount,
|
||||
const uint16_t* indices, uint32_t indexCount, const float* bindPoses,
|
||||
const int32_t* parents, uint32_t jointCount) {
|
||||
using namespace aurora::gfx::cockpit;
|
||||
if (hand >= 2) {
|
||||
return;
|
||||
}
|
||||
std::shared_ptr<HandMesh> mesh;
|
||||
if (vertices && indices && bindPoses && parents && jointCount == 26 && vertexCount > 0 && vertexCount <= 65535 &&
|
||||
indexCount <= 100000 && indexCount % 3 == 0) {
|
||||
for (uint32_t i = 0; i < indexCount; ++i) {
|
||||
if (indices[i] >= vertexCount) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
mesh = std::make_shared<HandMesh>();
|
||||
mesh->vertices.assign(vertices, vertices + vertexCount);
|
||||
mesh->indices.assign(indices, indices + indexCount);
|
||||
for (int j = 0; j < 26; ++j) {
|
||||
mesh->bind[j] = from_pose(bindPoses + j * 7);
|
||||
mesh->inverseBind[j] = inverse(mesh->bind[j]);
|
||||
mesh->parents[j] = parents[j];
|
||||
}
|
||||
}
|
||||
std::lock_guard lock(meshMutex);
|
||||
meshes[hand] = std::move(mesh);
|
||||
++meshRevision;
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <cstring>
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
@@ -290,6 +291,7 @@ struct ReplayTarget {
|
||||
wgpu::TextureView copySourceDepthView;
|
||||
wgpu::Extent3D size{};
|
||||
uint32_t msaaSamples = 1;
|
||||
wgpu::TextureFormat depthFormat = wgpu::TextureFormat::Depth32Float;
|
||||
};
|
||||
|
||||
struct StereoReplayEye {
|
||||
@@ -313,6 +315,8 @@ struct StereoReplayEye {
|
||||
|
||||
struct StereoReplayFrame {
|
||||
std::array<StereoReplayEye, AURORA_STEREO_EYE_COUNT> eyes;
|
||||
// VR hands and synthetic wheel, drawn per eye after the world (gfx/cockpit.hpp).
|
||||
AuroraCockpit cockpit{};
|
||||
};
|
||||
|
||||
void end_frame(const wgpu::CommandEncoder& cmd);
|
||||
@@ -358,7 +362,14 @@ bool prepare_late_stereo_replay(SealedFrame& frame, wgpu::CommandEncoder& cmd, c
|
||||
|
||||
// Encode a sealed frame. Never touches the producer-visible recording state,
|
||||
// so this may run concurrently with the producer's FIFO drains.
|
||||
void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame = -1, bool finalize = true);
|
||||
// `nativeRenderLastPass` limits the passes that do render work (texture bakes still run for every
|
||||
// pass): a headset never shows an immersive frame's native render, so encode_sealed_frame stops it
|
||||
// after the last pass whose EFB copy the eye replays sample.
|
||||
void render(SealedFrame& frame, wgpu::CommandEncoder& cmd, int32_t interpolatedFrame = -1, bool finalize = true,
|
||||
int32_t nativeRenderLastPass = INT32_MAX);
|
||||
// Index of the last recorded pass that resolves an EFB copy other than the display copy, or -1
|
||||
// when no pass does: everything after it exists only for the presented image.
|
||||
int32_t last_pass_feeding_replay(const SealedFrame& frame) noexcept;
|
||||
|
||||
// Replays only main-EFB passes into one Aurora-owned eye target. Native
|
||||
// offscreen/EFB-copy passes are consumed from the mono render and are not
|
||||
@@ -414,6 +425,39 @@ bool is_offscreen() noexcept;
|
||||
uint32_t get_sample_count() noexcept;
|
||||
void clear_caches() noexcept;
|
||||
|
||||
// Per-pass GPU timing for the frame-rate log. When enabled and the device has TimestampQuery,
|
||||
// every render or compute pass asks gpu_timing_pass() for timestamp writes under a category; the
|
||||
// frame's queries are resolved into a small ring of readback buffers and the completed frames'
|
||||
// durations are summed per category until gpu_timing_report() consumes them. Off by default:
|
||||
// aurora.cpp enables it together with the Android frame-rate log.
|
||||
enum class GpuTimingCategory : uint8_t {
|
||||
Mono, // the native (desktop) render of the recorded GX passes
|
||||
EyeLeft, // stereo replay of the left eye
|
||||
EyeRight, // stereo replay of the right eye
|
||||
Interpolated, // interpolated presentation slots
|
||||
VirtualScreen, // the 2D virtual screen built for each eye
|
||||
Panel, // the in-headset settings panel
|
||||
EfbCopy, // EFB copy format conversions
|
||||
Palette, // palette (TLUT) texture conversions
|
||||
DepthPeek, // the depth snapshot compute pass
|
||||
Snapshot, // presentation snapshot and its ImGui pass
|
||||
Present, // the desktop presentation copy
|
||||
Count,
|
||||
};
|
||||
void gpu_timing_set_enabled(bool enabled) noexcept;
|
||||
bool gpu_timing_enabled() noexcept;
|
||||
// Opens the current frame's query slot; a frame whose slot is still being read back is skipped.
|
||||
void gpu_timing_begin_frame() noexcept;
|
||||
// Timestamp writes for one pass of the open frame, or nullptr when timing is off or exhausted.
|
||||
const wgpu::PassTimestampWrites* gpu_timing_pass(GpuTimingCategory category) noexcept;
|
||||
// Resolves the open frame's queries on `encoder`, which must be the frame's last submission.
|
||||
void gpu_timing_end_frame(wgpu::CommandEncoder& encoder) noexcept;
|
||||
// After that submission: starts the readback of the resolved queries.
|
||||
void gpu_timing_after_submit() noexcept;
|
||||
// Per-frame averages of the frames read back since the last call, formatted for the log, or
|
||||
// an empty string when nothing was measured.
|
||||
std::string gpu_timing_report();
|
||||
|
||||
namespace tex_palette_conv {
|
||||
struct ConvRequest;
|
||||
} // namespace tex_palette_conv
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "depth_peek.hpp"
|
||||
#include "common.hpp"
|
||||
|
||||
#include "../dolphin/vi/vi_internal.hpp"
|
||||
#include "../gx/gx.hpp"
|
||||
@@ -403,6 +404,7 @@ void encode_frame_snapshot(const wgpu::CommandEncoder& cmd, const wgpu::TextureV
|
||||
|
||||
const wgpu::ComputePassDescriptor passDescriptor{
|
||||
.label = "Depth Peek Compute Pass",
|
||||
.timestampWrites = gpu_timing_pass(GpuTimingCategory::DepthPeek),
|
||||
};
|
||||
const auto pass = cmd.BeginComputePass(&passDescriptor);
|
||||
pass.SetPipeline(g_pipeline);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "tex_copy_conv.hpp"
|
||||
#include "common.hpp"
|
||||
#include "tex_copy_format_contract.hpp"
|
||||
|
||||
#include "../internal.hpp"
|
||||
@@ -651,6 +652,7 @@ static void execute(const wgpu::CommandEncoder& cmd, const ConvRequest& req, con
|
||||
.label = "TexCopyConv Pass",
|
||||
.colorAttachmentCount = colorAttachments.size(),
|
||||
.colorAttachments = colorAttachments.data(),
|
||||
.timestampWrites = gpu_timing_pass(GpuTimingCategory::EfbCopy),
|
||||
};
|
||||
const auto pass = cmd.BeginRenderPass(&renderPassDescriptor);
|
||||
pass.SetPipeline(pipeline);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "tex_palette_conv.hpp"
|
||||
#include "common.hpp"
|
||||
|
||||
#include "../internal.hpp"
|
||||
#include "../webgpu/gpu.hpp"
|
||||
@@ -249,6 +250,7 @@ void run(const wgpu::CommandEncoder& cmd, const ConvRequest& req) {
|
||||
.label = "TexPaletteConv Pass",
|
||||
.colorAttachmentCount = colorAttachments.size(),
|
||||
.colorAttachments = colorAttachments.data(),
|
||||
.timestampWrites = gpu_timing_pass(GpuTimingCategory::Palette),
|
||||
};
|
||||
const auto pass = cmd.BeginRenderPass(&renderPassDescriptor);
|
||||
pass.SetPipeline(pipeline);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "../gfx/texture_replacement.hpp"
|
||||
#include "dolphin/gx/GXAurora.h"
|
||||
#include "gx.hpp"
|
||||
#include "native_wheel.hpp"
|
||||
#include "gx_fmt.hpp"
|
||||
#include "pipeline.hpp"
|
||||
#include "shader_info.hpp"
|
||||
@@ -1929,7 +1930,8 @@ static u32 calculate_last_vtx_size(GXVtxFmt fmt) {
|
||||
|
||||
static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange,
|
||||
uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature,
|
||||
bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride);
|
||||
bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride,
|
||||
NativeWheelArray* nativeWheel);
|
||||
|
||||
// The per-draw geometry signature, matrix-usage mask and draw-identity hashes exist purely to feed frame interpolation
|
||||
// (build_uniform consumes them only after its `frame_interpolation_fps() == 0` early-out).
|
||||
@@ -1959,6 +1961,25 @@ static uint32_t matrix_index_prefix_size(GXVtxFmt fmt) noexcept {
|
||||
return size;
|
||||
}
|
||||
|
||||
// Which animated vertex array, if any, this draw takes (native_wheel.hpp). Called once per draw, before the merge
|
||||
// test, because a merged draw renders through the binding the draw it folds into resolved.
|
||||
static NativeWheelArray* resolve_native_wheel(GXVtxFmt fmt, const uint8_t* vertices, u16 vtxCount,
|
||||
uint32_t vtxStride) noexcept {
|
||||
// A direct-position draw reads no array at all, and g_gxState.arrays[GX_VA_POS] then still holds whatever was
|
||||
// bound last, which must not be matched against.
|
||||
if (g_gxState.vtxDesc[GX_VA_POS] != GX_INDEX8 && g_gxState.vtxDesc[GX_VA_POS] != GX_INDEX16)
|
||||
LIKELY { return nullptr; }
|
||||
const auto& array = g_gxState.arrays[GX_VA_POS];
|
||||
if (nativeWheelArrays.empty())
|
||||
LIKELY {
|
||||
if (!nativeWheelPreviousSources.empty())
|
||||
UNLIKELY { native_wheel_note_outside(array.data); }
|
||||
return nullptr;
|
||||
}
|
||||
return native_wheel_array(array, vertices, static_cast<u32>(vtxCount) * vtxStride, vtxStride,
|
||||
matrix_index_prefix_size(fmt));
|
||||
}
|
||||
|
||||
// Screen-space bounds of a simple orthographic rectangle or line, textured or
|
||||
// not: MKW's split-screen partition is a layout picture pane (a one-pixel quad
|
||||
// sampling a pattern texture), so texture use cannot disqualify a candidate.
|
||||
@@ -2185,6 +2206,10 @@ static ArrayRef<u16> offset_index_template(const CachedIndexTemplate& indexTempl
|
||||
|
||||
struct CachedPipelineState {
|
||||
gfx::PipelineRef ref = 0;
|
||||
const PipelineConfig* config = nullptr;
|
||||
mutable gfx::PipelineRef stereoRef = 0;
|
||||
mutable gfx::PipelineRef screenRef = 0;
|
||||
mutable gfx::PipelineRef stereoScreenRef = 0;
|
||||
HashType configHash = 0;
|
||||
// Carried here so the draw can be recorded without keeping the PipelineConfig that produced it alive; it is the only
|
||||
// field of the config the draw itself still needs.
|
||||
@@ -2210,6 +2235,7 @@ static const CachedPipelineState& cached_pipeline_state(const PipelineConfig& co
|
||||
entry.config = config;
|
||||
entry.state = {
|
||||
.ref = gfx::pipeline_ref(config),
|
||||
.config = &entry.config,
|
||||
.configHash = hash,
|
||||
.dstAlpha = config.dstAlpha,
|
||||
.shaderInfo = build_shader_info(config.shaderConfig),
|
||||
@@ -2252,37 +2278,20 @@ static const CachedPipelineState& resolve_pipeline_state(GXPrimitive prim, GXVtx
|
||||
return state;
|
||||
}
|
||||
|
||||
// Exact Screen Depth changes shader outputs but no GX pipeline state. Resolve a
|
||||
// sibling pipeline only for orthographic draws that can actually reach the VR
|
||||
// screen, and memoize it with the same state epoch as the ordinary pipeline.
|
||||
static gfx::PipelineRef resolve_exact_screen_depth_pipeline(GXPrimitive prim, GXVtxFmt fmt) {
|
||||
struct Memo {
|
||||
gfx::PipelineRef ref = 0;
|
||||
u32 generation = 0;
|
||||
u32 sampleCount = 0;
|
||||
GXPrimitive prim = static_cast<GXPrimitive>(0);
|
||||
GXVtxFmt fmt = static_cast<GXVtxFmt>(0);
|
||||
};
|
||||
static Memo memo{};
|
||||
|
||||
const u32 sampleCount = gfx::get_sample_count();
|
||||
const u32 generation = g_gxState.pipelineStateGeneration;
|
||||
if (memo.ref != 0 && memo.generation == generation && memo.sampleCount == sampleCount && memo.prim == prim &&
|
||||
memo.fmt == fmt)
|
||||
LIKELY { return memo.ref; }
|
||||
|
||||
PipelineConfig config{};
|
||||
populate_pipeline_config(config, prim, fmt);
|
||||
config.shaderConfig.exactScreenDepth = 1;
|
||||
const gfx::PipelineRef ref = gfx::pipeline_ref(config);
|
||||
memo = Memo{
|
||||
.ref = ref,
|
||||
.generation = generation,
|
||||
.sampleCount = sampleCount,
|
||||
.prim = prim,
|
||||
.fmt = fmt,
|
||||
};
|
||||
return ref;
|
||||
// Lazily cache eye-format siblings alongside the ordinary pipeline. Steady-state
|
||||
// draws only read the refs: no extra config population/hashing on the Quest CPU.
|
||||
// Shader modules are shared by the depth-format variants.
|
||||
static void resolve_replay_pipelines(const CachedPipelineState& state, bool screen) {
|
||||
if (state.stereoRef && (!screen || state.stereoScreenRef)) return;
|
||||
PipelineConfig config = *state.config;
|
||||
config.stereoStencil = 1;
|
||||
if (!state.stereoRef) state.stereoRef = gfx::pipeline_ref(config);
|
||||
if (screen && !state.stereoScreenRef) {
|
||||
config.shaderConfig.exactScreenDepth = 1;
|
||||
state.stereoScreenRef = gfx::pipeline_ref(config);
|
||||
config.stereoStencil = 0;
|
||||
state.screenRef = gfx::pipeline_ref(config);
|
||||
}
|
||||
}
|
||||
|
||||
bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, uint16_t vtxCount, uint32_t vertexBytes) {
|
||||
@@ -2321,7 +2330,8 @@ bool submit_raw_draw(GXPrimitive prim, GXVtxFmt fmt, const uint8_t* vertices, ui
|
||||
const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{};
|
||||
handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature,
|
||||
interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0,
|
||||
interpolationIdentityActive, vertices, vtxSize);
|
||||
interpolationIdentityActive, vertices, vtxSize,
|
||||
resolve_native_wheel(fmt, vertices, vtxCount, vtxSize));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2354,13 +2364,21 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
|
||||
gfx::Range vertRange = push_draw_vertices(vertices, vtxCount, vtxSize);
|
||||
pos += totalVtxBytes;
|
||||
|
||||
// Try to merge with previous draw call
|
||||
// The animated vertex array this draw takes is decided per draw, and the decision is part of what a merge would
|
||||
// share, so resolve it here and hand the result to handle_draw_unmerged rather than deciding twice.
|
||||
NativeWheelArray* const nativeWheel = resolve_native_wheel(fmt, vertices, vtxCount, vtxSize);
|
||||
|
||||
// Try to merge with previous draw call.
|
||||
if (!g_gxState.stateDirty && !(aurora::stereo_frame_provider_active() && g_gxState.projType == GX_ORTHOGRAPHIC))
|
||||
LIKELY {
|
||||
auto* lastDraw = gfx::get_last_draw_command<DrawData>();
|
||||
// Only if the previous draw call was a single instance draw (no lines/points handling)
|
||||
// Only if the previous draw call was a single instance draw (no lines/points handling), and only into a draw
|
||||
// that resolved the same animated array: the merged whole renders through that draw's binding. Anything the
|
||||
// decision cache cannot vouch for (a command it was not recorded against) stays unmerged.
|
||||
if (lastDraw != nullptr && prim != GX_LINES && prim != GX_LINESTRIP && prim != GX_POINTS &&
|
||||
lastDraw->instanceCount == 1)
|
||||
lastDraw->instanceCount == 1 &&
|
||||
(nativeWheelArrays.empty() ||
|
||||
(nativeWheelLastDrawCommand == lastDraw && nativeWheelLastDecision == nativeWheel)))
|
||||
LIKELY {
|
||||
const auto& indexTemplate = cached_index_template(prim, vtxCount);
|
||||
const auto indices = offset_index_template(indexTemplate, lastDraw->vtxCount);
|
||||
@@ -2389,13 +2407,14 @@ static bool handle_draw(u8 cmd, const u8* data, u32& pos, u32 size, bool bigEndi
|
||||
const PnMtxUsage matrixUsage = interpolationIdentityActive ? pn_mtx_usage(vertices, vtxCount, vtxSize) : PnMtxUsage{};
|
||||
handle_draw_unmerged(prim, fmt, vtxCount, vertRange, matrixUsage.mask, matrixUsage.topologySignature,
|
||||
interpolationIdentityActive ? draw_geometry_signature(fmt, vertices, vtxCount, vtxSize) : 0,
|
||||
interpolationIdentityActive, vertices, vtxSize);
|
||||
interpolationIdentityActive, vertices, vtxSize, nativeWheel);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, gfx::Range vertRange,
|
||||
uint16_t usedPnMtxMask, HashType matrixTopologySignature, HashType geometrySignature,
|
||||
bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride) {
|
||||
bool interpolationIdentityActive, const uint8_t* vertices, uint32_t vtxStride,
|
||||
NativeWheelArray* nativeWheel) {
|
||||
ZoneScoped;
|
||||
// GX_CULL_ALL rasterizes nothing on hardware - no color, no depth.
|
||||
if (g_gxState.cullMode == GX_CULL_ALL && prim != GX_LINES && prim != GX_LINESTRIP && prim != GX_POINTS)
|
||||
@@ -2419,6 +2438,24 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
|
||||
}
|
||||
auto& array = g_gxState.arrays[i];
|
||||
const u32 uploadStride = padded_upload_stride(array.stride);
|
||||
if (i == GX_VA_POS && nativeWheel != nullptr)
|
||||
UNLIKELY {
|
||||
static unsigned nativeWheelDrawLogs = 0;
|
||||
if (nativeWheelDrawLogs++ < 4) Log.info("Native steering wheel: animated local vehicle vertex array");
|
||||
// Never populate the shared source's cache with the animated copy: later draws of the same asset must
|
||||
// still see the original vertices. The copy takes the same padded upload path as the original.
|
||||
if (nativeWheel->uploaded.size == 0 || nativeWheel->uploadedStride != uploadStride) {
|
||||
AttrArray animated{};
|
||||
animated.data = nativeWheel->bytes.data();
|
||||
animated.size = array.size;
|
||||
animated.stride = array.stride;
|
||||
animated.le = array.le;
|
||||
nativeWheel->uploaded = push_vertex_array(animated, uploadStride);
|
||||
nativeWheel->uploadedStride = uploadStride;
|
||||
}
|
||||
ranges.vaRanges[0] = nativeWheel->uploaded;
|
||||
continue;
|
||||
}
|
||||
if (array.cachedRange.size > 0 && array.cachedStride == uploadStride) {
|
||||
ranges.vaRanges[i - GX_VA_POS] = array.cachedRange;
|
||||
} else {
|
||||
@@ -2459,10 +2496,9 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
|
||||
const bool perspective = g_gxState.projType == GX_PERSPECTIVE;
|
||||
const auto uniformRanges = build_uniform(info, vertRange.offset, ranges, drawIdentity, perspective, usedPnMtxMask);
|
||||
const auto& replayLayout = uniformRanges.replayLayout;
|
||||
const gfx::PipelineRef exactScreenDepthPipeline =
|
||||
aurora::stereo_frame_provider_active() && !replayLayout.perspective && !replayLayout.nativeEfbEffect
|
||||
? resolve_exact_screen_depth_pipeline(prim, fmt)
|
||||
: 0;
|
||||
const bool stereo = aurora::stereo_frame_provider_active();
|
||||
const bool screen = !replayLayout.perspective && !replayLayout.nativeEfbEffect;
|
||||
if (stereo) resolve_replay_pipelines(pipelineState, screen);
|
||||
s_lastDrawRecordedInterpolation = interpolationIdentityActive;
|
||||
|
||||
uint32_t instanceCount = 1;
|
||||
@@ -2475,7 +2511,9 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
|
||||
}
|
||||
gfx::push_draw_command(DrawData{
|
||||
.pipeline = pipeline,
|
||||
.exactScreenDepthPipeline = exactScreenDepthPipeline,
|
||||
.exactScreenDepthPipeline = stereo && screen ? pipelineState.screenRef : 0,
|
||||
.stereoPipeline = stereo ? pipelineState.stereoRef : 0,
|
||||
.stereoScreenPipeline = stereo && screen ? pipelineState.stereoScreenRef : 0,
|
||||
.vertRange = vertRange,
|
||||
.idxRange = idxRange,
|
||||
.uniformRange = uniformRanges.current,
|
||||
@@ -2490,6 +2528,9 @@ static void handle_draw_unmerged(GXPrimitive prim, GXVtxFmt fmt, u16 vtxCount, g
|
||||
.dstAlpha = pipelineState.dstAlpha,
|
||||
.screenRect = screen_rect(prim, fmt, vertices, vtxCount, vtxStride),
|
||||
});
|
||||
// What the next draw must match to be allowed to fold into this one.
|
||||
nativeWheelLastDrawCommand = gfx::get_last_draw_command<DrawData>();
|
||||
nativeWheelLastDecision = nativeWheel;
|
||||
g_gxState.stateDirty = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1609,10 +1609,18 @@ static inline wgpu::PrimitiveState to_primitive_state(GXCullMode gx_cullMode) {
|
||||
wgpu::RenderPipeline build_pipeline(const PipelineConfig& config, ArrayRef<wgpu::VertexBufferLayout> vtxBuffers,
|
||||
wgpu::ShaderModule shader, const char* label) noexcept {
|
||||
ZoneScoped;
|
||||
const bool maskCockpit = config.stereoStencil && config.shaderConfig.exactScreenDepth;
|
||||
const wgpu::StencilFaceState stencil{
|
||||
.compare = maskCockpit ? wgpu::CompareFunction::Equal : wgpu::CompareFunction::Always,
|
||||
};
|
||||
const wgpu::DepthStencilState depthStencil{
|
||||
.format = g_graphicsConfig.depthFormat,
|
||||
.format = config.stereoStencil ? wgpu::TextureFormat::Depth24PlusStencil8 : g_graphicsConfig.depthFormat,
|
||||
.depthWriteEnabled = config.depthUpdate,
|
||||
.depthCompare = config.depthCompare ? to_compare_function(config.depthFunc) : wgpu::CompareFunction::Always,
|
||||
.stencilFront = stencil,
|
||||
.stencilBack = stencil,
|
||||
.stencilReadMask = 1,
|
||||
.stencilWriteMask = 0,
|
||||
};
|
||||
const auto blendState = to_blend_state(config.blendMode, config.blendFacSrc, config.blendFacDst, config.blendOp,
|
||||
config.pixelFmt, config.dstAlpha);
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
//
|
||||
// The VR first-person camera animates the local vehicle's steering wheel by
|
||||
// handing Aurora a rotated copy of one of the vehicle's position arrays. The
|
||||
// copy applies only to a draw that binds that exact array *and* carries the
|
||||
// local vehicle's model-view matrix, so an opponent sharing the asset keeps
|
||||
// the original vertices. Everything here runs on the GX (command processor)
|
||||
// thread; the set/clear entry points are posted there by the runtime.
|
||||
#pragma once
|
||||
#include "gx.hpp"
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
#include <atomic>
|
||||
#include <aurora/native_wheel_match.hpp>
|
||||
namespace aurora::gx {
|
||||
struct NativeWheelArray {
|
||||
const void* source{};
|
||||
std::vector<uint8_t> bytes;
|
||||
std::array<float,12> modelView{};
|
||||
gfx::Range uploaded{};
|
||||
// Element stride of `uploaded`, which differs from the array's when the upload is padded.
|
||||
uint32_t uploadedStride=0;
|
||||
};
|
||||
inline std::vector<NativeWheelArray> nativeWheelArrays;
|
||||
inline uint32_t nativeWheelMatches=0;
|
||||
inline std::atomic<uint32_t> nativeWheelLastMatches{0};
|
||||
|
||||
// A draw folds into the previous one by appending its vertices to that draw's
|
||||
// range, so the merged whole renders through the *first* draw's array binding:
|
||||
// two draws may only merge when they resolved the same replacement. The
|
||||
// decision below is therefore taken once per draw (the ownership walk is far
|
||||
// too costly to repeat) and kept with the command it was recorded for. It is
|
||||
// cleared with the set, which the runtime posts once a frame, so a command
|
||||
// address a later frame's list reuses can never be read as a hit.
|
||||
inline NativeWheelArray* nativeWheelLastDecision=nullptr;
|
||||
inline const void* nativeWheelLastDrawCommand=nullptr;
|
||||
|
||||
// For the host log: why draws of the replaced arrays did or did not take them.
|
||||
struct NativeWheelDiagnostics {
|
||||
uint32_t sets=0; // replacement sets cleared since the last report
|
||||
uint32_t boundDraws=0; // draws that bound a replacement's source array
|
||||
uint32_t oversizeDraws=0; // ... whose bound range was larger than the replacement
|
||||
uint32_t outsideDraws=0; // draws that bound a cleared set's source while no set was active
|
||||
uint32_t matchedDraws=0;
|
||||
float bestError=INFINITY; // smallest largest-element difference of a position matrix
|
||||
bool bestIndexed=false;
|
||||
std::array<float,12> bestMatrix{};
|
||||
std::array<float,12> expected{};
|
||||
};
|
||||
inline NativeWheelDiagnostics nativeWheelDiagnostics;
|
||||
inline std::vector<const void*> nativeWheelPreviousSources;
|
||||
inline uint32_t nativeWheelClears=0;
|
||||
inline uint32_t nativeWheelReports=0;
|
||||
|
||||
inline void native_wheel_note_outside(const void* source) {
|
||||
for(const void* previous:nativeWheelPreviousSources)
|
||||
if(previous==source) { ++nativeWheelDiagnostics.outsideDraws;return; }
|
||||
}
|
||||
|
||||
inline void native_wheel_note_bound(const NativeWheelArray& replacement,bool indexedMatrix) {
|
||||
auto& diagnostics=nativeWheelDiagnostics;
|
||||
++diagnostics.boundDraws;
|
||||
for(uint32_t slot=0;slot<MaxPnMtx;++slot) {
|
||||
if(!indexedMatrix && slot!=g_gxState.currentPnMtx) continue;
|
||||
const auto* matrix=reinterpret_cast<const float*>(&g_gxState.pnMtx[slot].pos);
|
||||
float error=0;
|
||||
for(int i=0;i<12;++i) error=std::max(error,std::abs(matrix[i]-replacement.modelView[i]));
|
||||
if(error<diagnostics.bestError) {
|
||||
diagnostics.bestError=error;
|
||||
diagnostics.bestIndexed=indexedMatrix;
|
||||
std::memcpy(diagnostics.bestMatrix.data(),matrix,sizeof(float)*12);
|
||||
diagnostics.expected=replacement.modelView;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Called as a set is cleared (GXAurora.cpp): one host log line at about half a
|
||||
// second, five seconds and a minute of sets.
|
||||
void native_wheel_report();
|
||||
inline bool native_wheel_source(const void* source) {
|
||||
for(const auto& replacement:nativeWheelArrays) if(source==replacement.source) return true;
|
||||
return false;
|
||||
}
|
||||
inline NativeWheelArray* native_wheel_array(const AttrArray& array,const uint8_t* vertices,
|
||||
uint32_t vertexBytes,uint32_t vertexStride,uint32_t positionOffset) {
|
||||
const bool indexedMatrix=g_gxState.vtxDesc[GX_VA_PNMTXIDX]==GX_DIRECT;
|
||||
if(!indexedMatrix && g_gxState.currentPnMtx>=MaxPnMtx) return nullptr;
|
||||
for(auto& replacement:nativeWheelArrays) {
|
||||
if(array.data!=replacement.source) continue;
|
||||
if(array.size>replacement.bytes.size()) { ++nativeWheelDiagnostics.oversizeDraws;continue; }
|
||||
native_wheel_note_bound(replacement,indexedMatrix);
|
||||
// A matching asset alone would also animate an opponent. Multi-joint
|
||||
// models need a per-position ownership check, not a blanket exclusion.
|
||||
uint16_t matching=0;
|
||||
for(uint32_t slot=0;slot<MaxPnMtx;++slot) {
|
||||
if(!indexedMatrix && slot!=g_gxState.currentPnMtx) continue;
|
||||
const auto* matrix=reinterpret_cast<const float*>(&g_gxState.pnMtx[slot].pos);
|
||||
bool matches=true;
|
||||
for(int i=0;i<12;++i) {
|
||||
uint32_t bits;std::memcpy(&bits,&matrix[i],4);
|
||||
if((bits&0x7f800000u)==0x7f800000u ||
|
||||
std::abs(matrix[i]-replacement.modelView[i])>(i%4==3?0.1f:0.002f)) { matches=false;break; }
|
||||
}
|
||||
if(matches) matching|=uint16_t(1u<<slot);
|
||||
}
|
||||
if(!matching) continue;
|
||||
if(indexedMatrix && !NativeWheelDrawMatches(
|
||||
{static_cast<const uint8_t*>(array.data),array.size},
|
||||
{replacement.bytes.data(),array.size},array.stride,
|
||||
{vertices,vertexBytes},vertexStride,positionOffset,
|
||||
g_gxState.vtxDesc[GX_VA_POS]==GX_INDEX8?1:2,matching)) continue;
|
||||
++nativeWheelMatches;++nativeWheelDiagnostics.matchedDraws;return &replacement;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ struct DrawData {
|
||||
// Same GX state with exact fragment-depth export enabled. Bound only when
|
||||
// this draw is actually reprojected onto the VR virtual screen.
|
||||
gfx::PipelineRef exactScreenDepthPipeline;
|
||||
// Eye depth/stencil format siblings. Shader modules are shared with mono.
|
||||
gfx::PipelineRef stereoPipeline = 0;
|
||||
gfx::PipelineRef stereoScreenPipeline = 0;
|
||||
gfx::Range vertRange;
|
||||
gfx::Range idxRange;
|
||||
gfx::Range uniformRange;
|
||||
@@ -29,7 +32,7 @@ struct DrawData {
|
||||
std::optional<gfx::stereo_replay::SubviewRect> screenRect;
|
||||
};
|
||||
|
||||
constexpr uint32_t GXPipelineConfigVersion = 20;
|
||||
constexpr uint32_t GXPipelineConfigVersion = 21;
|
||||
|
||||
constexpr GXFogType effective_pipeline_fog_type(GXFogType fogType, GXZTexOp zTextureOp, bool zCompLocBeforeTex,
|
||||
GXBlendMode blendMode, GXLogicOp logicOp) noexcept {
|
||||
@@ -41,6 +44,7 @@ constexpr GXFogType effective_pipeline_fog_type(GXFogType fogType, GXZTexOp zTex
|
||||
struct PipelineConfig {
|
||||
uint32_t version = GXPipelineConfigVersion;
|
||||
uint32_t msaaSamples = 1;
|
||||
uint32_t stereoStencil = 0;
|
||||
ShaderConfig shaderConfig;
|
||||
GXCompare depthFunc;
|
||||
GXCullMode cullMode;
|
||||
@@ -61,7 +65,7 @@ inline bool valid_pipeline_config(const PipelineConfig& config) noexcept {
|
||||
};
|
||||
const bool validSamples =
|
||||
config.msaaSamples == 1 || config.msaaSamples == 2 || config.msaaSamples == 4 || config.msaaSamples == 8;
|
||||
return config.version == GXPipelineConfigVersion && validSamples && in_range(config.depthFunc, GX_ALWAYS) &&
|
||||
return config.version == GXPipelineConfigVersion && validSamples && config.stereoStencil <= 1 && in_range(config.depthFunc, GX_ALWAYS) &&
|
||||
in_range(config.cullMode, GX_CULL_ALL) && in_range(config.blendMode, GX_BM_SUBTRACT) &&
|
||||
in_range(config.blendFacSrc, GX_BL_INVDSTALPHA) && in_range(config.blendFacDst, GX_BL_INVDSTALPHA) &&
|
||||
in_range(config.blendOp, GX_LO_SET) && in_range(config.pixelFmt, GX_PF_YUV420);
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
#include "fs_helper.hpp"
|
||||
#include "internal.hpp"
|
||||
#include "stereo_overlay.hpp"
|
||||
#include "webgpu/gpu.hpp"
|
||||
#include "window.hpp"
|
||||
|
||||
@@ -28,7 +29,11 @@ static std::string g_imguiLog{};
|
||||
static bool g_useSdlRenderer = false;
|
||||
// Set once ImGui::Render() has produced this frame's draw data. Interpolation encodes up to four
|
||||
// ImGui passes per frame, and every one of them used to rebuild the draw lists from scratch.
|
||||
static bool g_frameDataBuilt = false;
|
||||
static bool g_frameDataBuilt = true;
|
||||
// Host-owned frames (see imgui.hpp). Once the host begins one, aurora never calls new_frame() or
|
||||
// ImGui::Render() itself; the sealed frame carries the host's copy of the draw data instead.
|
||||
static bool g_hostFrames = false;
|
||||
static bool g_hostFrameOpen = false;
|
||||
|
||||
static std::vector<SDL_Texture*> g_sdlTextures;
|
||||
static std::vector<wgpu::Texture> g_wgpuTextures;
|
||||
@@ -179,7 +184,7 @@ void new_frame(const AuroraWindowSize& size) noexcept {
|
||||
|
||||
void render_frame_data() noexcept {
|
||||
ZoneScoped;
|
||||
if (g_frameDataBuilt) {
|
||||
if (g_frameDataBuilt || g_hostFrames) {
|
||||
return;
|
||||
}
|
||||
ImGui::Render();
|
||||
@@ -190,6 +195,11 @@ void render_frame_data() noexcept {
|
||||
|
||||
void render(const wgpu::RenderPassEncoder& pass) noexcept {
|
||||
ZoneScoped;
|
||||
if (g_hostFrames) {
|
||||
// The shared context's draw data belongs to the host's current frame now;
|
||||
// a sealed frame without a host copy has nothing safe to draw.
|
||||
return;
|
||||
}
|
||||
render_frame_data();
|
||||
|
||||
auto* data = ImGui::GetDrawData();
|
||||
@@ -205,6 +215,71 @@ void render(const wgpu::RenderPassEncoder& pass) noexcept {
|
||||
}
|
||||
}
|
||||
|
||||
struct HostFrame {
|
||||
ImDrawData data{};
|
||||
std::vector<ImDrawList*> lists;
|
||||
~HostFrame() {
|
||||
for (ImDrawList* list : lists) {
|
||||
IM_DELETE(list);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void host_frame_begin(const AuroraWindowSize& size) noexcept {
|
||||
g_hostFrames = true;
|
||||
if (g_hostFrameOpen) {
|
||||
return;
|
||||
}
|
||||
if (!g_frameDataBuilt) {
|
||||
// aurora started this frame itself before the host took over: adopt it.
|
||||
g_hostFrameOpen = true;
|
||||
return;
|
||||
}
|
||||
new_frame(size);
|
||||
g_hostFrameOpen = true;
|
||||
}
|
||||
|
||||
HostFramePtr host_frame_end() noexcept {
|
||||
ZoneScoped;
|
||||
if (!g_hostFrameOpen) {
|
||||
host_frame_begin(window::get_window_size());
|
||||
}
|
||||
ImGui::Render();
|
||||
ImDrawData* source = ImGui::GetDrawData();
|
||||
source->FramebufferScale = ImGui::GetIO().DisplayFramebufferScale;
|
||||
auto frame = std::make_shared<HostFrame>();
|
||||
frame->data = *source;
|
||||
frame->data.CmdLists.clear();
|
||||
frame->lists.reserve(static_cast<size_t>(source->CmdListsCount));
|
||||
for (int i = 0; i < source->CmdListsCount; ++i) {
|
||||
const ImDrawList* src = source->CmdLists[i];
|
||||
ImDrawList* copy = IM_NEW(ImDrawList)(src->_Data);
|
||||
copy->CmdBuffer = src->CmdBuffer;
|
||||
copy->IdxBuffer = src->IdxBuffer;
|
||||
copy->VtxBuffer = src->VtxBuffer;
|
||||
copy->Flags = src->Flags;
|
||||
frame->lists.push_back(copy);
|
||||
frame->data.CmdLists.push_back(copy);
|
||||
}
|
||||
g_hostFrameOpen = false;
|
||||
g_frameDataBuilt = true;
|
||||
return frame;
|
||||
}
|
||||
|
||||
bool host_frames_active() noexcept { return g_hostFrames; }
|
||||
|
||||
const ImDrawData* host_frame_draw_data(const HostFrame& frame) noexcept { return &frame.data; }
|
||||
|
||||
void render(const wgpu::RenderPassEncoder& pass, const ImDrawData* data) noexcept {
|
||||
ZoneScoped;
|
||||
if (g_useSdlRenderer || data == nullptr) {
|
||||
return;
|
||||
}
|
||||
pass.PushDebugGroup("Aurora: Dear Imgui");
|
||||
ImGui_ImplWGPU_RenderDrawData(const_cast<ImDrawData*>(data), pass.Get());
|
||||
pass.PopDebugGroup();
|
||||
}
|
||||
|
||||
StereoOverlay latch_stereo_overlay() noexcept {
|
||||
std::lock_guard lock(g_stereoOverlayMutex);
|
||||
return g_stereoOverlay;
|
||||
@@ -274,6 +349,8 @@ ImTextureID aurora_imgui_add_texture(uint32_t width, uint32_t height, const void
|
||||
return aurora::imgui::add_texture(width, height, static_cast<const uint8_t*>(rgba8));
|
||||
}
|
||||
|
||||
void aurora_set_stereo_panel_layer(bool enabled) { aurora::stereo_overlay::set_layer_mode(enabled); }
|
||||
|
||||
void aurora_imgui_set_stereo_overlay(ImDrawData* drawData, float widthFraction) {
|
||||
std::lock_guard lock(aurora::imgui::g_stereoOverlayMutex);
|
||||
aurora::imgui::g_stereoOverlay = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <aurora/event.h>
|
||||
#include <memory>
|
||||
|
||||
union SDL_Event;
|
||||
struct ImDrawData;
|
||||
@@ -32,4 +33,17 @@ StereoOverlay latch_stereo_overlay() noexcept;
|
||||
// uniform for every pass, so a pass whose display size differs from the desktop's must be submitted
|
||||
// before the next pass is recorded.
|
||||
bool render_draw_data(const wgpu::RenderPassEncoder& pass, ImDrawData* data) noexcept;
|
||||
|
||||
// Host-owned ImGui frames. The host starts each frame on its own thread with host_frame_begin() and
|
||||
// closes it with host_frame_end(), which renders the frame and copies its draw data out of the shared
|
||||
// context. The copy is what the sealed frame replays, so the host may start the next frame while the
|
||||
// worker still encodes this one, and aurora stops starting frames itself once the host has begun one.
|
||||
struct HostFrame;
|
||||
using HostFramePtr = std::shared_ptr<HostFrame>;
|
||||
void host_frame_begin(const AuroraWindowSize& size) noexcept;
|
||||
HostFramePtr host_frame_end() noexcept;
|
||||
bool host_frames_active() noexcept;
|
||||
const ImDrawData* host_frame_draw_data(const HostFrame& frame) noexcept;
|
||||
// Renders a host frame's copied draw data in place of the shared context's.
|
||||
void render(const wgpu::RenderPassEncoder& pass, const ImDrawData* data) noexcept;
|
||||
} // namespace aurora::imgui
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "tracy/Tracy.hpp"
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
|
||||
namespace aurora::stereo_overlay {
|
||||
@@ -74,8 +75,12 @@ struct State {
|
||||
std::array<wgpu::BindGroup, AURORA_STEREO_EYE_COUNT> bindGroups;
|
||||
float widthFraction = 0.f;
|
||||
bool visible = false;
|
||||
// Stands in for the panel in a layer while it is not showing.
|
||||
webgpu::TextureWithSampler transparent;
|
||||
bool transparentCleared = false;
|
||||
};
|
||||
State g_state;
|
||||
std::atomic_bool g_layerMode{false};
|
||||
|
||||
bool ensure_pipeline() {
|
||||
auto& state = g_state;
|
||||
@@ -239,6 +244,7 @@ void composite(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& tar
|
||||
.label = eyeIndex == 0 ? "Headset panel left eye" : "Headset panel right eye",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::Panel),
|
||||
};
|
||||
const auto pass = encoder.BeginRenderPass(&descriptor);
|
||||
pass.SetPipeline(state.pipeline);
|
||||
@@ -294,6 +300,7 @@ wgpu::CommandBuffer prepare(ImDrawData* drawData, float widthFraction) noexcept
|
||||
.label = "Headset panel ImGui pass",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
.timestampWrites = gfx::gpu_timing_pass(gfx::GpuTimingCategory::Panel),
|
||||
};
|
||||
bool drawn = false;
|
||||
{
|
||||
@@ -312,7 +319,7 @@ wgpu::CommandBuffer prepare(ImDrawData* drawData, float widthFraction) noexcept
|
||||
void composite_immersive(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& eye,
|
||||
const Mat4x4<float>& eyeFrustum, const Mat3x4<float>& viewFromCenter,
|
||||
uint32_t eyeIndex) noexcept {
|
||||
if (!g_state.visible) {
|
||||
if (!g_state.visible || layer_mode()) {
|
||||
return;
|
||||
}
|
||||
float screenWidth = 0.f;
|
||||
@@ -329,7 +336,7 @@ void composite_immersive(const wgpu::CommandEncoder& encoder, const wgpu::Textur
|
||||
|
||||
void composite_flat(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& eye, const wgpu::Extent3D& size,
|
||||
uint32_t eyeIndex) noexcept {
|
||||
if (!g_state.visible || size.width == 0 || size.height == 0) {
|
||||
if (!g_state.visible || layer_mode() || size.width == 0 || size.height == 0) {
|
||||
return;
|
||||
}
|
||||
const float imageAspect = static_cast<float>(size.width) / static_cast<float>(size.height);
|
||||
@@ -338,6 +345,52 @@ void composite_flat(const wgpu::CommandEncoder& encoder, const wgpu::TextureView
|
||||
eyeIndex);
|
||||
}
|
||||
|
||||
void set_layer_mode(bool enabled) noexcept { g_layerMode.store(enabled, std::memory_order_release); }
|
||||
|
||||
bool layer_mode() noexcept { return g_layerMode.load(std::memory_order_acquire); }
|
||||
|
||||
bool layer_source(const wgpu::CommandEncoder& encoder, uint32_t width, uint32_t height, stereo::EyeImage& out) noexcept {
|
||||
auto& state = g_state;
|
||||
const auto format = webgpu::g_graphicsConfig.surfaceConfiguration.format;
|
||||
if (width == 0 || height == 0) {
|
||||
return false;
|
||||
}
|
||||
if (state.visible && state.panel.texture && state.panel.size.width == width && state.panel.size.height == height &&
|
||||
state.panel.format == format) {
|
||||
out = {.texture = &state.panel.texture, .view = &state.panel.view, .size = state.panel.size, .format = format};
|
||||
return true;
|
||||
}
|
||||
if (!state.transparent.texture || state.transparent.size.width != width ||
|
||||
state.transparent.size.height != height || state.transparent.format != format) {
|
||||
state.transparent = webgpu::create_render_texture(width, height, false);
|
||||
state.transparentCleared = false;
|
||||
if (state.transparent.size.width != width || state.transparent.size.height != height) {
|
||||
state.transparent = {};
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!state.transparentCleared) {
|
||||
const std::array attachments{
|
||||
wgpu::RenderPassColorAttachment{
|
||||
.view = state.transparent.view,
|
||||
.loadOp = wgpu::LoadOp::Clear,
|
||||
.storeOp = wgpu::StoreOp::Store,
|
||||
.clearValue = {.r = 0.0, .g = 0.0, .b = 0.0, .a = 0.0},
|
||||
},
|
||||
};
|
||||
const wgpu::RenderPassDescriptor descriptor{
|
||||
.label = "Headset panel layer clear",
|
||||
.colorAttachmentCount = attachments.size(),
|
||||
.colorAttachments = attachments.data(),
|
||||
};
|
||||
encoder.BeginRenderPass(&descriptor).End();
|
||||
state.transparentCleared = true;
|
||||
}
|
||||
out = {.texture = &state.transparent.texture, .view = &state.transparent.view, .size = state.transparent.size,
|
||||
.format = format};
|
||||
return true;
|
||||
}
|
||||
|
||||
void shutdown() noexcept { g_state = {}; }
|
||||
|
||||
} // namespace aurora::stereo_overlay
|
||||
@@ -3,6 +3,8 @@
|
||||
#include <aurora/math.hpp>
|
||||
#include <webgpu/webgpu_cpp.h>
|
||||
|
||||
#include "stereo.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
struct ImDrawData;
|
||||
@@ -28,6 +30,20 @@ void composite_immersive(const wgpu::CommandEncoder& encoder, const wgpu::Textur
|
||||
void composite_flat(const wgpu::CommandEncoder& encoder, const wgpu::TextureView& eye, const wgpu::Extent3D& size,
|
||||
uint32_t eyeIndex) noexcept;
|
||||
|
||||
// Layer mode: the OpenXR backend shows the panel as its own compositor quad
|
||||
// layer, sharp at any eye resolution, so it is no longer drawn into the eyes
|
||||
// (both composite functions do nothing). Any thread; read by the frame worker.
|
||||
void set_layer_mode(bool enabled) noexcept;
|
||||
bool layer_mode() noexcept;
|
||||
|
||||
// Frame worker, inside a stereo sink: the image to copy into a panel layer of
|
||||
// width x height in the eyes' format. That is the panel while it is showing at
|
||||
// exactly that size, and otherwise a transparent image of that size, so a layer
|
||||
// asked for before the panel's first frame (or after it closed) shows nothing.
|
||||
// A transparent image is cleared once, by a pass recorded into `encoder`.
|
||||
// False only when no image can be made.
|
||||
bool layer_source(const wgpu::CommandEncoder& encoder, uint32_t width, uint32_t height, stereo::EyeImage& out) noexcept;
|
||||
|
||||
void shutdown() noexcept;
|
||||
|
||||
} // namespace aurora::stereo_overlay
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "../internal.hpp"
|
||||
#include "../stereo.hpp"
|
||||
#include "../stereo_overlay.hpp"
|
||||
#include "gpu.hpp"
|
||||
|
||||
#if defined(_WIN32) && defined(WEBGPU_DAWN) && defined(DAWN_ENABLE_BACKEND_D3D12)
|
||||
@@ -126,6 +127,11 @@ struct SharedFenceDxgiHandleWire {
|
||||
void* handle = nullptr;
|
||||
};
|
||||
|
||||
// The eyes, then the settings panel's layer image in a slot of its own so the
|
||||
// eye intermediates are never resized for it.
|
||||
constexpr uint32_t kPanelIndex = AURORA_D3D12_STEREO_MAX_TARGETS;
|
||||
constexpr uint32_t kMaxImages = AURORA_D3D12_STEREO_MAX_TARGETS + 1;
|
||||
|
||||
struct IntermediateEye {
|
||||
ComPtr<ID3D12Resource> resource;
|
||||
wgpu::SharedTextureMemory memory;
|
||||
@@ -153,8 +159,8 @@ struct InFlightCommand {
|
||||
// both sides of every copy alive until this submission's fence completes;
|
||||
// an eye-size change may otherwise replace the bridge intermediate while
|
||||
// the GPU is still reading it.
|
||||
std::array<ComPtr<ID3D12Resource>, AURORA_D3D12_STEREO_MAX_TARGETS> sources;
|
||||
std::array<ComPtr<ID3D12Resource>, AURORA_D3D12_STEREO_MAX_TARGETS> destinations;
|
||||
std::array<ComPtr<ID3D12Resource>, kMaxImages> sources;
|
||||
std::array<ComPtr<ID3D12Resource>, kMaxImages> destinations;
|
||||
};
|
||||
|
||||
class StereoBridge final {
|
||||
@@ -209,38 +215,51 @@ public:
|
||||
return WaitForGpuLocked();
|
||||
}
|
||||
|
||||
bool SetTargets(uint64_t token, const AuroraD3D12StereoTarget* targets,
|
||||
uint32_t targetCount) noexcept {
|
||||
bool SetTargets(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t targetCount,
|
||||
const AuroraD3D12StereoTarget* panel) noexcept {
|
||||
if (token == 0 || targets == nullptr || targetCount == 0 ||
|
||||
targetCount > AURORA_D3D12_STEREO_MAX_TARGETS) {
|
||||
return false;
|
||||
}
|
||||
const auto valid = [](const AuroraD3D12StereoTarget& target) {
|
||||
if (target.resource == nullptr || target.width == 0 || target.height == 0 ||
|
||||
target.dxgiFormat == DXGI_FORMAT_UNKNOWN) {
|
||||
return false;
|
||||
}
|
||||
const D3D12_RESOURCE_DESC desc = static_cast<ID3D12Resource*>(target.resource)->GetDesc();
|
||||
return desc.Dimension == D3D12_RESOURCE_DIMENSION_TEXTURE2D && desc.Width >= target.width &&
|
||||
desc.Height >= target.height && desc.DepthOrArraySize == 1 && desc.MipLevels == 1 &&
|
||||
desc.SampleDesc.Count == 1 &&
|
||||
same_copy_family(desc.Format, static_cast<DXGI_FORMAT>(target.dxgiFormat));
|
||||
};
|
||||
std::lock_guard lock(m_mutex);
|
||||
if (m_framePending || m_encoded) {
|
||||
return false;
|
||||
}
|
||||
for (uint32_t eye = 0; eye < targetCount; ++eye) {
|
||||
if (targets[eye].resource == nullptr || targets[eye].width == 0 ||
|
||||
targets[eye].height == 0 || targets[eye].dxgiFormat == DXGI_FORMAT_UNKNOWN) {
|
||||
if (!valid(targets[eye])) {
|
||||
return false;
|
||||
}
|
||||
auto* resource = static_cast<ID3D12Resource*>(targets[eye].resource);
|
||||
const D3D12_RESOURCE_DESC desc = resource->GetDesc();
|
||||
if (desc.Dimension != D3D12_RESOURCE_DIMENSION_TEXTURE2D ||
|
||||
desc.Width < targets[eye].width || desc.Height < targets[eye].height ||
|
||||
desc.DepthOrArraySize != 1 || desc.MipLevels != 1 || desc.SampleDesc.Count != 1 ||
|
||||
!same_copy_family(desc.Format, static_cast<DXGI_FORMAT>(targets[eye].dxgiFormat))) {
|
||||
return false;
|
||||
}
|
||||
m_targets[eye] = {
|
||||
.resource = resource,
|
||||
.width = targets[eye].width,
|
||||
.height = targets[eye].height,
|
||||
.format = static_cast<DXGI_FORMAT>(targets[eye].dxgiFormat),
|
||||
};
|
||||
}
|
||||
for (uint32_t eye = targetCount; eye < m_targets.size(); ++eye) {
|
||||
m_targets[eye] = {};
|
||||
if (panel != nullptr && !valid(*panel)) {
|
||||
return false;
|
||||
}
|
||||
m_targets = {};
|
||||
m_imageCount = 0;
|
||||
const auto add = [&](uint32_t index, const AuroraD3D12StereoTarget& target) {
|
||||
m_targets[index] = {
|
||||
.resource = static_cast<ID3D12Resource*>(target.resource),
|
||||
.width = target.width,
|
||||
.height = target.height,
|
||||
.format = static_cast<DXGI_FORMAT>(target.dxgiFormat),
|
||||
};
|
||||
m_images[m_imageCount++] = index;
|
||||
};
|
||||
for (uint32_t eye = 0; eye < targetCount; ++eye) {
|
||||
add(eye, targets[eye]);
|
||||
}
|
||||
if (panel != nullptr) {
|
||||
add(kPanelIndex, *panel);
|
||||
}
|
||||
m_frameToken = token;
|
||||
m_targetCount = targetCount;
|
||||
@@ -307,7 +326,7 @@ private:
|
||||
if (source.texture == nullptr || sourceFormat == DXGI_FORMAT_UNKNOWN ||
|
||||
source.size.width != m_targets[eye].width || source.size.height != m_targets[eye].height ||
|
||||
!same_copy_family(sourceFormat, m_targets[eye].format)) {
|
||||
Log.error("Stereo eye {} does not match its OpenXR D3D12 target", eye);
|
||||
Log.error("Stereo image {} does not match its OpenXR D3D12 target", eye);
|
||||
return false;
|
||||
}
|
||||
if (intermediate.texture && intermediate.width == source.size.width &&
|
||||
@@ -350,7 +369,9 @@ private:
|
||||
wire.resource = intermediate.resource;
|
||||
const wgpu::SharedTextureMemoryDescriptor memoryDescriptor{
|
||||
.nextInChain = &wire.chain,
|
||||
.label = eye == 0 ? "OpenXR left eye intermediate" : "OpenXR right eye intermediate",
|
||||
.label = eye == 0 ? "OpenXR left eye intermediate"
|
||||
: eye == 1 ? "OpenXR right eye intermediate"
|
||||
: "OpenXR panel intermediate",
|
||||
};
|
||||
intermediate.memory = webgpu::g_device.ImportSharedTextureMemory(&memoryDescriptor);
|
||||
if (!intermediate.memory) {
|
||||
@@ -368,7 +389,9 @@ private:
|
||||
return false;
|
||||
}
|
||||
const wgpu::TextureDescriptor textureDescriptor{
|
||||
.label = eye == 0 ? "OpenXR left eye shared texture" : "OpenXR right eye shared texture",
|
||||
.label = eye == 0 ? "OpenXR left eye shared texture"
|
||||
: eye == 1 ? "OpenXR right eye shared texture"
|
||||
: "OpenXR panel shared texture",
|
||||
.usage = wgpu::TextureUsage::CopyDst,
|
||||
.dimension = wgpu::TextureDimension::e2D,
|
||||
.size = {source.size.width, source.size.height, 1},
|
||||
@@ -391,12 +414,22 @@ private:
|
||||
|
||||
bool EncodeLocked(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) noexcept {
|
||||
CollectCompletedCommandsLocked();
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
if (!EnsureIntermediate(eye, frame.eyes[eye])) {
|
||||
std::array<stereo::EyeImage, kMaxImages> sources{};
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
if (eye == kPanelIndex) {
|
||||
if (!stereo_overlay::layer_source(encoder, m_targets[eye].width, m_targets[eye].height, sources[eye])) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
sources[eye] = frame.eyes[eye];
|
||||
}
|
||||
if (!EnsureIntermediate(eye, sources[eye])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
auto& intermediate = m_intermediates[eye];
|
||||
const std::array fences{m_webgpuFence};
|
||||
const std::array values{m_lastExternalFenceValue};
|
||||
@@ -410,7 +443,8 @@ private:
|
||||
}
|
||||
if (intermediate.memory.BeginAccess(intermediate.texture, &begin) != wgpu::Status::Success) {
|
||||
Log.error("Dawn BeginAccess failed for stereo eye {}", eye);
|
||||
for (uint32_t begunEye = 0; begunEye < eye; ++begunEye) {
|
||||
for (uint32_t m = 0; m < n; ++m) {
|
||||
const uint32_t begunEye = m_images[m];
|
||||
wgpu::SharedTextureMemoryEndAccessState end{};
|
||||
m_intermediates[begunEye].memory.EndAccess(m_intermediates[begunEye].texture, &end);
|
||||
m_intermediates[begunEye].initialized = end.initialized;
|
||||
@@ -424,10 +458,11 @@ private:
|
||||
// to one. If a later BeginAccess fails, the rollback above can therefore
|
||||
// end the earlier accesses without leaving an unsubmitted copy that uses
|
||||
// a texture after its access interval.
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
const auto& intermediate = m_intermediates[eye];
|
||||
const wgpu::TexelCopyTextureInfo source{
|
||||
.texture = *frame.eyes[eye].texture,
|
||||
.texture = *sources[eye].texture,
|
||||
.mipLevel = 0,
|
||||
.origin = {},
|
||||
.aspect = wgpu::TextureAspect::All,
|
||||
@@ -446,7 +481,8 @@ private:
|
||||
|
||||
bool EndAccessLocked() noexcept {
|
||||
bool success = true;
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
auto& intermediate = m_intermediates[eye];
|
||||
if (!intermediate.accessBegun) {
|
||||
success = false;
|
||||
@@ -467,8 +503,8 @@ private:
|
||||
bool EnqueueNativeCopyLocked() noexcept {
|
||||
ComPtr<ID3D12CommandAllocator> allocator;
|
||||
ComPtr<ID3D12GraphicsCommandList> list;
|
||||
std::array<ComPtr<ID3D12Resource>, AURORA_D3D12_STEREO_MAX_TARGETS> sources;
|
||||
std::array<ComPtr<ID3D12Resource>, AURORA_D3D12_STEREO_MAX_TARGETS> destinations;
|
||||
std::array<ComPtr<ID3D12Resource>, kMaxImages> sources;
|
||||
std::array<ComPtr<ID3D12Resource>, kMaxImages> destinations;
|
||||
if (FAILED(m_device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT,
|
||||
IID_PPV_ARGS(&allocator))) ||
|
||||
FAILED(m_device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, allocator.Get(),
|
||||
@@ -477,7 +513,8 @@ private:
|
||||
return false;
|
||||
}
|
||||
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
const auto& source = m_intermediates[eye];
|
||||
const auto& destination = m_targets[eye];
|
||||
sources[eye] = source.resource;
|
||||
@@ -564,6 +601,7 @@ private:
|
||||
}
|
||||
m_frameToken = 0;
|
||||
m_targetCount = 0;
|
||||
m_imageCount = 0;
|
||||
m_framePending = false;
|
||||
m_encoded = false;
|
||||
}
|
||||
@@ -625,8 +663,11 @@ private:
|
||||
ComPtr<ID3D12CommandQueue> m_queue;
|
||||
ComPtr<ID3D12Fence> m_fence;
|
||||
wgpu::SharedFence m_webgpuFence;
|
||||
std::array<IntermediateEye, AURORA_D3D12_STEREO_MAX_TARGETS> m_intermediates{};
|
||||
std::array<PendingTarget, AURORA_D3D12_STEREO_MAX_TARGETS> m_targets{};
|
||||
std::array<IntermediateEye, kMaxImages> m_intermediates{};
|
||||
std::array<PendingTarget, kMaxImages> m_targets{};
|
||||
// The slots of m_targets this frame copies into, eyes first.
|
||||
std::array<uint32_t, kMaxImages> m_images{};
|
||||
uint32_t m_imageCount = 0;
|
||||
std::vector<InFlightCommand> m_commands;
|
||||
AuroraD3D12StereoSubmittedCallback m_callback = nullptr;
|
||||
void* m_userdata = nullptr;
|
||||
@@ -701,7 +742,13 @@ bool aurora_d3d12_set_stereo_targets(uint64_t frameToken,
|
||||
const AuroraD3D12StereoTarget* targets,
|
||||
uint32_t targetCount) {
|
||||
using namespace aurora::d3d12_interop;
|
||||
return g_bridge && g_bridge->SetTargets(frameToken, targets, targetCount);
|
||||
return g_bridge && g_bridge->SetTargets(frameToken, targets, targetCount, nullptr);
|
||||
}
|
||||
|
||||
bool aurora_d3d12_set_stereo_targets_with_panel(uint64_t frameToken, const AuroraD3D12StereoTarget* targets,
|
||||
uint32_t targetCount, const AuroraD3D12StereoTarget* panel) {
|
||||
using namespace aurora::d3d12_interop;
|
||||
return g_bridge && g_bridge->SetTargets(frameToken, targets, targetCount, panel);
|
||||
}
|
||||
|
||||
bool aurora_d3d12_cancel_stereo_targets(uint64_t frameToken) {
|
||||
@@ -744,6 +791,11 @@ bool aurora_d3d12_set_stereo_targets(uint64_t, const AuroraD3D12StereoTarget*, u
|
||||
return false;
|
||||
}
|
||||
|
||||
bool aurora_d3d12_set_stereo_targets_with_panel(uint64_t, const AuroraD3D12StereoTarget*, uint32_t,
|
||||
const AuroraD3D12StereoTarget*) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool aurora_d3d12_cancel_stereo_targets(uint64_t) { return false; }
|
||||
|
||||
bool aurora_d3d12_disable_stereo_bridge() { return true; }
|
||||
|
||||
@@ -80,6 +80,7 @@ wgpu::Instance g_instance;
|
||||
static wgpu::AdapterInfo g_adapterInfo;
|
||||
static wgpu::SurfaceCapabilities g_surfaceCapabilities;
|
||||
bool g_bcTexturesSupported;
|
||||
bool g_timestampQueriesSupported = false;
|
||||
// Written by Dawn's device-loss callback and consumed at ordered frame boundaries. Keep the
|
||||
// callback free of logging, allocation, teardown and renderer state mutation.
|
||||
static std::atomic_bool g_deviceLost{false};
|
||||
@@ -702,6 +703,12 @@ bool initialize(AuroraBackend auroraBackend) {
|
||||
g_bcTexturesSupported = true;
|
||||
requiredFeatures.push_back(feature);
|
||||
}
|
||||
// Per-pass GPU timing for the frame-rate log (gfx::gpu_timing_*). Requesting the feature
|
||||
// costs nothing until a pass carries timestamp writes.
|
||||
if (feature == wgpu::FeatureName::TimestampQuery) {
|
||||
g_timestampQueriesSupported = true;
|
||||
requiredFeatures.push_back(feature);
|
||||
}
|
||||
// The presenter calls device and queue methods while the frame worker encodes, which Dawn only
|
||||
// supports with this feature; without it the two race inside the device's dynamic uploader.
|
||||
if (feature == wgpu::FeatureName::ImplicitDeviceSynchronization) {
|
||||
@@ -769,10 +776,15 @@ bool initialize(AuroraBackend auroraBackend) {
|
||||
if (g_backendType == wgpu::BackendType::Vulkan) {
|
||||
enableToggles.push_back("vulkan_monolithic_pipeline_cache");
|
||||
}
|
||||
// Dawn quantizes timestamp queries to 100 us for web privacy; the per-pass GPU timing wants
|
||||
// the raw values.
|
||||
const std::array<const char*, 1> disableToggles{"timestamp_quantization"};
|
||||
const wgpu::DawnTogglesDescriptor togglesDescriptor({
|
||||
.nextInChain = &cacheDescriptor,
|
||||
.enabledToggleCount = enableToggles.size(),
|
||||
.enabledToggles = enableToggles.data(),
|
||||
.disabledToggleCount = g_timestampQueriesSupported ? disableToggles.size() : 0,
|
||||
.disabledToggles = disableToggles.data(),
|
||||
});
|
||||
#endif
|
||||
wgpu::DeviceDescriptor deviceDescriptor;
|
||||
|
||||
@@ -58,6 +58,8 @@ extern wgpu::RenderPipeline g_CopyPipeline;
|
||||
extern wgpu::BindGroup g_CopyBindGroup;
|
||||
extern wgpu::Instance g_instance;
|
||||
extern bool g_bcTexturesSupported;
|
||||
// The device was created with TimestampQuery, so passes may carry timestamp writes (gfx::gpu_timing_*).
|
||||
extern bool g_timestampQueriesSupported;
|
||||
|
||||
bool initialize(AuroraBackend backend);
|
||||
void shutdown();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "../internal.hpp"
|
||||
#include "../stereo.hpp"
|
||||
#include "../stereo_overlay.hpp"
|
||||
#include "gpu.hpp"
|
||||
|
||||
#if defined(__ANDROID__) && defined(WEBGPU_DAWN)
|
||||
@@ -105,6 +106,11 @@ struct Import {
|
||||
bool accessBegun = false;
|
||||
};
|
||||
|
||||
// The eyes, then the settings panel's layer image in a slot of its own.
|
||||
constexpr uint32_t kPanelIndex = AURORA_VULKAN_STEREO_MAX_TARGETS;
|
||||
constexpr uint32_t kMaxImages = AURORA_VULKAN_STEREO_MAX_RELEASES;
|
||||
using Releases = std::array<AuroraVulkanStereoRelease, kMaxImages>;
|
||||
|
||||
struct PendingTarget {
|
||||
AHardwareBuffer* buffer = nullptr;
|
||||
uint32_t width = 0;
|
||||
@@ -146,8 +152,8 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
bool SetTargets(uint64_t token, const AuroraVulkanStereoTarget* targets,
|
||||
uint32_t targetCount) noexcept {
|
||||
bool SetTargets(uint64_t token, const AuroraVulkanStereoTarget* targets, uint32_t targetCount,
|
||||
const AuroraVulkanStereoTarget* panel) noexcept {
|
||||
if (token == 0 || targets == nullptr || targetCount == 0 ||
|
||||
targetCount > AURORA_VULKAN_STEREO_MAX_TARGETS) {
|
||||
return false;
|
||||
@@ -157,25 +163,36 @@ public:
|
||||
return false;
|
||||
}
|
||||
const int64_t auroraFormat = to_vk_format(m_auroraFormat);
|
||||
const auto valid = [&](const AuroraVulkanStereoTarget& target) {
|
||||
return target.buffer != nullptr && target.width != 0 && target.height != 0 &&
|
||||
same_copy_family(target.vkFormat, auroraFormat);
|
||||
};
|
||||
for (uint32_t eye = 0; eye < targetCount; ++eye) {
|
||||
const auto& target = targets[eye];
|
||||
if (target.buffer == nullptr || target.width == 0 || target.height == 0 ||
|
||||
!same_copy_family(target.vkFormat, auroraFormat)) {
|
||||
if (!valid(targets[eye])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (uint32_t eye = 0; eye < targetCount; ++eye) {
|
||||
m_targets[eye] = {
|
||||
.buffer = targets[eye].buffer,
|
||||
.width = targets[eye].width,
|
||||
.height = targets[eye].height,
|
||||
.vkFormat = targets[eye].vkFormat,
|
||||
.acquireFenceFd = targets[eye].acquireFenceFd,
|
||||
.acquireImageLayout = targets[eye].acquireImageLayout,
|
||||
};
|
||||
if (panel != nullptr && !valid(*panel)) {
|
||||
return false;
|
||||
}
|
||||
for (uint32_t eye = targetCount; eye < m_targets.size(); ++eye) {
|
||||
m_targets[eye] = {};
|
||||
m_targets = {};
|
||||
m_imageCount = 0;
|
||||
const auto add = [&](uint32_t index, const AuroraVulkanStereoTarget& target) {
|
||||
m_targets[index] = {
|
||||
.buffer = target.buffer,
|
||||
.width = target.width,
|
||||
.height = target.height,
|
||||
.vkFormat = target.vkFormat,
|
||||
.acquireFenceFd = target.acquireFenceFd,
|
||||
.acquireImageLayout = target.acquireImageLayout,
|
||||
};
|
||||
m_images[m_imageCount++] = index;
|
||||
};
|
||||
for (uint32_t eye = 0; eye < targetCount; ++eye) {
|
||||
add(eye, targets[eye]);
|
||||
}
|
||||
if (panel != nullptr) {
|
||||
add(kPanelIndex, *panel);
|
||||
}
|
||||
m_frameToken = token;
|
||||
m_targetCount = targetCount;
|
||||
@@ -202,7 +219,7 @@ public:
|
||||
if (!m_framePending || !m_encoded || frame.frameToken != m_frameToken) {
|
||||
return;
|
||||
}
|
||||
std::array<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> releases{};
|
||||
Releases releases{};
|
||||
const bool success = EndAccessLocked(releases);
|
||||
NotifyLocked(frame.frameToken, success, true, releases);
|
||||
ClearFrameLocked();
|
||||
@@ -215,7 +232,7 @@ public:
|
||||
}
|
||||
const uint64_t token = m_frameToken;
|
||||
const bool encoded = m_encoded;
|
||||
std::array<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> releases{};
|
||||
Releases releases{};
|
||||
if (encoded) {
|
||||
EndAccessLocked(releases);
|
||||
for (auto& release : releases) {
|
||||
@@ -242,7 +259,7 @@ private:
|
||||
const auto& target = m_targets[eye];
|
||||
if (source.texture == nullptr || source.format != m_auroraFormat ||
|
||||
source.size.width != target.width || source.size.height != target.height) {
|
||||
Log.error("Stereo eye {} does not match its OpenXR Vulkan target ({}x{} vs {}x{})", eye,
|
||||
Log.error("Stereo image {} does not match its OpenXR Vulkan target ({}x{} vs {}x{})", eye,
|
||||
source.size.width, source.size.height, target.width, target.height);
|
||||
return nullptr;
|
||||
}
|
||||
@@ -264,7 +281,9 @@ private:
|
||||
ahb.handle = target.buffer;
|
||||
const wgpu::SharedTextureMemoryDescriptor memoryDescriptor{
|
||||
.nextInChain = &ahb,
|
||||
.label = eye == 0 ? "OpenXR left eye AHardwareBuffer" : "OpenXR right eye AHardwareBuffer",
|
||||
.label = eye == 0 ? "OpenXR left eye AHardwareBuffer"
|
||||
: eye == 1 ? "OpenXR right eye AHardwareBuffer"
|
||||
: "OpenXR panel AHardwareBuffer",
|
||||
};
|
||||
import.memory = webgpu::g_device.ImportSharedTextureMemory(&memoryDescriptor);
|
||||
if (!import.memory) {
|
||||
@@ -291,7 +310,9 @@ private:
|
||||
return nullptr;
|
||||
}
|
||||
const wgpu::TextureDescriptor textureDescriptor{
|
||||
.label = eye == 0 ? "OpenXR left eye shared texture" : "OpenXR right eye shared texture",
|
||||
.label = eye == 0 ? "OpenXR left eye shared texture"
|
||||
: eye == 1 ? "OpenXR right eye shared texture"
|
||||
: "OpenXR panel shared texture",
|
||||
.usage = wgpu::TextureUsage::CopyDst,
|
||||
.dimension = wgpu::TextureDimension::e2D,
|
||||
.size = {target.width, target.height, 1},
|
||||
@@ -313,19 +334,29 @@ private:
|
||||
}
|
||||
|
||||
bool EncodeLocked(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) noexcept {
|
||||
std::array<Import*, AURORA_VULKAN_STEREO_MAX_TARGETS> imports{};
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
imports[eye] = EnsureImport(eye, frame.eyes[eye]);
|
||||
std::array<stereo::EyeImage, kMaxImages> sources{};
|
||||
std::array<Import*, kMaxImages> imports{};
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
if (eye == kPanelIndex) {
|
||||
if (!stereo_overlay::layer_source(encoder, m_targets[eye].width, m_targets[eye].height, sources[eye])) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
sources[eye] = frame.eyes[eye];
|
||||
}
|
||||
imports[eye] = EnsureImport(eye, sources[eye]);
|
||||
if (imports[eye] == nullptr) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
auto& target = m_targets[eye];
|
||||
auto& import = *imports[eye];
|
||||
if (import.accessBegun) {
|
||||
Log.error("AHardwareBuffer for eye {} is still under a previous access", eye);
|
||||
RollbackAccesses(imports, eye);
|
||||
RollbackAccesses(imports, n);
|
||||
return false;
|
||||
}
|
||||
// The OpenXR side's release barrier leaves the image in acquireImageLayout;
|
||||
@@ -350,7 +381,7 @@ private:
|
||||
close_fd(target.acquireFenceFd);
|
||||
if (!acquireFence) {
|
||||
Log.error("Dawn could not import the OpenXR copy-out fence for eye {}", eye);
|
||||
RollbackAccesses(imports, eye);
|
||||
RollbackAccesses(imports, n);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -370,15 +401,16 @@ private:
|
||||
}
|
||||
if (import.memory.BeginAccess(import.texture, &begin) != wgpu::Status::Success) {
|
||||
Log.error("Dawn BeginAccess failed for stereo eye {}", eye);
|
||||
RollbackAccesses(imports, eye);
|
||||
RollbackAccesses(imports, n);
|
||||
return false;
|
||||
}
|
||||
import.accessBegun = true;
|
||||
}
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
const auto& import = *imports[eye];
|
||||
const wgpu::TexelCopyTextureInfo source{
|
||||
.texture = *frame.eyes[eye].texture,
|
||||
.texture = *sources[eye].texture,
|
||||
.mipLevel = 0,
|
||||
.origin = {},
|
||||
.aspect = wgpu::TextureAspect::All,
|
||||
@@ -396,9 +428,10 @@ private:
|
||||
return true;
|
||||
}
|
||||
|
||||
void RollbackAccesses(const std::array<Import*, AURORA_VULKAN_STEREO_MAX_TARGETS>& imports,
|
||||
uint32_t count) noexcept {
|
||||
for (uint32_t eye = 0; eye < count; ++eye) {
|
||||
// Ends the accesses begun for the first `count` images of this frame.
|
||||
void RollbackAccesses(const std::array<Import*, kMaxImages>& imports, uint32_t count) noexcept {
|
||||
for (uint32_t n = 0; n < count; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
if (imports[eye] != nullptr && imports[eye]->accessBegun) {
|
||||
wgpu::SharedTextureMemoryEndAccessState end{};
|
||||
imports[eye]->memory.EndAccess(imports[eye]->texture, &end);
|
||||
@@ -411,13 +444,15 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
bool EndAccessLocked(
|
||||
std::array<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS>& releases) noexcept {
|
||||
// Fills one release per image of this frame, in order: the eyes, then the panel.
|
||||
bool EndAccessLocked(Releases& releases) noexcept {
|
||||
bool success = true;
|
||||
for (auto& release : releases) {
|
||||
release = {.releaseFenceFd = -1, .releasedImageLayout = VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
}
|
||||
for (uint32_t eye = 0; eye < m_targetCount; ++eye) {
|
||||
for (uint32_t n = 0; n < m_imageCount; ++n) {
|
||||
const uint32_t eye = m_images[n];
|
||||
auto& release = releases[n];
|
||||
Import* import = m_encodedImports[eye];
|
||||
if (import == nullptr || !import->accessBegun) {
|
||||
success = false;
|
||||
@@ -431,7 +466,7 @@ private:
|
||||
success = false;
|
||||
} else {
|
||||
import->initialized = end.initialized;
|
||||
releases[eye].releasedImageLayout = layout.newLayout;
|
||||
release.releasedImageLayout = layout.newLayout;
|
||||
for (size_t i = 0; i < end.fenceCount; ++i) {
|
||||
wgpu::SharedFenceSyncFDExportInfo syncFd{};
|
||||
wgpu::SharedFenceExportInfo info{};
|
||||
@@ -441,16 +476,16 @@ private:
|
||||
// The fence keeps its descriptor; hand the caller an independent one.
|
||||
const int duplicate = ::dup(syncFd.handle);
|
||||
if (duplicate >= 0) {
|
||||
if (releases[eye].releaseFenceFd >= 0) {
|
||||
if (release.releaseFenceFd >= 0) {
|
||||
// Dawn normally returns exactly one fence per access. Both must
|
||||
// be honoured and one descriptor cannot express two fences, so
|
||||
// the earlier one is retired on the CPU before handing over the
|
||||
// latest.
|
||||
Log.warn("Dawn returned several release fences for eye {}; merging on the CPU", eye);
|
||||
wait_sync_fd(releases[eye].releaseFenceFd);
|
||||
close_fd(releases[eye].releaseFenceFd);
|
||||
wait_sync_fd(release.releaseFenceFd);
|
||||
close_fd(release.releaseFenceFd);
|
||||
}
|
||||
releases[eye].releaseFenceFd = duplicate;
|
||||
release.releaseFenceFd = duplicate;
|
||||
}
|
||||
} else {
|
||||
Log.error("Dawn returned a non-sync-fd fence for eye {}", eye);
|
||||
@@ -482,12 +517,13 @@ private:
|
||||
m_encodedImports = {};
|
||||
m_frameToken = 0;
|
||||
m_targetCount = 0;
|
||||
m_imageCount = 0;
|
||||
m_framePending = false;
|
||||
m_encoded = false;
|
||||
}
|
||||
|
||||
void PublishAndClearFrameLocked(uint64_t token, bool success, bool gpuWorkQueued) noexcept {
|
||||
std::array<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS> releases{};
|
||||
Releases releases{};
|
||||
for (auto& release : releases) {
|
||||
release = {.releaseFenceFd = -1, .releasedImageLayout = VK_IMAGE_LAYOUT_UNDEFINED};
|
||||
}
|
||||
@@ -496,10 +532,9 @@ private:
|
||||
}
|
||||
|
||||
void NotifyLocked(uint64_t token, bool success, bool gpuWorkQueued,
|
||||
const std::array<AuroraVulkanStereoRelease, AURORA_VULKAN_STEREO_MAX_TARGETS>&
|
||||
releases) noexcept {
|
||||
const Releases& releases) noexcept {
|
||||
if (m_callback != nullptr) {
|
||||
m_callback(token, success, gpuWorkQueued, releases.data(), m_targetCount, m_userdata);
|
||||
m_callback(token, success, gpuWorkQueued, releases.data(), m_imageCount, m_userdata);
|
||||
} else {
|
||||
for (auto release : releases) {
|
||||
close_fd(release.releaseFenceFd);
|
||||
@@ -509,8 +544,11 @@ private:
|
||||
|
||||
std::mutex m_mutex;
|
||||
std::unordered_map<AHardwareBuffer*, Import> m_imports;
|
||||
std::array<PendingTarget, AURORA_VULKAN_STEREO_MAX_TARGETS> m_targets{};
|
||||
std::array<Import*, AURORA_VULKAN_STEREO_MAX_TARGETS> m_encodedImports{};
|
||||
std::array<PendingTarget, kMaxImages> m_targets{};
|
||||
std::array<Import*, kMaxImages> m_encodedImports{};
|
||||
// The slots of m_targets this frame copies into, eyes first.
|
||||
std::array<uint32_t, kMaxImages> m_images{};
|
||||
uint32_t m_imageCount = 0;
|
||||
wgpu::TextureFormat m_auroraFormat = wgpu::TextureFormat::Undefined;
|
||||
AuroraVulkanStereoSubmittedCallback m_callback = nullptr;
|
||||
void* m_userdata = nullptr;
|
||||
@@ -575,7 +613,13 @@ bool aurora_vulkan_set_stereo_targets(uint64_t frameToken,
|
||||
const AuroraVulkanStereoTarget* targets,
|
||||
uint32_t targetCount) {
|
||||
using namespace aurora::vulkan_interop;
|
||||
return g_bridge && g_bridge->SetTargets(frameToken, targets, targetCount);
|
||||
return g_bridge && g_bridge->SetTargets(frameToken, targets, targetCount, nullptr);
|
||||
}
|
||||
|
||||
bool aurora_vulkan_set_stereo_targets_with_panel(uint64_t frameToken, const AuroraVulkanStereoTarget* targets,
|
||||
uint32_t targetCount, const AuroraVulkanStereoTarget* panel) {
|
||||
using namespace aurora::vulkan_interop;
|
||||
return g_bridge && g_bridge->SetTargets(frameToken, targets, targetCount, panel);
|
||||
}
|
||||
|
||||
bool aurora_vulkan_cancel_stereo_targets(uint64_t frameToken) {
|
||||
@@ -615,6 +659,11 @@ bool aurora_vulkan_set_stereo_targets(uint64_t, const AuroraVulkanStereoTarget*,
|
||||
return false;
|
||||
}
|
||||
|
||||
bool aurora_vulkan_set_stereo_targets_with_panel(uint64_t, const AuroraVulkanStereoTarget*, uint32_t,
|
||||
const AuroraVulkanStereoTarget*) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool aurora_vulkan_cancel_stereo_targets(uint64_t) { return false; }
|
||||
|
||||
bool aurora_vulkan_disable_stereo_bridge() { return true; }
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#include <aurora/vulkan_win32_interop.h>
|
||||
#include "../internal.hpp"
|
||||
#include "../stereo.hpp"
|
||||
#include "../stereo_overlay.hpp"
|
||||
#include "gpu.hpp"
|
||||
#if defined(_WIN32) && defined(WEBGPU_DAWN) && defined(DAWN_ENABLE_BACKEND_VULKAN)
|
||||
#include <windows.h>
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
namespace aurora::vulkan_win32 {
|
||||
namespace {
|
||||
Module Log("aurora::vulkan_win32");
|
||||
struct Api {
|
||||
AuroraDawnVulkanConfigureFn configure = nullptr;
|
||||
AuroraDawnVulkanHandlesFn handles = nullptr;
|
||||
AuroraDawnVulkanWrapFn wrap = nullptr;
|
||||
AuroraDawnVulkanReleaseFn release = nullptr;
|
||||
AuroraDawnVulkanLockFn lock = nullptr;
|
||||
AuroraDawnVulkanUnlockFn unlock = nullptr;
|
||||
AuroraDawnVulkanDrainFn drain = nullptr;
|
||||
bool Load() {
|
||||
HMODULE module = GetModuleHandleW(L"webgpu_dawn.dll");
|
||||
if (!module) return false;
|
||||
auto version = reinterpret_cast<AuroraDawnVulkanVersionFn>(GetProcAddress(module, "AuroraDawnVulkanVersion"));
|
||||
if (!version || version() != AURORA_DAWN_VULKAN_ABI) return false;
|
||||
#define LOAD(member, type, name) member = reinterpret_cast<type>(GetProcAddress(module, name)); if (!member) return false
|
||||
LOAD(configure, AuroraDawnVulkanConfigureFn, "AuroraDawnVulkanConfigure");
|
||||
LOAD(handles, AuroraDawnVulkanHandlesFn, "AuroraDawnVulkanGetHandles");
|
||||
LOAD(wrap, AuroraDawnVulkanWrapFn, "AuroraDawnVulkanWrap");
|
||||
LOAD(release, AuroraDawnVulkanReleaseFn, "AuroraDawnVulkanRelease");
|
||||
LOAD(lock, AuroraDawnVulkanLockFn, "AuroraDawnVulkanLock");
|
||||
LOAD(unlock, AuroraDawnVulkanUnlockFn, "AuroraDawnVulkanUnlock");
|
||||
LOAD(drain, AuroraDawnVulkanDrainFn, "AuroraDawnVulkanDrain");
|
||||
#undef LOAD
|
||||
return true;
|
||||
}
|
||||
} api;
|
||||
int64_t VkFormat(wgpu::TextureFormat format) {
|
||||
switch (format) {
|
||||
case wgpu::TextureFormat::RGBA8Unorm: return 37;
|
||||
case wgpu::TextureFormat::RGBA8UnormSrgb: return 43;
|
||||
case wgpu::TextureFormat::BGRA8Unorm: return 44;
|
||||
case wgpu::TextureFormat::BGRA8UnormSrgb: return 50;
|
||||
case wgpu::TextureFormat::RGBA16Float: return 97;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
bool CopyCompatible(int64_t a, int64_t b) {
|
||||
return a == b || ((a == 37 || a == 43) && (b == 37 || b == 43)) ||
|
||||
((a == 44 || a == 50) && (b == 44 || b == 50));
|
||||
}
|
||||
struct Import { uint64_t image; uint32_t width, height; wgpu::TextureFormat format; wgpu::Texture texture; };
|
||||
// The eyes, then the settings panel's layer image after them.
|
||||
constexpr uint32_t kMaxImages = 3;
|
||||
class Bridge {
|
||||
public:
|
||||
std::mutex mutex;
|
||||
std::vector<Import> imports;
|
||||
std::array<AuroraD3D12StereoTarget, kMaxImages> targets{};
|
||||
std::array<wgpu::Texture, kMaxImages> active{};
|
||||
uint64_t token = 0;
|
||||
uint32_t count = 0;
|
||||
// Images this frame copies: `count` eyes, plus the panel when `panel` is set.
|
||||
uint32_t images = 0;
|
||||
bool panel = false;
|
||||
bool encoded = false;
|
||||
AuroraD3D12StereoSubmittedCallback callback;
|
||||
void* userdata;
|
||||
Bridge(AuroraD3D12StereoSubmittedCallback cb, void* data) : callback(cb), userdata(data) {}
|
||||
bool Set(uint64_t next, const AuroraD3D12StereoTarget* data, uint32_t n, const AuroraD3D12StereoTarget* panelTarget) {
|
||||
if (!next || !data || !n || n > 2) return false;
|
||||
std::lock_guard guard(mutex);
|
||||
if (token) return false;
|
||||
const auto valid = [](const AuroraD3D12StereoTarget& target) {
|
||||
return target.resource && target.width && target.height;
|
||||
};
|
||||
for (uint32_t i = 0; i < n; ++i) {
|
||||
if (!valid(data[i])) return false;
|
||||
}
|
||||
if (panelTarget && !valid(*panelTarget)) return false;
|
||||
for (uint32_t i = 0; i < n; ++i) targets[i] = data[i];
|
||||
panel = panelTarget != nullptr;
|
||||
if (panel) targets[n] = *panelTarget;
|
||||
token = next; count = n; images = n + (panel ? 1u : 0u); return true;
|
||||
}
|
||||
bool Cancel(uint64_t wanted) {
|
||||
std::unique_lock guard(mutex, std::try_to_lock);
|
||||
if (!guard.owns_lock() || encoded || !token || token != wanted) return false;
|
||||
token = 0; return true;
|
||||
}
|
||||
bool Encode(wgpu::CommandEncoder& encoder, const stereo::SinkFrame& frame) {
|
||||
std::lock_guard guard(mutex);
|
||||
if (!token || encoded || frame.frameToken != token) return false;
|
||||
std::array<stereo::EyeImage, kMaxImages> sources{};
|
||||
for (uint32_t i = 0; i < count; ++i) sources[i] = frame.eyes[i];
|
||||
if (panel && !stereo_overlay::layer_source(encoder, targets[count].width, targets[count].height, sources[count]))
|
||||
return false;
|
||||
// Validate/import every target before recording any copy.
|
||||
for (uint32_t i = 0; i < images; ++i) {
|
||||
const auto& eye = sources[i];
|
||||
const auto& target = targets[i];
|
||||
if (!eye.texture || eye.size.width != target.width || eye.size.height != target.height ||
|
||||
!CopyCompatible(VkFormat(eye.format), target.dxgiFormat)) return false;
|
||||
const uint64_t image = reinterpret_cast<uintptr_t>(target.resource);
|
||||
auto it = std::find_if(imports.begin(), imports.end(), [&](const Import& entry) { return entry.image == image; });
|
||||
if (it == imports.end()) {
|
||||
wgpu::TextureDescriptor descriptor;
|
||||
descriptor.usage = wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::RenderAttachment;
|
||||
descriptor.size = {target.width, target.height, 1};
|
||||
descriptor.format = eye.format;
|
||||
void* wrapped = api.wrap(webgpu::g_device.Get(), &descriptor, image);
|
||||
if (!wrapped) return false;
|
||||
imports.push_back({image, target.width, target.height, eye.format,
|
||||
wgpu::Texture::Acquire(static_cast<WGPUTexture>(wrapped))});
|
||||
it = imports.end() - 1;
|
||||
}
|
||||
if (it->width != target.width || it->height != target.height || it->format != eye.format) {
|
||||
// The runtime reuses VkImage handles across swapchain recreation, and
|
||||
// Aurora's eye format can change with the surface. Re-wrap rather than
|
||||
// rejecting every future frame for this image.
|
||||
imports.erase(it);
|
||||
--i;
|
||||
continue;
|
||||
}
|
||||
active[i] = it->texture;
|
||||
}
|
||||
for (uint32_t i = 0; i < images; ++i) {
|
||||
wgpu::TexelCopyTextureInfo source, destination;
|
||||
source.texture = *sources[i].texture;
|
||||
destination.texture = active[i];
|
||||
wgpu::Extent3D size{targets[i].width, targets[i].height, 1};
|
||||
encoder.CopyTextureToTexture(&source, &destination, &size);
|
||||
}
|
||||
encoded = true;
|
||||
return true;
|
||||
}
|
||||
void Submitted(const stereo::SinkFrame& frame) {
|
||||
std::lock_guard guard(mutex);
|
||||
if (!token || !encoded || token != frame.frameToken) return;
|
||||
std::array<void*, kMaxImages> textures{};
|
||||
for (uint32_t i = 0; i < images; ++i) textures[i] = active[i].Get();
|
||||
// Append the COLOR_ATTACHMENT_OPTIMAL release barriers to Dawn's queue,
|
||||
// flush them under its device guard, then allow the XR thread to release.
|
||||
const bool success = api.release(webgpu::g_device.Get(), textures.data(), images) != 0;
|
||||
const auto completed = token;
|
||||
token = 0; encoded = false;
|
||||
callback(completed, success, userdata);
|
||||
}
|
||||
};
|
||||
std::unique_ptr<Bridge> bridge;
|
||||
}
|
||||
}
|
||||
bool aurora_vulkan_win32_configure(const AuroraDawnVulkanHooks* hooks) {
|
||||
using namespace aurora::vulkan_win32;
|
||||
return api.Load() && api.configure(hooks);
|
||||
}
|
||||
bool aurora_vulkan_win32_get_handles(AuroraDawnVulkanHandles* handles, int64_t* format) {
|
||||
using namespace aurora;
|
||||
if (!webgpu::g_device || webgpu::g_backendType != wgpu::BackendType::Vulkan || !vulkan_win32::api.Load()) return false;
|
||||
*format = vulkan_win32::VkFormat(webgpu::g_graphicsConfig.surfaceConfiguration.format);
|
||||
return *format && vulkan_win32::api.handles(webgpu::g_device.Get(), handles);
|
||||
}
|
||||
bool aurora_vulkan_win32_enable(AuroraD3D12StereoSubmittedCallback cb, void* data) {
|
||||
using namespace aurora::vulkan_win32;
|
||||
if (bridge || !cb || !api.Load()) return false;
|
||||
bridge = std::make_unique<Bridge>(cb, data);
|
||||
aurora::stereo::set_sink(
|
||||
[](wgpu::CommandEncoder& encoder, const aurora::stereo::SinkFrame& frame, void* self) noexcept {
|
||||
return static_cast<Bridge*>(self)->Encode(encoder, frame);
|
||||
}, [](const aurora::stereo::SinkFrame& frame, void* self) noexcept { static_cast<Bridge*>(self)->Submitted(frame); }, bridge.get());
|
||||
return true;
|
||||
}
|
||||
bool aurora_vulkan_win32_set_targets(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count) {
|
||||
using namespace aurora::vulkan_win32;
|
||||
return bridge && bridge->Set(token, targets, count, nullptr);
|
||||
}
|
||||
bool aurora_vulkan_win32_set_targets_with_panel(uint64_t token, const AuroraD3D12StereoTarget* targets, uint32_t count,
|
||||
const AuroraD3D12StereoTarget* panel) {
|
||||
using namespace aurora::vulkan_win32;
|
||||
return bridge && bridge->Set(token, targets, count, panel);
|
||||
}
|
||||
bool aurora_vulkan_win32_cancel(uint64_t token) {
|
||||
using namespace aurora::vulkan_win32;
|
||||
return bridge && bridge->Cancel(token);
|
||||
}
|
||||
bool aurora_vulkan_win32_disable() {
|
||||
using namespace aurora::vulkan_win32;
|
||||
if (!bridge) return true;
|
||||
aurora::stereo::set_sink(nullptr, nullptr, nullptr);
|
||||
if (!api.drain(aurora::webgpu::g_device.Get())) { bridge.release(); return false; }
|
||||
bridge.reset();
|
||||
return true;
|
||||
}
|
||||
void* aurora_vulkan_win32_lock_queue() {
|
||||
return aurora::vulkan_win32::api.lock(aurora::webgpu::g_device.Get());
|
||||
}
|
||||
void aurora_vulkan_win32_unlock_queue(void* guard) { aurora::vulkan_win32::api.unlock(guard); }
|
||||
#else
|
||||
// C ABI stubs keep the runtime's OpenXR integration linkable on Windows GX
|
||||
// builds whose Dawn has no Vulkan backend; the backend then reports that the
|
||||
// bridge is unavailable and the game falls back to the desktop renderer.
|
||||
bool aurora_vulkan_win32_configure(const AuroraDawnVulkanHooks*) { return false; }
|
||||
bool aurora_vulkan_win32_get_handles(AuroraDawnVulkanHandles* handles, int64_t* format) {
|
||||
if (handles) *handles = {};
|
||||
if (format) *format = 0;
|
||||
return false;
|
||||
}
|
||||
bool aurora_vulkan_win32_enable(AuroraD3D12StereoSubmittedCallback, void*) { return false; }
|
||||
bool aurora_vulkan_win32_set_targets(uint64_t, const AuroraD3D12StereoTarget*, uint32_t) { return false; }
|
||||
bool aurora_vulkan_win32_set_targets_with_panel(uint64_t, const AuroraD3D12StereoTarget*, uint32_t,
|
||||
const AuroraD3D12StereoTarget*) { return false; }
|
||||
bool aurora_vulkan_win32_cancel(uint64_t) { return false; }
|
||||
bool aurora_vulkan_win32_disable() { return true; }
|
||||
void* aurora_vulkan_win32_lock_queue() { return nullptr; }
|
||||
void aurora_vulkan_win32_unlock_queue(void*) {}
|
||||
#endif
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Apply the versioned Aurora native Vulkan ABI to the pinned Dawn source tree."""
|
||||
from pathlib import Path
|
||||
import shutil, sys
|
||||
root = Path(sys.argv[1]).resolve()
|
||||
here = Path(__file__).resolve().parent
|
||||
for name in ("aurora_vulkan_hooks.h", "aurora_vulkan_interop.inc"):
|
||||
shutil.copyfile(here / name, root / "src/dawn/native/vulkan" / name)
|
||||
shutil.copyfile(here.parent.parent / "include/aurora/dawn_vulkan_abi.h",
|
||||
root / "src/dawn/native/vulkan/aurora_dawn_vulkan_abi.h")
|
||||
p = root / "src/dawn/native/vulkan/VulkanBackend.cpp"
|
||||
s = p.read_text()
|
||||
if '#include "aurora_vulkan_interop.inc"' not in s:
|
||||
s += '\n#include "aurora_vulkan_interop.inc"\n'
|
||||
p.write_text(s)
|
||||
p = root / "src/dawn/common/DynamicLib.cpp"
|
||||
s = p.read_text()
|
||||
# LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR requires an absolute filename. Vulkan's
|
||||
# system loader is also tried by bare name; preserve the restricted default
|
||||
# search directories for that case instead of failing with ERROR_INVALID_PARAMETER.
|
||||
old = 'LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS;'
|
||||
new = '''((filename.size() > 2 && filename[1] == ':') || filename.starts_with("\\\\\\\\")
|
||||
? LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR : 0) | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS;'''
|
||||
if old in s:
|
||||
s = s.replace(old, new, 1)
|
||||
p.write_text(s)
|
||||
p = root / "src/dawn/native/vulkan/VulkanFunctions.cpp"
|
||||
s = p.read_text()
|
||||
if '#include "aurora_vulkan_hooks.h"' not in s:
|
||||
marker = 'namespace dawn::native::vulkan {'
|
||||
assert marker in s
|
||||
s = s.replace(marker, '#include "aurora_vulkan_hooks.h"\n\n' + marker, 1)
|
||||
for old, new in [
|
||||
('GET_GLOBAL_PROC(CreateInstance);', 'CreateInstance = AuroraCreateInstance(GetInstanceProcAddr);'),
|
||||
('GET_INSTANCE_PROC(CreateDevice);', 'CreateDevice = AuroraCreateDevice(GetInstanceProcAddr, instance);'),
|
||||
('GET_INSTANCE_PROC(EnumeratePhysicalDevices);', 'EnumeratePhysicalDevices = AuroraEnumeratePhysicalDevices(GetInstanceProcAddr, instance);')]:
|
||||
assert old in s, old
|
||||
s = s.replace(old, old + '\n ' + new, 1)
|
||||
p.write_text(s)
|
||||
@@ -0,0 +1,9 @@
|
||||
// Included only by the pinned Dawn source build.
|
||||
#pragma once
|
||||
#include "dawn/native/DawnNative.h"
|
||||
#include "aurora_dawn_vulkan_abi.h"
|
||||
namespace dawn::native::vulkan {
|
||||
PFN_vkCreateInstance AuroraCreateInstance(PFN_vkGetInstanceProcAddr proc);
|
||||
PFN_vkCreateDevice AuroraCreateDevice(PFN_vkGetInstanceProcAddr proc, VkInstance instance);
|
||||
PFN_vkEnumeratePhysicalDevices AuroraEnumeratePhysicalDevices(PFN_vkGetInstanceProcAddr proc, VkInstance instance);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Compiled inside VulkanBackend.cpp, using the pinned Dawn implementation.
|
||||
#include "aurora_vulkan_hooks.h"
|
||||
#include "src/dawn/native/ChainUtils.h"
|
||||
#include "src/dawn/native/vulkan/PhysicalDeviceVk.h"
|
||||
#include "src/dawn/native/vulkan/QueueVk.h"
|
||||
#include <mutex>
|
||||
namespace dawn::native::vulkan {
|
||||
namespace {
|
||||
AuroraDawnVulkanHooks auroraHooks{};
|
||||
std::recursive_mutex auroraHooksMutex;
|
||||
PFN_vkGetInstanceProcAddr auroraGetProc = nullptr;
|
||||
VkInstance auroraInstance = VK_NULL_HANDLE;
|
||||
::VkResult VKAPI_CALL AuroraCreateInstanceImpl(const VkInstanceCreateInfo* info,
|
||||
const VkAllocationCallbacks* allocator, VkInstance* instance) {
|
||||
std::lock_guard lock(auroraHooksMutex);
|
||||
if (auroraHooks.createInstance) return static_cast<::VkResult>(auroraHooks.createInstance(
|
||||
auroraHooks.userdata, reinterpret_cast<void*>(auroraGetProc), info, allocator,
|
||||
reinterpret_cast<void**>(instance)));
|
||||
return reinterpret_cast<PFN_vkCreateInstance>(auroraGetProc(nullptr, "vkCreateInstance"))(info, allocator, instance);
|
||||
}
|
||||
::VkResult VKAPI_CALL AuroraCreateDeviceImpl(VkPhysicalDevice physical, const VkDeviceCreateInfo* info,
|
||||
const VkAllocationCallbacks* allocator, VkDevice* device) {
|
||||
std::lock_guard lock(auroraHooksMutex);
|
||||
if (auroraHooks.createDevice) return static_cast<::VkResult>(auroraHooks.createDevice(
|
||||
auroraHooks.userdata, reinterpret_cast<void*>(auroraGetProc), physical, info, allocator,
|
||||
reinterpret_cast<void**>(device)));
|
||||
return reinterpret_cast<PFN_vkCreateDevice>(auroraGetProc(auroraInstance, "vkCreateDevice"))(physical, info, allocator, device);
|
||||
}
|
||||
::VkResult VKAPI_CALL AuroraEnumerateImpl(VkInstance instance, uint32_t* count, VkPhysicalDevice* devices) {
|
||||
std::lock_guard lock(auroraHooksMutex);
|
||||
if (!auroraHooks.getPhysicalDevice) return reinterpret_cast<PFN_vkEnumeratePhysicalDevices>(
|
||||
auroraGetProc(instance, "vkEnumeratePhysicalDevices"))(instance, count, devices);
|
||||
void* physical = nullptr;
|
||||
const auto result = static_cast<::VkResult>(auroraHooks.getPhysicalDevice(auroraHooks.userdata, instance, &physical));
|
||||
if (result != VK_SUCCESS) return result;
|
||||
if (!devices) { *count = 1; return VK_SUCCESS; }
|
||||
if (*count == 0) return VK_INCOMPLETE;
|
||||
devices[0] = static_cast<VkPhysicalDevice>(physical);
|
||||
*count = 1;
|
||||
return VK_SUCCESS;
|
||||
}
|
||||
struct AuroraDeviceGuard { decltype(std::declval<Device*>()->GetGuard()) guard; explicit AuroraDeviceGuard(Device* device) : guard(device->GetGuard()) {} };
|
||||
}
|
||||
PFN_vkCreateInstance AuroraCreateInstance(PFN_vkGetInstanceProcAddr proc) {
|
||||
std::lock_guard lock(auroraHooksMutex);
|
||||
if (!auroraHooks.createInstance) return reinterpret_cast<PFN_vkCreateInstance>(proc(nullptr, "vkCreateInstance"));
|
||||
auroraGetProc = proc;
|
||||
return AuroraCreateInstanceImpl;
|
||||
}
|
||||
PFN_vkCreateDevice AuroraCreateDevice(PFN_vkGetInstanceProcAddr proc, VkInstance instance) {
|
||||
std::lock_guard lock(auroraHooksMutex);
|
||||
if (!auroraHooks.createDevice) return reinterpret_cast<PFN_vkCreateDevice>(proc(instance, "vkCreateDevice"));
|
||||
auroraGetProc = proc; auroraInstance = instance;
|
||||
return AuroraCreateDeviceImpl;
|
||||
}
|
||||
PFN_vkEnumeratePhysicalDevices AuroraEnumeratePhysicalDevices(PFN_vkGetInstanceProcAddr proc, VkInstance instance) {
|
||||
std::lock_guard lock(auroraHooksMutex);
|
||||
if (!auroraHooks.getPhysicalDevice) return reinterpret_cast<PFN_vkEnumeratePhysicalDevices>(proc(instance, "vkEnumeratePhysicalDevices"));
|
||||
auroraGetProc = proc; auroraInstance = instance;
|
||||
return AuroraEnumerateImpl;
|
||||
}
|
||||
extern "C" DAWN_NATIVE_EXPORT uint32_t AuroraDawnVulkanVersion() { return AURORA_DAWN_VULKAN_ABI; }
|
||||
extern "C" DAWN_NATIVE_EXPORT int AuroraDawnVulkanConfigure(const AuroraDawnVulkanHooks* hooks) {
|
||||
std::lock_guard lock(auroraHooksMutex);
|
||||
// Called before adapter discovery, or cleared after Aurora shutdown. The
|
||||
// runtime and hook storage must outlive all device creation calls.
|
||||
auroraHooks = hooks ? *hooks : AuroraDawnVulkanHooks{};
|
||||
return 1;
|
||||
}
|
||||
extern "C" DAWN_NATIVE_EXPORT int AuroraDawnVulkanGetHandles(void* handle, AuroraDawnVulkanHandles* out) {
|
||||
auto* device = ToBackend(FromAPI(static_cast<WGPUDevice>(handle)));
|
||||
auto guard = device->GetGuard();
|
||||
if (!device->HasFeature(Feature::ImplicitDeviceSynchronization)) return 0;
|
||||
*out = {device->GetVkInstance(), ToBackend(device->GetPhysicalDevice())->GetVkPhysicalDevice(),
|
||||
device->GetVkDevice(), device->GetGraphicsQueueFamily(), 0};
|
||||
return 1;
|
||||
}
|
||||
extern "C" DAWN_NATIVE_EXPORT void* AuroraDawnVulkanWrap(void* handle, const void* desc, uint64_t image) {
|
||||
auto* device = ToBackend(FromAPI(static_cast<WGPUDevice>(handle)));
|
||||
auto guard = device->GetGuard();
|
||||
// Mirror DeviceBase::CreateTexture: fill the trivial frontend defaults
|
||||
// (dimension, mip and sample counts) and validate before the backend reads
|
||||
// them. An Undefined dimension would otherwise abort the first copy.
|
||||
TextureDescriptor raw = reinterpret_cast<const TextureDescriptor*>(desc)->WithTrivialFrontendDefaults();
|
||||
UnpackedPtr<TextureDescriptor> descriptor;
|
||||
if (device->ConsumedError(ValidateAndUnpack(&raw), &descriptor)) return nullptr;
|
||||
if (device->ConsumedError(ValidateTextureDescriptor(device, descriptor, AllowMultiPlanarTextureFormat::No))) return nullptr;
|
||||
auto texture = SwapChainTexture::Create(device, descriptor, VkImage::CreateFromHandle(reinterpret_cast<::VkImage>(image)));
|
||||
texture->SetIsSubresourceContentInitialized(true, texture->GetAllSubresources());
|
||||
texture->UpdateUsage(wgpu::TextureUsage::RenderAttachment, wgpu::ShaderStage::None, texture->GetAllSubresources());
|
||||
return ToAPI(ReturnToAPI(std::move(texture)));
|
||||
}
|
||||
extern "C" DAWN_NATIVE_EXPORT int AuroraDawnVulkanRelease(void* handle, void* const* textures, uint32_t count) {
|
||||
auto* device = ToBackend(FromAPI(static_cast<WGPUDevice>(handle)));
|
||||
auto guard = device->GetGuard();
|
||||
auto* queue = ToBackend(device->GetQueue());
|
||||
auto* context = queue->GetPendingRecordingContext();
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
auto* texture = ToBackend(FromAPI(static_cast<WGPUTexture>(textures[i])));
|
||||
texture->TransitionUsageNow(context, wgpu::TextureUsage::RenderAttachment,
|
||||
wgpu::ShaderStage::None, texture->GetAllSubresources());
|
||||
}
|
||||
return !device->ConsumedError(queue->SubmitPendingCommands());
|
||||
}
|
||||
extern "C" DAWN_NATIVE_EXPORT void* AuroraDawnVulkanLock(void* handle) {
|
||||
auto* device = ToBackend(FromAPI(static_cast<WGPUDevice>(handle)));
|
||||
return new AuroraDeviceGuard(device);
|
||||
}
|
||||
extern "C" DAWN_NATIVE_EXPORT void AuroraDawnVulkanUnlock(void* guard) {
|
||||
delete static_cast<AuroraDeviceGuard*>(guard);
|
||||
}
|
||||
extern "C" DAWN_NATIVE_EXPORT int AuroraDawnVulkanDrain(void* handle) {
|
||||
auto* device = ToBackend(FromAPI(static_cast<WGPUDevice>(handle)));
|
||||
auto guard = device->GetGuard();
|
||||
if (device->ConsumedError(ToBackend(device->GetQueue())->SubmitPendingCommands())) return 0;
|
||||
return device->fn.QueueWaitIdle(ToBackend(device->GetQueue())->GetVkQueue()) == VK_SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,18 @@ include(GoogleTest)
|
||||
|
||||
option(AURORA_GPU_SMOKE_TESTS "Build opt-in tests requiring a desktop GPU" OFF)
|
||||
if (AURORA_GPU_SMOKE_TESTS AND AURORA_ENABLE_GX AND WIN32)
|
||||
# Exercises the custom Dawn DLL's Aurora Vulkan ABI (patches/dawn). Run it with that
|
||||
# webgpu_dawn.dll beside the executable; the stock prebuilt Dawn lacks the exports and the
|
||||
# test reports that at startup. Vulkan headers come from the SDK or any tree on
|
||||
# AURORA_TEST_VULKAN_INCLUDE (the runtime's FetchContent copy works).
|
||||
find_path(AURORA_TEST_VULKAN_INCLUDE vulkan/vulkan.h HINTS "$ENV{VULKAN_SDK}/Include")
|
||||
if (AURORA_TEST_VULKAN_INCLUDE)
|
||||
add_executable(vulkan_native_bridge_smoke vulkan_native_bridge_smoke.cpp)
|
||||
target_include_directories(vulkan_native_bridge_smoke PRIVATE ../include "${AURORA_TEST_VULKAN_INCLUDE}")
|
||||
target_link_libraries(vulkan_native_bridge_smoke PRIVATE dawn::webgpu_dawn dawn::dawncpp_headers)
|
||||
else ()
|
||||
message(STATUS "vulkan_native_bridge_smoke skipped: no vulkan/vulkan.h (set VULKAN_SDK or AURORA_TEST_VULKAN_INCLUDE)")
|
||||
endif ()
|
||||
add_executable(stereo_frame_worker_smoke stereo_frame_worker_smoke.cpp)
|
||||
target_include_directories(stereo_frame_worker_smoke PRIVATE ../lib)
|
||||
target_link_libraries(stereo_frame_worker_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
|
||||
@@ -15,6 +27,13 @@ if (AURORA_GPU_SMOKE_TESTS AND AURORA_ENABLE_GX AND WIN32)
|
||||
target_include_directories(efb_ram_lifetime_smoke PRIVATE ../lib)
|
||||
target_link_libraries(efb_ram_lifetime_smoke PRIVATE aurora::core aurora::gx aurora::main aurora::vi
|
||||
dawn::dawncpp_headers)
|
||||
# VR cockpit overlay (hands, synthetic wheel) against real scene depth. Standalone: it
|
||||
# defines the GPU globals itself and needs only the header.
|
||||
add_executable(cockpit_gpu_smoke cockpit_gpu_smoke.cpp)
|
||||
target_include_directories(cockpit_gpu_smoke PRIVATE ../include ../lib)
|
||||
target_compile_definitions(cockpit_gpu_smoke PRIVATE AURORA TARGET_PC WEBGPU_DAWN)
|
||||
target_link_libraries(cockpit_gpu_smoke PRIVATE fmt::fmt xxhash absl::flat_hash_map absl::btree
|
||||
dawn::webgpu_dawn dawn::dawncpp_headers TracyClient ${AURORA_SDL3_TARGET})
|
||||
endif ()
|
||||
|
||||
if (NOT TARGET gtest)
|
||||
@@ -36,6 +55,8 @@ if (AURORA_ENABLE_GX)
|
||||
stereo_replay_test.cpp
|
||||
stereo_interpolation_test.cpp
|
||||
stereo_mirror_test.cpp
|
||||
native_wheel_test.cpp
|
||||
cockpit_geometry_test.cpp
|
||||
texture_bind_group_cache_key_test.cpp
|
||||
../lib/gfx/efb_ram_encoder.cpp
|
||||
# GX API implementations (encoders)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// VR cockpit overlay geometry: what the synthetic wheel, handlebar and hands
|
||||
// build in the seated frame, without a GPU.
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "gfx/cockpit.hpp"
|
||||
|
||||
namespace {
|
||||
using aurora::gfx::cockpit::V;
|
||||
using aurora::gfx::cockpit::Vertex;
|
||||
|
||||
bool all_finite(const std::vector<Vertex>& vertices) {
|
||||
for (const auto& vertex : vertices) {
|
||||
for (float value : vertex.position) {
|
||||
uint32_t bits = 0;
|
||||
std::memcpy(&bits, &value, sizeof(bits));
|
||||
if ((bits & 0x7f800000u) == 0x7f800000u) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void set_identity(float (&matrix)[12], V translation) {
|
||||
const auto identity = aurora::gfx::cockpit::identity();
|
||||
std::memcpy(matrix, identity.data(), sizeof(matrix));
|
||||
matrix[3] = translation[0];
|
||||
matrix[7] = translation[1];
|
||||
matrix[11] = translation[2];
|
||||
}
|
||||
|
||||
class CockpitGeometry : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override { clear_meshes(); }
|
||||
void TearDown() override { clear_meshes(); }
|
||||
static void clear_meshes() {
|
||||
std::lock_guard lock(aurora::gfx::cockpit::meshMutex);
|
||||
aurora::gfx::cockpit::meshes = {};
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(CockpitGeometry, NativeWheelWithoutHandsDrawsNothing) {
|
||||
AuroraCockpit cockpit{};
|
||||
cockpit.nativeWheel = true;
|
||||
EXPECT_TRUE(aurora::gfx::cockpit::geometry(cockpit).empty());
|
||||
}
|
||||
|
||||
TEST_F(CockpitGeometry, SyntheticKartWheelSitsOnItsRim) {
|
||||
AuroraCockpit cockpit{};
|
||||
const auto vertices = aurora::gfx::cockpit::geometry(cockpit);
|
||||
ASSERT_FALSE(vertices.empty());
|
||||
ASSERT_TRUE(all_finite(vertices));
|
||||
// The rim, spokes and hub stay within the 0.18 m wheel plus its tube, around
|
||||
// the wheel centre the input side uses (steering_wheel.h).
|
||||
for (const auto& vertex : vertices) {
|
||||
const float x = vertex.position[0];
|
||||
const float y = vertex.position[1] + 0.30f;
|
||||
EXPECT_LE(std::hypot(x, y), 0.18f + 0.02f);
|
||||
EXPECT_NEAR(vertex.position[2], -0.42f, 0.04f);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(CockpitGeometry, SyntheticWheelTurnsWithTheAngle) {
|
||||
AuroraCockpit cockpit{};
|
||||
const auto straight = aurora::gfx::cockpit::geometry(cockpit);
|
||||
cockpit.wheelAngle = 0.5f;
|
||||
const auto turned = aurora::gfx::cockpit::geometry(cockpit);
|
||||
ASSERT_EQ(straight.size(), turned.size());
|
||||
bool moved = false;
|
||||
for (size_t i = 0; i < straight.size() && !moved; ++i) {
|
||||
moved = std::abs(straight[i].position[0] - turned[i].position[0]) > 1e-3f;
|
||||
}
|
||||
EXPECT_TRUE(moved);
|
||||
}
|
||||
|
||||
TEST_F(CockpitGeometry, SyntheticHandlebarFollowsItsFrame) {
|
||||
AuroraCockpit cockpit{};
|
||||
cockpit.bike = true;
|
||||
cockpit.handlebarRadius = 0.25f;
|
||||
// Bar axis along seat +X, centred 0.3 m down and 0.42 m ahead.
|
||||
const float pose[12]{1, 0, 0, 0, 0, 0, 1, -0.3f, 0, -1, 0, -0.42f};
|
||||
std::memcpy(cockpit.seatFromHandlebar, pose, sizeof(pose));
|
||||
const auto vertices = aurora::gfx::cockpit::geometry(cockpit);
|
||||
ASSERT_FALSE(vertices.empty());
|
||||
ASSERT_TRUE(all_finite(vertices));
|
||||
float minX = 1e9f;
|
||||
float maxX = -1e9f;
|
||||
for (const auto& vertex : vertices) {
|
||||
minX = std::min(minX, vertex.position[0]);
|
||||
maxX = std::max(maxX, vertex.position[0]);
|
||||
}
|
||||
EXPECT_NEAR(minX, -0.25f, 0.03f);
|
||||
EXPECT_NEAR(maxX, 0.25f, 0.03f);
|
||||
}
|
||||
|
||||
TEST_F(CockpitGeometry, TrackedHandDrawsAGloveAtItsGrip) {
|
||||
AuroraCockpit cockpit{};
|
||||
cockpit.nativeWheel = true;
|
||||
cockpit.hands[1].tracked = true;
|
||||
cockpit.hands[1].squeeze = 1.0f;
|
||||
set_identity(cockpit.hands[1].seatFromGrip, {0.2f, -0.3f, -0.4f});
|
||||
const auto vertices = aurora::gfx::cockpit::geometry(cockpit);
|
||||
ASSERT_FALSE(vertices.empty());
|
||||
ASSERT_TRUE(all_finite(vertices));
|
||||
for (const auto& vertex : vertices) {
|
||||
EXPECT_LT(std::abs(vertex.position[0] - 0.2f), 0.15f);
|
||||
EXPECT_LT(std::abs(vertex.position[1] + 0.3f), 0.15f);
|
||||
EXPECT_LT(std::abs(vertex.position[2] + 0.4f), 0.15f);
|
||||
}
|
||||
}
|
||||
|
||||
// The grip space OpenXR defines: -Z up the curled fingers' tube towards the
|
||||
// thumb, +X out of the palm. So the fingers run along Y (+Y on the right hand,
|
||||
// -Y on the left) and close towards +X, never out of the back of the hand.
|
||||
TEST_F(CockpitGeometry, GloveFingersRunAlongTheHandAndCloseIntoThePalm) {
|
||||
// Both grips carry the same orientation when the hands hold a wheel symmetrically, so the fingers
|
||||
// run along -Y on both and it is the palm side that mirrors: +X on the left hand, -X on the right.
|
||||
// Building the right hand's fingers on +Y instead pointed them at the player (PC, 2026-09-23).
|
||||
for (int side = 0; side < 2; ++side) {
|
||||
const float palmSide = side == 0 ? 1.0f : -1.0f;
|
||||
const auto build = [&](float squeeze) {
|
||||
AuroraCockpit cockpit{};
|
||||
cockpit.nativeWheel = true;
|
||||
cockpit.hands[side].tracked = true;
|
||||
cockpit.hands[side].squeeze = squeeze;
|
||||
set_identity(cockpit.hands[side].seatFromGrip, {0.0f, 0.0f, 0.0f});
|
||||
return aurora::gfx::cockpit::geometry(cockpit);
|
||||
};
|
||||
struct Extent {
|
||||
float reach = 0.0f; // furthest along the fingers
|
||||
float palm = 0.0f; // furthest towards the palm's normal
|
||||
float back = 0.0f; // furthest out of the back of the hand
|
||||
float across = 0.0f; // furthest across the knuckles
|
||||
};
|
||||
const auto measure = [&](const std::vector<Vertex>& vertices) {
|
||||
Extent e{};
|
||||
for (const auto& vertex : vertices) {
|
||||
e.reach = std::max(e.reach, -vertex.position[1]);
|
||||
e.palm = std::max(e.palm, vertex.position[0] * palmSide);
|
||||
e.back = std::min(e.back, vertex.position[0] * palmSide);
|
||||
e.across = std::max(e.across, std::abs(vertex.position[2]));
|
||||
}
|
||||
return e;
|
||||
};
|
||||
const auto open = measure(build(0.0f));
|
||||
const auto closed = measure(build(1.0f));
|
||||
EXPECT_GT(open.reach, 0.09f) << "open fingers reach along the hand, side " << side;
|
||||
EXPECT_LT(open.palm, 0.05f) << "an open hand is flat, side " << side;
|
||||
EXPECT_LT(closed.reach, open.reach - 0.02f) << "closing shortens the reach, side " << side;
|
||||
EXPECT_GT(closed.palm, open.palm + 0.02f) << "closing moves the fingers into the palm, side " << side;
|
||||
EXPECT_GT(closed.back, -0.03f) << "fingers never bend out of the back of the hand, side " << side;
|
||||
EXPECT_LT(closed.across, 0.07f) << "fingers stay across the knuckles, side " << side;
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(CockpitGeometry, RuntimeFingersCurlTowardPalmForSqueezeAndWheelGrab) {
|
||||
using namespace aurora::gfx::cockpit;
|
||||
// OpenXR joint space: -Z runs toward the fingertip, +Y out of the back
|
||||
// of the hand, for BOTH hands. Mirror positions, not the curl direction.
|
||||
for (int side = 0; side < 2; ++side) {
|
||||
SCOPED_TRACE(side);
|
||||
HandMesh mesh;
|
||||
mesh.parents.fill(1);
|
||||
mesh.parents[1] = -1;
|
||||
const float rootPose[7]{0, 0, 0.70710678f, 0.70710678f, 0.12f, -0.08f, 0.03f};
|
||||
const M root = from_pose(rootPose);
|
||||
mesh.bind.fill(root);
|
||||
const int bases[]{2, 6, 11, 16, 21};
|
||||
for (int finger = 0; finger < 5; ++finger) {
|
||||
const int base = bases[finger];
|
||||
const int count = finger == 0 ? 4 : 5;
|
||||
for (int bone = 0; bone < count; ++bone) {
|
||||
M bind = identity();
|
||||
bind[3] = (side == 0 ? -1.0f : 1.0f) * (finger - 2) * 0.018f;
|
||||
bind[11] = -0.025f * (bone + 1);
|
||||
mesh.bind[base + bone] = compose(root, bind);
|
||||
mesh.parents[base + bone] = bone == 0 ? 1 : base + bone - 1;
|
||||
}
|
||||
}
|
||||
for (int j = 0; j < 26; ++j) mesh.inverseBind[j] = inverse(mesh.bind[j]);
|
||||
// A tiny triangle rigidly weighted to each joint, including each fingertip.
|
||||
for (int j = 0; j < 26; ++j) {
|
||||
for (V offset : {V{0, 0, 0}, V{0.001f, 0, 0}, V{0, 0, 0.001f}}) {
|
||||
AuroraVRHandVertex vertex{};
|
||||
const V p = point(mesh.bind[j].data(), offset);
|
||||
std::memcpy(vertex.position, p.data(), sizeof(vertex.position));
|
||||
vertex.joints[0] = j;
|
||||
vertex.weights[0] = 1;
|
||||
mesh.indices.push_back(static_cast<uint16_t>(mesh.vertices.size()));
|
||||
mesh.vertices.push_back(vertex);
|
||||
}
|
||||
}
|
||||
AuroraCockpitHand hand{};
|
||||
set_identity(hand.seatFromGrip, {0, 0, 0});
|
||||
const auto build = [&](float squeeze, bool held) {
|
||||
hand.squeeze = squeeze;
|
||||
hand.held = held;
|
||||
std::vector<Vertex> vertices;
|
||||
runtime_hand(vertices, hand, mesh);
|
||||
return vertices;
|
||||
};
|
||||
const auto open = build(0, false);
|
||||
for (int j = 0; j < 26; ++j) {
|
||||
const V bind = point(mesh.inverseBind[1].data(), point(mesh.bind[j].data(), {0, 0, 0}));
|
||||
for (int axis = 0; axis < 3; ++axis)
|
||||
EXPECT_NEAR(open[j * 3].position[axis], bind[axis] + (axis == 2 ? 0.04f : 0), 1e-6f);
|
||||
}
|
||||
for (const auto& closed : {build(0.5f, false), build(1, false), build(0, true)}) {
|
||||
ASSERT_TRUE(all_finite(closed));
|
||||
for (int tip : {5, 10, 15, 20, 25}) {
|
||||
EXPECT_LT(closed[tip * 3].position[1], open[tip * 3].position[1] - 0.005f)
|
||||
<< "fingertip must move toward palm (-Y), joint " << tip;
|
||||
EXPECT_GT(closed[tip * 3].position[2], open[tip * 3].position[2])
|
||||
<< "curl must shorten finger reach, joint " << tip;
|
||||
}
|
||||
for (int rigid : {0, 1, 6, 11, 16, 21})
|
||||
for (int axis = 0; axis < 3; ++axis)
|
||||
EXPECT_NEAR(closed[rigid * 3].position[axis], open[rigid * 3].position[axis], 1e-6f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(CockpitGeometry, RuntimeHandMeshIsSkinnedWithoutNans) {
|
||||
using namespace aurora::gfx::cockpit;
|
||||
auto mesh = std::make_shared<HandMesh>();
|
||||
// A 26-joint chain, each joint 1 cm past its parent; one triangle on the tip.
|
||||
for (int j = 0; j < 26; ++j) {
|
||||
mesh->bind[j] = identity();
|
||||
mesh->bind[j][11] = -0.01f * float(j);
|
||||
mesh->inverseBind[j] = inverse(mesh->bind[j]);
|
||||
mesh->parents[j] = j - 1;
|
||||
}
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
AuroraVRHandVertex vertex{};
|
||||
vertex.position[0] = 0.01f * float(i);
|
||||
vertex.position[2] = -0.25f;
|
||||
vertex.joints[0] = 25;
|
||||
vertex.joints[1] = vertex.joints[2] = vertex.joints[3] = -1;
|
||||
vertex.weights[0] = 1.0f;
|
||||
mesh->vertices.push_back(vertex);
|
||||
mesh->indices.push_back(uint16_t(i));
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(meshMutex);
|
||||
meshes[0] = mesh;
|
||||
}
|
||||
AuroraCockpit cockpit{};
|
||||
cockpit.nativeWheel = true;
|
||||
cockpit.hands[0].tracked = true;
|
||||
cockpit.hands[0].held = true;
|
||||
set_identity(cockpit.hands[0].seatFromGrip, {-0.2f, -0.3f, -0.4f});
|
||||
const auto vertices = geometry(cockpit);
|
||||
ASSERT_EQ(vertices.size(), 3u) << "the runtime mesh replaces the glove";
|
||||
EXPECT_TRUE(all_finite(vertices));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
// Renders the VR cockpit overlay on a real GPU against cleared, occluding and
|
||||
// partially occluding scene depth, forward and reversed, 1x and 4x MSAA.
|
||||
#include "../lib/gfx/cockpit.hpp"
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <atomic>
|
||||
namespace aurora::webgpu { wgpu::Device g_device; wgpu::Queue g_queue; GraphicsConfig g_graphicsConfig{}; }
|
||||
std::atomic<int> errors=0;
|
||||
int main() {
|
||||
using namespace aurora;
|
||||
using namespace webgpu;
|
||||
wgpu::InstanceDescriptor id{};
|
||||
const wgpu::InstanceFeatureName timed=wgpu::InstanceFeatureName::TimedWaitAny;
|
||||
id.requiredFeatureCount=1;id.requiredFeatures=&timed;
|
||||
auto instance=wgpu::CreateInstance(&id);
|
||||
wgpu::Adapter adapter;
|
||||
wgpu::RequestAdapterOptions options{.backendType=wgpu::BackendType::D3D12};
|
||||
auto future=instance.RequestAdapter(&options,wgpu::CallbackMode::WaitAnyOnly,
|
||||
[&](wgpu::RequestAdapterStatus status,wgpu::Adapter a,wgpu::StringView message) {
|
||||
if(status==wgpu::RequestAdapterStatus::Success) adapter=std::move(a);
|
||||
else std::cerr<<std::string_view(message)<<'\n';
|
||||
});
|
||||
if(instance.WaitAny(future,5000000000)!=wgpu::WaitStatus::Success||!adapter) return 1;
|
||||
wgpu::DeviceDescriptor dd{};
|
||||
dd.SetUncapturedErrorCallback([](const wgpu::Device&,wgpu::ErrorType,wgpu::StringView message) {
|
||||
++errors;std::cerr<<std::string_view(message)<<'\n';
|
||||
});
|
||||
future=adapter.RequestDevice(&dd,wgpu::CallbackMode::WaitAnyOnly,
|
||||
[&](wgpu::RequestDeviceStatus status,wgpu::Device device,wgpu::StringView message) {
|
||||
if(status==wgpu::RequestDeviceStatus::Success) g_device=std::move(device);
|
||||
else std::cerr<<std::string_view(message)<<'\n';
|
||||
});
|
||||
if(instance.WaitAny(future,5000000000)!=wgpu::WaitStatus::Success||!g_device) return 1;
|
||||
g_queue=g_device.GetQueue();
|
||||
g_graphicsConfig.surfaceConfiguration.format=wgpu::TextureFormat::RGBA8Unorm;
|
||||
g_graphicsConfig.depthFormat=wgpu::TextureFormat::Depth32Float;
|
||||
AuroraCockpit native{}; native.nativeWheel=true;
|
||||
if(!gfx::cockpit::geometry(native).empty()) return 1;
|
||||
native.nativeWheel=false;
|
||||
if(gfx::cockpit::geometry(native).empty()) return 1;
|
||||
for(bool bike : {false,true}) for(bool original : {false,true}) for(uint32_t samples : {1u,4u})
|
||||
for(bool hud : {false,true}) for(int coverage : {0,1,2}) for(bool reversed : {false,true}) for(uint32_t eyeIndex : {0u,1u}) {
|
||||
const bool occluded=coverage==1;
|
||||
gfx::StereoReplayFrame frame{};
|
||||
frame.cockpit.unitsPerMeter=100;
|
||||
frame.cockpit.active=true;frame.cockpit.wheelAngle=0.35f;
|
||||
frame.cockpit.nativeWheel=original;
|
||||
frame.cockpit.bike=bike;frame.cockpit.handlebarRadius=0.25f;
|
||||
const float handlePose[12]{1,0,0,0, 0,0,1,-0.3f, 0,-1,0,-0.42f};
|
||||
std::memcpy(frame.cockpit.seatFromHandlebar,handlePose,sizeof(handlePose));
|
||||
for(int hand=0;hand<2;++hand) {
|
||||
auto& h=frame.cockpit.hands[hand];h.tracked=true;h.held=true;h.squeeze=1;
|
||||
auto pose=gfx::cockpit::identity();pose[3]=hand?0.18f:-0.18f;pose[7]=-0.30f;pose[11]=-0.42f;
|
||||
std::memcpy(h.seatFromGrip,pose.data(),sizeof(h.seatFromGrip));
|
||||
}
|
||||
wgpu::TextureDescriptor td{.usage=wgpu::TextureUsage::RenderAttachment|wgpu::TextureUsage::CopySrc,
|
||||
.size={512,512,1},.format=wgpu::TextureFormat::RGBA8Unorm,.sampleCount=1};
|
||||
auto output=g_device.CreateTexture(&td);
|
||||
td.sampleCount=samples;td.usage=wgpu::TextureUsage::RenderAttachment;
|
||||
auto color=g_device.CreateTexture(&td);
|
||||
td.format=wgpu::TextureFormat::Depth24PlusStencil8;auto depth=g_device.CreateTexture(&td);
|
||||
auto& eye=frame.eyes[eyeIndex];eye.target.colorView=samples==1?output.CreateView():color.CreateView();
|
||||
if(samples>1) eye.target.resolveView=output.CreateView();
|
||||
eye.target.depthFormat=td.format;eye.target.depthView=depth.CreateView();eye.target.size={512,512,1};eye.target.msaaSamples=samples;
|
||||
eye.projection.m0[0]=1;eye.projection.m1[1]=1;
|
||||
eye.projection.m0[2]=eyeIndex?0.06f:-0.06f;
|
||||
auto view=gfx::cockpit::identity();view[7]=0.20f;
|
||||
std::memcpy(frame.cockpit.eyeFromSeat[eyeIndex],view.data(),sizeof(frame.cockpit.eyeFromSeat[eyeIndex]));
|
||||
auto encoder=g_device.CreateCommandEncoder();
|
||||
const wgpu::RenderPassColorAttachment clear{.view=eye.target.colorView,.resolveTarget=eye.target.resolveView,
|
||||
.loadOp=wgpu::LoadOp::Clear,.storeOp=wgpu::StoreOp::Store,.clearValue={0.06,0.09,0.13,1}};
|
||||
const wgpu::RenderPassDepthStencilAttachment sceneDepth{.view=eye.target.depthView,
|
||||
.depthLoadOp=wgpu::LoadOp::Clear,.depthStoreOp=wgpu::StoreOp::Store,.depthClearValue=reversed?(occluded?0.8f:0.0f):(occluded?0.2f:1.0f),
|
||||
.stencilLoadOp=wgpu::LoadOp::Clear,.stencilStoreOp=wgpu::StoreOp::Store,.stencilClearValue=0};
|
||||
const wgpu::RenderPassDescriptor pd{.colorAttachmentCount=1,.colorAttachments=&clear,.depthStencilAttachment=&sceneDepth};
|
||||
auto pass=encoder.BeginRenderPass(&pd);
|
||||
if(coverage==2) {
|
||||
wgpu::ShaderSourceWGSL code{};
|
||||
code.code=R"(
|
||||
@vertex fn vs(@builtin(vertex_index) i:u32) -> @builtin(position) vec4f {
|
||||
let p=array<vec2f,6>(vec2f(0,-1),vec2f(1,-1),vec2f(0,1),vec2f(0,1),vec2f(1,-1),vec2f(1,1));
|
||||
return vec4f(p[i],0.5,1);
|
||||
}
|
||||
@fragment fn fs() -> @location(0) vec4f { return vec4f(0.06,0.09,0.13,1); }
|
||||
)";
|
||||
wgpu::ShaderModuleDescriptor md{};md.nextInChain=&code;
|
||||
auto shader=g_device.CreateShaderModule(&md);
|
||||
const wgpu::ColorTargetState colorState{.format=wgpu::TextureFormat::RGBA8Unorm};
|
||||
const wgpu::FragmentState fragment{.module=shader,.entryPoint="fs",.targetCount=1,.targets=&colorState};
|
||||
const wgpu::DepthStencilState ds{.format=eye.target.depthFormat,.depthWriteEnabled=true,.depthCompare=wgpu::CompareFunction::Always};
|
||||
wgpu::RenderPipelineDescriptor desc{};desc.vertex={.module=shader,.entryPoint="vs"};
|
||||
desc.fragment=&fragment;desc.depthStencil=&ds;desc.multisample.count=samples;
|
||||
auto wall=g_device.CreateRenderPipeline(&desc);pass.SetPipeline(wall);pass.Draw(6);
|
||||
}
|
||||
if(coverage==2) gfx::cockpit::render(encoder,frame,eyeIndex,reversed?gfx::cockpit::SceneDepth{0,2,true}:gfx::cockpit::SceneDepth{-1,-2,true},&pass);
|
||||
pass.End();
|
||||
if(coverage!=2) gfx::cockpit::render(encoder,frame,eyeIndex,reversed?gfx::cockpit::SceneDepth{0,2,true}:gfx::cockpit::SceneDepth{-1,-2,true});
|
||||
if(hud) {
|
||||
// An opaque black screen and coloured HUD with depth testing disabled used
|
||||
// to overwrite the hands. Exercise a later pass too: the mask must survive.
|
||||
const wgpu::RenderPassColorAttachment load{.view=eye.target.colorView,.resolveTarget=eye.target.resolveView,
|
||||
.loadOp=wgpu::LoadOp::Load,.storeOp=wgpu::StoreOp::Store};
|
||||
const wgpu::RenderPassDepthStencilAttachment loadDepth{.view=eye.target.depthView,
|
||||
.depthLoadOp=wgpu::LoadOp::Load,.depthStoreOp=wgpu::StoreOp::Store,
|
||||
.stencilLoadOp=wgpu::LoadOp::Load,.stencilStoreOp=wgpu::StoreOp::Store};
|
||||
const wgpu::RenderPassDescriptor hudPass{.colorAttachmentCount=1,.colorAttachments=&load,.depthStencilAttachment=&loadDepth};
|
||||
auto overlay=encoder.BeginRenderPass(&hudPass);
|
||||
wgpu::ShaderSourceWGSL code{};
|
||||
code.code=R"(
|
||||
@vertex fn vs(@builtin(vertex_index) i:u32) -> @builtin(position) vec4f {
|
||||
let p=array<vec2f,3>(vec2f(-1,-1),vec2f(3,-1),vec2f(-1,3));
|
||||
return vec4f(p[i],0.5,1);
|
||||
}
|
||||
@fragment fn fs(@builtin(position) p:vec4f) -> @location(0) vec4f {
|
||||
return select(vec4f(0,0,0,1),vec4f(1,0,0,1),p.x<256);
|
||||
}
|
||||
)";
|
||||
wgpu::ShaderModuleDescriptor md{};md.nextInChain=&code;auto shader=g_device.CreateShaderModule(&md);
|
||||
const wgpu::ColorTargetState colorState{.format=wgpu::TextureFormat::RGBA8Unorm};
|
||||
const wgpu::FragmentState fragment{.module=shader,.entryPoint="fs",.targetCount=1,.targets=&colorState};
|
||||
const wgpu::StencilFaceState mask{.compare=wgpu::CompareFunction::Equal};
|
||||
const wgpu::DepthStencilState ds{.format=eye.target.depthFormat,.depthWriteEnabled=true,
|
||||
.depthCompare=wgpu::CompareFunction::Always,.stencilFront=mask,.stencilBack=mask,.stencilReadMask=1,.stencilWriteMask=0};
|
||||
wgpu::RenderPipelineDescriptor desc{};desc.vertex={.module=shader,.entryPoint="vs"};
|
||||
desc.fragment=&fragment;desc.depthStencil=&ds;desc.multisample.count=samples;
|
||||
auto screen=g_device.CreateRenderPipeline(&desc);overlay.SetPipeline(screen);overlay.Draw(3);overlay.End();
|
||||
}
|
||||
const wgpu::BufferDescriptor bd{.usage=wgpu::BufferUsage::CopyDst|wgpu::BufferUsage::MapRead,.size=512*512*4};
|
||||
auto readback=g_device.CreateBuffer(&bd);
|
||||
const wgpu::TexelCopyTextureInfo src{.texture=output};
|
||||
const wgpu::TexelCopyBufferInfo dst{.layout={.bytesPerRow=2048,.rowsPerImage=512},.buffer=readback};
|
||||
const wgpu::Extent3D extent{512,512,1};encoder.CopyTextureToBuffer(&src,&dst,&extent);
|
||||
auto commands=encoder.Finish();g_device.GetQueue().Submit(1,&commands);
|
||||
bool mapped=false;
|
||||
future=readback.MapAsync(wgpu::MapMode::Read,0,512*512*4,wgpu::CallbackMode::WaitAnyOnly,
|
||||
[&](wgpu::MapAsyncStatus status,wgpu::StringView) { mapped=status==wgpu::MapAsyncStatus::Success; });
|
||||
if(instance.WaitAny(future,5000000000)!=wgpu::WaitStatus::Success||!mapped) return 1;
|
||||
const auto* bytes=static_cast<const unsigned char*>(readback.GetConstMappedRange());
|
||||
size_t bright=0;
|
||||
for(size_t i=0;i<512*512;++i) if(bytes[4*i]>90&&bytes[4*i+1]>90&&bytes[4*i+2]>90) ++bright;
|
||||
if(hud && !(bytes[0]>250 && bytes[1]==0 && bytes[2]==0)) {
|
||||
std::cerr<<"HUD missing outside cockpit mask\n";++errors;
|
||||
}
|
||||
static size_t expectedBright[3][2][2]{};
|
||||
auto& expected=expectedBright[coverage][reversed][eyeIndex];
|
||||
if(!hud) expected=bright;
|
||||
else if(bright+64<expected || bright>expected+64) { std::cerr<<"HUD changed visible cockpit pixels\n";++errors; }
|
||||
if(occluded ? bright!=0 : bright<1000) { std::cerr<<"Incorrect hands/wheel occlusion\n";++errors; }
|
||||
if(coverage==2) {
|
||||
size_t left=0,right=0;
|
||||
for(size_t y=0;y<512;++y) for(size_t x=0;x<512;++x) {
|
||||
const auto i=y*512+x;
|
||||
if(bytes[4*i]>90&&bytes[4*i+1]>90&&bytes[4*i+2]>90) (x<256?left:right)++;
|
||||
}
|
||||
if(left<500||right>8) { std::cerr<<"Partial wall occlusion failed for eye "<<eyeIndex<<'\n';++errors; }
|
||||
}
|
||||
if(samples==4 && !original && !occluded) {
|
||||
std::ofstream image("cockpit-preview.ppm",std::ios::binary);image<<"P6\n512 512\n255\n";
|
||||
for(size_t i=0;i<512*512;++i) image.write(reinterpret_cast<const char*>(bytes+i*4),3);
|
||||
}
|
||||
readback.Unmap();
|
||||
std::cout<<(bike?"Bike ":"Kart ")<<(original?"native hands: ":"VR controls: ")<<samples<<"x MSAA: "<<bright<<" visible geometry pixels\n";
|
||||
}
|
||||
gfx::cockpit::shutdown();g_queue=nullptr;g_device.Destroy();g_device=nullptr;
|
||||
return errors?1:0;
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Native steering wheel: indexed-matrix ownership and array matching. The
|
||||
// ownership cases come from heurazy's mario-kart-wii-VR-port test set.
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
#include "gx_test_common.hpp"
|
||||
#include "gx/native_wheel.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Three positions, 12 bytes each. Position 1 is on the wheel; 0 and 2 belong to
|
||||
// the body/wing.
|
||||
struct OwnershipFixture {
|
||||
std::array<uint8_t, 36> original{};
|
||||
std::array<uint8_t, 36> replacement{};
|
||||
// PN matrix byte, texture matrix byte, big-endian position index.
|
||||
std::array<uint8_t, 12> vertices{0, 30, 0, 0, 0, 30, 0, 1, 3, 30, 0, 2};
|
||||
OwnershipFixture() { replacement[12] = 7; }
|
||||
bool matches(uint16_t mask) const {
|
||||
return aurora::NativeWheelDrawMatches(original, replacement, 12, vertices, 4, 2, 2, mask);
|
||||
}
|
||||
};
|
||||
|
||||
TEST(NativeWheelMatch, MixedBodyAndWingDrawAcceptsWheelPositionsOnLocalBody) {
|
||||
OwnershipFixture f;
|
||||
EXPECT_TRUE(f.matches(1));
|
||||
}
|
||||
|
||||
TEST(NativeWheelMatch, UnrelatedWingJointCannotAnimateWheel) {
|
||||
OwnershipFixture f;
|
||||
EXPECT_FALSE(f.matches(2));
|
||||
}
|
||||
|
||||
TEST(NativeWheelMatch, OpponentWithoutLocalMatrixIsRejected) {
|
||||
OwnershipFixture f;
|
||||
EXPECT_FALSE(f.matches(0));
|
||||
}
|
||||
|
||||
TEST(NativeWheelMatch, WheelPositionUnderAnotherMatrixIsRejected) {
|
||||
OwnershipFixture f;
|
||||
f.vertices[4] = 6;
|
||||
EXPECT_FALSE(f.matches(1));
|
||||
EXPECT_TRUE(f.matches(4)) << "local body can occupy a different palette slot";
|
||||
}
|
||||
|
||||
TEST(NativeWheelMatch, MalformedMatrixSelectorRejected) {
|
||||
OwnershipFixture f;
|
||||
f.vertices[4] = 1;
|
||||
EXPECT_FALSE(f.matches(1));
|
||||
}
|
||||
|
||||
TEST(NativeWheelMatch, OutOfRangePositionIndexRejected) {
|
||||
OwnershipFixture f;
|
||||
f.vertices[11] = 3;
|
||||
EXPECT_FALSE(f.matches(1));
|
||||
}
|
||||
|
||||
TEST(NativeWheelMatch, SelectionTouchingAnotherJointCannotDeformIt) {
|
||||
OwnershipFixture f;
|
||||
f.replacement[24] = 1;
|
||||
EXPECT_FALSE(f.matches(1));
|
||||
}
|
||||
|
||||
TEST(NativeWheelMatch, EightBitIndicesAndLayoutValidation) {
|
||||
OwnershipFixture f;
|
||||
std::array<uint8_t, 4> indexed8{0, 1, 3, 2};
|
||||
EXPECT_TRUE(aurora::NativeWheelDrawMatches(f.original, f.replacement, 12, indexed8, 2, 1, 1, 1));
|
||||
EXPECT_FALSE(aurora::NativeWheelDrawMatches(f.original, f.replacement, 12, indexed8, 2, 2, 1, 1))
|
||||
<< "invalid vertex layout rejected";
|
||||
EXPECT_FALSE(aurora::NativeWheelDrawMatches(f.original, f.original, 12, indexed8, 2, 1, 1, 1))
|
||||
<< "unchanged positions are not counted as animation";
|
||||
}
|
||||
|
||||
// Draw-time matching against the GX position-matrix palette.
|
||||
class NativeWheelArrayTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
aurora::gx::g_gxState = {};
|
||||
aurora::gx::nativeWheelArrays.clear();
|
||||
aurora::gx::nativeWheelMatches = 0;
|
||||
aurora::gx::NativeWheelArray replacement;
|
||||
replacement.source = source.data();
|
||||
replacement.bytes.assign(36, 0);
|
||||
replacement.bytes[12] = 1;
|
||||
aurora::gx::nativeWheelArrays.push_back(replacement);
|
||||
array.data = source.data();
|
||||
array.size = 36;
|
||||
array.stride = 12;
|
||||
}
|
||||
void TearDown() override {
|
||||
aurora::gx::g_gxState = {};
|
||||
aurora::gx::nativeWheelArrays.clear();
|
||||
}
|
||||
static float* pos(uint32_t slot) { return reinterpret_cast<float*>(&aurora::gx::g_gxState.pnMtx[slot].pos); }
|
||||
std::array<uint8_t, 36> source{};
|
||||
aurora::gx::AttrArray array{};
|
||||
};
|
||||
|
||||
TEST_F(NativeWheelArrayTest, MatchesLocalVehicleMatrixOnly) {
|
||||
EXPECT_NE(aurora::gx::native_wheel_array(array, nullptr, 0, 0, 0), nullptr);
|
||||
EXPECT_EQ(aurora::gx::nativeWheelMatches, 1u);
|
||||
// Same asset drawn by an opponent: a translated matrix.
|
||||
pos(0)[3] = 100;
|
||||
EXPECT_EQ(aurora::gx::native_wheel_array(array, nullptr, 0, 0, 0), nullptr);
|
||||
EXPECT_EQ(aurora::gx::nativeWheelMatches, 1u);
|
||||
}
|
||||
|
||||
TEST_F(NativeWheelArrayTest, UnboundSourceIsNotAWheelArray) {
|
||||
std::array<uint8_t, 36> other{};
|
||||
aurora::gx::AttrArray unrelated = array;
|
||||
unrelated.data = other.data();
|
||||
EXPECT_EQ(aurora::gx::native_wheel_array(unrelated, nullptr, 0, 0, 0), nullptr);
|
||||
EXPECT_TRUE(aurora::gx::native_wheel_source(source.data()));
|
||||
EXPECT_FALSE(aurora::gx::native_wheel_source(other.data()));
|
||||
}
|
||||
|
||||
TEST_F(NativeWheelArrayTest, MultiJointBodyChecksPerPositionOwnership) {
|
||||
// Multi-joint body with an independently animated wing, as in Mario's
|
||||
// mb/mc kart models. Only the wheel position uses the matching body slot.
|
||||
aurora::gx::g_gxState.vtxDesc[GX_VA_PNMTXIDX] = GX_DIRECT;
|
||||
aurora::gx::g_gxState.vtxDesc[GX_VA_POS] = GX_INDEX16;
|
||||
for (uint32_t slot = 0; slot < aurora::gx::MaxPnMtx; ++slot) pos(slot)[3] = 100;
|
||||
pos(2)[3] = 0;
|
||||
uint8_t vertices[]{6, 0, 1, 3, 0, 2};
|
||||
EXPECT_NE(aurora::gx::native_wheel_array(array, vertices, sizeof(vertices), 3, 1), nullptr);
|
||||
vertices[0] = 0; // opponent wheel while an unrelated palette slot still matches
|
||||
EXPECT_EQ(aurora::gx::native_wheel_array(array, vertices, sizeof(vertices), 3, 1), nullptr);
|
||||
}
|
||||
|
||||
TEST_F(NativeWheelArrayTest, NonFiniteMatrixNeverMatches) {
|
||||
pos(0)[0] = __builtin_nanf("");
|
||||
EXPECT_EQ(aurora::gx::native_wheel_array(array, nullptr, 0, 0, 0), nullptr);
|
||||
}
|
||||
|
||||
// Draw merging around an animated array. A merged draw appends its vertices to the previous draw's range and renders
|
||||
// through that draw's array binding, so two draws may merge only when they resolved the same replacement. The kart's
|
||||
// display list is hundreds of same-state primitives, and merging them is worth several ms an eye on a tiler.
|
||||
class NativeWheelMergeTest : public GXFifoTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
GXFifoTest::SetUp();
|
||||
aurora::gfx::testing::use_draw_command_tracking(true);
|
||||
aurora::gx::nativeWheelArrays.clear();
|
||||
aurora::gx::nativeWheelLastDecision = nullptr;
|
||||
aurora::gx::nativeWheelLastDrawCommand = nullptr;
|
||||
aurora::gx::NativeWheelArray replacement;
|
||||
replacement.source = source.data();
|
||||
replacement.bytes.assign(source.size(), 0);
|
||||
replacement.bytes[12] = 1; // one animated position
|
||||
aurora::gx::nativeWheelArrays.push_back(replacement);
|
||||
auto& state = aurora::gx::g_gxState;
|
||||
state.lastVtxFmt = GX_VTXFMT0;
|
||||
state.lastVtxSize = 1;
|
||||
state.vtxDesc[GX_VA_POS] = GX_INDEX8;
|
||||
state.arrays[GX_VA_POS].data = source.data();
|
||||
state.arrays[GX_VA_POS].size = static_cast<u32>(source.size());
|
||||
state.arrays[GX_VA_POS].stride = 12;
|
||||
state.stateDirty = true;
|
||||
}
|
||||
void TearDown() override {
|
||||
aurora::gx::nativeWheelArrays.clear();
|
||||
aurora::gx::nativeWheelLastDecision = nullptr;
|
||||
aurora::gx::nativeWheelLastDrawCommand = nullptr;
|
||||
}
|
||||
void draw() {
|
||||
std::vector<u8> fifo{static_cast<u8>(GX_TRIANGLES) | static_cast<u8>(GX_VTXFMT0), 0, 3, 0, 1, 2};
|
||||
decode_fifo(fifo);
|
||||
}
|
||||
// The local vehicle's matrix: the replacement's model-view is all zeroes, and so is a default palette slot.
|
||||
void makeOpponent() { reinterpret_cast<float*>(&aurora::gx::g_gxState.pnMtx[0].pos)[3] = 100.f; }
|
||||
std::array<uint8_t, 36> source{};
|
||||
};
|
||||
|
||||
TEST_F(NativeWheelMergeTest, PrimitivesSharingTheAnimatedArrayStillMerge) {
|
||||
draw();
|
||||
ASSERT_EQ(aurora::gx::nativeWheelLastDecision, &aurora::gx::nativeWheelArrays.front());
|
||||
draw();
|
||||
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 1u);
|
||||
}
|
||||
|
||||
TEST_F(NativeWheelMergeTest, PrimitivesThatTakeTheOriginalArrayAlsoStillMerge) {
|
||||
makeOpponent();
|
||||
draw();
|
||||
ASSERT_EQ(aurora::gx::nativeWheelLastDecision, nullptr);
|
||||
draw();
|
||||
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 1u);
|
||||
}
|
||||
|
||||
TEST_F(NativeWheelMergeTest, OpponentPrimitiveNeverFoldsIntoAnAnimatedDraw) {
|
||||
draw();
|
||||
makeOpponent();
|
||||
draw();
|
||||
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u) << "the merged whole would render the opponent animated";
|
||||
}
|
||||
|
||||
TEST_F(NativeWheelMergeTest, AnimatedPrimitiveNeverFoldsIntoAnOpponentDraw) {
|
||||
makeOpponent();
|
||||
draw();
|
||||
reinterpret_cast<float*>(&aurora::gx::g_gxState.pnMtx[0].pos)[3] = 0.f;
|
||||
draw();
|
||||
EXPECT_EQ(aurora::gfx::g_mergedDrawCallCount, 0u) << "the wheel would render on the original vertices";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -0,0 +1,162 @@
|
||||
// Opt-in real GPU test for the custom Dawn DLL. No headset required.
|
||||
// Exercises the MinGW/MSVC C ABI, borrowed-image lifetime and repeated layout
|
||||
// transitions. Run with VK_INSTANCE_LAYERS=VK_LAYER_KHRONOS_validation as well.
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
#include <vulkan/vulkan.h>
|
||||
#include <dawn/webgpu_cpp.h>
|
||||
#include <aurora/dawn_vulkan_abi.h>
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
static void Check(bool ok) { if (!ok) { std::fputs("Native Vulkan bridge smoke failed\n", stderr); std::abort(); } }
|
||||
static PFN_vkGetInstanceProcAddr hookProc;
|
||||
static VkInstance hookInstance;
|
||||
static int instances = 0, devices = 0, selections = 0;
|
||||
static int32_t CreateInstance(void*, void* proc, const void* info, const void* allocator, void** out) {
|
||||
++instances; hookProc = reinterpret_cast<PFN_vkGetInstanceProcAddr>(proc);
|
||||
auto create = reinterpret_cast<PFN_vkCreateInstance>(hookProc(nullptr, "vkCreateInstance"));
|
||||
auto result = create(static_cast<const VkInstanceCreateInfo*>(info), static_cast<const VkAllocationCallbacks*>(allocator), &hookInstance);
|
||||
*out = hookInstance; return result;
|
||||
}
|
||||
static int32_t CreateDevice(void*, void*, void* physical, const void* info, const void* allocator, void** out) {
|
||||
++devices;
|
||||
auto create = reinterpret_cast<PFN_vkCreateDevice>(hookProc(hookInstance, "vkCreateDevice"));
|
||||
return create(static_cast<VkPhysicalDevice>(physical), static_cast<const VkDeviceCreateInfo*>(info),
|
||||
static_cast<const VkAllocationCallbacks*>(allocator), reinterpret_cast<VkDevice*>(out));
|
||||
}
|
||||
static int32_t SelectPhysical(void*, void* instance, void** out) {
|
||||
++selections;
|
||||
auto enumerate = reinterpret_cast<PFN_vkEnumeratePhysicalDevices>(hookProc(static_cast<VkInstance>(instance), "vkEnumeratePhysicalDevices"));
|
||||
uint32_t count = 1;
|
||||
auto result = enumerate(static_cast<VkInstance>(instance), &count, reinterpret_cast<VkPhysicalDevice*>(out));
|
||||
return result == VK_INCOMPLETE ? VK_SUCCESS : result;
|
||||
}
|
||||
int main() {
|
||||
auto dll = LoadLibraryW(L"webgpu_dawn.dll"); Check(dll != nullptr);
|
||||
#define API(name, type) auto name = reinterpret_cast<type>(GetProcAddress(dll, "AuroraDawnVulkan" #name)); Check(name != nullptr)
|
||||
API(Version, AuroraDawnVulkanVersionFn);
|
||||
API(Configure, AuroraDawnVulkanConfigureFn);
|
||||
API(GetHandles, AuroraDawnVulkanHandlesFn);
|
||||
API(Wrap, AuroraDawnVulkanWrapFn);
|
||||
API(Release, AuroraDawnVulkanReleaseFn);
|
||||
API(Lock, AuroraDawnVulkanLockFn);
|
||||
API(Unlock, AuroraDawnVulkanUnlockFn);
|
||||
API(Drain, AuroraDawnVulkanDrainFn);
|
||||
Check(Version() == AURORA_DAWN_VULKAN_ABI);
|
||||
AuroraDawnVulkanHooks hooks{nullptr, CreateInstance, CreateDevice, SelectPhysical};
|
||||
Check(Configure(&hooks));
|
||||
wgpu::InstanceFeatureName feature = wgpu::InstanceFeatureName::TimedWaitAny;
|
||||
wgpu::InstanceDescriptor instanceDesc;
|
||||
instanceDesc.requiredFeatureCount = 1; instanceDesc.requiredFeatures = &feature;
|
||||
std::fputs("Creating WebGPU instance\n", stderr);
|
||||
auto instance = wgpu::CreateInstance(&instanceDesc);
|
||||
wgpu::RequestAdapterOptions options; options.backendType = wgpu::BackendType::Vulkan;
|
||||
wgpu::Adapter adapter;
|
||||
std::fputs("Requesting Vulkan adapter\n", stderr);
|
||||
auto future = instance.RequestAdapter(&options, wgpu::CallbackMode::WaitAnyOnly,
|
||||
[&](wgpu::RequestAdapterStatus status, wgpu::Adapter value, wgpu::StringView) {
|
||||
Check(status == wgpu::RequestAdapterStatus::Success); adapter = std::move(value);
|
||||
});
|
||||
Check(instance.WaitAny(future, 10'000'000'000) == wgpu::WaitStatus::Success);
|
||||
wgpu::FeatureName sync = wgpu::FeatureName::ImplicitDeviceSynchronization;
|
||||
wgpu::DeviceDescriptor deviceDesc;
|
||||
deviceDesc.requiredFeatureCount = 1; deviceDesc.requiredFeatures = &sync;
|
||||
deviceDesc.SetUncapturedErrorCallback([](const wgpu::Device&, wgpu::ErrorType, wgpu::StringView message) {
|
||||
std::fprintf(stderr, "Dawn: %.*s\n", static_cast<int>(message.length), message.data); Check(false);
|
||||
});
|
||||
std::fputs("Creating Vulkan device\n", stderr);
|
||||
auto device = adapter.CreateDevice(&deviceDesc); Check(!!device);
|
||||
Check(instances > 0 && devices > 0 && selections > 0);
|
||||
std::fputs("Getting native device\n", stderr);
|
||||
AuroraDawnVulkanHandles handles{}; Check(GetHandles(device.Get(), &handles));
|
||||
VkDevice vkDevice = static_cast<VkDevice>(handles.device);
|
||||
VkPhysicalDevice physical = static_cast<VkPhysicalDevice>(handles.physicalDevice);
|
||||
auto loader = LoadLibraryW(L"vulkan-1.dll"); Check(loader != nullptr);
|
||||
auto getProc = reinterpret_cast<PFN_vkGetInstanceProcAddr>(GetProcAddress(loader, "vkGetInstanceProcAddr"));
|
||||
#define VK(name) auto name = reinterpret_cast<PFN_##name>(getProc(static_cast<VkInstance>(handles.instance), #name)); Check(name != nullptr)
|
||||
VK(vkCreateImage); VK(vkGetImageMemoryRequirements); VK(vkGetPhysicalDeviceMemoryProperties);
|
||||
VK(vkAllocateMemory); VK(vkBindImageMemory); VK(vkCreateCommandPool); VK(vkAllocateCommandBuffers);
|
||||
VK(vkBeginCommandBuffer); VK(vkCmdPipelineBarrier); VK(vkEndCommandBuffer); VK(vkGetDeviceQueue);
|
||||
VK(vkQueueSubmit); VK(vkQueueWaitIdle); VK(vkDestroyCommandPool); VK(vkDestroyImage); VK(vkFreeMemory);
|
||||
VkImageCreateInfo imageInfo{VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
|
||||
imageInfo.imageType = VK_IMAGE_TYPE_2D; imageInfo.format = VK_FORMAT_R8G8B8A8_UNORM;
|
||||
imageInfo.extent = {64, 16, 1}; imageInfo.mipLevels = 1; imageInfo.arrayLayers = 1;
|
||||
imageInfo.samples = VK_SAMPLE_COUNT_1_BIT; imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
|
||||
imageInfo.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT;
|
||||
std::fputs("Creating borrowed Vulkan image\n", stderr);
|
||||
VkImage image; Check(vkCreateImage(vkDevice, &imageInfo, nullptr, &image) == VK_SUCCESS);
|
||||
VkMemoryRequirements requirements; vkGetImageMemoryRequirements(vkDevice, image, &requirements);
|
||||
VkPhysicalDeviceMemoryProperties properties; vkGetPhysicalDeviceMemoryProperties(physical, &properties);
|
||||
uint32_t memoryType = 0;
|
||||
while (!(requirements.memoryTypeBits & (1u << memoryType))) ++memoryType;
|
||||
VkMemoryAllocateInfo allocation{VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
|
||||
allocation.allocationSize = requirements.size; allocation.memoryTypeIndex = memoryType;
|
||||
VkDeviceMemory memory; Check(vkAllocateMemory(vkDevice, &allocation, nullptr, &memory) == VK_SUCCESS);
|
||||
Check(vkBindImageMemory(vkDevice, image, memory, 0) == VK_SUCCESS);
|
||||
VkCommandPoolCreateInfo poolInfo{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; poolInfo.queueFamilyIndex = handles.queueFamily;
|
||||
VkCommandPool pool; Check(vkCreateCommandPool(vkDevice, &poolInfo, nullptr, &pool) == VK_SUCCESS);
|
||||
VkCommandBufferAllocateInfo alloc{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
|
||||
alloc.commandPool = pool; alloc.commandBufferCount = 1; alloc.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
|
||||
VkCommandBuffer commands; Check(vkAllocateCommandBuffers(vkDevice, &alloc, &commands) == VK_SUCCESS);
|
||||
VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
|
||||
Check(vkBeginCommandBuffer(commands, &begin) == VK_SUCCESS);
|
||||
VkImageMemoryBarrier barrier{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
|
||||
barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
|
||||
barrier.srcQueueFamilyIndex = barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
barrier.image = image; barrier.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
|
||||
barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
|
||||
vkCmdPipelineBarrier(commands, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||||
0, 0, nullptr, 0, nullptr, 1, &barrier);
|
||||
Check(vkEndCommandBuffer(commands) == VK_SUCCESS);
|
||||
VkQueue queue; vkGetDeviceQueue(vkDevice, handles.queueFamily, handles.queueIndex, &queue);
|
||||
VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO}; submit.commandBufferCount = 1; submit.pCommandBuffers = &commands;
|
||||
std::fputs("Locking native queue\n", stderr);
|
||||
auto guard = Lock(device.Get());
|
||||
Check(vkQueueSubmit(queue, 1, &submit, VK_NULL_HANDLE) == VK_SUCCESS);
|
||||
Check(vkQueueWaitIdle(queue) == VK_SUCCESS); Unlock(guard);
|
||||
wgpu::TextureDescriptor textureDesc;
|
||||
textureDesc.size = {64, 16, 1}; textureDesc.format = wgpu::TextureFormat::RGBA8Unorm;
|
||||
textureDesc.usage = wgpu::TextureUsage::CopyDst | wgpu::TextureUsage::CopySrc | wgpu::TextureUsage::RenderAttachment;
|
||||
std::fputs("Wrapping borrowed image\n", stderr);
|
||||
auto borrowed = wgpu::Texture::Acquire(static_cast<WGPUTexture>(Wrap(device.Get(), &textureDesc, reinterpret_cast<uint64_t>(image))));
|
||||
Check(!!borrowed);
|
||||
std::fputs("Creating copy resources\n", stderr);
|
||||
auto source = device.CreateTexture(&textureDesc);
|
||||
wgpu::BufferDescriptor bufferDesc; bufferDesc.size = 4096;
|
||||
bufferDesc.usage = wgpu::BufferUsage::CopyDst | wgpu::BufferUsage::MapRead;
|
||||
auto readback = device.CreateBuffer(&bufferDesc);
|
||||
std::fputs("Running GPU copy\n", stderr);
|
||||
for (uint8_t value : {17, 99, 201}) {
|
||||
std::array<uint8_t, 4096> pixels; pixels.fill(value);
|
||||
wgpu::TexelCopyTextureInfo sourceInfo; sourceInfo.texture = source;
|
||||
wgpu::TexelCopyTextureInfo targetInfo; targetInfo.texture = borrowed;
|
||||
wgpu::TexelCopyBufferLayout layout; layout.bytesPerRow = 256; layout.rowsPerImage = 16;
|
||||
wgpu::Extent3D extent{64, 16, 1};
|
||||
device.GetQueue().WriteTexture(&sourceInfo, pixels.data(), pixels.size(), &layout, &extent);
|
||||
std::fputs("WriteTexture done\n", stderr);
|
||||
auto encoder = device.CreateCommandEncoder(); encoder.CopyTextureToTexture(&sourceInfo, &targetInfo, &extent);
|
||||
auto copy = encoder.Finish(); device.GetQueue().Submit(1, ©);
|
||||
std::fputs("Copy submitted\n", stderr);
|
||||
void* textures[] = {borrowed.Get()}; Check(Release(device.Get(), textures, 1));
|
||||
std::fputs("Release transition done\n", stderr);
|
||||
encoder = device.CreateCommandEncoder();
|
||||
wgpu::TexelCopyBufferInfo destination; destination.buffer = readback; destination.layout = layout;
|
||||
encoder.CopyTextureToBuffer(&targetInfo, &destination, &extent);
|
||||
copy = encoder.Finish(); device.GetQueue().Submit(1, ©);
|
||||
Check(Release(device.Get(), textures, 1));
|
||||
auto mapped = readback.MapAsync(wgpu::MapMode::Read, 0, 4096, wgpu::CallbackMode::WaitAnyOnly,
|
||||
[](wgpu::MapAsyncStatus status, wgpu::StringView) { Check(status == wgpu::MapAsyncStatus::Success); });
|
||||
Check(instance.WaitAny(mapped, 10'000'000'000) == wgpu::WaitStatus::Success);
|
||||
const auto* bytes = static_cast<const uint8_t*>(readback.GetConstMappedRange());
|
||||
for (size_t i = 0; i < pixels.size(); ++i) Check(bytes[i] == value);
|
||||
readback.Unmap();
|
||||
}
|
||||
Check(Drain(device.Get())); borrowed = nullptr;
|
||||
// The wrapper must not free the runtime-owned image or its memory.
|
||||
vkDestroyImage(vkDevice, image, nullptr); vkFreeMemory(vkDevice, memory, nullptr);
|
||||
vkDestroyCommandPool(vkDevice, pool, nullptr);
|
||||
Check(Configure(nullptr));
|
||||
std::puts("Native Vulkan bridge: three GPU copy/readback cycles passed");
|
||||
}
|
||||
+141
-4
@@ -183,6 +183,12 @@ suggested for `oculus/touch_controller` and `khr/simple_controller`.
|
||||
provider is registered on Android, Aurora skips the surface present and the
|
||||
desktop mirror copy (`headset_owns_display` in `lib/aurora.cpp`). The game's
|
||||
own render size is unaffected: at `resolution_multiplier = 1` it is 640x528.
|
||||
Since 2026-09-19 an immersive race also stops that native render after the
|
||||
last pass whose EFB copy the eye replays sample (`last_pass_feeding_replay`):
|
||||
the main scene and display copy of a 1280x720 image nobody sees were 4 to
|
||||
6 ms of a 12 ms GPU frame on a Quest 3. A pending CPU readback of an EFB
|
||||
copy or a frame capture still renders the whole image, and menus (the
|
||||
virtual screen) keep it because their eyes are built from that snapshot.
|
||||
- **JNI only on the real thread stack.** Guest threads run on libco stacks
|
||||
inside the SDL thread, and SDL's Android event pump can reach Java (joystick
|
||||
polling, HIDAPI). ART binds JNI transitions to the thread's real stack, so
|
||||
@@ -193,6 +199,23 @@ suggested for `oculus/touch_controller` and `khr/simple_controller`.
|
||||
finding, from device crashes.
|
||||
- Time conversion for frame interpolation uses `XR_KHR_convert_timespec_time`
|
||||
(CLOCK_MONOTONIC, the clock behind `steady_clock` on Bionic).
|
||||
- **Passthrough around the menus** (`[vr] passthrough`, default on, live):
|
||||
`runtime/src/vr/openxr_passthrough.cpp` owns one `XR_FB_passthrough`
|
||||
reconstruction layer, the PPSSPP VR design. The Vulkan backend starts it
|
||||
(created on first use) or pauses it as each presentation arrives, and submits
|
||||
it first, under the virtual screen's quad, or alone while there is no image
|
||||
yet (startup, a recenter). An immersive race never submits it and pauses the
|
||||
cameras; a `flat_screen` race is a virtual screen, so it keeps the room.
|
||||
The quad is cropped to the snapshot Aurora letterboxes into the
|
||||
nearly square eye image (`OpenXRVirtualScreenContentRect`), or its black
|
||||
bands would frame the picture against the room. The manifest's `com.oculus.feature.PASSTHROUGH` is what lets Horizon
|
||||
OS composite it; DolphinXR found that without it every call succeeds and the
|
||||
layer stays empty. The log says `OpenXR passthrough started`, `paused` and
|
||||
`resumed`; when it runs, logcat also shows `Starting camera streams for
|
||||
purpose: passthrough` and `is_displaying_passthrough_content` going to true.
|
||||
ClientMgrFocus logs `[App Enabled for PT: 0]` at every launch, flag or not
|
||||
(it is about the launch transition), so it proves nothing. Checked on a
|
||||
Quest 3 on 2026-09-22: the room shows around the title screen.
|
||||
|
||||
### Launcher and game process
|
||||
|
||||
@@ -200,13 +223,30 @@ The app opens on `LauncherActivity` (`android/app/src/main/java/org/wiicompiled/
|
||||
a 2D Horizon OS panel modelled on the PC launcher, WheelWizard VR, and using its
|
||||
palette. **Home** has the Play button and reports a missing or incomplete `DATA`
|
||||
(the check is the runtime's own `IsDvdDataRoot`: `files/` and `sys/fst.bin`).
|
||||
**Settings** edits `Config.toml` in tabs: VR (camera, rotation, driver hiding,
|
||||
lean back, render scale, VR interpolation, virtual screen size and distance),
|
||||
**Settings** edits `Config.toml` in tabs: VR (Flat Screen mode, camera, rotation, driver hiding,
|
||||
seat, hand steering, lean back, render scale, VR interpolation, virtual screen
|
||||
size and distance),
|
||||
Graphics (resolution, widescreen, bloom, shader stutter), Controls (controller
|
||||
mode, vibration, the Wii Remote mapping), Audio, and About (paths, OpenXR
|
||||
logging). The launch-time geometry (`render_scale`, `hud_distance_meters`,
|
||||
`hud_width_meters`) is only reachable here, not from the in-headset panel.
|
||||
|
||||
**Patches**, between the two, is the PC launcher's mods page without its mod
|
||||
browser (`PatchesPage`, `ModLibrary`). Import takes one or more picked files,
|
||||
asks for a name and makes them one mod under `WiiCompiledOpenXRVR/Mods/<name>/`
|
||||
with the PC's `<name>.ini` (Name, Author, ModID, IsEnabled, Priority), so a
|
||||
`Mods` folder copied from WheelWizard reads the same. Unlike the PC's Import, a
|
||||
picked `.zip` is unpacked, since there is no browser to install downloaded mods;
|
||||
`.7z` and `.rar` are refused. Each mod can be switched off, moved up or down,
|
||||
renamed or deleted. As on the PC, mods only change Retro Rewind: its Play first
|
||||
flattens the enabled mods into `RetroRewind6/Patches` exactly like
|
||||
`ModsLaunchService.PrepareModsForLaunch` (the top of the list wins a file both
|
||||
carry, `<name>.<tag>.szs` archives take their mod's priority as a prefix, and
|
||||
loose files no mod provides are removed). With no mod enabled, a Patches folder
|
||||
that still holds files is only cleared if the player says so. The pack's
|
||||
Riivolution XML maps that folder onto `/patches` and `/sound`. `ModLibraryTest`
|
||||
covers the rules.
|
||||
|
||||
The launcher follows the runtime's rules exactly. `TomlConfig` edits one line
|
||||
the way `RuntimeConfigFile::WriteSetting` does, and every edit re-reads the file,
|
||||
so values the in-headset panel wrote are kept. Each row reads its key with the
|
||||
@@ -331,6 +371,17 @@ Home's main button becomes **Download Retro Rewind** whenever that game is selec
|
||||
missing, and Settings → About shows the installed version with an Update button. Building the mod on
|
||||
the headset needs the mod's `Code.pul`, which is part of the pack, so the same rule covers it.
|
||||
|
||||
Online play (Retro Rewind WFC) needs the Retro-WFC payload translated into the mod, as on a PC:
|
||||
`translate-mod --retro-wfc-payload`, with the payload Setup downloads and verifies from
|
||||
`https://rwfc.net/api/wfc/payload?g=RMCPD00`. Without it the mod downloads `WWFC/Payload` while
|
||||
connecting and jumps into code that was never translated, and the game stops with a missing
|
||||
translated function (seen: `0x81895BF4`, called from `rr_kamek_*` on the `NHTTPi_CommThreadProc`
|
||||
thread, with `r3` pointing at `"WWFC/Payload"`). So the headset
|
||||
build downloads the payload before translating and checks it with `validate-retro-wfc-payload`, and
|
||||
`Invoke-QuestGameBuild` refuses a Retro Rewind translation whose `mod_data_patches.cpp` has no
|
||||
`kRetroWfcInitializerAddress`. The payload is fixed at build time: when rwfc.net publishes a new
|
||||
one, rebuild the game.
|
||||
|
||||
### Game packages (.wcgame) and Import from computer
|
||||
|
||||
A `.wcgame` is a zip holding `game.json`, `libmain.so` and optionally `DATA/…` (the extracted
|
||||
@@ -503,8 +554,8 @@ powershell -ExecutionPolicy Bypass -File android/Prepare-QuestDependencies.ps1
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Install # the app, its game kit and toolchain, debug-signed
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-Quest.ps1 -Headset quest1 -Install # Quest 1: Kryo CPU and direct-VR library entry
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Install # your game, against that kit, into Import (or WheelWizard VR's Build for Quest)
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output with --retro-wfc-payload)
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Headset quest1 -Install # game package from the Quest 1 kit
|
||||
powershell -ExecutionPolicy Bypass -File android/Build-QuestGame.ps1 -Product retro_rewind -Mod <RetroRewind6> -Install # the mod and its pack (needs translate-mod output)
|
||||
adb push MarioKart.iso /sdcard/Download/ # then Select disc image in the launcher
|
||||
```
|
||||
|
||||
@@ -626,8 +677,15 @@ the app:
|
||||
| --- | --- |
|
||||
| `debug.wiicompiled.vtxpad 0` | Turns the stride padding off, to re-check a driver update |
|
||||
| `debug.wiicompiled.validation 1` | Keeps WebGPU validation and robustness on in release builds |
|
||||
| `debug.wiicompiled.panel_layer 0` | Draws the headset settings panel into the eye images instead of on its own quad layer (`OPENXR.md`, Settings in the headset); read about once a second, so it can be switched while the panel is open |
|
||||
| `debug.wiicompiled.inject <n>:<button>` | Presses `a`, `b`, `x`, `y`, `start`, `up`, `down`, `left` or `right` for 12 XR frames each time `<n>` changes. As a Wii Remote, `x`/`y`/`start` are 1/2/+, the directions push the Nunchuk stick, and `home`, `c` and `z` also exist. `panel` presses the settings panel's button (left Y, or both thumbsticks as a gamepad), opening or closing it (see `OPENXR.md`) |
|
||||
| `debug.wiicompiled.fpslog 1` | Logs the game's rendered frame rate every 5 s, with per-frame averages of the producer's waits for the frame worker's DONE and SEALED phases and of the worker's seal, permit wait, prepare and encode stretches. The compositor's `VrApi` log line gives headset FPS, `GPU%`, `CPU%`, clock levels and app GPU time (`App=`) |
|
||||
| `debug.wiicompiled.fpslog 1` | Logs the game's rendered frame rate every 5 s, with per-frame averages of the producer's waits for the frame worker's DONE and SEALED phases and of the worker's seal, permit wait, prepare and encode stretches, and of the draw calls the recorded frame holds and the primitives that merged into them (an overlay that stops draws merging shows up there first). A third line reports the GX thread's command ring (records, waits, busy share). A second line gives the GPU time per frame from timestamp queries on every pass (`mono` native render, `eyeL`/`eyeR` replays, `screen`, `panel`, `efbcopy`, `palette`, `peek`, plus `passes-span` from the first pass begin to the last pass end and `between-passes` for copies and idle gaps). The compositor's `VrApi` log line gives headset FPS, `GPU%`, `CPU%`, clock levels and app GPU time (`App=`) |
|
||||
|
||||
A `Config.toml` written with `adb push` (or `sed -i` in `adb shell`) belongs
|
||||
to the shell user afterwards, and the app then fails every save with EACCES
|
||||
(the launcher logs `GameStorage.prepare ... open failed`). `chmod 664` on the
|
||||
pushed file gives the app's group write access back; a file the app created
|
||||
itself never has the problem.
|
||||
|
||||
The injector makes headset tests possible with nobody wearing the headset.
|
||||
Keep the display awake, drive the menus, then take a compositor screenshot:
|
||||
@@ -694,6 +752,85 @@ at the start is about 1 ms of game-thread CPU per frame, with the GPU at 85 to
|
||||
89%, so the next steps are on both sides: the guest-code share (translator
|
||||
output quality) and the eye replay's GPU cost.
|
||||
|
||||
The GPU side, measured the same day with per-pass timestamp queries (the second
|
||||
`fpslog` line): on SNES Ghost Valley 2 at `render_scale` 0.5 (840x880 eyes) a
|
||||
stereo frame cost 13.2 ms, of which the native render was 5.7 ms, the eyes 3.5
|
||||
and 3.8, copies and gaps 0.4. That native render is a 1280x720 image nobody
|
||||
sees during an immersive race, so it now stops after the last pass whose EFB
|
||||
copy the eyes sample: `mono` fell to 0.15 ms and a Luigi Circuit start at 0.5
|
||||
renders in 5.5 to 10 ms of GPU per frame. The last limiter was the headset
|
||||
pacing: with the display at 72 or 90 Hz, each headset frame stayed open for
|
||||
the next 60 Hz game frame plus the whole encode (`open` 16 ms in the pacing
|
||||
summary), so cycles spanned one to two display slots and the headset got 40 to
|
||||
60 frames per second while the game rendered 60. The Vulkan backend now paces
|
||||
render-first (`PreparePacket`, `BeginFrameForPacket`, `CopyRenderedEyes` in
|
||||
`openxr_vulkan.cpp`; see `OPENXR.md`): the packet is located and handed to
|
||||
Aurora with no compositor frame open, and the frame is begun only once the
|
||||
eyes exist, for the copy alone. On the same automated start at 0.75 the
|
||||
summary reads `cycles=60 skipped-slots=12 late=0 layers new=60 repeat=0
|
||||
open=5.5 end-gap=16.7`, the compositor shows 60 to 61 of 72 with the
|
||||
inherent 12 stale slots, app-to-compositor latency fell from 51 to 9 to 13 ms,
|
||||
and the frame worker's encode fell from 8 to 2.7 ms because the eye copy and
|
||||
its fence wait moved off the worker onto the pacing thread.
|
||||
|
||||
Retro Rewind tracks then showed a game-thread limit of their own: on Athens
|
||||
Dash (a Mario Kart Tour port) the display-list index scan
|
||||
(`WalkDisplayList<DlIndexScanVisitor>`) was 11.5% of the thread while the base
|
||||
game's tracks spend 0.3% there. The scan cache in `gx_dl.cpp` refused lists
|
||||
above 64 KiB, so that track's large shape lists were scanned again on every
|
||||
call; the cap is now 4 MiB. With it the scan is 0.2%, the game rate on Athens
|
||||
Dash went from 47 to 51 fps to 50 to 58, and the thread splits into 62% game
|
||||
plus mod code, 9% GX HLE, 6% FIFO decode, 4% memory copies, 3.5% dispatch and
|
||||
the rest. What remains on such tracks is the game's own code plus the mod's,
|
||||
which no host change shrinks; a GX thread could move about 20% of it.
|
||||
|
||||
That GX thread exists now (`runtime/include/gx_thread.h`, `[video] gx_thread`,
|
||||
on by default on Android and opt-in elsewhere). Every GX HLE override is split
|
||||
into a game-thread front, which keeps the guest-visible side effects (GXData
|
||||
shadow registers, the getters, display-list recording, the texture meta table),
|
||||
and a `_gx` back holding the aurora work and the parser state, posted through
|
||||
one ordered 16 MiB command ring; immediate-mode gather-pipe bytes travel as
|
||||
8 KiB chunks in call order. The hazard rule follows the hardware: whatever the
|
||||
SDK copied into the FIFO at call time (matrices, projection, colours, light
|
||||
objects, copy filters, layout quads, texture object registers) is snapshotted
|
||||
when posted, and whatever the GP read from memory when it reached the command
|
||||
(display lists, vertex arrays, indexed matrices, texture data) is read when the
|
||||
GX thread executes it, so `GXDrawDone` drains the ring and the frame's
|
||||
schedule, first-person anchor and policy tag are latched into the present
|
||||
record on the game thread. The desktop overlay became a game-thread-owned
|
||||
ImGui frame whose draw data Aurora copies per sealed frame, which also removed
|
||||
the frame-worker join `GXCopyDisp` used to make. With `fpslog` on, a third
|
||||
line reports the ring: records and bytes per frame, the game thread's waits
|
||||
for ring space and in drains, the GX thread's busy share and any exceptions
|
||||
it caught. A texture or matrix that is wrong only with the thread on is a
|
||||
hazard-rule violation (a front reading guest memory the game rewrites before
|
||||
the GX thread runs, or a back writing guest memory). Measured on the same
|
||||
automated Grand Prix start at `render_scale` 0.75, same build, switched by the
|
||||
config key: with the thread off the crowded first half minute ran at 52 to
|
||||
56 fps before settling at 60; with it on the same stretch ran at 56.5 in the
|
||||
window that includes the countdown and 60.0 in every window after, while the
|
||||
ring carried 4.5k to 6.2k records (250 to 380 KiB) per frame, the game thread
|
||||
waited under 0.1 ms per frame in its two `GXDrawDone` drains and never for
|
||||
ring space, and the GX thread was 25 to 40% busy. Retro Rewind's menus were
|
||||
unaffected (prewarm 5.2 s, 60 fps).
|
||||
|
||||
Two things the first day on it taught. The Retro Rewind menu with the blurred
|
||||
background fell to 14 to 18 fps, with the GX thread on or off, and the
|
||||
per-record profile that the `fpslog` line now carries (`costliest:`) put it
|
||||
all in the FIFO records: the game re-initialises its capture texture objects
|
||||
every frame, and the split had kept one aurora object per guest object alive
|
||||
across those re-initialisations, so `GXInitTexObjData` kept incrementing
|
||||
`texDataVersion`, which is part of aurora's static upload key, and every
|
||||
frame converted every such texture again (`convert_texture` 18% of the
|
||||
thread). A guest `GXInitTexObj` now rebuilds the aurora object, as it always
|
||||
had, so the version restarts and the upload cache hits. Second, that menu
|
||||
calls `GXDrawDone` 22 to 24 times per frame (the base main menu 9 times),
|
||||
and each drain cost about 1.5 ms while the game thread slept on a condition
|
||||
variable: both the drain and the idle consumer now spin for a few hundred
|
||||
microseconds before blocking, with a sequentially consistent sleep handshake,
|
||||
and the 22 drains cost 2.6 ms per frame in total; that screen runs at 60 with
|
||||
the thread on.
|
||||
|
||||
Verified on device since: the menus on the virtual screen, controller input
|
||||
(the user has driven races), and an immersive Grand Prix start with all 12
|
||||
racers rendering correctly. Not yet verified: stereo comfort and scale,
|
||||
|
||||
@@ -286,6 +286,15 @@ if(MKW_PROJECT_COMPILE_DEFINITIONS)
|
||||
add_compile_definitions(${MKW_PROJECT_COMPILE_DEFINITIONS})
|
||||
endif()
|
||||
|
||||
if(MKW_PLATFORM_WINDOWS AND MKW_ENABLE_OPENXR)
|
||||
include(FetchContent)
|
||||
FetchContent_Declare(vulkan_headers
|
||||
URL "https://github.com/KhronosGroup/Vulkan-Headers/archive/015e25c3c91b70eb1a754d36fb14c4ba6ad9b0b9.tar.gz"
|
||||
DOWNLOAD_EXTRACT_TIMESTAMP TRUE)
|
||||
FetchContent_MakeAvailable(vulkan_headers)
|
||||
include_directories("${vulkan_headers_SOURCE_DIR}/include")
|
||||
endif()
|
||||
|
||||
set(MKW_OPENXR_TARGET "")
|
||||
if(MKW_ENABLE_OPENXR)
|
||||
if(NOT MKW_PLATFORM_WINDOWS AND NOT MKW_PLATFORM_LINUX AND NOT MKW_PLATFORM_ANDROID)
|
||||
@@ -340,6 +349,7 @@ endif()
|
||||
# a registration file is silently never compiled and never errors. The stale-glob
|
||||
# failure mode is worth far more than the milliseconds.
|
||||
file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS "src/*.cpp")
|
||||
set_source_files_properties(src/vr/openxr_vulkan_win32.cpp PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON)
|
||||
if(MKW_PLATFORM_MACOS)
|
||||
list(REMOVE_ITEM SOURCES "${CMAKE_CURRENT_LIST_DIR}/src/guest_flat_memory.cpp")
|
||||
# HostContext's Apple Silicon backend is implemented in a small assembly
|
||||
@@ -397,6 +407,26 @@ target_include_directories(mkw_vr_first_person_tests PRIVATE "${CMAKE_CURRENT_LI
|
||||
target_compile_features(mkw_vr_first_person_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_vr_first_person_tests COMMAND mkw_vr_first_person_tests)
|
||||
|
||||
# The first-person cockpit (seated eye, wheel and handlebar geometry, level seat,
|
||||
# native wheel vertices) and hand steering (grab, turn, hand-off to the game),
|
||||
# ported from heurazy's mario-kart-wii-VR-port. All header-only.
|
||||
foreach(test_name mkw_steering_wheel_tests mkw_vr_cockpit_tests mkw_vr_hand_steering_tests mkw_vr_camera_toggle_tests)
|
||||
string(REGEX REPLACE "^mkw_" "" test_source "${test_name}")
|
||||
add_executable(${test_name} "${CMAKE_CURRENT_LIST_DIR}/tests/${test_source}.cpp")
|
||||
target_include_directories(${test_name} PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include")
|
||||
target_compile_features(${test_name} PRIVATE cxx_std_17)
|
||||
add_test(NAME ${test_name} COMMAND ${test_name})
|
||||
endforeach()
|
||||
|
||||
# The USB wheel's calibration and GameCube pad mapping (physical_wheel.h),
|
||||
# ported from heurazy's mario-kart-wii-VR-port. Header-only; needs dolphin/pad.h.
|
||||
add_executable(mkw_physical_wheel_tests "${CMAKE_CURRENT_LIST_DIR}/tests/physical_wheel_tests.cpp")
|
||||
target_include_directories(mkw_physical_wheel_tests PRIVATE "${CMAKE_CURRENT_LIST_DIR}/include"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include")
|
||||
target_compile_definitions(mkw_physical_wheel_tests PRIVATE TARGET_PC)
|
||||
target_compile_features(mkw_physical_wheel_tests PRIVATE cxx_std_17)
|
||||
add_test(NAME mkw_physical_wheel_tests COMMAND mkw_physical_wheel_tests)
|
||||
|
||||
# The VR controllers' Wii Remote presentation (accelerometer frame, pointer
|
||||
# raycast, debounce, button profile) is header-only for the same reason.
|
||||
add_executable(mkw_vr_wii_remote_tests "${CMAKE_CURRENT_LIST_DIR}/tests/vr_wii_remote_tests.cpp")
|
||||
@@ -453,6 +483,16 @@ if(MKW_ENABLE_OPENXR AND MKW_PLATFORM_WINDOWS)
|
||||
target_compile_features(mkw_openxr_replay_tests PRIVATE cxx_std_17)
|
||||
set_target_properties(mkw_openxr_replay_tests PROPERTIES UNITY_BUILD OFF)
|
||||
add_test(NAME mkw_openxr_replay_tests COMMAND mkw_openxr_replay_tests)
|
||||
add_executable(mkw_openxr_vulkan_replay_tests
|
||||
tests/openxr_d3d12_replay_tests.cpp src/vr/openxr_vulkan_win32.cpp src/vr/openxr_diagnostics.cpp)
|
||||
target_include_directories(mkw_openxr_vulkan_replay_tests PRIVATE
|
||||
"${CMAKE_CURRENT_LIST_DIR}/include"
|
||||
"${CMAKE_CURRENT_LIST_DIR}/../aurora-main/include"
|
||||
"$<TARGET_PROPERTY:${MKW_OPENXR_TARGET},INTERFACE_INCLUDE_DIRECTORIES>")
|
||||
target_compile_definitions(mkw_openxr_vulkan_replay_tests PRIVATE MKW_ENABLE_OPENXR=1 TEST_WINDOWS_VULKAN=1)
|
||||
target_compile_features(mkw_openxr_vulkan_replay_tests PRIVATE cxx_std_17)
|
||||
set_target_properties(mkw_openxr_vulkan_replay_tests PROPERTIES UNITY_BUILD OFF)
|
||||
add_test(NAME mkw_openxr_vulkan_replay_tests COMMAND mkw_openxr_vulkan_replay_tests)
|
||||
endif()
|
||||
|
||||
add_executable(mkw_nand_save_tests "${CMAKE_CURRENT_LIST_DIR}/tests/nand_save_tests.cpp")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "settings_overlay.h"
|
||||
#include "runtime_config.h"
|
||||
#include "fiber_manager.h"
|
||||
#include "gx_thread.h"
|
||||
|
||||
#include <aurora/aurora.h>
|
||||
#include <aurora/event.h>
|
||||
@@ -142,7 +143,11 @@ inline bool BeginAuroraFrame() {
|
||||
if (!aurora_begin_frame()) {
|
||||
return false;
|
||||
}
|
||||
ApplyPendingMkwDynamicAspectSurface();
|
||||
// The viewport policy writes guest memory (EGG screen records), so the GX
|
||||
// thread leaves it to the game thread's present path.
|
||||
if (!GxThread::IsGxThread()) {
|
||||
ApplyPendingMkwDynamicAspectSurface();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
// The VR cockpit's native steering wheel, on the GX side: the game thread
|
||||
// hands Aurora a rotated copy of one of the player's vehicle position arrays,
|
||||
// and Aurora substitutes it for draws that bind that array with the player's
|
||||
// own model-view matrix (aurora_set_native_wheel_vertices). The copies must
|
||||
// reach Aurora in order with the frame's draws, so these post to the GX thread
|
||||
// when it runs and call through directly when it does not.
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace GxNativeWheel {
|
||||
|
||||
// Game thread. Drops every replacement, after the draws posted before it.
|
||||
void PostClear();
|
||||
|
||||
// Game thread. `guestArray` is the guest address of the original array, as the
|
||||
// vehicle's MDL0 holds it; `bytes` (size bytes, big-endian like the original)
|
||||
// is copied now. The replacement is registered under every host pointer the
|
||||
// game can bind that array through (the SDK's GXSetArray address and the
|
||||
// display lists' physical CP address), so it matches whichever reaches Aurora.
|
||||
// Returns false when the array does not resolve to host memory.
|
||||
bool PostVertices(uint32_t guestArray, const uint8_t* bytes, uint32_t size, const float modelView[12]);
|
||||
|
||||
// Any thread. How many draws the most recent cleared set of replacements was
|
||||
// substituted into.
|
||||
uint32_t LastDrawCount();
|
||||
|
||||
} // namespace GxNativeWheel
|
||||
@@ -0,0 +1,106 @@
|
||||
#pragma once
|
||||
// GX thread: the host side of the GX pipeline (state tracking, FIFO parsing,
|
||||
// display-list scanning, texture object resolution and every aurora GX call)
|
||||
// runs on its own thread, fed by an ordered command ring the game thread posts
|
||||
// to. Each GX HLE override is split into a game-thread front (guest-visible
|
||||
// side effects: shadow registers, getters, display-list recording) and a
|
||||
// GX-thread back (the aurora work). When the thread is disabled, Post() runs
|
||||
// the back inline, so the split is behaviour-preserving in both modes.
|
||||
//
|
||||
// Data hazards follow the hardware: anything the GX library copies into the
|
||||
// FIFO at call time (immediate-mode vertices, matrices, colours, light objects,
|
||||
// texture object registers) is snapshotted at post time; anything the GP reads
|
||||
// from memory when it reaches the command (display lists, vertex arrays,
|
||||
// indexed matrices, texture data) is read when the GX thread executes it.
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
namespace GxThread {
|
||||
|
||||
// Decides whether posted work runs on the GX thread. Read once, before GXInit.
|
||||
void Configure(bool enabled);
|
||||
bool Enabled() noexcept;
|
||||
// True on the consumer thread.
|
||||
bool IsGxThread() noexcept;
|
||||
void Start();
|
||||
// Executes everything posted so far and joins the thread.
|
||||
void Stop();
|
||||
// Game thread: blocks until every posted record has executed (GXDrawDone).
|
||||
void Drain();
|
||||
// Publishes the open immediate-mode FIFO chunk. Post() does this itself.
|
||||
void FlushFifo();
|
||||
// Invoked at bounded intervals while the game thread blocks in Drain() or on
|
||||
// a full ring, so guest timing (VI retraces, OS alarms) keeps running.
|
||||
void SetWaitCallback(void (*callback)());
|
||||
// Native thread id of the consumer (Android: gettid), 0 until started.
|
||||
uint32_t NativeThreadId() noexcept;
|
||||
// One line for the frame-rate log; resets the window counters.
|
||||
std::string FormatStatsAndReset(double windowSeconds, uint32_t frames);
|
||||
|
||||
// Immediate-mode write-gather bytes. Only valid when Enabled().
|
||||
void PostFifoWord(uint32_t value, uint32_t sizeBytes);
|
||||
void PostFifoBytes(const uint8_t* data, uint32_t sizeBytes);
|
||||
|
||||
namespace detail {
|
||||
using Invoke = void (*)(const uint8_t* payload, uint32_t payloadBytes);
|
||||
void PostRecord(Invoke invoke, const void* payload, uint32_t payloadBytes);
|
||||
|
||||
template <typename... Ts>
|
||||
struct Pack;
|
||||
template <>
|
||||
struct Pack<> {
|
||||
template <typename F, typename... Prev>
|
||||
void Call(F f, Prev... prev) const {
|
||||
f(prev...);
|
||||
}
|
||||
};
|
||||
template <typename T, typename... Ts>
|
||||
struct Pack<T, Ts...> {
|
||||
T head;
|
||||
Pack<Ts...> tail;
|
||||
template <typename F, typename... Prev>
|
||||
void Call(F f, Prev... prev) const {
|
||||
tail.Call(f, prev..., head);
|
||||
}
|
||||
};
|
||||
template <typename... Ts>
|
||||
struct BuildPack;
|
||||
template <>
|
||||
struct BuildPack<> {
|
||||
static Pack<> Make() { return {}; }
|
||||
};
|
||||
template <typename T, typename... Ts>
|
||||
struct BuildPack<T, Ts...> {
|
||||
static Pack<T, Ts...> Make(T head, Ts... tail) {
|
||||
return Pack<T, Ts...>{head, BuildPack<Ts...>::Make(tail...)};
|
||||
}
|
||||
};
|
||||
template <typename R, typename... Params>
|
||||
struct CallRecord {
|
||||
R (*fn)(Params...);
|
||||
Pack<Params...> args;
|
||||
};
|
||||
template <typename R, typename... Params>
|
||||
void InvokeCall(const uint8_t* payload, uint32_t) {
|
||||
CallRecord<R, Params...> record;
|
||||
std::memcpy(&record, payload, sizeof(record));
|
||||
record.args.Call(record.fn);
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
// Posts fn(args...) to the GX thread, or runs it now when the thread is off.
|
||||
// Parameters must be trivially copyable values (no pointers into guest memory
|
||||
// that the game may rewrite before the GX thread reads them).
|
||||
template <typename R, typename... Params, typename... Args>
|
||||
inline void Post(R (*fn)(Params...), Args&&... args) {
|
||||
if (!Enabled()) {
|
||||
fn(static_cast<Params>(args)...);
|
||||
return;
|
||||
}
|
||||
detail::CallRecord<R, Params...> record{fn, detail::BuildPack<Params...>::Make(static_cast<Params>(args)...)};
|
||||
detail::PostRecord(&detail::InvokeCall<R, Params...>, &record, sizeof(record));
|
||||
}
|
||||
|
||||
} // namespace GxThread
|
||||
@@ -0,0 +1,173 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// USB steering wheel and pedals for player 1. Ported from heurazy's
|
||||
// mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
//
|
||||
// Any SDL joystick works: the steering axis, both pedals and the buttons are
|
||||
// chosen and calibrated by moving or pressing them (F10 > Controllers > USB
|
||||
// wheel and pedals), so no per-model table or gamepad mapping is involved, and
|
||||
// separate pedals, reversed axes and combined pedal axes all calibrate the same
|
||||
// way. The wheel is a GameCube controller on port 0: in a race it owns
|
||||
// steering, pedals and the assigned buttons; in menus it adds its confirm,
|
||||
// back, pause and D-pad (the steering device's first hat) to whatever else
|
||||
// drives port 0. The settings live in PhysicalWheel.toml beside Config.toml.
|
||||
//
|
||||
// The mapping below is pure and tested (tests/physical_wheel_tests.cpp).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <dolphin/pad.h>
|
||||
|
||||
namespace physical_wheel {
|
||||
|
||||
// Endpoint calibration also handles reversed and combined pedal axes.
|
||||
inline float Pedal(int raw, int released, int pressed) {
|
||||
if (std::abs(pressed - released) < 1024) return 0;
|
||||
return std::clamp(float(raw - released) / float(pressed - released), 0.f, 1.f);
|
||||
}
|
||||
inline float Steering(int raw, int left, int center, int right, float deadzone) {
|
||||
if (std::abs(left - center) < 1024 || std::abs(right - center) < 1024 || (left < center) == (right < center))
|
||||
return 0;
|
||||
const float direction = float(raw - center) / float(right - center);
|
||||
const float x = direction >= 0 ? Pedal(raw, center, right) : -Pedal(raw, center, left);
|
||||
deadzone = std::clamp(deadzone, 0.f, .25f);
|
||||
return std::copysign(std::max(0.f, (std::abs(x) - deadzone) / (1 - deadzone)), x);
|
||||
}
|
||||
inline PADStatus Map(float steering, float throttle, float brake, bool drift, bool item) {
|
||||
PADStatus p{};
|
||||
p.err = PAD_ERR_NONE;
|
||||
p.stickX = static_cast<int8_t>(std::lround(std::clamp(steering, -1.f, 1.f) * 100));
|
||||
if (throttle > .1f) {
|
||||
p.button |= PAD_BUTTON_A;
|
||||
p.analogA = 255;
|
||||
}
|
||||
if (drift) {
|
||||
p.button |= PAD_TRIGGER_R;
|
||||
p.triggerR = 255;
|
||||
}
|
||||
if (item) {
|
||||
p.button |= PAD_TRIGGER_L;
|
||||
p.triggerL = 255;
|
||||
}
|
||||
if (brake > .1f) {
|
||||
p.button &= ~(PAD_BUTTON_A | PAD_TRIGGER_R);
|
||||
p.analogA = p.triggerR = 0;
|
||||
p.button |= PAD_BUTTON_B;
|
||||
p.analogB = 255;
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
// SDL_HAT_UP/RIGHT/DOWN/LEFT bits as the GameCube D-pad.
|
||||
inline uint16_t HatButtons(uint8_t hat) {
|
||||
uint16_t buttons = 0;
|
||||
if (hat & 0x01) buttons |= PAD_BUTTON_UP;
|
||||
if (hat & 0x02) buttons |= PAD_BUTTON_RIGHT;
|
||||
if (hat & 0x04) buttons |= PAD_BUTTON_DOWN;
|
||||
if (hat & 0x08) buttons |= PAD_BUTTON_LEFT;
|
||||
return buttons;
|
||||
}
|
||||
|
||||
// One sample of the calibrated hardware.
|
||||
struct Controls {
|
||||
float steering = 0, throttle = 0, brake = 0;
|
||||
bool drift = false, item = false, trick = false, confirm = false, pause = false, back = false;
|
||||
uint8_t hat = 0;
|
||||
};
|
||||
|
||||
// Race: steering on the stick, the accelerator on A, the brake pedal on B
|
||||
// (braking, then reversing, over the accelerator and drift), R drift, L item,
|
||||
// the D-pad and the trick button for tricks and wheelies.
|
||||
inline PADStatus RacePad(const Controls& c) {
|
||||
auto p = Map(c.steering, c.throttle, c.brake, c.drift, c.item);
|
||||
p.button |= HatButtons(c.hat);
|
||||
if (c.trick) p.button |= PAD_BUTTON_UP;
|
||||
if (c.confirm && !(p.button & PAD_BUTTON_B)) {
|
||||
p.button |= PAD_BUTTON_A;
|
||||
p.analogA = 255;
|
||||
}
|
||||
if (c.pause) p.button |= PAD_BUTTON_START;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Menus: only deliberate presses, so a resting foot or a turned wheel never
|
||||
// moves a cursor or confirms. The pedals and the steering stay out.
|
||||
inline PADStatus MenuPad(const Controls& c) {
|
||||
PADStatus p{};
|
||||
p.err = PAD_ERR_NONE;
|
||||
p.button = HatButtons(c.hat);
|
||||
if (c.confirm) {
|
||||
p.button |= PAD_BUTTON_A;
|
||||
p.analogA = 255;
|
||||
}
|
||||
if (c.back) {
|
||||
p.button |= PAD_BUTTON_B;
|
||||
p.analogB = 255;
|
||||
}
|
||||
if (c.pause) p.button |= PAD_BUTTON_START;
|
||||
return p;
|
||||
}
|
||||
|
||||
// Input that was held while blocked (settings open, not yet armed) stays out of
|
||||
// the game until it is released.
|
||||
class PadFilter {
|
||||
public:
|
||||
PADStatus Apply(PADStatus pad, bool blocked) noexcept {
|
||||
if (blocked) {
|
||||
suppressed_ = pad.button;
|
||||
suppress_stick_ = pad.stickX != 0 || pad.stickY != 0;
|
||||
pad = {};
|
||||
pad.err = PAD_ERR_NONE;
|
||||
return pad;
|
||||
}
|
||||
suppressed_ &= pad.button;
|
||||
pad.button &= ~suppressed_;
|
||||
if (!(pad.button & PAD_BUTTON_A)) pad.analogA = 0;
|
||||
if (!(pad.button & PAD_BUTTON_B)) pad.analogB = 0;
|
||||
if (!(pad.button & PAD_TRIGGER_L)) pad.triggerL = 0;
|
||||
if (!(pad.button & PAD_TRIGGER_R)) pad.triggerR = 0;
|
||||
if (suppress_stick_) {
|
||||
suppress_stick_ = pad.stickX != 0 || pad.stickY != 0;
|
||||
pad.stickX = pad.stickY = 0;
|
||||
}
|
||||
return pad;
|
||||
}
|
||||
|
||||
private:
|
||||
uint16_t suppressed_ = 0;
|
||||
bool suppress_stick_ = false;
|
||||
};
|
||||
|
||||
// Folds the wheel into port 0. A race gives the wheel the whole pad, keeping
|
||||
// only the other source's pause and its stick's vertical axis (item aiming);
|
||||
// menus add the wheel's buttons to it.
|
||||
inline void Merge(PADStatus& pad, PADStatus wheel, bool race, bool blocked, PadFilter& filter) {
|
||||
if (race) {
|
||||
const auto pause = pad.button & PAD_BUTTON_START;
|
||||
const auto aim = pad.stickY;
|
||||
pad = filter.Apply(wheel, blocked);
|
||||
pad.button |= pause;
|
||||
pad.stickY = blocked ? 0 : aim;
|
||||
} else {
|
||||
wheel.stickX = wheel.stickY = 0;
|
||||
wheel = filter.Apply(wheel, blocked);
|
||||
pad.button |= wheel.button;
|
||||
pad.analogA = std::max(pad.analogA, wheel.analogA);
|
||||
pad.analogB = std::max(pad.analogB, wheel.analogB);
|
||||
pad.err = PAD_ERR_NONE;
|
||||
}
|
||||
}
|
||||
|
||||
// Device and UI operations run on the game thread (PADRead and the settings
|
||||
// overlay); the XR pacing thread reads a locked snapshot.
|
||||
bool ReadPad(PADStatus& pad, bool blocked, bool race);
|
||||
void DrawSettings();
|
||||
// The calibrated steering, -1..1, while the wheel is driving a race.
|
||||
bool SteeringSnapshot(float& steering);
|
||||
// PADControlMotor for port 0; false when the wheel does not own it.
|
||||
bool Motor(int channel, unsigned command);
|
||||
void Shutdown();
|
||||
|
||||
} // namespace physical_wheel
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <toml.hpp>
|
||||
#include "platform/host_platform.h"
|
||||
#include "vr/frame_interpolation_pacing.h"
|
||||
#include "vr/steering_wheel.h"
|
||||
#ifdef _WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
@@ -47,6 +48,7 @@ struct RuntimeUserConfig {
|
||||
std::optional<bool> textureReplacements;
|
||||
std::optional<bool> textureDumps;
|
||||
std::optional<bool> showFps;
|
||||
std::optional<bool> gxThread;
|
||||
std::optional<uint32_t> disabledPostProcessingPaths;
|
||||
std::optional<bool> vrEnabled;
|
||||
std::optional<bool> vrRequired;
|
||||
@@ -55,12 +57,15 @@ struct RuntimeUserConfig {
|
||||
std::optional<float> vrHudDistanceMeters;
|
||||
std::optional<float> vrHudWidthMeters;
|
||||
std::optional<bool> vrHudVirtualScreen;
|
||||
std::optional<bool> vrFlatScreen;
|
||||
std::optional<bool> vrPassthrough;
|
||||
std::optional<bool> vrStopAtDisplayCopy;
|
||||
std::optional<bool> vrSkipCopyClears;
|
||||
std::optional<std::string> vrMirrorView;
|
||||
std::optional<std::string> vrControllerMode;
|
||||
std::optional<uint32_t> vrFrameInterpolationFps;
|
||||
std::optional<bool> vrFirstPerson;
|
||||
std::optional<bool> vrFirstPersonToggleClick;
|
||||
std::optional<float> vrFirstPersonUnitsPerMeter;
|
||||
std::optional<float> vrFirstPersonHeadUpMeters;
|
||||
std::optional<float> vrFirstPersonHeadForwardMeters;
|
||||
@@ -68,6 +73,18 @@ struct RuntimeUserConfig {
|
||||
std::optional<bool> vrFirstPersonHideDriver;
|
||||
std::optional<int32_t> vrFirstPersonHiddenModel;
|
||||
std::optional<std::string> vrFirstPersonRotation;
|
||||
std::optional<std::string> vrFirstPersonSeat;
|
||||
std::optional<float> vrCockpitUnitsPerMeter;
|
||||
std::optional<bool> vrSteeringWheel;
|
||||
std::optional<bool> vrNativeSteeringWheel;
|
||||
std::optional<bool> vrHandSteering;
|
||||
std::optional<float> vrWheelKartDegrees;
|
||||
std::optional<float> vrWheelBikeDegrees;
|
||||
std::optional<float> vrWheelGrabDistance;
|
||||
std::optional<float> vrWheelGrabAssist;
|
||||
std::optional<float> vrWheelResponse;
|
||||
std::optional<float> vrWheelTrackingGrace;
|
||||
std::optional<bool> vrWheelHaptics;
|
||||
std::optional<std::string> vrPerformanceLevel;
|
||||
std::optional<std::string> vrRecenterKey;
|
||||
std::optional<float> vrLeanBackDegrees;
|
||||
@@ -185,12 +202,40 @@ inline constexpr float kVrFirstPersonHeadRightDefault = 0.0f;
|
||||
inline constexpr bool kVrFirstPersonHideDriverDefault = true;
|
||||
inline constexpr int32_t kVrFirstPersonHiddenModelDefault = 0;
|
||||
inline constexpr float kVrFirstPersonHeadOffsetLimit = 10.0f;
|
||||
// "yaw", "yaw_pitch" or "full", matching FirstPersonRotation.
|
||||
inline constexpr const char* kVrFirstPersonRotationDefault = "yaw";
|
||||
// "yaw", "yaw_pitch" or "full", matching FirstPersonRotation. The cockpit seat
|
||||
// is the first-person default, and sitting in the vehicle reads better with its
|
||||
// climb than with a level horizon, so "yaw_pitch" is the default anchor.
|
||||
inline constexpr const char* kVrFirstPersonRotationDefault = "yaw_pitch";
|
||||
|
||||
inline bool IsSupportedVrFirstPersonRotation(std::string_view value) {
|
||||
return value == "yaw" || value == "yaw_pitch" || value == "full";
|
||||
}
|
||||
// Where the first-person head sits, matching FirstPersonSeat: "cockpit" at the
|
||||
// driver's own eyes behind the wheel, "custom" at the head offsets above.
|
||||
inline constexpr const char* kVrFirstPersonSeatDefault = "cockpit";
|
||||
|
||||
inline bool IsSupportedVrFirstPersonSeat(std::string_view value) {
|
||||
return value == "cockpit" || value == "custom";
|
||||
}
|
||||
// The cockpit seat's world scale before the character's height is allowed for.
|
||||
inline constexpr float kVrCockpitUnitsPerMeterDefault = 100.0f;
|
||||
inline constexpr float kVrCockpitUnitsPerMeterMin = 20.0f;
|
||||
inline constexpr float kVrCockpitUnitsPerMeterMax = 400.0f;
|
||||
// The vehicle's steering wheel or handlebar turns with the steering; the
|
||||
// vehicle's own model is animated unless native_steering_wheel is off, which
|
||||
// draws a separate VR wheel instead. Hand steering (grabbing that wheel with
|
||||
// the tracked controllers, by heurazy) comes with it: the stick still steers
|
||||
// until a grip actually takes hold of the wheel. Both launchers register
|
||||
// hand_steering with this same default.
|
||||
inline constexpr bool kVrSteeringWheelDefault = true;
|
||||
inline constexpr bool kVrNativeSteeringWheelDefault = true;
|
||||
inline constexpr bool kVrHandSteeringDefault = true;
|
||||
// Hand steering tuning ranges; the defaults are mkw::vr::WheelTuning's.
|
||||
inline constexpr float kVrWheelDegreesMin = 20.0f, kVrWheelDegreesMax = 180.0f;
|
||||
inline constexpr float kVrWheelGrabDistanceMin = 0.15f, kVrWheelGrabDistanceMax = 0.8f;
|
||||
inline constexpr float kVrWheelGrabAssistMin = 0.7f, kVrWheelGrabAssistMax = 2.0f;
|
||||
inline constexpr float kVrWheelResponseMin = 0.5f, kVrWheelResponseMax = 2.0f;
|
||||
inline constexpr float kVrWheelTrackingGraceMin = 0.05f, kVrWheelTrackingGraceMax = 0.5f;
|
||||
// The performance level asked of the OpenXR runtime (XR_EXT_performance_settings) for its CPU and
|
||||
// GPU domains. Standalone headsets clock their cores by this request: a Quest 3 ran the game
|
||||
// thread at 1.92 GHz with the runtime's own choice while its fast cores reach 2.36 GHz. "default"
|
||||
@@ -409,7 +454,11 @@ inline void EnsureConfigFile() {
|
||||
"graphics_api = \"auto\"\n"
|
||||
"skip_unready_pipelines = true\n"
|
||||
"disable_copy_filter = true\n"
|
||||
"show_fps = true\n"
|
||||
"show_fps = false\n"
|
||||
"# Run the host side of the GX pipeline (state tracking, FIFO parsing,\n"
|
||||
"# texture uploads) on its own thread. On by default on the Quest, where\n"
|
||||
"# the game thread is the bottleneck; opt-in elsewhere.\n"
|
||||
"# gx_thread = true\n"
|
||||
"# Dolphin-style custom textures. When enabled, the renderer indexes\n"
|
||||
"# texture_replacements/ next to this file at startup and substitutes\n"
|
||||
"# any tex1_<W>x<H>_<hash>[_<tlut hash>]_<format>.dds or .png it finds\n"
|
||||
@@ -448,6 +497,11 @@ inline void EnsureConfigFile() {
|
||||
"# Changeable live from the F10 menu; the two sizes above place\n"
|
||||
"# that screen and the menu screen alike and are read at launch.\n"
|
||||
"hud_virtual_screen = true\n"
|
||||
"# Flat Screen mode keeps races on that same screen, as the\n"
|
||||
"# menus are, instead of all around you: no stereo race view, no\n"
|
||||
"# first-person camera or hand steering. Changeable live from the\n"
|
||||
"# F10 menu.\n"
|
||||
"flat_screen = false\n"
|
||||
"# EFB replay controls for the per-eye views, changeable live\n"
|
||||
"# from the F10 menu. stop_at_display_copy ends each eye at the\n"
|
||||
"# frame's final GXCopyDisp; skip_copy_clears drops the EFB\n"
|
||||
@@ -457,14 +511,24 @@ inline void EnsureConfigFile() {
|
||||
"skip_copy_clears = true\n"
|
||||
"# Put the camera at the Player 1 driver's head instead of behind\n"
|
||||
"# the kart, with the horizon kept level. Changeable live from the\n"
|
||||
"# F10 menu, and only during a single-screen race. The world scale\n"
|
||||
"# below replaces world_units_per_meter while it is engaged: 10 is\n"
|
||||
"# life-size, where the 500 above makes the race a small diorama.\n"
|
||||
"# F10 menu, and only during a single-screen race.\n"
|
||||
"first_person = false\n"
|
||||
"first_person_units_per_meter = 30.0\n"
|
||||
"# Where the head sits in the kart's own frame, in metres.\n"
|
||||
"first_person_head_up_meters = 3.0\n"
|
||||
"first_person_head_forward_meters = 12.0\n"
|
||||
"# Clicking the right thumbstick, on the VR controllers or on any\n"
|
||||
"# gamepad while VR runs, toggles first_person as the F10 checkbox does.\n"
|
||||
"first_person_toggle_click = true\n"
|
||||
"# Where the head sits: \"cockpit\" puts it at the driver's own eyes,\n"
|
||||
"# behind the steering wheel, at a life-size scale that allows for\n"
|
||||
"# the character's height, so the wheel is within reach.\n"
|
||||
"# \"custom\" uses the world scale and head offsets below instead.\n"
|
||||
"first_person_seat = \"cockpit\"\n"
|
||||
"cockpit_units_per_meter = 100.0\n"
|
||||
"# The custom seat's world scale, replacing world_units_per_meter\n"
|
||||
"# while first person is engaged; the 500 above makes the race a\n"
|
||||
"# small diorama.\n"
|
||||
"first_person_units_per_meter = 50.0\n"
|
||||
"# Where the custom seat's head sits in the kart's own frame, in metres.\n"
|
||||
"first_person_head_up_meters = 1.5\n"
|
||||
"first_person_head_forward_meters = 0.0\n"
|
||||
"first_person_head_right_meters = 0.0\n"
|
||||
"# In first person the driver sits where your eyes are. Hiding\n"
|
||||
"# the driver removes the head that would otherwise be in the\n"
|
||||
@@ -476,7 +540,28 @@ inline void EnsureConfigFile() {
|
||||
"# Where the view's orientation comes from: \"yaw\" levels the\n"
|
||||
"# horizon, \"yaw_pitch\" adds the kart's climb but no roll, and\n"
|
||||
"# \"full\" takes the kart's whole orientation so the view banks.\n"
|
||||
"first_person_rotation = \"yaw\"\n\n"
|
||||
"first_person_rotation = \"yaw_pitch\"\n"
|
||||
"# In the cockpit the vehicle's steering wheel or handlebar turns\n"
|
||||
"# with the steering. native_steering_wheel animates the vehicle's\n"
|
||||
"# own model; false draws a separate VR wheel instead.\n"
|
||||
"steering_wheel = true\n"
|
||||
"native_steering_wheel = true\n"
|
||||
"# Hand steering (by heurazy): squeeze a grip near the wheel or\n"
|
||||
"# handlebar to take hold of it with the tracked controllers, and\n"
|
||||
"# turn it to steer. Releasing both grips gives steering back to the\n"
|
||||
"# stick. The tuning below: degrees of turn for full lock on karts\n"
|
||||
"# and bikes, how far (metres) and how generously a grip reaches\n"
|
||||
"# the wheel, how quickly the wheel follows the hands, how long\n"
|
||||
"# (seconds) a hand that loses tracking keeps hold, and a short\n"
|
||||
"# pulse on grab and release. All changeable live from the F10 menu.\n"
|
||||
"hand_steering = true\n"
|
||||
"wheel_kart_degrees = 90.0\n"
|
||||
"wheel_bike_degrees = 45.0\n"
|
||||
"wheel_grab_distance = 0.35\n"
|
||||
"wheel_grab_assist = 1.0\n"
|
||||
"wheel_response = 1.0\n"
|
||||
"wheel_tracking_grace = 0.2\n"
|
||||
"wheel_haptics = true\n\n"
|
||||
"# Performance level asked of the headset's runtime for its CPU and\n"
|
||||
"# GPU: \"boost\", \"sustained_high\", \"sustained_low\", \"power_savings\",\n"
|
||||
"# or \"default\" to leave the runtime's own choice. Standalone headsets\n"
|
||||
@@ -641,6 +726,7 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
config.skipUnreadyPipelines = FindConfigValue<bool>(document, "video", "skip_unready_pipelines");
|
||||
config.disableCopyFilter = FindConfigValue<bool>(document, "video", "disable_copy_filter");
|
||||
config.showFps = FindConfigValue<bool>(document, "video", "show_fps");
|
||||
config.gxThread = FindConfigValue<bool>(document, "video", "gx_thread");
|
||||
config.textureReplacements = FindConfigValue<bool>(document, "video", "texture_replacements");
|
||||
config.textureDumps = FindConfigValue<bool>(document, "video", "texture_dumps");
|
||||
if (auto value = FindConfigUint(document, "video", "disabled_post_processing_paths");
|
||||
@@ -667,9 +753,12 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
config.vrHudWidthMeters = *value;
|
||||
}
|
||||
config.vrHudVirtualScreen = FindConfigValue<bool>(document, "vr", "hud_virtual_screen");
|
||||
config.vrFlatScreen = FindConfigValue<bool>(document, "vr", "flat_screen");
|
||||
config.vrPassthrough = FindConfigValue<bool>(document, "vr", "passthrough");
|
||||
config.vrStopAtDisplayCopy = FindConfigValue<bool>(document, "vr", "stop_at_display_copy");
|
||||
config.vrSkipCopyClears = FindConfigValue<bool>(document, "vr", "skip_copy_clears");
|
||||
config.vrFirstPerson = FindConfigValue<bool>(document, "vr", "first_person");
|
||||
config.vrFirstPersonToggleClick = FindConfigValue<bool>(document, "vr", "first_person_toggle_click");
|
||||
if (auto value = FindConfigFloat(document, "vr", "first_person_units_per_meter");
|
||||
value && *value >= 1.0f && *value <= 10000.0f) {
|
||||
config.vrFirstPersonUnitsPerMeter = *value;
|
||||
@@ -724,6 +813,28 @@ inline RuntimeUserConfig ParseConfigDocument(const toml::value& document) {
|
||||
value && *value >= -1 && *value <= 31) {
|
||||
config.vrFirstPersonHiddenModel = static_cast<int32_t>(*value);
|
||||
}
|
||||
if (auto value = FindConfigValue<std::string>(document, "vr", "first_person_seat");
|
||||
value && IsSupportedVrFirstPersonSeat(*value)) {
|
||||
config.vrFirstPersonSeat = *value;
|
||||
}
|
||||
const auto readRangedFloat = [&](std::string_view key, float low, float high) -> std::optional<float> {
|
||||
auto value = FindConfigFloat(document, "vr", key);
|
||||
return value && *value >= low && *value <= high ? value : std::nullopt;
|
||||
};
|
||||
config.vrCockpitUnitsPerMeter =
|
||||
readRangedFloat("cockpit_units_per_meter", kVrCockpitUnitsPerMeterMin, kVrCockpitUnitsPerMeterMax);
|
||||
config.vrSteeringWheel = FindConfigValue<bool>(document, "vr", "steering_wheel");
|
||||
config.vrNativeSteeringWheel = FindConfigValue<bool>(document, "vr", "native_steering_wheel");
|
||||
config.vrHandSteering = FindConfigValue<bool>(document, "vr", "hand_steering");
|
||||
config.vrWheelKartDegrees = readRangedFloat("wheel_kart_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
|
||||
config.vrWheelBikeDegrees = readRangedFloat("wheel_bike_degrees", kVrWheelDegreesMin, kVrWheelDegreesMax);
|
||||
config.vrWheelGrabDistance =
|
||||
readRangedFloat("wheel_grab_distance", kVrWheelGrabDistanceMin, kVrWheelGrabDistanceMax);
|
||||
config.vrWheelGrabAssist = readRangedFloat("wheel_grab_assist", kVrWheelGrabAssistMin, kVrWheelGrabAssistMax);
|
||||
config.vrWheelResponse = readRangedFloat("wheel_response", kVrWheelResponseMin, kVrWheelResponseMax);
|
||||
config.vrWheelTrackingGrace =
|
||||
readRangedFloat("wheel_tracking_grace", kVrWheelTrackingGraceMin, kVrWheelTrackingGraceMax);
|
||||
config.vrWheelHaptics = FindConfigValue<bool>(document, "vr", "wheel_haptics");
|
||||
config.diagnosticsOpenXRLogging = FindConfigValue<bool>(document, "diagnostics", "openxr_logging");
|
||||
|
||||
auto readVolume = [&](std::string_view key) -> std::optional<float> {
|
||||
@@ -957,6 +1068,16 @@ inline bool SetVrHudVirtualScreen(bool value) {
|
||||
return WriteSetting("vr", "hud_virtual_screen", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrFlatScreen(bool value) {
|
||||
Mutable().vrFlatScreen = value;
|
||||
return WriteSetting("vr", "flat_screen", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrPassthrough(bool value) {
|
||||
Mutable().vrPassthrough = value;
|
||||
return WriteSetting("vr", "passthrough", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrStopAtDisplayCopy(bool value) {
|
||||
Mutable().vrStopAtDisplayCopy = value;
|
||||
return WriteSetting("vr", "stop_at_display_copy", value ? "true" : "false");
|
||||
@@ -972,6 +1093,11 @@ inline bool SetVrFirstPerson(bool value) {
|
||||
return WriteSetting("vr", "first_person", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrFirstPersonToggleClick(bool value) {
|
||||
Mutable().vrFirstPersonToggleClick = value;
|
||||
return WriteSetting("vr", "first_person_toggle_click", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrFirstPersonUnitsPerMeter(float value) {
|
||||
value = std::clamp(value, 1.0f, 10000.0f);
|
||||
Mutable().vrFirstPersonUnitsPerMeter = value;
|
||||
@@ -1052,6 +1178,62 @@ inline bool SetVrPerformanceLevel(std::string value) {
|
||||
return WriteSetting("vr", "performance_level", FormatString(value));
|
||||
}
|
||||
|
||||
inline bool SetVrFirstPersonSeat(std::string value) {
|
||||
if (!IsSupportedVrFirstPersonSeat(value)) {
|
||||
return false;
|
||||
}
|
||||
Mutable().vrFirstPersonSeat = value;
|
||||
return WriteSetting("vr", "first_person_seat", FormatString(value));
|
||||
}
|
||||
|
||||
inline bool SetVrCockpitUnitsPerMeter(float value) {
|
||||
value = std::clamp(value, kVrCockpitUnitsPerMeterMin, kVrCockpitUnitsPerMeterMax);
|
||||
Mutable().vrCockpitUnitsPerMeter = value;
|
||||
std::ostringstream formatted;
|
||||
formatted << value;
|
||||
return WriteSetting("vr", "cockpit_units_per_meter", formatted.str());
|
||||
}
|
||||
|
||||
inline bool SetVrSteeringWheel(bool value) {
|
||||
Mutable().vrSteeringWheel = value;
|
||||
return WriteSetting("vr", "steering_wheel", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrNativeSteeringWheel(bool value) {
|
||||
Mutable().vrNativeSteeringWheel = value;
|
||||
return WriteSetting("vr", "native_steering_wheel", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrHandSteering(bool value) {
|
||||
Mutable().vrHandSteering = value;
|
||||
return WriteSetting("vr", "hand_steering", value ? "true" : "false");
|
||||
}
|
||||
|
||||
inline bool SetVrWheelTuning(const mkw::vr::WheelTuning& tuning) {
|
||||
auto& config = Mutable();
|
||||
const auto write = [](const char* key, std::optional<float>& slot, float value, float low, float high) {
|
||||
value = std::clamp(value, low, high);
|
||||
slot = value;
|
||||
std::ostringstream formatted;
|
||||
formatted << value;
|
||||
return WriteSetting("vr", key, formatted.str());
|
||||
};
|
||||
bool ok = write("wheel_kart_degrees", config.vrWheelKartDegrees, tuning.kartDegrees, kVrWheelDegreesMin,
|
||||
kVrWheelDegreesMax);
|
||||
ok = write("wheel_bike_degrees", config.vrWheelBikeDegrees, tuning.bikeDegrees, kVrWheelDegreesMin,
|
||||
kVrWheelDegreesMax) && ok;
|
||||
ok = write("wheel_grab_distance", config.vrWheelGrabDistance, tuning.grabDistance, kVrWheelGrabDistanceMin,
|
||||
kVrWheelGrabDistanceMax) && ok;
|
||||
ok = write("wheel_grab_assist", config.vrWheelGrabAssist, tuning.grabAssist, kVrWheelGrabAssistMin,
|
||||
kVrWheelGrabAssistMax) && ok;
|
||||
ok = write("wheel_response", config.vrWheelResponse, tuning.response, kVrWheelResponseMin,
|
||||
kVrWheelResponseMax) && ok;
|
||||
ok = write("wheel_tracking_grace", config.vrWheelTrackingGrace, tuning.trackingGrace,
|
||||
kVrWheelTrackingGraceMin, kVrWheelTrackingGraceMax) && ok;
|
||||
config.vrWheelHaptics = tuning.haptics;
|
||||
return WriteSetting("vr", "wheel_haptics", tuning.haptics ? "true" : "false") && ok;
|
||||
}
|
||||
|
||||
inline bool SetVrFirstPersonHiddenModel(int32_t value) {
|
||||
value = std::clamp(value, -1, 31);
|
||||
Mutable().vrFirstPersonHiddenModel = value;
|
||||
@@ -1290,10 +1472,21 @@ inline bool DisableCopyFilter(bool fallback = true) {
|
||||
return Get().disableCopyFilter.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool ShowFps(bool fallback = true) {
|
||||
// The counter is a diagnostic, so it starts off and the F10 bar turns it on.
|
||||
inline bool ShowFps(bool fallback = false) {
|
||||
return Get().showFps.value_or(fallback);
|
||||
}
|
||||
|
||||
// Runs the host side of the GX pipeline on its own thread (gx_thread.h). On by
|
||||
// default on the Quest, where the game thread is the bottleneck; opt-in elsewhere.
|
||||
inline bool GxThread() {
|
||||
#if defined(__ANDROID__)
|
||||
return Get().gxThread.value_or(true);
|
||||
#else
|
||||
return Get().gxThread.value_or(false);
|
||||
#endif
|
||||
}
|
||||
|
||||
inline bool TextureReplacements(bool fallback = false) {
|
||||
return Get().textureReplacements.value_or(fallback);
|
||||
}
|
||||
@@ -1338,6 +1531,20 @@ inline bool VrHudVirtualScreen(bool fallback = true) {
|
||||
return Get().vrHudVirtualScreen.value_or(fallback);
|
||||
}
|
||||
|
||||
// Races on the flat virtual screen the menus use, instead of immersive
|
||||
// stereo. The launcher's Settings page shows the same default.
|
||||
inline bool VrFlatScreen(bool fallback = false) {
|
||||
return Get().vrFlatScreen.value_or(fallback);
|
||||
}
|
||||
|
||||
// The room, through the headset's cameras, around the menu screen and every
|
||||
// other virtual screen, a Flat Screen race included (never an immersive
|
||||
// race). Only the Quest offers it; the launcher's Settings page shows the same
|
||||
// default.
|
||||
inline bool VrPassthrough(bool fallback = true) {
|
||||
return Get().vrPassthrough.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool VrStopAtDisplayCopy(bool fallback = true) {
|
||||
return Get().vrStopAtDisplayCopy.value_or(fallback);
|
||||
}
|
||||
@@ -1350,6 +1557,10 @@ inline bool VrFirstPerson(bool fallback = false) {
|
||||
return Get().vrFirstPerson.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool VrFirstPersonToggleClick(bool fallback = true) {
|
||||
return Get().vrFirstPersonToggleClick.value_or(fallback);
|
||||
}
|
||||
|
||||
inline float VrFirstPersonUnitsPerMeter(float fallback = kVrFirstPersonUnitsPerMeterDefault) {
|
||||
return std::clamp(Get().vrFirstPersonUnitsPerMeter.value_or(fallback), 1.0f, 10000.0f);
|
||||
}
|
||||
@@ -1419,6 +1630,47 @@ inline int32_t VrFirstPersonHiddenModel(int32_t fallback = kVrFirstPersonHiddenM
|
||||
return std::clamp(Get().vrFirstPersonHiddenModel.value_or(fallback), -1, 31);
|
||||
}
|
||||
|
||||
inline std::string VrFirstPersonSeat(std::string fallback = kVrFirstPersonSeatDefault) {
|
||||
const auto& value = Get().vrFirstPersonSeat;
|
||||
return value && IsSupportedVrFirstPersonSeat(*value) ? *value : std::move(fallback);
|
||||
}
|
||||
|
||||
inline float VrCockpitUnitsPerMeter(float fallback = kVrCockpitUnitsPerMeterDefault) {
|
||||
return std::clamp(Get().vrCockpitUnitsPerMeter.value_or(fallback), kVrCockpitUnitsPerMeterMin,
|
||||
kVrCockpitUnitsPerMeterMax);
|
||||
}
|
||||
|
||||
inline bool VrSteeringWheel(bool fallback = kVrSteeringWheelDefault) {
|
||||
return Get().vrSteeringWheel.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool VrNativeSteeringWheel(bool fallback = kVrNativeSteeringWheelDefault) {
|
||||
return Get().vrNativeSteeringWheel.value_or(fallback);
|
||||
}
|
||||
|
||||
inline bool VrHandSteering(bool fallback = kVrHandSteeringDefault) {
|
||||
return Get().vrHandSteering.value_or(fallback);
|
||||
}
|
||||
|
||||
inline mkw::vr::WheelTuning VrWheelTuning() {
|
||||
const auto& config = Get();
|
||||
mkw::vr::WheelTuning tuning{};
|
||||
tuning.kartDegrees =
|
||||
std::clamp(config.vrWheelKartDegrees.value_or(tuning.kartDegrees), kVrWheelDegreesMin, kVrWheelDegreesMax);
|
||||
tuning.bikeDegrees =
|
||||
std::clamp(config.vrWheelBikeDegrees.value_or(tuning.bikeDegrees), kVrWheelDegreesMin, kVrWheelDegreesMax);
|
||||
tuning.grabDistance = std::clamp(config.vrWheelGrabDistance.value_or(tuning.grabDistance),
|
||||
kVrWheelGrabDistanceMin, kVrWheelGrabDistanceMax);
|
||||
tuning.grabAssist = std::clamp(config.vrWheelGrabAssist.value_or(tuning.grabAssist), kVrWheelGrabAssistMin,
|
||||
kVrWheelGrabAssistMax);
|
||||
tuning.response =
|
||||
std::clamp(config.vrWheelResponse.value_or(tuning.response), kVrWheelResponseMin, kVrWheelResponseMax);
|
||||
tuning.trackingGrace = std::clamp(config.vrWheelTrackingGrace.value_or(tuning.trackingGrace),
|
||||
kVrWheelTrackingGraceMin, kVrWheelTrackingGraceMax);
|
||||
tuning.haptics = config.vrWheelHaptics.value_or(tuning.haptics);
|
||||
return tuning;
|
||||
}
|
||||
|
||||
inline std::string GraphicsApi(std::string fallback = "auto") {
|
||||
return Get().graphicsApi.value_or(std::move(fallback));
|
||||
}
|
||||
|
||||
@@ -16,6 +16,10 @@ void AdvancePresentedFrame() noexcept;
|
||||
// converted with the world scale currently in effect, so switching the
|
||||
// first-person camera on or off has to repeat it.
|
||||
void RefreshVrHudVirtualScreen() noexcept;
|
||||
// Flips the first-person camera exactly as its F10 checkbox does, on the game
|
||||
// thread at the next frame. Callable from any thread (the VR controllers'
|
||||
// right-thumbstick click).
|
||||
void RequestFirstPersonToggle() noexcept;
|
||||
// Put host controllers back to a neutral state before the process ends.
|
||||
void ReleaseControllers() noexcept;
|
||||
} // namespace settings_overlay
|
||||
@@ -0,0 +1,44 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
// The right thumbstick click that toggles the first-person camera, on the VR
|
||||
// controllers and on any gamepad while VR runs (F10 > VR >
|
||||
// first_person_toggle_click). Kept free of OpenXR and SDL so it is tested
|
||||
// headlessly (tests/vr_camera_toggle_tests.cpp).
|
||||
|
||||
namespace mkw::vr {
|
||||
|
||||
// One clean click of a button: it fires on release, and only if its partner
|
||||
// (the other thumbstick, which with it opens the headset settings panel on a
|
||||
// gamepad) stayed up and nothing owned the controllers at any point during the
|
||||
// press. Firing on release is what lets a two-stick chord pass untouched.
|
||||
class ClickToggle {
|
||||
public:
|
||||
bool Update(bool held, bool partner_held, bool blocked) noexcept {
|
||||
if (held) {
|
||||
if (!held_) {
|
||||
held_ = true;
|
||||
spoiled_ = partner_held || blocked;
|
||||
} else if (partner_held || blocked) {
|
||||
spoiled_ = true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const bool fire = held_ && !spoiled_ && !blocked;
|
||||
held_ = false;
|
||||
spoiled_ = false;
|
||||
return fire;
|
||||
}
|
||||
void Reset() noexcept {
|
||||
held_ = false;
|
||||
spoiled_ = false;
|
||||
}
|
||||
bool Held() const noexcept { return held_; }
|
||||
|
||||
private:
|
||||
bool held_ = false;
|
||||
bool spoiled_ = false;
|
||||
};
|
||||
|
||||
} // namespace mkw::vr
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
#pragma once
|
||||
#include "vr/mkw_vr_first_person.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace mkw::vr {
|
||||
// Simulation position and driving direction, never the animated vehicle matrix.
|
||||
// Follow the simulation position exactly; stabilize only impact orientation.
|
||||
class CockpitStabilizer {
|
||||
public:
|
||||
Mtx34 Update(const Mtx34& simulation, bool damaged, float dt) {
|
||||
const float yaw = std::atan2(simulation[2], simulation[10]);
|
||||
const float dx = simulation[3]-position_[0], dy = simulation[7]-position_[1], dz = simulation[11]-position_[2];
|
||||
if (!valid_ || dx*dx+dy*dy+dz*dz > 1500.0f*1500.0f) {
|
||||
position_ = {simulation[3],simulation[7],simulation[11]};
|
||||
yaw_ = yaw; valid_ = true; recovering_ = false;
|
||||
}
|
||||
if (damaged) recovering_ = true;
|
||||
else if (recovering_) {
|
||||
const float alpha = 1.0f-std::exp(-8.0f*std::clamp(dt,0.0f,0.05f));
|
||||
const float delta = std::remainder(yaw-yaw_,6.283185307f);
|
||||
yaw_ += delta*alpha;
|
||||
if (std::abs(delta)<0.002f) recovering_=false;
|
||||
} else {
|
||||
position_={simulation[3],simulation[7],simulation[11]}; yaw_=yaw;
|
||||
}
|
||||
// Freezing/blending translation left the seat behind after collisions.
|
||||
// Dynamics excludes visual shake; retain its exact kart attachment.
|
||||
position_={simulation[3],simulation[7],simulation[11]};
|
||||
const float c=std::cos(yaw_),s=std::sin(yaw_);
|
||||
return {c,0,s,position_[0], 0,1,0,position_[1], -s,0,c,position_[2]};
|
||||
}
|
||||
private:
|
||||
std::array<float,3> position_{};
|
||||
float yaw_=0;
|
||||
bool valid_=false,recovering_=false;
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "vr/steering_wheel.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
@@ -40,12 +43,35 @@ enum class FirstPersonRotation : uint8_t {
|
||||
Full,
|
||||
};
|
||||
|
||||
// Where the first-person head is placed.
|
||||
enum class FirstPersonSeat : uint8_t {
|
||||
// At the driver's own eyes, measured from the character's model and kept
|
||||
// behind the steering wheel, at a life-size cockpit scale. The wheel or
|
||||
// handlebar is then within reach of the player's hands.
|
||||
Cockpit,
|
||||
// The free first_person_head_*_meters offsets at first_person_units_per_meter.
|
||||
Custom,
|
||||
};
|
||||
|
||||
// The camera relocation published to Aurora for one guest frame: a transform
|
||||
// from the game's recorded view space into the space the headset renders from.
|
||||
struct FirstPersonAnchor {
|
||||
Mtx34 anchor_from_scene = kIdentityMtx34;
|
||||
bool valid = false;
|
||||
uint64_t guest_frame_index = 0;
|
||||
// The rest describes the cockpit seat and is left empty by the custom seat.
|
||||
bool cockpit = false;
|
||||
// World units per metre the anchor was built with (character and player
|
||||
// scale included).
|
||||
float units_per_meter = 0.0f;
|
||||
// The vehicle's steering wheel or handlebar, in metres in the seated frame
|
||||
// (+X right, +Y up, -Z forward, origin at the head).
|
||||
WheelGeometry native_wheel{};
|
||||
bool bike = false;
|
||||
// The vehicle's own wheel is being animated in the scene this frame.
|
||||
bool native_mesh_prepared = false;
|
||||
// Changes whenever the player's vehicle object does.
|
||||
uint64_t vehicle_identity = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -137,6 +163,241 @@ inline Vec3 TransformPoint(const Mtx34& matrix, float x, float y, float z) noexc
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cockpit seat and steering-wheel geometry. Ported from heurazy's
|
||||
// mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
inline Mtx34 ComposeMtx(const Mtx34& a, const Mtx34& b) noexcept {
|
||||
Mtx34 out{};
|
||||
for (int row = 0; row < 3; ++row) {
|
||||
for (int col = 0; col < 4; ++col) {
|
||||
out[row * 4 + col] = col == 3 ? a[row * 4 + 3] : 0.0f;
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
out[row * 4 + col] += a[row * 4 + k] * b[k * 4 + col];
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
inline bool InvertMtx(const Mtx34& m, Mtx34& out) noexcept {
|
||||
if (!detail::IsFiniteMtx34(m)) {
|
||||
return false;
|
||||
}
|
||||
const detail::Vec3 a{m[0], m[4], m[8]}, b{m[1], m[5], m[9]}, c{m[2], m[6], m[10]};
|
||||
const auto x = detail::Cross(b, c), y = detail::Cross(c, a), z = detail::Cross(a, b);
|
||||
const float det = detail::Dot(a, x);
|
||||
if (!detail::IsFiniteFloat(&det) || std::abs(det) < 1e-6f) {
|
||||
return false;
|
||||
}
|
||||
out = {x.x / det, x.y / det, x.z / det, 0, y.x / det, y.y / det, y.z / det, 0, z.x / det, z.y / det, z.z / det, 0};
|
||||
for (int row = 0; row < 3; ++row) {
|
||||
out[row * 4 + 3] = -(out[row * 4] * m[3] + out[row * 4 + 1] * m[7] + out[row * 4 + 2] * m[11]);
|
||||
}
|
||||
return detail::IsFiniteMtx34(out);
|
||||
}
|
||||
|
||||
// Keeps the eye behind the steering wheel or handlebar even when a long face or
|
||||
// a leaned-forward riding animation puts the character's eyes over it. Units
|
||||
// are the vehicle's; `radius` is the control's half width.
|
||||
inline float EyeBehindControls(float eyeForward, float controlsForward, float units, float radius) noexcept {
|
||||
const float clearance = std::clamp(0.40f + radius / units * 0.3f, 0.45f, 0.65f) * units;
|
||||
return std::min(eyeForward, controlsForward - clearance);
|
||||
}
|
||||
|
||||
// Tall characters sit higher; normalise them to a comfortable perceived cockpit
|
||||
// height by growing the world scale with the measured eye height.
|
||||
inline float CharacterCockpitScale(float eyeHeight) noexcept {
|
||||
if (!detail::IsFiniteFloat(&eyeHeight)) {
|
||||
return 1.0f;
|
||||
}
|
||||
return std::clamp(eyeHeight / 100.0f, 1.0f, 2.5f);
|
||||
}
|
||||
|
||||
inline float ValidPlayerScale(float scale) noexcept {
|
||||
return detail::IsFiniteFloat(&scale) && scale >= 0.1f && scale <= 4.0f ? scale : 1.0f;
|
||||
}
|
||||
|
||||
inline Mtx34 ScaleModelBasis(Mtx34 pose, const std::array<float, 3>& scale) noexcept {
|
||||
for (int row = 0; row < 3; ++row) {
|
||||
for (int col = 0; col < 3; ++col) {
|
||||
pose[row * 4 + col] *= scale[col];
|
||||
}
|
||||
}
|
||||
return pose;
|
||||
}
|
||||
|
||||
inline bool NeutralPlayerScale(const std::array<float, 3>& scale) noexcept {
|
||||
for (float value : scale) {
|
||||
if (!detail::IsFiniteFloat(&value) || std::abs(value - 1.0f) > 0.001f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Eye position resources are in the face bone's local coordinates, whose axes
|
||||
// differ between characters. Transform their centre through the complete bind
|
||||
// matrix before applying the vehicle-specific driver placement.
|
||||
inline bool ComputeDriverEyeFromBounds(const Mtx34& face, const Mtx34& placement, detail::Vec3 minimum,
|
||||
detail::Vec3 maximum, std::array<float, 3>& eye) noexcept {
|
||||
if (!detail::IsFiniteMtx34(face) || !detail::IsFiniteMtx34(placement)) {
|
||||
return false;
|
||||
}
|
||||
const std::array<float, 6> bounds{minimum.x, minimum.y, minimum.z, maximum.x, maximum.y, maximum.z};
|
||||
for (const auto& value : bounds) {
|
||||
if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (minimum.x > maximum.x || minimum.y > maximum.y || minimum.z > maximum.z) {
|
||||
return false;
|
||||
}
|
||||
const auto model = detail::TransformPoint(face, (minimum.x + maximum.x) * 0.5f, (minimum.y + maximum.y) * 0.5f,
|
||||
(minimum.z + maximum.z) * 0.5f);
|
||||
const auto seat = detail::TransformPoint(placement, model.x, model.y, model.z);
|
||||
const std::array<float, 3> result{seat.x, seat.y, seat.z};
|
||||
for (const auto& value : result) {
|
||||
if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (seat.y < 5.0f) {
|
||||
return false;
|
||||
}
|
||||
eye = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Removes the visible vehicle's world transform from the evaluated head pose.
|
||||
// This retains the riding posture, but never imports kart motion into the seat.
|
||||
inline bool ComputeSeatedEye(const Mtx34& faceWorld, const Mtx34& bodyWorld, detail::Vec3 eyeLocal,
|
||||
std::array<float, 3>& eye) noexcept {
|
||||
if (!detail::IsFiniteMtx34(faceWorld) || !detail::IsFiniteMtx34(bodyWorld)) {
|
||||
return false;
|
||||
}
|
||||
const detail::Vec3 a{bodyWorld[0], bodyWorld[4], bodyWorld[8]}, b{bodyWorld[1], bodyWorld[5], bodyWorld[9]},
|
||||
c{bodyWorld[2], bodyWorld[6], bodyWorld[10]};
|
||||
const auto bc = detail::Cross(b, c), ca = detail::Cross(c, a), ab = detail::Cross(a, b);
|
||||
const float det = detail::Dot(a, bc);
|
||||
if (!detail::IsFiniteFloat(&det) || std::abs(det) < 1e-6f) {
|
||||
return false;
|
||||
}
|
||||
const auto world = detail::TransformPoint(faceWorld, eyeLocal.x, eyeLocal.y, eyeLocal.z);
|
||||
const detail::Vec3 delta{world.x - bodyWorld[3], world.y - bodyWorld[7], world.z - bodyWorld[11]};
|
||||
const std::array<float, 3> result{detail::Dot(bc, delta) / det, detail::Dot(ca, delta) / det,
|
||||
detail::Dot(ab, delta) / det};
|
||||
for (const auto& value : result) {
|
||||
if (!detail::IsFiniteFloat(&value) || std::abs(value) > 500.0f) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (result[1] < 5.0f) {
|
||||
return false;
|
||||
}
|
||||
eye = result;
|
||||
return true;
|
||||
}
|
||||
|
||||
// The neutral seated eye, accepted once eight consecutive safe samples agree
|
||||
// within two units, then frozen until the driver or the race changes.
|
||||
struct SeatedEyeReference {
|
||||
std::array<float, 3> value{}, candidate{};
|
||||
unsigned stable = 0;
|
||||
bool valid = false;
|
||||
void Observe(const std::array<float, 3>& sample, bool safe, bool freeze) {
|
||||
if (freeze && valid) {
|
||||
return;
|
||||
}
|
||||
if (!safe) {
|
||||
stable = 0;
|
||||
return;
|
||||
}
|
||||
float delta = 0.0f;
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
delta = std::max(delta, std::abs(sample[i] - candidate[i]));
|
||||
}
|
||||
stable = stable && delta < 2.0f ? stable + 1 : 1;
|
||||
candidate = sample;
|
||||
if (stable >= 8) {
|
||||
value = sample;
|
||||
valid = true;
|
||||
stable = 8;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Neutral authored hand targets, transformed by the stabilised cockpit body. Do
|
||||
// not use the animated hand IK targets: feeding their steering rotation back
|
||||
// into the controller angle would make the input chase its own animation.
|
||||
// `seat_from_body` maps vehicle-local units into the seated frame in units;
|
||||
// the result is in metres.
|
||||
inline WheelGeometry ComputeNativeWheelGeometry(const Mtx34& seat_from_body, detail::Vec3 left, detail::Vec3 right,
|
||||
float units) noexcept {
|
||||
WheelGeometry out{};
|
||||
if (!detail::IsFiniteMtx34(seat_from_body) || !detail::IsFiniteFloat(&units) || units <= 0.0f) {
|
||||
return out;
|
||||
}
|
||||
if (left.x > right.x) {
|
||||
std::swap(left, right);
|
||||
}
|
||||
const auto a = detail::TransformPoint(seat_from_body, left.x, left.y, left.z);
|
||||
const auto b = detail::TransformPoint(seat_from_body, right.x, right.y, right.z);
|
||||
detail::Vec3 x{b.x - a.x, b.y - a.y, b.z - a.z};
|
||||
const float radius = std::sqrt(detail::Dot(x, x)) / (2.0f * units);
|
||||
if (!detail::IsFiniteFloat(&radius) || radius < 0.04f || radius > 1.0f || !detail::Normalize(x)) {
|
||||
return out;
|
||||
}
|
||||
// Kart +X points left when looking along its +Z driving direction.
|
||||
// WheelHand uses headset +X (right), so reverse the authored lateral axis.
|
||||
x = {-x.x, -x.y, -x.z};
|
||||
detail::Vec3 y{seat_from_body[1], seat_from_body[5], seat_from_body[9]};
|
||||
const float projection = detail::Dot(x, y);
|
||||
y = {y.x - x.x * projection, y.y - x.y * projection, y.z - x.z * projection};
|
||||
if (!detail::Normalize(y)) {
|
||||
return out;
|
||||
}
|
||||
const auto z = detail::Cross(x, y);
|
||||
out.center = {(a.x + b.x) / (2.0f * units), (a.y + b.y) / (2.0f * units), (a.z + b.z) / (2.0f * units)};
|
||||
for (const auto& value : out.center) {
|
||||
if (!detail::IsFiniteFloat(&value) || std::abs(value) > 5.0f) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
out.right = {x.x, x.y, x.z};
|
||||
out.up = {y.x, y.y, y.z};
|
||||
out.normal = {z.x, z.y, z.z};
|
||||
out.radius = radius;
|
||||
out.valid = true;
|
||||
return out;
|
||||
}
|
||||
|
||||
inline WheelGeometry ComputeNativeHandlebarGeometry(const Mtx34& seatFromHandle, const Mtx34& seatFromBody,
|
||||
detail::Vec3 left, detail::Vec3 right, float units) noexcept {
|
||||
auto out = ComputeNativeWheelGeometry(seatFromHandle, left, right, units);
|
||||
if (!out.valid || !detail::IsFiniteMtx34(seatFromBody)) {
|
||||
return {};
|
||||
}
|
||||
// Use the body's neutral axes, not the already-steered handle's axes.
|
||||
// Otherwise the visual steering feeds back into the next input sample.
|
||||
detail::Vec3 x{-seatFromBody[0], -seatFromBody[4], -seatFromBody[8]},
|
||||
forward{seatFromBody[2], seatFromBody[6], seatFromBody[10]};
|
||||
if (!detail::Normalize(x)) {
|
||||
return {};
|
||||
}
|
||||
const float along = detail::Dot(forward, x);
|
||||
forward = {forward.x - along * x.x, forward.y - along * x.y, forward.z - along * x.z};
|
||||
if (!detail::Normalize(forward)) {
|
||||
return {};
|
||||
}
|
||||
const auto vertical = detail::Cross(x, forward);
|
||||
out.right = {x.x, x.y, x.z};
|
||||
out.up = {forward.x, forward.y, forward.z};
|
||||
out.normal = {vertical.x, vertical.y, vertical.z};
|
||||
return out;
|
||||
}
|
||||
|
||||
// Builds the anchor from the game's view matrix (world -> recorded view space),
|
||||
// the kart's pose (kart-local -> world), and head offsets already converted to
|
||||
// world units.
|
||||
|
||||
@@ -71,6 +71,7 @@ inline constexpr uint32_t kMkwVRRequiredImmersiveBindings =
|
||||
|
||||
struct MkwVRPolicyConfig {
|
||||
bool enabled = false;
|
||||
// False is Flat Screen mode: races stay on the virtual screen the menus use.
|
||||
bool immersive_races = true;
|
||||
float world_units_per_meter = 500.0f;
|
||||
float hud_distance_meters = 2.0f;
|
||||
@@ -151,6 +152,9 @@ void MkwVRPolicySetFirstPersonEngaged(bool engaged) noexcept;
|
||||
// MkwVRPolicyConfigure so the F10 slider can retune it during a race without
|
||||
// republishing (and revalidating) the whole configuration.
|
||||
void MkwVRPolicySetFirstPersonUnitsPerMeter(float units_per_meter) noexcept;
|
||||
// Flat Screen mode, live from the F10 menu. Unlike the scale above this changes
|
||||
// which content is safe to present, so it advances the safety generation.
|
||||
void MkwVRPolicySetImmersiveRaces(bool enabled) noexcept;
|
||||
MkwVRPolicySnapshot MkwVRPolicyGetSnapshot() noexcept;
|
||||
|
||||
// This classifier is deliberately structural rather than heuristic: future
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
#pragma once
|
||||
#include "vr/mkw_vr_first_person.h"
|
||||
#include <vector>
|
||||
|
||||
namespace mkw::vr {
|
||||
// A number of MKW karts bake the steering wheel into their single body bone.
|
||||
// Find its thin disc around the authored hand targets, including the hub and
|
||||
// spokes, and rotate only that disc. Work on a render copy, never guest assets.
|
||||
inline unsigned RotateNativeWheelVertices(std::vector<detail::Vec3>& points,
|
||||
detail::Vec3 center,float radius,float angle,const Mtx34* bodyCorrection=nullptr) {
|
||||
if (!(radius>4 && radius<100) || !detail::IsFiniteFloat(&angle)) return 0;
|
||||
if(bodyCorrection && !detail::IsFiniteMtx34(*bodyCorrection)) return 0;
|
||||
float meanY=0,meanZ=0; unsigned count=0;
|
||||
const auto candidate=[&](const detail::Vec3& p) {
|
||||
return std::abs(p.x-center.x)<radius*1.5f && std::abs(p.y-center.y)<radius*1.5f &&
|
||||
std::abs(p.z-center.z)<radius*0.9f;
|
||||
};
|
||||
for(const auto& p:points) if(candidate(p)) { meanY+=p.y; meanZ+=p.z; ++count; }
|
||||
if(count<8) return 0;
|
||||
meanY/=count; meanZ/=count;
|
||||
float yy=0,yz=0;
|
||||
for(const auto& p:points) if(candidate(p)) { yy+=(p.y-meanY)*(p.y-meanY); yz+=(p.y-meanY)*(p.z-meanZ); }
|
||||
if(yy<radius*radius) return 0;
|
||||
const float slope=std::clamp(yz/yy,-1.0f,1.0f);
|
||||
center.z=meanZ+slope*(center.y-meanY);
|
||||
const float inv=1/std::sqrt(1+slope*slope);
|
||||
const detail::Vec3 up{0,inv,slope*inv},normal{0,-slope*inv,inv};
|
||||
const float c=std::cos(angle),s=std::sin(angle);
|
||||
unsigned changed=0;
|
||||
for(auto& p:points) {
|
||||
const detail::Vec3 delta{p.x-center.x,p.y-center.y,p.z-center.z};
|
||||
const float x=delta.x,y=detail::Dot(delta,up),z=detail::Dot(delta,normal);
|
||||
if(x*x+y*y>radius*radius*2.25f || std::abs(z)>radius*0.30f) continue;
|
||||
const float rx=c*x-s*y,ry=s*x+c*y;
|
||||
p={center.x+rx,center.y+up.y*ry+normal.y*z,center.z+up.z*ry+normal.z*z};
|
||||
// The body may spin during tricks/damage while the seated reference
|
||||
// stays level. Compensate only the wheel, leaving chassis animation intact.
|
||||
if(bodyCorrection) p=detail::TransformPoint(*bodyCorrection,p.x,p.y,p.z);
|
||||
++changed;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
} // namespace mkw::vr
|
||||
@@ -5,8 +5,11 @@
|
||||
#if defined(MKW_ENABLE_OPENXR)
|
||||
|
||||
#include "vr/openxr_runtime.h"
|
||||
#include "vr/openxr_settings_panel.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace mkw::vr {
|
||||
@@ -42,12 +45,84 @@ enum class OpenXRSubmissionStatus {
|
||||
ShuttingDown,
|
||||
};
|
||||
|
||||
// The headset settings panel as a compositor quad layer of its own, over the
|
||||
// eyes or the menu screen, so the eye resolution never limits its text. Its
|
||||
// image is rendered with the frame's eyes (Aurora's panel stereo target) into a
|
||||
// swapchain of the panel canvas's own size. Nothing is allocated or copied
|
||||
// until the panel first opens, and nothing is submitted while it is closed.
|
||||
struct OpenXRPanelLayer {
|
||||
// The frame renders the panel's image: set by the pacing thread when the
|
||||
// panel is open, cleared by a backend that could not provide the layer.
|
||||
bool requested = false;
|
||||
// Where it hangs in the application space, once the head pose is known.
|
||||
bool placed = false;
|
||||
XrPosef pose{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}};
|
||||
float width_meters = 0.0f;
|
||||
float height_meters = 0.0f;
|
||||
};
|
||||
|
||||
// The panel image's size, which is the settings panel canvas's.
|
||||
inline constexpr uint32_t kOpenXRPanelLayerWidth = static_cast<uint32_t>(kSettingsPanelWidthPixels);
|
||||
inline constexpr uint32_t kOpenXRPanelLayerHeight = static_cast<uint32_t>(kSettingsPanelHeightPixels);
|
||||
|
||||
// The panel's layer, submitted after (so over) the scene's.
|
||||
inline XrCompositionLayerQuad OpenXRPanelQuadLayer(const OpenXRPanelLayer& panel, XrSpace space,
|
||||
XrSwapchain swapchain) noexcept {
|
||||
XrCompositionLayerQuad quad{XR_TYPE_COMPOSITION_LAYER_QUAD};
|
||||
// ImGui leaves premultiplied colour in the cleared panel image.
|
||||
quad.layerFlags = XR_COMPOSITION_LAYER_BLEND_TEXTURE_SOURCE_ALPHA_BIT;
|
||||
quad.space = space;
|
||||
quad.eyeVisibility = XR_EYE_VISIBILITY_BOTH;
|
||||
quad.subImage.swapchain = swapchain;
|
||||
quad.subImage.imageRect = {{0, 0},
|
||||
{static_cast<int32_t>(kOpenXRPanelLayerWidth),
|
||||
static_cast<int32_t>(kOpenXRPanelLayerHeight)}};
|
||||
quad.subImage.imageArrayIndex = 0;
|
||||
quad.pose = panel.pose;
|
||||
quad.size = {panel.width_meters, panel.height_meters};
|
||||
return quad;
|
||||
}
|
||||
|
||||
// The part of the virtual screen's image that holds anything. Aurora
|
||||
// letterboxes the desktop snapshot into that eye-sized image exactly like this
|
||||
// (webgpu::calculate_present_viewport_for_aspect) and, when the settings panel
|
||||
// is drawn into the eyes, centres it at kSettingsPanelWidthFraction of the
|
||||
// width; the rest is black. An unknown content_aspect (0) keeps the whole image.
|
||||
inline XrRect2Di OpenXRVirtualScreenContentRect(uint32_t width, uint32_t height, float content_aspect) noexcept {
|
||||
XrRect2Di rect{{0, 0}, {static_cast<int32_t>(width), static_cast<int32_t>(height)}};
|
||||
if (width == 0 || height == 0 || !(content_aspect > 0.0f)) {
|
||||
return rect;
|
||||
}
|
||||
uint32_t content_width = width;
|
||||
uint32_t content_height = std::min<uint32_t>(
|
||||
height, std::max<uint32_t>(1u, static_cast<uint32_t>(std::lround(
|
||||
static_cast<double>(width) * static_cast<double>(1.0f / content_aspect)))));
|
||||
if (content_height == height) {
|
||||
content_width = std::min<uint32_t>(
|
||||
width, std::max<uint32_t>(1u, static_cast<uint32_t>(std::lround(static_cast<double>(height) *
|
||||
static_cast<double>(content_aspect)))));
|
||||
}
|
||||
const uint32_t panel_width = std::min<uint32_t>(
|
||||
width, static_cast<uint32_t>(std::ceil(static_cast<double>(width) * kSettingsPanelWidthFraction)));
|
||||
const uint32_t panel_height = std::min<uint32_t>(
|
||||
height, static_cast<uint32_t>(std::ceil(static_cast<double>(panel_width) * kSettingsPanelHeightPixels /
|
||||
kSettingsPanelWidthPixels)));
|
||||
const uint32_t shown_width = std::max(content_width, panel_width);
|
||||
const uint32_t shown_height = std::max(content_height, panel_height);
|
||||
rect.offset = {static_cast<int32_t>((width - shown_width) / 2), static_cast<int32_t>((height - shown_height) / 2)};
|
||||
rect.extent = {static_cast<int32_t>(shown_width), static_cast<int32_t>(shown_height)};
|
||||
return rect;
|
||||
}
|
||||
|
||||
struct OpenXRPresentation {
|
||||
OpenXRFrameMode mode = OpenXRFrameMode::ImmersiveProjection;
|
||||
|
||||
// Used only by VirtualScreen.
|
||||
float quad_distance_meters = 2.0f;
|
||||
float quad_width_meters = 2.4f;
|
||||
// The desktop snapshot's width over height, which Aurora letterboxes into
|
||||
// the screen's image (see OpenXRVirtualScreenContentRect); 0 while unknown.
|
||||
float quad_content_aspect = 0.0f;
|
||||
|
||||
// When quad_anchored is set, the quad is placed at quad_pose in the
|
||||
// application reference space and stays put as the player looks around.
|
||||
@@ -56,6 +131,13 @@ struct OpenXRPresentation {
|
||||
// head pose good enough to anchor against.
|
||||
bool quad_anchored = false;
|
||||
XrPosef quad_pose{{0.0f, 0.0f, 0.0f, 1.0f}, {0.0f, 0.0f, 0.0f}};
|
||||
|
||||
// Show the room through the headset's cameras around the virtual screen
|
||||
// (OpenXRPassthrough). Taken when the presentation is handed to the backend,
|
||||
// which starts or pauses the view then; a backend without one ignores it.
|
||||
bool passthrough = false;
|
||||
|
||||
OpenXRPanelLayer panel;
|
||||
};
|
||||
|
||||
struct OpenXRBackendFrame {
|
||||
|
||||
@@ -81,6 +81,16 @@ public:
|
||||
// available, resubmits the retained layer using the current display time.
|
||||
bool FinishFrame(OpenXRD3D12Frame& frame, bool submit_layer);
|
||||
|
||||
// Render-first path when interpolation is off. Aurora renders into acquired
|
||||
// non-retained XR images while no compositor frame is open. BeginFrameForPacket
|
||||
// accepts only a completed packet; CopyRenderedEyes verifies the already queued
|
||||
// bridge copy. FinishFrame releases the images and submits their original poses.
|
||||
OpenXRBeginStatus PreparePacket(const OpenXRPresentation& presentation, OpenXRBackendFrame& packet);
|
||||
bool TryCancelPendingPacket(OpenXRBackendFrame& packet);
|
||||
OpenXRBeginStatus BeginFrameForPacket(const OpenXRBackendFrame& packet, OpenXRBackendFrame& frame);
|
||||
OpenXRSubmissionStatus CopyRenderedEyes(const OpenXRBackendFrame& frame);
|
||||
OpenXRBeginStatus KeepAliveCycle();
|
||||
|
||||
// Call on the XR owner thread after Aurora's worker is idle and before
|
||||
// aurora_shutdown(). Safe to repeat. False means a submitted D3D12 command
|
||||
// could not be fenced; the caller must retain this backend and its runtime
|
||||
@@ -88,6 +98,9 @@ public:
|
||||
bool Shutdown();
|
||||
|
||||
bool IsBound() const;
|
||||
// False once the settings panel's own layer could not be set up; the panel
|
||||
// is then drawn into the eyes again.
|
||||
bool PanelLayerAvailable() const;
|
||||
const OpenXRD3D12GraphicsRequirements& GraphicsRequirements() const;
|
||||
int64_t SwapchainFormat() const;
|
||||
const std::string& LastError() const;
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
// the settings overlay and the headless tests can use it in any build.
|
||||
namespace mkw::vr::diagnostics {
|
||||
|
||||
// Pacing-thread wall time, including scheduling delays. Stages may nest.
|
||||
enum class Stage : uint8_t {
|
||||
PollEvents, BeginCall, LocateViews, InputSync, SyncActions, Publish,
|
||||
Withdraw, SubmissionWait, Cancel, SetTargets, Count
|
||||
};
|
||||
|
||||
enum class EmptyFrameReason : uint8_t {
|
||||
NoRetainedLayer,
|
||||
ShouldRenderOff,
|
||||
@@ -105,7 +111,8 @@ public:
|
||||
void OnInterpolationSkip();
|
||||
void OnPacketPublished(int64_t now_ns);
|
||||
// consumed_ns is when Aurora took the packet, or 0 if it never did.
|
||||
void OnPacketCanceled(int64_t consumed_ns);
|
||||
void OnPacketCanceled(int64_t now_ns, int64_t consumed_ns);
|
||||
void OnStage(Stage stage, int64_t ns, int64_t now_ns);
|
||||
void OnKeepaliveRepeat();
|
||||
void OnSubmission(int64_t now_ns, int64_t consumed_ns, bool success);
|
||||
|
||||
@@ -168,6 +175,18 @@ private:
|
||||
uint32_t events_ = 0;
|
||||
uint32_t suppressed_ = 0;
|
||||
|
||||
struct StageSamples {
|
||||
Samples samples;
|
||||
int64_t worst_ns = -1;
|
||||
int64_t worst_at_ns = 0;
|
||||
uint64_t cycle = 0;
|
||||
};
|
||||
std::array<StageSamples, static_cast<size_t>(Stage::Count)> stages_{};
|
||||
int64_t session_start_ns_ = 0;
|
||||
uint64_t cycle_sequence_ = 0;
|
||||
Samples cancel_age_ms_;
|
||||
Samples cancel_pickup_ms_;
|
||||
Samples cancel_after_pickup_ms_;
|
||||
Samples wait_frame_ms_;
|
||||
Samples open_ms_;
|
||||
Samples margin_ms_;
|
||||
@@ -184,6 +203,7 @@ inline std::atomic_bool g_enabled{false};
|
||||
inline std::atomic_int64_t g_packet_consumed_ns{0};
|
||||
|
||||
int64_t NowNs() noexcept;
|
||||
void OnStage(Stage stage, int64_t ns);
|
||||
void OnWaitFrame(int64_t wait_ns, int64_t display_time, int64_t display_period);
|
||||
void OnBeginFrame();
|
||||
void OnEndFrame(int64_t submit_ns, int64_t call_ns);
|
||||
@@ -233,6 +253,30 @@ private:
|
||||
int64_t start_ns_;
|
||||
};
|
||||
|
||||
// Only use on the XR pacing thread. A diagnostic failure must not interrupt
|
||||
// frame ownership or change the instrumented call's return value.
|
||||
class ScopedStage {
|
||||
public:
|
||||
explicit ScopedStage(Stage stage) noexcept : stage_(stage) {}
|
||||
~ScopedStage() noexcept {
|
||||
const int64_t ns = timer_.ElapsedNs();
|
||||
if (ns >= 0 && Enabled()) {
|
||||
try { detail::OnStage(stage_, ns); } catch (...) {}
|
||||
}
|
||||
}
|
||||
ScopedStage(const ScopedStage&) = delete;
|
||||
ScopedStage& operator=(const ScopedStage&) = delete;
|
||||
private:
|
||||
Stage stage_;
|
||||
Stopwatch timer_;
|
||||
};
|
||||
|
||||
template <typename Call>
|
||||
decltype(auto) Measure(Stage stage, Call&& call) {
|
||||
const ScopedStage timer(stage);
|
||||
return call();
|
||||
}
|
||||
|
||||
inline void OnWaitFrame(const Stopwatch& wait, int64_t display_time, int64_t display_period) {
|
||||
if (const int64_t ns = wait.ElapsedNs(); ns >= 0 && Enabled()) detail::OnWaitFrame(ns, display_time, display_period);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
// Steering wheel and hand steering in the first-person cockpit.
|
||||
//
|
||||
// The OpenXR pacing thread locates the controllers in the seated frame, runs
|
||||
// the SteeringWheel (steering_wheel.h, ported from heurazy's
|
||||
// mario-kart-wii-VR-port) and publishes one DrivingSnapshot per XR frame. The
|
||||
// guest thread reads the latest one to turn the vehicle's own wheel mesh, and
|
||||
// the pacing thread hands the same state to Aurora's cockpit overlay. Nothing
|
||||
// in this header depends on OpenXR, so the guest side builds without it and
|
||||
// the rules below are tested headlessly (tests/vr_hand_steering_tests.cpp).
|
||||
//
|
||||
// The seated frame is the application space re-based on the immersive head
|
||||
// position and turned by the lean-back angle, in metres: +X right, +Y up, -Z
|
||||
// forward. It is the frame the first-person anchor places the vehicle in, so
|
||||
// hands, wheel geometry and eye transforms all meet there.
|
||||
|
||||
#include "vr/openxr_wii_remote.h"
|
||||
#include "vr/steering_wheel.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace mkw::vr {
|
||||
|
||||
// One tracked hand in the seated frame.
|
||||
struct DrivingHand {
|
||||
bool tracked = false;
|
||||
bool held = false;
|
||||
float squeeze = 0.0f;
|
||||
// Row-major 3x4 from the controller's grip space into the seated frame.
|
||||
std::array<float, 12> seat_from_grip{1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f, 0.0f};
|
||||
};
|
||||
|
||||
struct DrivingSnapshot {
|
||||
// The first-person cockpit is engaged and the controllers are mapped into it.
|
||||
bool cockpit_active = false;
|
||||
// Hand steering is on: a squeezed grip near the wheel takes hold of it.
|
||||
bool hand_steering = false;
|
||||
std::array<bool, 2> held{};
|
||||
// The steering the game receives, -1..1: the wheel while a hand holds it,
|
||||
// otherwise the left stick.
|
||||
float steering_input = 0.0f;
|
||||
// What the wheel or handlebar shows, in radians. Positive turns it
|
||||
// clockwise as the driver sees it, i.e. to the right.
|
||||
float visual_angle = 0.0f;
|
||||
std::array<DrivingHand, 2> hands{};
|
||||
// What the cockpit overlay draws: a separate VR wheel or handlebar when the
|
||||
// vehicle's own is not the one turning. `control` places the handlebar
|
||||
// (and, when its geometry is valid, is what the hands reach for).
|
||||
bool synthetic_control = false;
|
||||
bool bike = false;
|
||||
WheelGeometry control{};
|
||||
};
|
||||
|
||||
// Pacing thread publishes; any thread reads the latest. A default snapshot
|
||||
// (nothing held, centred) is returned before the first publication.
|
||||
void OpenXRPublishDriving(const DrivingSnapshot& snapshot) noexcept;
|
||||
DrivingSnapshot OpenXRReadDriving() noexcept;
|
||||
|
||||
namespace driving {
|
||||
|
||||
inline bool IsFinite(float value) noexcept {
|
||||
// Bit test: the runtime may be built with -ffast-math.
|
||||
uint32_t bits = 0;
|
||||
std::memcpy(&bits, &value, sizeof(bits));
|
||||
return (bits & 0x7F800000u) != 0x7F800000u;
|
||||
}
|
||||
|
||||
// The wheel angle at full steering lock, in radians.
|
||||
inline float MaxWheelAngle(bool bike, const WheelTuning& tuning) noexcept {
|
||||
const float degrees = bike ? tuning.bikeDegrees : tuning.kartDegrees;
|
||||
const float clamped = IsFinite(degrees) ? std::clamp(degrees, 20.0f, 180.0f) : (bike ? 45.0f : 90.0f);
|
||||
return clamped * 0.01745329252f;
|
||||
}
|
||||
|
||||
// Grips only grab. As a Wii Remote they press nothing at all (C, the game's
|
||||
// look-behind, is right B); as a gamepad they are the shoulders, so a holding
|
||||
// hand's squeeze is released for the game. The wheel replaces the left stick's
|
||||
// X axis, which both controller modes steer with, and the stick's Y axis keeps
|
||||
// aiming items forwards and backwards.
|
||||
inline void ApplyHandSteering(std::array<wii_remote::HandInputs, 2>& hands, const WheelState& wheel) noexcept {
|
||||
for (size_t hand = 0; hand < hands.size(); ++hand) {
|
||||
if (wheel.held[hand]) {
|
||||
hands[hand].squeeze = 0.0f;
|
||||
}
|
||||
}
|
||||
if ((wheel.held[0] || wheel.held[1]) && IsFinite(wheel.steering)) {
|
||||
hands[0].stick_x = std::clamp(wheel.steering, -1.0f, 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
// The angle the wheel shows. A held wheel shows the hands' own angle; otherwise
|
||||
// it follows the stick at the configured full-lock angle, eased so a flicked
|
||||
// stick does not snap it round.
|
||||
class WheelVisual {
|
||||
public:
|
||||
float Update(bool held, float held_angle, float stick_x, float max_angle, float dt) noexcept {
|
||||
if (!IsFinite(dt)) {
|
||||
dt = 0.0f;
|
||||
}
|
||||
if (held && IsFinite(held_angle)) {
|
||||
angle_ = held_angle;
|
||||
return angle_;
|
||||
}
|
||||
const float stick = IsFinite(stick_x) ? std::clamp(stick_x, -1.0f, 1.0f) : 0.0f;
|
||||
const float target = stick * (IsFinite(max_angle) ? max_angle : 0.0f);
|
||||
angle_ += (target - angle_) * (1.0f - std::exp(-15.0f * std::clamp(dt, 0.0f, 0.1f)));
|
||||
return angle_;
|
||||
}
|
||||
void Reset() noexcept { angle_ = 0.0f; }
|
||||
|
||||
private:
|
||||
float angle_ = 0.0f;
|
||||
};
|
||||
|
||||
// Where the seated frame is: the immersive head position in the application
|
||||
// space, turned about +X by the lean-back angle.
|
||||
struct SeatFrame {
|
||||
bool valid = false;
|
||||
std::array<float, 3> base{};
|
||||
float lean_back_radians = 0.0f;
|
||||
};
|
||||
|
||||
// A pose in the application space (position, then a unit quaternion x, y, z, w)
|
||||
// as a row-major 3x4 in the seated frame: R_lean^T * (p - base) for the
|
||||
// position and R_lean^T * R for the orientation. The inverse of how the eye
|
||||
// transforms place the seated frame (world = base + R_lean * seat).
|
||||
inline std::array<float, 12> SeatFromApp(const SeatFrame& seat, const std::array<float, 3>& position,
|
||||
const std::array<float, 4>& orientation) noexcept {
|
||||
float x = orientation[0], y = orientation[1], z = orientation[2], w = orientation[3];
|
||||
const float length = std::sqrt(x * x + y * y + z * z + w * w);
|
||||
if (IsFinite(length) && length > 1e-6f) {
|
||||
x /= length;
|
||||
y /= length;
|
||||
z /= length;
|
||||
w /= length;
|
||||
} else {
|
||||
x = y = z = 0.0f;
|
||||
w = 1.0f;
|
||||
}
|
||||
const float r[9]{1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
|
||||
2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
|
||||
2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)};
|
||||
const float c = std::cos(seat.lean_back_radians), s = std::sin(seat.lean_back_radians);
|
||||
// R_lean about +X is rows (1,0,0), (0,c,-s), (0,s,c); its transpose applied to v:
|
||||
const auto unlean = [c, s](float vx, float vy, float vz) {
|
||||
return std::array<float, 3>{vx, c * vy + s * vz, -s * vy + c * vz};
|
||||
};
|
||||
std::array<float, 12> out{};
|
||||
for (int col = 0; col < 3; ++col) {
|
||||
const auto column = unlean(r[col], r[3 + col], r[6 + col]);
|
||||
out[col] = column[0];
|
||||
out[4 + col] = column[1];
|
||||
out[8 + col] = column[2];
|
||||
}
|
||||
const auto p = unlean(position[0] - seat.base[0], position[1] - seat.base[1], position[2] - seat.base[2]);
|
||||
out[3] = p[0];
|
||||
out[7] = p[1];
|
||||
out[11] = p[2];
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace driving
|
||||
|
||||
} // namespace mkw::vr
|
||||
@@ -0,0 +1,75 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
#pragma once
|
||||
#include "vr/openxr_runtime.h"
|
||||
#include <aurora/aurora.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace mkw::vr {
|
||||
// AnimalCrossing-VR-MR-Standalone obtains its white hands from this Meta
|
||||
// runtime extension, not from a distributable model asset. Use the same API,
|
||||
// with a procedural fallback on PC runtimes that do not expose Meta meshes.
|
||||
inline bool LoadRuntimeHandMeshes(OpenXRRuntime& runtime) {
|
||||
for (uint32_t h = 0; h < 2; ++h)
|
||||
aurora_set_vr_hand_mesh(h, nullptr, 0, nullptr, 0, nullptr, nullptr, 0);
|
||||
const auto& extensions = runtime.EnabledExtensions();
|
||||
if (std::find(extensions.begin(), extensions.end(), XR_FB_HAND_TRACKING_MESH_EXTENSION_NAME) == extensions.end())
|
||||
return false;
|
||||
PFN_xrCreateHandTrackerEXT create = nullptr;
|
||||
PFN_xrDestroyHandTrackerEXT destroy = nullptr;
|
||||
PFN_xrGetHandMeshFB meshFn = nullptr;
|
||||
xrGetInstanceProcAddr(runtime.Instance(), "xrCreateHandTrackerEXT", reinterpret_cast<PFN_xrVoidFunction*>(&create));
|
||||
xrGetInstanceProcAddr(runtime.Instance(), "xrDestroyHandTrackerEXT", reinterpret_cast<PFN_xrVoidFunction*>(&destroy));
|
||||
xrGetInstanceProcAddr(runtime.Instance(), "xrGetHandMeshFB", reinterpret_cast<PFN_xrVoidFunction*>(&meshFn));
|
||||
if (!create || !destroy || !meshFn) return false;
|
||||
bool any = false;
|
||||
for (uint32_t h = 0; h < 2; ++h) {
|
||||
XrHandTrackerCreateInfoEXT info{XR_TYPE_HAND_TRACKER_CREATE_INFO_EXT};
|
||||
info.hand = h ? XR_HAND_RIGHT_EXT : XR_HAND_LEFT_EXT;
|
||||
info.handJointSet = XR_HAND_JOINT_SET_DEFAULT_EXT;
|
||||
XrHandTrackerEXT tracker = XR_NULL_HANDLE;
|
||||
if (XR_FAILED(create(runtime.Session(), &info, &tracker))) continue;
|
||||
struct Guard { XrHandTrackerEXT tracker; PFN_xrDestroyHandTrackerEXT destroy; ~Guard() { destroy(tracker); } } guard{tracker, destroy};
|
||||
XrHandTrackingMeshFB mesh{XR_TYPE_HAND_TRACKING_MESH_FB};
|
||||
if (XR_FAILED(meshFn(tracker, &mesh)) || mesh.jointCountOutput != 26 ||
|
||||
!mesh.vertexCountOutput || mesh.vertexCountOutput > 65535 || !mesh.indexCountOutput ||
|
||||
mesh.indexCountOutput > 100000 || mesh.indexCountOutput % 3) continue;
|
||||
std::vector<XrPosef> poses(mesh.jointCountOutput);
|
||||
std::vector<float> radii(mesh.jointCountOutput);
|
||||
std::vector<XrHandJointEXT> parents(mesh.jointCountOutput);
|
||||
std::vector<XrVector3f> positions(mesh.vertexCountOutput), normals(mesh.vertexCountOutput);
|
||||
std::vector<XrVector2f> uv(mesh.vertexCountOutput);
|
||||
std::vector<XrVector4sFB> joints(mesh.vertexCountOutput);
|
||||
std::vector<XrVector4f> weights(mesh.vertexCountOutput);
|
||||
std::vector<int16_t> indices(mesh.indexCountOutput);
|
||||
mesh.jointCapacityInput = poses.size(); mesh.jointBindPoses = poses.data();
|
||||
mesh.jointRadii = radii.data(); mesh.jointParents = parents.data();
|
||||
mesh.vertexCapacityInput = positions.size(); mesh.vertexPositions = positions.data();
|
||||
mesh.vertexNormals = normals.data(); mesh.vertexUVs = uv.data();
|
||||
mesh.vertexBlendIndices = joints.data(); mesh.vertexBlendWeights = weights.data();
|
||||
mesh.indexCapacityInput = indices.size(); mesh.indices = indices.data();
|
||||
if (XR_FAILED(meshFn(tracker, &mesh))) continue;
|
||||
std::vector<AuroraVRHandVertex> vertices(positions.size());
|
||||
for (size_t i = 0; i < vertices.size(); ++i) {
|
||||
auto& v = vertices[i];
|
||||
std::memcpy(v.position, &positions[i], sizeof(v.position));
|
||||
std::memcpy(v.joints, &joints[i], sizeof(v.joints));
|
||||
std::memcpy(v.weights, &weights[i], sizeof(v.weights));
|
||||
}
|
||||
std::array<float, 26 * 7> bind{};
|
||||
std::array<int32_t, 26> parentIds{};
|
||||
for (size_t j = 0; j < poses.size(); ++j) {
|
||||
std::memcpy(bind.data() + j * 7, &poses[j], 7 * sizeof(float));
|
||||
parentIds[j] = static_cast<int32_t>(parents[j]);
|
||||
}
|
||||
aurora_set_vr_hand_mesh(h, vertices.data(), vertices.size(),
|
||||
reinterpret_cast<const uint16_t*>(indices.data()), indices.size(), bind.data(), parentIds.data(), poses.size());
|
||||
any = true;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
} // namespace mkw::vr
|
||||
@@ -4,9 +4,12 @@
|
||||
|
||||
#if defined(MKW_ENABLE_OPENXR)
|
||||
|
||||
#include "vr/camera_toggle.h"
|
||||
#include "vr/openxr_driving.h"
|
||||
#include "vr/openxr_runtime.h"
|
||||
#include "vr/openxr_settings_panel.h"
|
||||
#include "vr/openxr_wii_remote.h"
|
||||
#include "vr/steering_wheel.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
@@ -56,6 +59,16 @@ struct OpenXRPointerScreen {
|
||||
// Both are bound for the Oculus Touch profile; khr/simple_controller gets
|
||||
// select/menu and the poses so an unknown runtime still offers something.
|
||||
//
|
||||
// In the first-person cockpit (openxr_driving.h) the grip poses are located in
|
||||
// the seated frame every frame. With hand steering on, a squeezed grip near the
|
||||
// steering wheel or handlebar takes hold of it; while held, the wheel replaces
|
||||
// the left stick's X axis in both presentations and that grip no longer reaches
|
||||
// the game (a shoulder on the gamepad; as a Wii Remote the grips are unbound,
|
||||
// so a hand on the wheel cannot hold down a button).
|
||||
//
|
||||
// A right-thumbstick click on its own toggles the first-person camera, as its
|
||||
// F10 checkbox does (first_person_toggle_click).
|
||||
//
|
||||
// Left Y (both thumbsticks clicked together as a gamepad) opens the in-headset
|
||||
// settings panel (openxr_settings_panel.h). While it is open, and until every
|
||||
// button has been released after it closes, the game sees idle controllers: the
|
||||
@@ -64,8 +77,10 @@ struct OpenXRPointerScreen {
|
||||
// Lifetime: Create after the session exists (attaches the action set, which
|
||||
// OpenXR permits once per session), Sync once per xrWaitFrame, Idle while the
|
||||
// session is not running, Destroy before the session is destroyed. All of them
|
||||
// run on the XR pacing thread; SDL's virtual joystick setters and the Wii
|
||||
// Remote bridge are internally locked, so the game thread may read concurrently.
|
||||
// run on the XR pacing thread. The Wii Remote bridge is internally locked, so
|
||||
// the game thread may read it concurrently, and the virtual gamepad is only
|
||||
// published here: OpenXRApplyVirtualGamepad() performs the SDL writes on the
|
||||
// game thread, keeping SDL's joystick lock off this thread entirely.
|
||||
class OpenXRInput final {
|
||||
public:
|
||||
explicit OpenXRInput(OpenXRLogCallback logger = {});
|
||||
@@ -83,9 +98,13 @@ public:
|
||||
// xrSyncActions + state reads, then publishes to the virtual gamepad and
|
||||
// the Wii Remote bridge. predicted_display_time is the frame's XrTime;
|
||||
// screen is where the Wii Remote pointer can land this frame, and
|
||||
// settings_panel where the settings panel is (its whole rectangle).
|
||||
// settings_panel where the settings panel is (its whole rectangle). seat is
|
||||
// the immersive seated frame, invalid outside an immersive race.
|
||||
void Sync(XrTime predicted_display_time, const OpenXRPointerScreen& screen,
|
||||
const OpenXRPointerScreen& settings_panel);
|
||||
const OpenXRPointerScreen& settings_panel, const driving::SeatFrame& seat);
|
||||
|
||||
// The cockpit state the last Sync published (also OpenXRReadDriving()).
|
||||
const DrivingSnapshot& Driving() const noexcept { return m_driving; }
|
||||
|
||||
// Publishes a remote with nothing held, at rest and not pointing, and stops
|
||||
// the haptics, for frames without focused input.
|
||||
@@ -116,6 +135,11 @@ private:
|
||||
bool withheld);
|
||||
void PublishSettingsPanel(XrTime input_time, const OpenXRPointerScreen& panel,
|
||||
const settings_panel::Frame& frame);
|
||||
// Hand steering: locates the grips in the seated frame, runs the wheel and
|
||||
// hands its steering to `hands` before the game sees them.
|
||||
void UpdateDriving(XrTime display_time, const driving::SeatFrame& seat,
|
||||
std::array<wii_remote::HandInputs, kHands>& hands, bool withheld);
|
||||
void ResetDriving();
|
||||
void UpdateRumble();
|
||||
void StopRumble();
|
||||
bool Check(XrResult result, const char* operation);
|
||||
@@ -149,12 +173,25 @@ private:
|
||||
bool m_haptics_active[kHands]{};
|
||||
uint32_t m_joystick_id = 0; // SDL_JoystickID; 0 when detached
|
||||
void* m_joystick = nullptr; // SDL_Joystick*
|
||||
ClickToggle m_first_person_click;
|
||||
SteeringWheel m_wheel;
|
||||
WheelReferenceLatch m_wheel_reference;
|
||||
driving::WheelVisual m_wheel_visual;
|
||||
std::array<bool, kHands> m_wheel_held{};
|
||||
XrTime m_wheel_time = 0;
|
||||
bool m_wheel_uses_geometry = false;
|
||||
bool m_wheel_bike = false;
|
||||
DrivingSnapshot m_driving{};
|
||||
bool m_created = false;
|
||||
bool m_logged_sync_failure = false;
|
||||
bool m_logged_pointer = false;
|
||||
std::string m_last_error;
|
||||
};
|
||||
|
||||
// Game thread: writes the gamepad the pacing thread last published, if any.
|
||||
// Does nothing when no OpenXR controllers are attached.
|
||||
void OpenXRApplyVirtualGamepad() noexcept;
|
||||
|
||||
} // namespace mkw::vr
|
||||
|
||||
#endif // defined(MKW_ENABLE_OPENXR)
|
||||
@@ -34,6 +34,13 @@ void OpenXRShutdownBeforeAurora() noexcept;
|
||||
void OpenXRServiceProducerFrameBoundary() noexcept;
|
||||
|
||||
bool OpenXRIsRunning() noexcept;
|
||||
|
||||
// Writes the controllers the pacing thread last published to the virtual
|
||||
// gamepad. Call it from the thread that polls controllers, wherever the game
|
||||
// is about to read them: the pacing thread deliberately leaves SDL alone, since
|
||||
// SDL's joystick lock is held for the length of a device enumeration and an
|
||||
// OpenXR frame may not wait that long. Cheap and safe to call when VR is off.
|
||||
void OpenXRApplyControllerState() noexcept;
|
||||
std::string OpenXRLastError();
|
||||
|
||||
// Recenters on where the player is sitting now: it moves the immersive race
|
||||
@@ -53,6 +60,12 @@ void OpenXRRequestRecenter() noexcept;
|
||||
// once per published frame.
|
||||
void OpenXRSetLeanBackDegrees(float degrees) noexcept;
|
||||
|
||||
// Shows the room through the headset's cameras around the menu screen and every
|
||||
// other virtual screen, never during an immersive race. Only the standalone
|
||||
// (Quest) backend offers it; elsewhere this changes nothing. Callable from any
|
||||
// thread; applied on the XR pacing thread's next frame.
|
||||
void OpenXRSetPassthrough(bool enabled) noexcept;
|
||||
|
||||
// Live scene interpolation at the headset's own display deadlines.
|
||||
// 0 = Off, 1 = Auto, otherwise 72/90/120 as a rendering-rate ceiling.
|
||||
void OpenXRSetFrameInterpolationFps(uint32_t target) noexcept;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#if defined(MKW_ENABLE_OPENXR)
|
||||
|
||||
#include "vr/openxr_runtime.h"
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace mkw::vr {
|
||||
|
||||
// The headset's camera view of the room (XR_FB_passthrough) around the virtual
|
||||
// screen, as PPSSPP VR and DolphinXR show it: one reconstruction layer,
|
||||
// submitted before every other layer so they all cover it, with the frame's
|
||||
// blend mode left OPAQUE. Its objects are created the first time the view is
|
||||
// wanted and paused whenever it is not, so a race never keeps the cameras
|
||||
// running. On a Quest the app must also declare com.oculus.feature.PASSTHROUGH
|
||||
// in its manifest, or the runtime accepts every call and composites nothing.
|
||||
//
|
||||
// Not synchronized: every call belongs to the thread that ends the session's
|
||||
// frames, while the session exists. The handles are the session's children:
|
||||
// Destroy() before xrDestroySession, which otherwise frees them itself.
|
||||
class OpenXRPassthrough final {
|
||||
public:
|
||||
explicit OpenXRPassthrough(OpenXRLogCallback logger = {});
|
||||
|
||||
// Starts (creating it on first use) or pauses the view. A runtime without
|
||||
// XR_FB_passthrough, or one that refuses it, is logged once and leaves the
|
||||
// view off until Destroy().
|
||||
void SetRunning(OpenXRRuntime& runtime, bool running);
|
||||
|
||||
// The layer to submit first while the view runs, otherwise null.
|
||||
const XrCompositionLayerBaseHeader* Layer() const noexcept;
|
||||
|
||||
void Destroy() noexcept;
|
||||
|
||||
private:
|
||||
bool Create(OpenXRRuntime& runtime);
|
||||
// gives_up: the failure turns the view off until Destroy().
|
||||
void LogResult(const OpenXRRuntime& runtime, std::string_view operation, XrResult result, bool gives_up) const;
|
||||
void Log(OpenXRLogLevel level, std::string_view message) const noexcept;
|
||||
|
||||
OpenXRLogCallback m_logger;
|
||||
PFN_xrCreatePassthroughFB m_create_passthrough = nullptr;
|
||||
PFN_xrDestroyPassthroughFB m_destroy_passthrough = nullptr;
|
||||
PFN_xrPassthroughStartFB m_start_passthrough = nullptr;
|
||||
PFN_xrPassthroughPauseFB m_pause_passthrough = nullptr;
|
||||
PFN_xrCreatePassthroughLayerFB m_create_layer = nullptr;
|
||||
PFN_xrDestroyPassthroughLayerFB m_destroy_layer = nullptr;
|
||||
PFN_xrPassthroughLayerResumeFB m_resume_layer = nullptr;
|
||||
PFN_xrPassthroughLayerPauseFB m_pause_layer = nullptr;
|
||||
XrPassthroughFB m_passthrough = XR_NULL_HANDLE;
|
||||
XrPassthroughLayerFB m_layer = XR_NULL_HANDLE;
|
||||
XrCompositionLayerPassthroughFB m_composition{XR_TYPE_COMPOSITION_LAYER_PASSTHROUGH_FB};
|
||||
bool m_running = false;
|
||||
bool m_failed = false;
|
||||
};
|
||||
|
||||
} // namespace mkw::vr
|
||||
|
||||
#endif // defined(MKW_ENABLE_OPENXR)
|
||||
@@ -106,6 +106,23 @@ public:
|
||||
OpenXRRuntime(OpenXRRuntime&&) = delete;
|
||||
OpenXRRuntime& operator=(OpenXRRuntime&&) = delete;
|
||||
|
||||
// Same-device Vulkan runtimes may touch Dawn's queue in frame/swapchain
|
||||
// calls. Share Dawn's device guard; never hold it across xrWaitFrame.
|
||||
struct GraphicsQueueGuard {
|
||||
GraphicsQueueGuard(void* value, void (*release)(void*)) : token(value), unlock(release) {}
|
||||
GraphicsQueueGuard(const GraphicsQueueGuard&) = delete;
|
||||
GraphicsQueueGuard& operator=(const GraphicsQueueGuard&) = delete;
|
||||
void* token;
|
||||
void (*unlock)(void*);
|
||||
~GraphicsQueueGuard() { if (token && unlock) unlock(token); }
|
||||
};
|
||||
void SetGraphicsQueueGuard(void* (*lock)(), void (*unlock)(void*)) {
|
||||
m_queue_lock = lock; m_queue_unlock = unlock;
|
||||
}
|
||||
GraphicsQueueGuard LockGraphicsQueue() const {
|
||||
return {m_queue_lock ? m_queue_lock() : nullptr, m_queue_unlock};
|
||||
}
|
||||
|
||||
// Creates the instance, resolves the HMD system, and enumerates the stereo
|
||||
// view configuration. Returns false without terminating the application;
|
||||
// the caller should continue in non-VR mode.
|
||||
@@ -153,6 +170,11 @@ public:
|
||||
OpenXRFrameStatus WaitFrame(OpenXRFrame& frame);
|
||||
bool BeginFrame(const OpenXRFrame& frame);
|
||||
bool LocateViews(OpenXRFrame& frame);
|
||||
// Locates the views for `display_time` outside the frame protocol, for a
|
||||
// packet whose eyes are rendered before the compositor frame that will show
|
||||
// them is begun (the standalone backend's render-first pacing). Fills the
|
||||
// frame's display time, views, flags and validity; requires a running session.
|
||||
bool LocateViewsAt(XrTime display_time, OpenXRFrame& frame);
|
||||
bool EndFrame(
|
||||
const OpenXRFrame& frame,
|
||||
const XrCompositionLayerBaseHeader* const* layers,
|
||||
@@ -209,6 +231,8 @@ public:
|
||||
const OpenXRError& LastError() const { return m_last_error; }
|
||||
|
||||
private:
|
||||
void* (*m_queue_lock)() = nullptr;
|
||||
void (*m_queue_unlock)(void*) = nullptr;
|
||||
enum class FramePhase {
|
||||
Idle,
|
||||
Waited,
|
||||
@@ -224,6 +248,7 @@ private:
|
||||
bool EnumerateSwapchainFormats();
|
||||
bool HandleSessionStateChanged(const XrEventDataSessionStateChanged& event);
|
||||
bool IsFrameTokenCurrent(const OpenXRFrame& frame, FramePhase expected) const;
|
||||
bool LocateViewsForFrame(OpenXRFrame& frame);
|
||||
void ResetFrameState();
|
||||
void DestroyReferenceSpaces();
|
||||
void ResetSessionState();
|
||||
|
||||
@@ -58,12 +58,29 @@ public:
|
||||
bool RepeatFrame(const OpenXRBackendFrame& frame);
|
||||
bool FinishFrame(OpenXRBackendFrame& frame, bool submit_layer);
|
||||
|
||||
// Render-first pacing, used while VR interpolation is off. A packet is prepared with the
|
||||
// views located for an estimated display time and handed to Aurora without a compositor
|
||||
// frame open; once Aurora has rendered the eyes into the shared buffers, the compositor frame
|
||||
// is begun, the eyes are copied into its swapchain images and it is ended at once. A headset
|
||||
// frame therefore never waits for a game frame: it stays open for the copy alone.
|
||||
OpenXRBeginStatus PreparePacket(const OpenXRPresentation& presentation, OpenXRBackendFrame& packet);
|
||||
bool TryCancelPendingPacket(OpenXRBackendFrame& packet);
|
||||
OpenXRBeginStatus BeginFrameForPacket(const OpenXRBackendFrame& packet, OpenXRBackendFrame& frame);
|
||||
OpenXRSubmissionStatus CopyRenderedEyes(const OpenXRBackendFrame& frame);
|
||||
// One compositor cycle that resubmits the retained layer (or nothing), with no frame left
|
||||
// active: keeps the runtime fed while the eyes are still being rendered and learns the
|
||||
// display timing the next packet is located for.
|
||||
OpenXRBeginStatus KeepAliveCycle();
|
||||
|
||||
// Drains this backend's own queue before tearing down. Returns false only
|
||||
// when the private device could not be waited on, in which case the caller
|
||||
// retains the backend and runtime for the process lifetime.
|
||||
bool Shutdown();
|
||||
|
||||
bool IsBound() const;
|
||||
// False once the settings panel's own layer could not be set up; the panel
|
||||
// is then drawn into the eyes again.
|
||||
bool PanelLayerAvailable() const;
|
||||
const OpenXRVulkanGraphicsRequirements& GraphicsRequirements() const;
|
||||
int64_t SwapchainFormat() const;
|
||||
const std::string& LastError() const;
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#if defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
|
||||
|
||||
#include "vr/openxr_backend.h"
|
||||
#include "vr/openxr_runtime.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace mkw::vr {
|
||||
|
||||
// Shared frame vocabulary for the Windows pacing and replay paths.
|
||||
using OpenXRWindowsVulkanFrameMode = OpenXRFrameMode;
|
||||
using OpenXRWindowsVulkanBeginStatus = OpenXRBeginStatus;
|
||||
using OpenXRWindowsVulkanSubmissionStatus = OpenXRSubmissionStatus;
|
||||
using OpenXRWindowsVulkanPresentation = OpenXRPresentation;
|
||||
using OpenXRWindowsVulkanFrame = OpenXRBackendFrame;
|
||||
|
||||
struct OpenXRWindowsVulkanGraphicsRequirements {
|
||||
XrVersion min_api_version = 0;
|
||||
XrVersion max_api_version = 0;
|
||||
};
|
||||
|
||||
// Same-device Dawn/OpenXR Vulkan backend.
|
||||
//
|
||||
// Startup is deliberately split in two. QueryGraphicsRequirements() runs
|
||||
// after OpenXRRuntime::Initialize() but before aurora_initialize(), allowing
|
||||
// OpenXR to create Dawn's Vulkan instance/device and select the physical GPU.
|
||||
// BindAurora() runs afterwards and checks the device before creating a session.
|
||||
//
|
||||
// All methods from BeginFrame() through FinishFrame(), plus PollEvents on the
|
||||
// associated OpenXRRuntime, belong to one XR pacing thread. Aurora's frame
|
||||
// worker never calls OpenXR: its post-submit callback only publishes a token
|
||||
// that WaitForSubmission() consumes. This is the synchronization boundary
|
||||
// required by the asynchronous sealed-frame renderer.
|
||||
class OpenXRWindowsVulkanBackend final {
|
||||
public:
|
||||
explicit OpenXRWindowsVulkanBackend(OpenXRLogCallback logger = {});
|
||||
~OpenXRWindowsVulkanBackend();
|
||||
|
||||
OpenXRWindowsVulkanBackend(const OpenXRWindowsVulkanBackend&) = delete;
|
||||
OpenXRWindowsVulkanBackend& operator=(const OpenXRWindowsVulkanBackend&) = delete;
|
||||
OpenXRWindowsVulkanBackend(OpenXRWindowsVulkanBackend&&) = delete;
|
||||
OpenXRWindowsVulkanBackend& operator=(OpenXRWindowsVulkanBackend&&) = delete;
|
||||
|
||||
bool QueryGraphicsRequirements(OpenXRRuntime& runtime);
|
||||
bool BindAurora(OpenXRRuntime& runtime);
|
||||
|
||||
OpenXRWindowsVulkanBeginStatus BeginFrame(const OpenXRWindowsVulkanPresentation& presentation,
|
||||
OpenXRWindowsVulkanFrame& frame);
|
||||
|
||||
// timeout_ms == UINT32_MAX waits until Aurora publishes this token or
|
||||
// Shutdown() interrupts the wait. A timeout does not release XR images;
|
||||
// the caller may keep pacing with RepeatFrame while Aurora still owns them.
|
||||
OpenXRWindowsVulkanSubmissionStatus WaitForSubmission(const OpenXRWindowsVulkanFrame& frame,
|
||||
uint32_t timeout_ms = UINT32_MAX);
|
||||
|
||||
// Withdraws this token only if Aurora has not encoded it. On success no GPU
|
||||
// command can reference the acquired images, and FinishFrame(frame, false)
|
||||
// is required to release them and close the compositor frame.
|
||||
bool TryCancelPendingFrame(OpenXRWindowsVulkanFrame& frame);
|
||||
|
||||
// Ends the current compositor cycle with the last completed layer and starts
|
||||
// another, without releasing or changing Aurora's pending images/render token.
|
||||
// The original render poses remain attached to the pending and retained images.
|
||||
bool RepeatFrame(const OpenXRWindowsVulkanFrame& frame);
|
||||
|
||||
// Releases acquired images and calls xrEndFrame. submit_layer must only be
|
||||
// true after WaitForSubmission returned Success. Immersive frames submit
|
||||
// XrCompositionLayerProjection; virtual-screen frames submit an
|
||||
// XrCompositionLayerQuad using the single mono target, placed as the
|
||||
// presentation's quad_anchored/quad_pose describe. If no new usable layer is
|
||||
// available, resubmits the retained layer using the current display time.
|
||||
bool FinishFrame(OpenXRWindowsVulkanFrame& frame, bool submit_layer);
|
||||
|
||||
// Render-first path when interpolation is off. Aurora renders into acquired
|
||||
// non-retained XR images while no compositor frame is open. BeginFrameForPacket
|
||||
// accepts only a completed packet; CopyRenderedEyes verifies the already queued
|
||||
// bridge copy. FinishFrame releases the images and submits their original poses.
|
||||
OpenXRBeginStatus PreparePacket(const OpenXRPresentation& presentation, OpenXRBackendFrame& packet);
|
||||
bool TryCancelPendingPacket(OpenXRBackendFrame& packet);
|
||||
OpenXRBeginStatus BeginFrameForPacket(const OpenXRBackendFrame& packet, OpenXRBackendFrame& frame);
|
||||
OpenXRSubmissionStatus CopyRenderedEyes(const OpenXRBackendFrame& frame);
|
||||
OpenXRBeginStatus KeepAliveCycle();
|
||||
|
||||
// Call on the XR owner thread after Aurora's worker is idle and before
|
||||
// aurora_shutdown(). Safe to repeat. False means a submitted Vulkan command
|
||||
// could not be fenced; the caller must retain this backend and its runtime
|
||||
// for the process lifetime instead of destroying possibly live resources.
|
||||
bool Shutdown();
|
||||
|
||||
bool IsBound() const;
|
||||
// False once the settings panel's own layer could not be set up; the panel
|
||||
// is then drawn into the eyes again.
|
||||
bool PanelLayerAvailable() const;
|
||||
const OpenXRWindowsVulkanGraphicsRequirements& GraphicsRequirements() const;
|
||||
int64_t SwapchainFormat() const;
|
||||
const std::string& LastError() const;
|
||||
|
||||
private:
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> m_impl;
|
||||
};
|
||||
|
||||
} // namespace mkw::vr
|
||||
|
||||
#endif // defined(MKW_ENABLE_OPENXR) && defined(_WIN32)
|
||||
@@ -48,6 +48,9 @@ OpenXRControllerMode OpenXRGetControllerMode() noexcept;
|
||||
// Guest side. True when `sdl_joystick_id` is the OpenXR virtual gamepad and the
|
||||
// controllers are currently presented as a Wii Remote.
|
||||
bool OpenXRWiiRemoteOwnsGamepad(uint32_t sdl_joystick_id) noexcept;
|
||||
// True when `sdl_joystick_id` is the VR controllers' virtual gamepad, in either
|
||||
// presentation.
|
||||
bool OpenXRIsControllerGamepad(uint32_t sdl_joystick_id) noexcept;
|
||||
// Latest published sample; false before the first one or after withdrawal.
|
||||
bool OpenXRReadWiiRemote(OpenXRWiiRemoteSample& sample) noexcept;
|
||||
// WPADControlMotor for the emulated remote.
|
||||
@@ -339,10 +342,12 @@ struct HandInputs {
|
||||
|
||||
// Adapted from DolphinXR's default "OpenXR Wii Remote" profile
|
||||
// (Data/Sys/Profiles/Wiimote):
|
||||
// right A -> A, right trigger -> B, right stick up/down -> 1/2,
|
||||
// right A -> A, right trigger -> B, right B -> C, right stick up/down -> 1/2,
|
||||
// left X -> -, left menu -> +,
|
||||
// left grip -> C, left trigger -> Z, left stick -> Nunchuk stick.
|
||||
// left trigger -> Z, left stick -> Nunchuk stick.
|
||||
// HOME has no button; left Y opens the settings panel (openxr_settings_panel.h).
|
||||
// The grips press nothing: they take hold of the wheel (openxr_driving.h), and C
|
||||
// is the game's look-behind, which a hand on the wheel would otherwise hold down.
|
||||
inline uint32_t RemoteButtons(const HandInputs& left, const HandInputs& right) noexcept {
|
||||
uint32_t hold = 0;
|
||||
const auto press = [&hold](bool held, uint32_t bit) {
|
||||
@@ -352,11 +357,11 @@ inline uint32_t RemoteButtons(const HandInputs& left, const HandInputs& right) n
|
||||
};
|
||||
press(right.primary, kButtonA);
|
||||
press(right.trigger > kPressThreshold, kButtonB);
|
||||
press(right.secondary, kButtonC);
|
||||
press(right.stick_y > kPressThreshold, kButtonOne);
|
||||
press(right.stick_y < -kPressThreshold, kButtonTwo);
|
||||
press(left.primary, kButtonMinus);
|
||||
press(left.menu, kButtonPlus);
|
||||
press(left.squeeze > kPressThreshold, kButtonC);
|
||||
press(left.trigger > kPressThreshold, kButtonZ);
|
||||
return hold;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
#include "vr/openxr_d3d12.h"
|
||||
#include "vr/openxr_vulkan_win32.h"
|
||||
#include <variant>
|
||||
namespace mkw::vr {
|
||||
// Runtime API selection; both backends expose the same pacing/ownership contract.
|
||||
class OpenXRWindowsBackend {
|
||||
std::variant<std::unique_ptr<OpenXRD3D12Backend>, std::unique_ptr<OpenXRWindowsVulkanBackend>> backend_;
|
||||
public:
|
||||
OpenXRWindowsBackend(OpenXRLogCallback logger, bool vulkan) {
|
||||
if (vulkan) backend_ = std::make_unique<OpenXRWindowsVulkanBackend>(logger);
|
||||
else backend_ = std::make_unique<OpenXRD3D12Backend>(logger);
|
||||
}
|
||||
const OpenXRD3D12GraphicsRequirements& GraphicsRequirements() {
|
||||
return std::get<0>(backend_)->GraphicsRequirements();
|
||||
}
|
||||
bool QueryGraphicsRequirements(OpenXRRuntime& runtime) {
|
||||
return std::visit([&](auto& backend) -> bool { return backend->QueryGraphicsRequirements(runtime); }, backend_);
|
||||
}
|
||||
bool BindAurora(OpenXRRuntime& runtime) {
|
||||
return std::visit([&](auto& backend) -> bool { return backend->BindAurora(runtime); }, backend_);
|
||||
}
|
||||
OpenXRBeginStatus BeginFrame(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame) {
|
||||
return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->BeginFrame(presentation, frame); }, backend_);
|
||||
}
|
||||
OpenXRSubmissionStatus WaitForSubmission(const OpenXRBackendFrame& frame, uint32_t timeout) {
|
||||
return std::visit([&](auto& backend) -> OpenXRSubmissionStatus { return backend->WaitForSubmission(frame, timeout); }, backend_);
|
||||
}
|
||||
bool TryCancelPendingFrame(OpenXRBackendFrame& frame) {
|
||||
return std::visit([&](auto& backend) -> bool { return backend->TryCancelPendingFrame(frame); }, backend_);
|
||||
}
|
||||
bool RepeatFrame(const OpenXRBackendFrame& frame) {
|
||||
return std::visit([&](auto& backend) -> bool { return backend->RepeatFrame(frame); }, backend_);
|
||||
}
|
||||
bool FinishFrame(OpenXRBackendFrame& frame, bool submit) {
|
||||
return std::visit([&](auto& backend) -> bool { return backend->FinishFrame(frame, submit); }, backend_);
|
||||
}
|
||||
OpenXRBeginStatus PreparePacket(const OpenXRPresentation& presentation, OpenXRBackendFrame& frame) {
|
||||
return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->PreparePacket(presentation, frame); }, backend_);
|
||||
}
|
||||
bool TryCancelPendingPacket(OpenXRBackendFrame& frame) {
|
||||
return std::visit([&](auto& backend) -> bool { return backend->TryCancelPendingPacket(frame); }, backend_);
|
||||
}
|
||||
OpenXRBeginStatus BeginFrameForPacket(const OpenXRBackendFrame& packet, OpenXRBackendFrame& frame) {
|
||||
return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->BeginFrameForPacket(packet, frame); }, backend_);
|
||||
}
|
||||
OpenXRSubmissionStatus CopyRenderedEyes(const OpenXRBackendFrame& frame) {
|
||||
return std::visit([&](auto& backend) -> OpenXRSubmissionStatus { return backend->CopyRenderedEyes(frame); }, backend_);
|
||||
}
|
||||
OpenXRBeginStatus KeepAliveCycle() {
|
||||
return std::visit([&](auto& backend) -> OpenXRBeginStatus { return backend->KeepAliveCycle(); }, backend_);
|
||||
}
|
||||
bool Shutdown() {
|
||||
return std::visit([&](auto& backend) -> bool { return backend->Shutdown(); }, backend_);
|
||||
}
|
||||
bool PanelLayerAvailable() {
|
||||
return std::visit([&](auto& backend) -> bool { return backend->PanelLayerAvailable(); }, backend_);
|
||||
}
|
||||
int64_t SwapchainFormat() {
|
||||
return std::visit([&](auto& backend) -> int64_t { return backend->SwapchainFormat(); }, backend_);
|
||||
}
|
||||
const std::string& LastError() {
|
||||
return std::visit([&](auto& backend) -> const std::string& { return backend->LastError(); }, backend_);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Ported from heurazy's mario-kart-wii-VR-port (GPL-3.0-or-later).
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <cstdint>
|
||||
|
||||
namespace mkw::vr {
|
||||
// Metres in a fixed seated frame: +X right, +Y up, -Z forward.
|
||||
struct WheelHand { float x=0, y=0, z=0, squeeze=0; bool tracked=false; };
|
||||
struct WheelGeometry {
|
||||
std::array<float,3> center{}, right{1,0,0}, up{0,1,0}, normal{0,0,1};
|
||||
float radius=0.18f;
|
||||
bool valid=false;
|
||||
WheelHand ToWheel(WheelHand hand) const {
|
||||
const std::array<float,3> p{hand.x-center[0],hand.y-center[1],hand.z-center[2]};
|
||||
const auto dot=[&](const auto& axis) { return p[0]*axis[0]+p[1]*axis[1]+p[2]*axis[2]; };
|
||||
hand.x=dot(right); hand.y=dot(up)-0.30f; hand.z=dot(normal)-0.42f;
|
||||
return hand;
|
||||
}
|
||||
};
|
||||
struct WheelState {
|
||||
float angle=0, steering=0, visualAngle=0;
|
||||
std::array<bool, 2> held{};
|
||||
};
|
||||
class WheelReferenceLatch {
|
||||
WheelGeometry last_{};
|
||||
uint64_t identity_=0;
|
||||
float missing_=0;
|
||||
bool bike_=false;
|
||||
public:
|
||||
bool Resolve(WheelGeometry& geometry, bool enabled, bool available, bool held,
|
||||
bool bike, uint64_t identity, float dt) {
|
||||
if(!enabled || identity_!=identity || bike_!=bike) { last_={};missing_=0; }
|
||||
identity_=identity;bike_=bike;
|
||||
if(!enabled) return false;
|
||||
if(available && geometry.valid) { last_=geometry;missing_=0;return true; }
|
||||
missing_+=std::clamp(dt,0.0f,0.05f);
|
||||
if(held && last_.valid && missing_<0.20f) { geometry=last_;return true; }
|
||||
last_={};return false;
|
||||
}
|
||||
};
|
||||
struct WheelTuning {
|
||||
float kartDegrees=90, bikeDegrees=45, grabDistance=0.35f, grabAssist=1, response=1, trackingGrace=0.20f;
|
||||
bool haptics=true;
|
||||
};
|
||||
class SteeringWheel {
|
||||
public:
|
||||
static constexpr float Radius=0.18f, Height=-0.30f, Depth=-0.42f;
|
||||
WheelState Update(const std::array<WheelHand, 2>& hands, bool active, float dt, float radius=Radius, bool handlebars=false,
|
||||
WheelTuning tuning={}) {
|
||||
const auto finite=[](float value) { uint32_t bits; std::memcpy(&bits,&value,sizeof(bits)); return (bits&0x7f800000u)!=0x7f800000u; };
|
||||
if (!finite(dt)) dt=0;
|
||||
if (!finite(radius) || radius<0.04f || radius>1.0f) { active=false; radius=Radius; }
|
||||
dt=std::clamp(dt, 0.0f, 0.05f);
|
||||
const bool previouslyHeld=state_.held[0]||state_.held[1];
|
||||
float deltaSum=0,weightSum=0;
|
||||
std::array<bool,2> moving{};
|
||||
std::array<bool,2> validHands{};
|
||||
std::array<float,2> delta{};
|
||||
const float degrees=handlebars?tuning.bikeDegrees:tuning.kartDegrees;
|
||||
const float maxAngle=(finite(degrees)?std::clamp(degrees,20.0f,180.0f):(handlebars?45.0f:90.0f))*0.01745329252f;
|
||||
const float assist=finite(tuning.grabAssist)?std::clamp(tuning.grabAssist,0.7f,2.0f):1.0f;
|
||||
const float reach=finite(tuning.grabDistance)?std::clamp(tuning.grabDistance,0.15f,0.8f):0.35f;
|
||||
const float grace=finite(tuning.trackingGrace)?std::clamp(tuning.trackingGrace,0.05f,0.5f):0.2f;
|
||||
for (int h=0; h<2; ++h) {
|
||||
const auto& p=hands[h];
|
||||
const bool valid=p.tracked&&finite(p.x)&&finite(p.y)&&finite(p.z)&&finite(p.squeeze);
|
||||
validHands[h]=valid;
|
||||
const bool down=finite(p.squeeze)&&p.squeeze > (pressed_[h] ? 0.15f : 0.55f);
|
||||
if(!valid) {
|
||||
lost_[h]+=dt;
|
||||
if(!p.tracked && down && active && state_.held[h] && lost_[h]<grace) {
|
||||
center_[h]=true;
|
||||
} else { state_.held[h]=false; pressed_[h]=down; }
|
||||
continue;
|
||||
}
|
||||
lost_[h]=0;
|
||||
const float y=p.y-Height, z=p.z-Depth;
|
||||
const float radial=std::hypot(p.x,y);
|
||||
const float gripX=radius*std::cos(state_.angle),gripY=-radius*std::sin(state_.angle);
|
||||
const bool near_rim=std::abs(z)<reach && (handlebars
|
||||
? std::min(std::hypot(p.x-gripX,y-gripY),std::hypot(p.x+gripX,y+gripY))<std::max(0.22f,radius*0.55f)*assist
|
||||
: radial<std::max(radius+0.16f,0.32f)*assist);
|
||||
const float angle=-std::atan2(y,p.x);
|
||||
// Arcade latch: distance only gates acquisition. Once grabbed,
|
||||
// large gestures and vehicle animation cannot release ownership.
|
||||
if (!active || !down)
|
||||
state_.held[h]=false;
|
||||
else if (!pressed_[h] && near_rim) {
|
||||
state_.held[h]=true;
|
||||
last_[h]=angle;
|
||||
center_[h]=false;
|
||||
}
|
||||
if (state_.held[h]) {
|
||||
// Angle is undefined at the hub. Keep ownership and the last
|
||||
// steering value, then rebase on exit to avoid a 180-degree jump.
|
||||
if (radial<(center_[h]?0.065f:0.045f)) center_[h]=true;
|
||||
else {
|
||||
moving[h]=!center_[h];
|
||||
if (moving[h]) delta[h]=std::remainder(angle-last_[h], 6.283185307f);
|
||||
last_[h]=angle;
|
||||
center_[h]=false;
|
||||
}
|
||||
if(moving[h]) {
|
||||
const float weight=std::clamp(radial/0.18f,0.15f,1.0f);
|
||||
deltaSum+=delta[h]*weight; weightSum+=weight;
|
||||
}
|
||||
}
|
||||
pressed_[h]=down;
|
||||
}
|
||||
// Two hands define one rigid control. Their relative angle ignores
|
||||
// shared translations, so leaning or moving both arms does not steer.
|
||||
const float spanX=hands[1].x-hands[0].x,spanY=hands[1].y-hands[0].y;
|
||||
// Pair orientation is defined by the span, even when one hand is near
|
||||
// the original hub after a common translation of both arms.
|
||||
const bool pair=state_.held[0]&&state_.held[1]&&validHands[0]&&validHands[1]&&
|
||||
std::hypot(spanX,spanY)>(pairValid_?0.10f:0.14f);
|
||||
const float pairAngle=pair ? -std::atan2(spanY,spanX):0;
|
||||
float change=weightSum>0 ? deltaSum/weightSum:0;
|
||||
// With both hands held, do not switch to angles about the hub when
|
||||
// their span collapses: that changes the reference frame and creates
|
||||
// false turns as the hands approach/cross each other. Hold the angle
|
||||
// through that singularity and establish a fresh pair baseline on exit.
|
||||
if(state_.held[0] && state_.held[1])
|
||||
change=pair && pairValid_ ? std::remainder(pairAngle-lastPair_,6.283185307f):0;
|
||||
pairValid_=pair; lastPair_=pairAngle;
|
||||
// Rebase a discontinuous tracking pose without sending a full-lock
|
||||
// impulse. Normal fast arcade steering remains inside this envelope.
|
||||
if(std::abs(change)>0.25f+8.0f*dt) change=0;
|
||||
const bool held=state_.held[0]||state_.held[1];
|
||||
if(held && !previouslyHeld) target_=state_.angle;
|
||||
// A single accumulated target avoids jumps when a second hand joins,
|
||||
// leaves, passes through the hub or temporarily loses tracking.
|
||||
// Keep physical overtravel. Clamping this accumulator discards motion
|
||||
// past full lock, so retracing the gesture no longer returns to centre.
|
||||
// Only the game's steering command is saturated, never the hand angle.
|
||||
target_=held ? target_+change:0;
|
||||
const float error=target_-state_.angle;
|
||||
// Quiet near a steady heading, responsive during deliberate turns.
|
||||
const float tuningResponse=finite(tuning.response)?std::clamp(tuning.response,0.5f,2.0f):1;
|
||||
const float response=held ? std::clamp((18.0f+80.0f*std::abs(error))*tuningResponse,9.0f,90.0f):12.0f;
|
||||
state_.angle += error*(1-std::exp(-dt*response));
|
||||
state_.steering=active ? std::clamp(state_.angle/maxAngle,-1.0f,1.0f) : 0;
|
||||
if (!active) { state_.angle=0;target_=0;pairValid_=false; }
|
||||
// Share the validated physical rotation with both renderer paths.
|
||||
// Handlebars keep their limited travel; a kart wheel can turn freely.
|
||||
state_.visualAngle=handlebars ? std::clamp(state_.angle,-maxAngle,maxAngle)
|
||||
: std::remainder(state_.angle,6.283185307f);
|
||||
return state_;
|
||||
}
|
||||
private:
|
||||
WheelState state_{};
|
||||
std::array<bool,2> pressed_{};
|
||||
std::array<bool,2> center_{};
|
||||
std::array<float,2> last_{};
|
||||
std::array<float,2> lost_{};
|
||||
float target_=0,lastPair_=0;
|
||||
bool pairValid_=false;
|
||||
};
|
||||
} // namespace mkw::vr
|
||||
@@ -62,69 +62,84 @@ void InvalidateEfbCopyDestinationsForRange(uint32_t addr, uint32_t size) {
|
||||
// Preserve FIFO ordering: the destroy command is emitted before any later
|
||||
// texture load that can consume the freshly flushed RAM bytes.
|
||||
for (const uint32_t copyAddr : retired) {
|
||||
GXDestroyCopyTex(GuestToHostPtr(copyAddr));
|
||||
GxThread::Post(&GxHostDestroyCopyTex_gx, copyAddr);
|
||||
}
|
||||
}
|
||||
|
||||
void GxHostDestroyCopyTex_gx(uint32_t copyAddr) { GXDestroyCopyTex(GuestToHostPtr(copyAddr)); }
|
||||
|
||||
// ============================================================================
|
||||
// Display Copy Source/Destination
|
||||
// ============================================================================
|
||||
|
||||
extern "C" void GX__SetDispCopySrc_8016f438(uint32_t l, uint32_t t, uint32_t w, uint32_t h) {
|
||||
static void GX__SetDispCopySrc_8016f438_gx(uint32_t l, uint32_t t, uint32_t w, uint32_t h) {
|
||||
GXSetDispCopySrc((u16)l, (u16)t, (u16)w, (u16)h);
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(8016f438, GX__SetDispCopySrc_8016f438, (uint32_t l, uint32_t t, uint32_t w, uint32_t h), (l, t, w, h));
|
||||
GX_DEFERRED_OVERRIDE_VOID(8016f438, GX__SetDispCopySrc_8016f438, (uint32_t l, uint32_t t, uint32_t w, uint32_t h), (l, t, w, h));
|
||||
|
||||
extern "C" void GX__SetDispCopyDst_8016f4b8(uint32_t w, uint32_t h) { GXSetDispCopyDst((u16)w, (u16)h); }
|
||||
PPC_NATIVE_OVERRIDE_VOID(8016f4b8, GX__SetDispCopyDst_8016f4b8, (uint32_t w, uint32_t h), (w, h));
|
||||
static void GX__SetDispCopyDst_8016f4b8_gx(uint32_t w, uint32_t h) { GXSetDispCopyDst((u16)w, (u16)h); }
|
||||
GX_DEFERRED_OVERRIDE_VOID(8016f4b8, GX__SetDispCopyDst_8016f4b8, (uint32_t w, uint32_t h), (w, h));
|
||||
|
||||
// ============================================================================
|
||||
// Texture Copy Source/Destination
|
||||
// ============================================================================
|
||||
|
||||
extern "C" void GX__SetTexCopySrc_8016f478(uint32_t l, uint32_t t, uint32_t w, uint32_t h) {
|
||||
static void GX__SetTexCopySrc_gx(uint32_t l, uint32_t t, uint32_t w, uint32_t h) {
|
||||
GXSetTexCopySrc((u16)l, (u16)t, (u16)w, (u16)h);
|
||||
}
|
||||
extern "C" void GX__SetTexCopySrc_8016f478(uint32_t l, uint32_t t, uint32_t w, uint32_t h) {
|
||||
GxThread::Post(&GX__SetTexCopySrc_gx, l, t, w, h);
|
||||
g_texCopyState.srcLeft=(u16)l; g_texCopyState.srcTop=(u16)t;
|
||||
g_texCopyState.srcWidth=(u16)w; g_texCopyState.srcHeight=(u16)h;
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(8016f478, GX__SetTexCopySrc_8016f478, (uint32_t l, uint32_t t, uint32_t w, uint32_t h), (l, t, w, h));
|
||||
|
||||
extern "C" void GX__SetTexCopyDst_8016f4dc(uint32_t w, uint32_t h, uint32_t f, uint32_t m) {
|
||||
static void GX__SetTexCopyDst_gx(uint32_t w, uint32_t h, uint32_t f, uint32_t m) {
|
||||
GXSetTexCopyDst((u16)w, (u16)h, (GXTexFmt)f, (GXBool)m);
|
||||
}
|
||||
extern "C" void GX__SetTexCopyDst_8016f4dc(uint32_t w, uint32_t h, uint32_t f, uint32_t m) {
|
||||
GxThread::Post(&GX__SetTexCopyDst_gx, w, h, f, m);
|
||||
g_texCopyState.dstWidth=(u16)w; g_texCopyState.dstHeight=(u16)h;
|
||||
g_texCopyState.dstFormat=f; g_texCopyState.dstMipmap=m;
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(8016f4dc, GX__SetTexCopyDst_8016f4dc, (uint32_t w, uint32_t h, uint32_t f, uint32_t m), (w, h, f, m));
|
||||
|
||||
struct GxCopyFilterSnapshot {
|
||||
uint8_t sp[12][2];
|
||||
uint8_t vfb[7];
|
||||
};
|
||||
static void GX__SetCopyFilter_gx(uint32_t aa, uint32_t vf, GxCopyFilterSnapshot filter) {
|
||||
GXSetCopyFilter((GXBool)aa, filter.sp, (GXBool)vf, filter.vfb);
|
||||
}
|
||||
extern "C" void GX__SetCopyFilter_8016fa40(uint32_t aa, uint32_t spa, uint32_t vf, uint32_t vfa) {
|
||||
uint8_t sp[12][2]={}, vfb[7]={};
|
||||
if(spa) std::memcpy(sp, GuestToHostPtr(spa, 24), 24);
|
||||
if(vfa) std::memcpy(vfb, GuestToHostPtr(vfa, 7), 7);
|
||||
GXSetCopyFilter((GXBool)aa, sp, (GXBool)vf, vfb);
|
||||
GxCopyFilterSnapshot filter{};
|
||||
if(spa) std::memcpy(filter.sp, GuestToHostPtr(spa, 24), 24);
|
||||
if(vfa) std::memcpy(filter.vfb, GuestToHostPtr(vfa, 7), 7);
|
||||
GxThread::Post(&GX__SetCopyFilter_gx, aa, vf, filter);
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(8016fa40, GX__SetCopyFilter_8016fa40, (uint32_t aa, uint32_t spa, uint32_t vf, uint32_t vfa), (aa, spa, vf, vfa));
|
||||
|
||||
extern "C" void GX__SetDispCopyGamma_8016fc24(uint32_t g) { GXSetDispCopyGamma((GXGamma)g); }
|
||||
PPC_NATIVE_OVERRIDE_VOID(8016fc24, GX__SetDispCopyGamma_8016fc24, (uint32_t g), (g));
|
||||
static void GX__SetDispCopyGamma_8016fc24_gx(uint32_t g) { GXSetDispCopyGamma((GXGamma)g); }
|
||||
GX_DEFERRED_OVERRIDE_VOID(8016fc24, GX__SetDispCopyGamma_8016fc24, (uint32_t g), (g));
|
||||
|
||||
// ============================================================================
|
||||
// Copy Execution
|
||||
// ============================================================================
|
||||
|
||||
extern "C" void GX__CopyDisp_8016fc38(uint32_t da, uint32_t c) {
|
||||
static void GX__CopyDisp_gx(uint32_t da, uint32_t c) {
|
||||
EnsureAuroraFrameActive();
|
||||
// GX copies are FIFO-ordered on hardware. Drain submitted draws before
|
||||
// resolving the EFB so high-level copies see the same contents.
|
||||
GXDrawDone();
|
||||
GXCopyDisp(GuestToHostPtr(da), (GXBool)c);
|
||||
// No second GXDrawDone here: the frame-worker wait below is for the DONE
|
||||
// phase, which strictly subsumes the drain this call would perform.
|
||||
}
|
||||
extern "C" void GX__CopyDisp_8016fc38(uint32_t da, uint32_t c) {
|
||||
GxThread::Post(&GX__CopyDisp_gx, da, c);
|
||||
++g_gxFrameCount;
|
||||
VI_HLE_SetXfbReady(da);
|
||||
// Present immediately so post-copy draws don't leak into this frame. Join at the DONE phase
|
||||
// (not the cheaper SEALED phase GXDrawDone waits for) because ImGui's draw lists, owned by
|
||||
// Aurora's render worker, replay during encode; aurora_end_frame would join here anyway.
|
||||
aurora_wait_for_frame_worker();
|
||||
// Present immediately so post-copy draws don't leak into this frame. The
|
||||
// overlay draws into the game thread's own ImGui frame, whose draw data
|
||||
// the seal copies, so no frame-worker join is needed here.
|
||||
settings_overlay::Draw();
|
||||
// Seal, pace to the VI retrace boundary (Aurora renders the sealed frame
|
||||
// during the wait), and pre-warm the next frame.
|
||||
@@ -134,14 +149,15 @@ extern "C" void GX__CopyDisp_8016fc38(uint32_t da, uint32_t c) {
|
||||
PPC_NATIVE_OVERRIDE_VOID(8016fc38, GX__CopyDisp_8016fc38, (uint32_t da, uint32_t c), (da, c));
|
||||
|
||||
|
||||
extern "C" void GX__CopyTex_8016fd74(uint32_t da, uint32_t c) {
|
||||
static void GX__CopyTex_gx(uint32_t da, uint32_t c, uint32_t srcLeft, uint32_t srcTop, uint32_t srcWidth,
|
||||
uint32_t srcHeight) {
|
||||
EnsureAuroraFrameActive();
|
||||
// Match GX FIFO ordering: texture copies observe all prior draws.
|
||||
GXDrawDone();
|
||||
const uint16_t rawSrcLeft = g_texCopyState.srcLeft;
|
||||
const uint16_t rawSrcTop = g_texCopyState.srcTop;
|
||||
const uint16_t rawSrcWidth = g_texCopyState.srcWidth;
|
||||
const uint16_t rawSrcHeight = g_texCopyState.srcHeight;
|
||||
const uint16_t rawSrcLeft = (uint16_t)srcLeft;
|
||||
const uint16_t rawSrcTop = (uint16_t)srcTop;
|
||||
const uint16_t rawSrcWidth = (uint16_t)srcWidth;
|
||||
const uint16_t rawSrcHeight = (uint16_t)srcHeight;
|
||||
|
||||
// Keep the source in guest EFB coordinates. Aurora maps it to the scaled
|
||||
// EFB exactly once, matching Dolphin's ConvertEFBRectangle path.
|
||||
@@ -153,9 +169,13 @@ extern "C" void GX__CopyTex_8016fd74(uint32_t da, uint32_t c) {
|
||||
// auto-downloaded, so guest reads see stale RAM; call aurora_flush_efb_copies_to_ram if a
|
||||
// copy needs reading back.
|
||||
GXCopyTex(GuestToHostPtr(da), (GXBool)c);
|
||||
GXSetTexCopySrc(rawSrcLeft, rawSrcTop, rawSrcWidth, rawSrcHeight);
|
||||
}
|
||||
extern "C" void GX__CopyTex_8016fd74(uint32_t da, uint32_t c) {
|
||||
GxThread::Post(&GX__CopyTex_gx, da, c, g_texCopyState.srcLeft, g_texCopyState.srcTop,
|
||||
g_texCopyState.srcWidth, g_texCopyState.srcHeight);
|
||||
RememberEfbCopyDestination(
|
||||
da, GXGetTexBufferSize(g_texCopyState.dstWidth, g_texCopyState.dstHeight,
|
||||
g_texCopyState.dstFormat, GX_FALSE, 0));
|
||||
GXSetTexCopySrc(rawSrcLeft, rawSrcTop, rawSrcWidth, rawSrcHeight);
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(8016fd74, GX__CopyTex_8016fd74, (uint32_t da, uint32_t c), (da, c));
|
||||
@@ -28,9 +28,13 @@ using GxCpDecode::SameVtxAttrFmt;
|
||||
// Small display lists dominate the in-race call count. Cache them as well, but
|
||||
// cap both individual entries and aggregate copied command bytes so malformed
|
||||
// guest input cannot turn this optimization into unbounded host allocation.
|
||||
constexpr uint32_t kDlScanCacheMaxEntryBytes = 64u * 1024u;
|
||||
// The entry cap was 64 KiB: a list above it was never cached and its index
|
||||
// scan ran on every call, which on a Retro Rewind track with large shape lists
|
||||
// was 11% of the game thread on a Quest 3. Only lists that need flattening
|
||||
// store a copy, and the aggregate cap still bounds those.
|
||||
constexpr uint32_t kDlScanCacheMaxEntryBytes = 4u * 1024u * 1024u;
|
||||
constexpr size_t kDlScanCacheMaxEntries = 8192;
|
||||
constexpr size_t kDlScanCacheMaxStoredBytes = 8u * 1024u * 1024u;
|
||||
constexpr size_t kDlScanCacheMaxStoredBytes = 32u * 1024u * 1024u;
|
||||
|
||||
// Display-list write tracking (audit F6a): re-digesting every list every call is the
|
||||
// costliest step of GX__CallDisplayList, and wasted on BRRES shape lists that are written
|
||||
@@ -124,7 +128,7 @@ static inline void AppendLytQuadVertices(uint8_t* packet, uint32_t& pos, float x
|
||||
AppendLytQuadVertex(packet, pos, x0, y1, texCoordAddr, texCoordCount, 16, colors, 2);
|
||||
}
|
||||
|
||||
static bool CanSubmitLytDrawDirect(int texCoordCount, const uint32_t* colors) {
|
||||
static bool CanSubmitLytDrawDirect(int texCoordCount, bool hasColors) {
|
||||
if (texCoordCount < 0 || texCoordCount > 8) {
|
||||
return false;
|
||||
}
|
||||
@@ -135,7 +139,6 @@ static bool CanSubmitLytDrawDirect(int texCoordCount, const uint32_t* colors) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool hasColors = colors != nullptr;
|
||||
const auto& clrFmt = g_hleGxState.vtxAttrFmt[GX_VTXFMT0][GX_VA_CLR0];
|
||||
if (hasColors) {
|
||||
if (g_hleGxState.vtxDesc[GX_VA_CLR0] != GX_DIRECT ||
|
||||
@@ -175,33 +178,30 @@ static bool CanSubmitLytDrawDirect(int texCoordCount, const uint32_t* colors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool SubmitLytDrawDirect(float x0, float y0, float x1, float y1, int texCoordCount,
|
||||
uint32_t texCoordAddr, const uint32_t* colors) {
|
||||
if (!CanSubmitLytDrawDirect(texCoordCount, colors)) {
|
||||
return false;
|
||||
// One nw4r::lyt quad as a GX draw packet (3-byte header, 4 vertices), built on
|
||||
// the game thread from the guest's layout data and submitted on the GX thread,
|
||||
// whose descriptor state decides between the raw-draw fast path and the packet
|
||||
// parser.
|
||||
struct GxLytQuadPacket {
|
||||
uint32_t bytes = 0;
|
||||
int32_t texCoordCount = 0;
|
||||
bool hasColors = false;
|
||||
uint8_t data[3u + 4u * (8u + 4u + 8u * 8u)];
|
||||
};
|
||||
|
||||
static void GxLytQuad_gx(GxLytQuadPacket packet) {
|
||||
if (CanSubmitLytDrawDirect(packet.texCoordCount, packet.hasColors)) {
|
||||
EnsureAuroraFrameActive();
|
||||
ApplyAuroraVtxDesc();
|
||||
ApplyAuroraVtxAttrFmtForDisplayList(GX_VTXFMT0, false);
|
||||
EnsureDefaultGxAlphaCompare();
|
||||
if (aurora::gx::fifo::submit_raw_draw(GX_QUADS, GX_VTXFMT0, packet.data + 3, 4, packet.bytes - 3u)) {
|
||||
GXMarkFrameWork();
|
||||
SyncAppliedVtxStateFromHleReal();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
EnsureAuroraFrameActive();
|
||||
|
||||
ApplyAuroraVtxDesc();
|
||||
|
||||
ApplyAuroraVtxAttrFmtForDisplayList(GX_VTXFMT0, false);
|
||||
|
||||
EnsureDefaultGxAlphaCompare();
|
||||
|
||||
|
||||
std::array<uint8_t, 4u * (8u + 4u + 8u * 8u)> vertices{};
|
||||
uint32_t pos = 0;
|
||||
AppendLytQuadVertices(vertices.data(), pos, x0, y0, x1, y1, texCoordAddr, texCoordCount, colors);
|
||||
|
||||
if (!aurora::gx::fifo::submit_raw_draw(GX_QUADS, GX_VTXFMT0, vertices.data(), 4, pos)) {
|
||||
return false;
|
||||
}
|
||||
GXMarkFrameWork();
|
||||
|
||||
SyncAppliedVtxStateFromHleReal();
|
||||
return true;
|
||||
SubmitLytDrawPacket(packet.data, packet.bytes);
|
||||
}
|
||||
|
||||
static inline void EmitLytDrawQuad(uint32_t posAddr, uint32_t sizeAddr, int texCoordCount,
|
||||
@@ -213,10 +213,6 @@ static inline void EmitLytDrawQuad(uint32_t posAddr, uint32_t sizeAddr, int texC
|
||||
const float x1 = static_cast<float>(x0 + Memory::ReadFloat32(sizeAddr));
|
||||
const float y1 = static_cast<float>(y0 - Memory::ReadFloat32(sizeAddr + 4));
|
||||
|
||||
if (SubmitLytDrawDirect(x0, y0, x1, y1, texCoordCount, texCoordAddr, colors)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// GX has exactly 8 texture coordinates, so nw4r::lyt cannot ask for more.
|
||||
// The fixed packet buffer below is sized for that maximum; bail rather than
|
||||
// overrun it if the guest ever hands us something else.
|
||||
@@ -224,12 +220,15 @@ static inline void EmitLytDrawQuad(uint32_t posAddr, uint32_t sizeAddr, int texC
|
||||
return;
|
||||
}
|
||||
|
||||
std::array<uint8_t, 3u + 4u * (8u + 4u + 8u * 8u)> packet{};
|
||||
GxLytQuadPacket packet{};
|
||||
packet.texCoordCount = texCoordCount;
|
||||
packet.hasColors = colors != nullptr;
|
||||
uint32_t pos = 0;
|
||||
packet[pos++] = GX_DRAW_QUADS_CMD | GX_VTXFMT0;
|
||||
BigEndian::Append16(packet.data(), pos, 4);
|
||||
AppendLytQuadVertices(packet.data(), pos, x0, y0, x1, y1, texCoordAddr, texCoordCount, colors);
|
||||
SubmitLytDrawPacket(packet.data(), pos);
|
||||
packet.data[pos++] = GX_DRAW_QUADS_CMD | GX_VTXFMT0;
|
||||
BigEndian::Append16(packet.data, pos, 4);
|
||||
AppendLytQuadVertices(packet.data, pos, x0, y0, x1, y1, texCoordAddr, texCoordCount, colors);
|
||||
packet.bytes = pos;
|
||||
GxThread::Post(&GxLytQuad_gx, packet);
|
||||
}
|
||||
|
||||
static uint32_t SubmitDLVertex(const uint8_t* ptr, GXVtxFmt vtxfmt, const GXAttrType* sourceVtxDesc) {
|
||||
@@ -1289,7 +1288,8 @@ extern "C" void GxNotifyDisplayListMemoryWrite(uint32_t addr, uint32_t size) {
|
||||
GxGuestWrite::NotifyWrite(addr, size);
|
||||
}
|
||||
|
||||
extern "C" void GX__CallDisplayList_80172f64(uint32_t listAddr, uint32_t nbytes) {
|
||||
// The list itself is read when the GX thread reaches the call, as the GP does.
|
||||
void GX__CallDisplayList_gx(uint32_t listAddr, uint32_t nbytes) {
|
||||
if (nbytes == 0 || listAddr == 0) return;
|
||||
try {
|
||||
const uint8_t* list = static_cast<const uint8_t*>(GuestToHostPtr(listAddr, nbytes));
|
||||
@@ -1483,6 +1483,9 @@ extern "C" void GX__CallDisplayList_80172f64(uint32_t listAddr, uint32_t nbytes)
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
extern "C" void GX__CallDisplayList_80172f64(uint32_t listAddr, uint32_t nbytes) {
|
||||
GxThread::Post(&GX__CallDisplayList_gx, listAddr, nbytes);
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(80172F64, GX__CallDisplayList_80172f64, (uint32_t listAddr, uint32_t nbytes), (listAddr, nbytes));
|
||||
|
||||
extern "C" void nw4r__lyt__detail__DrawQuad_800847c0(CpuContext* ctx) {
|
||||
|
||||
@@ -79,10 +79,10 @@ extern "C" void EGG__LightTexture__SetupTevFinish_HLE_8022e2bc(CpuContext* ctx)
|
||||
if (stageCount != 0) {
|
||||
uint32_t remainder = tevCount % stageCount;
|
||||
if (remainder > 0) {
|
||||
const GXColor black{0, 0, 0, 255};
|
||||
constexpr uint32_t kBlack = 0x000000FFu;
|
||||
while (remainder < stageCount) {
|
||||
GXSetTevColor(static_cast<GXTevRegID>(remainder + 1), black);
|
||||
GXSetTevKColor(static_cast<GXTevKColorID>(remainder), black);
|
||||
GxThread::Post(&GX__SetTevColor_gx, remainder + 1, kBlack);
|
||||
GxThread::Post(&GX__SetTevKColor_gx, remainder, kBlack);
|
||||
++remainder;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "gx_stream_common.h"
|
||||
#include "gx_cp_decode.h"
|
||||
#include "isa/big_endian.h"
|
||||
#include "runtime_log.h"
|
||||
|
||||
// Opcode constants and the stream helpers this file shares with gx_dl.cpp /
|
||||
// gx_vertex.cpp; see gx_stream_common.h.
|
||||
@@ -346,7 +347,9 @@ void SubmitAttribute(GXAttr attr, float* comps, const VtxAttrFmt& fmt, const u32
|
||||
// `val` is a raw big-endian bit pattern: the FIFO stream is type-agnostic, and
|
||||
// the float entry point converts before it gets here.
|
||||
void HleFifoWrite(u32 val, uint32_t sizeBytes) {
|
||||
const bool recordOnly = IsDisplayListActive();
|
||||
// Display-list recording is game-thread state; bytes only reach the GX
|
||||
// thread when nothing is being recorded, so it must not consult it.
|
||||
const bool recordOnly = !GxThread::IsGxThread() && IsDisplayListActive();
|
||||
if (recordOnly) {
|
||||
WriteDisplayListData(val, sizeBytes);
|
||||
return;
|
||||
@@ -480,7 +483,7 @@ void HleFifoWrite(u32 val, uint32_t sizeBytes) {
|
||||
const uint32_t listSize = ReadBE32(data + 5);
|
||||
if (!consumeBytes(9, sink)) break;
|
||||
if (listAddr != 0 && listSize > 0) {
|
||||
GX__CallDisplayList_80172f64(listAddr, listSize);
|
||||
GX__CallDisplayList_gx(listAddr, listSize);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -682,7 +685,8 @@ static uint32_t ApplyFifoPacketsDirect(const uint8_t* data, uint32_t sizeBytes)
|
||||
while (offset < sizeBytes) {
|
||||
// Re-tested per packet, not once per burst: nothing currently re-enters GX HLE mid-walk,
|
||||
// but if it ever does, breaking here just hands the remainder to the ring.
|
||||
if (IsDisplayListActive() || g_hleGxState.inBegin || g_hleGxState.fifoByteCount != 0) {
|
||||
if ((!GxThread::IsGxThread() && IsDisplayListActive()) || g_hleGxState.inBegin ||
|
||||
g_hleGxState.fifoByteCount != 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -775,17 +779,50 @@ static bool WriteDisplayListBurst(const uint8_t* data, uint32_t sizeBytes) {
|
||||
return true;
|
||||
}
|
||||
|
||||
extern "C" void GX_HLE_FIFO_WriteBurst(const uint8_t* data, uint32_t sizeBytes) {
|
||||
if (data == nullptr || sizeBytes == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsDisplayListActive() && WriteDisplayListBurst(data, sizeBytes)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// GX-thread side of the write-gather pipe (or inline when the thread is off).
|
||||
extern "C" void GxFifoConsumeBytes(const uint8_t* data, uint32_t sizeBytes) {
|
||||
const uint32_t applied = ApplyFifoPacketsDirect(data, sizeBytes);
|
||||
if (applied < sizeBytes) {
|
||||
HleFifoWriteBurstChunked(data + applied, sizeBytes - applied);
|
||||
}
|
||||
}
|
||||
|
||||
// Game-thread fronts of the write-gather pipe. Display-list recording is
|
||||
// resolved here (it writes guest memory); everything else is parsed now or
|
||||
// posted to the GX thread as raw bytes, in call order.
|
||||
static inline void GxFifoFrontWrite(u32 val, uint32_t sizeBytes) {
|
||||
if (IsDisplayListActive()) {
|
||||
WriteDisplayListData(val, sizeBytes);
|
||||
return;
|
||||
}
|
||||
if (GxThread::Enabled()) {
|
||||
GxThread::PostFifoWord(val, sizeBytes);
|
||||
return;
|
||||
}
|
||||
HleFifoWrite(val, sizeBytes);
|
||||
}
|
||||
|
||||
extern "C" void GX_HLE_FIFO_WriteFloat(float val) {
|
||||
u32 raw; std::memcpy(&raw, &val, 4);
|
||||
try { GxFifoFrontWrite(raw, 4); } catch (...) { RT_LOGF(RT_TAG_GX, "FIFO write float failed\n"); }
|
||||
}
|
||||
extern "C" void GX_HLE_FIFO_Write32(uint32_t val) { GxFifoFrontWrite(val, 4); }
|
||||
extern "C" void GX_HLE_FIFO_Write16(uint16_t val) { GxFifoFrontWrite(static_cast<u32>(val), 2); }
|
||||
extern "C" void GX_HLE_FIFO_Write8(uint8_t val) { GxFifoFrontWrite(static_cast<u32>(val), 1); }
|
||||
|
||||
extern "C" void GX_HLE_FIFO_WriteBurst(const uint8_t* data, uint32_t sizeBytes) {
|
||||
if (data == nullptr || sizeBytes == 0) {
|
||||
return;
|
||||
}
|
||||
if (IsDisplayListActive()) {
|
||||
if (!WriteDisplayListBurst(data, sizeBytes)) {
|
||||
HleFifoWriteBurstChunked(data, sizeBytes);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (GxThread::Enabled()) {
|
||||
GxThread::PostFifoBytes(data, sizeBytes);
|
||||
return;
|
||||
}
|
||||
GxFifoConsumeBytes(data, sizeBytes);
|
||||
}
|
||||
@@ -68,7 +68,10 @@ void BeginNextAuroraFrameWithRetry(std::chrono::milliseconds timeout) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + timeout;
|
||||
uint32_t attempts = 0;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
UpdateAuroraAndProcessEvents();
|
||||
// SDL is pumped by the window's thread; the GX thread only retries the begin.
|
||||
if (!GxThread::IsGxThread()) {
|
||||
UpdateAuroraAndProcessEvents();
|
||||
}
|
||||
++attempts;
|
||||
if (BeginAuroraFrame()) {
|
||||
g_auroraFrameActive.store(true, std::memory_order_release);
|
||||
|
||||
@@ -5,22 +5,29 @@
|
||||
// Indirect Texture Stages
|
||||
// ============================================================================
|
||||
|
||||
extern "C" void GX__SetNumIndStages_80171b38(uint32_t n) { GXSetNumIndStages((u8)n); }
|
||||
PPC_NATIVE_OVERRIDE_VOID(80171b38, GX__SetNumIndStages_80171b38, (uint32_t n), (n));
|
||||
static void GX__SetNumIndStages_80171b38_gx(uint32_t n) { GXSetNumIndStages((u8)n); }
|
||||
GX_DEFERRED_OVERRIDE_VOID(80171b38, GX__SetNumIndStages_80171b38, (uint32_t n), (n));
|
||||
|
||||
extern "C" void GX__SetIndTexOrder_80171a6c(uint32_t s, uint32_t c, uint32_t m) {
|
||||
static void GX__SetIndTexOrder_80171a6c_gx(uint32_t s, uint32_t c, uint32_t m) {
|
||||
GXSetIndTexOrder((GXIndTexStageID)s, (GXTexCoordID)(c==0xFFu?0:c), (GXTexMapID)(m==0xFFu?0:m));
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(80171a6c, GX__SetIndTexOrder_80171a6c, (uint32_t s, uint32_t c, uint32_t m), (s, c, m));
|
||||
GX_DEFERRED_OVERRIDE_VOID(80171a6c, GX__SetIndTexOrder_80171a6c, (uint32_t s, uint32_t c, uint32_t m), (s, c, m));
|
||||
|
||||
extern "C" void GX__SetIndTexCoordScale_80171968(uint32_t s, uint32_t ss, uint32_t ts) {
|
||||
static void GX__SetIndTexCoordScale_80171968_gx(uint32_t s, uint32_t ss, uint32_t ts) {
|
||||
GXSetIndTexCoordScale((GXIndTexStageID)s, (GXIndTexScale)ss, (GXIndTexScale)ts);
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(80171968, GX__SetIndTexCoordScale_80171968, (uint32_t s, uint32_t ss, uint32_t ts), (s, ss, ts));
|
||||
GX_DEFERRED_OVERRIDE_VOID(80171968, GX__SetIndTexCoordScale_80171968, (uint32_t s, uint32_t ss, uint32_t ts), (s, ss, ts));
|
||||
|
||||
struct GxIndTexMtxSnapshot {
|
||||
float m[6];
|
||||
};
|
||||
static void GX__SetIndTexMtx_gx(uint32_t id, GxIndTexMtxSnapshot mtx, uint32_t se) {
|
||||
GXSetIndTexMtx((GXIndTexMtxID)id, mtx.m, (s8)se);
|
||||
}
|
||||
extern "C" void GX__SetIndTexMtx_80171814(uint32_t id, uint32_t ma, uint32_t se) {
|
||||
float m[6]; for(int i=0; i<6; ++i) m[i]=Memory::ReadFloat32(ma+i*4);
|
||||
GXSetIndTexMtx((GXIndTexMtxID)id, m, (s8)se);
|
||||
GxIndTexMtxSnapshot mtx{};
|
||||
for(int i=0; i<6; ++i) mtx.m[i]=Memory::ReadFloat32(ma+i*4);
|
||||
GxThread::Post(&GX__SetIndTexMtx_gx, id, mtx, se);
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(80171814, GX__SetIndTexMtx_80171814, (uint32_t id, uint32_t ma, uint32_t se), (id, ma, se));
|
||||
|
||||
@@ -28,18 +35,18 @@ PPC_NATIVE_OVERRIDE_VOID(80171814, GX__SetIndTexMtx_80171814, (uint32_t id, uint
|
||||
// TEV Indirect Texture Control
|
||||
// ============================================================================
|
||||
|
||||
extern "C" void GX__SetTevDirect_80171b58(uint32_t s) { GXSetTevDirect((GXTevStageID)s); }
|
||||
PPC_NATIVE_OVERRIDE_VOID(80171b58, GX__SetTevDirect_80171b58, (uint32_t s), (s));
|
||||
static void GX__SetTevDirect_80171b58_gx(uint32_t s) { GXSetTevDirect((GXTevStageID)s); }
|
||||
GX_DEFERRED_OVERRIDE_VOID(80171b58, GX__SetTevDirect_80171b58, (uint32_t s), (s));
|
||||
|
||||
extern "C" void GX__SetTevIndWarp_80171ba0(uint32_t ts, uint32_t is, uint32_t so, uint32_t rm, uint32_t ms) {
|
||||
static void GX__SetTevIndWarp_80171ba0_gx(uint32_t ts, uint32_t is, uint32_t so, uint32_t rm, uint32_t ms) {
|
||||
GXSetTevIndWarp((GXTevStageID)std::min(ts, 15u), (GXIndTexStageID)std::min(is, 3u),
|
||||
(GXBool)so, (GXBool)rm, (GXIndTexMtxID)ms);
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(80171ba0, GX__SetTevIndWarp_80171ba0, (uint32_t ts, uint32_t is, uint32_t so, uint32_t rm, uint32_t ms), (ts, is, so, rm, ms));
|
||||
GX_DEFERRED_OVERRIDE_VOID(80171ba0, GX__SetTevIndWarp_80171ba0, (uint32_t ts, uint32_t is, uint32_t so, uint32_t rm, uint32_t ms), (ts, is, so, rm, ms));
|
||||
|
||||
extern "C" void GX__SetTevIndirect_801717ac(uint32_t ts, uint32_t is, uint32_t f, uint32_t bs, uint32_t ms, uint32_t ws, uint32_t wt, uint32_t ap, uint32_t il, uint32_t as) {
|
||||
static void GX__SetTevIndirect_801717ac_gx(uint32_t ts, uint32_t is, uint32_t f, uint32_t bs, uint32_t ms, uint32_t ws, uint32_t wt, uint32_t ap, uint32_t il, uint32_t as) {
|
||||
GXSetTevIndirect((GXTevStageID)std::min(ts,15u), (GXIndTexStageID)std::min(is,3u), (GXIndTexFormat)f,
|
||||
(GXIndTexBiasSel)bs, (GXIndTexMtxID)ms, (GXIndTexWrap)ws, (GXIndTexWrap)wt,
|
||||
(GXBool)ap, (GXBool)il, (GXIndTexAlphaSel)as);
|
||||
}
|
||||
PPC_NATIVE_OVERRIDE_VOID(801717ac, GX__SetTevIndirect_801717ac, (uint32_t ts, uint32_t is, uint32_t f, uint32_t bs, uint32_t ms, uint32_t ws, uint32_t wt, uint32_t ap, uint32_t il, uint32_t as), (ts, is, f, bs, ms, ws, wt, ap, il, as));
|
||||
GX_DEFERRED_OVERRIDE_VOID(801717ac, GX__SetTevIndirect_801717ac, (uint32_t ts, uint32_t is, uint32_t f, uint32_t bs, uint32_t ms, uint32_t ws, uint32_t wt, uint32_t ap, uint32_t il, uint32_t as), (ts, is, f, bs, ms, ws, wt, ap, il, as));
|
||||
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user