Added foveated rendering for the Quest

- Eyes render under a VK_EXT_fragment_density_map: full rate around each eye's forward direction,
  2x2 then 4x4 pixel blocks towards the edges ([vr] foveation = off|low|medium|high, default off).
  XR_FB_foveation cannot help here: the runtime's maps only shape passes drawing into its
  swapchain, and the eyes reach it through a copy.
- aurora-main/patches/dawn/aurora_fdm.inc: Dawn enables the extension only on request and for
  dynamic rendering, flags every render pipeline, and chains an immutable RG8 map into any pass
  whose first color attachment is a view bound to one (ABI: include/aurora/dawn_fdm_abi.h).
- android/Build-QuestDawn.ps1 builds the pinned Dawn revision with those patches for arm64
  (dawn-build CI flags, protobuf off) into a cached package; Build-Quest.ps1 links it
  (-StockDawn opts out) and AuroraDawnProvider.cmake enables the ABI from its manifest.
- lib/gfx/foveation.hpp generates the maps (32 px per texel, densities 255/127/63); an eye is
  foveated only when single_pass_eyes draws it in one render pass. Menus never are.
- Live level from the headset panel's VR tab and the launcher; the launch decides whether the
  device has maps. debug.wiicompiled.foveation and debug.wiicompiled.fdm for A/B.
- Tests: Foveation cases in gx_fifo_tests, mkw_vr_config_tests. Docs: OPENXR.md, quest-port.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
iChris4andClaude Opus 5.5 committed 2026-09-24 19:03:17 +02:00
1 parent ceeeba0332
commit cc653272c8
30 files changed
+1527 -14

No files matched your search

+12 -1
View File
@@ -15,13 +15,19 @@
# Prerequisites (see docs/quest-port.md): JDK 17, the Android SDK with NDK
# 29.0.14206865 and CMake 3.22.1, and android/Prepare-QuestDependencies.ps1
# having staged the SDL3 AAR.
#
# Dawn is built from source with Aurora's patches (android/Build-QuestDawn.ps1:
# fragment density maps for foveated rendering) and cached under
# .scratch/quest-dawn; the first build takes a while. -StockDawn links the
# stock prebuilt package instead, which leaves foveated rendering unavailable.
[CmdletBinding()]
param(
[string]$Generated = '',
[string]$Dependencies = '',
[string]$CMakeDir = '',
[ValidateSet('debug', 'release')] [string]$Configuration = 'debug',
[switch]$Install
[switch]$Install,
[switch]$StockDawn
)
$ErrorActionPreference = 'Stop'
@@ -79,6 +85,11 @@ $gradleArgs = @(
"-PmkwGeneratedDir=$Generated"
)
if ($Dependencies) { $gradleArgs += "-PmkwDependenciesDir=$Dependencies" }
if (-not $StockDawn) {
$dawnWork = Join-Path $repo '.scratch\quest-dawn'
& (Join-Path $root 'Build-QuestDawn.ps1') -WorkDirectory $dawnWork
$gradleArgs += "-PmkwQuestDawnDir=$(Join-Path $dawnWork 'package')"
}
$gradleArgs += $task
Write-Host "gradlew $($gradleArgs -join ' ')"
& (Join-Path $root 'gradlew.bat') @gradleArgs
+167
View File
@@ -0,0 +1,167 @@
# Builds the pinned Dawn for the Quest (Android arm64) with Aurora's patches (aurora-main/patches/dawn).
# The stock prebuilt package has no fragment density maps, which the headset's foveated rendering
# attaches to the eye render passes. The result is an install tree in the stock package's layout;
# Build-Quest.ps1 hands it to the native build through -PmkwQuestDawnDir.
#
# powershell -ExecutionPolicy Bypass -File android/Build-QuestDawn.ps1 [-WorkDirectory <dir>] [-Force]
#
# The package is cached. Its aurora-dawn.json records the source revision, the patch files' hash, the
# NDK and the flags it was built with, and a later run with the same inputs reuses it. That matters
# beyond build time: every Quest game kit fingerprints the Dawn archive, so a rebuilt archive would
# make players rebuild their games.
#
# Prerequisites: Git, Python 3.12+, CMake 3.25+, and the Android SDK with NDK 29.0.14206865 and CMake
# 3.22.1 (for Ninja). The first build compiles all of Dawn and Tint and takes a while.
[CmdletBinding()]
param(
[string]$WorkDirectory = '',
[string]$Python = 'python',
[int]$Jobs = [Environment]::ProcessorCount,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version 3.0
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$repo = (Resolve-Path (Join-Path $root '..')).Path
if (-not $WorkDirectory) { $WorkDirectory = Join-Path $repo '.scratch\quest-dawn' }
$WorkDirectory = [IO.Path]::GetFullPath($WorkDirectory)
[IO.Directory]::CreateDirectory($WorkDirectory) | Out-Null
# The revision the stock package and the Windows Vulkan DLL (Launcher/Build-DawnVulkan.ps1) are
# built from, and the dawn-build CI's Android configuration.
$revision = '13abc3bc8ea2d3c2050f9e77a12d012108ceee24'
$archiveHash = '713bea5b92d4f6c5175752fd7cbf1c3c5ce36598ff5dd98685d8a1216614ebba'
$ndkVersion = '29.0.14206865'
$platform = 'android-28'
$flags = @(
'-DCMAKE_BUILD_TYPE=Release',
'-DANDROID_ABI=arm64-v8a',
"-DANDROID_PLATFORM=$platform",
'-DANDROID_STL=c++_shared',
'-DDAWN_FETCH_DEPENDENCIES=ON',
'-DDAWN_BUILD_MONOLITHIC_LIBRARY=STATIC',
'-DBUILD_SHARED_LIBS=OFF',
'-DDAWN_ENABLE_INSTALL=ON',
'-DDAWN_BUILD_SAMPLES=OFF',
'-DDAWN_BUILD_TESTS=OFF',
'-DDAWN_BUILD_BENCHMARKS=OFF',
'-DDAWN_USE_GLFW=OFF',
'-DTINT_BUILD_TESTS=OFF',
'-DTINT_BUILD_CMD_TOOLS=OFF',
'-DTINT_BUILD_IR_BINARY=OFF',
# Only the IR binary format needs protobuf, and building it would run the protoc it cross-compiled
# for Android (the CI hands it a host protoc instead).
'-DDAWN_BUILD_PROTOBUF=OFF'
)
$sdkRoot = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' }
$ndk = Join-Path $sdkRoot "ndk\$ndkVersion"
$toolchainFile = Join-Path $ndk 'build\cmake\android.toolchain.cmake'
if (-not (Test-Path $toolchainFile)) { throw "NDK $ndkVersion not found under $sdkRoot (set ANDROID_HOME)" }
$llvmBin = Join-Path $ndk 'toolchains\llvm\prebuilt\windows-x86_64\bin'
$ninja = Get-ChildItem -Path (Join-Path $sdkRoot 'cmake') -Directory -ErrorAction SilentlyContinue |
Sort-Object Name -Descending | ForEach-Object { Join-Path $_.FullName 'bin\ninja.exe' } |
Where-Object { Test-Path $_ } | Select-Object -First 1
if (-not $ninja) { throw "No Ninja under $sdkRoot\cmake; install the SDK's CMake 3.22.1" }
$pythonPath = (Get-Command $Python -ErrorAction Stop).Source
function Get-TextSha256([string]$text) {
$sha = [Security.Cryptography.SHA256]::Create()
try {
$bytes = $sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($text))
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
} finally { $sha.Dispose() }
}
$patchDir = Join-Path $repo 'aurora-main\patches\dawn'
$patchFiles = @(
(Join-Path $patchDir 'apply.py'),
(Join-Path $patchDir 'aurora_vulkan_hooks.h'),
(Join-Path $patchDir 'aurora_vulkan_interop.inc'),
(Join-Path $patchDir 'aurora_fdm.h'),
(Join-Path $patchDir 'aurora_fdm.inc'),
(Join-Path $repo 'aurora-main\include\aurora\dawn_vulkan_abi.h'),
(Join-Path $repo 'aurora-main\include\aurora\dawn_fdm_abi.h')
)
# Line endings vary between checkouts; hash the patch text with LF only.
$patchHash = Get-TextSha256 (($patchFiles | ForEach-Object {
"$([IO.Path]::GetFileName($_))`n$(([IO.File]::ReadAllText($_)).Replace("`r`n", "`n"))"
}) -join "`n")
$cacheKey = Get-TextSha256 "$revision|$archiveHash|$patchHash|$ndkVersion|$($flags -join ' ')"
$package = Join-Path $WorkDirectory 'package'
$manifestPath = Join-Path $package 'aurora-dawn.json'
$library = Join-Path $package 'lib\libwebgpu_dawn.a'
if (-not $Force -and (Test-Path $manifestPath) -and (Test-Path $library)) {
$manifest = [IO.File]::ReadAllText($manifestPath) | ConvertFrom-Json
$libraryHash = (Get-FileHash -LiteralPath $library -Algorithm SHA256).Hash.ToLowerInvariant()
if ($manifest.CacheKey -eq $cacheKey -and $manifest.ArchiveSha256 -eq $libraryHash) {
Write-Host "Patched Dawn for the Quest is up to date: $package"
return
}
}
$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.'
}
# The patches are applied to pristine sources: src/ is extracted again whenever the patches changed,
# while third_party/, which DAWN_FETCH_DEPENDENCIES fills, is kept. A tree already patched with these
# patches is reused, so a flag change does not recompile all of Dawn.
$source = Join-Path $WorkDirectory "dawn-$revision"
$patchMarker = Join-Path $source 'aurora-patches.sha256'
$patched = (Test-Path -LiteralPath $patchMarker) -and ([IO.File]::ReadAllText($patchMarker).Trim() -eq $patchHash)
$extract = @'
import sys, tarfile
archive, destination, only_src = sys.argv[1], sys.argv[2], sys.argv[3] == "1"
with tarfile.open(archive) as tar:
members = tar.getmembers()
if only_src:
members = [m for m in members if m.name.split("/", 2)[1:2] == ["src"]]
tar.extractall(destination, members=members, filter="data")
'@
if (-not $patched) {
$extractScript = Join-Path $WorkDirectory 'extract.py'
[IO.File]::WriteAllText($extractScript, $extract)
$onlySource = Test-Path -LiteralPath $source
if ($onlySource) { Remove-Item -LiteralPath (Join-Path $source 'src') -Recurse -Force -ErrorAction SilentlyContinue }
& $pythonPath $extractScript $archive $WorkDirectory $(if ($onlySource) { '1' } else { '0' })
if ($LASTEXITCODE -ne 0) { throw 'Dawn source extraction failed (Python 3.12+ required).' }
& $pythonPath (Join-Path $patchDir 'apply.py') $source
if ($LASTEXITCODE -ne 0) { throw 'Applying the Aurora Dawn patches failed.' }
[IO.File]::WriteAllText($patchMarker, $patchHash)
}
$build = Join-Path $WorkDirectory 'build'
& cmake -S $source -B $build -G Ninja "-DCMAKE_MAKE_PROGRAM=$ninja" "-DCMAKE_TOOLCHAIN_FILE=$toolchainFile" `
"-DPython3_EXECUTABLE=$pythonPath" @flags "-DCMAKE_INSTALL_PREFIX=$package"
if ($LASTEXITCODE -ne 0) { throw 'Dawn configuration failed.' }
& cmake --build $build --parallel $Jobs
if ($LASTEXITCODE -ne 0) { throw 'Dawn build failed.' }
if (Test-Path -LiteralPath $package) { Remove-Item -LiteralPath $package -Recurse -Force }
& cmake --install $build
if ($LASTEXITCODE -ne 0) { throw 'Dawn installation failed.' }
if (-not (Test-Path -LiteralPath $library)) { throw "Dawn archive missing: $library" }
# As the stock package: debug info would multiply the archive the APK carries in its game kit.
& (Join-Path $llvmBin 'llvm-strip.exe') --strip-debug $library
if ($LASTEXITCODE -ne 0) { throw 'Stripping the Dawn archive failed.' }
$manifestJson = [ordered]@{
SourceRevision = $revision
SourceSha256 = $archiveHash
PatchSha256 = $patchHash
AuroraVulkanAbi = 1
AuroraFdmAbi = 1
Ndk = $ndkVersion
Flags = ($flags -join ' ')
CacheKey = $cacheKey
ArchiveSha256 = (Get-FileHash -LiteralPath $library -Algorithm SHA256).Hash.ToLowerInvariant()
} | ConvertTo-Json
# No byte order mark: CMake's string(JSON) reads this file.
[IO.File]::WriteAllText($manifestPath, $manifestJson, (New-Object Text.UTF8Encoding $false))
Write-Host "Patched Dawn for the Quest ready: $package"
+6
View File
@@ -12,6 +12,9 @@ val mkwGeneratedDir = providers.gradleProperty("mkwGeneratedDir").orNull
// Optional: a directory of already-fetched dependency sources (the installer's
// BuildWorkspace/Dependencies) so the native configure does not download them.
val mkwDependenciesDir = providers.gradleProperty("mkwDependenciesDir").orNull
// Optional: the Dawn package android/Build-QuestDawn.ps1 built with Aurora's patches (fragment density
// maps for foveated rendering). Without it the stock prebuilt Dawn is used and foveation is off.
val mkwQuestDawnDir = providers.gradleProperty("mkwQuestDawnDir").orNull
// Optional: a directory holding a second SDL3 AAR/prefab is not needed; the
// AAR in app/libs is produced by Prepare-QuestDependencies.ps1.
val mkwRepoRoot = rootProject.file("..").canonicalFile
@@ -135,6 +138,9 @@ android {
if (mkwDependenciesDir != null) {
arguments += "-DMKW_DEPENDENCIES_DIR=${File(mkwDependenciesDir).canonicalPath.replace('\\', '/')}"
}
// Always passed, empty when absent, so the native build drops a previous override.
arguments += "-DMKW_QUEST_DAWN_PACKAGE_DIR=" +
(mkwQuestDawnDir?.let { File(it).canonicalPath.replace('\\', '/') } ?: "")
}
}
}
+12
View File
@@ -36,6 +36,18 @@ if(DEFINED MKW_DEPENDENCIES_DIR AND IS_DIRECTORY "${MKW_DEPENDENCIES_DIR}")
endforeach()
endif()
# The Dawn package android/Build-QuestDawn.ps1 built with Aurora's patches (fragment density maps for
# foveated rendering) stands in for the stock prebuilt one. The override lives in this build
# directory's cache, so a build without the package clears it rather than keep the last one.
if(DEFINED MKW_QUEST_DAWN_PACKAGE_DIR AND NOT MKW_QUEST_DAWN_PACKAGE_DIR STREQUAL "")
if(NOT EXISTS "${MKW_QUEST_DAWN_PACKAGE_DIR}/aurora-dawn.json")
message(FATAL_ERROR "No patched Dawn package at ${MKW_QUEST_DAWN_PACKAGE_DIR}; run android/Build-QuestDawn.ps1")
endif()
set(FETCHCONTENT_SOURCE_DIR_DAWN_PREBUILT "${MKW_QUEST_DAWN_PACKAGE_DIR}" CACHE PATH "" FORCE)
else()
unset(FETCHCONTENT_SOURCE_DIR_DAWN_PREBUILT CACHE)
endif()
find_package(SDL3 REQUIRED CONFIG)
set(AURORA_SDL3_PROVIDER system CACHE STRING "" FORCE)
set(AURORA_SDL3_LINKAGE shared CACHE STRING "" FORCE)
@@ -200,6 +200,15 @@ class SettingsPage(
read = { stringIndex(it, "vr", "performance_level", PERFORMANCE_LEVELS) },
write = { c, index -> c.setString("vr", "performance_level", PERFORMANCE_LEVELS[index]) },
)
choice(
R.string.vr_foveation, R.string.vr_foveation_helper,
listOf(
R.string.vr_foveation_off, R.string.vr_foveation_low,
R.string.vr_foveation_medium, R.string.vr_foveation_high,
),
read = { stringIndex(it, "vr", "foveation", FOVEATION_LEVELS) },
write = { c, index -> c.setString("vr", "foveation", FOVEATION_LEVELS[index]) },
)
choice(
R.string.vr_interpolation, R.string.vr_interpolation_helper,
listOf(activity.getString(R.string.vr_interpolation_off), activity.getString(R.string.vr_interpolation_auto), "72 FPS", "90 FPS", "120 FPS"),
@@ -725,6 +734,8 @@ class SettingsPage(
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")
// runtime_config.h's kVrFoveationLevels, the default ("off") first.
val FOVEATION_LEVELS = listOf("off", "low", "medium", "high")
val CONTROLLER_MODES = listOf("wii_remote", "gamepad")
val INTERPOLATION_FPS = listOf(0L, 1L, 72L, 90L, 120L)
val RESOLUTIONS = listOf(1.0, 1.5, 2.0, 3.0, 4.0)
@@ -543,6 +543,12 @@
<string name="vr_performance_level_sustained_low">Sustained low</string>
<string name="vr_performance_level_power_savings">Power savings</string>
<string name="vr_performance_level_default">Headset default</string>
<string name="vr_foveation">Foveated rendering</string>
<string name="vr_foveation_helper">Shades the edges of the race view in coarser blocks, where the lenses blur the picture anyway, to free GPU time for a higher render scale or a steadier frame rate. Higher levels start closer to the centre; High also coarsens the corners of the HUD. Menus are never foveated.</string>
<string name="vr_foveation_off">Off</string>
<string name="vr_foveation_low">Low</string>
<string name="vr_foveation_medium">Medium</string>
<string name="vr_foveation_high">High</string>
<string name="vr_interpolation">VR frame interpolation</string>
<string name="vr_interpolation_helper">Experimental. Renders extra frames between game frames at the headset\'s rate. Needs GPU headroom and adds one game frame of latency.</string>
<string name="vr_interpolation_off">Off</string>