Frame Studio and Frame Home: cross-platform builds

Frame Studio now builds for Windows (x64, x86, ARM64), Linux (x64, x86, ARM64)
and macOS (Apple Silicon, Intel):
- studio/platform.h with Win32 and POSIX implementations (dialogs through
  tinyfiledialogs on Linux/macOS), a GLFW window beside the Win32 one, and a
  platform-neutral frame pacer
- MoltenVK support in the renderer (portability enumeration) and bundled in
  the macOS app
- 32-bit builds: large-address-aware, LZMA packing within a 32-bit address space
- unaligned file reads in the loaders go through loadUnaligned (no UB)
- scripts/build-studio.sh, studio/build_windows.bat [x64|x86|arm64],
  cmake/llvm-mingw.cmake, and a GitHub Actions workflow building every target

Frame Home installs without Studio: frame/install-frame-home.{sh,ps1} and
"Install Frame Home.bat" (packaged as FrameHome-installer.zip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ToastconcernandClaude Opus 5.5 committed 2026-10-21 12:03:25 -05:00
commit f7345a5ac3
1176 files changed
+1634784

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
# Windows scripts keep Windows line endings (cmd.exe misreads others); shell scripts keep Unix ones.
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
*.sh text eol=lf
install.sh text eol=lf
# built files: as they are
*.so binary
*.spv binary
frame/payload/bin/* binary
+107
View File
@@ -0,0 +1,107 @@
# Builds Frame Studio for every computer it runs on, and the Frame Home installer, on each push. Each build is a
# downloadable artifact of the run; a tag "v*" also publishes them all as a GitHub release.
name: build
on:
push:
pull_request:
workflow_dispatch:
jobs:
linux:
name: Linux ${{ matrix.arch }}
strategy:
fail-fast: false
matrix:
include:
- { arch: x64, runner: ubuntu-24.04, extra: "" }
- { arch: x86, runner: ubuntu-24.04, extra: "gcc-multilib g++-multilib" }
- { arch: arm64, runner: ubuntu-24.04-arm, extra: "" }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Tools and X11 headers
run: |
sudo apt-get update
sudo apt-get install -y ninja-build cmake libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libxkbcommon-dev ${{ matrix.extra }}
- name: Build
run: sh scripts/build-studio.sh linux-${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: FrameStudio-linux-${{ matrix.arch }}
path: dist/FrameStudio-linux-${{ matrix.arch }}.tar.gz
windows:
name: Windows ${{ matrix.arch }}
strategy:
fail-fast: false
matrix:
arch: [x64, x86, arm64]
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Build (MSVC)
shell: cmd
run: studio\build_windows.bat ${{ matrix.arch }} "%GITHUB_WORKSPACE%\build\windows-${{ matrix.arch }}"
- name: Package
shell: pwsh
run: |
$name = "FrameStudio-windows-${{ matrix.arch }}"
$stage = "build/stage/$name"
New-Item -ItemType Directory -Force $stage | Out-Null
Copy-Item "build/windows-${{ matrix.arch }}/Frame Studio.exe" $stage
Copy-Item -Recurse "build/windows-${{ matrix.arch }}/frame" "$stage/frame"
New-Item -ItemType Directory -Force dist | Out-Null
Compress-Archive -Path $stage -DestinationPath "dist/$name.zip" -Force
- uses: actions/upload-artifact@v4
with:
name: FrameStudio-windows-${{ matrix.arch }}
path: dist/FrameStudio-windows-${{ matrix.arch }}.zip
macos:
name: macOS ${{ matrix.arch }}
strategy:
fail-fast: false
matrix:
include:
- { arch: arm64, runner: macos-14 }
- { arch: x64, runner: macos-13 }
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Tools and MoltenVK (Vulkan on Metal, bundled into the app)
run: brew install ninja molten-vk
- name: Build
run: sh scripts/build-studio.sh macos-${{ matrix.arch }}
- uses: actions/upload-artifact@v4
with:
name: FrameStudio-macos-${{ matrix.arch }}
path: dist/FrameStudio-macos-${{ matrix.arch }}.zip
frame-home:
name: Frame Home installer
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Package
run: sh scripts/build-studio.sh frame-home
- uses: actions/upload-artifact@v4
with:
name: FrameHome-installer
path: dist/FrameHome-installer.zip
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: [linux, windows, macos, frame-home]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Publish
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "${{ github.ref_name }}" dist/* --repo "${{ github.repository }}" --title "Frame Studio ${{ github.ref_name }}" --generate-notes
+20
View File
@@ -0,0 +1,20 @@
# build output
/build*/
/dist/
studio/build*/
tools/*/build*/
# editor / OS clutter
.vscode/
.idea/
*.swp
.DS_Store
Thumbs.db
# never commit homes, environments or keys
*.apk
*.fenv
*.pem
id_rsa*
id_ed25519*
*:Zone.Identifier
# astcenc's own test framework (not built here)
third_party/astcenc/Source/GoogleTest/
+110
View File
@@ -0,0 +1,110 @@
cmake_minimum_required(VERSION 3.16)
project(frame_home LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
# SteamVR ships the OpenVR client library; link against the runtime's own copy.
set(OPENVR_LIB_DIR "/opt/steamvr/bin/linuxarm64" CACHE PATH "Directory containing libopenvr_api.so")
find_library(OPENVR_LIB NAMES openvr_api PATHS "${OPENVR_LIB_DIR}" NO_DEFAULT_PATH REQUIRED)
find_package(Vulkan REQUIRED)
set(TP "${CMAKE_SOURCE_DIR}/third_party")
# ── Editor loader stack (Quest Home Editor: ASMH/HSTF/RENDMESH/MATL/RENDTXTR/RENDSHAD) ──
add_library(miniz STATIC "${TP}/miniz/miniz.c" "${TP}/miniz/miniz_tdef.c" "${TP}/miniz/miniz_tinfl.c" "${TP}/miniz/miniz_zip.c")
target_include_directories(miniz PUBLIC "${TP}/miniz")
# APKs are signed and intact; re-checksumming every extracted asset was ~20% of load time.
target_compile_definitions(miniz PRIVATE MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS)
add_library(smolv STATIC "${TP}/smolv/smolv.cpp")
target_include_directories(smolv PUBLIC "${TP}/smolv")
# astcenc: decoder for the homes' ASTC textures (LDR + HDR lightmaps).
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(ASTCENC_CLI OFF CACHE BOOL "" FORCE)
set(ASTCENC_DECOMPRESSOR OFF CACHE BOOL "" FORCE)
set(ASTCENC_UNIVERSAL_BUILD OFF CACHE BOOL "" FORCE)
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$")
set(ASTCENC_ISA_NEON ON CACHE BOOL "" FORCE)
set(ASTCENC_LIB astcenc-neon-static)
else()
set(ASTCENC_ISA_SSE41 ON CACHE BOOL "" FORCE)
set(ASTCENC_LIB astcenc-sse4.1-static)
endif()
add_subdirectory("${TP}/astcenc" astcenc EXCLUDE_FROM_ALL)
# LZMA SDK (public domain): the .fenv scene coder (src/fenv_lzma.cpp), with its threaded match finder.
add_library(lzma STATIC "${TP}/lzma/LzmaEnc.c" "${TP}/lzma/LzmaDec.c" "${TP}/lzma/LzFind.c" "${TP}/lzma/LzFindOpt.c"
"${TP}/lzma/LzFindMt.c" "${TP}/lzma/Threads.c" "${TP}/lzma/CpuArch.c")
target_include_directories(lzma PUBLIC "${TP}/lzma")
# The home loader: HSL / Clay / glTF homes, .fenv environments (baked scenes), Blender / Unity models.
add_library(frame-scene STATIC
src/hsl_scene.cpp
src/scene_bake.cpp
src/anim_bake.cpp
src/fenv_lzma.cpp
src/model_import.cpp
"${TP}/ufbx/ufbx.c"
"${TP}/qhe/cook/hzanim_acl.cpp"
"${TP}/stb/stb_image_impl.c")
target_include_directories(frame-scene PUBLIC
src
"${TP}/stb"
"${TP}/qhe"
"${TP}/miniz"
"${TP}/cgltf"
"${TP}/ufbx"
"${TP}/acl/includes"
"${TP}/rtm/includes"
"${TP}/astcenc/Source")
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
set_source_files_properties("${TP}/qhe/cook/hzanim_acl.cpp" PROPERTIES COMPILE_OPTIONS "-flax-vector-conversions")
endif()
target_link_libraries(frame-scene PUBLIC miniz lzma smolv ${ASTCENC_LIB} pthread)
# libdeflate when the system has it (the Frame): APK scene.zips inflate several times faster than with miniz.
find_library(LIBDEFLATE_LIB deflate)
find_path(LIBDEFLATE_INC libdeflate.h)
if(LIBDEFLATE_LIB AND LIBDEFLATE_INC)
target_compile_definitions(frame-scene PUBLIC QHE_HAVE_LIBDEFLATE)
target_include_directories(frame-scene PUBLIC "${LIBDEFLATE_INC}")
target_link_libraries(frame-scene PUBLIC "${LIBDEFLATE_LIB}")
endif()
# Ogg Opus ambience (Clay homes' _BACKGROUND_LOOP.ogg, or a sound added in Frame Studio): Xiph libopus + libogg +
# opusfile (BSD), decode only — as Frame Studio has it, so the headset plays every sound the preview does.
set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(OPUS_INSTALL_PKG_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
set(OPUS_INSTALL_CMAKE_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
add_subdirectory("${TP}/opus" opus EXCLUDE_FROM_ALL)
add_library(ogg STATIC "${TP}/ogg/src/bitwise.c" "${TP}/ogg/src/framing.c")
target_include_directories(ogg PUBLIC "${TP}/ogg/include")
add_library(opusfile STATIC "${TP}/opusfile/src/info.c" "${TP}/opusfile/src/internal.c"
"${TP}/opusfile/src/opusfile.c" "${TP}/opusfile/src/stream.c")
target_include_directories(opusfile PUBLIC "${TP}/opusfile/include" PRIVATE "${TP}/opusfile/src")
target_compile_definitions(opusfile PRIVATE OP_DISABLE_HTTP)
target_link_libraries(opusfile PUBLIC opus ogg)
add_executable(frame-home
src/main.cpp
src/hsl_renderer.cpp
src/panorama.cpp
"${TP}/stb/stb_vorbis_impl.c"
"${TP}/miniaudio/miniaudio_impl.c")
target_include_directories(frame-home PRIVATE "${TP}/openvr" "${TP}/miniaudio")
target_link_libraries(frame-home PRIVATE frame-scene opusfile Vulkan::Vulkan "${OPENVR_LIB}" dl m)
set_target_properties(frame-home PROPERTIES BUILD_RPATH "${OPENVR_LIB_DIR}" INSTALL_RPATH "${OPENVR_LIB_DIR}")
# fenv-tool: convert homes and models into .fenv environments.
add_executable(fenv-tool src/fenv_tool.cpp)
target_link_libraries(fenv-tool PRIVATE frame-scene)
# frame-switcher: the SteamVR dashboard panel that switches the environment.
add_executable(frame-switcher src/switcher.cpp)
target_include_directories(frame-switcher PRIVATE src "${TP}/stb" "${TP}/qhe" "${TP}/miniz" "${TP}/openvr")
target_link_libraries(frame-switcher PRIVATE frame-scene "${OPENVR_LIB}")
set_target_properties(frame-switcher PROPERTIES BUILD_RPATH "${OPENVR_LIB_DIR}" INSTALL_RPATH "${OPENVR_LIB_DIR}")
+62
View File
@@ -0,0 +1,62 @@
# Frame Studio and Frame Home
**Frame Home** makes your homes the Steam Frame's environment: Meta Quest homes (HSL, Clay `.opa`, glTF) and models,
converted into Frame-native `.fenv` environments and drawn in SteamVR's compositor in place of its own.
**Frame Studio** is the desktop editor for them. It opens Meta homes, `.fenv` environments and models (glTF, FBX,
OBJ), shows them with the same renderer the headset runs, and lets you move, hide and delete objects, shape the walkable
area, add sounds and pictures, save environments and install them on the headset.
## Downloads
Every push builds everything (Actions → the latest run → Artifacts); tagged versions are on the Releases page.
| Computer | Frame Studio |
|---|---|
| Windows, 64-bit PCs | `FrameStudio-windows-x64.zip` |
| Windows, 32-bit | `FrameStudio-windows-x86.zip` |
| Windows on ARM (Snapdragon, Surface Pro X…) | `FrameStudio-windows-arm64.zip` |
| Linux, 64-bit PCs | `FrameStudio-linux-x64.tar.gz` |
| Linux, 32-bit | `FrameStudio-linux-x86.tar.gz` |
| Linux on ARM (Raspberry Pi 5, ARM laptops, the Steam Frame's desktop) | `FrameStudio-linux-arm64.tar.gz` |
| Mac with Apple Silicon (M1 and later) | `FrameStudio-macos-arm64.zip` |
| Mac with Intel | `FrameStudio-macos-x64.zip` |
Studio needs a GPU with Vulkan 1.2: any recent NVIDIA, AMD or Intel driver on Windows and Linux; on a Mac, Vulkan runs
on Metal through MoltenVK, which comes inside the app. The 32-bit builds have at most 4 GB to work in: the very
largest homes may not fit.
**Frame Home without Studio:** `FrameHome-installer.zip` installs it on the headset from any computer (or on the
headset itself); see `frame/README-frame-home.txt` inside it.
## Building
Everything Studio needs is in `third_party/` (Vulkan is loaded at runtime). CMake 3.16+ and Ninja.
| On | Command | Builds |
|---|---|---|
| Windows (Visual Studio 2022 with C++) | `studio\build_windows.bat x64` (or `x86`, `arm64`) | Windows |
| Linux | `scripts/build-studio.sh linux-x64` (or `linux-x86`, `linux-arm64` on an ARM machine) | Linux |
| Linux with [llvm-mingw](https://github.com/mstorsjo/llvm-mingw) | `scripts/build-studio.sh windows-x64 windows-x86 windows-arm64` | Windows, cross |
| macOS (Xcode command line tools, `brew install ninja molten-vk`) | `scripts/build-studio.sh macos-arm64 macos-x64` | macOS |
| anywhere | `scripts/build-studio.sh frame-home` | the Frame Home installer |
| anywhere it can | `scripts/build-studio.sh all` | everything this computer can build |
Linux builds need the X11 headers (`libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev`) and, for
`linux-x86`, `gcc-multilib g++-multilib`. Packages land in `dist/`.
The headset side (`frame-home`, `frame-switcher`, `fenv-tool`, the compositor layer) is built on the Steam Frame
itself against SteamVR's own OpenVR library: the top-level `CMakeLists.txt`, then
`g++ -O2 -shared -fPIC -std=c++17 layer/frame_home_layer.cpp -o build/layer/libframe_home_layer.so`. Its ready-built
copy is `frame/payload/`, which every Studio build and the installer carry.
## Layout
| | |
|---|---|
| `studio/` | Frame Studio: `studio.cpp` (the editor), `platform*.{h,cpp}` (Windows / Linux / macOS), `frame_pacer.h` |
| `src/` | shared engine: renderer (`hsl_renderer`), scene loading and baking, `.fenv`, walkable area, Frame Home (`main.cpp`), `fenv-tool` |
| `layer/` | the Vulkan layer inside SteamVR's compositor that shows Frame Home in place of its environment |
| `frame/` | the headset payload and the stand-alone installers |
| `third_party/` | vendored libraries (each with its own license) |
| `tools/` | test and measuring tools (headless renders, walk tests, shader generators) |
+32
View File
@@ -0,0 +1,32 @@
# Cross-building Frame Studio for Windows from Linux with llvm-mingw (https://github.com/mstorsjo/llvm-mingw):
# cmake -S studio -B build/windows-arm64 -DCMAKE_TOOLCHAIN_FILE=cmake/llvm-mingw.cmake -DWINDOWS_ARCH=aarch64 \
# -DLLVM_MINGW=/path/to/llvm-mingw
# WINDOWS_ARCH: x86_64 (64-bit PCs), i686 (32-bit), aarch64 (Windows on ARM). LLVM_MINGW defaults to $LLVM_MINGW,
# then ~/.local/opt/llvm-mingw.
set(CMAKE_SYSTEM_NAME Windows)
if(NOT WINDOWS_ARCH)
set(WINDOWS_ARCH x86_64)
endif()
set(WINDOWS_ARCH "${WINDOWS_ARCH}" CACHE STRING "x86_64, i686 or aarch64")
if(WINDOWS_ARCH STREQUAL "aarch64")
set(CMAKE_SYSTEM_PROCESSOR ARM64)
elseif(WINDOWS_ARCH STREQUAL "i686")
set(CMAKE_SYSTEM_PROCESSOR X86)
else()
set(CMAKE_SYSTEM_PROCESSOR AMD64)
endif()
if(NOT LLVM_MINGW)
if(DEFINED ENV{LLVM_MINGW})
set(LLVM_MINGW "$ENV{LLVM_MINGW}")
else()
set(LLVM_MINGW "$ENV{HOME}/.local/opt/llvm-mingw")
endif()
endif()
set(LLVM_MINGW "${LLVM_MINGW}" CACHE PATH "llvm-mingw's folder")
set(CMAKE_C_COMPILER "${LLVM_MINGW}/bin/${WINDOWS_ARCH}-w64-mingw32-clang")
set(CMAKE_CXX_COMPILER "${LLVM_MINGW}/bin/${WINDOWS_ARCH}-w64-mingw32-clang++")
set(CMAKE_RC_COMPILER "${LLVM_MINGW}/bin/${WINDOWS_ARCH}-w64-mingw32-windres")
set(CMAKE_FIND_ROOT_PATH "${LLVM_MINGW}/${WINDOWS_ARCH}-w64-mingw32")
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
+13
View File
@@ -0,0 +1,13 @@
@echo off
rem Installs Frame Home on the Steam Frame (no Frame Studio needed). Double-click it, or drop .fenv environments on
rem it to copy them to the headset too. Asks for the headset's address the first time (default: frame).
setlocal
set "HOSTFILE=%APPDATA%\FrameStudio\frame-host.txt"
set "FRAMEHOST="
if exist "%HOSTFILE%" set /p FRAMEHOST=<"%HOSTFILE%"
if "%FRAMEHOST%"=="" set "FRAMEHOST=frame"
set /p ENTERED=Steam Frame address [%FRAMEHOST%]:
if not "%ENTERED%"=="" set "FRAMEHOST=%ENTERED%"
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install-frame-home.ps1" -HostName "%FRAMEHOST%" %*
if errorlevel 1 (echo. & echo Frame Home was NOT installed: see the messages above.) else (echo. & echo Frame Home is installed.)
pause
+40
View File
@@ -0,0 +1,40 @@
Frame Home for the Steam Frame
==============================
Frame Home replaces SteamVR's own environment on the Steam Frame with your homes (Meta Quest homes converted by
Frame Studio, or any .fenv environment). This folder installs it without Frame Studio.
What you need
-------------
- The Steam Frame with developer mode and SSH turned on, on the same network as your computer.
- SSH access to it: the headset's developer key in your SSH settings, or its password.
The headset's address is "frame" when an entry of that name is in your ~/.ssh/config (or the network knows it by
that name); otherwise use its IP address, e.g. steamos@192.168.1.50.
Install from a Windows PC
-------------------------
Double-click "Install Frame Home.bat" and type the headset's address (Enter keeps the one shown).
To copy environments to the headset at the same time, drop .fenv files onto "Install Frame Home.bat".
(Needs Windows' OpenSSH Client, under Settings > Apps > Optional features, or Git for Windows.)
Install from Linux or macOS
---------------------------
sh install-frame-home.sh --host steamos@frame [environment.fenv ...]
Install on the headset itself
-----------------------------
Copy this folder to the headset (Desktop Mode), open a terminal (Konsole) in it and run:
sh payload/install.sh
Put .fenv environments in ~/frame-home/envs; the Environments panel in the Steam menu lists them.
Uninstall
---------
sh install-frame-home.sh --uninstall (Linux / macOS)
powershell -ExecutionPolicy Bypass -File install-frame-home.ps1 -Uninstall (Windows)
sh payload/install.sh uninstall (on the headset)
SteamVR's own environment comes back; your environments in ~/frame-home stay.
If anything goes wrong, creating the file ~/frame-home/layer/off on the headset turns Frame Home off at once
(SteamVR's environment shows after its next start).
+72
View File
@@ -0,0 +1,72 @@
# Installs Frame Home on a Steam Frame from a Windows PC, without Frame Studio: the same steps as Studio's
# Frame > Install on Steam Frame. Needs ssh access to the headset (its developer key, or a password) and an ssh
# client: Windows' own OpenSSH (Settings > Optional features) or Git for Windows'.
#
# powershell -ExecutionPolicy Bypass -File install-frame-home.ps1 [-HostName HOST] [-Uninstall] [environment.fenv ...]
#
# HOST: the headset's ssh address (default "steamos@frame": an entry named frame in ~/.ssh/config, or the headset's
# name on the network; an IP works too, e.g. steamos@192.168.1.50). Environments (.fenv) given are copied into its
# selector as well; the first is the one shown. -Uninstall gives the environment back to SteamVR (files stay).
param(
[string]$HostName = "steamos@frame",
[switch]$Uninstall,
[Parameter(ValueFromRemainingArguments = $true)][string[]]$Environments = @()
)
$ErrorActionPreference = "Stop"
function Say($text) { Write-Host "frame-home: $text" }
# ssh / scp: Windows' OpenSSH, else Git for Windows' copy (as Frame Studio finds them)
function Find-Tool($name) {
$onPath = Get-Command "$name.exe" -ErrorAction SilentlyContinue
if ($onPath) { return $onPath.Source }
$tries = @("$env:WINDIR\System32\OpenSSH\$name.exe")
foreach ($root in @($env:ProgramW6432, $env:ProgramFiles, $env:LOCALAPPDATA)) {
if ($root) { $tries += "$root\Git\usr\bin\$name.exe"; $tries += "$root\Programs\Git\usr\bin\$name.exe" }
}
foreach ($t in $tries) { if (Test-Path $t) { return $t } }
throw "No $name found: turn on Windows' OpenSSH Client (Settings > Apps > Optional features), or install Git for Windows."
}
$ssh = Find-Tool "ssh"
$scp = Find-Tool "scp"
if ($HostName -notmatch "@") { $HostName = "steamos@$HostName" }
# The name an environment gets on the headset: lowercase, spaces as dashes, only [a-z0-9._-] (as Studio names them).
function Frame-Name($path) {
$n = ([IO.Path]::GetFileName($path)).ToLowerInvariant() -replace " ", "-"
return ($n -replace "[^a-z0-9._-]", "")
}
$payload = Join-Path $PSScriptRoot "payload"
if (-not $Uninstall -and -not (Test-Path (Join-Path $payload "install.sh"))) { throw "The headset files are missing: expected $payload\install.sh" }
Say "connecting to $HostName"
$arch = & $ssh -o ConnectTimeout=8 $HostName "mkdir -p frame-home/envs frame-home/.install && uname -m"
if ($LASTEXITCODE -ne 0) { throw "Can't reach $HostName over ssh (is the headset on, in developer mode, with ssh enabled?)" }
if ("$arch".Trim() -ne "aarch64") { throw "$HostName is a $arch machine, not a Steam Frame" }
if ($Uninstall) {
# (never installed from a copy like this one: Frame Home itself puts SteamVR's environment back)
& $ssh $HostName "if [ -f frame-home/.install/frame/install.sh ]; then sh frame-home/.install/frame/install.sh uninstall; else frame-home/bin/frame-home --uninstall; fi"
} else {
Say "copying Frame Home"
& $ssh $HostName "rm -rf frame-home/.install/frame"
# Relative source from its own folder: scp would read "C:\..." as a host named C.
Push-Location $PSScriptRoot
try { & $scp -q -r payload "${HostName}:frame-home/.install/frame" } finally { Pop-Location }
if ($LASTEXITCODE -ne 0) { throw "Copying to the headset failed" }
foreach ($f in $Environments) {
if (-not (Test-Path $f)) { Say "no such file: $f"; continue }
$name = Frame-Name $f
Say "copying $([IO.Path]::GetFileName($f)) -> $name"
Push-Location (Split-Path -Parent (Resolve-Path $f))
try { & $scp -q ([IO.Path]::GetFileName($f)) "${HostName}:frame-home/envs/$name.part" } finally { Pop-Location }
& $ssh $HostName "mv -f frame-home/envs/$name.part frame-home/envs/$name"
}
& $ssh $HostName "sed -i 's/\r`$//' frame-home/.install/frame/install.sh; sh frame-home/.install/frame/install.sh"
if ($LASTEXITCODE -ne 0) { throw "The installer on the headset failed (see above)" }
if ($Environments.Count -gt 0 -and (Test-Path $Environments[0])) { # the first is the one shown
$first = Frame-Name $Environments[0]
& $ssh $HostName "printf '%s\n' '$first' > frame-home/current"
}
}
Say "done"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Installs Frame Home on a Steam Frame from a Linux or macOS computer, without Frame Studio: the same steps as
# Studio's Frame > Install on Steam Frame. Needs ssh access to the headset (its developer key, or a password).
#
# sh install-frame-home.sh [--host HOST] [--uninstall] [environment.fenv ...]
#
# HOST: the headset's ssh address (default "steamos@frame": an entry named frame in ~/.ssh/config, or the
# headset's name on the network; an IP works too, e.g. steamos@192.168.1.50). Environments (.fenv) given are
# copied into its selector as well. --uninstall gives the environment back to SteamVR (files stay).
set -eu
HERE=$(cd "$(dirname "$0")" && pwd)
PAYLOAD="$HERE/payload"
HOST=steamos@frame
MODE=install
ENVS=""
while [ $# -gt 0 ]; do
case "$1" in
--host) HOST=$2; shift 2 ;;
--uninstall) MODE=uninstall; shift ;;
-h|--help) sed -n '2,10p' "$0"; exit 0 ;;
*) ENVS="$ENVS
$1"; shift ;;
esac
done
case "$HOST" in *@*) ;; *) HOST="steamos@$HOST" ;; esac
say() { echo "frame-home: $*"; }
[ -f "$PAYLOAD/install.sh" ] || { say "the headset files are missing: expected $PAYLOAD/install.sh"; exit 1; }
say "connecting to $HOST"
ARCH=$(ssh -o ConnectTimeout=8 "$HOST" "mkdir -p frame-home/envs frame-home/.install && uname -m") || {
say "can't reach $HOST over ssh (is the headset on, in developer mode, with ssh enabled?)"; exit 1; }
[ "$ARCH" = aarch64 ] || { say "$HOST is a $ARCH machine, not a Steam Frame"; exit 1; }
if [ "$MODE" = install ]; then
say "copying Frame Home"
ssh "$HOST" "rm -rf frame-home/.install/frame"
scp -q -r "$PAYLOAD" "$HOST:frame-home/.install/frame"
printf '%s\n' "$ENVS" | while IFS= read -r f; do
[ -n "$f" ] || continue
[ -f "$f" ] || { say "no such file: $f"; continue; }
# the name it gets on the headset: lowercase, spaces as dashes, only [a-z0-9._-] (as Studio names them)
name=$(basename "$f" | tr 'A-Z ' 'a-z-' | tr -cd 'a-z0-9._-')
say "copying $(basename "$f") -> $name"
scp -q "$f" "$HOST:frame-home/envs/$name.part" && ssh "$HOST" "mv -f frame-home/envs/$name.part frame-home/envs/$name"
done
ssh "$HOST" "sed -i 's/\r\$//' frame-home/.install/frame/install.sh; sh frame-home/.install/frame/install.sh"
# the first environment given is the one shown
FIRST=$(printf '%s\n' "$ENVS" | sed -n '/./{p;q;}')
if [ -n "$FIRST" ] && [ -f "$FIRST" ]; then
name=$(basename "$FIRST" | tr 'A-Z ' 'a-z-' | tr -cd 'a-z0-9._-')
ssh "$HOST" "printf '%s\n' '$name' > frame-home/current"
fi
else
# (never installed from a copy like this one: Frame Home itself puts SteamVR's environment back)
ssh "$HOST" "if [ -f frame-home/.install/frame/install.sh ]; then sh frame-home/.install/frame/install.sh uninstall; else frame-home/bin/frame-home --uninstall; fi"
fi
say "done"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+126
View File
@@ -0,0 +1,126 @@
#!/bin/sh
# Frame Home installer — runs on the Steam Frame. Frame Studio uploads this folder and runs it over ssh
# (Frame > Install on Steam Frame); it also works by hand:
# sh install.sh install or update Frame Home and make it the headset's environment
# sh install.sh uninstall give the environment back to SteamVR (environments and binaries stay)
#
# Frame Home replaces Aurora, SteamVR's own environment: a Vulkan layer in SteamVR's compositor draws your home where
# Aurora goes (layer/), and frame-home --system renders it; the Environments panel (frame-switcher, started with
# SteamVR) keeps that running. No app, no dock entry; it shows at boot, behind the Steam menu and during game loads.
# Nothing is changed under /opt or /usr. ~/frame-home/layer/off turns it off (Aurora as usual).
set -u
P=$(cd "$(dirname "$0")" && pwd)
H="$HOME/frame-home"
CFG="${XDG_CONFIG_HOME:-$HOME/.config}/openvr/config"
MODE="${1:-install}"
say() { echo "frame-install: $*"; }
chmod +x "$P"/bin/* 2>/dev/null # copied from Windows: no executable bit
# SteamVR not running: write the same registration into its config files (it reads them when it starts).
offline() {
python3 - "$CFG" "$1" "$H" <<'PY'
import json, os, sys
cfg, mode, home = sys.argv[1:4]
def load(p): # missing: start empty; there but unreadable: stop (saving would wipe the user's SteamVR settings)
try:
with open(p) as f: return json.load(f)
except FileNotFoundError: return {}
except Exception as e: sys.exit('frame-install: %s does not read (%s): left as it is' % (p, e))
def save(p, d):
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p + '.tmp', 'w') as f: json.dump(d, f, indent=3)
os.replace(p + '.tmp', p)
ac = os.path.join(cfg, 'appconfig.json'); a = load(ac); paths = a.setdefault('manifest_paths', [])
for m in (os.path.join(home, 'frame-home.vrmanifest'), os.path.join(home, 'frame-switcher.vrmanifest')):
if mode == 'install' and m not in paths: paths.append(m)
if mode == 'uninstall' and m in paths: paths.remove(m)
save(ac, a)
vs = os.path.join(cfg, 'steamvr.vrsettings'); s = load(vs)
s.setdefault('steamvr', {})['enableHomeApp'] = False # the old way (Frame Home as SteamVR's home app) stays off
if mode == 'install': s['steamvr']['environmentMode'] = 1 # Aurora: what the layer replaces
s['steamvr'].pop('globalActionSetPriority', None) # earlier versions took the sticks over the Steam menu: the menu's again
save(vs, s)
if mode == 'install':
save(os.path.join(cfg, 'vrappconfig', 'framehome.switcher.vrappconfig'), {'autolaunch': True, 'last_launch_time': '0'})
PY
}
LAYERJSON="$HOME/.local/share/vulkan/implicit_layer.d/frame_home_layer.json"
if [ "$MODE" = uninstall ]; then
mkdir -p "$H/layer"; : > "$H/layer/off"; rm -f "$LAYERJSON" # Aurora is back once SteamVR restarts
pkill -f "^$H/bin/frame-home --system" 2>/dev/null
if [ -x "$P/bin/frame-home" ]; then "$P/bin/frame-home" --uninstall; else "$H/bin/frame-home" --uninstall; fi; rc=$?
[ $rc -eq 3 ] && { offline uninstall; rc=$?; }
[ $rc -eq 0 ] && say "SteamVR's own environment is back" || say "uninstall failed ($rc)"
exit $rc
fi
[ -x "$P/bin/frame-home" ] || { say "this installer has no Frame Home binaries"; exit 1; }
# Missing libraries (or a build for another machine) show up here, before anything installed is touched, rather than
# as a silent black environment later.
if ! LD_LIBRARY_PATH="$P/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$P/bin/frame-home" --version >/dev/null 2>&1; then
missing=$(ldd "$P/bin/frame-home" 2>/dev/null | grep "not found" | head -3 | tr -s ' \t\n' ' ')
say "Frame Home can't run on this headset: ${missing:-unknown error} (nothing was changed)"; exit 1
fi
mkdir -p "$H/bin" "$H/envs" || exit 1
# New binaries beside the old ones, then renamed over them: a running copy keeps its file until it restarts.
for x in frame-home frame-switcher fenv-tool; do
[ -f "$P/bin/$x" ] || continue
cp -f "$P/bin/$x" "$H/bin/$x.new" && chmod +x "$H/bin/$x.new" && mv -f "$H/bin/$x.new" "$H/bin/$x" || { say "couldn't install $x"; exit 1; }
done
# The compositor layer and its shader (renamed over the old ones: a running compositor keeps its copy).
layerChanged=0
if [ -f "$P/layer/libframe_home_layer.so" ]; then
mkdir -p "$H/layer" "$(dirname "$LAYERJSON")"
cmp -s "$P/layer/libframe_home_layer.so" "$H/layer/libframe_home_layer.so" || layerChanged=1
cmp -s "$P/layer/env_ps.spv" "$H/layer/env_ps.spv" || layerChanged=1
for f in libframe_home_layer.so env_ps.spv; do cp -f "$P/layer/$f" "$H/layer/.$f.new" && mv -f "$H/layer/.$f.new" "$H/layer/$f"; done
rm -f "$H/layer/off" "$H/layer/starting" "$H/layer/crashes"
[ -f "$LAYERJSON" ] || layerChanged=1
cat > "$LAYERJSON" <<JSON
{
"file_format_version": "1.2.0",
"layer": {
"name": "VK_LAYER_FRAMEHOME_compositor",
"type": "GLOBAL",
"library_path": "$H/layer/libframe_home_layer.so",
"api_version": "1.4.309",
"implementation_version": "1",
"description": "Frame Home: replaces the SteamVR compositor environment (active only in vrcompositor)",
"functions": { "vkNegotiateLoaderLayerInterfaceVersion": "frameHome_NegotiateLoaderLayerInterfaceVersion" },
"disable_environment": { "FRAME_HOME_LAYER_DISABLE": "1" }
}
}
JSON
fi
# Libraries the binaries need that the headset may not have (none today; kept for future builds).
[ -d "$P/lib" ] && { mkdir -p "$H/lib"; cp -f "$P"/lib/* "$H/lib/"; }
# Environments that came with this install.
for f in "$P"/envs/*.fenv; do [ -f "$f" ] && mv -f "$f" "$H/envs/"; done
# The Environments panel starts with SteamVR and keeps frame-home --system running. Restarted here so the new
# binaries take over now; the compositor too when the layer changed (SteamVR brings it straight back).
if pgrep -x vrserver >/dev/null; then
pkill -x frame-switcher 2>/dev/null; pkill -f "^$H/bin/frame-home --system" 2>/dev/null
for i in $(seq 1 20); do pgrep -x frame-switcher >/dev/null || break; sleep 0.5; done # gone before relaunching
"$P/bin/frame-switcher" --install; rs=$?
[ $rs -eq 0 ] || { say "SteamVR refused the Environments panel ($rs)"; exit 1; }
sleep 3
if ! pgrep -x frame-switcher >/dev/null; then # SteamVR still counted the old one as running: start it ourselves
(cd "$H/bin" && STEAMVR_APPKEY=framehome.switcher setsid nohup ./frame-switcher >/dev/null 2>&1 </dev/null &)
fi
if [ $layerChanged -eq 1 ]; then pkill -x vrcompositor 2>/dev/null; fi
say "installed — Frame Home is now the headset's environment (in Aurora's place)"
else
"$P/bin/frame-switcher" --install >/dev/null 2>&1 # writes its manifest (SteamVR isn't there to register it)
offline install || { say "couldn't register with SteamVR"; exit 1; }
say "installed — Frame Home becomes the environment when SteamVR next starts"
fi
# First environment: the newest one installed, unless one is already chosen.
if [ ! -s "$H/current" ]; then
first=$(ls -t "$H"/envs/*.fenv 2>/dev/null | head -1)
[ -n "$first" ] && basename "$first" > "$H/current"
fi
# Thumbnails for the switcher, in the background at low priority.
(nohup "$H/bin/frame-switcher" --thumbs >/dev/null 2>&1 &)
exit 0
Binary file not shown.
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
#version 450
// Frame Home's environment, drawn by vrcompositor in place of Aurora (frame_home_layer.cpp swaps it in for every
// Aurora pipeline: same vertex shader, same set 0, plus set 1 below).
//
// Aurora's vertex shader hands over each point's tracking-space position (location 1) and the frame's uniform block
// holds the eye position (_m11, as Aurora's floor shader reads it). The direction from the eye to the point is
// looked up in the newest frame Frame Home published: its projection * view rotation for that eye, so the picture
// stays locked to the world as the head turns between Frame Home's frame and the compositor's (rotation reprojection).
// SteamVR's per-frame block (set 0, binding 4), as far as the members used here.
layout(set = 0, binding = 4, std140) uniform CompositorFrame {
layout(row_major) mat4 m0, m1, m2, m3, m4;
vec4 m5[4];
vec4 m6; // w: the environment's opacity (the compositor dims it behind the menu and in transitions)
vec4 m7;
vec2 m8, m9;
vec4 m10;
vec4 m11; // xyz: this eye's position, tracking space
} frame;
layout(set = 1, binding = 0) uniform sampler2DArray home; // both eyes, layer 0 left, 1 right
layout(set = 1, binding = 1, std140) uniform HomeFrame {
mat4 viewProj[2]; // projection * view rotation, per eye (tracking space)
vec4 eyePos[2]; // where each eye was when Frame Home rendered it
vec4 params; // x: brightness (0 fades to black), y: 1 when there's something to show
} homeFrame;
layout(location = 1) in vec3 worldPos;
layout(location = 0) out vec4 color;
void main() {
const vec3 eye = frame.m11.xyz;
const vec3 dir = normalize(worldPos - eye);
const int k = distance(eye, homeFrame.eyePos[0].xyz) <= distance(eye, homeFrame.eyePos[1].xyz) ? 0 : 1;
const vec4 c = homeFrame.viewProj[k] * vec4(dir, 0.0);
vec3 rgb = vec3(0.0);
if (c.w > 1e-5 && homeFrame.params.y > 0.5) {
const vec2 uv = (c.xy / c.w) * 0.5 + 0.5;
// (explicit level 0: subsampled frames must be read so, and plain ones have only that level)
rgb = textureLod(home, vec3(uv, float(k)), 0.0).rgb;
}
color = vec4(rgb * homeFrame.params.x, frame.m6.w);
}
File diff suppressed because it is too large. Load diff
+109
View File
@@ -0,0 +1,109 @@
#!/bin/sh
# Builds Frame Studio for one or more computers and packages each into dist/, ready to hand out.
#
# scripts/build-studio.sh TARGET... [--build-dir DIR]
#
# Targets (each needs the matching compiler; GitHub Actions builds all of them: .github/workflows/build.yml):
# linux-x64 linux-x86 linux-arm64 on Linux: the native compiler (x86: gcc-multilib / g++-multilib); X11
# headers (libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev)
# windows-x64 windows-x86 windows-arm64 on Linux with llvm-mingw (cmake/llvm-mingw.cmake; $LLVM_MINGW)
# (on Windows itself: studio\build_windows.bat x64|x86|arm64)
# macos-arm64 macos-x64 on a Mac with Xcode's command line tools; MoltenVK is bundled into the
# app from $MOLTENVK (a libMoltenVK.dylib) or Homebrew's molten-vk
# frame-home the Steam Frame installer on its own (no Studio needed): the headset
# files from frame/payload and the install scripts
# all every target this computer can build
# Output: dist/FrameStudio-<target>.zip (.tar.gz on Linux) and dist/FrameHome-installer.zip.
set -eu
ROOT=$(cd "$(dirname "$0")/.." && pwd)
DIST="$ROOT/dist"
BUILD="$ROOT/build"
CMAKE=${CMAKE:-cmake}
TARGETS=""
while [ $# -gt 0 ]; do
case "$1" in
--build-dir) BUILD=$2; shift 2 ;;
-h|--help) sed -n '2,19p' "$0"; exit 0 ;;
*) TARGETS="$TARGETS $1"; shift ;;
esac
done
[ -n "$TARGETS" ] || { sed -n '2,19p' "$0"; exit 1; }
mkdir -p "$DIST" "$BUILD"
say() { echo "build-studio: $*"; }
if [ "$TARGETS" = " all" ]; then
case "$(uname -s)" in
Darwin) TARGETS="macos-arm64 macos-x64 frame-home" ;;
*) case "$(uname -m)" in
aarch64|arm64) TARGETS="linux-arm64 frame-home" ;;
*) TARGETS="linux-x64 linux-x86 frame-home" ;;
esac
[ -x "${LLVM_MINGW:-$HOME/.local/opt/llvm-mingw}/bin/x86_64-w64-mingw32-clang" ] && TARGETS="$TARGETS windows-x64 windows-x86 windows-arm64" ;;
esac
fi
# configure + build one target; $1 target, then extra cmake arguments
build() {
target=$1; shift
dir="$BUILD/$target"
say "building $target"
"$CMAKE" -S "$ROOT/studio" -B "$dir" -DCMAKE_BUILD_TYPE=Release "$@" > "$dir.configure.log" 2>&1 || {
tail -20 "$dir.configure.log"; say "$target: configure failed (full log: $dir.configure.log)"; exit 1; }
"$CMAKE" --build "$dir" --target frame-studio --parallel
}
# dist/FrameStudio-<target>: the program and, beside it, frame/ (the headset side Studio installs)
package() {
target=$1 dir="$BUILD/$1" name="FrameStudio-$1"
stage="$BUILD/stage/$name"
rm -rf "$stage" && mkdir -p "$stage"
case "$target" in
windows-*) cp "$dir/Frame Studio.exe" "$stage/" && cp -R "$dir/frame" "$stage/frame"
(cd "$BUILD/stage" && rm -f "$DIST/$name.zip" && zip -qr "$DIST/$name.zip" "$name") ;;
linux-*) cp "$dir/Frame Studio" "$stage/" && cp -R "$dir/frame" "$stage/frame"
tar -czf "$DIST/$name.tar.gz" -C "$BUILD/stage" "$name" ;;
macos-*) cp -R "$dir/Frame Studio.app" "$stage/"
bundle_moltenvk "$stage/Frame Studio.app"
(cd "$BUILD/stage" && rm -f "$DIST/$name.zip" && ditto -c -k --keepParent "$name" "$DIST/$name.zip") ;;
esac
say "$target -> dist/$(ls "$DIST" | grep "^$name\." | head -1)"
}
# MoltenVK (Vulkan on Metal) inside the app: Contents/Frameworks/libMoltenVK.dylib, which Studio loads itself when
# the Mac has no Vulkan loader installed.
bundle_moltenvk() {
app=$1
lib=${MOLTENVK:-}
[ -n "$lib" ] || { command -v brew > /dev/null && lib="$(brew --prefix molten-vk 2>/dev/null)/lib/libMoltenVK.dylib"; } || true
if [ -n "$lib" ] && [ -f "$lib" ]; then
mkdir -p "$app/Contents/Frameworks" && cp "$lib" "$app/Contents/Frameworks/"
else
say "warning: no MoltenVK found (set MOLTENVK or: brew install molten-vk): the app needs the Vulkan SDK installed to run"
fi
}
# dist/FrameHome-installer.zip: Frame Home for the Steam Frame, installed without Frame Studio
package_frame_home() {
stage="$BUILD/stage/FrameHome-installer"
rm -rf "$stage" && mkdir -p "$stage"
cp -R "$ROOT/frame/payload" "$stage/payload"
cp "$ROOT/frame/install-frame-home.sh" "$ROOT/frame/install-frame-home.ps1" "$ROOT/frame/Install Frame Home.bat" "$ROOT/frame/README-frame-home.txt" "$stage/"
chmod +x "$stage/install-frame-home.sh" "$stage/payload/install.sh" "$stage"/payload/bin/* 2>/dev/null || true
(cd "$BUILD/stage" && rm -f "$DIST/FrameHome-installer.zip" && zip -qr "$DIST/FrameHome-installer.zip" FrameHome-installer)
say "frame-home -> dist/FrameHome-installer.zip"
}
for t in $TARGETS; do
case "$t" in
linux-x64|linux-arm64) build "$t" -G Ninja && package "$t" ;;
linux-x86) build "$t" -G Ninja -DCMAKE_C_FLAGS=-m32 -DCMAKE_CXX_FLAGS=-m32 -DCMAKE_SYSTEM_PROCESSOR=i686 \
-DCMAKE_LIBRARY_ARCHITECTURE=i386-linux-gnu && package "$t" ;;
windows-x64) build "$t" -G Ninja -DCMAKE_TOOLCHAIN_FILE="$ROOT/cmake/llvm-mingw.cmake" -DWINDOWS_ARCH=x86_64 && package "$t" ;;
windows-x86) build "$t" -G Ninja -DCMAKE_TOOLCHAIN_FILE="$ROOT/cmake/llvm-mingw.cmake" -DWINDOWS_ARCH=i686 && package "$t" ;;
windows-arm64) build "$t" -G Ninja -DCMAKE_TOOLCHAIN_FILE="$ROOT/cmake/llvm-mingw.cmake" -DWINDOWS_ARCH=aarch64 && package "$t" ;;
macos-arm64) build "$t" -G Ninja -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 && package "$t" ;;
macos-x64) build "$t" -G Ninja -DCMAKE_OSX_ARCHITECTURES=x86_64 -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 && package "$t" ;;
frame-home) package_frame_home ;;
*) say "unknown target: $t (see --help)"; exit 1 ;;
esac
done
+405
View File
@@ -0,0 +1,405 @@
#include "anim_bake.h"
#include "bake_io.h"
#include "loaders/opa_loader.h"
#include "loaders/gltf_loader.h"
#include <algorithm>
#include <climits>
#include <functional>
#include <set>
#include <memory>
namespace {
using bakeio::Reader;
using bakeio::Writer;
constexpr u8 kClay = 1, kGltf = 2;
// Which nodes the animated meshes hang from: those nodes and every ancestor the evaluation reaches from them, the
// rest dropped (kept in their order, so each still follows its parents). `parentOf(i)` is the parent the evaluation
// uses (-1: none); out[i] = the node's new index, -1 when it isn't kept.
template <typename ParentOf>
std::vector<int> keptNodes(size_t n, const std::vector<int>& used, ParentOf parentOf) {
std::vector<char> keep(n, 0);
for (int u : used) {
for (int i = u, steps = 0; i >= 0 && static_cast<size_t>(i) < n && !keep[i] && steps <= static_cast<int>(n); ++steps) {
keep[i] = 1;
i = parentOf(i);
}
}
std::vector<int> out(n, -1);
int k = 0;
for (size_t i = 0; i < n; ++i) if (keep[i]) out[i] = k++;
return out;
}
// ── Clay (OpaLoader) ──
void writeTrack(Writer& w, const OpaLoader::Track& t) { w.pod(t.comps); w.pod(t.nFrames); w.pod(t.effFrames); w.vec(t.v); }
OpaLoader::Track readTrack(Reader& r) {
OpaLoader::Track t;
t.comps = r.pod<int>(); t.nFrames = r.pod<int>(); t.effFrames = r.pod<int>(); t.v = r.vec<float>();
return t;
}
// sampleTrack reads nFrames x comps values when the track has frames and its comps are what it asks for (3 or 4)
bool trackOk(const OpaLoader::Track& t) { return t.nFrames <= 0 || (t.comps != 3 && t.comps != 4) || t.v.size() >= static_cast<size_t>(t.nFrames) * t.comps; }
void saveClay(const OpaLoader& L, Writer& w) {
w.f(L.animFps); w.pod(L.animMaxFrames); w.f(L.vatFps); w.f(L.uvMaxLoopSec);
// the node hierarchy, as far as the animated meshes need it (evalAnimNodes: a parent at or after its child is none)
std::vector<int> used;
for (const auto& ar : L.animRecs) used.push_back(static_cast<int>(ar.nodeIdx));
const std::vector<int> nodeMap = keptNodes(L.animNodes.size(), used, [&](int i) {
const int p = L.animNodes[i].parent;
return p >= 0 && p < i ? p : -1;
});
std::vector<const OpaLoader::Node*> nodes;
for (size_t i = 0; i < L.animNodes.size(); ++i) if (nodeMap[i] >= 0) nodes.push_back(&L.animNodes[i]);
w.u(static_cast<u32>(nodes.size()));
for (const OpaLoader::Node* nd : nodes) {
const int i = static_cast<int>(nd - L.animNodes.data()), p = nd->parent;
w.str(nd->name); w.pod<int>(p >= 0 && p < i ? nodeMap[p] : -1); w.fa(nd->t, 3); w.fa(nd->r, 4); w.fa(nd->s, 3);
}
{ // their keyed tracks (by node name, as evalAnimNodes looks them up)
std::vector<std::pair<const std::string*, const OpaLoader::NodeTracks*>> tracks;
std::set<std::string> seen;
for (const OpaLoader::Node* nd : nodes) {
const auto it = L.nodeAnim.find(nd->name);
if (it != L.nodeAnim.end() && seen.insert(nd->name).second) tracks.emplace_back(&it->first, &it->second);
}
w.u(static_cast<u32>(tracks.size()));
for (const auto& t : tracks) { w.str(*t.first); writeTrack(w, t.second->t); writeTrack(w, t.second->r); writeTrack(w, t.second->s); }
}
w.u(static_cast<u32>(L.animRecs.size()));
for (const auto& ar : L.animRecs) {
w.u(static_cast<u32>(ar.meshIdx));
w.pod<int>(ar.nodeIdx < nodeMap.size() ? nodeMap[ar.nodeIdx] : INT_MAX); // (out of range: evaluated as no node)
w.vec(ar.basePos); w.pod(ar.rigid); w.pod(ar.rest); w.fa(ar.restInv, 16); w.b(ar.atRest);
}
// skin clips the skinned meshes play (animate() evaluates every clip each frame: the unused ones go)
std::vector<int> clipMap(L.clips.size(), -1);
std::vector<const OpaLoader::AnimClip*> clips;
for (const auto& sr : L.skinRecs)
if (sr.clipIdx >= 0 && static_cast<size_t>(sr.clipIdx) < L.clips.size() && clipMap[sr.clipIdx] < 0) clipMap[sr.clipIdx] = 0;
for (size_t c = 0; c < L.clips.size(); ++c) if (clipMap[c] >= 0) { clipMap[c] = static_cast<int>(clips.size()); clips.push_back(&L.clips[c]); }
w.u(static_cast<u32>(clips.size()));
for (const OpaLoader::AnimClip* c : clips) { w.pod(c->numJoints); w.pod(c->numFrames); w.vec(c->parents); w.vec(c->mats); }
w.u(static_cast<u32>(L.skinRecs.size()));
for (const auto& sr : L.skinRecs) {
w.u(static_cast<u32>(sr.meshIdx));
w.pod<int>(sr.clipIdx >= 0 && static_cast<size_t>(sr.clipIdx) < clipMap.size() ? clipMap[sr.clipIdx] : -1);
w.pod(sr.nJoints); w.b(sr.gpu);
// skinned on the GPU: the bind pose and weights are the mesh's own (skinBindPos, boneIndices / boneWeights)
static const std::vector<float> noF;
static const std::vector<int> noI;
w.vec(sr.gpu ? noF : sr.basePos); w.vec(sr.gpu ? noI : sr.jidx); w.vec(sr.gpu ? noF : sr.jw);
w.vec(sr.boneClip); w.vec(sr.invBind); w.vec(sr.jointBox); w.fa(sr.unriggedBox, 6);
}
{ // UV tracks the UV-animated meshes play, each with its scroll-or-flipbook answer
std::vector<std::pair<const std::string*, const OpaLoader::UVTrack*>> tracks;
std::set<std::string> seen;
for (const auto& ur : L.uvAnimRecs) {
const auto it = L.matUVAnim.find(ur.node);
if (it != L.matUVAnim.end() && seen.insert(ur.node).second) tracks.emplace_back(&it->first, &it->second);
}
w.u(static_cast<u32>(tracks.size()));
for (const auto& t : tracks) {
const OpaLoader::UVTrack& tr = *t.second;
int scroll = tr.scroll;
float ru = tr.ru, rv = tr.rv;
if (scroll < 0) scroll = L.uvScrollRate(tr, ru, rv) ? 1 : 0;
w.str(*t.first); w.pod(tr.nFrames); w.vec(tr.m); w.pod(scroll); w.f(ru); w.f(rv);
}
}
w.u(static_cast<u32>(L.uvAnimRecs.size()));
for (const auto& ur : L.uvAnimRecs) { w.u(static_cast<u32>(ur.meshIdx)); w.str(ur.node); w.vec(ur.baseUV); w.b(ur.atRest); }
{
std::vector<std::pair<const std::string*, const OpaLoader::TintTrack*>> tracks;
std::set<std::string> seen;
for (const auto& tr : L.tintRecs) {
const auto it = L.matTintAnim.find(tr.node);
if (it != L.matTintAnim.end() && seen.insert(tr.node).second) tracks.emplace_back(&it->first, &it->second);
}
w.u(static_cast<u32>(tracks.size()));
for (const auto& t : tracks) { w.str(*t.first); w.pod(t.second->nFrames); w.vec(t.second->rgba); }
}
w.u(static_cast<u32>(L.tintRecs.size()));
for (const auto& tr : L.tintRecs) { w.u(static_cast<u32>(tr.meshIdx)); w.str(tr.node); }
std::vector<const OpaLoader::VatData*> vats;
for (const auto& vr : L.vatRecs) if (vr.vd && std::find(vats.begin(), vats.end(), vr.vd) == vats.end()) vats.push_back(vr.vd);
w.u(static_cast<u32>(vats.size()));
for (const OpaLoader::VatData* vd : vats) { w.pod(vd->cols); w.pod(vd->frames); w.vec(vd->off); }
w.u(static_cast<u32>(L.vatRecs.size()));
for (const auto& vr : L.vatRecs) {
w.u(static_cast<u32>(vr.meshIdx)); w.vec(vr.basePos); w.vec(vr.col); w.pod(vr.world);
w.pod<int>(vr.vd ? static_cast<int>(std::find(vats.begin(), vats.end(), vr.vd) - vats.begin()) : -1);
}
}
std::shared_ptr<OpaLoader> loadClay(Reader& r, size_t meshes) {
auto L = std::make_shared<OpaLoader>();
L->verbose = false;
L->animFps = r.f(); L->animMaxFrames = r.pod<int>(); L->vatFps = r.f(); L->uvMaxLoopSec = r.f();
L->animNodes.resize(r.count(44));
for (auto& nd : L->animNodes) { if (r.bad) return nullptr; nd.name = r.str(); nd.parent = r.pod<int>(); r.fa(nd.t, 3); r.fa(nd.r, 4); r.fa(nd.s, 3); }
for (u32 n = r.count(40); n-- && !r.bad;) {
const std::string name = r.str();
OpaLoader::NodeTracks nt;
nt.t = readTrack(r); nt.r = readTrack(r); nt.s = readTrack(r);
if (!trackOk(nt.t) || !trackOk(nt.r) || !trackOk(nt.s)) return nullptr;
L->nodeAnim[name] = std::move(nt);
}
L->animRecs.resize(r.count(145));
for (auto& ar : L->animRecs) {
if (r.bad) return nullptr;
ar.meshIdx = r.u(); ar.nodeIdx = static_cast<u32>(r.pod<int>()); ar.basePos = r.vec<float>(); ar.rigid = r.pod<int>();
ar.rest = r.pod<OpaLoader::Mat4>(); r.fa(ar.restInv, 16); ar.atRest = r.b();
ar.parentWorld = OpaLoader::identity();
if (ar.meshIdx >= meshes) return nullptr;
}
L->clips.resize(r.count(16));
for (auto& c : L->clips) {
if (r.bad) return nullptr;
c.numJoints = r.pod<int>(); c.numFrames = r.pod<int>(); c.parents = r.vec<int>(); c.mats = r.vec<float>();
if (c.numJoints < 0 || c.numFrames < 0 || c.mats.size() < static_cast<size_t>(c.numFrames) * c.numJoints * 16) return nullptr;
}
L->skinRecs.resize(r.count(57));
for (auto& sr : L->skinRecs) {
if (r.bad) return nullptr;
sr.meshIdx = r.u(); sr.clipIdx = r.pod<int>(); sr.nJoints = r.pod<int>(); sr.gpu = r.b();
sr.basePos = r.vec<float>(); sr.jidx = r.vec<int>(); sr.jw = r.vec<float>();
sr.boneClip = r.vec<int>(); sr.invBind = r.vec<float>(); sr.jointBox = r.vec<float>(); r.fa(sr.unriggedBox, 6);
const size_t nv = sr.basePos.size() / 3;
if (sr.meshIdx >= meshes || (!sr.gpu && (sr.jidx.size() < nv * 4 || sr.jw.size() < nv * 4)) ||
(sr.gpu && sr.jointBox.size() < static_cast<size_t>(std::max(0, sr.nJoints)) * 6))
return nullptr;
}
for (u32 n = r.count(24); n-- && !r.bad;) {
const std::string name = r.str();
OpaLoader::UVTrack tr;
tr.nFrames = r.pod<int>(); tr.m = r.vec<float>(); tr.scroll = r.pod<int>(); tr.ru = r.f(); tr.rv = r.f();
if (tr.nFrames < 0 || tr.m.size() < static_cast<size_t>(tr.nFrames) * 6) return nullptr;
L->matUVAnim[name] = std::move(tr);
}
L->uvAnimRecs.resize(r.count(13));
for (auto& ur : L->uvAnimRecs) {
if (r.bad) return nullptr;
ur.meshIdx = r.u(); ur.node = r.str(); ur.baseUV = r.vec<float>(); ur.atRest = r.b();
if (ur.meshIdx >= meshes) return nullptr;
}
for (u32 n = r.count(12); n-- && !r.bad;) {
const std::string name = r.str();
OpaLoader::TintTrack tt;
tt.nFrames = r.pod<int>(); tt.rgba = r.vec<float>();
if (tt.nFrames < 0 || tt.rgba.size() < static_cast<size_t>(tt.nFrames) * 4) return nullptr;
L->matTintAnim[name] = std::move(tt);
}
L->tintRecs.resize(r.count(8));
for (auto& tr : L->tintRecs) { if (r.bad) return nullptr; tr.meshIdx = r.u(); tr.node = r.str(); if (tr.meshIdx >= meshes) return nullptr; }
std::vector<std::string> vatNames(r.count(12));
for (size_t i = 0; i < vatNames.size() && !r.bad; ++i) {
vatNames[i] = std::to_string(i); // (keyed by the geo name when parsed; by order here)
OpaLoader::VatData& vd = L->vatByBase[vatNames[i]];
vd.cols = r.pod<int>(); vd.frames = r.pod<int>(); vd.off = r.vec<float>();
if (vd.cols < 0 || vd.frames < 0 || vd.off.size() < static_cast<size_t>(vd.frames) * vd.cols * 3) return nullptr;
}
L->vatRecs.resize(r.count(80));
for (auto& vr : L->vatRecs) {
if (r.bad) return nullptr;
vr.meshIdx = r.u(); vr.basePos = r.vec<float>(); vr.col = r.vec<int>(); vr.world = r.pod<OpaLoader::Mat4>();
const int vd = r.pod<int>();
vr.vd = vd >= 0 && static_cast<size_t>(vd) < vatNames.size() ? &L->vatByBase[vatNames[vd]] : nullptr;
if (vr.meshIdx >= meshes || (vr.vd && vr.col.size() < vr.basePos.size() / 3)) return nullptr;
}
return r.bad ? nullptr : L;
}
// ── glTF (GltfLoader) ──
void saveGltf(const GltfLoader& L, Writer& w) {
w.f(L.animDuration); w.fa(L.reanchor, 3);
// the nodes the animated and skinned meshes follow, with their ancestors (nodeWorlds: an out-of-range parent is
// evaluated as the identity, so it stays out of range)
std::vector<int> used;
for (const auto& rec : L.nodeAnimRecs) used.push_back(rec.nodeIdx);
for (const auto& rec : L.skinned)
if (rec.skin >= 0 && static_cast<size_t>(rec.skin) < L.gskins.size()) for (int j : L.gskins[rec.skin].joints) used.push_back(j);
const size_t nn = L.gnodes.size();
const std::vector<int> nodeMap = keptNodes(nn, used, [&](int i) { return L.gnodes[i].parent; });
auto mapNode = [&](int n) { return n >= 0 && static_cast<size_t>(n) < nn ? (nodeMap[n] >= 0 ? nodeMap[n] : INT_MAX) : (n < 0 ? n : INT_MAX); };
u32 kept = 0;
for (int m : nodeMap) kept += m >= 0;
w.u(kept);
for (size_t i = 0; i < nn; ++i) {
if (nodeMap[i] < 0) continue;
const GltfLoader::GNode& g = L.gnodes[i];
w.fa(g.t, 3); w.fa(g.r, 4); w.fa(g.s, 3); w.pod<int>(mapNode(g.parent));
}
w.u(static_cast<u32>(L.gskins.size()));
for (const auto& sk : L.gskins) {
std::vector<int> joints;
for (int j : sk.joints) joints.push_back(mapNode(j));
w.vec(joints); w.vec(sk.ibm);
}
// the channels on kept nodes (in their order: a later one on the same node wins), and the samplers they read
std::vector<int> samplerMap(L.gsamplers.size(), -1);
std::vector<const GltfLoader::GChannel*> channels;
for (const auto& ch : L.gchannels)
if (ch.node >= 0 && static_cast<size_t>(ch.node) < nn && nodeMap[ch.node] >= 0 && ch.sampler >= 0 && static_cast<size_t>(ch.sampler) < L.gsamplers.size()) {
channels.push_back(&ch);
samplerMap[ch.sampler] = 0;
}
std::vector<const GltfLoader::GSampler*> samplers;
for (size_t s = 0; s < L.gsamplers.size(); ++s) if (samplerMap[s] >= 0) { samplerMap[s] = static_cast<int>(samplers.size()); samplers.push_back(&L.gsamplers[s]); }
w.u(static_cast<u32>(samplers.size()));
for (const GltfLoader::GSampler* s : samplers) { w.vec(s->times); w.vec(s->vals); w.pod(s->comps); w.b(s->step); }
w.u(static_cast<u32>(channels.size()));
for (const GltfLoader::GChannel* ch : channels) { w.pod<int>(nodeMap[ch->node]); w.pod(ch->path); w.pod<int>(samplerMap[ch->sampler]); }
w.u(static_cast<u32>(L.skinned.size()));
for (const auto& rec : L.skinned) { w.pod(rec.meshIdx); w.pod(rec.skin); w.u(rec.nv); w.vec(rec.basePos); w.vec(rec.jidx); w.vec(rec.jw); }
w.u(static_cast<u32>(L.nodeAnimRecs.size()));
for (const auto& rec : L.nodeAnimRecs) { w.pod(rec.meshIdx); w.pod<int>(mapNode(rec.nodeIdx)); w.vec(rec.basePos); w.pod(rec.rigid); w.fa(rec.rest, 16); w.fa(rec.restInv, 16); w.b(rec.atRest); }
}
std::shared_ptr<GltfLoader> loadGltf(Reader& r, size_t meshes) {
auto L = std::make_shared<GltfLoader>();
L->verbose = false;
L->animDuration = r.f(); r.fa(L->reanchor, 3);
L->gnodes.resize(r.count(44));
for (auto& g : L->gnodes) { if (r.bad) return nullptr; r.fa(g.t, 3); r.fa(g.r, 4); r.fa(g.s, 3); g.parent = r.pod<int>(); }
L->gskins.resize(r.count(8));
for (auto& sk : L->gskins) { if (r.bad) return nullptr; sk.joints = r.vec<int>(); sk.ibm = r.vec<float>(); }
L->gsamplers.resize(r.count(13));
for (auto& s : L->gsamplers) {
if (r.bad) return nullptr;
s.times = r.vec<float>(); s.vals = r.vec<float>(); s.comps = r.pod<int>(); s.step = r.b();
// (sampleSampler: up to 4 values a key, a key's worth even without keys)
if (s.comps < 0 || s.comps > 4 || (s.times.empty() ? !s.vals.empty() && s.vals.size() < static_cast<size_t>(s.comps)
: s.vals.size() < s.times.size() * s.comps))
return nullptr;
}
L->gchannels.resize(r.count(12));
for (auto& ch : L->gchannels) { if (r.bad) return nullptr; ch.node = r.pod<int>(); ch.path = r.pod<int>(); ch.sampler = r.pod<int>(); }
L->skinned.resize(r.count(24));
for (auto& rec : L->skinned) {
if (r.bad) return nullptr;
rec.meshIdx = r.pod<int>(); rec.skin = r.pod<int>(); rec.nv = r.u(); rec.basePos = r.vec<float>(); rec.jidx = r.vec<u8>(); rec.jw = r.vec<float>();
if (rec.meshIdx < 0 || static_cast<size_t>(rec.meshIdx) >= meshes || rec.basePos.size() < static_cast<size_t>(rec.nv) * 3 ||
rec.jidx.size() < static_cast<size_t>(rec.nv) * 4 || rec.jw.size() < static_cast<size_t>(rec.nv) * 4)
return nullptr;
}
L->nodeAnimRecs.resize(r.count(145));
for (auto& rec : L->nodeAnimRecs) {
if (r.bad) return nullptr;
rec.meshIdx = r.pod<int>(); rec.nodeIdx = r.pod<int>(); rec.basePos = r.vec<float>(); rec.rigid = r.pod<int>(); r.fa(rec.rest, 16); r.fa(rec.restInv, 16); rec.atRest = r.b();
if (rec.meshIdx < 0 || static_cast<size_t>(rec.meshIdx) >= meshes) return nullptr;
}
return r.bad ? nullptr : L;
}
// The per-frame play of a loader's animation, parsed or rebuilt: it works on the scene's meshes (the loader indexes them
// as its own, same order).
template <typename Loader> std::function<void(float, std::vector<MeshData>&)> player(std::shared_ptr<Loader> L) {
return [L](float t, std::vector<MeshData>& m) { std::swap(L->meshes, m); L->animateRuntime(t); std::swap(L->meshes, m); };
}
// One stored animation, rebuilt: the loader holding it.
struct Animator {
std::shared_ptr<OpaLoader> clay;
std::shared_ptr<GltfLoader> gltf;
std::function<void(float, std::vector<MeshData>&)> animate() const { return clay ? player(clay) : gltf ? player(gltf) : nullptr; }
void save(std::vector<u8>& out) const { if (clay) animbake::save(*clay, out); else if (gltf) animbake::save(*gltf, out); }
};
bool rebuild(const std::vector<u8>& bytes, size_t meshes, Animator& a) {
Reader r{bytes.data(), bytes.data() + bytes.size()};
const u8 kind = r.pod<u8>();
a = Animator();
if (kind == kClay) a.clay = loadClay(r, meshes);
else if (kind == kGltf) a.gltf = loadGltf(r, meshes);
return (a.clay || a.gltf) && !r.bad && r.p == r.end;
}
} // namespace
namespace animbake {
void adopt(HslScene& scene, std::shared_ptr<OpaLoader> loader) {
scene.animate = player(loader);
scene.saveAnimation = [loader](std::vector<u8>& out) { save(*loader, out); };
}
void adopt(HslScene& scene, std::shared_ptr<GltfLoader> loader) {
scene.animate = player(loader);
scene.saveAnimation = [loader](std::vector<u8>& out) { save(*loader, out); };
}
void save(const OpaLoader& loader, std::vector<u8>& out) {
out.clear();
Writer w{out};
w.pod(kClay);
saveClay(loader, w);
}
void save(const GltfLoader& loader, std::vector<u8>& out) {
out.clear();
Writer w{out};
w.pod(kGltf);
saveGltf(loader, w);
}
bool load(const std::vector<u8>& bytes, size_t meshes, HslScene& scene) {
Animator a;
if (!rebuild(bytes, meshes, a)) return false;
scene.animate = a.animate();
scene.saveAnimation = [bytes](std::vector<u8>& out) { out = bytes; };
return true;
}
std::vector<int> targets(const std::vector<u8>& bytes) {
Animator a;
std::vector<int> out;
if (!rebuild(bytes, SIZE_MAX, a)) return out;
if (a.clay) {
const OpaLoader& L = *a.clay;
for (const auto& x : L.animRecs) out.push_back(static_cast<int>(x.meshIdx));
for (const auto& x : L.skinRecs) out.push_back(static_cast<int>(x.meshIdx));
for (const auto& x : L.uvAnimRecs) out.push_back(static_cast<int>(x.meshIdx));
for (const auto& x : L.tintRecs) out.push_back(static_cast<int>(x.meshIdx));
for (const auto& x : L.vatRecs) out.push_back(static_cast<int>(x.meshIdx));
} else {
for (const auto& x : a.gltf->skinned) out.push_back(x.meshIdx);
for (const auto& x : a.gltf->nodeAnimRecs) out.push_back(x.meshIdx);
}
std::sort(out.begin(), out.end());
out.erase(std::unique(out.begin(), out.end()), out.end());
return out;
}
bool startPose(std::vector<u8>& bytes, const std::vector<MeshData>& meshes, std::vector<std::pair<size_t, MeshData>>& posed) {
posed.clear();
Animator a;
if (!rebuild(bytes, meshes.size(), a)) return false;
// only the meshes it writes are copied; the others stay empty (animate() never looks at them)
std::vector<MeshData> work(meshes.size());
const std::vector<int> written = targets(bytes);
for (int m : written) work[m] = meshes[m];
a.animate()(0.0f, work);
for (int m : written) posed.emplace_back(static_cast<size_t>(m), std::move(work[m]));
a.save(bytes);
return true;
}
bool toStart(HslScene& scene) {
if (!scene.saveAnimation) return false;
std::vector<u8> bytes;
scene.saveAnimation(bytes);
std::vector<std::pair<size_t, MeshData>> posed;
if (!startPose(bytes, scene.meshes, posed)) return false;
for (auto& [m, md] : posed) scene.meshes[m] = std::move(md);
return true;
}
} // namespace animbake
+45
View File
@@ -0,0 +1,45 @@
#pragma once
// A Clay (.opa / .ovrscene) or glTF home's CPU animation as data, for the baked scene (scene.bin v13): everything the
// loader's animateRuntime(t) reads (the node hierarchy and its keyed tracks, skins and their clips, UV scroll /
// flipbook and tint tracks, vertex-animation offsets, each animated mesh's base vertices and its rigid-or-rewritten
// choice), and nothing the parse needed besides. A .fenv's animation is rebuilt from it as an OpaLoader / GltfLoader
// holding only that state, so it plays through the loaders' own animate() — the same code, the same numbers.
// Worked out at the bake: the rigid choices, each UV track's scroll-or-flipbook answer, and the nodes / tracks / clips
// no animated mesh depends on (left out).
#include "hsl_scene.h"
#include <memory>
#include <utility>
#include <vector>
class OpaLoader;
class GltfLoader;
namespace animbake {
// A parsed home's animation, given to its scene: scene.animate plays it through the loader's animateRuntime(t) and
// scene.saveAnimation writes it. A .fenv's rebuilt one plays through the same function (one compiled copy of the
// code, so no compiler choice — operand order, fused multiply-adds — can make the two differ).
void adopt(HslScene& scene, std::shared_ptr<OpaLoader> loader);
void adopt(HslScene& scene, std::shared_ptr<GltfLoader> loader);
// The loader's animation, written as data (read only: call it from the thread that animates the scene).
void save(const OpaLoader& loader, std::vector<u8>& out);
void save(const GltfLoader& loader, std::vector<u8>& out);
// The scene's animation from what save() wrote: scene.animate plays it, scene.saveAnimation writes the same bytes
// again. `meshes`: how many meshes the scene has (an animation that writes past them is damage). False: damaged.
bool load(const std::vector<u8>& bytes, size_t meshes, HslScene& scene);
// Every mesh the animation writes (animatedMeshes is the ones seen moving at a few times; this is all of them).
std::vector<int> targets(const std::vector<u8>& bytes);
// The meshes the animation writes as a fresh load has them (the animation at t = 0, from a new animator), copied, by
// mesh index; `bytes` becomes the animation as that animator saves it (its rigid choices made). False: damaged.
bool startPose(std::vector<u8>& bytes, const std::vector<MeshData>& meshes, std::vector<std::pair<size_t, MeshData>>& posed);
// The same in place: the scene's animated meshes put back where a load has them (a copy of a scene Frame Studio has
// been playing, before the optimizer reads its vertices). False: it has no stored animation, or it's damaged.
bool toStart(HslScene& scene);
} // namespace animbake
+52
View File
@@ -0,0 +1,52 @@
#pragma once
// The baked scene's binary coder (scene_bake.cpp, anim_bake.cpp): little-endian PODs, length-prefixed strings and
// vectors, and a reader that turns every short or damaged read into `bad` instead of reading past the end.
#include "core/types.h"
#include <cstring>
#include <string>
#include <vector>
namespace bakeio {
struct Writer {
std::vector<u8>& out;
template <typename T> void pod(const T& v) { const u8* p = reinterpret_cast<const u8*>(&v); out.insert(out.end(), p, p + sizeof(T)); }
void u(u32 v) { pod(v); }
void b(bool v) { pod(static_cast<u8>(v)); }
void f(float v) { pod(v); }
void fa(const float* a, int n) { for (int i = 0; i < n; ++i) f(a[i]); }
void str(const std::string& s) { u(static_cast<u32>(s.size())); out.insert(out.end(), s.begin(), s.end()); }
template <typename T> void vec(const std::vector<T>& v) {
u(static_cast<u32>(v.size()));
if (!v.empty()) { const u8* p = reinterpret_cast<const u8*>(v.data()); out.insert(out.end(), p, p + v.size() * sizeof(T)); }
}
};
struct Reader {
const u8* p;
const u8* end;
bool bad = false;
bool need(size_t n) { if (bad || static_cast<size_t>(end - p) < n) { bad = true; return false; } return true; }
template <typename T> T pod() { T v{}; if (need(sizeof(T))) { std::memcpy(&v, p, sizeof(T)); p += sizeof(T); } return v; }
u32 u() { return pod<u32>(); }
bool b() { return pod<u8>() != 0; }
float f() { return pod<float>(); }
void fa(float* a, int n) { for (int i = 0; i < n; ++i) a[i] = f(); }
std::string str() { const u32 n = u(); std::string s; if (need(n)) { s.assign(reinterpret_cast<const char*>(p), n); p += n; } return s; }
// An element count, checked against what is left (each element takes at least `minBytes`): a damaged count
// must not size a vector of billions before the reads run out.
u32 count(size_t minBytes) {
const u32 n = u();
if (bad || static_cast<size_t>(end - p) / minBytes < n) { bad = true; return 0; }
return n;
}
template <typename T> std::vector<T> vec() {
const u32 n = u();
std::vector<T> v;
if (n && need(static_cast<size_t>(n) * sizeof(T))) { v.resize(n); std::memcpy(v.data(), p, static_cast<size_t>(n) * sizeof(T)); p += static_cast<size_t>(n) * sizeof(T); }
return v;
}
};
} // namespace bakeio
+272
View File
@@ -0,0 +1,272 @@
#pragma once
// GENERATED by tools/gen_clay_shader.py: the built-in Clay (.opa home) shaders. Do not edit by hand.
#include <cstdint>
static const uint32_t kClayVertSpirv[] = {
0x07230203,0x00010000,0x00000000,0x0000004d,0x00000000,0x00020011,0x00000001,0x00020011,
0x00001157,0x0006000a,0x5f565053,0x5f52484b,0x746c756d,0x65697669,0x00000077,0x0003000e,
0x00000000,0x00000001,0x0010000f,0x00000000,0x00000001,0x6e69616d,0x00000000,0x00000020,
0x00000021,0x00000022,0x00000023,0x00000024,0x00000029,0x00000025,0x00000026,0x00000027,
0x0000002a,0x00000028,0x00040005,0x00000001,0x6e69616d,0x00000000,0x00060005,0x0000000c,
0x626f6c47,0x6e556c61,0x726f6669,0x0000736d,0x00060005,0x0000001e,0x626f6c67,0x6e556c61,
0x726f6669,0x0000736d,0x00060005,0x0000000d,0x68737550,0x736e6f43,0x746e6174,0x00000073,
0x00060005,0x0000001f,0x68737570,0x736e6f43,0x746e6174,0x00000073,0x00050005,0x00000020,
0x69736f70,0x6e6f6974,0x00000000,0x00030005,0x00000021,0x00007675,0x00040005,0x00000022,
0x6f6c6f63,0x00000072,0x00040005,0x00000023,0x6d726f6e,0x00006c61,0x00030005,0x00000025,
0x00565576,0x00040005,0x00000026,0x6c6f4376,0x0000726f,0x00040005,0x00000027,0x726f4e76,
0x006c616d,0x00050005,0x00000029,0x6867696c,0x70616d74,0x00007655,0x00040005,0x0000002a,
0x31565576,0x00000000,0x00070006,0x0000000c,0x00000000,0x70696c63,0x6d6f7246,0x6c726f57,
0x00000064,0x00070006,0x0000000d,0x00000000,0x6c726f77,0x6f724664,0x646f4d6d,0x00006c65,
0x00050006,0x0000000d,0x00000001,0x6f6c6f63,0x00000072,0x00070006,0x0000000d,0x00000002,
0x72547675,0x66736e61,0x556d726f,0x00000000,0x00070006,0x0000000d,0x00000003,0x72547675,
0x66736e61,0x566d726f,0x00000000,0x00040047,0x00000020,0x0000001e,0x00000000,0x00040047,
0x00000021,0x0000001e,0x00000001,0x00040047,0x00000022,0x0000001e,0x00000002,0x00040047,
0x00000023,0x0000001e,0x00000003,0x00040047,0x00000029,0x0000001e,0x00000004,0x00040047,
0x00000025,0x0000001e,0x00000000,0x00040047,0x00000026,0x0000001e,0x00000001,0x00040047,
0x00000027,0x0000001e,0x00000002,0x00040047,0x0000002a,0x0000001e,0x00000003,0x00040047,
0x00000028,0x0000000b,0x00000000,0x00040047,0x00000024,0x0000000b,0x00001158,0x00040047,
0x0000000b,0x00000006,0x00000040,0x00050048,0x0000000c,0x00000000,0x00000023,0x00000000,
0x00040048,0x0000000c,0x00000000,0x00000005,0x00050048,0x0000000c,0x00000000,0x00000007,
0x00000010,0x00030047,0x0000000c,0x00000002,0x00040047,0x0000001e,0x00000022,0x00000000,
0x00040047,0x0000001e,0x00000021,0x00000000,0x00050048,0x0000000d,0x00000000,0x00000023,
0x00000000,0x00040048,0x0000000d,0x00000000,0x00000005,0x00050048,0x0000000d,0x00000000,
0x00000007,0x00000010,0x00050048,0x0000000d,0x00000001,0x00000023,0x00000040,0x00050048,
0x0000000d,0x00000002,0x00000023,0x00000050,0x00050048,0x0000000d,0x00000003,0x00000023,
0x00000060,0x00030047,0x0000000d,0x00000002,0x00020013,0x00000002,0x00030021,0x00000003,
0x00000002,0x00030016,0x00000004,0x00000020,0x00040015,0x00000005,0x00000020,0x00000001,
0x00040015,0x00000006,0x00000020,0x00000000,0x00040017,0x00000007,0x00000004,0x00000002,
0x00040017,0x00000008,0x00000004,0x00000003,0x00040017,0x00000009,0x00000004,0x00000004,
0x00040018,0x0000000a,0x00000009,0x00000004,0x0004002b,0x00000006,0x0000001d,0x00000002,
0x0004001c,0x0000000b,0x0000000a,0x0000001d,0x0003001e,0x0000000c,0x0000000b,0x0006001e,
0x0000000d,0x0000000a,0x00000009,0x00000009,0x00000009,0x00040020,0x0000000e,0x00000002,
0x0000000c,0x00040020,0x0000000f,0x00000002,0x0000000a,0x00040020,0x00000010,0x00000009,
0x0000000d,0x00040020,0x00000011,0x00000009,0x0000000a,0x00040020,0x00000012,0x00000009,
0x00000009,0x00040020,0x00000013,0x00000001,0x00000008,0x00040020,0x00000014,0x00000001,
0x00000007,0x00040020,0x00000015,0x00000001,0x00000009,0x00040020,0x00000016,0x00000001,
0x00000005,0x00040020,0x00000017,0x00000003,0x00000007,0x00040020,0x00000018,0x00000003,
0x00000009,0x00040020,0x00000019,0x00000003,0x00000008,0x0004002b,0x00000004,0x0000001a,
0x3f800000,0x0004002b,0x00000005,0x0000001b,0x00000000,0x0004002b,0x00000005,0x0000001c,
0x00000001,0x0004002b,0x00000005,0x0000003f,0x00000002,0x0004002b,0x00000005,0x00000040,
0x00000003,0x0004003b,0x0000000e,0x0000001e,0x00000002,0x0004003b,0x00000010,0x0000001f,
0x00000009,0x0004003b,0x00000013,0x00000020,0x00000001,0x0004003b,0x00000014,0x00000021,
0x00000001,0x0004003b,0x00000015,0x00000022,0x00000001,0x0004003b,0x00000013,0x00000023,
0x00000001,0x0004003b,0x00000016,0x00000024,0x00000001,0x0004003b,0x00000017,0x00000025,
0x00000003,0x0004003b,0x00000018,0x00000026,0x00000003,0x0004003b,0x00000019,0x00000027,
0x00000003,0x0004003b,0x00000018,0x00000028,0x00000003,0x0004003b,0x00000014,0x00000029,
0x00000001,0x0004003b,0x00000017,0x0000002a,0x00000003,0x00050036,0x00000002,0x00000001,
0x00000000,0x00000003,0x000200f8,0x0000002c,0x0004003d,0x00000005,0x0000002d,0x00000024,
0x00060041,0x0000000f,0x0000002e,0x0000001e,0x0000001b,0x0000002d,0x0004003d,0x0000000a,
0x0000002f,0x0000002e,0x00050041,0x00000011,0x00000030,0x0000001f,0x0000001b,0x0004003d,
0x0000000a,0x00000031,0x00000030,0x00050041,0x00000012,0x00000032,0x0000001f,0x0000001c,
0x0004003d,0x00000009,0x00000033,0x00000032,0x0004003d,0x00000008,0x00000034,0x00000020,
0x00050051,0x00000004,0x00000035,0x00000034,0x00000000,0x00050051,0x00000004,0x00000036,
0x00000034,0x00000001,0x00050051,0x00000004,0x00000037,0x00000034,0x00000002,0x00070050,
0x00000009,0x00000038,0x00000035,0x00000036,0x00000037,0x0000001a,0x00050091,0x00000009,
0x00000039,0x00000031,0x00000038,0x00050091,0x00000009,0x0000003a,0x0000002f,0x00000039,
0x0003003e,0x00000028,0x0000003a,0x0004003d,0x00000007,0x0000003b,0x00000021,0x00050041,
0x00000012,0x00000041,0x0000001f,0x0000003f,0x0004003d,0x00000009,0x00000042,0x00000041,
0x00050041,0x00000012,0x00000043,0x0000001f,0x00000040,0x0004003d,0x00000009,0x00000044,
0x00000043,0x0008004f,0x00000008,0x00000045,0x00000042,0x00000042,0x00000000,0x00000001,
0x00000002,0x0008004f,0x00000008,0x00000046,0x00000044,0x00000044,0x00000000,0x00000001,
0x00000002,0x00050051,0x00000004,0x00000047,0x0000003b,0x00000000,0x00050051,0x00000004,
0x00000048,0x0000003b,0x00000001,0x00060050,0x00000008,0x00000049,0x00000047,0x00000048,
0x0000001a,0x00050094,0x00000004,0x0000004a,0x00000045,0x00000049,0x00050094,0x00000004,
0x0000004b,0x00000046,0x00000049,0x00050050,0x00000007,0x0000004c,0x0000004a,0x0000004b,
0x0003003e,0x00000025,0x0000004c,0x0003003e,0x00000026,0x00000033,0x0004003d,0x00000008,
0x0000003e,0x00000023,0x0003003e,0x00000027,0x0000003e,0x0004003d,0x00000007,0x0000002b,
0x00000029,0x0003003e,0x0000002a,0x0000002b,0x000100fd,0x00010038,
};
static const uint32_t kClayFragSpirv[] = {
0x07230203,0x00010000,0x00000000,0x0000002e,0x00000000,0x00020011,0x00000001,0x0003000e,
0x00000000,0x00000001,0x0009000f,0x00000004,0x00000001,0x6e69616d,0x00000000,0x00000012,
0x00000013,0x00000023,0x00000014,0x00030010,0x00000001,0x00000007,0x00040005,0x00000001,
0x6e69616d,0x00000000,0x00060005,0x00000010,0x65736162,0x6f6c6f43,0x78655472,0x00000000,
0x00070005,0x00000011,0x656e696c,0x72577261,0x61537061,0x656c706d,0x00000072,0x00030005,
0x00000012,0x00565576,0x00040005,0x00000013,0x6c6f4376,0x0000726f,0x00050005,0x00000014,
0x4374756f,0x726f6c6f,0x00000000,0x00050005,0x00000022,0x6867696c,0x70616d74,0x00786554,
0x00040005,0x00000023,0x31565576,0x00000000,0x00040047,0x00000012,0x0000001e,0x00000000,
0x00040047,0x00000013,0x0000001e,0x00000001,0x00040047,0x00000014,0x0000001e,0x00000000,
0x00040047,0x00000011,0x00000022,0x00000002,0x00040047,0x00000011,0x00000021,0x00000000,
0x00040047,0x00000010,0x00000022,0x00000002,0x00040047,0x00000010,0x00000021,0x00000001,
0x00040047,0x00000022,0x00000022,0x00000002,0x00040047,0x00000022,0x00000021,0x00000002,
0x00040047,0x00000023,0x0000001e,0x00000003,0x00020013,0x00000002,0x00030021,0x00000003,
0x00000002,0x00030016,0x00000004,0x00000020,0x00020014,0x0000000a,0x00040017,0x00000005,
0x00000004,0x00000002,0x00040017,0x00000006,0x00000004,0x00000004,0x00090019,0x00000007,
0x00000004,0x00000001,0x00000000,0x00000000,0x00000000,0x00000001,0x00000000,0x0002001a,
0x00000008,0x0003001b,0x00000009,0x00000007,0x00040020,0x0000000b,0x00000000,0x00000007,
0x00040020,0x0000000c,0x00000000,0x00000008,0x00040020,0x0000000d,0x00000001,0x00000005,
0x00040020,0x0000000e,0x00000001,0x00000006,0x00040020,0x0000000f,0x00000003,0x00000006,
0x0004002b,0x00000004,0x00000015,0x3f000000,0x0004002b,0x00000004,0x0000002b,0x3f800000,
0x0004003b,0x0000000b,0x00000010,0x00000000,0x0004003b,0x0000000c,0x00000011,0x00000000,
0x0004003b,0x0000000d,0x00000012,0x00000001,0x0004003b,0x0000000e,0x00000013,0x00000001,
0x0004003b,0x0000000f,0x00000014,0x00000003,0x0004003b,0x0000000b,0x00000022,0x00000000,
0x0004003b,0x0000000d,0x00000023,0x00000001,0x00050036,0x00000002,0x00000001,0x00000000,
0x00000003,0x000200f8,0x00000016,0x0004003d,0x00000007,0x00000017,0x00000010,0x0004003d,
0x00000008,0x00000018,0x00000011,0x00050056,0x00000009,0x00000019,0x00000017,0x00000018,
0x0004003d,0x00000005,0x0000001a,0x00000012,0x00050057,0x00000006,0x0000001b,0x00000019,
0x0000001a,0x0004003d,0x00000006,0x0000001c,0x00000013,0x00050085,0x00000006,0x0000002d,
0x0000001b,0x0000001c,0x0004003d,0x00000007,0x00000024,0x00000022,0x00050056,0x00000009,
0x00000025,0x00000024,0x00000018,0x0004003d,0x00000005,0x00000026,0x00000023,0x00050057,
0x00000006,0x00000027,0x00000025,0x00000026,0x00050051,0x00000004,0x00000028,0x00000027,
0x00000000,0x00050051,0x00000004,0x00000029,0x00000027,0x00000001,0x00050051,0x00000004,
0x0000002a,0x00000027,0x00000002,0x00070050,0x00000006,0x0000002c,0x00000028,0x00000029,
0x0000002a,0x0000002b,0x00050085,0x00000006,0x0000001d,0x0000002d,0x0000002c,0x0003003e,
0x00000014,0x0000001d,0x000100fd,0x00010038,
};
static const uint32_t kClayFragCutoutSpirv[] = {
0x07230203,0x00010000,0x00000000,0x0000002e,0x00000000,0x00020011,0x00000001,0x0003000e,
0x00000000,0x00000001,0x0009000f,0x00000004,0x00000001,0x6e69616d,0x00000000,0x00000012,
0x00000013,0x00000023,0x00000014,0x00030010,0x00000001,0x00000007,0x00040005,0x00000001,
0x6e69616d,0x00000000,0x00060005,0x00000010,0x65736162,0x6f6c6f43,0x78655472,0x00000000,
0x00070005,0x00000011,0x656e696c,0x72577261,0x61537061,0x656c706d,0x00000072,0x00030005,
0x00000012,0x00565576,0x00040005,0x00000013,0x6c6f4376,0x0000726f,0x00050005,0x00000014,
0x4374756f,0x726f6c6f,0x00000000,0x00050005,0x00000022,0x6867696c,0x70616d74,0x00786554,
0x00040005,0x00000023,0x31565576,0x00000000,0x00040047,0x00000012,0x0000001e,0x00000000,
0x00040047,0x00000013,0x0000001e,0x00000001,0x00040047,0x00000014,0x0000001e,0x00000000,
0x00040047,0x00000011,0x00000022,0x00000002,0x00040047,0x00000011,0x00000021,0x00000000,
0x00040047,0x00000010,0x00000022,0x00000002,0x00040047,0x00000010,0x00000021,0x00000001,
0x00040047,0x00000022,0x00000022,0x00000002,0x00040047,0x00000022,0x00000021,0x00000002,
0x00040047,0x00000023,0x0000001e,0x00000003,0x00020013,0x00000002,0x00030021,0x00000003,
0x00000002,0x00030016,0x00000004,0x00000020,0x00020014,0x0000000a,0x00040017,0x00000005,
0x00000004,0x00000002,0x00040017,0x00000006,0x00000004,0x00000004,0x00090019,0x00000007,
0x00000004,0x00000001,0x00000000,0x00000000,0x00000000,0x00000001,0x00000000,0x0002001a,
0x00000008,0x0003001b,0x00000009,0x00000007,0x00040020,0x0000000b,0x00000000,0x00000007,
0x00040020,0x0000000c,0x00000000,0x00000008,0x00040020,0x0000000d,0x00000001,0x00000005,
0x00040020,0x0000000e,0x00000001,0x00000006,0x00040020,0x0000000f,0x00000003,0x00000006,
0x0004002b,0x00000004,0x00000015,0x3f000000,0x0004002b,0x00000004,0x0000002b,0x3f800000,
0x0004003b,0x0000000b,0x00000010,0x00000000,0x0004003b,0x0000000c,0x00000011,0x00000000,
0x0004003b,0x0000000d,0x00000012,0x00000001,0x0004003b,0x0000000e,0x00000013,0x00000001,
0x0004003b,0x0000000f,0x00000014,0x00000003,0x0004003b,0x0000000b,0x00000022,0x00000000,
0x0004003b,0x0000000d,0x00000023,0x00000001,0x00050036,0x00000002,0x00000001,0x00000000,
0x00000003,0x000200f8,0x00000016,0x0004003d,0x00000007,0x00000017,0x00000010,0x0004003d,
0x00000008,0x00000018,0x00000011,0x00050056,0x00000009,0x00000019,0x00000017,0x00000018,
0x0004003d,0x00000005,0x0000001a,0x00000012,0x00050057,0x00000006,0x0000001b,0x00000019,
0x0000001a,0x0004003d,0x00000006,0x0000001c,0x00000013,0x00050085,0x00000006,0x0000002d,
0x0000001b,0x0000001c,0x0004003d,0x00000007,0x00000024,0x00000022,0x00050056,0x00000009,
0x00000025,0x00000024,0x00000018,0x0004003d,0x00000005,0x00000026,0x00000023,0x00050057,
0x00000006,0x00000027,0x00000025,0x00000026,0x00050051,0x00000004,0x00000028,0x00000027,
0x00000000,0x00050051,0x00000004,0x00000029,0x00000027,0x00000001,0x00050051,0x00000004,
0x0000002a,0x00000027,0x00000002,0x00070050,0x00000006,0x0000002c,0x00000028,0x00000029,
0x0000002a,0x0000002b,0x00050085,0x00000006,0x0000001d,0x0000002d,0x0000002c,0x00050051,
0x00000004,0x0000001e,0x0000001d,0x00000003,0x000500b8,0x0000000a,0x0000001f,0x0000001e,
0x00000015,0x000300f7,0x00000021,0x00000000,0x000400fa,0x0000001f,0x00000020,0x00000021,
0x000200f8,0x00000020,0x000100fc,0x000200f8,0x00000021,0x0003003e,0x00000014,0x0000001d,
0x000100fd,0x00010038,
};
static const uint32_t kClaySkinnedVertSpirv[] = {
0x07230203,0x00010000,0x00000000,0x00000082,0x00000000,0x00020011,0x00000001,0x00020011,
0x00001157,0x0006000a,0x5f565053,0x5f52484b,0x746c756d,0x65697669,0x00000077,0x0003000e,
0x00000000,0x00000001,0x0012000f,0x00000000,0x00000001,0x6e69616d,0x00000000,0x00000020,
0x00000021,0x00000022,0x00000023,0x00000024,0x00000029,0x00000025,0x00000026,0x00000027,
0x0000002a,0x00000028,0x0000004f,0x00000050,0x00050005,0x0000004f,0x656e6f62,0x69646e49,
0x00736563,0x00050005,0x00000050,0x656e6f62,0x67696557,0x00737468,0x00070005,0x00000053,
0x6e696b53,0x676e696e,0x7274614d,0x73656369,0x00000000,0x00070005,0x00000055,0x6b536273,
0x696e6e69,0x614d676e,0x63697274,0x00007365,0x00050006,0x00000053,0x00000000,0x6e696f6a,
0x00007374,0x00040005,0x00000001,0x6e69616d,0x00000000,0x00060005,0x0000000c,0x626f6c47,
0x6e556c61,0x726f6669,0x0000736d,0x00060005,0x0000001e,0x626f6c67,0x6e556c61,0x726f6669,
0x0000736d,0x00060005,0x0000000d,0x68737550,0x736e6f43,0x746e6174,0x00000073,0x00060005,
0x0000001f,0x68737570,0x736e6f43,0x746e6174,0x00000073,0x00050005,0x00000020,0x69736f70,
0x6e6f6974,0x00000000,0x00030005,0x00000021,0x00007675,0x00040005,0x00000022,0x6f6c6f63,
0x00000072,0x00040005,0x00000023,0x6d726f6e,0x00006c61,0x00030005,0x00000025,0x00565576,
0x00040005,0x00000026,0x6c6f4376,0x0000726f,0x00040005,0x00000027,0x726f4e76,0x006c616d,
0x00050005,0x00000029,0x6867696c,0x70616d74,0x00007655,0x00040005,0x0000002a,0x31565576,
0x00000000,0x00070006,0x0000000c,0x00000000,0x70696c63,0x6d6f7246,0x6c726f57,0x00000064,
0x00070006,0x0000000d,0x00000000,0x6c726f77,0x6f724664,0x646f4d6d,0x00006c65,0x00050006,
0x0000000d,0x00000001,0x6f6c6f63,0x00000072,0x00070006,0x0000000d,0x00000002,0x72547675,
0x66736e61,0x556d726f,0x00000000,0x00070006,0x0000000d,0x00000003,0x72547675,0x66736e61,
0x566d726f,0x00000000,0x00040047,0x00000020,0x0000001e,0x00000000,0x00040047,0x00000021,
0x0000001e,0x00000001,0x00040047,0x00000022,0x0000001e,0x00000002,0x00040047,0x00000023,
0x0000001e,0x00000003,0x00040047,0x00000029,0x0000001e,0x00000004,0x00040047,0x00000025,
0x0000001e,0x00000000,0x00040047,0x00000026,0x0000001e,0x00000001,0x00040047,0x00000027,
0x0000001e,0x00000002,0x00040047,0x0000002a,0x0000001e,0x00000003,0x00040047,0x00000028,
0x0000000b,0x00000000,0x00040047,0x00000024,0x0000000b,0x00001158,0x00040047,0x0000000b,
0x00000006,0x00000040,0x00050048,0x0000000c,0x00000000,0x00000023,0x00000000,0x00040048,
0x0000000c,0x00000000,0x00000005,0x00050048,0x0000000c,0x00000000,0x00000007,0x00000010,
0x00030047,0x0000000c,0x00000002,0x00040047,0x0000001e,0x00000022,0x00000000,0x00040047,
0x0000001e,0x00000021,0x00000000,0x00050048,0x0000000d,0x00000000,0x00000023,0x00000000,
0x00040048,0x0000000d,0x00000000,0x00000005,0x00050048,0x0000000d,0x00000000,0x00000007,
0x00000010,0x00050048,0x0000000d,0x00000001,0x00000023,0x00000040,0x00050048,0x0000000d,
0x00000002,0x00000023,0x00000050,0x00050048,0x0000000d,0x00000003,0x00000023,0x00000060,
0x00030047,0x0000000d,0x00000002,0x00040047,0x0000004f,0x0000001e,0x00000005,0x00040047,
0x00000050,0x0000001e,0x00000006,0x00040047,0x00000052,0x00000006,0x00000040,0x00050048,
0x00000053,0x00000000,0x00000023,0x00000000,0x00040048,0x00000053,0x00000000,0x00000005,
0x00050048,0x00000053,0x00000000,0x00000007,0x00000010,0x00030047,0x00000053,0x00000002,
0x00040047,0x00000055,0x00000022,0x00000002,0x00040047,0x00000055,0x00000021,0x00000003,
0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,0x00000004,0x00000020,
0x00040015,0x00000005,0x00000020,0x00000001,0x00040015,0x00000006,0x00000020,0x00000000,
0x00040017,0x00000007,0x00000004,0x00000002,0x00040017,0x00000008,0x00000004,0x00000003,
0x00040017,0x00000009,0x00000004,0x00000004,0x00040018,0x0000000a,0x00000009,0x00000004,
0x0004002b,0x00000006,0x0000001d,0x00000002,0x0004001c,0x0000000b,0x0000000a,0x0000001d,
0x0003001e,0x0000000c,0x0000000b,0x0006001e,0x0000000d,0x0000000a,0x00000009,0x00000009,
0x00000009,0x00020014,0x00000056,0x00040017,0x0000004d,0x00000006,0x00000004,0x0004002b,
0x00000006,0x00000051,0x00000100,0x0004001c,0x00000052,0x0000000a,0x00000051,0x0003001e,
0x00000053,0x00000052,0x00040020,0x00000054,0x00000002,0x00000053,0x00040020,0x0000004e,
0x00000001,0x0000004d,0x00040020,0x0000000e,0x00000002,0x0000000c,0x00040020,0x0000000f,
0x00000002,0x0000000a,0x00040020,0x00000010,0x00000009,0x0000000d,0x00040020,0x00000011,
0x00000009,0x0000000a,0x00040020,0x00000012,0x00000009,0x00000009,0x00040020,0x00000013,
0x00000001,0x00000008,0x00040020,0x00000014,0x00000001,0x00000007,0x00040020,0x00000015,
0x00000001,0x00000009,0x00040020,0x00000016,0x00000001,0x00000005,0x00040020,0x00000017,
0x00000003,0x00000007,0x00040020,0x00000018,0x00000003,0x00000009,0x00040020,0x00000019,
0x00000003,0x00000008,0x0004002b,0x00000004,0x0000001a,0x3f800000,0x0004002b,0x00000005,
0x0000001b,0x00000000,0x0004002b,0x00000005,0x0000001c,0x00000001,0x0004002b,0x00000005,
0x0000003f,0x00000002,0x0004002b,0x00000005,0x00000040,0x00000003,0x0004002b,0x00000004,
0x00000057,0x38d1b717,0x0004002b,0x00000004,0x00000058,0x00000000,0x0004003b,0x00000054,
0x00000055,0x00000002,0x0004003b,0x0000004e,0x0000004f,0x00000001,0x0004003b,0x00000015,
0x00000050,0x00000001,0x0004003b,0x0000000e,0x0000001e,0x00000002,0x0004003b,0x00000010,
0x0000001f,0x00000009,0x0004003b,0x00000013,0x00000020,0x00000001,0x0004003b,0x00000014,
0x00000021,0x00000001,0x0004003b,0x00000015,0x00000022,0x00000001,0x0004003b,0x00000013,
0x00000023,0x00000001,0x0004003b,0x00000016,0x00000024,0x00000001,0x0004003b,0x00000017,
0x00000025,0x00000003,0x0004003b,0x00000018,0x00000026,0x00000003,0x0004003b,0x00000019,
0x00000027,0x00000003,0x0004003b,0x00000018,0x00000028,0x00000003,0x0004003b,0x00000014,
0x00000029,0x00000001,0x0004003b,0x00000017,0x0000002a,0x00000003,0x00050036,0x00000002,
0x00000001,0x00000000,0x00000003,0x000200f8,0x0000002c,0x0004003d,0x00000005,0x0000002d,
0x00000024,0x00060041,0x0000000f,0x0000002e,0x0000001e,0x0000001b,0x0000002d,0x0004003d,
0x0000000a,0x0000002f,0x0000002e,0x00050041,0x00000011,0x00000030,0x0000001f,0x0000001b,
0x0004003d,0x0000000a,0x00000031,0x00000030,0x00050041,0x00000012,0x00000032,0x0000001f,
0x0000001c,0x0004003d,0x00000009,0x00000033,0x00000032,0x0004003d,0x00000008,0x00000034,
0x00000020,0x00050051,0x00000004,0x00000035,0x00000034,0x00000000,0x00050051,0x00000004,
0x00000036,0x00000034,0x00000001,0x00050051,0x00000004,0x00000037,0x00000034,0x00000002,
0x00070050,0x00000009,0x00000038,0x00000035,0x00000036,0x00000037,0x0000001a,0x0004003d,
0x0000004d,0x00000059,0x0000004f,0x0004003d,0x00000009,0x0000005a,0x00000050,0x00050051,
0x00000006,0x0000005b,0x00000059,0x00000000,0x00050051,0x00000004,0x0000005c,0x0000005a,
0x00000000,0x00060041,0x0000000f,0x0000005d,0x00000055,0x0000001b,0x0000005b,0x0004003d,
0x0000000a,0x0000005e,0x0000005d,0x00050091,0x00000009,0x0000005f,0x0000005e,0x00000038,
0x0005008e,0x00000009,0x00000060,0x0000005f,0x0000005c,0x00050051,0x00000006,0x00000061,
0x00000059,0x00000001,0x00050051,0x00000004,0x00000062,0x0000005a,0x00000001,0x00060041,
0x0000000f,0x00000063,0x00000055,0x0000001b,0x00000061,0x0004003d,0x0000000a,0x00000064,
0x00000063,0x00050091,0x00000009,0x00000065,0x00000064,0x00000038,0x0005008e,0x00000009,
0x00000066,0x00000065,0x00000062,0x00050081,0x00000009,0x00000067,0x00000060,0x00000066,
0x00050081,0x00000004,0x00000068,0x0000005c,0x00000062,0x00050051,0x00000006,0x00000069,
0x00000059,0x00000002,0x00050051,0x00000004,0x0000006a,0x0000005a,0x00000002,0x00060041,
0x0000000f,0x0000006b,0x00000055,0x0000001b,0x00000069,0x0004003d,0x0000000a,0x0000006c,
0x0000006b,0x00050091,0x00000009,0x0000006d,0x0000006c,0x00000038,0x0005008e,0x00000009,
0x0000006e,0x0000006d,0x0000006a,0x00050081,0x00000009,0x0000006f,0x00000067,0x0000006e,
0x00050081,0x00000004,0x00000070,0x00000068,0x0000006a,0x00050051,0x00000006,0x00000071,
0x00000059,0x00000003,0x00050051,0x00000004,0x00000072,0x0000005a,0x00000003,0x00060041,
0x0000000f,0x00000073,0x00000055,0x0000001b,0x00000071,0x0004003d,0x0000000a,0x00000074,
0x00000073,0x00050091,0x00000009,0x00000075,0x00000074,0x00000038,0x0005008e,0x00000009,
0x00000076,0x00000075,0x00000072,0x00050081,0x00000009,0x00000077,0x0000006f,0x00000076,
0x00050081,0x00000004,0x00000078,0x00000070,0x00000072,0x000500b8,0x00000056,0x00000079,
0x00000078,0x00000057,0x000600a9,0x00000004,0x0000007a,0x00000079,0x0000001a,0x00000058,
0x00050083,0x00000004,0x0000007b,0x0000001a,0x0000007a,0x00050081,0x00000004,0x0000007c,
0x00000078,0x0000007a,0x00050088,0x00000004,0x0000007d,0x0000001a,0x0000007c,0x0005008e,
0x00000009,0x0000007e,0x00000077,0x0000007d,0x0005008e,0x00000009,0x0000007f,0x0000007e,
0x0000007b,0x0005008e,0x00000009,0x00000080,0x00000038,0x0000007a,0x00050081,0x00000009,
0x00000081,0x0000007f,0x00000080,0x00050091,0x00000009,0x00000039,0x00000031,0x00000081,
0x00050091,0x00000009,0x0000003a,0x0000002f,0x00000039,0x0003003e,0x00000028,0x0000003a,
0x0004003d,0x00000007,0x0000003b,0x00000021,0x00050041,0x00000012,0x00000041,0x0000001f,
0x0000003f,0x0004003d,0x00000009,0x00000042,0x00000041,0x00050041,0x00000012,0x00000043,
0x0000001f,0x00000040,0x0004003d,0x00000009,0x00000044,0x00000043,0x0008004f,0x00000008,
0x00000045,0x00000042,0x00000042,0x00000000,0x00000001,0x00000002,0x0008004f,0x00000008,
0x00000046,0x00000044,0x00000044,0x00000000,0x00000001,0x00000002,0x00050051,0x00000004,
0x00000047,0x0000003b,0x00000000,0x00050051,0x00000004,0x00000048,0x0000003b,0x00000001,
0x00060050,0x00000008,0x00000049,0x00000047,0x00000048,0x0000001a,0x00050094,0x00000004,
0x0000004a,0x00000045,0x00000049,0x00050094,0x00000004,0x0000004b,0x00000046,0x00000049,
0x00050050,0x00000007,0x0000004c,0x0000004a,0x0000004b,0x0003003e,0x00000025,0x0000004c,
0x0003003e,0x00000026,0x00000033,0x0004003d,0x00000008,0x0000003e,0x00000023,0x0003003e,
0x00000027,0x0000003e,0x0004003d,0x00000007,0x0000002b,0x00000029,0x0003003e,0x0000002a,
0x0000002b,0x000100fd,0x00010038,
};
+178
View File
@@ -0,0 +1,178 @@
#pragma once
// The world layer drawn over the home in the headset (the Debug page's "World layer"): the walkable area as Frame
// Home walks it, the surfaces and colliders, spawns, seats, hotspots, wall spots and gravity zones, in the colours
// Frame Studio uses for them. Built once per home as guide geometry (guide_geom.h) and appended to the guide each
// frame. Lines are thin crossed ribbons, so they read from any side without being rebuilt as you move.
#include "guide_geom.h"
#include "walkgrid.h"
#include <cmath>
#include <cstdint>
#include <unordered_set>
#include <utility>
#include <vector>
namespace debuggeom {
using guide::C;
using guide::P;
// A segment as two ribbons at right angles (w wide): visible edge-on from anywhere.
inline void line(std::vector<GuideVertex>& o, P a, P b, C c, float w = 0.012f) {
const P d = guide::norm(b - a);
P u = guide::norm(guide::cross(d, std::fabs(d.y) < 0.9f ? P{0, 1, 0} : P{1, 0, 0}));
P v = guide::cross(d, u);
u = u * (w * 0.5f); v = v * (w * 0.5f);
guide::quad(o, a - u, c, b - u, c, b + u, c, a + u, c);
guide::quad(o, a - v, c, b - v, c, b + v, c, a + v, c);
}
inline P xf(const float m[16], P p) { // column-major frame
return {m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12], m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13], m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14]};
}
inline void box(std::vector<GuideVertex>& o, const float m[16], const float h[3], C c) {
P k[8];
for (int i = 0; i < 8; ++i) k[i] = xf(m, {(i & 1) ? h[0] : -h[0], (i & 2) ? h[1] : -h[1], (i & 4) ? h[2] : -h[2]});
static const int e[12][2] = {{0, 1}, {2, 3}, {4, 5}, {6, 7}, {0, 2}, {1, 3}, {4, 6}, {5, 7}, {0, 4}, {1, 5}, {2, 6}, {3, 7}};
for (const auto& ed : e) line(o, k[ed[0]], k[ed[1]], c);
}
inline void circle(std::vector<GuideVertex>& o, P c, float r, C col, int segs = 32) {
for (int i = 0; i < segs; ++i) {
const float a0 = 6.2831853f * i / segs, a1 = 6.2831853f * (i + 1) / segs;
line(o, c + P{std::cos(a0) * r, 0, std::sin(a0) * r}, c + P{std::cos(a1) * r, 0, std::sin(a1) * r}, col);
}
}
// A marker on the floor: a ring and an arrow the way it faces (yaw 0 faces -Z), and a post to find it from afar.
inline void marker(std::vector<GuideVertex>& o, const WorldPoint& p, float r, float post, C c) {
const P at{p.pos[0], p.pos[1] + 0.02f, p.pos[2]};
circle(o, at, r, c);
const P f{-std::sin(p.yaw), 0, -std::cos(p.yaw)};
line(o, at, at + f * (r * 1.6f), c);
if (post > 0) line(o, at, at + P{0, post, 0}, guide::alpha(c, 0.6f));
}
// Colours as Frame Studio draws them (linear, straight alpha).
constexpr C kWalk{0.15f, 0.85f, 0.35f, 0.32f}, kWalkEdge{0.25f, 1.0f, 0.45f, 0.9f}, kBridged{0.95f, 0.75f, 0.2f, 0.25f};
constexpr C kSolid{0.95f, 0.95f, 0.95f, 0.55f}, kObject{1.0f, 0.55f, 0.25f, 0.7f}, kTrigger{0.6f, 0.6f, 1.0f, 0.45f}, kWalkable{0.3f, 1.0f, 0.5f, 0.7f};
constexpr C kSpawn{0.3f, 0.75f, 1.0f, 0.95f}, kSeat{1.0f, 0.8f, 0.25f, 0.95f}, kHotspot{0.85f, 0.45f, 1.0f, 0.95f};
constexpr C kSpot{0.4f, 0.9f, 1.0f, 0.8f}, kGravity{1.0f, 0.35f, 0.6f, 0.6f};
// Everything, in world space. Mesh surfaces are drawn as their boundary-free wire (every edge) only up to a budget:
// a mesh collider can be a whole room's geometry.
inline void build(const HomeWorld& w, const WalkGrid* g, std::vector<GuideVertex>& o) {
o.clear();
if (g) {
// (6 cm up: the walkable surface can be the collision under a carpet, and level with the carpet it flickers.)
// The floor you can stand on, cell by cell, each corner at the height the cells around it share (a ramp or a
// raked floor is a slope, not steps), and runs along x of flat cells at one height as one quad (a flat floor is
// a few hundred quads, not one per cell); its edge as the smooth line you walk to.
const float cs = g->p.cell, lift = 0.06f;
auto corner = [&](int cx, int cz, float y) { // the corner at the low x / low z side of cell (cx, cz)
float sum = 0;
int n = 0;
for (int j = -1; j <= 0; ++j)
for (int i = -1; i <= 0; ++i)
if (g->inside(cx + i, cz + j))
if (const WalkSpan* q = g->walkNear(cx + i, cz + j, y, g->p.step)) { sum += q->hi; ++n; }
return n ? sum / n : y;
};
for (int z = 0; z < g->nz; ++z) {
struct Run { int x0, x1; float h; bool bridged; };
std::vector<Run> open, next;
const float z0 = g->oz + z * cs, z1 = g->oz + (z + 1) * cs; // (one way for both neighbours, or a seam shows)
auto flush = [&](const Run& r) {
const float x0 = g->ox + r.x0 * cs, x1 = g->ox + (r.x1 + 1) * cs, y = r.h + lift;
const C col = r.bridged ? kBridged : kWalk;
guide::quad(o, {x0, y, z0}, col, {x1, y, z0}, col, {x1, y, z1}, col, {x0, y, z1}, col);
};
for (int x = 0; x < g->nx; ++x) {
next.clear();
for (const WalkSpan* sp = g->begin(x, z); sp != g->end(x, z); ++sp) {
if (!(sp->flags & (kWalkOk | kWalkNear)) || !g->walkable(g->centerX(x), g->centerZ(z), sp->hi)) continue;
const bool br = (sp->flags & kWalkBridged) != 0;
const float h00 = corner(x, z, sp->hi), h10 = corner(x + 1, z, sp->hi), h11 = corner(x + 1, z + 1, sp->hi), h01 = corner(x, z + 1, sp->hi);
const bool flat = std::fabs(h00 - h10) < 0.002f && std::fabs(h00 - h11) < 0.002f && std::fabs(h00 - h01) < 0.002f;
if (!flat) { // a slope: this cell alone, its corners where they are
const float x0 = g->ox + x * cs, x1 = g->ox + (x + 1) * cs;
const C col = br ? kBridged : kWalk;
guide::quad(o, {x0, h00 + lift, z0}, col, {x1, h10 + lift, z0}, col, {x1, h11 + lift, z1}, col, {x0, h01 + lift, z1}, col);
continue;
}
bool joined = false;
for (Run& r : open)
if (r.x1 == x - 1 && std::fabs(r.h - h00) < 0.002f && r.bridged == br) { r.x1 = x; next.push_back(r); r.x1 = -2; joined = true; break; }
if (!joined) next.push_back({x, x, h00, br});
}
for (const Run& r : open) if (r.x1 != -2) flush(r); // ended at x - 1
open.swap(next);
}
for (const Run& r : open) flush(r);
}
g->outline([](const float*, const float*, const float*, bool) {},
[&](const float* a, const float* b) { // lying flat: it's seen from above
const P pa{a[0], a[1] + 0.07f, a[2]}, pb{b[0], b[1] + 0.07f, b[2]};
const P u = guide::norm(guide::cross(pb - pa, P{0, 1, 0})) * 0.012f;
guide::quad(o, pa - u, kWalkEdge, pb - u, kWalkEdge, pb + u, kWalkEdge, pa + u, kWalkEdge);
});
}
size_t meshEdges = 0;
for (const WorldCollider& c : w.colliders) {
const C col = c.role == WorldCollider::Trigger ? kTrigger : c.role == WorldCollider::Object ? kObject : c.role == WorldCollider::Walkable ? kWalkable : kSolid;
switch (c.kind) {
case WorldCollider::Box: box(o, c.world, c.half, col); break;
case WorldCollider::Sphere: {
const P ctr = xf(c.world, {0, 0, 0});
circle(o, ctr, c.radius, col);
for (int i = 0; i < 24; ++i) { // and a vertical ring
const float a0 = 6.2831853f * i / 24, a1 = 6.2831853f * (i + 1) / 24;
line(o, ctr + P{std::cos(a0) * c.radius, std::sin(a0) * c.radius, 0}, ctr + P{std::cos(a1) * c.radius, std::sin(a1) * c.radius, 0}, col);
}
break;
}
case WorldCollider::Capsule: {
const P a = xf(c.world, {0, -c.length * 0.5f, 0}), b = xf(c.world, {0, c.length * 0.5f, 0});
circle(o, a, c.radius, col); circle(o, b, c.radius, col); line(o, a, b, col);
break;
}
default: { // each edge once (neighbouring triangles share them), as one ribbon facing up
const std::vector<float> wp = worldMeshPositions(c);
std::unordered_set<uint64_t> seen;
auto edge = [&](u32 i0, u32 i1) {
if (i0 > i1) std::swap(i0, i1);
if (meshEdges >= 25000 || static_cast<size_t>(i1) * 3 + 2 >= wp.size() || !seen.insert((static_cast<uint64_t>(i0) << 32) | i1).second) return;
++meshEdges;
const P a{wp[i0 * 3], wp[i0 * 3 + 1], wp[i0 * 3 + 2]}, b{wp[i1 * 3], wp[i1 * 3 + 1], wp[i1 * 3 + 2]};
const P d = guide::norm(b - a);
const P u = guide::norm(guide::cross(d, std::fabs(d.y) < 0.9f ? P{0, 1, 0} : P{1, 0, 0})) * 0.004f;
const C faint = guide::alpha(col, 0.45f); // a whole room's triangles: quieter than the boxes
guide::quad(o, a - u, faint, b - u, faint, b + u, faint, a + u, faint);
};
for (size_t t = 0; t + 2 < c.indices.size(); t += 3) {
edge(c.indices[t], c.indices[t + 1]); edge(c.indices[t + 1], c.indices[t + 2]); edge(c.indices[t + 2], c.indices[t]);
}
break;
}
}
}
for (const WorldPoint& p : w.spawns) marker(o, p, 0.3f, 1.8f, kSpawn);
for (const WorldPoint& p : w.hotspots) marker(o, p, 0.25f, 1.2f, kHotspot);
for (const WorldPoint& p : w.seats) {
marker(o, p, 0.18f, 0, kSeat);
const P at{p.pos[0], p.pos[1], p.pos[2]};
const P ex{p.pos[0] + p.exit[0], p.pos[1] + p.exit[1] + 0.02f, p.pos[2] + p.exit[2]};
if (p.exit[0] != 0 || p.exit[2] != 0) { line(o, at, ex, guide::alpha(kSeat, 0.5f)); circle(o, ex, 0.12f, guide::alpha(kSeat, 0.7f)); }
}
for (const WorldWallSpot& s : w.wallSpots) {
const float h[3] = {s.width * 0.5f, s.height * 0.5f, 0.01f};
box(o, s.world, h, kSpot);
}
for (const WorldGravity& z : w.gravity) {
if (z.kind == WorldGravity::Planet) circle(o, xf(z.world, {0, 0, 0}), z.radius, kGravity, 64);
else box(o, z.world, z.half, kGravity);
}
}
} // namespace debuggeom
+103
View File
@@ -0,0 +1,103 @@
#pragma once
// GENERATED by tools/debugview/gen_debug_spirv.sh from tools/debugview/debug.{vert,frag}. Do not edit by hand.
// vert: in vec3 position @0; set0 b0 buffer { Draw { mat4 clipFromModel[2]; mat4 worldFromModel; vec4 color; } d[]; } by gl_InstanceIndex
// frag: out = color, faceted shading when spec 0 = 1
#include <cstddef>
#include <cstdint>
static const uint32_t kDebugVertSpirv[] = {
0x07230203,0x00010300,0x000d000b,0x0000003f,0x00000000,0x00020011,0x00000001,0x00020011,
0x00001157,0x0006000b,0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,
0x00000000,0x00000001,0x000b000f,0x00000000,0x00000004,0x6e69616d,0x00000000,0x0000000d,
0x00000019,0x0000001b,0x00000022,0x0000002d,0x00000039,0x00030047,0x0000000b,0x00000002,
0x00050048,0x0000000b,0x00000000,0x0000000b,0x00000000,0x00050048,0x0000000b,0x00000001,
0x0000000b,0x00000001,0x00050048,0x0000000b,0x00000002,0x0000000b,0x00000003,0x00050048,
0x0000000b,0x00000003,0x0000000b,0x00000004,0x00040047,0x00000012,0x00000006,0x00000040,
0x00040048,0x00000013,0x00000000,0x00000005,0x00050048,0x00000013,0x00000000,0x00000007,
0x00000010,0x00050048,0x00000013,0x00000000,0x00000023,0x00000000,0x00040048,0x00000013,
0x00000001,0x00000005,0x00050048,0x00000013,0x00000001,0x00000007,0x00000010,0x00050048,
0x00000013,0x00000001,0x00000023,0x00000080,0x00050048,0x00000013,0x00000002,0x00000023,
0x000000c0,0x00040047,0x00000014,0x00000006,0x000000d0,0x00030047,0x00000015,0x00000002,
0x00040048,0x00000015,0x00000000,0x00000018,0x00050048,0x00000015,0x00000000,0x00000023,
0x00000000,0x00030047,0x00000017,0x00000018,0x00040047,0x00000017,0x00000021,0x00000000,
0x00040047,0x00000017,0x00000022,0x00000000,0x00040047,0x00000019,0x0000000b,0x0000002b,
0x00040047,0x0000001b,0x0000000b,0x00001158,0x00040047,0x00000022,0x0000001e,0x00000000,
0x00040047,0x0000002d,0x0000001e,0x00000000,0x00030047,0x00000039,0x0000000e,0x00040047,
0x00000039,0x0000001e,0x00000001,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,
0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,0x00000004,0x00040015,
0x00000008,0x00000020,0x00000000,0x0004002b,0x00000008,0x00000009,0x00000001,0x0004001c,
0x0000000a,0x00000006,0x00000009,0x0006001e,0x0000000b,0x00000007,0x00000006,0x0000000a,
0x0000000a,0x00040020,0x0000000c,0x00000003,0x0000000b,0x0004003b,0x0000000c,0x0000000d,
0x00000003,0x00040015,0x0000000e,0x00000020,0x00000001,0x0004002b,0x0000000e,0x0000000f,
0x00000000,0x00040018,0x00000010,0x00000007,0x00000004,0x0004002b,0x00000008,0x00000011,
0x00000002,0x0004001c,0x00000012,0x00000010,0x00000011,0x0005001e,0x00000013,0x00000012,
0x00000010,0x00000007,0x0003001d,0x00000014,0x00000013,0x0003001e,0x00000015,0x00000014,
0x00040020,0x00000016,0x0000000c,0x00000015,0x0004003b,0x00000016,0x00000017,0x0000000c,
0x00040020,0x00000018,0x00000001,0x0000000e,0x0004003b,0x00000018,0x00000019,0x00000001,
0x0004003b,0x00000018,0x0000001b,0x00000001,0x00040020,0x0000001d,0x0000000c,0x00000010,
0x00040017,0x00000020,0x00000006,0x00000003,0x00040020,0x00000021,0x00000001,0x00000020,
0x0004003b,0x00000021,0x00000022,0x00000001,0x0004002b,0x00000006,0x00000024,0x3f800000,
0x00040020,0x0000002a,0x00000003,0x00000007,0x00040020,0x0000002c,0x00000003,0x00000020,
0x0004003b,0x0000002c,0x0000002d,0x00000003,0x0004002b,0x0000000e,0x0000002f,0x00000001,
0x0004003b,0x0000002a,0x00000039,0x00000003,0x0004002b,0x0000000e,0x0000003b,0x00000002,
0x00040020,0x0000003c,0x0000000c,0x00000007,0x00050036,0x00000002,0x00000004,0x00000000,
0x00000003,0x000200f8,0x00000005,0x0004003d,0x0000000e,0x0000001a,0x00000019,0x0004003d,
0x0000000e,0x0000001c,0x0000001b,0x00080041,0x0000001d,0x0000001e,0x00000017,0x0000000f,
0x0000001a,0x0000000f,0x0000001c,0x0004003d,0x00000010,0x0000001f,0x0000001e,0x0004003d,
0x00000020,0x00000023,0x00000022,0x00050051,0x00000006,0x00000025,0x00000023,0x00000000,
0x00050051,0x00000006,0x00000026,0x00000023,0x00000001,0x00050051,0x00000006,0x00000027,
0x00000023,0x00000002,0x00070050,0x00000007,0x00000028,0x00000025,0x00000026,0x00000027,
0x00000024,0x00050091,0x00000007,0x00000029,0x0000001f,0x00000028,0x00050041,0x0000002a,
0x0000002b,0x0000000d,0x0000000f,0x0003003e,0x0000002b,0x00000029,0x00070041,0x0000001d,
0x00000030,0x00000017,0x0000000f,0x0000001a,0x0000002f,0x0004003d,0x00000010,0x00000031,
0x00000030,0x00050091,0x00000007,0x00000037,0x00000031,0x00000028,0x0008004f,0x00000020,
0x00000038,0x00000037,0x00000037,0x00000000,0x00000001,0x00000002,0x0003003e,0x0000002d,
0x00000038,0x00070041,0x0000003c,0x0000003d,0x00000017,0x0000000f,0x0000001a,0x0000003b,
0x0004003d,0x00000007,0x0000003e,0x0000003d,0x0003003e,0x00000039,0x0000003e,0x000100fd,
0x00010038,
};
static const size_t kDebugVertSize = sizeof(kDebugVertSpirv);
static const uint32_t kDebugFragSpirv[] = {
0x07230203,0x00010300,0x000d000b,0x00000049,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0008000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x0000000c,0x00000018,0x0000003b,
0x00030010,0x00000004,0x00000007,0x00030047,0x0000000c,0x0000000e,0x00040047,0x0000000c,
0x0000001e,0x00000001,0x00040047,0x00000010,0x00000001,0x00000000,0x00040047,0x00000018,
0x0000001e,0x00000000,0x00040047,0x0000003b,0x0000001e,0x00000000,0x00020013,0x00000002,
0x00030021,0x00000003,0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,
0x00000006,0x00000003,0x00040017,0x0000000a,0x00000006,0x00000004,0x00040020,0x0000000b,
0x00000001,0x0000000a,0x0004003b,0x0000000b,0x0000000c,0x00000001,0x00040015,0x0000000f,
0x00000020,0x00000001,0x00040032,0x0000000f,0x00000010,0x00000001,0x0004002b,0x0000000f,
0x00000011,0x00000001,0x00020014,0x00000012,0x00060034,0x00000012,0x00000013,0x000000aa,
0x00000010,0x00000011,0x00040020,0x00000017,0x00000001,0x00000007,0x0004003b,0x00000017,
0x00000018,0x00000001,0x0004002b,0x00000006,0x00000023,0x1e3ce508,0x0004002b,0x00000006,
0x0000002a,0x3eb2c0f1,0x0004002b,0x00000006,0x0000002b,0x3f590edb,0x0004002b,0x00000006,
0x0000002c,0x3ecc4a38,0x0006002c,0x00000007,0x0000002d,0x0000002a,0x0000002b,0x0000002c,
0x0004002b,0x00000006,0x00000031,0x3f800000,0x0004002b,0x00000006,0x00000033,0x3ed70a3d,
0x0004002b,0x00000006,0x00000034,0x3f147ae1,0x00040020,0x0000003a,0x00000003,0x0000000a,
0x0004003b,0x0000003a,0x0000003b,0x00000003,0x00040015,0x0000003d,0x00000020,0x00000000,
0x0004002b,0x0000003d,0x0000003e,0x00000003,0x00040020,0x0000003f,0x00000001,0x00000006,
0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,0x0004003d,
0x0000000a,0x0000000d,0x0000000c,0x0008004f,0x00000007,0x0000000e,0x0000000d,0x0000000d,
0x00000000,0x00000001,0x00000002,0x000300f7,0x00000015,0x00000000,0x000400fa,0x00000013,
0x00000014,0x00000015,0x000200f8,0x00000014,0x0004003d,0x00000007,0x00000019,0x00000018,
0x000400cf,0x00000007,0x0000001a,0x00000019,0x000400d0,0x00000007,0x0000001c,0x00000019,
0x0007000c,0x00000007,0x0000001d,0x00000001,0x00000044,0x0000001a,0x0000001c,0x00050094,
0x00000006,0x00000022,0x0000001d,0x0000001d,0x000500ba,0x00000012,0x00000024,0x00000022,
0x00000023,0x000300f7,0x00000027,0x00000000,0x000400fa,0x00000024,0x00000026,0x00000030,
0x000200f8,0x00000026,0x0006000c,0x00000007,0x00000029,0x00000001,0x00000045,0x0000001d,
0x00050094,0x00000006,0x0000002e,0x00000029,0x0000002d,0x0006000c,0x00000006,0x0000002f,
0x00000001,0x00000004,0x0000002e,0x000200f9,0x00000027,0x000200f8,0x00000030,0x000200f9,
0x00000027,0x000200f8,0x00000027,0x000700f5,0x00000006,0x00000046,0x0000002f,0x00000026,
0x00000031,0x00000030,0x00050085,0x00000006,0x00000036,0x00000034,0x00000046,0x00050081,
0x00000006,0x00000037,0x00000033,0x00000036,0x0005008e,0x00000007,0x00000039,0x0000000e,
0x00000037,0x000200f9,0x00000015,0x000200f8,0x00000015,0x000700f5,0x00000007,0x00000048,
0x0000000e,0x00000005,0x00000039,0x00000027,0x00050041,0x0000003f,0x00000040,0x0000000c,
0x0000003e,0x0004003d,0x00000006,0x00000041,0x00000040,0x00050051,0x00000006,0x00000042,
0x00000048,0x00000000,0x00050051,0x00000006,0x00000043,0x00000048,0x00000001,0x00050051,
0x00000006,0x00000044,0x00000048,0x00000002,0x00070050,0x0000000a,0x00000045,0x00000042,
0x00000043,0x00000044,0x00000041,0x0003003e,0x0000003b,0x00000045,0x000100fd,0x00010038,
};
static const size_t kDebugFragSize = sizeof(kDebugFragSpirv);
+143
View File
@@ -0,0 +1,143 @@
#pragma once
// Frame Home's side of the system environment (env_share.h): the shared state, the exported frame images and the
// connection to the compositor layer. frame-home --system renders into slot(), then publish() once the GPU is done.
#include "env_share.h"
#include "hsl_renderer.h"
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <new>
#include <string>
#include <vector>
class EnvExport {
public:
~EnvExport() { disconnect(); if (state_) ::munmap(state_, sizeof(envshare::State)); if (stateFd_ >= 0) ::close(stateFd_); }
bool init() {
stateFd_ = memfd_create("framehome-env", MFD_CLOEXEC);
if (stateFd_ < 0 || ftruncate(stateFd_, sizeof(envshare::State)) != 0) return false;
void* p = ::mmap(nullptr, sizeof(envshare::State), PROT_READ | PROT_WRITE, MAP_SHARED, stateFd_, 0);
if (p == MAP_FAILED) return false;
state_ = new (p) envshare::State();
state_->show = 0;
return true;
}
envshare::State* state() { return state_; }
// The images go before the renderer's device does (after VR_Shutdown; exit and restart alike).
void release(HslRenderer& r) { disconnect(); r.destroyExportImages(images_); }
bool connected() const { return sock_ >= 0 && sent_; }
// (Re)creates the images at the renderer's size; they go to the layer on the next connect().
bool createImages(HslRenderer& r) {
r.exportSlot = -1;
if (!r.createExportImages(envshare::kSlots, images_, memoryType_, uuid_)) return false;
usage_ = r.exportUsage();
flags_ = r.exportCreateFlags();
w_ = r.width();
h_ = r.height();
++generation_; // the layer keeps showing the last frame of the old images until it has these
if (sent_) sent_ = false, lastTry_ = {};
return true;
}
// Connects to the layer (once a second at most) and sends the images; false while there's no layer.
bool connect() {
if (connected()) return true;
const auto now = std::chrono::steady_clock::now();
if (now - lastTry_ < std::chrono::seconds(1) || images_.empty()) return false;
lastTry_ = now;
if (sock_ < 0) {
const char* rt = std::getenv("XDG_RUNTIME_DIR");
const std::string path = std::string(rt && *rt ? rt : "/run/user/1000") + "/" + envshare::kSocketName;
sock_ = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
sockaddr_un a = {};
a.sun_family = AF_UNIX;
std::snprintf(a.sun_path, sizeof a.sun_path, "%s", path.c_str());
if (sock_ < 0 || ::connect(sock_, reinterpret_cast<sockaddr*>(&a), sizeof a) != 0) { disconnect(); return false; }
}
envshare::Hello h;
h.width = w_;
h.height = h_;
h.layers = 2;
h.format = 43; // VK_FORMAT_R8G8B8A8_SRGB
h.memoryType = memoryType_;
h.generation = generation_;
h.usage = usage_;
h.flags = flags_;
std::memcpy(h.deviceUUID, uuid_, 16);
int fds[envshare::kSlots + 1];
for (uint32_t i = 0; i < envshare::kSlots; ++i) { h.memorySize[i] = images_[i].size; fds[i] = images_[i].fd; }
fds[envshare::kSlots] = stateFd_;
iovec iov = {&h, sizeof h};
char ctrl[CMSG_SPACE(sizeof fds)] = {};
msghdr m = {};
m.msg_iov = &iov;
m.msg_iovlen = 1;
m.msg_control = ctrl;
m.msg_controllen = sizeof ctrl;
cmsghdr* cm = CMSG_FIRSTHDR(&m);
cm->cmsg_level = SOL_SOCKET;
cm->cmsg_type = SCM_RIGHTS;
cm->cmsg_len = CMSG_LEN(sizeof fds);
std::memcpy(CMSG_DATA(cm), fds, sizeof fds);
if (::sendmsg(sock_, &m, MSG_NOSIGNAL) != static_cast<ssize_t>(sizeof h)) { disconnect(); return false; }
sent_ = true;
std::fprintf(stderr, "[frame-home] connected to the compositor: Frame Home is the environment (%ux%u per eye)\n", w_, h_);
return true;
}
// Is the layer still there? (it closes the socket when the compositor goes away)
void poll() {
if (sock_ < 0) return;
char b;
const ssize_t n = ::recv(sock_, &b, 1, MSG_DONTWAIT);
if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) {
std::fprintf(stderr, "[frame-home] the compositor went away: reconnecting\n");
disconnect();
}
}
// The slot to render the next frame into.
int beginFrame() { const int k = static_cast<int>(next_); next_ = (next_ + 1) % envshare::kSlots; return k; }
// The frame in slot k is done on the GPU: hand it to the layer with the eye poses it was rendered from.
void publish(int k, const float viewProj[2][16], const float eyePos[2][3]) {
envshare::Slot& s = state_->slot[k];
s.frame = ++frame_;
s.generation = generation_;
std::memcpy(s.viewProj, viewProj, sizeof s.viewProj);
for (int e = 0; e < 2; ++e) { for (int k = 0; k < 3; ++k) s.eyePos[e][k] = eyePos[e][k]; s.eyePos[e][3] = 1; }
state_->published.store(frame_);
state_->latest.store(static_cast<uint32_t>(k), std::memory_order_release);
state_->publishNs.store(envshare::nowNs());
}
// The layer couldn't import the images with these create flags (0: none refused).
uint32_t refusedFlags() const { return state_->refusedFlags.load() & flags_; }
// Seconds since the layer last drew the home (large while a game runs, or before the layer is there).
double sinceSampled() const {
const uint64_t s = state_->sampledNs.load();
return s ? (envshare::nowNs() - s) / 1e9 : 1e9;
}
private:
void disconnect() { if (sock_ >= 0) ::close(sock_); sock_ = -1; sent_ = false; }
envshare::State* state_ = nullptr;
int stateFd_ = -1, sock_ = -1;
bool sent_ = false;
std::vector<HslRenderer::ExportImage> images_;
uint32_t memoryType_ = 0, w_ = 0, h_ = 0, next_ = 0, usage_ = 0, flags_ = 0;
uint8_t uuid_[16] = {};
uint64_t frame_ = 0;
uint32_t generation_ = 0;
std::chrono::steady_clock::time_point lastTry_;
};
+73
View File
@@ -0,0 +1,73 @@
#pragma once
// Frame Home as the system environment: what Frame Home (frame-home --system) and the compositor layer
// (layer/frame_home_layer.cpp, inside vrcompositor) share.
//
// Frame Home renders the home for both eyes into one of kSlots exportable 2-layer images (Vulkan external memory,
// opaque fds) and publishes it here once the GPU has finished it. The layer imports the images on the compositor's
// device and, where the compositor draws Aurora, draws a shader that looks each pixel's direction up in the newest
// published frame (rotation reprojection from the pose that frame was rendered with). No frame, or a stale one,
// and the layer leaves Aurora alone: Frame Home stopping or crashing never blanks the headset.
//
// Hand-over: Frame Home connects to the layer's unix socket (kSocketName in $XDG_RUNTIME_DIR) and sends one Hello
// with SCM_RIGHTS fds: kSlots image memories, then the shared State (a memfd). A new Hello (new size) replaces the
// old images.
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <ctime>
namespace envshare {
constexpr const char* kSocketName = "framehome-env.sock";
constexpr uint32_t kMagic = 0x46484556; // "FHEV"
constexpr uint32_t kVersion = 5; // 4: Hello says the images' usage (Frame Home draws straight into them)
// 5: ...and their create flags (subsampled: the compositor smooths foveation)
// Four: the compositor reads the newest while Frame Home publishes the one before it finished and renders the next.
constexpr uint32_t kSlots = 4;
struct Hello {
uint32_t magic = kMagic, version = kVersion;
uint32_t width = 0, height = 0, layers = 2;
uint32_t format = 0; // VkFormat of the images (R8G8B8A8_SRGB)
uint32_t memoryType = 0; // memory type index the images were allocated from (same GPU)
uint32_t dedicated = 1; // allocated as dedicated allocations
uint64_t memorySize[kSlots] = {};
uint8_t deviceUUID[16] = {}; // the layer refuses images from another GPU
uint32_t generation = 0; // which set of images (a new Hello after a resize is a new generation)
uint32_t usage = 0; // v4: VkImageUsageFlags they were created with (the import must match)
uint32_t flags = 0; // v5: VkImageCreateFlags they were created with (likewise). SUBSAMPLED: the
// foveated pass's own output, read through a subsampled sampler
};
// Older Hellos: the same without the later fields. v3: no `usage` (images made for copying into: TRANSFER_DST |
// SAMPLED); v4: no `flags` (none).
constexpr size_t kHelloV3Size = offsetof(Hello, usage);
constexpr size_t kHelloV4Size = offsetof(Hello, flags);
struct Slot {
uint64_t frame = 0; // frame number published into this slot (0 = never)
uint32_t generation = 0; // the image generation it was rendered into (see Hello)
float viewProj[2][16] = {}; // per eye: projection * view rotation (column-major), tracking space
float eyePos[2][4] = {}; // per eye: where it was, tracking space (xyz)
};
struct State {
uint32_t magic = kMagic, version = kVersion;
std::atomic<uint32_t> latest{0xFFFFFFFFu}; // newest finished slot (0xFFFFFFFF none)
std::atomic<uint64_t> published{0}; // frame number of `latest`
std::atomic<uint64_t> publishNs{0}; // CLOCK_MONOTONIC when it was published
std::atomic<uint64_t> sampledNs{0}; // the layer: when it last drew the home (Frame Home idles if stale)
std::atomic<uint32_t> fade{1000}; // brightness, 0..1000 (switching homes fades through black)
std::atomic<uint32_t> show{1}; // 0: Frame Home has nothing to show yet (the layer draws black)
std::atomic<uint64_t> busyUntilNs{0}; // Frame Home is uploading a home until then: keep the last frame (black)
Slot slot[kSlots];
std::atomic<uint32_t> refusedFlags{0}; // the layer: Hello flags it can't import with (Frame Home then sends
// images without them)
};
inline uint64_t nowNs() {
timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return static_cast<uint64_t>(ts.tv_sec) * 1000000000ull + static_cast<uint64_t>(ts.tv_nsec);
}
} // namespace envshare
+446
View File
@@ -0,0 +1,446 @@
#pragma once
// .fenv — a Frame environment in one file: the home's APKs (stored, not recompressed: they are already zips),
// Frame Studio's object edits, imported models (glTF / GLB / FBX / OBJ from Blender or Unity, with the texture
// files they reference) and a thumbnail, described by fenv.json:
//
// { "format": "fenv", "version": 1, "name": "Haven + Calming",
// "sources": [ { "file": "apks/haven2025.apk", "role": "Footprint", "format": "HSL",
// "package": "com.meta.shell.env.footprint.haven2025", "version": "206.0.0.0.70" } ],
// "models": [ { "file": "models/chair/chair.glb" } ],
// "edits": "edits.json", "thumbnail": "thumb.png" }
//
// Frame Studio writes and opens it; Frame Home and the switcher on the Steam Frame load it directly. Opening
// extracts the members once into a cache folder keyed by the file's path, size and time, then loads them from there.
// A member named "<file>.lz" is an LZMA stream (fenv_lzma.h) and is unpacked to <file> — the baked scene is stored
// so ("scene.bin.lz"): the zip's own deflate is far weaker on ASTC textures.
#include "core/tinyjson.h"
#include "world_settings.h"
#ifdef _WIN32
#include <process.h>
#include <windows.h> // LockFileEx (the builds define NOMINMAX / WIN32_LEAN_AND_MEAN)
#else
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
#endif
#include "fenv_lzma.h"
#include "miniz.h"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <exception>
#include <functional>
#include <string>
#include <system_error>
#include <vector>
struct FenvSource {
std::string file, role, format, package, version;
};
struct FenvManifest {
std::string name;
std::vector<FenvSource> sources; // footprint first, then the vista (or one whole home)
std::vector<std::string> models; // imported model files (member paths)
std::string edits, thumbnail; // member paths, empty when absent
std::string scene; // the baked scene (scene_bake.h): when present it IS the home, sources are info
std::string world; // world.json (core/world.h): spawns, seats, walls, walkable areas
WorldSettings settings; // the world's own render settings (world_settings.h): none set = the headset's
};
namespace fenv_detail {
inline std::string quote(const std::string& s) {
std::string o = "\"";
for (char c : s) {
if (c == '"' || c == '\\') { o += '\\'; o += c; }
else if (static_cast<unsigned char>(c) < 0x20) { char b[8]; std::snprintf(b, sizeof b, "\\u%04x", c); o += b; }
else o += c;
}
return o + "\"";
}
inline std::string manifestJson(const FenvManifest& m) {
std::string j = "{\n \"format\": \"fenv\",\n \"version\": 1,\n \"name\": " + quote(m.name) + ",\n \"sources\": [";
for (size_t i = 0; i < m.sources.size(); ++i) {
const FenvSource& s = m.sources[i];
j += std::string(i ? "," : "") + "\n { \"file\": " + quote(s.file) + ", \"role\": " + quote(s.role) + ", \"format\": " +
quote(s.format) + ", \"package\": " + quote(s.package) + ", \"version\": " + quote(s.version) + " }";
}
j += "\n ],\n \"models\": [";
for (size_t i = 0; i < m.models.size(); ++i) j += std::string(i ? "," : "") + "\n { \"file\": " + quote(m.models[i]) + " }";
j += "\n ]";
if (!m.edits.empty()) j += ",\n \"edits\": " + quote(m.edits);
if (!m.thumbnail.empty()) j += ",\n \"thumbnail\": " + quote(m.thumbnail);
if (!m.scene.empty()) j += ",\n \"scene\": " + quote(m.scene);
if (!m.world.empty()) j += ",\n \"world\": " + quote(m.world);
if (!m.settings.empty() || !m.settings.tuned.empty()) j += ",\n \"settings\": " + m.settings.toJson();
return j + "\n}\n";
}
// Everything one .fenv may unpack to (its members, LZMA ones as unpacked): a damaged or hostile file must not
// fill the disk or memory. The largest homes are a few hundred MB.
constexpr uint64_t kExtractBudget = uint64_t(4) << 30;
// Empty when missing or larger than `limit` (the zip's declared size, which miniz holds the inflate to).
inline std::vector<uint8_t> readMember(mz_zip_archive& z, const char* name, uint64_t limit = uint64_t(64) << 20) {
std::vector<uint8_t> out;
const int i = mz_zip_reader_locate_file(&z, name, nullptr, 0);
mz_zip_archive_file_stat st;
if (i < 0 || !mz_zip_reader_file_stat(&z, static_cast<mz_uint>(i), &st) || st.m_uncomp_size > limit || st.m_comp_size > limit) return out;
size_t n = 0;
void* p = mz_zip_reader_extract_to_heap(&z, static_cast<mz_uint>(i), &n, 0);
if (!p) return out;
try { out.assign(static_cast<uint8_t*>(p), static_cast<uint8_t*>(p) + n); } catch (const std::exception&) { out = {}; }
mz_free(p);
return out;
}
// A short tag for a file's full path: two "haven.fenv" in different folders get different cache folders.
inline std::string pathTag(const std::filesystem::path& p) {
std::error_code ec;
std::filesystem::path full = std::filesystem::weakly_canonical(p, ec);
if (ec || full.empty()) full = std::filesystem::absolute(p, ec).lexically_normal();
std::string s = full.u8string();
#ifdef _WIN32
for (char& c : s) { if (c >= 'A' && c <= 'Z') c = static_cast<char>(c - 'A' + 'a'); else if (c == '/') c = '\\'; } // one file, however spelled
#endif
uint64_t h = 1469598103934665603ull; // FNV-1a
for (unsigned char c : s) h = (h ^ c) * 1099511628211ull;
char b[16];
std::snprintf(b, sizeof b, "%08x", static_cast<unsigned>(h ^ (h >> 32)));
return b;
}
// One unpacker per cache key at a time, across processes (Frame Home and the switcher may open the same
// environment together; on Windows, two Studio windows).
struct ExtractLock {
#ifdef _WIN32
HANDLE h = INVALID_HANDLE_VALUE;
OVERLAPPED ov = {};
explicit ExtractLock(const std::filesystem::path& p) {
h = CreateFileW(p.wstring().c_str(), GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr,
OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h != INVALID_HANDLE_VALUE && !LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK, 0, MAXDWORD, MAXDWORD, &ov)) { CloseHandle(h); h = INVALID_HANDLE_VALUE; }
}
~ExtractLock() { if (h != INVALID_HANDLE_VALUE) { UnlockFileEx(h, 0, MAXDWORD, MAXDWORD, &ov); CloseHandle(h); } }
#else
int fd;
explicit ExtractLock(const std::filesystem::path& p) : fd(open(p.c_str(), O_RDWR | O_CREAT | O_CLOEXEC, 0600)) { if (fd >= 0) flock(fd, LOCK_EX); }
~ExtractLock() { if (fd >= 0) { flock(fd, LOCK_UN); close(fd); } }
#endif
ExtractLock(const ExtractLock&) = delete;
ExtractLock& operator=(const ExtractLock&) = delete;
};
inline long processId() {
#ifdef _WIN32
return static_cast<long>(_getpid());
#else
return static_cast<long>(getpid());
#endif
}
// A member path is safe to extract: relative, no "..", no drive letters.
inline bool safeMember(const std::string& m) {
if (m.empty() || m[0] == '/' || m[0] == '\\' || m.find(':') != std::string::npos) return false;
for (size_t a = 0; a <= m.size();) {
size_t b = m.find_first_of("/\\", a);
if (b == std::string::npos) b = m.size();
if (m.compare(a, b - a, "..") == 0 && b - a == 2) return false;
a = b + 1;
}
return true;
}
} // namespace fenv_detail
// Where opened .fenv files are unpacked: FRAME_FENV_CACHE, else %LOCALAPPDATA%\FrameStudio\cache (Windows) or
// $XDG_CACHE_HOME / ~/.cache /frame-home/fenv.
inline std::string fenvCacheDir() {
if (const char* e = std::getenv("FRAME_FENV_CACHE")) return e;
#ifdef _WIN32
if (const char* l = std::getenv("LOCALAPPDATA")) return std::string(l) + "\\FrameStudio\\cache";
#endif
if (const char* x = std::getenv("XDG_CACHE_HOME")) return std::string(x) + "/frame-home/fenv";
const char* h = std::getenv("HOME");
return std::string(h ? h : "/tmp") + "/.cache/frame-home/fenv";
}
inline bool isFenvPath(const std::string& p) {
return p.size() > 5 && (p.compare(p.size() - 5, 5, ".fenv") == 0 || p.compare(p.size() - 5, 5, ".FENV") == 0);
}
inline bool fenvParseManifest(const std::string& json, FenvManifest& m) {
try {
const tinyjson::Value doc = tinyjson::parse(json);
if (!doc.isObject() || doc["format"].asString() != "fenv") return false;
m = FenvManifest{};
m.name = doc["name"].asString();
for (size_t i = 0; i < doc["sources"].size(); ++i) {
const auto& s = doc["sources"][i];
FenvSource src;
src.file = s["file"].asString(); src.role = s["role"].asString(); src.format = s["format"].asString();
src.package = s["package"].asString(); src.version = s["version"].asString();
if (fenv_detail::safeMember(src.file)) m.sources.push_back(src);
}
for (size_t i = 0; i < doc["models"].size(); ++i) {
const std::string f = doc["models"][i]["file"].asString();
if (fenv_detail::safeMember(f)) m.models.push_back(f);
}
if (doc.has("edits") && fenv_detail::safeMember(doc["edits"].asString())) m.edits = doc["edits"].asString();
if (doc.has("thumbnail") && fenv_detail::safeMember(doc["thumbnail"].asString())) m.thumbnail = doc["thumbnail"].asString();
if (doc.has("scene") && fenv_detail::safeMember(doc["scene"].asString())) m.scene = doc["scene"].asString();
if (doc.has("world") && fenv_detail::safeMember(doc["world"].asString())) m.world = doc["world"].asString();
if (doc.has("settings")) m.settings.fromValue(doc["settings"]);
return !m.sources.empty() || !m.models.empty() || !m.scene.empty();
} catch (...) {
return false;
}
}
// Reads fenv.json (and, if asked, the thumbnail bytes) without extracting anything else.
inline bool fenvReadManifest(const std::string& path, FenvManifest& m, std::vector<uint8_t>* thumb = nullptr) {
mz_zip_archive z = {};
if (!mz_zip_reader_init_file(&z, path.c_str(), 0)) return false;
const std::vector<uint8_t> j = fenv_detail::readMember(z, "fenv.json");
bool ok = !j.empty() && fenvParseManifest(std::string(j.begin(), j.end()), m);
if (ok && thumb && !m.thumbnail.empty()) *thumb = fenv_detail::readMember(z, m.thumbnail.c_str());
mz_zip_reader_end(&z);
return ok;
}
// Extracts every member into <cacheRoot>/<name>-<path tag>-<size>-<time>/ (once) and returns the sources' and
// models' paths there, plus the edits file ("" when the environment has none).
inline bool fenvExtract(const std::string& path, const std::string& cacheRoot, FenvManifest& m,
std::vector<std::string>& sources, std::vector<std::string>& models, std::string& editsPath,
std::string& error) {
namespace fs = std::filesystem;
std::error_code ec;
const fs::path src = fs::u8path(path);
const auto size = fs::file_size(src, ec);
// (a 128-bit count on some standard libraries: no to_string for it, and 64 bits hold any file's date)
const long long time = static_cast<long long>(fs::last_write_time(src, ec).time_since_epoch().count());
if (!fenvReadManifest(path, m)) { error = "not a Frame environment (.fenv): " + path; return false; }
const std::string stem = src.stem().u8string(), key = stem + "-" + fenv_detail::pathTag(src);
const fs::path root = fs::u8path(cacheRoot);
const fs::path dir = root / (key + "-" + std::to_string(size) + "-" + std::to_string(time));
const fs::path done = dir / ".complete";
fs::create_directories(root, ec);
const fenv_detail::ExtractLock lock(root / (key + ".lock"));
if (!fs::exists(done, ec)) {
// Earlier versions of this environment (same file, other size / time) are stale now, as are folders of the
// naming before the path tag ("<name>-<size>-<time>") and unpacks that never finished (".tmp-<pid>": the
// lock is ours, so nobody is still writing one).
const auto digits = [](std::string t) { // the time part can be negative
if (!t.empty() && t[0] == '-') t.erase(0, 1);
return !t.empty() && t.find_first_not_of("0123456789") == std::string::npos;
};
const auto sizeTime = [&](const std::string& rest) { // exactly "<size>-<time>"
const size_t dash = rest.find('-');
return dash != std::string::npos && digits(rest.substr(0, dash)) && digits(rest.substr(dash + 1));
};
for (const auto& e : fs::directory_iterator(root, ec)) {
if (!e.is_directory(ec) || e.path() == dir) continue;
const std::string f = e.path().filename().u8string();
// "haven-1a2b3c4d-123-456" is this haven's, "haven-calming-..." and another folder's haven are not
bool ours = f.rfind(key + "-", 0) == 0;
if (ours) {
std::string rest = f.substr(key.size() + 1);
const size_t t = rest.find(".tmp-");
if (t != std::string::npos && digits(rest.substr(t + 5))) rest.erase(t);
ours = sizeTime(rest);
}
const bool legacy = f.rfind(stem + "-", 0) == 0 && sizeTime(f.substr(stem.size() + 1));
if (ours || legacy) fs::remove_all(e.path(), ec);
}
// Unpacked beside the cache folder and renamed into place once whole: a crash or a full disk midway
// leaves no folder that looks finished.
const fs::path tmp = fs::u8path(dir.u8string() + ".tmp-" + std::to_string(fenv_detail::processId()));
fs::remove_all(tmp, ec);
fs::remove_all(dir, ec);
fs::create_directories(tmp, ec);
mz_zip_archive z = {};
if (!mz_zip_reader_init_file(&z, path.c_str(), 0)) { error = "can't open " + path; fs::remove_all(tmp, ec); return false; }
const mz_uint count = mz_zip_reader_get_num_files(&z);
uint64_t claimed = 0; // what the zip says it holds, before anything is written
for (mz_uint i = 0; i < count; ++i) {
mz_zip_archive_file_stat st;
if (mz_zip_reader_file_stat(&z, i, &st) && !st.m_is_directory) claimed += std::max(st.m_comp_size, st.m_uncomp_size);
}
bool ok = claimed <= fenv_detail::kExtractBudget;
if (!ok) error = path + " claims " + std::to_string(claimed >> 20) + " MB unpacked: damaged";
uint64_t written = 0;
for (mz_uint i = 0; i < count && ok; ++i) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&z, i, &st) || st.m_is_directory) continue;
const std::string name = st.m_filename;
if (!fenv_detail::safeMember(name)) continue;
const fs::path out = tmp / fs::u8path(name);
fs::create_directories(out.parent_path(), ec);
if (name.size() > 3 && name.compare(name.size() - 3, 3, ".lz") == 0) {
std::string why;
try {
std::vector<uint8_t> packed = fenv_detail::readMember(z, name.c_str(), fenv_detail::kExtractBudget - written), raw;
ok = !packed.empty() && fenvLzmaDecompress(packed.data(), packed.size(), raw, why);
packed = std::vector<uint8_t>();
if (ok && raw.size() > fenv_detail::kExtractBudget - written) { ok = false; why = "larger than any environment"; }
if (ok) {
written += raw.size();
const fs::path plain = tmp / fs::u8path(name.substr(0, name.size() - 3));
std::ofstream f(plain, std::ios::binary);
f.write(reinterpret_cast<const char*>(raw.data()), static_cast<std::streamsize>(raw.size()));
ok = static_cast<bool>(f);
}
} catch (const std::exception&) { // bad_alloc: the scene doesn't fit in this memory
ok = false;
why = "not enough memory";
}
if (!ok) error = "couldn't unpack " + name + " from " + path + (why.empty() ? "" : ": " + why);
continue;
}
written += std::max(st.m_comp_size, st.m_uncomp_size);
ok = written <= fenv_detail::kExtractBudget && mz_zip_reader_extract_to_file(&z, i, out.u8string().c_str(), 0);
if (!ok) error = "couldn't extract " + name + " from " + path;
}
mz_zip_reader_end(&z);
if (ok) {
std::ofstream f(tmp / ".complete");
f << "ok\n";
f.close(); // (closed before the rename: Windows won't move a folder with open files)
ok = static_cast<bool>(f);
if (!ok) error = "couldn't write the cache for " + path;
}
if (ok) {
fs::rename(tmp, dir, ec);
ok = !ec;
if (!ok) error = "couldn't finish the cache for " + path + ": " + ec.message();
}
if (!ok) { fs::remove_all(tmp, ec); return false; }
}
sources.clear();
models.clear();
for (const FenvSource& s : m.sources)
if (fs::exists(dir / fs::u8path(s.file), ec)) sources.push_back((dir / fs::u8path(s.file)).u8string());
for (const std::string& f : m.models) models.push_back((dir / fs::u8path(f)).u8string());
editsPath = m.edits.empty() ? std::string() : (dir / fs::u8path(m.edits)).u8string();
if (!m.scene.empty()) m.scene = (dir / fs::u8path(m.scene)).u8string(); // now the extracted file
if (!m.world.empty()) m.world = (dir / fs::u8path(m.world)).u8string();
return true;
}
// One member to write: its path inside the .fenv and either a file on disk or bytes.
struct FenvMember {
std::string name, file;
std::vector<uint8_t> bytes;
bool store = false; // already compressed (APKs, PNG/JPG, GLB): stored as-is
bool fast = false; // large: deflated at the fastest level (inflates as fast; saving is 2x quicker)
bool lzma = false; // packed with LZMA as "<name>.lz" (the baked scene: much smaller than deflate)
FenvPacking packing = FenvPacking::Smallest;
};
// progress(done, total): LZMA members, as they are packed (from the packing threads).
inline bool fenvWrite(const std::string& outPath, const FenvManifest& m, const std::vector<FenvMember>& members, std::string& error,
const std::function<void(size_t, size_t)>& progress = {}) {
namespace fs = std::filesystem;
const std::string tmp = outPath + ".part";
mz_zip_archive z = {};
if (!mz_zip_writer_init_file_v2(&z, tmp.c_str(), 0, 0)) { error = "can't write " + outPath; return false; }
const std::string json = fenv_detail::manifestJson(m);
bool ok = mz_zip_writer_add_mem(&z, "fenv.json", json.data(), json.size(), MZ_DEFAULT_COMPRESSION);
for (const FenvMember& f : members) {
if (!ok) break;
const mz_uint level = f.store ? MZ_NO_COMPRESSION : f.fast ? MZ_BEST_SPEED : MZ_DEFAULT_COMPRESSION;
if (f.lzma) {
std::vector<uint8_t> packed;
std::string why;
ok = fenvLzmaCompress(f.bytes.data(), f.bytes.size(), packed, why, f.packing, progress) &&
mz_zip_writer_add_mem(&z, (f.name + ".lz").c_str(), packed.data(), packed.size(), MZ_NO_COMPRESSION);
if (!ok) error = "couldn't pack " + f.name + (why.empty() ? "" : ": " + why);
continue;
}
ok = f.file.empty() ? mz_zip_writer_add_mem(&z, f.name.c_str(), f.bytes.data(), f.bytes.size(), level)
: mz_zip_writer_add_file(&z, f.name.c_str(), f.file.c_str(), nullptr, 0, level);
if (!ok) error = "couldn't add " + (f.file.empty() ? f.name : f.file);
}
ok = ok && mz_zip_writer_finalize_archive(&z);
mz_zip_writer_end(&z);
std::error_code ec;
if (!ok) { fs::remove(fs::u8path(tmp), ec); if (error.empty()) error = "couldn't finish " + outPath; return false; }
fs::rename(fs::u8path(tmp), fs::u8path(outPath), ec);
if (ec) { fs::remove(fs::u8path(outPath), ec); fs::rename(fs::u8path(tmp), fs::u8path(outPath), ec); }
if (ec) { error = "couldn't replace " + outPath + ": " + ec.message(); return false; }
return true;
}
// A new manifest for an existing .fenv, every other member copied over as it is (nothing re-packed): how a world's
// settings change without saving the whole world again.
inline bool fenvRewriteManifest(const std::string& path, const FenvManifest& m, std::string& error) {
namespace fs = std::filesystem;
mz_zip_archive in = {}, out = {};
if (!mz_zip_reader_init_file(&in, path.c_str(), 0)) { error = "can't read " + path; return false; }
const std::string tmp = path + ".part";
if (!mz_zip_writer_init_file_v2(&out, tmp.c_str(), 0, 0)) { mz_zip_reader_end(&in); error = "can't write " + tmp; return false; }
const std::string json = fenv_detail::manifestJson(m);
bool ok = mz_zip_writer_add_mem(&out, "fenv.json", json.data(), json.size(), MZ_DEFAULT_COMPRESSION);
const mz_uint n = mz_zip_reader_get_num_files(&in);
for (mz_uint i = 0; ok && i < n; ++i) {
char name[512];
mz_zip_reader_get_filename(&in, i, name, sizeof name);
if (std::strcmp(name, "fenv.json") == 0) continue;
ok = mz_zip_writer_add_from_zip_reader(&out, &in, i);
if (!ok) error = std::string("couldn't copy ") + name;
}
ok = ok && mz_zip_writer_finalize_archive(&out);
mz_zip_writer_end(&out);
mz_zip_reader_end(&in);
std::error_code ec;
if (!ok) { fs::remove(fs::u8path(tmp), ec); if (error.empty()) error = "couldn't finish " + tmp; return false; }
fs::rename(fs::u8path(tmp), fs::u8path(path), ec);
if (ec) { fs::remove(fs::u8path(tmp), ec); error = "couldn't replace " + path + ": " + ec.message(); return false; }
return true;
}
// A home's own settings: a .fenv's manifest, else "<apk>.settings.json" beside it (none: all unset).
inline WorldSettings worldSettingsFor(const std::string& homePath) {
WorldSettings w;
if (isFenvPath(homePath)) {
FenvManifest m;
if (fenvReadManifest(homePath, m)) w = m.settings;
return w;
}
std::FILE* f = std::fopen(worldSettingsPathFor(homePath).c_str(), "rb");
if (!f) return w;
std::string j;
char b[4096];
for (size_t k; (k = std::fread(b, 1, sizeof b, f)) > 0;) j.append(b, k);
std::fclose(f);
if (!w.fromJson(j)) w = WorldSettings();
return w;
}
// Stores them: into the .fenv's manifest, or the APK's settings file (removed when none are set).
inline bool setWorldSettings(const std::string& homePath, const WorldSettings& w, std::string& error) {
if (isFenvPath(homePath)) {
FenvManifest m;
if (!fenvReadManifest(homePath, m)) { error = "not a Frame environment (.fenv): " + homePath; return false; }
m.settings = w;
return fenvRewriteManifest(homePath, m, error);
}
const std::string p = worldSettingsPathFor(homePath);
std::error_code ec;
if (w.empty() && w.tuned.empty()) { std::filesystem::remove(std::filesystem::u8path(p), ec); return true; }
const std::string j = w.toJson() + "\n", tmp = p + ".part";
std::FILE* f = std::fopen(tmp.c_str(), "wb");
if (!f || std::fwrite(j.data(), 1, j.size(), f) != j.size()) { if (f) std::fclose(f); error = "can't write " + p; return false; }
std::fclose(f);
std::filesystem::rename(std::filesystem::u8path(tmp), std::filesystem::u8path(p), ec);
if (ec) { error = "can't write " + p + ": " + ec.message(); return false; }
return true;
}
+152
View File
@@ -0,0 +1,152 @@
#include "fenv_lzma.h"
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <stdexcept>
#include <string>
#include <thread>
extern "C" {
#include "LzmaDec.h"
#include "LzmaEnc.h"
}
namespace {
void* lzAlloc(ISzAllocPtr, size_t size) { return ::operator new(size, std::nothrow); }
void lzFree(ISzAllocPtr, void* p) { ::operator delete(p); }
const ISzAlloc kAlloc = {lzAlloc, lzFree};
struct Progress {
ICompressProgress vt;
std::atomic<size_t>* done;
size_t last = 0, total;
const std::function<void(size_t, size_t)>* cb;
};
SRes onProgress(ICompressProgressPtr p, UInt64 in, UInt64) {
Progress* pr = reinterpret_cast<Progress*>(const_cast<ICompressProgress*>(p)); // vt is the first member
const size_t now = static_cast<size_t>(in);
if (now > pr->last) { *pr->done += now - pr->last; pr->last = now; }
if (*pr->cb) (*pr->cb)(pr->done->load(), pr->total);
return SZ_OK;
}
struct Chunk { size_t at = 0, size = 0; std::vector<uint8_t> packed; uint8_t props[5] = {}; SRes res = SZ_OK; };
void put64(std::vector<uint8_t>& o, uint64_t v) { for (int i = 0; i < 8; ++i) o.push_back(static_cast<uint8_t>(v >> (8 * i))); }
uint64_t get64(const uint8_t* p) { uint64_t v = 0; for (int i = 7; i >= 0; --i) v = v << 8 | p[i]; return v; }
// Runs fn(i) for i in [0, n) on up to `threads` threads.
template <typename F> void parallel(size_t n, unsigned threads, F&& fn) {
std::atomic<size_t> next{0};
std::vector<std::thread> pool;
for (unsigned t = 0; t < std::min<size_t>(threads, n); ++t)
pool.emplace_back([&] { for (size_t i; (i = next++) < n;) fn(i); });
for (auto& th : pool) th.join();
}
} // namespace
bool fenvLzmaIsStream(const uint8_t* data, size_t size) {
return size >= 8 && (std::memcmp(data, "FLZ2", 4) == 0 || std::memcmp(data, "FLZ1", 4) == 0);
}
bool fenvLzmaCompress(const uint8_t* data, size_t size, std::vector<uint8_t>& out, std::string& error, FenvPacking packing,
const std::function<void(size_t, size_t)>& progress) {
// A 32-bit process (Frame Studio for 32-bit Windows / Linux) has 2-4 GB of address space, and the encoder takes
// ~11.5x its dictionary: 256 MB dictionaries on 8 threads need far more. There: 32 MB at most, two at a time
// (a little bigger file; the decoder reads the size from the stream, so every build opens it).
constexpr bool kSmallAddressSpace = sizeof(void*) < 8;
constexpr uint32_t kMaxDict = kSmallAddressSpace ? (32u << 20) : (256u << 20);
const unsigned hw = kSmallAddressSpace ? std::min(2u, std::max(1u, std::thread::hardware_concurrency()))
: std::max(1u, std::thread::hardware_concurrency());
std::vector<Chunk> chunks;
if (packing == FenvPacking::Fast && size > (48u << 20)) {
// Up to 8 chunks of at least 32 MB: more would cost size (each chunk starts with an empty model) and memory.
const size_t n = std::min<size_t>({8, hw, (size + (32u << 20) - 1) / (32u << 20)});
const size_t step = ((size + n - 1) / n + 15) & ~size_t(15); // on 16-byte steps: ASTC blocks stay aligned
for (size_t at = 0; at < size; at += step) { Chunk c; c.at = at; c.size = std::min(step, size - at); chunks.push_back(std::move(c)); }
} else {
Chunk c; c.size = size; chunks.push_back(std::move(c));
}
std::atomic<size_t> done{0};
const bool single = chunks.size() == 1;
parallel(chunks.size(), hw, [&](size_t i) {
Chunk& c = chunks[i];
CLzmaEncProps p;
LzmaEncProps_Init(&p);
p.level = 9;
p.dictSize = 1u << 20;
while (p.dictSize < c.size && p.dictSize < kMaxDict) p.dictSize <<= 1;
p.lc = 0; p.lp = 4; p.pb = 4; // ASTC: a 16-byte block's byte position predicts best
p.algo = 1; p.fb = 64; p.btMode = 1; p.numHashBytes = 4; p.mc = 1u << 30; // longer fb finds nothing more
p.numThreads = single ? 2 : 1; // one stream: the match finder on a second thread
SizeT len = c.size + c.size / 3 + (1 << 16);
c.packed.resize(len);
SizeT propsLen = 5;
Progress pr{{onProgress}, &done, 0, size, &progress};
c.res = LzmaEncode(c.packed.data(), &len, data + c.at, c.size, &p, c.props, &propsLen, 0, &pr.vt, &kAlloc, &kAlloc);
c.packed.resize(c.res == SZ_OK ? len : 0);
});
out.clear();
out.insert(out.end(), {'F', 'L', 'Z', '2'});
const uint32_t n = static_cast<uint32_t>(chunks.size());
for (int i = 0; i < 4; ++i) out.push_back(static_cast<uint8_t>(n >> (8 * i)));
for (const Chunk& c : chunks) {
if (c.res != SZ_OK) { error = c.res == SZ_ERROR_MEM ? "not enough memory to compress" : "compression failed"; out.clear(); return false; }
out.insert(out.end(), c.props, c.props + 5);
put64(out, c.size);
put64(out, c.packed.size());
out.insert(out.end(), c.packed.begin(), c.packed.end());
}
return true;
}
bool fenvLzmaDecompress(const uint8_t* data, size_t size, std::vector<uint8_t>& out, std::string& error) {
if (!data || !fenvLzmaIsStream(data, size)) { error = "not an LZMA stream"; return false; }
// The sizes in the header are the file's word: a damaged one must not make us allocate terabytes. A real scene
// is a few hundred MB and packs at most a few times smaller; 1000x the packed size (or 8 GB) is never one.
const uint64_t cap = std::min<uint64_t>(uint64_t(8) << 30, static_cast<uint64_t>(size) * 1000 + (1u << 20));
struct Part { const uint8_t* props; uint64_t raw, packed; const uint8_t* src; size_t at; };
std::vector<Part> parts;
uint64_t total = 0;
if (std::memcmp(data, "FLZ1", 4) == 0) { // earlier builds: one chunk, size then data
if (size < 17) { error = "damaged LZMA stream"; return false; }
parts.push_back({data + 4, get64(data + 9), size - 17, data + 17, 0});
total = parts[0].raw;
} else {
uint32_t n = 0;
std::memcpy(&n, data + 4, 4);
size_t at = 8;
for (uint32_t i = 0; i < n; ++i) {
if (at + 21 > size) { error = "damaged LZMA stream"; return false; }
Part p{data + at, get64(data + at + 5), get64(data + at + 13), data + at + 21, static_cast<size_t>(total)};
if (p.packed > size - at - 21 || p.raw > cap) { error = "damaged LZMA stream"; return false; }
at += 21 + p.packed;
total += p.raw;
if (total > cap) { error = "damaged LZMA stream (claims " + std::to_string(total >> 20) + " MB)"; return false; }
parts.push_back(p);
}
}
if (total > cap || total > SIZE_MAX) { error = "damaged LZMA stream (claims " + std::to_string(total >> 20) + " MB)"; return false; }
try {
out.resize(static_cast<size_t>(total));
} catch (const std::exception&) { // bad_alloc / length_error: within the cap, but not in this memory
out = std::vector<uint8_t>();
error = "not enough memory to unpack " + std::to_string(total >> 20) + " MB";
return false;
}
std::atomic<bool> ok{true};
parallel(parts.size(), std::max(1u, std::thread::hardware_concurrency()), [&](size_t i) {
const Part& p = parts[i];
SizeT destLen = p.raw, srcLen = p.packed;
ELzmaStatus status;
const SRes r = LzmaDecode(out.data() + p.at, &destLen, p.src, &srcLen, p.props, 5, LZMA_FINISH_END, &status, &kAlloc);
if (r != SZ_OK || destLen != p.raw) ok = false;
});
if (!ok) { error = "damaged LZMA stream"; out.clear(); return false; }
return true;
}
+22
View File
@@ -0,0 +1,22 @@
#pragma once
// LZMA for a .fenv's scene data: the strongest lossless coder we found for ASTC textures (about a fifth smaller than
// deflate on real homes). Tuned for ASTC: position bits on the 16-byte block (lc0 lp4 pb4), optimal parsing.
// Smallest: one stream with a dictionary as large as the data (up to 256 MB); the match finder runs on its own
// thread. Every repeat anywhere in the scene is found.
// Fast: up to 8 chunks coded at once (~3% bigger, several times quicker to save).
// Either way the chunks unpack in parallel.
// Stream "FLZ2": u32 chunk count, then per chunk: 5 property bytes, u64 raw size, u64 packed size, LZMA data.
// ("FLZ1" — one chunk, no count — from earlier builds still unpacks.)
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
enum class FenvPacking { Smallest, Fast };
// progress(done, total) is called from the coding threads as input is consumed (may be empty).
bool fenvLzmaCompress(const uint8_t* data, size_t size, std::vector<uint8_t>& out, std::string& error,
FenvPacking packing = FenvPacking::Smallest, const std::function<void(size_t, size_t)>& progress = {});
bool fenvLzmaIsStream(const uint8_t* data, size_t size);
bool fenvLzmaDecompress(const uint8_t* data, size_t size, std::vector<uint8_t>& out, std::string& error);
+476
View File
@@ -0,0 +1,476 @@
// fenv-tool — convert homes and models into Frame environments (.fenv).
//
// fenv-tool pack out.fenv [--name NAME] [--edits edits.json] [--thumb thumb.png] [--keep-apks] [--fast] [--no-optimize] <input>...
// --fast: pack in parallel chunks (several times quicker, ~3% bigger; see fenv_lzma.h)
// --no-optimize: keep what can't be seen from anywhere you can be (visibility_bake.h; pack, convert, upgrade)
// inputs: a home APK (HSL, Clay .opa, glTF .ovrscene), a footprint + its vista, models (.glb .gltf .fbx
// .obj). Every home is baked (scene.bin: opens several times faster; a Clay / glTF home's animation goes in as
// data, anim_bake.h). --keep-apks stores the source APKs as well.
// fenv-tool info file.fenv
// fenv-tool convert outdir <input>... one .fenv per input ("a.apk+b.apk" pairs a footprint with a vista)
// fenv-tool upgrade file.fenv... re-bake a .fenv's scene with this build, everything else kept (one that
// loads from its homes is baked: its APKs, models and world.json give way)
// fenv-tool report [--json] <input>... what a home is made of, what it costs and what's wrong with it (exit 3: broken)
// fenv-tool settings <home.fenv | home.apk> [JSON | --clear] a world's own settings (world_settings.h), shown or set
// fenv-tool tune [--seconds S] [--late F] [--cool C] [--dry-run] <home.fenv | home.apk>
// the world's best settings on this headset (walked by frame-home --bench, paced at the display rate), stored in
// the .fenv (or <apk>.settings.json); prints them as JSON last
#include "fenv.h"
#include "home_report.h"
#include "tex_mips.h"
#include "scene_bake.h"
#include "visibility_bake.h"
#include <algorithm>
#include <chrono>
#include <ctime>
#include <map>
#include <thread>
#ifndef _WIN32
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
#endif
#include <set>
#include <cstdio>
#include <cstring>
#include <filesystem>
namespace fs = std::filesystem;
namespace {
FenvPacking g_packing = FenvPacking::Smallest;
bool g_optimize = true;
// The mesh optimizer for a bake (the object edits named keep their meshes whole).
bool optimize(const HslScene& scene, const std::string& editsFile, VisibilityResult& vis, BakeOptions& opt) {
if (!g_optimize) return false;
HomeEdits e;
const bool hasEdits = !editsFile.empty() && loadHomeEdits(editsFile, e);
return optimizeForBake(scene, hasEdits ? &e.objects : nullptr, vis, opt);
}
std::string stem(const std::string& p) { return fs::u8path(p).stem().u8string(); }
std::string displayName(const HslScene& s, const std::vector<std::string>& inputs) {
std::string n;
for (size_t i = 0; i < inputs.size(); ++i) {
std::string part = i < s.sources.size() && !s.sources[i].package.empty() ? s.sources[i].package : stem(inputs[i]);
const size_t dot = part.find_last_of('.');
if (dot != std::string::npos && part.find("com.") == 0) part = part.substr(dot + 1);
n += (n.empty() ? "" : " + ") + part;
}
return n;
}
int pack(const std::string& out, const std::vector<std::string>& inputs, std::string name, const std::string& editsFile,
const std::string& thumbFile, bool keepApks) {
const auto t0 = std::chrono::steady_clock::now();
HslScene scene;
std::string err;
if (!loadHslScene(inputs, scene, err)) { std::fprintf(stderr, "fenv-tool: %s\n", err.c_str()); return 1; }
FenvManifest m;
m.name = name.empty() ? displayName(scene, inputs) : name;
std::vector<FenvMember> members;
std::vector<u8> blob;
VisibilityResult vis;
BakeOptions opt;
const bool optimized = optimize(scene, editsFile, vis, opt);
const bool baked = bakeScene(scene, blob, err, optimized ? &opt : nullptr);
if (baked) {
m.scene = "scene.bin";
FenvMember b;
b.name = m.scene;
b.bytes = std::move(blob);
b.lzma = true; // LZMA (fenv_lzma.h): unpacked once, into the .fenv cache
b.packing = g_packing;
members.push_back(std::move(b));
}
for (size_t i = 0; i < inputs.size(); ++i) {
const HslSourceInfo info = i < scene.sources.size() ? scene.sources[i] : HslSourceInfo{};
if (info.role == "Model") {
if (baked) continue; // its meshes are in scene.bin
const fs::path base = fs::u8path(inputs[i]).parent_path();
const std::string dir = "models/" + stem(inputs[i]) + "/";
const std::vector<std::string>& files = i < scene.sourceFiles.size() ? scene.sourceFiles[i] : std::vector<std::string>{inputs[i]};
for (const std::string& f : files) {
std::error_code ec;
fs::path rel = fs::relative(fs::u8path(f), base, ec);
if (ec || rel.empty() || rel.u8string().find("..") == 0) rel = fs::u8path(f).filename();
FenvMember mm;
mm.name = dir + rel.generic_u8string();
mm.file = f;
members.push_back(mm);
if (fs::equivalent(fs::u8path(f), fs::u8path(inputs[i]), ec)) m.models.push_back(mm.name);
}
continue;
}
FenvSource src;
src.file = "apks/" + fs::u8path(inputs[i]).filename().u8string();
src.role = info.role; src.format = info.format; src.package = info.package; src.version = info.version;
m.sources.push_back(src);
if (!baked || keepApks) {
FenvMember a;
a.name = src.file;
a.file = inputs[i];
a.store = true;
members.push_back(a);
}
}
if (!editsFile.empty()) { m.edits = "edits.json"; FenvMember e; e.name = m.edits; e.file = editsFile; members.push_back(e); }
if (!thumbFile.empty()) { m.thumbnail = "thumb.png"; FenvMember t; t.name = m.thumbnail; t.file = thumbFile; t.store = true; members.push_back(t); }
if (!fenvWrite(out, m, members, err)) { std::fprintf(stderr, "fenv-tool: %s\n", err.c_str()); return 1; }
std::error_code ec;
const double s = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
std::printf("%s: \"%s\", %zu meshes, %s, %.0f MB, %.1f s\n", out.c_str(), m.name.c_str(), scene.meshes.size(),
baked ? "baked" : ("from its homes (" + err + ")").c_str(), fs::file_size(fs::u8path(out), ec) / 1048576.0, s);
return 0;
}
int info(const std::string& path) {
FenvManifest m;
std::vector<u8> thumb;
if (!fenvReadManifest(path, m, &thumb)) { std::fprintf(stderr, "fenv-tool: not a .fenv: %s\n", path.c_str()); return 1; }
std::printf("%s\n name: %s\n %s\n", path.c_str(), m.name.c_str(), m.scene.empty() ? "loads from its homes" : "baked scene");
for (const FenvSource& s : m.sources) std::printf(" %-9s %-17s %s %s\n", s.role.c_str(), s.format.c_str(), s.package.c_str(), s.version.c_str());
for (const std::string& f : m.models) std::printf(" model %s\n", f.c_str());
std::printf(" edits: %s, thumbnail: %s\n", m.edits.empty() ? "no" : "yes", thumb.empty() ? "no" : "yes");
std::printf(" settings: %s%s\n", m.settings.describe().c_str(), m.settings.tuned.empty() ? "" : (" (" + m.settings.tuned + ")").c_str());
return 0;
}
// Re-bakes a .fenv's scene with this build (smaller texture levels baked in, tex_mips.h), keeping everything else in it
// (world, edits, sound, picture, models) byte for byte. One whose textures all have their levels already is left alone.
// One that loads from its homes (saved before Clay / glTF animation could be baked) is baked: the scene replaces its
// APKs, models and world.json (the world as it loads goes in the scene).
int upgrade(const std::string& path) {
FenvManifest m;
if (!fenvReadManifest(path, m)) { std::fprintf(stderr, "fenv-tool: not a .fenv: %s\n", path.c_str()); return 1; }
const bool fromHomes = m.scene.empty();
const auto t0 = std::chrono::steady_clock::now();
HslScene scene;
std::string err;
if (!loadHslScene(std::vector<std::string>{path}, scene, err)) { std::fprintf(stderr, "fenv-tool: %s: %s\n", path.c_str(), err.c_str()); return 1; }
size_t want = 0;
{
std::set<const TexSource*> seen;
auto look = [&](const std::shared_ptr<const TexSource>& t) { if (t && seen.insert(t.get()).second && texmips::wants(*t)) ++want; };
for (const MeshData& md : scene.meshes) {
for (const auto* t : {&md.texSrc, &md.normalSrc, &md.ormSrc, &md.emissiveSrc, &md.lmLdrSrc}) look(*t);
for (const auto& nt : md.namedTex) look(nt.second);
}
}
VisibilityResult vis;
BakeOptions opt;
const bool optimized = optimize(scene, scene.fenvEdits, vis, opt) && vis.trisAfter < vis.trisBefore;
if (!want && !optimized && !fromHomes) { std::printf("%s: already up to date\n", path.c_str()); return 0; }
std::vector<u8> blob;
if (!bakeScene(scene, blob, err, optimized ? &opt : nullptr)) { std::fprintf(stderr, "fenv-tool: %s: %s\n", path.c_str(), err.c_str()); return 1; }
std::vector<FenvMember> members;
const std::string world = m.world;
if (fromHomes) { m.scene = "scene.bin"; m.world.clear(); m.models.clear(); }
FenvMember b;
b.name = m.scene;
b.bytes = std::move(blob);
b.lzma = true;
b.packing = g_packing;
members.push_back(std::move(b));
{ // everything else, as it is
mz_zip_archive z = {};
if (!mz_zip_reader_init_file(&z, path.c_str(), 0)) { std::fprintf(stderr, "fenv-tool: can't open %s\n", path.c_str()); return 1; }
for (mz_uint i = 0; i < mz_zip_reader_get_num_files(&z); ++i) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&z, i, &st) || st.m_is_directory) continue;
const std::string name = st.m_filename;
if (name == "fenv.json" || name == m.scene || name == m.scene + ".lz") continue;
if (fromHomes && (name.rfind("apks/", 0) == 0 || name.rfind("models/", 0) == 0 || name == world)) continue; // in the scene now
FenvMember f;
f.name = name;
f.bytes = fenv_detail::readMember(z, name.c_str(), uint64_t(4) << 30);
if (f.bytes.size() != st.m_uncomp_size) { mz_zip_reader_end(&z); std::fprintf(stderr, "fenv-tool: can't read %s in %s\n", name.c_str(), path.c_str()); return 1; }
f.store = st.m_method == 0; // stored before (APKs, pictures): stored again
members.push_back(std::move(f));
}
mz_zip_reader_end(&z);
}
if (!fenvWrite(path, m, members, err)) { std::fprintf(stderr, "fenv-tool: %s\n", err.c_str()); return 1; }
std::error_code ec;
std::printf("%s: %s%zu textures given their smaller levels, %.0f MB, %.1f s\n", path.c_str(), fromHomes ? "baked from its homes, " : "", want, fs::file_size(fs::u8path(path), ec) / 1048576.0,
std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count());
return 0;
}
// The files one home argument names: a footprint + vista written as a+b, or one file. A file that exists is taken
// whole even when its name holds a '+' (a converted pair's .fenv is named footprint+vista.fenv).
std::vector<std::string> homeFiles(const std::string& arg) {
std::error_code ec;
if (fs::is_regular_file(fs::u8path(arg), ec)) return {arg};
std::vector<std::string> files;
for (size_t p = 0, q; p <= arg.size(); p = q + 1) {
q = arg.find('+', p);
if (q == std::string::npos) q = arg.size();
if (q > p) files.push_back(arg.substr(p, q - p));
}
return files;
}
// What a home is made of, what it costs and what's wrong with it (home_report.h): a footprint + vista as a+b.
int report(const std::string& arg, bool json) {
const std::vector<std::string> group = homeFiles(arg);
const auto t0 = std::chrono::steady_clock::now();
HslScene scene;
std::string err;
if (!loadHslScene(group, scene, err)) { std::fprintf(stderr, "fenv-tool: %s: %s\n", arg.c_str(), err.c_str()); return 1; }
const double loadSec = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
const HomeWorld walk = walkWorld(scene);
HomeReport r = makeHomeReport(scene, &walk);
r.loadSec = loadSec;
const std::string out = json ? r.json() : r.text();
std::fwrite(out.data(), 1, out.size(), stdout);
return r.worst() >= 2 ? 3 : 0;
}
#ifndef _WIN32
// ── tune: a world's best settings, measured on this headset ──
// Frame Home's bench walks the world (FH_BENCH_WALK + FH_BENCH_MOVES: walking, snap turns, teleports, fast head turns)
// at candidate settings, paced at the display rate beside the live compositor, as the headset runs it. A candidate
// keeps up when the walk holds the display's rate with at most `--late` of its frames late. Your MSAA is kept: the
// foveation strength moves first (lower when there's room: sharper edges; higher when not), then the resolution, then
// half frame rate as the last resort.
// One paced walk through the world (frame-home --bench at the display rate, beside the live compositor as on the
// headset): whether it kept up. GPU cycles from the perf counters can't judge that: Adreno's counters are GPU-wide, so
// they also count the compositor's work, and an unpaced run doesn't meet the compositor at all.
struct TuneRun {
bool ok = false;
double fps = 0, gpuAvg = 0, gpuP95 = 0;
int frames = 0, late = 0, hitches = 0; // late: frames over the display period; hitches: over 1.5 of it
double lateShare() const { return frames > 0 ? static_cast<double>(late) / frames : 1.0; }
};
// The hottest thermal sensor, in thousandths of a degree (0: none readable).
long hottest() {
long t = 0;
std::error_code ec;
for (const auto& e : fs::directory_iterator("/sys/class/thermal", ec)) {
if (e.path().filename().u8string().rfind("thermal_zone", 0) != 0) continue;
if (std::FILE* f = std::fopen((e.path() / "temp").c_str(), "rb")) {
long v = 0;
if (std::fscanf(f, "%ld", &v) == 1) t = std::max(t, v);
std::fclose(f);
}
}
return t;
}
std::string shellQuote(const std::string& s) {
std::string q = "'";
for (char c : s) q += c == '\'' ? std::string("'\\''") : std::string(1, c);
return q + "'";
}
// One paced walk through the world at these settings (the world's own settings left out).
TuneRun tuneRun(const std::string& bench, const std::string& home, float strength, float scale, int seconds, long coolBelow) {
TuneRun r;
// Waits for the headset to cool first: a hot GPU runs slower, and every candidate should start alike.
for (int waited = 0; coolBelow > 0 && hottest() >= coolBelow && waited < 600; waited += 5) {
if (waited % 30 == 0) { std::printf("tune: cooling (%.1f C)\n", hottest() / 1000.0); std::fflush(stdout); }
std::this_thread::sleep_for(std::chrono::seconds(5));
}
// One GPU benchmark at a time on this headset.
const int lock = ::open("/tmp/frame-gpu.lock", O_RDWR | O_CREAT, 0666);
if (lock >= 0) ::flock(lock, LOCK_EX);
char env[512];
std::snprintf(env, sizeof env,
"FH_BENCH_WALK=1 FH_BENCH_MOVES=1 FH_BENCH_NOWORLD=1 FH_BENCH_OVERLAP=1 FH_BENCH_EARLY=1 "
"FH_BENCH_FOVSTRENGTH=%.3f FH_BENCH_SCALE=%.3f",
strength, scale);
const std::string cmd = std::string(env) + " " + shellQuote(bench) + " --bench --seconds " + std::to_string(seconds) + " " +
shellQuote(home) + " 2>&1";
if (std::FILE* p = ::popen(cmd.c_str(), "r")) {
char line[8192];
while (std::fgets(line, sizeof line, p)) {
const std::string l = line;
if (l.rfind("{\"home\"", 0) != 0) continue;
try {
const tinyjson::Value j = tinyjson::parse(l);
r.fps = j["fps"].asFloat();
r.frames = static_cast<int>(j["frames"].asFloat());
r.late = static_cast<int>(j["overBudget"].asFloat());
r.hitches = static_cast<int>(j["hitches"].asFloat());
r.gpuAvg = j["gpuAvg"].asFloat();
r.gpuP95 = j["gpuP95"].asFloat();
r.ok = r.frames > 0;
} catch (...) {}
}
::pclose(p);
}
if (lock >= 0) { ::flock(lock, LOCK_UN); ::close(lock); }
return r;
}
// The best-looking settings this headset keeps up with in this world, found by walking it (tuneRun) at candidates:
// first the headset's own; if they keep up, sharper foveation while it still does; if not, stronger foveation, then
// lower resolution, and as a last resort half frame rate. "Keeps up": the display's rate, at most `maxLate` of the
// frames late.
int tune(const std::string& home, const std::string& benchArg, int seconds, double maxLate, long coolBelow, bool dryRun, double hz) {
std::error_code ec;
std::string bench = benchArg;
if (bench.empty()) bench = (fs::read_symlink("/proc/self/exe", ec).parent_path() / "frame-home").u8string();
if (!fs::exists(fs::u8path(bench), ec)) { std::fprintf(stderr, "fenv-tool tune: no frame-home beside fenv-tool (--bench PATH)\n"); return 1; }
const char* homeDir = std::getenv("HOME");
PerfSettings headset;
{
std::string j;
if (std::FILE* f = std::fopen((std::string(homeDir ? homeDir : ".") + "/frame-home/settings.json").c_str(), "rb")) {
char b[4096];
for (size_t k; (k = std::fread(b, 1, sizeof b, f)) > 0;) j.append(b, k);
std::fclose(f);
}
if (!headset.fromJson(j)) headset = PerfSettings();
}
const float s0 = static_cast<float>(headset.foveationLevel), scale0 = headset.autoResolution ? 1.0f : headset.resolution;
std::printf("tune: %s: the headset's resolution %.0f%%, %dx MSAA, foveation %.0f; walking it %d s at each try (%.0f fps, at most %.1f%% late)\n",
fs::u8path(home).filename().u8string().c_str(), scale0 * 100, headset.msaa, s0, seconds, hz, maxLate * 100);
std::fflush(stdout);
std::map<std::pair<int, int>, TuneRun> seen;
auto keepsUp = [&](const TuneRun& r) { return r.ok && r.fps >= hz * 0.998 && r.lateShare() <= maxLate; };
auto measure = [&](float s, float sc) {
const std::pair<int, int> key{static_cast<int>(std::lround(s * 100)), static_cast<int>(std::lround(sc * 100))};
auto it = seen.find(key);
if (it != seen.end()) return it->second;
const TuneRun r = tuneRun(bench, home, s, sc, seconds, coolBelow);
if (r.ok) std::printf("tune: foveation %.2f, resolution %.0f%%: %.1f fps, %d of %d frames late, GPU %.2f ms (p95 %.2f) (%s)\n", s, sc * 100,
r.fps, r.late, r.frames, r.gpuAvg, r.gpuP95, keepsUp(r) ? "keeps up" : "too much");
else std::printf("tune: foveation %.2f, resolution %.0f%%: the bench didn't run\n", s, sc * 100);
std::fflush(stdout);
seen[key] = r;
return r;
};
float s = s0, sc = scale0;
int frameRate = -1;
TuneRun at = measure(s, sc);
if (!at.ok) { std::fprintf(stderr, "fenv-tool tune: the bench didn't run (%s)\n", bench.c_str()); return 1; }
if (keepsUp(at)) { // room to spare: sharper edges, half a step at a time
for (float t = s - 0.5f; t >= 1.0f - 1e-3f; t -= 0.5f) {
const TuneRun r = measure(t, sc);
if (!keepsUp(r)) break;
s = t; at = r;
}
} else {
bool ok = false;
for (float t = s + 0.5f; t <= 3.0f + 1e-3f && !ok; t += 0.5f) { const TuneRun r = measure(t, sc); s = t; at = r; ok = keepsUp(r); }
for (float c = sc - 0.1f; !ok && c >= std::max(0.7f, scale0 * 0.75f) - 1e-3f; c -= 0.1f) { const TuneRun r = measure(s, c); sc = c; at = r; ok = keepsUp(r); }
if (!ok) frameRate = 1; // drawn every other display frame: twice the time each
}
WorldSettings w;
if (std::abs(s - s0) > 1e-3f) w.foveation = s;
if (std::abs(sc - scale0) > 1e-3f) w.resolution = sc;
if (frameRate >= 0) w.frameRate = frameRate;
char when[32];
const std::time_t now = std::time(nullptr);
std::strftime(when, sizeof when, "%Y-%m-%d", std::localtime(&now));
char note[256];
std::snprintf(note, sizeof note, "tuned %s: %.1f fps walking, %.1f%% of frames late%s", when, at.fps, at.lateShare() * 100,
frameRate == 1 ? ", even so over: half frame rate" : "");
w.tuned = note;
w.clamp();
std::printf("tune: best: %s (%s)\n", w.describe().c_str(), w.tuned.c_str());
if (!dryRun) {
std::string err;
if (!setWorldSettings(home, w, err)) { std::fprintf(stderr, "fenv-tool tune: %s\n", err.c_str()); return 1; }
std::printf("tune: stored in %s\n", isFenvPath(home) ? home.c_str() : worldSettingsPathFor(home).c_str());
}
std::printf("{\"settings\": %s, \"fps\": %.1f, \"lateShare\": %.4f}\n", w.toJson().c_str(), at.fps, at.lateShare());
return 0;
}
#endif
} // namespace
int main(int argc, char** argv) {
if (argc >= 3 && !std::strcmp(argv[1], "info")) return info(argv[2]);
if (argc >= 3 && !std::strcmp(argv[1], "upgrade")) {
for (int i = 2; i < argc; ++i) if (!std::strcmp(argv[i], "--no-optimize")) g_optimize = false;
int rc = 0;
for (int i = 2; i < argc; ++i) if (std::strcmp(argv[i], "--no-optimize")) rc = std::max(rc, upgrade(argv[i]));
return rc;
}
if (argc >= 3 && !std::strcmp(argv[1], "report")) {
int rc = 0;
const bool json = argc >= 4 && !std::strcmp(argv[2], "--json");
for (int i = json ? 3 : 2; i < argc; ++i) { if (i > (json ? 3 : 2) && !json) std::printf("\n"); rc = std::max(rc, report(argv[i], json)); }
return rc;
}
if (argc >= 4 && !std::strcmp(argv[1], "pack")) {
std::string name, edits, thumb;
bool keep = false;
std::vector<std::string> inputs;
for (int i = 3; i < argc; ++i) {
const std::string a = argv[i];
if (a == "--name" && i + 1 < argc) name = argv[++i];
else if (a == "--edits" && i + 1 < argc) edits = argv[++i];
else if (a == "--thumb" && i + 1 < argc) thumb = argv[++i];
else if (a == "--keep-apks") keep = true;
else if (a == "--fast") g_packing = FenvPacking::Fast;
else if (a == "--no-optimize") g_optimize = false;
else inputs.push_back(a);
}
if (inputs.empty()) { std::fprintf(stderr, "fenv-tool pack: no inputs\n"); return 2; }
return pack(argv[2], inputs, name, edits, thumb, keep);
}
if (argc >= 4 && !std::strcmp(argv[1], "convert")) {
int failed = 0;
std::error_code ec;
fs::create_directories(fs::u8path(argv[2]), ec);
for (int i = 3; i < argc; ++i) {
const std::string a = argv[i];
if (a == "--no-optimize") { g_optimize = false; continue; }
const std::vector<std::string> group = homeFiles(a);
std::string base;
for (const auto& g : group) base += (base.empty() ? "" : "+") + stem(g);
failed += pack((fs::u8path(argv[2]) / fs::u8path(base + ".fenv")).u8string(), group, "", "", "", false) != 0;
}
return failed ? 1 : 0;
}
if (argc >= 3 && !std::strcmp(argv[1], "settings")) { // shown, or set from JSON ("{}" / --clear: none)
if (argc == 3) { const WorldSettings w = worldSettingsFor(argv[2]); std::printf("%s\n", w.toJson().c_str()); return 0; }
WorldSettings w;
if (std::strcmp(argv[3], "--clear") != 0 && !w.fromJson(argv[3])) { std::fprintf(stderr, "fenv-tool settings: not settings JSON: %s\n", argv[3]); return 2; }
std::string err;
if (!setWorldSettings(argv[2], w, err)) { std::fprintf(stderr, "fenv-tool settings: %s\n", err.c_str()); return 1; }
std::printf("%s: %s\n", argv[2], w.describe().c_str());
return 0;
}
#ifndef _WIN32
if (argc >= 3 && !std::strcmp(argv[1], "tune")) {
std::string bench, home;
int seconds = 30; // each candidate's walk
double maxLate = 0.01, hz = 120; // keeping up: the display's rate with at most 1% of frames late
long cool = 80000; // each candidate starts below 80 C (as the bench scripts do)
bool dry = false;
for (int i = 2; i < argc; ++i) {
const std::string a = argv[i];
if (a == "--bench" && i + 1 < argc) bench = argv[++i];
else if (a == "--seconds" && i + 1 < argc) seconds = std::clamp(std::atoi(argv[++i]), 5, 300);
else if (a == "--late" && i + 1 < argc) maxLate = std::clamp(std::atof(argv[++i]), 0.0, 0.5);
else if (a == "--hz" && i + 1 < argc) hz = std::clamp(std::atof(argv[++i]), 30.0, 240.0);
else if (a == "--cool" && i + 1 < argc) cool = static_cast<long>(std::atof(argv[++i]) * 1000);
else if (a == "--dry-run") dry = true;
else home = a;
}
if (home.empty()) { std::fprintf(stderr, "fenv-tool tune: which world?\n"); return 2; }
return tune(home, bench, seconds, maxLate, cool, dry, hz);
}
#endif
std::fprintf(stderr,
"usage:\n fenv-tool pack out.fenv [--name NAME] [--edits edits.json] [--thumb thumb.png] [--keep-apks] [--fast] <home.apk|vista.apk|model>...\n"
" fenv-tool info file.fenv\n fenv-tool convert outdir <home.apk | footprint.apk+vista.apk | model>...\n"
" fenv-tool upgrade file.fenv... re-bake with this build (keeps world, edits, sound, picture)\n"
" fenv-tool report [--json] <home.apk | home.fenv | footprint.apk+vista.apk>... what it costs and what's wrong with it\n"
" fenv-tool settings <home.fenv | home.apk> [JSON | --clear] a world's own settings, shown or set\n"
" fenv-tool tune [--seconds 30] [--late 0.01] [--cool 88] [--dry-run] <home.fenv | home.apk> its best settings on this headset, stored in it\n");
return 2;
}
+117
View File
@@ -0,0 +1,117 @@
#pragma once
// Generated by tools/fovpass/gen.sh from tools/fovpass/fovpass.vert / .frag: the smooth-foveation pass.
#include <cstdint>
static const uint32_t kFovPassVert[] = {
0x07230203u, 0x00010300u, 0x000d000bu, 0x0000002cu,
0x00000000u, 0x00020011u, 0x00000001u, 0x0006000bu,
0x00000001u, 0x4c534c47u, 0x6474732eu, 0x3035342eu,
0x00000000u, 0x0003000eu, 0x00000000u, 0x00000001u,
0x0007000fu, 0x00000000u, 0x00000004u, 0x6e69616du,
0x00000000u, 0x0000000cu, 0x0000001du, 0x00040047u,
0x0000000cu, 0x0000000bu, 0x0000002au, 0x00030047u,
0x0000001bu, 0x00000002u, 0x00050048u, 0x0000001bu,
0x00000000u, 0x0000000bu, 0x00000000u, 0x00050048u,
0x0000001bu, 0x00000001u, 0x0000000bu, 0x00000001u,
0x00050048u, 0x0000001bu, 0x00000002u, 0x0000000bu,
0x00000003u, 0x00050048u, 0x0000001bu, 0x00000003u,
0x0000000bu, 0x00000004u, 0x00020013u, 0x00000002u,
0x00030021u, 0x00000003u, 0x00000002u, 0x00030016u,
0x00000006u, 0x00000020u, 0x00040017u, 0x00000007u,
0x00000006u, 0x00000002u, 0x00040015u, 0x0000000au,
0x00000020u, 0x00000001u, 0x00040020u, 0x0000000bu,
0x00000001u, 0x0000000au, 0x0004003bu, 0x0000000bu,
0x0000000cu, 0x00000001u, 0x0004002bu, 0x0000000au,
0x0000000eu, 0x00000001u, 0x0004002bu, 0x0000000au,
0x00000010u, 0x00000002u, 0x00040017u, 0x00000017u,
0x00000006u, 0x00000004u, 0x00040015u, 0x00000018u,
0x00000020u, 0x00000000u, 0x0004002bu, 0x00000018u,
0x00000019u, 0x00000001u, 0x0004001cu, 0x0000001au,
0x00000006u, 0x00000019u, 0x0006001eu, 0x0000001bu,
0x00000017u, 0x00000006u, 0x0000001au, 0x0000001au,
0x00040020u, 0x0000001cu, 0x00000003u, 0x0000001bu,
0x0004003bu, 0x0000001cu, 0x0000001du, 0x00000003u,
0x0004002bu, 0x0000000au, 0x0000001eu, 0x00000000u,
0x0004002bu, 0x00000006u, 0x00000020u, 0x40000000u,
0x0004002bu, 0x00000006u, 0x00000022u, 0x3f800000u,
0x0004002bu, 0x00000006u, 0x00000025u, 0x00000000u,
0x00040020u, 0x00000029u, 0x00000003u, 0x00000017u,
0x0005002cu, 0x00000007u, 0x0000002bu, 0x00000022u,
0x00000022u, 0x00050036u, 0x00000002u, 0x00000004u,
0x00000000u, 0x00000003u, 0x000200f8u, 0x00000005u,
0x0004003du, 0x0000000au, 0x0000000du, 0x0000000cu,
0x000500c4u, 0x0000000au, 0x0000000fu, 0x0000000du,
0x0000000eu, 0x000500c7u, 0x0000000au, 0x00000011u,
0x0000000fu, 0x00000010u, 0x0004006fu, 0x00000006u,
0x00000012u, 0x00000011u, 0x000500c7u, 0x0000000au,
0x00000014u, 0x0000000du, 0x00000010u, 0x0004006fu,
0x00000006u, 0x00000015u, 0x00000014u, 0x00050050u,
0x00000007u, 0x00000016u, 0x00000012u, 0x00000015u,
0x0005008eu, 0x00000007u, 0x00000021u, 0x00000016u,
0x00000020u, 0x00050083u, 0x00000007u, 0x00000024u,
0x00000021u, 0x0000002bu, 0x00050051u, 0x00000006u,
0x00000026u, 0x00000024u, 0x00000000u, 0x00050051u,
0x00000006u, 0x00000027u, 0x00000024u, 0x00000001u,
0x00070050u, 0x00000017u, 0x00000028u, 0x00000026u,
0x00000027u, 0x00000025u, 0x00000022u, 0x00050041u,
0x00000029u, 0x0000002au, 0x0000001du, 0x0000001eu,
0x0003003eu, 0x0000002au, 0x00000028u, 0x000100fdu,
0x00010038u,
};
static const uint32_t kFovPassFrag[] = {
0x07230203u, 0x00010300u, 0x000d000bu, 0x00000027u,
0x00000000u, 0x00020011u, 0x00000001u, 0x00020011u,
0x00001157u, 0x0006000bu, 0x00000001u, 0x4c534c47u,
0x6474732eu, 0x3035342eu, 0x00000000u, 0x0003000eu,
0x00000000u, 0x00000001u, 0x0008000fu, 0x00000004u,
0x00000004u, 0x6e69616du, 0x00000000u, 0x00000009u,
0x00000010u, 0x0000001eu, 0x00030010u, 0x00000004u,
0x00000007u, 0x00040047u, 0x00000009u, 0x0000001eu,
0x00000000u, 0x00040047u, 0x0000000du, 0x00000021u,
0x00000000u, 0x00040047u, 0x0000000du, 0x00000022u,
0x00000000u, 0x00040047u, 0x00000010u, 0x0000000bu,
0x0000000fu, 0x00030047u, 0x00000014u, 0x00000002u,
0x00050048u, 0x00000014u, 0x00000000u, 0x00000023u,
0x00000000u, 0x00040047u, 0x0000001eu, 0x0000000bu,
0x00001158u, 0x00030047u, 0x0000001eu, 0x0000000eu,
0x00020013u, 0x00000002u, 0x00030021u, 0x00000003u,
0x00000002u, 0x00030016u, 0x00000006u, 0x00000020u,
0x00040017u, 0x00000007u, 0x00000006u, 0x00000004u,
0x00040020u, 0x00000008u, 0x00000003u, 0x00000007u,
0x0004003bu, 0x00000008u, 0x00000009u, 0x00000003u,
0x00090019u, 0x0000000au, 0x00000006u, 0x00000001u,
0x00000000u, 0x00000001u, 0x00000000u, 0x00000001u,
0x00000000u, 0x0003001bu, 0x0000000bu, 0x0000000au,
0x00040020u, 0x0000000cu, 0x00000000u, 0x0000000bu,
0x0004003bu, 0x0000000cu, 0x0000000du, 0x00000000u,
0x00040020u, 0x0000000fu, 0x00000001u, 0x00000007u,
0x0004003bu, 0x0000000fu, 0x00000010u, 0x00000001u,
0x00040017u, 0x00000011u, 0x00000006u, 0x00000002u,
0x0003001eu, 0x00000014u, 0x00000011u, 0x00040020u,
0x00000015u, 0x00000009u, 0x00000014u, 0x0004003bu,
0x00000015u, 0x00000016u, 0x00000009u, 0x00040015u,
0x00000017u, 0x00000020u, 0x00000001u, 0x0004002bu,
0x00000017u, 0x00000018u, 0x00000000u, 0x00040020u,
0x00000019u, 0x00000009u, 0x00000011u, 0x00040020u,
0x0000001du, 0x00000001u, 0x00000017u, 0x0004003bu,
0x0000001du, 0x0000001eu, 0x00000001u, 0x00040017u,
0x00000021u, 0x00000006u, 0x00000003u, 0x0004002bu,
0x00000006u, 0x00000025u, 0x00000000u, 0x00050036u,
0x00000002u, 0x00000004u, 0x00000000u, 0x00000003u,
0x000200f8u, 0x00000005u, 0x0004003du, 0x0000000bu,
0x0000000eu, 0x0000000du, 0x0004003du, 0x00000007u,
0x00000012u, 0x00000010u, 0x0007004fu, 0x00000011u,
0x00000013u, 0x00000012u, 0x00000012u, 0x00000000u,
0x00000001u, 0x00050041u, 0x00000019u, 0x0000001au,
0x00000016u, 0x00000018u, 0x0004003du, 0x00000011u,
0x0000001bu, 0x0000001au, 0x00050085u, 0x00000011u,
0x0000001cu, 0x00000013u, 0x0000001bu, 0x0004003du,
0x00000017u, 0x0000001fu, 0x0000001eu, 0x0004006fu,
0x00000006u, 0x00000020u, 0x0000001fu, 0x00050051u,
0x00000006u, 0x00000022u, 0x0000001cu, 0x00000000u,
0x00050051u, 0x00000006u, 0x00000023u, 0x0000001cu,
0x00000001u, 0x00060050u, 0x00000021u, 0x00000024u,
0x00000022u, 0x00000023u, 0x00000020u, 0x00070058u,
0x00000007u, 0x00000026u, 0x0000000eu, 0x00000024u,
0x00000002u, 0x00000025u, 0x0003003eu, 0x00000009u,
0x00000026u, 0x000100fdu, 0x00010038u,
};
+521
View File
@@ -0,0 +1,521 @@
#pragma once
// Walking with any "down" (WorldGravity zones): onto walls, across ceilings, all the way round a planet, and falling.
// Frame Home uses GravityWalker for homes that have gravity zones; without zones Locomotion keeps its upright walk.
//
// The rig (worldFromTracking) is a full frame here: tracking +Y is the player's up, -Z their facing, origin on the
// floor under the tracking origin. Each frame:
// 1. turns (snap or smooth: the caller's per-frame angle) rotate about up, through the head
// 2. up eases toward the zone's (Direction: the zone's +Y; Planet: away from the centre; Surface: out of the
// ground underfoot, or out of a wall walked into), turning about the feet so you roll onto the new floor
// 3. the stick walks along the ground plane; walls (steep for this up) push back, except in Surface zones,
// where walking into a wall makes it the floor
// 4. a ray down (along -up) finds the ground: stand on it (ramps followed exactly, steps eased), or fall with
// gravity and land; falling further than the world's fallLimit puts you back at the spawn
// Walking speeds up and slows down over a fraction of a second (grav::approach), never instantly.
#include "core/world.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <vector>
namespace grav {
struct V {
float x = 0, y = 0, z = 0;
V() = default;
V(float a, float b, float c) : x(a), y(b), z(c) {}
explicit V(const float* p) : x(p[0]), y(p[1]), z(p[2]) {}
V operator+(const V& o) const { return {x + o.x, y + o.y, z + o.z}; }
V operator-(const V& o) const { return {x - o.x, y - o.y, z - o.z}; }
V operator*(float s) const { return {x * s, y * s, z * s}; }
V operator-() const { return {-x, -y, -z}; }
};
inline float dot(const V& a, const V& b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
inline V cross(const V& a, const V& b) { return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; }
inline float len(const V& a) { return std::sqrt(dot(a, a)); }
inline V norm(const V& a) { const float l = len(a); return l > 1e-9f ? a * (1.0f / l) : V(0, 1, 0); }
// Rotates v about unit axis k by angle a (Rodrigues).
inline V rotate(const V& v, const V& k, float a) {
const float c = std::cos(a), s = std::sin(a);
return v * c + cross(k, v) * s + k * (dot(k, v) * (1 - c));
}
inline V xform(const float* m, const V& p) {
return {m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12], m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13], m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14]};
}
inline V closestOnTri(const V& p, const V& a, const V& b, const V& c) { // Ericson, Real-Time Collision Detection 5.1.5
const V ab = b - a, ac = c - a, ap = p - a;
const float d1 = dot(ab, ap), d2 = dot(ac, ap);
if (d1 <= 0 && d2 <= 0) return a;
const V bp = p - b;
const float d3 = dot(ab, bp), d4 = dot(ac, bp);
if (d3 >= 0 && d4 <= d3) return b;
const float vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0) return a + ab * (d1 / (d1 - d3));
const V cp = p - c;
const float d5 = dot(ab, cp), d6 = dot(ac, cp);
if (d6 >= 0 && d5 <= d6) return c;
const float vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0) return a + ac * (d2 / (d2 - d6));
const float va = d3 * d6 - d5 * d4;
if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) return b + (c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)));
const float den = 1 / (va + vb + vc);
return a + ab * (vb * den) + ac * (vc * den);
}
// Walking velocity eased toward the stick's: full speed in kAccel s from a standstill, a stop in kDecel s. Turning
// (the head, mid-walk) rides the same rate, so the path curves instead of kinking. Instant starts and stops are a
// classic comfort problem in VR; a short ramp reads as natural without feeling floaty.
constexpr float kAccel = 0.15f, kDecel = 0.12f;
inline void approach(V& v, const V& want, float maxSpeed, float dt) {
const V dv = want - v;
const float l = len(dv);
if (l < 1e-6f) { v = want; return; }
const float rate = maxSpeed / (dot(want, want) > dot(v, v) ? kAccel : kDecel);
v = v + dv * (std::min(l, rate * dt) / l);
}
// Critically damped easing of x toward target (Unity's SmoothDamp): starts and ends with no jolt, never overshoots.
// vel carries over between frames.
inline float smoothDamp(float x, float target, float& vel, float smoothTime, float dt) {
const float o = 2 / smoothTime, k = o * dt, e = 1 / (1 + k + 0.48f * k * k + 0.235f * k * k * k);
const float ch = x - target, tmp = (vel + o * ch) * dt;
vel = (vel - o * tmp) * e;
float out = target + (ch + tmp) * e;
if ((target - x > 0) == (out > target)) { out = target; vel = 0; }
return out;
}
// Steps vs ramps: a change of floor height within this of the distance walked is a slope, followed exactly (easing
// it would sink you into a ramp going up); more is a step, eased up (kStepUp s) or down (kStepDown s).
constexpr float kRampSlope = 1.2f, kStepUp = 0.09f, kStepDown = 0.11f;
// The walk stick: a round deadzone (no axis snapping), then n^1.6 so a light push strolls and only a full one
// walks at full speed.
inline void shapeStick(float x, float y, float& ox, float& oy) {
const float m = std::sqrt(x * x + y * y);
if (m < 0.15f) { ox = oy = 0; return; }
const float n = std::min(1.0f, (m - 0.15f) / 0.85f), k = std::pow(n, 1.6f) / m;
ox = x * k; oy = y * k;
}
// Every surface of the world in a 3D grid, queried in any direction (WorldRuntime only looks straight down).
class Surfaces {
public:
struct Tri { V a, b, c, n; float floorUp; bool blocks; bool allWall; }; // floorUp: |n.up| needed to stand (0 = never floor);
// allWall: every face blocks (the Wall role)
struct Ball { V c; float r; float floorUp; bool blocks; };
void build(const HomeWorld& w) {
tris_.clear(); balls_.clear(); grid_.clear(); big_.clear();
auto addTri = [&](const V& a, const V& b, const V& c, float floorUp, bool blocks, bool allWall = false) {
const V n = cross(b - a, c - a);
if (len(n) < 1e-9f) return;
Tri t{a, b, c, norm(n), floorUp, blocks, allWall};
const uint32_t id = static_cast<uint32_t>(tris_.size());
tris_.push_back(t);
const int x0 = cell(std::min({a.x, b.x, c.x})), x1 = cell(std::max({a.x, b.x, c.x}));
const int y0 = cell(std::min({a.y, b.y, c.y})), y1 = cell(std::max({a.y, b.y, c.y}));
const int z0 = cell(std::min({a.z, b.z, c.z})), z1 = cell(std::max({a.z, b.z, c.z}));
if (static_cast<int64_t>(x1 - x0 + 1) * (y1 - y0 + 1) * (z1 - z0 + 1) > 4096) { big_.push_back(id); return; }
for (int x = x0; x <= x1; ++x) for (int y = y0; y <= y1; ++y) for (int z = z0; z <= z1; ++z) grid_[key(x, y, z)].push_back(id);
};
if (!w.walkIndices.empty()) {
const size_t nv = w.walkPositions.size() / 3;
for (size_t i = 0; i + 2 < w.walkIndices.size(); i += 3)
if (w.walkIndices[i] < nv && w.walkIndices[i + 1] < nv && w.walkIndices[i + 2] < nv)
addTri(V(&w.walkPositions[w.walkIndices[i] * 3]), V(&w.walkPositions[w.walkIndices[i + 1] * 3]), V(&w.walkPositions[w.walkIndices[i + 2] * 3]), 0.01f, false);
}
for (const WorldCollider& c : w.colliders) {
if (c.role == WorldCollider::Trigger) continue;
const float floorUp = c.role == WorldCollider::Solid ? 0.7f : c.role == WorldCollider::Floor || c.role == WorldCollider::Walkable ? 0.5f : 0.0f;
const bool blocks = c.role == WorldCollider::Solid || c.role == WorldCollider::Wall || c.role == WorldCollider::Object;
const bool allWall = c.role == WorldCollider::Wall || c.role == WorldCollider::Object; // blocks at any slope
if (c.kind == WorldCollider::Mesh) {
const std::vector<float> p = worldMeshPositions(c);
const size_t nv = p.size() / 3;
for (size_t i = 0; i + 2 < c.indices.size(); i += 3)
if (c.indices[i] < nv && c.indices[i + 1] < nv && c.indices[i + 2] < nv)
addTri(V(&p[c.indices[i] * 3]), V(&p[c.indices[i + 1] * 3]), V(&p[c.indices[i + 2] * 3]), floorUp, blocks, allWall);
} else if (c.kind == WorldCollider::Box) {
V v[8];
for (int k = 0; k < 8; ++k) v[k] = xform(c.world, V((k & 1 ? 1 : -1) * c.half[0], (k & 2 ? 1 : -1) * c.half[1], (k & 4 ? 1 : -1) * c.half[2]));
static const int f[6][4] = {{0, 1, 3, 2}, {4, 6, 7, 5}, {0, 4, 5, 1}, {2, 3, 7, 6}, {0, 2, 6, 4}, {1, 5, 7, 3}};
for (const auto& q : f) { addTri(v[q[0]], v[q[1]], v[q[2]], floorUp, blocks, allWall); addTri(v[q[0]], v[q[2]], v[q[3]], floorUp, blocks, allWall); }
} else if (c.kind == WorldCollider::Sphere) {
balls_.push_back({xform(c.world, V(0, 0, 0)), c.radius, floorUp, blocks});
} else { // capsule: three balls along its axis
for (float t : {-0.5f, 0.0f, 0.5f}) balls_.push_back({xform(c.world, V(0, t * c.length, 0)), c.radius, floorUp, blocks});
}
}
stamp_.assign(tris_.size(), 0);
}
bool empty() const { return tris_.empty() && balls_.empty(); }
// First surface you could stand on along o + d t (d unit), t in [0, maxT]. anyAngle: every surface counts as
// ground (Surface zones). n comes back facing the ray's origin.
// standableOut: when given, every surface stops the ray (a teleport arc doesn't pass through walls and
// ceilings), and it says whether the one hit can be stood on.
bool ray(const V& o, const V& d, float maxT, const V& up, bool anyAngle, float& tHit, V& nHit, bool* standableOut = nullptr) const {
bool hit = false;
tHit = maxT;
auto test = [&](uint32_t id) {
const Tri& t = tris_[id];
const V facing = dot(t.n, d) < 0 ? t.n : -t.n; // the side the ray comes from
const bool ok = standable(t.floorUp, facing, up, anyAngle);
if (!ok && !standableOut) return;
float th;
if (rayTri(o, d, t, th) && th >= 0 && th <= tHit) { tHit = th; nHit = facing; hit = true; if (standableOut) *standableOut = ok; }
};
++gen_;
const float step = kCell * 0.5f;
for (float s = 0; s <= maxT + step; s += step) {
const V p = o + d * std::min(s, maxT);
const int cx = cell(p.x), cy = cell(p.y), cz = cell(p.z);
for (int x = cx - 1; x <= cx + 1; ++x) for (int y = cy - 1; y <= cy + 1; ++y) for (int z = cz - 1; z <= cz + 1; ++z) {
auto it = grid_.find(key(x, y, z));
if (it == grid_.end()) continue;
for (uint32_t id : it->second) if (stamp_[id] != gen_) { stamp_[id] = gen_; test(id); }
}
if (s > tHit) break;
}
for (uint32_t id : big_) test(id);
for (const Ball& b : balls_) {
const V oc = o - b.c;
const float bq = dot(oc, d), cq = dot(oc, oc) - b.r * b.r, disc = bq * bq - cq;
if (disc < 0) continue;
const float th = -bq - std::sqrt(disc);
if (th < 0 || th > tHit) continue;
const V n = norm(o + d * th - b.c);
const bool ok = standable(b.floorUp, n, up, anyAngle);
if (!ok && !standableOut) continue;
tHit = th; nHit = n; hit = true;
if (standableOut) *standableOut = ok;
}
return hit;
}
// Pushes a ball (the body) out of what blocks it for this up: steep blocking surfaces, blocking balls.
// onlyWalls: just the surfaces that can never be floor (Surface zones, where every other surface is walkable).
V pushOut(V p, float r, const V& up, bool onlyWalls = false) const {
for (int pass = 0; pass < 2; ++pass) {
++gen_;
const int cx = cell(p.x), cy = cell(p.y), cz = cell(p.z);
auto test = [&](uint32_t id) {
const Tri& t = tris_[id];
if (!t.blocks || (onlyWalls ? t.floorUp > 0 : !t.allWall && std::fabs(dot(t.n, up)) >= 0.7f)) return;
const V q = closestOnTri(p, t.a, t.b, t.c), dv = p - q;
const float dl = len(dv);
if (dl < r && dl > 1e-6f) p = p + dv * ((r - dl) / dl);
};
for (int x = cx - 1; x <= cx + 1; ++x) for (int y = cy - 1; y <= cy + 1; ++y) for (int z = cz - 1; z <= cz + 1; ++z) {
auto it = grid_.find(key(x, y, z));
if (it == grid_.end()) continue;
for (uint32_t id : it->second) if (stamp_[id] != gen_) { stamp_[id] = gen_; test(id); }
}
for (uint32_t id : big_) test(id);
}
for (const Ball& b : balls_) {
if (!b.blocks || (onlyWalls && b.floorUp > 0)) continue;
const V dv = p - b.c;
const float dl = len(dv);
if (dl < b.r + r && dl > 1e-6f) p = b.c + dv * ((b.r + r) / dl);
}
return p;
}
private:
static constexpr float kCell = 2.0f;
std::vector<Tri> tris_;
std::vector<Ball> balls_;
std::unordered_map<int64_t, std::vector<uint32_t>> grid_;
std::vector<uint32_t> big_;
mutable std::vector<uint32_t> stamp_;
mutable uint32_t gen_ = 0;
static int cell(float v) { return static_cast<int>(std::floor(v / kCell)); }
static int64_t key(int x, int y, int z) { return (static_cast<int64_t>(x & 0x1FFFFF) << 42) | (static_cast<int64_t>(y & 0x1FFFFF) << 21) | (z & 0x1FFFFF); }
// n: the surface's normal on the side being approached. Facing up (for this up) to stand on it: the underside
// of a ceiling or a beam is never floor.
static bool standable(float floorUp, const V& n, const V& up, bool anyAngle) {
if (floorUp <= 0) return false; // walls never carry you
return anyAngle || dot(n, up) > floorUp;
}
static bool rayTri(const V& o, const V& d, const Tri& t, float& th) {
const V e1 = t.b - t.a, e2 = t.c - t.a, pv = cross(d, e2);
const float det = dot(e1, pv);
if (std::fabs(det) < 1e-9f) return false;
const float inv = 1 / det;
const V tv = o - t.a;
const float u = dot(tv, pv) * inv;
if (u < 0 || u > 1) return false;
const V qv = cross(tv, e1);
const float v = dot(d, qv) * inv;
if (v < 0 || u + v > 1) return false;
th = dot(e2, qv) * inv;
return true;
}
};
// The zone deciding down at p: the highest priority one containing it (ties: the later), or -1.
inline int zoneAt(const std::vector<WorldGravity>& zones, const V& p) {
int best = -1;
for (size_t i = 0; i < zones.size(); ++i) {
const WorldGravity& g = zones[i];
bool in;
if (g.kind == WorldGravity::Planet) in = len(p - V(&g.world[12])) <= g.radius;
else {
const V d = p - V(&g.world[12]);
in = true;
for (int k = 0; k < 3 && in; ++k) {
const V col(&g.world[k * 4]);
const float sc = len(col); // a scaled frame scales the region (as Studio draws it)
in = sc > 1e-6f && std::fabs(dot(d, col * (1.0f / sc))) <= g.half[k] * sc;
}
}
if (in && (best < 0 || g.priority >= zones[best].priority)) best = static_cast<int>(i);
}
return best;
}
} // namespace grav
class GravityWalker {
public:
using V = grav::V;
void build(const HomeWorld& w) {
surf_.build(w);
zones_ = w.gravity;
fallLimit_ = w.fallLimit > 1 ? w.fallLimit : 30.0f;
hasSpawn_ = !w.spawns.empty();
if (hasSpawn_) spawn_ = w.spawns[0];
reset();
}
// To the spawn (or the origin), standing on the ground under it, facing its way.
void reset() {
V p = hasSpawn_ ? V(spawn_.pos) : V(0, 0, 0);
const V up = upAt(p, V(0, 1, 0)); // not the old up: you may have fallen off a wall
const float yaw = hasSpawn_ ? spawn_.yaw : 0.0f;
V fwd(-std::sin(yaw), 0, -std::cos(yaw));
fwd = fwd - up * grav::dot(fwd, up);
if (grav::len(fwd) < 1e-3f) fwd = grav::cross(up, std::fabs(up.x) < 0.9f ? V(1, 0, 0) : V(0, 0, 1));
setBasis(up, grav::norm(fwd));
float t; V n;
if (surf_.ray(p + up * 1.5f, -up, 6.0f, up, zoneKind(p) == WorldGravity::Surface, t, n)) p = p + up * 1.5f - up * t;
origin_ = p;
vel_ = fallen_ = 0;
falling_ = false;
wallUp_ = V();
wallTime_ = 0;
walkV_ = V();
rem_ = riseV_ = 0;
lastFeet_ = origin_;
}
void rig(float m[16]) const {
const float r[16] = {x_.x, x_.y, x_.z, 0, up_.x, up_.y, up_.z, 0, z_.x, z_.y, z_.z, 0, origin_.x, origin_.y, origin_.z, 1};
std::copy(r, r + 16, m);
}
V up() const { return up_; }
bool falling() const { return falling_; }
int respawns() const { return respawns_; }
V head(const float headT[16]) const { return toWorld(V(&headT[12])); }
void setSpeed(float metresPerSecond) { speed_ = metresPerSecond; } // the walk stick at full deflection
// One frame: moveX / moveY the walk stick (-1..1), turn the turn to make now (radians, + = left).
void update(float dt, const float headT[16], float moveX, float moveY, float turn) {
dt = std::min(dt, 0.05f);
const V h(&headT[12]);
V hw = toWorld(h);
if (turn != 0) rotateRig(up_, turn, hw);
// Up: the zone's, or the wall walked into (Surface zones), eased so you roll onto it.
const int zi = grav::zoneAt(zones_, hw);
const bool surfaceZone = zi >= 0 && zones_[zi].kind == WorldGravity::Surface;
V target = upAt(hw, up_);
if (surfaceZone) {
float t; V n;
if (surf_.ray(hw, -up_, h.y + 1.0f, up_, true, t, n)) target = n;
if (wallTime_ > 0) { target = wallUp_; wallTime_ -= dt; }
}
const float ang = std::acos(std::clamp(grav::dot(up_, target), -1.0f, 1.0f));
if (ang > 1e-3f) {
V axis = grav::cross(up_, target);
if (grav::len(axis) < 1e-5f) axis = x_; // straight flip: roll forward
const float stepA = std::min(ang, (surfaceZone ? 4.0f : 2.5f) * dt);
rotateRig(grav::norm(axis), stepA, hw - up_ * h.y); // about the feet
}
hw = toWorld(h);
// Walk along the ground plane, head-relative, the velocity eased (approach) toward the stick's.
{
V want;
V fwd = toWorldDir(V(-headT[8], -headT[9], -headT[10]));
fwd = fwd - up_ * grav::dot(fwd, up_);
if ((moveX != 0 || moveY != 0) && grav::len(fwd) > 1e-4f) {
fwd = grav::norm(fwd);
want = (fwd * moveY + grav::cross(fwd, up_) * moveX) * speed_;
}
walkV_ = walkV_ - up_ * grav::dot(walkV_, up_); // along the ground as up turns
grav::approach(walkV_, want, speed_, dt);
}
if (grav::len(walkV_) > 1e-5f) {
V d = walkV_ * dt;
const V feet = hw - up_ * h.y;
if (surfaceZone) {
// A wall ahead becomes the floor: start rolling onto it.
float t; V n;
const V dd = grav::norm(d);
if (surf_.ray(feet + up_ * 0.3f, dd, grav::len(d) + 0.35f, up_, true, t, n) && std::fabs(grav::dot(n, up_)) < 0.7f) {
wallUp_ = n;
wallTime_ = 0.6f;
}
// Walls that can never be floor (the Wall role) still block here.
const V body = feet + up_ * 0.9f;
d = surf_.pushOut(body + d, 0.25f, up_, true) - body;
d = d - up_ * grav::dot(d, up_);
} else {
const V body = feet + up_ * 0.9f;
V moved = surf_.pushOut(body + d, 0.25f, up_);
d = moved - body;
d = d - up_ * grav::dot(d, up_);
}
origin_ = origin_ + d;
hw = hw + d;
walkV_ = d * (1 / dt); // what a wall left of it: no pent-up push
}
// Stand on the ground, or fall. The ground is looked for from knee height (a step), not from the head: a
// head inside a cabinet or under a beam would otherwise find that and pop you up onto it.
float t; V n;
const V feet = hw - up_ * h.y;
constexpr float kStep = 0.45f;
V walked = feet - lastFeet_; // stick and roomscale, along the ground
walked = walked - up_ * grav::dot(walked, up_);
lastFeet_ = feet;
if (!falling_ && surf_.ray(feet + up_ * kStep, -up_, kStep + 0.6f, up_, surfaceZone, t, n)) {
float rise = kStep - t; // + : the ground is above the feet (a step up)
// The ground's own change since last frame: a ramp is followed exactly, a step joins the eased rest.
const float d = rise - rem_;
if (std::fabs(d) <= grav::len(walked) * grav::kRampSlope + 0.004f) { origin_ = origin_ + up_ * d; rise -= d; }
const float left = grav::smoothDamp(rise, 0, riseV_, rise > 0 ? grav::kStepUp : grav::kStepDown, dt);
origin_ = origin_ + up_ * (rise - left);
rem_ = left;
vel_ = fallen_ = 0;
} else {
rem_ = riseV_ = 0;
falling_ = true;
vel_ = std::min(vel_ + 9.8f * dt, 55.0f);
const float stepD = vel_ * dt;
// Landing: from a step above the feet, so ground rising under you while you fall is still found.
if (surf_.ray(feet + up_ * kStep, -up_, stepD + kStep, up_, surfaceZone, t, n)) {
origin_ = origin_ + up_ * (kStep - t);
falling_ = false;
vel_ = fallen_ = 0;
} else {
origin_ = origin_ - up_ * stepD;
fallen_ += stepD;
if (fallen_ > fallLimit_) { ++respawns_; reset(); }
}
}
}
// The teleport arc from the hand (tracking space), under this gravity; true with the landing point. It stops at
// the first surface it meets: one you can stand on is the landing, anything else (a wall, a ceiling) means
// nowhere. normal (optional): the landing surface's normal, facing the arc. path (optional): the arc's points
// in world space, hand first, for drawing it. headPos (optional, tracking space): a hand reached through a wall
// from the head aims nothing.
bool aim(const float handPos[3], const float handDir[3], float target[3], float normal[3] = nullptr,
std::vector<float>* path = nullptr, const float* headPos = nullptr) const {
V p = toWorld(V(handPos)), v = toWorldDir(V(handDir)) * 7.0f;
auto keep = [&](const V& q) { if (path) { path->push_back(q.x); path->push_back(q.y); path->push_back(q.z); } };
if (path) path->clear();
keep(p);
const bool anyAngle = zoneKind(p) == WorldGravity::Surface;
if (headPos) {
const V hd = toWorld(V(headPos)), to = p - hd;
const float tl = grav::len(to);
float t; V n; bool st = false;
if (tl > 1e-4f && surf_.ray(hd, to * (1 / tl), tl, up_, anyAngle, t, n, &st)) return false;
}
for (int i = 0; i < 90; ++i) {
const float dt = 0.03f;
const V seg = v * dt - up_ * (4.9f * dt * dt);
const float sl = grav::len(seg);
float t; V n;
bool canStand = false;
if (sl > 1e-6f && surf_.ray(p, seg * (1 / sl), sl, up_, anyAngle, t, n, &canStand)) {
const V hit = p + seg * (t / sl);
keep(hit);
if (!canStand) return false; // a wall or a ceiling in the way: nowhere to land
target[0] = hit.x; target[1] = hit.y; target[2] = hit.z;
if (normal) { normal[0] = n.x; normal[1] = n.y; normal[2] = n.z; }
return true;
}
p = p + seg;
keep(p);
v = v - up_ * (9.8f * dt);
}
return false;
}
// Moves the player so their feet land on `target` (world). In a Surface zone, `normal` (the landing surface's)
// becomes the new up there: teleporting onto a wall stands you on it.
void teleport(const float target[3], const float headT[16], const float* normal = nullptr) {
if (normal && zoneKind(V(target)) == WorldGravity::Surface) {
const V nu = grav::norm(V(normal));
const float ang = std::acos(std::clamp(grav::dot(up_, nu), -1.0f, 1.0f));
if (ang > 1e-3f) {
V axis = grav::cross(up_, nu);
if (grav::len(axis) < 1e-5f) axis = x_;
rotateRig(grav::norm(axis), ang, toWorld(V(&headT[12])) - up_ * headT[13]);
}
wallTime_ = 0;
}
const V feet = toWorld(V(&headT[12])) - up_ * headT[13];
origin_ = origin_ + (V(target) - feet);
falling_ = false;
vel_ = fallen_ = 0;
walkV_ = V(); // you arrive standing still
rem_ = riseV_ = 0;
lastFeet_ = V(target);
}
// tracking -> world, for overlays (the teleport ring): the ring's frame at a world point.
V toTracking(const V& w) const { const V d = w - origin_; return V(grav::dot(d, x_), grav::dot(d, up_), grav::dot(d, z_)); }
private:
grav::Surfaces surf_;
std::vector<WorldGravity> zones_;
float fallLimit_ = 30, speed_ = 2.0f;
bool hasSpawn_ = false;
WorldPoint spawn_;
V x_{1, 0, 0}, up_{0, 1, 0}, z_{0, 0, 1}, origin_;
float vel_ = 0, fallen_ = 0;
bool falling_ = false;
V wallUp_;
float wallTime_ = 0;
int respawns_ = 0;
V walkV_; // walking velocity (eased, along the ground)
float rem_ = 0, riseV_ = 0; // a step still being eased (metres along up), its rate
V lastFeet_;
V toWorld(const V& t) const { return x_ * t.x + up_ * t.y + z_ * t.z + origin_; }
V toWorldDir(const V& t) const { return x_ * t.x + up_ * t.y + z_ * t.z; }
void setBasis(const V& up, const V& fwd) { up_ = grav::norm(up); z_ = -fwd; x_ = grav::norm(grav::cross(up_, z_)); z_ = grav::cross(x_, up_); }
void rotateRig(const V& axis, float a, const V& pivot) {
up_ = grav::norm(grav::rotate(up_, axis, a));
x_ = grav::rotate(x_, axis, a);
x_ = grav::norm(x_ - up_ * grav::dot(x_, up_)); // Gram-Schmidt: no drift from many small turns
z_ = grav::cross(x_, up_);
origin_ = pivot + grav::rotate(origin_ - pivot, axis, a);
}
int zoneKind(const V& p) const { const int zi = grav::zoneAt(zones_, p); return zi >= 0 ? zones_[zi].kind : -1; }
// Up at p for Direction / Planet zones and no zone; Surface zones keep `cur` (their up comes from the ground).
V upAt(const V& p, const V& cur) const {
const int zi = grav::zoneAt(zones_, p);
if (zi < 0) return V(0, 1, 0);
const WorldGravity& g = zones_[zi];
if (g.kind == WorldGravity::Direction) return grav::norm(V(&g.world[4]));
if (g.kind == WorldGravity::Planet) { const V d = p - V(&g.world[12]); return grav::len(d) > 1e-3f ? grav::norm(d) : cur; }
return cur;
}
};
+225
View File
@@ -0,0 +1,225 @@
#pragma once
// Guide geometry: what Frame Home draws itself to show you where you're going (the teleport arc and its landing
// marker, the seats you can sit in, the pointer on a seat). Built on the CPU each frame as plain world-space
// triangles with a colour each, and drawn by HslRenderer::setGuide over the scene (depth tested, so walls hide it,
// with a faint copy where it's hidden). It's Frame Home's own frame, so it shows in system mode too, where
// SteamVR overlays of a non-scene app can't be relied on.
#include <algorithm>
#include <cmath>
#include <vector>
struct GuideVertex { float pos[3]; float rgba[4]; }; // world (home) space; linear colour, straight alpha
// What locomotion wants shown this frame (Locomotion::guide()).
struct LocoGuide {
bool aiming = false, valid = false, seat = false; // the arc is up; it lands; it lands on a seat
std::vector<float> arc; // xyz points, the hand first, the end last
float target[3] = {0, 0, 0}, normal[3] = {0, 1, 0}; // landing point and its surface's normal
float aimTime = 0; // seconds the arc has been up (it fades in)
struct Seat { float pos[3]; float yaw; bool hot; float near = 1; }; // hot: pointed at (or the arc lands on it); near: 0..1 fade
std::vector<Seat> seats; // seats near you (not the one you sit in)
bool pointer = false; // a hand points at a seat: a short line to it
float pointerFrom[3] = {0, 0, 0}, pointerTo[3] = {0, 0, 0};
};
namespace guide {
struct P { float x, y, z; };
inline P operator+(P a, P b) { return {a.x + b.x, a.y + b.y, a.z + b.z}; }
inline P operator-(P a, P b) { return {a.x - b.x, a.y - b.y, a.z - b.z}; }
inline P operator*(P a, float s) { return {a.x * s, a.y * s, a.z * s}; }
inline float dot(P a, P b) { return a.x * b.x + a.y * b.y + a.z * b.z; }
inline P cross(P a, P b) { return {a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x}; }
inline P norm(P a) { const float l = std::sqrt(dot(a, a)); return l > 1e-9f ? a * (1 / l) : P{0, 1, 0}; }
inline float smooth(float e0, float e1, float x) { const float t = std::clamp((x - e0) / (e1 - e0), 0.0f, 1.0f); return t * t * (3 - 2 * t); }
struct C { float r, g, b, a; };
inline C mix(C a, C b, float t) { return {a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t, a.a + (b.a - a.a) * t}; }
inline C alpha(C c, float a) { return {c.r, c.g, c.b, c.a * a}; }
inline void vert(std::vector<GuideVertex>& o, P p, C c) { o.push_back({{p.x, p.y, p.z}, {c.r, c.g, c.b, c.a}}); }
inline void tri(std::vector<GuideVertex>& o, P a, C ca, P b, C cb, P c, C cc) { vert(o, a, ca); vert(o, b, cb); vert(o, c, cc); }
inline void quad(std::vector<GuideVertex>& o, P a, C ca, P b, C cb, P c, C cc, P d, C cd) { tri(o, a, ca, b, cb, c, cc); tri(o, a, ca, c, cc, d, cd); }
// Two axes across a surface with normal n.
inline void basis(P n, P& u, P& w) {
u = norm(cross(std::fabs(n.y) < 0.9f ? P{0, 1, 0} : P{1, 0, 0}, n));
w = cross(n, u);
}
// A flat band between radii r0 and r1 around c (in the u/w plane), soft-edged: transparent at both rims, `col`
// along its middle.
inline void ring(std::vector<GuideVertex>& o, P c, P u, P w, float r0, float r1, C col, int segs = 64) {
const float rm = (r0 + r1) * 0.5f;
const C clear = alpha(col, 0);
for (int i = 0; i < segs; ++i) {
const float a0 = 6.2831853f * i / segs, a1 = 6.2831853f * (i + 1) / segs;
const P d0 = u * std::cos(a0) + w * std::sin(a0), d1 = u * std::cos(a1) + w * std::sin(a1);
quad(o, c + d0 * r0, clear, c + d1 * r0, clear, c + d1 * rm, col, c + d0 * rm, col);
quad(o, c + d0 * rm, col, c + d1 * rm, col, c + d1 * r1, clear, c + d0 * r1, clear);
}
}
// A filled disc, `mid` at the centre fading to `rim` at radius r.
inline void disc(std::vector<GuideVertex>& o, P c, P u, P w, float r, C mid, C rim, int segs = 48) {
for (int i = 0; i < segs; ++i) {
const float a0 = 6.2831853f * i / segs, a1 = 6.2831853f * (i + 1) / segs;
tri(o, c, mid, c + (u * std::cos(a0) + w * std::sin(a0)) * r, rim, c + (u * std::cos(a1) + w * std::sin(a1)) * r, rim);
}
}
// The comfort vignette: the edges of view darkened by `amount` (0..1) while you move without moving yourself. A band
// a hand's width in front of the eyes, facing along f (u, w across it): clear inside `inner`, dark from `outer` (both
// tangents of the angle off the view's centre) out past the edge of view.
inline void vignette(std::vector<GuideVertex>& o, P eye, P f, P u, P w, float inner, float outer, float amount, int segs = 48) {
const float d = 0.25f;
const P c = eye + f * d;
const C clear{0, 0, 0, 0}, dark{0, 0, 0, amount};
for (int i = 0; i < segs; ++i) {
const float a0 = 6.2831853f * i / segs, a1 = 6.2831853f * (i + 1) / segs;
const P d0 = u * std::cos(a0) + w * std::sin(a0), d1 = u * std::cos(a1) + w * std::sin(a1);
quad(o, c + d0 * (inner * d), clear, c + d1 * (inner * d), clear, c + d1 * (outer * d), dark, c + d0 * (outer * d), dark);
quad(o, c + d0 * (outer * d), dark, c + d1 * (outer * d), dark, c + d1 * (4.0f * d), dark, c + d0 * (4.0f * d), dark);
}
}
// A chevron (an arrowhead ">") pointing along f in the plane of f and s, its tip at `tip`.
inline void chevron(std::vector<GuideVertex>& o, P tip, P f, P s, float size, float thick, C col) {
const C clear = alpha(col, 0);
for (float side : {-1.0f, 1.0f}) {
const P a = tip, b = tip - f * size + s * (side * size);
const P across = norm(cross(cross(f, s), b - a)) * (thick * 0.5f); // in the plane, across the stroke
quad(o, a - across, clear, b - across, clear, b, col, a, col);
quad(o, a, col, b, col, b + across, clear, a + across, clear);
}
}
// A camera-facing ribbon along the points, soft-edged across. width(s, u) / colour(s, u): s metres from the
// start, u = s / length.
template <typename W, typename K>
inline void ribbon(std::vector<GuideVertex>& o, const std::vector<P>& pts, P eye, W width, K colour) {
if (pts.size() < 2) return;
std::vector<float> s(pts.size(), 0);
for (size_t i = 1; i < pts.size(); ++i) s[i] = s[i - 1] + std::sqrt(dot(pts[i] - pts[i - 1], pts[i] - pts[i - 1]));
const float len = std::max(s.back(), 1e-4f);
P prevL{}, prevM{}, prevR{};
C prevC{};
for (size_t i = 0; i < pts.size(); ++i) {
const P t = norm(pts[std::min(i + 1, pts.size() - 1)] - pts[i > 0 ? i - 1 : 0]);
const P side = norm(cross(t, eye - pts[i])) * width(s[i], s[i] / len);
const C c = colour(s[i], s[i] / len);
const P L = pts[i] - side, M = pts[i], R = pts[i] + side;
if (i > 0) {
quad(o, prevL, alpha(prevC, 0), L, alpha(c, 0), M, c, prevM, prevC);
quad(o, prevM, prevC, M, c, R, alpha(c, 0), prevR, alpha(prevC, 0));
}
prevL = L; prevM = M; prevR = R; prevC = c;
}
}
// The points resampled every `step` metres (dashes need even spacing; the arc's own points are 7 m/s x 30 ms).
inline std::vector<P> resample(const std::vector<P>& in, float step) {
std::vector<P> out;
if (in.empty()) return out;
out.push_back(in[0]);
float carry = 0;
for (size_t i = 1; i < in.size(); ++i) {
const P d = in[i] - in[i - 1];
const float l = std::sqrt(dot(d, d));
float at = step - carry;
for (; at < l; at += step) out.push_back(in[i - 1] + d * (at / l));
carry = l - (at - step);
}
if (std::sqrt(dot(out.back() - in.back(), out.back() - in.back())) > 1e-4f) out.push_back(in.back());
return out;
}
} // namespace guide
// Everything in `g` as triangles, for an eye at `eye` (ribbons face it) at time `t` seconds (dashes flow, rings
// pulse). Appends to `out`.
inline void buildGuide(const LocoGuide& g, const float eye[3], float t, std::vector<GuideVertex>& out) {
using namespace guide;
const P E{eye[0], eye[1], eye[2]};
const C cyan{0.30f, 0.78f, 1.00f, 1}, ice{0.86f, 0.97f, 1.00f, 1}, red{1.00f, 0.32f, 0.26f, 1}, gold{1.00f, 0.78f, 0.42f, 1};
const float appear = smooth(0.0f, 0.12f, g.aimTime);
const float pulse = 0.5f + 0.5f * std::sin(t * 6.2831853f * 1.1f); // ~1 Hz: alive, not flashing
// Seats near you: a faint ring on the seat, a backrest arc behind it and a chevron the way it faces;
// bright and breathing while pointed at.
for (const LocoGuide::Seat& s : g.seats) {
const P c{s.pos[0], s.pos[1] + 0.08f, s.pos[2]}; // a little over the seat point: cushions sit above it
const P f{-std::sin(s.yaw), 0, -std::cos(s.yaw)}, r{std::cos(s.yaw), 0, -std::sin(s.yaw)}, up{0, 1, 0};
constexpr float kSize = 0.6f; // small: a hint on the seat, not furniture of its own
const float k = s.hot ? 0.8f + 0.2f * pulse : 0.38f * s.near, grow = (s.hot ? 1.0f + 0.05f * pulse : 1.0f) * kSize;
const C col = alpha(s.hot ? mix(gold, C{1, 0.95f, 0.85f, 1}, 0.3f) : gold, k);
ring(out, c, r, f, 0.20f * grow, 0.235f * grow, col);
if (s.hot) disc(out, c, r, f, 0.20f * grow, alpha(gold, 0.16f), alpha(gold, 0.05f));
// the backrest: an upright arc behind the seat, so it reads as a chair from any side
std::vector<P> back;
for (int i = 0; i <= 16; ++i) {
const float a = -1.1f + 2.2f * i / 16;
back.push_back(c - f * (0.235f * grow * std::cos(a)) + r * (0.235f * grow * std::sin(a)) + up * (0.20f * grow * std::cos(a * 0.9f)));
}
ribbon(out, back, E, [](float, float) { return 0.012f; }, [&](float, float) { return col; });
chevron(out, c + f * (0.34f * grow), f, r, 0.07f * grow, 0.022f * grow, col);
// and a "here" chevron floating over it, above chair backs, so it shows from behind the furniture too;
// pointed at, it bobs gently (the marker moves, never your view)
if (s.hot) { // (only while pointed at: close by, the seat's ring says enough)
const P over = c + up * (0.45f + 0.03f * pulse);
const P across = norm(cross(up, E - over));
chevron(out, over, up * -1.0f, across, 0.06f, 0.02f, col);
}
}
// The pointer: from the hand to the seat it's on, fading in from the hand.
if (g.pointer) {
const P a{g.pointerFrom[0], g.pointerFrom[1], g.pointerFrom[2]}, b{g.pointerTo[0], g.pointerTo[1], g.pointerTo[2]};
std::vector<P> line;
for (int i = 0; i <= 24; ++i) line.push_back(a + (b - a) * (i / 24.0f));
ribbon(out, line, E, [](float, float u) { return 0.003f + 0.002f * u; },
[&](float, float u) { return alpha(gold, 0.85f * smooth(0.0f, 0.35f, u)); });
P u, w;
basis(norm(E - b), u, w);
disc(out, b, u, w, 0.018f, alpha(C{1, 0.95f, 0.85f, 1}, 0.9f), alpha(gold, 0), 16);
}
if (!g.aiming || g.arc.size() < 6) return;
std::vector<P> raw;
for (size_t i = 0; i + 2 < g.arc.size(); i += 3) raw.push_back({g.arc[i], g.arc[i + 1], g.arc[i + 2]});
const std::vector<P> pts = resample(raw, 0.03f);
const C tip = g.seat ? gold : ice;
// The arc: a soft glow under a bright core, both transparent at the hand (so it grows out of the controller
// rather than starting in your face) and, when it lands, dashes flowing toward the target. Aimed nowhere it is
// dim red and still.
auto core = [&](float s, float u) {
const float hand = smooth(0.0f, 0.35f, s);
if (!g.valid) return alpha(red, 0.55f * hand * appear * (1 - 0.6f * smooth(0.7f, 1.0f, u)));
const float d = 0.5f + 0.5f * std::cos(6.2831853f * (s - t * 1.4f) / 0.22f); // a dash every 22 cm, 1.4 m/s
const float dash = 0.4f + 0.6f * smooth(0.25f, 0.75f, d);
return alpha(mix(cyan, tip, smooth(0.2f, 1.0f, u)), 0.95f * hand * dash * appear);
};
ribbon(out, pts, E, [](float, float u) { return 0.030f + 0.012f * u; },
[&](float s, float u) { const C c = core(s, u); return alpha(c, 0.28f); });
ribbon(out, pts, E, [](float, float u) { return 0.009f + 0.004f * u; }, core);
if (!g.valid) return;
// The landing marker, flat on the surface: a pulsing ring, a faint disc, a ripple spreading out every second,
// and a chevron pointing the way you're going.
const P n = norm(P{g.normal[0], g.normal[1], g.normal[2]});
const P c = P{g.target[0], g.target[1], g.target[2]} + n * 0.012f;
P u, w;
basis(n, u, w);
const float R = 0.26f * (1.0f + 0.035f * pulse);
ring(out, c, u, w, R - 0.022f, R + 0.022f, alpha(tip, 0.9f * appear));
disc(out, c, u, w, R - 0.02f, alpha(tip, 0.20f * appear), alpha(cyan, 0.08f * appear));
const float ph = std::fmod(t, 1.0f);
ring(out, c, u, w, R + 0.1f * ph - 0.012f, R + 0.1f * ph + 0.012f, alpha(tip, 0.5f * (1 - ph) * appear));
// which way: from the hand toward the target, along the surface
P f = P{g.target[0], g.target[1], g.target[2]} - raw.front();
f = f - n * dot(f, n);
if (dot(f, f) > 1e-6f) {
f = norm(f);
chevron(out, c + f * (R - 0.06f), f, cross(n, f), 0.08f, 0.026f, alpha(tip, 0.85f * appear));
}
}
+66
View File
@@ -0,0 +1,66 @@
#pragma once
// GENERATED by tools/guide/gen_guide_spirv.sh from tools/guide/guide.{vert,frag}. Do not edit by hand.
// vert: in vec3 position @0, vec4 color @1; push { mat4 clipFromWorld[2]; }; spec 0 = alpha scale
// frag: out = color
#include <cstddef>
#include <cstdint>
static const uint32_t kGuideVertSpirv[] = {
0x07230203,0x00010300,0x000d000b,0x00000037,0x00000000,0x00020011,0x00000001,0x00020011,
0x00001157,0x0006000b,0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,
0x00000000,0x00000001,0x000a000f,0x00000000,0x00000004,0x6e69616d,0x00000000,0x0000000d,
0x00000017,0x0000001e,0x00000028,0x0000002a,0x00030047,0x0000000b,0x00000002,0x00050048,
0x0000000b,0x00000000,0x0000000b,0x00000000,0x00050048,0x0000000b,0x00000001,0x0000000b,
0x00000001,0x00050048,0x0000000b,0x00000002,0x0000000b,0x00000003,0x00050048,0x0000000b,
0x00000003,0x0000000b,0x00000004,0x00040047,0x00000012,0x00000006,0x00000040,0x00030047,
0x00000013,0x00000002,0x00040048,0x00000013,0x00000000,0x00000005,0x00050048,0x00000013,
0x00000000,0x00000007,0x00000010,0x00050048,0x00000013,0x00000000,0x00000023,0x00000000,
0x00040047,0x00000017,0x0000000b,0x00001158,0x00040047,0x0000001e,0x0000001e,0x00000000,
0x00040047,0x00000028,0x0000001e,0x00000000,0x00040047,0x0000002a,0x0000001e,0x00000001,
0x00040047,0x00000031,0x00000001,0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,
0x00000002,0x00030016,0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,0x00000004,
0x00040015,0x00000008,0x00000020,0x00000000,0x0004002b,0x00000008,0x00000009,0x00000001,
0x0004001c,0x0000000a,0x00000006,0x00000009,0x0006001e,0x0000000b,0x00000007,0x00000006,
0x0000000a,0x0000000a,0x00040020,0x0000000c,0x00000003,0x0000000b,0x0004003b,0x0000000c,
0x0000000d,0x00000003,0x00040015,0x0000000e,0x00000020,0x00000001,0x0004002b,0x0000000e,
0x0000000f,0x00000000,0x00040018,0x00000010,0x00000007,0x00000004,0x0004002b,0x00000008,
0x00000011,0x00000002,0x0004001c,0x00000012,0x00000010,0x00000011,0x0003001e,0x00000013,
0x00000012,0x00040020,0x00000014,0x00000009,0x00000013,0x0004003b,0x00000014,0x00000015,
0x00000009,0x00040020,0x00000016,0x00000001,0x0000000e,0x0004003b,0x00000016,0x00000017,
0x00000001,0x00040020,0x00000019,0x00000009,0x00000010,0x00040017,0x0000001c,0x00000006,
0x00000003,0x00040020,0x0000001d,0x00000001,0x0000001c,0x0004003b,0x0000001d,0x0000001e,
0x00000001,0x0004002b,0x00000006,0x00000020,0x3f800000,0x00040020,0x00000026,0x00000003,
0x00000007,0x0004003b,0x00000026,0x00000028,0x00000003,0x00040020,0x00000029,0x00000001,
0x00000007,0x0004003b,0x00000029,0x0000002a,0x00000001,0x0004002b,0x00000008,0x0000002d,
0x00000003,0x00040020,0x0000002e,0x00000001,0x00000006,0x00040032,0x00000006,0x00000031,
0x3f800000,0x00050036,0x00000002,0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,
0x0004003d,0x0000000e,0x00000018,0x00000017,0x00060041,0x00000019,0x0000001a,0x00000015,
0x0000000f,0x00000018,0x0004003d,0x00000010,0x0000001b,0x0000001a,0x0004003d,0x0000001c,
0x0000001f,0x0000001e,0x00050051,0x00000006,0x00000021,0x0000001f,0x00000000,0x00050051,
0x00000006,0x00000022,0x0000001f,0x00000001,0x00050051,0x00000006,0x00000023,0x0000001f,
0x00000002,0x00070050,0x00000007,0x00000024,0x00000021,0x00000022,0x00000023,0x00000020,
0x00050091,0x00000007,0x00000025,0x0000001b,0x00000024,0x00050041,0x00000026,0x00000027,
0x0000000d,0x0000000f,0x0003003e,0x00000027,0x00000025,0x0004003d,0x00000007,0x0000002b,
0x0000002a,0x00050041,0x0000002e,0x0000002f,0x0000002a,0x0000002d,0x0004003d,0x00000006,
0x00000030,0x0000002f,0x00050085,0x00000006,0x00000032,0x00000030,0x00000031,0x00050051,
0x00000006,0x00000033,0x0000002b,0x00000000,0x00050051,0x00000006,0x00000034,0x0000002b,
0x00000001,0x00050051,0x00000006,0x00000035,0x0000002b,0x00000002,0x00070050,0x00000007,
0x00000036,0x00000033,0x00000034,0x00000035,0x00000032,0x0003003e,0x00000028,0x00000036,
0x000100fd,0x00010038,
};
static const size_t kGuideVertSize = sizeof(kGuideVertSpirv);
static const uint32_t kGuideFragSpirv[] = {
0x07230203,0x00010300,0x000d000b,0x0000000d,0x00000000,0x00020011,0x00000001,0x0006000b,
0x00000001,0x4c534c47,0x6474732e,0x3035342e,0x00000000,0x0003000e,0x00000000,0x00000001,
0x0007000f,0x00000004,0x00000004,0x6e69616d,0x00000000,0x00000009,0x0000000b,0x00030010,
0x00000004,0x00000007,0x00040047,0x00000009,0x0000001e,0x00000000,0x00040047,0x0000000b,
0x0000001e,0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,0x00030016,
0x00000006,0x00000020,0x00040017,0x00000007,0x00000006,0x00000004,0x00040020,0x00000008,
0x00000003,0x00000007,0x0004003b,0x00000008,0x00000009,0x00000003,0x00040020,0x0000000a,
0x00000001,0x00000007,0x0004003b,0x0000000a,0x0000000b,0x00000001,0x00050036,0x00000002,
0x00000004,0x00000000,0x00000003,0x000200f8,0x00000005,0x0004003d,0x00000007,0x0000000c,
0x0000000b,0x0003003e,0x00000009,0x0000000c,0x000100fd,0x00010038,
};
static const size_t kGuideFragSize = sizeof(kGuideFragSpirv);
+248
View File
@@ -0,0 +1,248 @@
#pragma once
// Per-home edits made in Frame Studio (moved / deleted objects), saved as "<home>.apk.edits.json"
// next to the home on the Frame. The home itself stays Meta's untouched APK.
//
// { "home": "theatre.apk",
// "objects": [ { "index": 12, "name": "merged_sofa", "deleted": false,
// "pivot": [16 floats], "world": [16 floats] } ] }
//
// `index` is the mesh's position in the loaded scene; `name` guards against a different home version.
// `world` is the mesh's final world matrix; `pivot` is the editor's gizmo frame (only Studio uses it).
//
// A background sound added in Frame Studio, and how loud the home's own ambience plays (0: off):
// "sound": { "file": "haven.apk.sound-1a2b3c4d.mp3", "name": "rain.mp3", "volume": 0.8, "homeVolume": 1 }
// `file` sits next to this edits file (on the Frame beside the home; in a .fenv beside its edits.json): Ogg Vorbis,
// Ogg Opus, WAV, MP3 or FLAC, looped.
//
// What the environment is called and its picture, as you set them in Frame Studio (the headset's Environments panel
// shows them; a .fenv carries them as its name and thumbnail):
// "title": "Rainy Haven", "image": "haven.apk.image-1a2b3c4d.jpg" (the image beside this file, like the sound)
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <sstream>
#include <string>
#include <vector>
struct ObjectEdit {
int index = -1;
std::string name;
bool deleted = false;
float pivot[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
float world[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
};
struct HomeSound {
std::string file; // "" = no sound of its own
std::string name; // what the file was called when it was added (shown in Studio)
float volume = 0.8f; // 0..1
float homeVolume = 1; // the home's own ambience, 0..1
bool any() const { return !file.empty() || homeVolume != 1; }
};
struct HomeEdits {
std::string home;
std::vector<ObjectEdit> objects;
HomeSound sound;
std::string title; // the environment's name ("" = the home's own)
std::string image; // its picture, a file beside this one ("" = a view of it)
bool empty() const { return objects.empty() && !sound.any() && title.empty() && image.empty(); }
};
// Sound files a background sound can be.
inline bool isSoundFile(std::string name) {
for (char& c : name) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
for (const char* x : {".ogg", ".opus", ".oga", ".wav", ".mp3", ".flac"})
if (name.size() > std::strlen(x) && name.compare(name.size() - std::strlen(x), std::strlen(x), x) == 0) return true;
return false;
}
// The sound file of edits read from `editsPath` (it sits beside it); "" when there's none.
inline std::string soundPathFor(const std::string& editsPath, const HomeSound& s) {
if (s.file.empty() || s.file.find_first_of("/\\") != std::string::npos || s.file == ".." || s.file == ".") return {};
const size_t slash = editsPath.find_last_of("/\\");
return (slash == std::string::npos ? std::string() : editsPath.substr(0, slash + 1)) + s.file;
}
namespace home_edits_detail {
inline std::string quote(const std::string& s) {
std::string o = "\"";
for (char c : s) {
if (c == '"' || c == '\\') { o += '\\'; o += c; }
else if (static_cast<unsigned char>(c) < 0x20) { char b[8]; std::snprintf(b, sizeof b, "\\u%04x", c); o += b; }
else o += c;
}
return o + "\"";
}
// Minimal reader for the subset this file uses: objects, arrays, strings, numbers, booleans.
struct Reader {
const char* p;
const char* end;
void ws() { while (p < end && std::isspace(static_cast<unsigned char>(*p))) ++p; }
bool eat(char c) { ws(); if (p < end && *p == c) { ++p; return true; } return false; }
bool str(std::string& out) {
ws();
if (p >= end || *p != '"') return false;
++p;
out.clear();
while (p < end && *p != '"') {
if (*p == '\\' && p + 1 < end) {
++p;
if (*p == 'u' && p + 4 < end) { out += static_cast<char>(std::strtol(std::string(p + 1, 4).c_str(), nullptr, 16)); p += 5; continue; }
out += *p == 'n' ? '\n' : *p == 't' ? '\t' : *p;
++p;
} else {
out += *p++;
}
}
return eat('"');
}
bool num(double& v) {
ws();
char* e = nullptr;
v = std::strtod(p, &e);
if (e == p) return false;
p = e;
return true;
}
bool boolean(bool& b) {
ws();
if (end - p >= 4 && std::strncmp(p, "true", 4) == 0) { b = true; p += 4; return true; }
if (end - p >= 5 && std::strncmp(p, "false", 5) == 0) { b = false; p += 5; return true; }
return false;
}
bool floats(float* out, int n) {
if (!eat('[')) return false;
for (int i = 0; i < n; ++i) {
double v;
if (!num(v)) return false;
out[i] = static_cast<float>(v);
if (i + 1 < n && !eat(',')) return false;
}
return eat(']');
}
// Skips any value (for keys this version does not know). Depth-capped: a hostile file of nested '['
// would otherwise recurse until the stack overflows.
bool skip(int depth = 0) {
ws();
if (p >= end || depth > 256) return false;
if (*p == '"') { std::string s; return str(s); }
if (*p == '{' || *p == '[') {
const char close = *p == '{' ? '}' : ']';
++p;
if (eat(close)) return true;
do {
if (close == '}') { std::string k; if (!str(k) || !eat(':')) return false; }
if (!skip(depth + 1)) return false;
} while (eat(','));
return eat(close);
}
bool b; double d;
return boolean(b) || num(d) || (end - p >= 4 && std::strncmp(p, "null", 4) == 0 && (p += 4, true));
}
};
} // namespace home_edits_detail
inline std::string editsPathFor(const std::string& homePath) { return homePath + ".edits.json"; }
// Frame Studio's world layer for a home (spawns, seats, walls, walkable areas: core/world.h), next to it.
inline std::string worldPathFor(const std::string& homePath) { return homePath + ".world.json"; }
inline bool saveHomeEdits(const std::string& path, const HomeEdits& e) {
using home_edits_detail::quote;
std::ostringstream o;
o.precision(9);
auto mat = [&](const float* m) {
o << "[";
for (int i = 0; i < 16; ++i) o << (i ? ", " : "") << m[i];
o << "]";
};
o << "{\n \"home\": " << quote(e.home) << ",\n \"objects\": [";
for (size_t i = 0; i < e.objects.size(); ++i) {
const ObjectEdit& x = e.objects[i];
o << (i ? ",\n" : "\n") << " { \"index\": " << x.index << ", \"name\": " << quote(x.name)
<< ", \"deleted\": " << (x.deleted ? "true" : "false") << ",\n \"pivot\": ";
mat(x.pivot);
o << ",\n \"world\": ";
mat(x.world);
o << " }";
}
o << "\n ]";
if (e.sound.any())
o << ",\n \"sound\": { \"file\": " << quote(e.sound.file) << ", \"name\": " << quote(e.sound.name) << ", \"volume\": " << e.sound.volume << ", \"homeVolume\": " << e.sound.homeVolume << " }";
if (!e.title.empty()) o << ",\n \"title\": " << quote(e.title);
if (!e.image.empty()) o << ",\n \"image\": " << quote(e.image);
o << "\n}\n";
std::ofstream f(path, std::ios::binary);
if (!f) return false;
f << o.str();
return static_cast<bool>(f);
}
inline bool loadHomeEdits(const std::string& path, HomeEdits& out) {
std::ifstream f(path, std::ios::binary);
if (!f) return false;
std::stringstream ss;
ss << f.rdbuf();
const std::string text = ss.str();
home_edits_detail::Reader r{text.data(), text.data() + text.size()};
out = HomeEdits{};
if (!r.eat('{')) return false;
if (r.eat('}')) return true;
do {
std::string key;
if (!r.str(key) || !r.eat(':')) return false;
if (key == "home") {
if (!r.str(out.home)) return false;
} else if (key == "title") {
if (!r.str(out.title)) return false;
} else if (key == "image") {
if (!r.str(out.image)) return false;
} else if (key == "objects") {
if (!r.eat('[')) return false;
if (r.eat(']')) continue;
do {
ObjectEdit x;
if (!r.eat('{')) return false;
do {
std::string k;
if (!r.str(k) || !r.eat(':')) return false;
double d;
bool ok = k == "index" ? (r.num(d) && (x.index = static_cast<int>(d), true))
: k == "name" ? r.str(x.name)
: k == "deleted" ? r.boolean(x.deleted)
: k == "pivot" ? r.floats(x.pivot, 16)
: k == "world" ? r.floats(x.world, 16)
: r.skip();
if (!ok) return false;
} while (r.eat(','));
if (!r.eat('}')) return false;
out.objects.push_back(x);
} while (r.eat(','));
if (!r.eat(']')) return false;
} else if (key == "sound") {
if (!r.eat('{')) return false;
if (r.eat('}')) continue;
do {
std::string k;
if (!r.str(k) || !r.eat(':')) return false;
double d = 0;
const bool ok = k == "file" ? r.str(out.sound.file)
: k == "name" ? r.str(out.sound.name)
: k == "volume" ? (r.num(d) && (out.sound.volume = static_cast<float>(std::clamp(d, 0.0, 1.0)), true))
: k == "homeVolume" ? (r.num(d) && (out.sound.homeVolume = static_cast<float>(std::clamp(d, 0.0, 1.0)), true))
: r.skip();
if (!ok) return false;
} while (r.eat(','));
if (!r.eat('}')) return false;
} else if (!r.skip()) {
return false;
}
} while (r.eat(','));
return r.eat('}');
}
+229
View File
@@ -0,0 +1,229 @@
#pragma once
// A home's health report: what it's made of, what it costs and what's wrong with it, from the loaded scene alone
// (no GPU). fenv-tool report prints it, Frame Studio shows it (Debug > Home Report), and Frame Home writes it to
// ~/frame-home/report.json after each load for the Environments panel's Debug page.
#include "hsl_scene.h"
#include "walkgrid.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <map>
#include <string>
#include <vector>
struct HomeProblem {
int level = 1; // 0 worth knowing, 1 costs speed or looks, 2 broken
std::string what; // one line
int mesh = -1; // the HslScene::meshes index it's about, if one
};
struct HomeReport {
std::string name, format;
size_t meshes = 0, triangles = 0, vertices = 0, empty = 0;
size_t seeThrough = 0, seeThroughTris = 0, cutout = 0, skinned = 0, animated = 0, lightmapped = 0, sky = 0, programs = 0;
size_t textures = 0; // distinct
double textureMB = 0, decodedMB = 0, geometryMB = 0; // compressed (as the GPU gets it when it can), decoded RGBA, vertices + indices
size_t spawns = 0, seats = 0, hotspots = 0, colliders = 0, wallSpots = 0, gravity = 0;
double walkArea = -1; // m², < 0: no walkable area known
double loadSec = 0; // set by the caller
struct Mesh { size_t tris; int mesh; };
struct Tex { unsigned w, h, mips; double mb; size_t users; int mesh; };
std::vector<Mesh> biggestMeshes; // most triangles first
std::vector<Tex> biggestTextures; // most memory first
std::vector<HomeProblem> problems; // worst first
std::vector<std::string> meshNames; // by HslScene::meshes index (for the lists above)
int worst() const { int w = -1; for (const auto& p : problems) w = std::max(w, p.level); return w; }
std::string meshName(int i) const { return i >= 0 && static_cast<size_t>(i) < meshNames.size() ? meshNames[i] : std::string(); }
std::string text() const {
std::string o;
char b[512];
std::snprintf(b, sizeof b, "%s%s%s\n", name.c_str(), format.empty() ? "" : " · ", format.c_str());
o += b;
std::snprintf(b, sizeof b, "%zu meshes, %.2f M triangles, %.2f M vertices%s\n", meshes, triangles / 1e6, vertices / 1e6,
loadSec > 0 ? ("; loaded in " + num(loadSec, 1) + " s").c_str() : "");
o += b;
std::snprintf(b, sizeof b, "see-through %zu (%.2f M triangles), cut-out %zu, skinned %zu, animated %zu, lightmapped %zu, sky %zu, %zu shaders\n",
seeThrough, seeThroughTris / 1e6, cutout, skinned, animated, lightmapped, sky, programs);
o += b;
std::snprintf(b, sizeof b, "%zu textures: %.0f MB compressed, %.0f MB decoded; geometry %.0f MB\n", textures, textureMB, decodedMB, geometryMB);
o += b;
std::snprintf(b, sizeof b, "world: %zu spawns, %zu seats, %zu hotspots, %zu surfaces, %zu wall spots, %zu gravity zones", spawns, seats, hotspots,
colliders, wallSpots, gravity);
o += b;
o += walkArea >= 0 ? "; walkable " + num(walkArea, 0) + " m²\n" : "\n";
if (!biggestMeshes.empty()) o += "most triangles:\n";
for (const Mesh& m : biggestMeshes) { std::snprintf(b, sizeof b, " %9zu %s\n", m.tris, meshName(m.mesh).c_str()); o += b; }
if (!biggestTextures.empty()) o += "biggest textures:\n";
for (const Tex& t : biggestTextures) {
std::snprintf(b, sizeof b, " %6.1f MB %ux%u, %u mip%s, %zu mesh%s %s\n", t.mb, t.w, t.h, t.mips, t.mips == 1 ? "" : "s", t.users,
t.users == 1 ? "" : "es", meshName(t.mesh).c_str());
o += b;
}
if (problems.empty()) o += "no problems found\n";
for (const HomeProblem& p : problems) o += std::string(p.level >= 2 ? "PROBLEM " : p.level == 1 ? "warning " : "note ") + p.what + "\n";
return o;
}
std::string json() const {
std::string o = "{\"name\":" + q(name) + ",\"format\":" + q(format);
auto n = [&](const char* k, double v) { o += ",\""; o += k; o += "\":" + num(v, v == std::floor(v) ? 0 : 2); };
n("meshes", meshes); n("triangles", triangles); n("vertices", vertices); n("seeThrough", seeThrough); n("seeThroughTris", seeThroughTris);
n("cutout", cutout); n("skinned", skinned); n("animated", animated); n("lightmapped", lightmapped); n("programs", programs);
n("textures", textures); n("textureMB", std::round(textureMB)); n("decodedMB", std::round(decodedMB)); n("geometryMB", std::round(geometryMB));
n("spawns", spawns); n("seats", seats); n("colliders", colliders); n("wallSpots", wallSpots); n("walkArea", std::round(walkArea));
n("loadSec", std::round(loadSec * 10) / 10);
o += ",\"biggestMeshes\":[";
for (size_t i = 0; i < biggestMeshes.size(); ++i) o += (i ? ",{\"name\":" : "{\"name\":") + q(meshName(biggestMeshes[i].mesh)) + ",\"tris\":" + num(biggestMeshes[i].tris, 0) + "}";
o += "],\"problems\":[";
for (size_t i = 0; i < problems.size(); ++i)
o += (i ? ",{\"level\":" : "{\"level\":") + num(problems[i].level, 0) + ",\"what\":" + q(problems[i].what) + ",\"mesh\":" + q(meshName(problems[i].mesh)) + "}";
return o + "]}\n";
}
static std::string num(double v, int digits) { char b[64]; std::snprintf(b, sizeof b, "%.*f", digits, std::isfinite(v) ? v : 0.0); return b; }
static std::string q(const std::string& s) {
std::string o = "\"";
for (unsigned char c : s) {
if (c == '"' || c == '\\') { o += '\\'; o += static_cast<char>(c); }
else if (c >= 0x20) o += static_cast<char>(c);
}
return o + "\"";
}
};
// `walk`: the world as Frame Home walks it (walkWorld), for the walkable area and the spawns on it; null to leave
// those out.
inline HomeReport makeHomeReport(const HslScene& s, const HomeWorld* walk = nullptr) {
HomeReport r;
if (!s.apkPaths.empty()) {
std::string f = s.fenvPath.empty() ? s.apkPaths.front() : s.fenvPath;
const size_t sl = f.find_last_of("/\\");
r.name = sl == std::string::npos ? f : f.substr(sl + 1);
}
if (!s.sources.empty()) r.format = s.sources.front().format + (s.sources.front().role.empty() ? "" : " " + s.sources.front().role);
r.meshes = s.meshes.size();
r.programs = s.programs.size();
r.animated = s.animatedMeshes.size();
r.meshNames.reserve(s.meshes.size());
// Textures: each compressed source once (meshes share them), decoded RGBA per mesh (or its share source).
struct TexUse { unsigned w, h, mips; double mb; size_t users; int mesh; };
std::map<const void*, TexUse> tex;
auto useSrc = [&](const std::shared_ptr<const TexSource>& t, int mesh) {
if (!t) return;
auto [it, fresh] = tex.try_emplace(t.get(), TexUse{t->w, t->h, t->mips, t->astc.size() / 1048576.0, 0, mesh});
++it->second.users;
if (fresh) r.textureMB += it->second.mb;
};
auto useRgba = [&](const std::vector<u8>& px, unsigned w, unsigned h, int mesh) {
if (px.empty()) return;
auto [it, fresh] = tex.try_emplace(px.data(), TexUse{w, h, 1, px.size() / 1048576.0, 0, mesh});
++it->second.users;
if (fresh) r.decodedMB += it->second.mb;
};
size_t nanMeshes = 0, badIndexMeshes = 0, degenerateMeshes = 0;
for (size_t i = 0; i < s.meshes.size(); ++i) {
const MeshData& m = s.meshes[i];
const int mi = static_cast<int>(i);
r.meshNames.push_back(m.name.empty() ? "(unnamed " + std::to_string(i) + ")" : m.name);
const size_t tris = m.indices.size() / 3, verts = m.positions.size() / 3;
r.triangles += tris;
r.vertices += verts;
if (!tris) ++r.empty;
r.geometryMB += ((m.positions.size() + m.uvs.size() + m.uvs3.size() + m.uvs4.size()) * 4 + m.indices.size() * 4 + m.colors.size()) / 1048576.0;
if (m.useBlend || m.additive) { ++r.seeThrough; r.seeThroughTris += tris; }
r.cutout += m.alphaTest;
r.skinned += !m.boneIndices.empty();
r.lightmapped += m.hasLmHdr() || m.hasLightmap || m.lmLdrSrc != nullptr;
r.sky += m.isSkybox;
r.biggestMeshes.push_back({tris, mi});
for (const auto* t : {&m.texSrc, &m.normalSrc, &m.ormSrc, &m.emissiveSrc, &m.lmSrc, &m.lmLdrSrc, &m.cubeSrc}) useSrc(*t, mi);
for (const auto& nt : m.namedTex) useSrc(nt.second, mi);
for (const auto& nh : m.namedHdr) useSrc(nh.second.src, mi);
if (m.texShareSrc < 0) {
if (!m.texSrc) useRgba(m.texRGBA, m.texW, m.texH, mi);
useRgba(m.normalRGBA, m.normalW, m.normalH, mi);
useRgba(m.ormRGBA, m.ormW, m.ormH, mi);
useRgba(m.emissiveRGBA, m.emissiveW, m.emissiveH, mi);
useRgba(m.lmRGBA, m.lmW, m.lmH, mi);
}
// Broken geometry: positions that aren't numbers, indices past the vertices, triangles with no area.
bool nan = false;
for (float v : m.positions) if (!std::isfinite(v)) { nan = true; break; }
bool badIndex = false;
size_t degenerate = 0;
for (size_t t = 0; t + 2 < m.indices.size(); t += 3) {
const u32 a = m.indices[t], b = m.indices[t + 1], c = m.indices[t + 2];
if (a >= verts || b >= verts || c >= verts) { badIndex = true; break; }
if (a == b || b == c || a == c) { ++degenerate; continue; }
const float* pa = &m.positions[a * 3]; const float* pb = &m.positions[b * 3]; const float* pc = &m.positions[c * 3];
const float ux = pb[0] - pa[0], uy = pb[1] - pa[1], uz = pb[2] - pa[2], vx = pc[0] - pa[0], vy = pc[1] - pa[1], vz = pc[2] - pa[2];
const float cx = uy * vz - uz * vy, cy = uz * vx - ux * vz, cz = ux * vy - uy * vx;
if (cx * cx + cy * cy + cz * cz < 1e-18f) ++degenerate;
}
if (nan && nanMeshes++ < 5) r.problems.push_back({2, "Positions that aren't numbers in " + r.meshNames.back() + ": it may not draw, or draw as spikes", mi});
if (badIndex && badIndexMeshes++ < 5) r.problems.push_back({2, "Triangles pointing past the vertices in " + r.meshNames.back() + ": it can't be drawn safely", mi});
if (tris >= 1000 && degenerate * 5 > tris && degenerateMeshes++ < 3)
r.problems.push_back({0, r.meshNames.back() + ": " + std::to_string(degenerate * 100 / tris) + "% of its triangles have no area (wasted work)", mi});
if (tris > 500000)
r.problems.push_back({1, r.meshNames.back() + " alone has " + HomeReport::num(tris / 1e6, 2) + " M triangles", mi});
if ((m.useBlend || m.additive) && tris > 60000)
r.problems.push_back({1, "See-through " + r.meshNames.back() + " has " + std::to_string(tris / 1000) + " k triangles: blended surfaces are drawn back to front, every layer paid for", mi});
}
r.textures = tex.size();
// Textures the GPU can't sample well, one line per kind: the count, and the biggest of them.
size_t huge = 0, noMips = 0;
const TexUse* hugeTop = nullptr;
const TexUse* noMipsTop = nullptr;
std::map<const void*, bool> base; // compressed base textures (the ones that get their mips made)
for (const MeshData& m : s.meshes) if (m.texSrc) base[m.texSrc.get()] = true;
for (const auto& kv : tex) {
const TexUse& t = kv.second;
r.biggestTextures.push_back({t.w, t.h, t.mips, t.mb, t.users, t.mesh});
const unsigned side = std::max(t.w, t.h);
if (side > 4096) { ++huge; if (!hugeTop || t.mb > hugeTop->mb) hugeTop = &t; }
if (t.mips <= 1 && side > 256 && base.count(kv.first)) { ++noMips; if (!noMipsTop || t.mb > noMipsTop->mb) noMipsTop = &t; }
}
auto dims = [](const TexUse* t) { return std::to_string(t->w) + "x" + std::to_string(t->h); };
if (noMips)
r.problems.push_back({1, std::to_string(noMips) + (noMips == 1 ? " texture has" : " textures have") + " no smaller versions (the biggest " +
dims(noMipsTop) + ", " + r.meshName(noMipsTop->mesh) + "): Frame Home makes them at load, which loads slower",
noMipsTop->mesh});
if (huge)
r.problems.push_back({0, std::to_string(huge) + (huge == 1 ? " texture is" : " textures are") + " bigger than 4096 (" + dims(hugeTop) + ", " +
r.meshName(hugeTop->mesh) + "): GPUs that can't read them compressed get them at 2048", hugeTop->mesh});
std::sort(r.biggestMeshes.begin(), r.biggestMeshes.end(), [](const auto& a, const auto& b) { return a.tris > b.tris; });
if (r.biggestMeshes.size() > 8) r.biggestMeshes.resize(8);
std::sort(r.biggestTextures.begin(), r.biggestTextures.end(), [](const auto& a, const auto& b) { return a.mb > b.mb; });
if (r.biggestTextures.size() > 8) r.biggestTextures.resize(8);
// The whole home.
if (r.triangles > 1500000)
r.problems.push_back({1, HomeReport::num(r.triangles / 1e6, 1) + " M triangles in all: the Frame keeps its frame rate best under about 1 M in view"});
if (r.meshes > 2500) r.problems.push_back({1, std::to_string(r.meshes) + " separate meshes: each one in view is a draw, and draws cost CPU time every frame"});
if (r.seeThroughTris > 300000)
r.problems.push_back({1, HomeReport::num(r.seeThroughTris / 1e6, 2) + " M see-through triangles: overdraw is likely what's slow (try Debug view: Overdraw)"});
if (r.textureMB + r.decodedMB > 1500)
r.problems.push_back({1, HomeReport::num(r.textureMB + r.decodedMB, 0) + " MB of textures: loads slowly and leaves little memory for the apps in the Steam menu"});
if (r.empty) r.problems.push_back({0, std::to_string(r.empty) + " meshes with no triangles"});
// The world.
const HomeWorld& w = walk ? *walk : s.world;
r.spawns = w.spawns.size(); r.seats = w.seats.size(); r.hotspots = w.hotspots.size(); r.colliders = w.colliders.size();
r.wallSpots = w.wallSpots.size(); r.gravity = w.gravity.size();
const WalkGrid* g = walk ? walk->walkGridCache.get() : nullptr;
float floorY = 0;
if (w.spawns.empty() && w.gravity.empty() && (!g || !g->floorAt(0, 0, 0, 0.5f, 1.0f, floorY)))
r.problems.push_back({g ? 1 : 0, "No spawn point: you start at the home's centre, which isn't on walkable floor"});
if (g) {
r.walkArea = g->walkArea;
if (w.gravity.empty() && g->walkArea < 2) r.problems.push_back({2, "Almost nowhere to walk (" + HomeReport::num(g->walkArea, 1) + " m²): the floor wasn't found"});
for (size_t i = 0; i < w.spawns.size() && w.gravity.empty(); ++i) {
const WorldPoint& sp = w.spawns[i];
float h = 0;
if (!g->floorAt(sp.pos[0], sp.pos[2], sp.pos[1], 0.5f, 1.0f, h))
r.problems.push_back({1, "Spawn " + (sp.name.empty() ? std::to_string(i) : sp.name) + " isn't on walkable floor: you may start stuck or falling"});
}
}
std::stable_sort(r.problems.begin(), r.problems.end(), [](const HomeProblem& a, const HomeProblem& b) { return a.level > b.level; });
return r;
}
+4314
View File
File diff suppressed because it is too large. Load diff
+548
View File
@@ -0,0 +1,548 @@
#pragma once
// Stereo Vulkan renderer for Meta HSL homes that runs each home's OWN shaders (RENDSHAD forward pass).
// Every resource a shader declares is bound by its SPIR-V name, the way libshell binds them:
// set0 globalUniforms (both eyes' clipFromWorld / viewFromWorld / camera, time) + linearWrapSampler
// set1 lighting: lightUniforms, sbLightItems, lightGridCells, IBL cubes, shadow map + compare sampler
// set2 per material: matParams (cooked constants bound by MurmurHash3 name), lightprobesParams,
// lightBakerParams, instance, skinning, samplers, and textures routed by name
// Both eyes render in ONE multiview pass into a 2-layer target, then go to vrcompositor as layers 0/1.
#include <unordered_set>
#include "guide_geom.h"
#include "hsl_scene.h"
#include "shader_bake.h"
#include "spv_reflect.h"
#ifdef FH_USE_VOLK
#include <volk.h> // Windows: Vulkan entry points loaded at runtime (no SDK needed)
#else
#include <vulkan/vulkan.h>
#endif
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <deque>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
struct Mat4 { float m[16]; }; // column-major
// Largest texture side decoded for GPUs without native ASTC (Frame Studio's setting; 0 = no limit). Applies to
// textures decoded from then on.
void setTextureCap(uint32_t side);
struct EyeView {
Mat4 proj; // clip-from-view (Vulkan, reversed-Z)
Mat4 view; // view-from-world
float position[3]; // eye position in world
};
class HslRenderer {
public:
// Device setup on the GPU and with the extensions SteamVR requires.
bool init(uint32_t eyeWidth, uint32_t eyeHeight);
// Uploads a home (replacing any previous one).
bool load(const HslScene& scene);
// Incremental load: beginLoad, then loadStep(ms) once per frame until it returns true.
void beginLoad(const HslScene& scene);
bool loadStep(double budgetMs);
float loadProgress() const;
void unload();
// Renders both eyes; the result is left in `colorImage()` layers 0 (left) / 1 (right), TRANSFER_SRC layout.
void render(const EyeView eyes[2], float timeSec);
// Copies one eye layer of the last frame to RGBA8 (sRGB-encoded) — headless snapshots / tests.
bool readback(uint32_t layer, std::vector<u8>& rgba);
void shutdown();
// New eye size (window viewports); programs, pipelines and the loaded home are kept.
void resize(uint32_t eyeWidth, uint32_t eyeHeight);
// UNORM 2D view of one eye layer: the frame's sRGB bytes as-is, for display in a UI.
VkImageView layerView(uint32_t layer) const { return layerViews_[layer]; }
// Texture streaming: deferred (still-compressed) textures are uploaded as ASTC when the GPU samples it,
// otherwise decoded on background threads and swapped in a few per frame (render() does this).
size_t texturesPending();
// The previous frame is still on the GPU: a UI that skips this frame instead of calling render() never
// blocks (WebGPU never makes HSR Studio's page wait either).
bool gpuBusy() const;
// Load-time: decode and upload every texture of the home now (not as objects come into view), so moving and
// looking around never waits on a texture. Progress 0..1; render() uploads with a larger budget meanwhile.
void requestAllTextures();
float textureProgress();
size_t drawnCount() const { return drawn_; } // meshes that passed culling last frame
void finishStreaming(); // blocks until every queued texture is in (snapshots)
// Editing: the meshes that were uploaded (draw order), each tied to its HslScene::meshes index.
size_t meshCount() const { return meshes_.size(); }
int meshSceneIndex(size_t i) const { return meshes_[i].sceneIndex; }
const float* meshWorld(size_t i) const { return meshes_[i].world; }
void setMeshWorld(size_t i, const float world[16]);
bool meshVisible(size_t i) const { return meshes_[i].visible; }
int meshBlend(size_t i) const { return meshes_[i].blend; } // 0 opaque, 1 alpha, 2 additive (as drawn)
bool meshDynamic(size_t i) const { return meshes_[i].dynamic; }
// The home isn't on screen (faded out, nobody looking): textures stream in big steps.
void setUploadsUnseen(bool unseen) { uploadsUnseen_ = unseen; }
void setMeshVisible(size_t i, bool visible) { meshes_[i].visible = visible; }
// Scene meshes whose vertices change every frame (HslScene::animatedMeshes). Set before beginLoad().
void setDynamicMeshes(const std::vector<int>& sceneIndices);
// Highlight one mesh (its faces tinted, every triangle edge drawn) on the GPU; -1 clears. Built once per call,
// nothing per frame on the CPU.
void setSelection(int mesh);
// Guide geometry: world-space triangles drawn over the home, unlit and alpha blended; depth tested so walls hide
// them, with a faint copy where they do. False from guideAvailable(): the pipelines couldn't be built (draws nothing).
// Two kinds, both drawn every frame until replaced ({} clears):
// - setGuide: what changes from frame to frame (guide_geom.h: the teleport arc, landing and seat markers, the
// vignette). Copied into a host-visible buffer each frame, so keep it small.
// - setStaticGuide: what stays put for many frames (the walkable area, debug_geom.h's world layer). Uploaded once
// into video memory. Waits for the GPU when it replaces geometry (a hitch), so not for every frame.
void setGuide(const std::vector<GuideVertex>& tris) { guideVerts_ = tris; }
void setStaticGuide(const std::vector<GuideVertex>& tris);
// System mode: frames recorded while the GPU finishes the one before (see render()). FH_NO_EARLY_RECORD=1: never.
void setEarlyRecord(bool on) { earlyRecord_ = on; }
bool guideAvailable() const { return guide_.visible != VK_NULL_HANDLE; }
const std::string& meshProgram(size_t i) const { return programs_[meshes_[i].program].path; }
// Debug views (Frame Studio's View > Debug View, the Environments panel's Debug page): how a home is put together
// and what it costs, drawn from each mesh's positions alone (skinned meshes in their rest pose).
// Wireframe every triangle edge over the home (see-through meshes blue, cut-out green)
// Meshes each mesh in its own colour, shaded by its slope (no textures or lighting)
// Cost the home tinted by what each mesh costs the GPU: green from costScaleMs / 50 down, yellow, red from
// costScaleMs up. Timed per draw on desktop GPUs; on tile-based ones (the Frame) by drawing each mesh
// twice in turn and timing what that adds, so a full round takes a couple of seconds
// (measured per draw, which slows the GPU a little while it's on)
// Overdraw every mesh added up, nothing hidden: bright where many surfaces cover the same pixels
enum DebugView { kViewNormal = 0, kViewWireframe, kViewMeshes, kViewCost, kViewOverdraw, kDebugViews };
void setDebugView(int view);
int debugView() const { return debugView_; }
bool wireframeAvailable() const { return wireOk_; } // the GPU draws lines (fillModeNonSolid)
// Leaves parts of the home out, to find the part that's slow or wrong (bits, applies from the next frame).
enum DebugHide : unsigned { kHideSeeThrough = 1, kHideCutout = 2, kHideSky = 4, kHideAnimated = 8, kHideSolid = 16 };
unsigned debugHide = 0;
float costScaleMs = 0.25f;
// Cost view: a mesh's GPU time (ms, smoothed), by draw index; < 0 not measured.
float meshCostMs(size_t i) const { return i < cost_.size() ? cost_[i] : -1.0f; }
// The n costliest meshes as last measured: (ms, HslScene::meshes index), costliest first.
std::vector<std::pair<float, int>> costliest(size_t n) const;
VkInstance instance() const { return instance_; }
VkPhysicalDevice physicalDevice() const { return physical_; }
VkDevice device() const { return device_; }
VkQueue queue() const { return queue_; }
uint32_t queueFamily() const { return queueFamily_; }
VkImage colorImage() const { return resolve_.image; }
// Frame Home as the system environment (env_share.h): n images like the eye images that another process (the
// compositor) can import, as opaque fds it takes ownership of. Recreate after resize(). render() copies the eye
// images into exportSlot (when >= 0) at the end of the frame; waitFrame() blocks until that frame is done.
struct ExportImage { VkImage image = VK_NULL_HANDLE; VkDeviceMemory mem = VK_NULL_HANDLE; VkDeviceSize size = 0; int fd = -1; };
bool createExportImages(uint32_t n, std::vector<ExportImage>& out, uint32_t& memoryType, uint8_t deviceUUID[16]);
// Set before init(): frames are drawn straight into the export images (their MSAA resolve lands there) instead of
// being copied in at the end of the frame (1.1-1.5 ms a frame on the Frame). Foveation then redraws its density
// map where you look each frame rather than offsetting it (the images can't carry the offset flag across).
bool exportDirect = false;
// Set before init(), with exportDirect: with smooth foveation the export images are the foveated pass's own
// subsampled output, and the compositor layer reads them through a subsampled sampler, which smooths the coarse
// regions as it samples. That replaces our full-screen smoothing pass (~0.65 ms a frame at 2416x2416 per eye).
bool exportSubsampled = false;
bool exportsSubsampled() const { return exportDirect && exportSubsampled && smoothFov_; } // (known after init())
uint32_t exportUsage() const; // the export images' usage flags (the layer imports them alike)
uint32_t exportCreateFlags() const; // ...and their VkImageCreateFlags (likewise)
void destroyExportImages(std::vector<ExportImage>& images);
int exportSlot = -1;
void waitFrame();
// Frames counted on the GPU (a timeline semaphore, when the device has them): render() signals frameSerial() once
// that frame is done, and waitFrameSerial() may be called from another thread (system mode publishes from one).
bool hasFrameSerial() const { return timeline_ != VK_NULL_HANDLE; }
uint64_t frameSerial() const { return serial_; }
bool waitFrameSerial(uint64_t serial, uint64_t timeoutNs);
// The GPU was reset under this device (a hang anywhere on the GPU can take it): render() does nothing from then
// on, and only a new device (Frame Home restarts itself) draws again.
bool deviceLost() const { return lost_; }
uint32_t width() const { return width_; }
uint32_t height() const { return height_; }
float clearColor[3] = {0, 0, 0}; // linear; shows wherever the home draws nothing
std::string pipelineCachePath; // compiled pipelines saved here between runs (empty = no file)
uint32_t viewCount = 2; // set before init(): 2 = both eyes (VR), 1 = a single view (editor, half the work)
// Performance options, set before init() (Frame Home / Studio settings):
int msaa = 4; // samples: 1, 2, 4 or 8 (the largest the GPU has up to this)
float anisotropy = 4; // texture filtering, 1..16 (clamped to the GPU's limit)
// Foveated rendering: full detail where you look, coarser toward the edges (VK_EXT_fragment_density_map with
// VK_QCOM_fragment_density_map_offset to move it each frame). Used only when the GPU has both.
bool foveation = false;
// Strength 1 (gentle) .. 3 (strong); where each eye looks, in pixels of its image (from eye tracking, or the
// lens centre). Both take effect from the next frame.
void setFoveationLevel(int level);
// The same on a continuous scale (0 = full detail everywhere, 1-3 = the levels): the rings move smoothly with it
// (the thermal governor turns it a little at a time). Steps of 1/32.
void setFoveationStrength(float strength);
float foveationStrength() const { return fovStrength_; }
void setFoveationCenter(uint32_t eye, float px, float py) { if (eye < 2) { fovCenter_[eye][0] = px; fovCenter_[eye][1] = py; } }
bool foveationActive() const { return fdmOn_; }
// Provided by the caller (OpenVR knows which GPU and extensions the compositor needs).
std::vector<std::string> instanceExtensions, deviceExtensions;
VkPhysicalDevice (*pickPhysicalDevice)(VkInstance) = nullptr;
private:
// memOffset/memSize/pooled: a range of a shared MemBlock (see allocMemory) rather than its own allocation.
struct Buffer { VkBuffer buf = VK_NULL_HANDLE; VkDeviceMemory mem = VK_NULL_HANDLE; void* mapped = nullptr; VkDeviceSize size = 0;
VkDeviceSize memOffset = 0, memSize = 0; bool pooled = false; };
struct Image { VkImage image = VK_NULL_HANDLE; VkDeviceMemory mem = VK_NULL_HANDLE; VkImageView view = VK_NULL_HANDLE;
VkDeviceSize memOffset = 0, memSize = 0; bool pooled = false; };
// Suballocation, as Dawn (HSR Studio's WebGPU) does it: thousands of small vkAllocateMemory calls make the
// Windows video memory manager stall the queue for seconds. Buffers and images keep separate blocks.
struct MemBlock { VkDeviceMemory mem = VK_NULL_HANDLE; VkDeviceSize size = 0; uint32_t type = 0; bool linear = false;
void* mapped = nullptr; std::map<VkDeviceSize, VkDeviceSize> free; };
struct MemRange { VkDeviceMemory mem = VK_NULL_HANDLE; VkDeviceSize offset = 0, size = 0; void* mapped = nullptr; bool pooled = false; };
std::vector<MemBlock> blocks_;
std::mutex allocMutex_;
MemRange allocMemory(const VkMemoryRequirements& req, uint32_t type, bool linear, bool map);
void freeMemory(VkDeviceMemory mem, VkDeviceSize offset, VkDeviceSize size, bool pooled, bool mapped);
uint32_t hostMemoryType(uint32_t bits);
enum class Role { Pos, Normal, Uv0, Uv1, Color, BoneIdx, BoneWgt, Tangent, Zero, Uv2, Uv3 };
struct VertexAttr { uint32_t location; VkFormat format; uint32_t offset, size; Role role; bool integer; };
struct Program {
std::string path;
VkShaderModule vs = VK_NULL_HANDLE, fs = VK_NULL_HANDLE;
VkShaderModule fsAlpha = VK_NULL_HANDLE; // fs writing (a,a,a,a): its colour work left for the compiler to drop
std::vector<VertexAttr> attrs;
uint32_t stride = 0;
std::vector<spv::Resource> sets[3]; // merged vert+frag, sorted by binding
VkDescriptorSetLayout setLayouts[3] = {};
VkPipelineLayout layout = VK_NULL_HANDLE;
VkDescriptorSet set0 = VK_NULL_HANDLE, set1 = VK_NULL_HANDLE;
uint32_t pushSize = 0;
int pcWorld = -1, pcColor = -1, pcUvU = -1, pcUvV = -1; // push-constant member offsets
std::map<uint32_t, VkPipeline> pipelines; // key: blend mode * 2 + cull
bool multiview = false;
bool lightBaker = false; // has lightBakerParams (lightmap atlas uvScale/uvOffset)
bool clay = false; // a built-in Clay shader: fragment alpha = texture alpha x tint alpha
// Specialized fragment shaders (spv_specialize.h): the uniform values constant for a material baked in. One per
// distinct set of those values, shared by the meshes that have it; each with its own pipelines.
struct Variant { VkShaderModule fs = VK_NULL_HANDLE, fsAlpha = VK_NULL_HANDLE; std::map<uint32_t, VkPipeline> pipelines; };
const ShaderProgramSrc* src = nullptr; // the scene's (its baked variants)
bool boned = false; // its vertex stage reads bone indices
std::vector<std::pair<uint32_t, uint32_t>> foldBlocks; // (set, binding) of the blocks it reads constant members of
std::vector<Variant> variants;
std::map<uint64_t, int> variantFor; // hash of those blocks' bytes -> variant (-1: nothing folds)
};
struct GpuMesh {
int program = -1;
bool cutOnce = false; // a cut-out drawn in one pass (its shader has no alpha-only form)
int variant = -1; // its Program::variants entry (-1: the shader as shipped)
int sceneIndex = -1;
bool visible = true;
Buffer vbo, ibo;
uint32_t indexCount = 0;
std::vector<Image> images;
std::vector<Buffer> ubos;
VkDescriptorSet set2 = VK_NULL_HANDLE;
float world[16];
float color[4] = {1, 1, 1, 1};
int blend = 0; // 0 opaque, 1 alpha, 2 additive
bool cull = false, skybox = false;
float centroid[3] = {0, 0, 0};
float localCentroid[3] = {0, 0, 0};
float localMin[3] = {0, 0, 0}, localMax[3] = {0, 0, 0};
float worldMin[3] = {0, 0, 0}, worldMax[3] = {0, 0, 0};
bool inView = true; // passed the view-frustum test this frame
std::vector<std::pair<const TexSource*, bool>> wants; // streamed textures still to request
int skinUbo = -1; // index in ubos of the sbSkinningMatrices buffer
bool alphaToCoverage = false; // cut-out foliage (SHAD pass state)
bool dynamic = false; // animated on the CPU: host-visible vertices, rewritten per frame
uint32_t posGen = 0, uvGen = 0; // the MeshData posGen / uvGen its vertex buffer holds
uint32_t skinGen = 0; // the MeshData skinGen its skinning buffer holds
float baseWorld[16]; // its own world; world = baseWorld * anim
float anim[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1}; // MeshData::animXform last applied
float uvXform[6] = {1, 0, 0, 0, 1, 0}; // MeshData::uvXform (pushed as uvTransformU / V)
bool debug = false; // FH_DEBUG matched: cull state logged once
std::string debugName;
};
// Vulkan plumbing
uint32_t memoryType(uint32_t bits, VkMemoryPropertyFlags flags);
Buffer createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool hostVisible);
Buffer createDeviceBuffer(const void* data, VkDeviceSize size, VkBufferUsageFlags usage);
void destroy(Buffer& b);
void destroy(Image& i);
void retire(Buffer& b);
VkCommandBuffer recordInto_ = VK_NULL_HANDLE; // set while uploads ride in the frame's commands
std::vector<Buffer> frameStaging_; // their staging, freed after that frame
VkPipelineCache pipelineCache_ = VK_NULL_HANDLE;
static constexpr uint32_t kMaxQueries = 8192; // the Cost view times every draw (2 per eye each)
VkQueryPool queryPool_ = VK_NULL_HANDLE;
public:
float lastGpuMs() const { return gpuMs_; }
// FH_PERF=name,name...: the GPU's own counters (VK_KHR_performance_query; on Turnip with TU_DEBUG=perfc) whose
// names contain one of these, summed over the frames read back; FH_PERF=list logs every counter there is.
std::vector<std::pair<std::string, double>> perfCounterAverages() const;
uint64_t gpuResults() const { return gpuResults_; } // counts lastGpuMs updates (a frame without one keeps the old value)
// CPU side of the last render() (FH_PROFILE / --bench): where its time went, in ms.
struct FrameStats {
double waitMs = 0, readbackMs = 0, animMs = 0, globalsMs = 0, dynamicMs = 0, uploadMs = 0, recordMs = 0, submitMs = 0, totalMs = 0;
uint32_t uploads = 0, dynamicRebuilt = 0, dynamicMeshes = 0, draws = 0, pipelineBinds = 0;
uint64_t triangles = 0; // drawn this frame (per eye)
bool early = false; // recorded while the GPU still drew the frame before
};
const FrameStats& lastStats() const { return stats_; }
float lastGpuUploadMs() const { return gpuUploadMs_; } // parts of lastGpuMs: texture uploads, and the copy of the
float lastGpuCopyMs() const { return gpuCopyMs_; } // frame into the compositor's slot (system mode)
private:
FrameStats stats_;
// Load-time uploads (loadStep) ride in one command buffer per step instead of a queue round trip each.
VkCommandBuffer loadCmd_ = VK_NULL_HANDLE;
VkDeviceSize loadStaged_ = 0; // staging bytes recorded into loadCmd_
std::vector<Buffer> loadStaging_; // ...and their staging, freed when it's flushed
void beginUploadBatch();
void flushUploadBatch();
float timestampPeriod_ = 0;
bool fdmOn_ = false; // foveation running (render pass has a density map)
Image fdm_; // the density map: 2x the eye image, fovea in the middle
uint32_t fdmTexel_ = 32, fdmGranularity_ = 8, fdmGranularityY_ = 8, fdmW_ = 0, fdmH_ = 0;
VkFormat fdmFormat_ = VK_FORMAT_UNDEFINED;
int fovLevel_ = 2;
float fovStrength_ = 2.0f;
float fovCenter_[2][2] = {{-1, -1}, {-1, -1}};
void writeDensityMap();
float gpuUploadMs_ = 0, gpuCopyMs_ = 0;
float gpuMs_ = 0, gpuSum_ = 0, gpuMax_ = 0; // GPU time of the last frame, and a running average
int gpuFrames_ = 0;
uint64_t gpuResults_ = 0;
uint32_t profNext_ = 0;
std::vector<std::pair<size_t, uint32_t>> profDraws_; // mesh, first query
bool profFrame_ = false; // frame timestamps written (last 3 queries)
uint32_t lastUploads_ = 0, prevUploads_ = 0; // textures recorded this frame / in the frame being waited on
double lastUploadMB_ = 0, prevUploadMB_ = 0;
template <typename F> void oneShot(F&& record);
Image createImage2D(uint32_t w, uint32_t h, uint32_t layers, uint32_t mips, VkSampleCountFlagBits samples,
VkFormat fmt, VkImageUsageFlags usage, VkImageAspectFlags aspect, VkImageViewType viewType,
VkImageCreateFlags flags = 0);
Image createTexture(const u8* rgba, uint32_t w, uint32_t h, bool srgb);
Image createTextureFmt(const void* pixels, uint32_t w, uint32_t h, VkFormat fmt);
Image createSolidCube(const float top[3], const float side[3], const float bottom[3]);
Image createSolid3D(const u8 rgba[4]);
// `bytes`: what `faces` holds; a cube whose levels need more is refused (Image{}), never over-read.
Image createCubeTexture(const void* faces, size_t bytes, uint32_t size, VkFormat fmt = VK_FORMAT_R8G8B8A8_SRGB, uint32_t mips = 1,
uint32_t levels = 0);
Image createShadowDepth();
void createTargets();
void createSharedResources();
int programFor(const ShaderProgramSrc& src);
// Cut-out (alpha-to-coverage) meshes in two passes: kCutDepth writes their depth from fsAlpha (Meta's shader
// computing only its alpha), kCutShade shades them with depth writes off, the front-most sample alone passing.
enum CutPass { kCutNone = 0, kCutDepth = 1, kCutShade = 2 };
VkPipeline pipelineFor(Program& p, int blend, bool cull, bool sky = false, bool a2c = false, CutPass cut = kCutNone, int variant = -1);
// The variant of p for a mesh whose set-2 uniform buffers hold `set2` (binding -> bytes): made on first use.
int variantFor(Program& p, const std::map<uint32_t, std::vector<u8>>& set2);
shaderbake::SceneConstants sceneConsts_; // the scene-wide uniform bytes that never change (shader_bake.h)
size_t specMade_ = 0, specBaked_ = 0; // this load's specialized shaders: made at load / from the .fenv
void writeSharedSets(Program& p);
VkImageView textureFor(const spv::Resource& r, GpuMesh& gm, const MeshData& md);
bool uploadMesh(const MeshData& md, int sceneIndex);
void updateGlobals(const EyeView eyes[2], float timeSec);
VkInstance instance_ = VK_NULL_HANDLE;
VkQueryPool perfPool_ = VK_NULL_HANDLE;
std::vector<std::string> perfNames_;
std::vector<VkPerformanceCounterStorageKHR> perfStorage_;
std::vector<double> perfSum_;
uint32_t perfFrames_ = 0;
bool perfRecorded_ = false;
VkPhysicalDevice physical_ = VK_NULL_HANDLE;
VkDevice device_ = VK_NULL_HANDLE;
VkQueue queue_ = VK_NULL_HANDLE;
uint32_t queueFamily_ = 0;
VkCommandPool pool_ = VK_NULL_HANDLE;
VkCommandBuffer cmd_ = VK_NULL_HANDLE;
VkFence fence_ = VK_NULL_HANDLE;
VkSemaphore timeline_ = VK_NULL_HANDLE;
uint64_t serial_ = 0;
std::atomic<bool> lost_{false}; // (set by the publisher thread's waits too)
bool fenceUnsignalled_ = false; // the last submit failed: its fence never signals
std::vector<VkImage> exportImages_; // createExportImages: render() copies into exportImages_[exportSlot]
VkRenderPass exportPass_ = VK_NULL_HANDLE; // exportDirect: renderPass_ ending in GENERAL, for the export images
std::vector<VkImageView> exportViews_;
std::vector<VkFramebuffer> exportFbs_; // per export image
int lastExportSlot_ = -1; // the export image the last frame was drawn into (readback)
bool fdmOffset_ = false; // foveation moved by VK_QCOM_fragment_density_map_offset
bool fdmInit_ = false; // the density map has been written (its layout is FRAGMENT_DENSITY_MAP_OPTIMAL)
int64_t fdmKey_ = -1; // the density map's centres and level as last written (not rewritten unchanged)
void createExportFramebuffers();
void destroyExportFramebuffers();
void densityTexels(float cx, float cy, std::vector<float>& dens) const; // two per texel: across, down
void packDensity(const std::vector<float>& dens, uint8_t* dst) const; // in the map's format
std::vector<bool> exportInited_;
VkDescriptorPool descPool_ = VK_NULL_HANDLE;
// Selection overlay (selection_spirv.h): edges as a line list + faces, solid colour from a 1x1 texture.
struct Selection {
int mesh = -1;
Buffer vbo, edges, tris, ubo;
uint32_t edgeCount = 0, triCount = 0;
VkDescriptorSetLayout layout = VK_NULL_HANDLE;
VkPipelineLayout pipeLayout = VK_NULL_HANDLE;
VkPipeline lines = VK_NULL_HANDLE, faces = VK_NULL_HANDLE;
VkDescriptorSet set = VK_NULL_HANDLE;
Image color;
} sel_;
void createSelectionPipelines();
// Guide geometry: setGuide's goes into a host-visible vertex buffer per command buffer, rewritten each frame;
// setStaticGuide's into one device-local buffer. `hidden` draws where the home is in front, faintly.
struct Guide {
VkPipelineLayout layout = VK_NULL_HANDLE;
VkPipeline visible = VK_NULL_HANDLE, hidden = VK_NULL_HANDLE;
Buffer vbo[2]; // per command buffer: one is written while the other may be read
Buffer staticVbo; // setStaticGuide's, in video memory
uint32_t staticVertexCount = 0;
} guide_;
std::vector<GuideVertex> guideVerts_;
void createGuidePipelines();
void destroyGuide();
void drawGuide(const EyeView eyes[2]);
void destroySelection();
// Debug views (setDebugView): one pipeline per vertex layout and mode, and the draws' matrices and colours in
// one host-visible buffer (indexed by firstInstance), rewritten each frame after the fence.
enum DebugMode { kDbgSolid = 0, kDbgTint, kDbgWire, kDbgAdd };
struct Debug {
VkDescriptorSetLayout setLayout = VK_NULL_HANDLE;
VkPipelineLayout layout = VK_NULL_HANDLE;
VkDescriptorSet set = VK_NULL_HANDLE;
VkShaderModule vs = VK_NULL_HANDLE, fs = VK_NULL_HANDLE;
Buffer draws;
std::map<uint64_t, VkPipeline> pipes; // (stride, position offset, format, mode)
uint32_t used = 0, capacity = 0;
} dbg_;
int debugView_ = kViewNormal;
bool wireOk_ = false;
std::vector<float> cost_;
bool tiler_ = false; // tile-based GPU: the Cost view draws meshes twice to time them
int timedSlot_ = -1; // the export slot of the frame being timed
int doubled_ = -1; // drawn twice in the last frame (timed at the next render())
size_t costNext_ = 0;
uint64_t costFrame_ = 0;
static constexpr uint32_t kCostSamples = 6, kCostBase = 48;
std::vector<float> costMin_; // per mesh: its last kCostSamples doubled-frame times
std::vector<uint32_t> costSeen_;
float costBaseRing_[kCostBase] = {1e9f}; // the last baseline frames (nothing doubled)
uint32_t costBaseNext_ = 0;
std::vector<std::pair<double, size_t>> opaqueOrder_; // render(): this frame's opaque draw order (kept: no allocation)
bool createDebug();
void destroyDebug();
VkPipeline debugPipeline(const Program& p, int mode);
void drawDebug(const GpuMesh& gm, int mode, const float color[4], const EyeView eyes[2], bool nudge);
std::unordered_set<int> dynamicScene_;
// cpu: tints, matrices, bounds and which meshes rebuild (no GPU memory); upload: those rebuilds and the bones,
// into what the GPU reads (only once the frame before is done with them).
void refreshDynamicMeshes(bool cpu, bool upload);
struct DynJob { GpuMesh* gm; const MeshData* md; bool pos, uv; };
std::vector<DynJob> dynJobs_;
std::vector<GpuMesh*> skinCopies_; // GPU-skinned meshes whose bones changed
bool canRecordEarly();
int64_t fdmWantKey() const; // the foveation map this frame wants (fdmKey_ when it has it)
void fdmCentres(int32_t cx[2], int32_t cy[2]) const;
VkCommandBuffer cmds_[2] = {};
int cmdIndex_ = 0;
bool earlyRecord_ = false, fdmCopyRecorded_ = false;
void buildVertices(const Program& p, const MeshData& md, std::vector<u8>& vbo, std::vector<float>& normals) const;
VkSampleCountFlagBits samples_ = VK_SAMPLE_COUNT_1_BIT;
float maxAniso_ = 1;
uint32_t maxImageDim_ = 4096, maxCubeDim_ = 4096; // the GPU's limits: larger sizes from a file are refused
bool anisotropy_ = false;
uint32_t width_ = 0, height_ = 0;
VkRenderPass renderPass_ = VK_NULL_HANDLE;
VkFramebuffer framebuffer_ = VK_NULL_HANDLE;
Image colorMs_, depth_, resolve_;
// Smooth foveation: the foveated pass draws into sub_ (subsampled: the driver keeps its coarse regions coarse),
// and one full-screen pass reads it through the subsampled sampler (filtering between the coarse pixels) into
// resolve_ or the compositor's image. Without it the coarse regions were stretched into blocks.
bool smoothFov_ = false;
Image sub_;
VkRenderPass fovPass_ = VK_NULL_HANDLE;
VkFramebuffer fovFb_ = VK_NULL_HANDLE;
VkSampler subSampler_ = VK_NULL_HANDLE;
VkDescriptorSetLayout fovSetLayout_ = VK_NULL_HANDLE;
VkPipelineLayout fovLayout_ = VK_NULL_HANDLE;
VkPipeline fovPipe_ = VK_NULL_HANDLE;
VkDescriptorPool fovPool_ = VK_NULL_HANDLE;
VkDescriptorSet fovSet_ = VK_NULL_HANDLE;
VkRenderPass colorOnlyPass(VkImageLayout finalLayout) const; // one 1-sample colour attachment, both views
void createFovPass();
void recordFovPass(VkFramebuffer fb, VkRenderPass pass);
VkImageView layerViews_[2] = {};
VkSampler wrapSampler_ = VK_NULL_HANDLE, anisoSampler_ = VK_NULL_HANDLE, shadowSampler_ = VK_NULL_HANDLE;
Image nullTex_;
Image noMetal_; // white, alpha 0: BaseColorMetallic placeholder (never a chrome flash)
size_t texturesRequested_ = 0; // requestAllTextures: how many were queued (progress)
bool preloading_ = false; // the initial everything-at-once texture load is running
Image white_, black_, flatNormal_, whiteCube_, white3D_, iblDiffuse_, iblReflection_, shadowMap_;
Buffer globals_, lightUniforms_, lightItems_, lightGrid_;
uint32_t globalsSize_ = 1024;
std::vector<spv::Member> globalMembers_;
std::vector<Program> programs_;
std::map<std::string, int> programIndex_;
std::vector<GpuMesh> meshes_;
std::map<uint64_t, Image> textureCache_; // content hash -> shared texture (freed on unload)
const HslScene* scene_ = nullptr;
size_t loadNext_ = 0, loadSkipped_ = 0;
bool loadDone_ = true;
std::chrono::steady_clock::time_point loadStart_;
float ambient_[3] = {1, 1, 1};
// ── Texture streaming ──
using TexKey = std::pair<const TexSource*, bool>; // source, sRGB
struct TexSlot { size_t mesh; uint32_t binding, count; VkDescriptorType type; VkSampler sampler; };
struct TexJob { std::shared_ptr<const TexSource> src; bool srgb; uint64_t gen; };
// staging: its own buffer (load-time path) or, for streamed textures, the shared upload ring at stagingOffset;
// pixels: a streamed texture's decoded texels, copied into the ring when its upload is recorded.
struct PreparedTex { Image img; Buffer staging; VkDeviceSize stagingOffset = 0; std::vector<u8> pixels; uint32_t w = 0, h = 0, mips = 1; };
struct TexDone { TexKey key; uint64_t gen; PreparedTex prep; bool ok; };
PreparedTex prepareTexture(const void* pixels, uint32_t w, uint32_t h, VkFormat fmt, bool ownStaging = true);
// One persistent upload buffer, reused every frame (as WebGPU's writeTexture does): per-texture staging
// allocations made Windows page memory in and out and held the GPU queue for seconds.
static constexpr VkDeviceSize kRingSize = 256ull << 20;
Buffer ring_;
VkDeviceSize ringHead_ = 0;
void recordTextureUpload(VkCommandBuffer c, const PreparedTex& t);
VkImageView streamTexture(const std::shared_ptr<const TexSource>& src, bool srgb, VkImageView placeholder);
void pumpTextures(double budgetMs);
void startWorkers();
void stopWorkers();
VkFormat astcFormat(u32 bw, u32 bh, bool srgb) const;
Image createAstcTexture(const TexSource& src, VkFormat fmt);
bool astcLdr_ = false; // GPU samples ASTC (the Frame's Adreno)
bool astcHdr_ = false; // ...and HDR-profile ASTC (lightmaps, panoramas) too
// An HDR map (lightmap, panorama): uploaded once per source, as ASTC when the GPU can, else RGBA16F.
// exact: data the shader reads texel by texel (a vertex-animation table): never shrunk.
VkImageView hdrTexture(const std::vector<u16>* half, const std::shared_ptr<const TexSource>& src, u32 w, u32 h, bool exact = false);
std::map<TexKey, VkImageView> streamed_; // in GPU memory
std::map<TexKey, std::vector<TexSlot>> waiting_; // descriptors showing a placeholder until it lands
std::map<TexKey, std::shared_ptr<const TexSource>> pendingSrc_; // not decoded until something using it is in view
std::map<TexKey, bool> requested_;
size_t drawn_ = 0;
bool uploadsUnseen_ = false;
void updateBounds(GpuMesh& gm);
void placeMesh(GpuMesh& gm);
void measureMesh(GpuMesh& gm, const MeshData& md, uint32_t nv);
void updateAnimations(float timeSec);
std::map<int, size_t> sceneToMesh_; // HslScene::meshes index -> meshes_ index
void requestTextures(GpuMesh& gm);
bool slotPending_ = false; // textureFor queued one: uploadMesh records the slot
TexKey pendingKey_{};
std::vector<std::shared_ptr<const TexSource>> keepAlive_;
std::deque<TexJob> jobs_;
std::vector<TexDone> done_;
std::mutex jobMutex_;
std::condition_variable jobCv_;
std::vector<std::thread> workers_;
bool stopping_ = false;
uint64_t generation_ = 0; // bumped on unload: stale results are dropped
};
+870
View File
@@ -0,0 +1,870 @@
#include "hsl_scene.h"
#include "world_gen.h"
#include "surface_mesh.h"
#include "loaders/scene_loader.h"
#include "loaders/rendshad_parser.h"
#include "loaders/opa_loader.h"
#include "loaders/gltf_loader.h"
#include "clay_spirv.h"
#include "core/fsb5_vorbis.h"
#include "fenv.h"
#include "model_import.h"
#include "scene_bake.h"
#include "anim_bake.h"
#include <fstream>
#include <iterator>
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <array>
#include <unordered_map>
#include <memory>
#include <set>
#include <thread>
namespace {
// Reads a whole zip entry; empty on failure.
std::vector<u8> extract(mz_zip_archive& z, int index) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&z, static_cast<mz_uint>(index), &st)) return {};
std::vector<u8> out(static_cast<size_t>(st.m_uncomp_size)); // unpacked straight into place
if (!mz_zip_reader_extract_to_mem(&z, static_cast<mz_uint>(index), out.data(), out.size(), 0)) return {};
return out;
}
std::string surfaceName(const std::string& path) {
size_t s = path.find(".surface");
if (s == std::string::npos) return {};
size_t b = path.rfind('/', s);
return path.substr(b == std::string::npos ? 0 : b + 1, s + 8 - (b == std::string::npos ? 0 : b + 1));
}
// Scans assets/scene.zip: every RENDSHAD forward pass (shader programs) and the merged lightprobe
// ambient (lightprobes_merged.lprb, field2 = global SH; L00 / 3.5449 = ambient radiance).
void scanSceneZip(const std::string& apkPath, HslScene& scene) {
const std::shared_ptr<const std::vector<u8>> sceneZip = scenezip::get(apkPath); // the loader's copy
if (!sceneZip || sceneZip->empty()) return;
mz_zip_archive z = {};
if (!mz_zip_reader_init_mem(&z, sceneZip->data(), sceneZip->size(), 0)) return;
for (mz_uint i = 0; i < mz_zip_reader_get_num_files(&z); ++i) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
std::string name = st.m_filename;
std::string base = name.substr(name.find_last_of('/') + 1);
// Any "<name>.surface/<hash>/shader*" entry: older homes keep theirs under meta/clay_shaders/0.0.1/, not /shaders/.
if (name.find(".surface/") != std::string::npos && base.rfind("shader", 0) == 0) {
std::vector<u8> data = extract(z, static_cast<int>(i));
std::vector<SpirvBlob> blobs;
if (!parseRendShadForward(data, blobs)) { std::fprintf(stderr, "[scene] shader %s: no forward pass parsed\n", name.c_str()); continue; }
ShaderProgramSrc p;
p.path = name.rfind("content/", 0) == 0 ? name.substr(8) : name;
for (auto& b : blobs) (b.stageType == 0 ? p.vert : p.frag) = std::move(b.code);
if (!p.vert.empty() && !p.frag.empty()) scene.programs[p.path] = std::move(p);
else std::fprintf(stderr, "[scene] shader %s: %zu blob(s), vertex %s, fragment %s\n", name.c_str(), blobs.size(),
p.vert.empty() ? "missing" : "ok", p.frag.empty() ? "missing" : "ok");
} else if (name.find("lightprobes_merged.lprb") != std::string::npos) {
std::vector<u8> d = extract(z, static_cast<int>(i));
if (d.size() < 80 || std::memcmp(d.data() + 4, "LPRB", 4) != 0) continue;
auto rd32 = [&](size_t o) { u32 v = 0; if (o + 4 <= d.size()) std::memcpy(&v, d.data() + o, 4); return v; };
auto rd16 = [&](size_t o) { u16 v = 0; if (o + 2 <= d.size()) std::memcpy(&v, d.data() + o, 2); return v; };
u32 root = rd32(0);
u32 vt = root - rd32(root);
if (rd16(vt) < 10) continue;
u16 f2 = rd16(vt + 8);
if (!f2) continue;
u32 vec = root + f2 + rd32(root + f2);
if (vec + 16 > d.size() || rd32(vec) < 3) continue;
for (int c = 0; c < 3; ++c) {
float v; std::memcpy(&v, d.data() + vec + 4 + c * 4, 4);
scene.ambientRGB[c] = v / 3.5449f;
}
scene.hasEnvAmbient = true;
}
}
mz_zip_reader_end(&z);
}
// AndroidManifest.xml (binary XML): the <manifest> element's package and versionName attributes.
static void readManifest(const std::string& apkPath, std::string& package, std::string& version) {
mz_zip_archive apk = {};
if (!mz_zip_reader_init_file(&apk, apkPath.c_str(), 0)) return;
const int mi = mz_zip_reader_locate_file(&apk, "AndroidManifest.xml", nullptr, 0);
const std::vector<u8> x = mi >= 0 ? extract(apk, mi) : std::vector<u8>();
mz_zip_reader_end(&apk);
auto rd16 = [&](size_t o) -> u32 { return o + 2 <= x.size() ? x[o] | x[o + 1] << 8 : 0; };
auto rd32 = [&](size_t o) -> u32 { return o + 4 <= x.size() ? rd16(o) | rd16(o + 2) << 16 : 0; };
if (x.size() < 16 || rd16(0) != 0x0003 || rd16(8) != 0x0001) return;
// String pool: count, flags (0x100 = UTF-8), offsets.
const size_t pool = 8, count = rd32(pool + 8), poolEnd = pool + rd32(pool + 4);
const bool utf8 = rd32(pool + 16) & 0x100;
const size_t strings = pool + rd32(pool + 20);
auto str = [&](u32 i) -> std::string {
if (i >= count) return {};
size_t o = strings + rd32(pool + 28 + i * 4);
std::string out;
if (utf8) {
if (o < x.size() && (x[o] & 0x80)) ++o; // UTF-16 length (1-2 bytes), then the UTF-8 byte length
++o;
size_t n = o < x.size() ? x[o] : 0;
if (n & 0x80) { n = (n & 0x7F) << 8 | (o + 1 < x.size() ? x[o + 1] : 0); ++o; }
++o;
if (o + n <= x.size()) out.assign(reinterpret_cast<const char*>(&x[o]), n);
} else {
size_t n = rd16(o);
if (n & 0x8000) { n = (n & 0x7FFF) << 16 | rd16(o + 2); o += 2; }
o += 2;
for (size_t k = 0; k < n && o + 2 * k + 2 <= x.size(); ++k) { const u32 c = rd16(o + 2 * k); out += c < 0x80 ? static_cast<char>(c) : '?'; }
}
return out;
};
for (size_t c = poolEnd; c + 36 <= x.size();) {
const u32 type = rd16(c), size = rd32(c + 4);
if (size < 8) break;
if (type == 0x0102 && str(rd32(c + 20)) == "manifest") { // START_ELEMENT
const size_t attrs = c + 16 + rd16(c + 24), stride = rd16(c + 26), n = rd16(c + 28);
for (size_t a = 0; a < n; ++a) {
const size_t at = attrs + a * stride;
const std::string name = str(rd32(at + 4));
const u32 raw = rd32(at + 8);
const std::string value = raw != 0xFFFFFFFFu ? str(raw) : x.size() > at + 15 && x[at + 15] == 0x03 ? str(rd32(at + 16)) : std::string();
if (name == "package") package = value;
if (name == "versionName") version = value;
}
break;
}
c += size;
}
}
} // namespace
// The built-in programs Clay (.opa) homes are drawn with (tools/gen_clay_shader.py): libshell builds its Clay
// shaders natively, so - like Quest Home Editor - an unlit texture x vertex colour x tint program with real alpha,
// and its cut-out twin for AlphaTest materials.
constexpr const char* kClayProgram = "builtin/clay.surface/0/shader";
constexpr const char* kClayCutoutProgram = "builtin/clay_cutout.surface/0/shader";
constexpr const char* kClaySkinnedProgram = "builtin/clay_skinned.surface/0/shader"; // skinned on the GPU
constexpr const char* kClaySkinnedCutoutProgram = "builtin/clay_skinned_cutout.surface/0/shader";
// The home's looping ambience: an Ogg Vorbis entry of scene.zip (Clay homes ship _BACKGROUND_LOOP.ogg).
static std::vector<u8> findAmbientOgg(const std::string& apkPath, std::string& source, std::string& name) {
const std::shared_ptr<const std::vector<u8>> zipData = scenezip::get(apkPath); // the loader's copy
std::vector<u8> best;
mz_zip_archive z = {};
if (!zipData || zipData->empty() || !mz_zip_reader_init_mem(&z, zipData->data(), zipData->size(), 0)) return {};
bool bestIsLoop = false;
std::vector<int> banks; // HSL homes: the FMOD banks hold the same bed (see fsb5_vorbis.h)
for (mz_uint i = 0; i < mz_zip_reader_get_num_files(&z); ++i) {
mz_zip_archive_file_stat st;
if (!mz_zip_reader_file_stat(&z, i, &st)) continue;
std::string n = st.m_filename;
for (auto& c : n) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
if (n.find(".bank/") != std::string::npos && n.find(".strings.bank/") == std::string::npos)
banks.push_back(static_cast<int>(i)); // the home's own bank and shared ones (nativespacesmain)
if (n.size() < 4 || n.compare(n.size() - 4, 4, ".ogg") != 0) continue;
const bool loop = n.find("background") != std::string::npos || n.find("loop") != std::string::npos;
if (!best.empty() && (bestIsLoop || !loop)) continue;
best = extract(z, static_cast<int>(i));
bestIsLoop = loop;
name = st.m_filename; // its file, without the folders and ".ogg"
if (const size_t sl = name.find_last_of('/'); sl != std::string::npos) name = name.substr(sl + 1);
if (name.size() > 4) name.resize(name.size() - 4);
}
if (!best.empty()) source = "Ogg loop";
if (best.empty() && !banks.empty()) {
std::vector<std::vector<u8>> data;
std::vector<const std::vector<u8>*> ptrs;
for (int b : banks) data.push_back(extract(z, b));
for (const auto& d : data) ptrs.push_back(&d);
std::string why;
best = fsb5::ambienceOgg(ptrs, &why);
std::fprintf(stderr, "[scene] ambience from the FMOD bank: %s (%s)\n", best.empty() ? "unsupported" : "Ogg Vorbis rebuilt",
why.c_str());
if (!best.empty()) {
source = "FMOD bank";
const size_t q0 = why.find('"'), q1 = q0 == std::string::npos ? q0 : why.find('"', q0 + 1); // the sound's name in the bank
name = q1 != std::string::npos ? why.substr(q0 + 1, q1 - q0 - 1) : std::string();
}
}
mz_zip_reader_end(&z);
return best;
}
// Desert Terrace 210's palms (desertterrace_palms_v8.fbx, palmtreefoliage) ship wound inside-out and without
// normals. Their shader discards back faces (doubleSided 0), so the trunks drew their far, inner walls (black) and
// the fronds lost their lit side. Rendered with the order reversed, trunks and fronds match the textures. Only this
// shader reads gl_FrontFacing among the homes checked, and Meta's own meshes (authored normals) wind correctly.
static bool insideOutPalms(const MeshData& md) {
if (md.shaderPath.find("palmtreefoliage") == std::string::npos) return false;
for (float n : md.normals) if (n != 0) return false;
return true;
}
// Which meshes a loader's animate() changes: sample the loop at a few times and compare each mesh's
// positions / uvs / tint / rigid motion with the t=0 pose. Only those re-upload vertices every frame.
static std::vector<int> findAnimatedMeshes(const std::function<void(float, std::vector<MeshData>&)>& animate,
std::vector<MeshData>& meshes, float duration) {
auto sig = [](const MeshData& m) {
uint64_t h = 1469598103934665603ull;
auto mix = [&](const void* p, size_t n) {
const u8* b = static_cast<const u8*>(p);
for (size_t i = 0; i < n; ++i) h = (h ^ b[i]) * 1099511628211ull;
};
mix(m.positions.data(), m.positions.size() * 4);
mix(m.uvs.data(), m.uvs.size() * 4);
mix(m.curTint, sizeof m.curTint);
mix(m.animXform, sizeof m.animXform);
mix(m.uvXform, sizeof m.uvXform);
mix(m.skinJoints.data(), m.skinJoints.size() * 4);
return h;
};
animate(0.0f, meshes);
std::vector<uint64_t> base(meshes.size());
for (size_t i = 0; i < meshes.size(); ++i) base[i] = sig(meshes[i]);
std::vector<char> moved(meshes.size(), 0);
const float d = duration > 0 ? duration : 10.0f;
for (float t : {0.1f, 0.5f, d * 0.37f, d * 0.71f}) {
animate(t, meshes);
for (size_t i = 0; i < meshes.size(); ++i) if (sig(meshes[i]) != base[i]) moved[i] = 1;
}
animate(0.0f, meshes);
std::vector<int> out;
for (size_t i = 0; i < meshes.size(); ++i) if (moved[i]) out.push_back(static_cast<int>(i));
return out;
}
static void addClayPrograms(HslScene& scene) {
ShaderProgramSrc p;
p.path = kClayProgram;
p.vert.assign(std::begin(kClayVertSpirv), std::end(kClayVertSpirv));
p.frag.assign(std::begin(kClayFragSpirv), std::end(kClayFragSpirv));
scene.programs[p.path] = p;
p.path = kClayCutoutProgram;
p.frag.assign(std::begin(kClayFragCutoutSpirv), std::end(kClayFragCutoutSpirv));
scene.programs[p.path] = p;
p.path = kClaySkinnedCutoutProgram;
p.vert.assign(std::begin(kClaySkinnedVertSpirv), std::end(kClaySkinnedVertSpirv));
scene.programs[p.path] = p;
p.path = kClaySkinnedProgram;
p.frag.assign(std::begin(kClayFragSpirv), std::end(kClayFragSpirv));
scene.programs[p.path] = p;
}
// A .fenv: its baked scene when it has one (instant), else its homes and models through the normal loaders.
static bool loadFenv(const std::string& path, HslScene& scene, std::string& error) {
FenvManifest m;
std::vector<std::string> sources, models;
std::string edits;
if (!fenvExtract(path, fenvCacheDir(), m, sources, models, edits, error)) return false;
if (!m.scene.empty()) {
std::ifstream f(std::filesystem::u8path(m.scene), std::ios::binary | std::ios::ate);
std::vector<u8> blob(f ? static_cast<size_t>(f.tellg()) : 0); // in one read (an istreambuf copy was 750 ms for 316 MB)
f.seekg(0);
if (!blob.empty() && !f.read(reinterpret_cast<char*>(blob.data()), static_cast<std::streamsize>(blob.size()))) blob.clear();
if (!unbakeScene(blob.data(), blob.size(), scene, error)) { error += ": " + path; return false; }
scene.sourceFiles.resize(scene.apkPaths.size());
} else {
std::vector<std::string> all = sources;
all.insert(all.end(), models.begin(), models.end());
if (all.empty()) { error = "empty environment: " + path; return false; }
if (!loadHslScene(all, scene, error)) return false;
}
scene.fenvPath = path;
scene.fenvEdits = edits;
if (!m.world.empty()) { // the environment's own world layer (edited in Frame Studio) replaces the converted one
std::ifstream wf(std::filesystem::u8path(m.world));
const std::string wj((std::istreambuf_iterator<char>(wf)), std::istreambuf_iterator<char>());
HomeWorld w;
if (worldFromJson(wj, w)) scene.world = std::move(w);
}
std::fprintf(stderr, "[scene] environment %s (%s): %zu meshes\n", m.name.c_str(), m.scene.empty() ? "from its homes" : "baked", scene.meshes.size());
return true;
}
bool loadHslScene(const std::vector<std::string>& apkPaths, HslScene& scene, std::string& error) {
if (apkPaths.size() == 1 && isFenvPath(apkPaths[0])) {
if (!loadFenv(apkPaths[0], scene, error)) return false;
simplifyWorldMeshes(scene.world);
return true;
}
scene = HslScene{};
scene.apkPaths = apkPaths;
g_deferTextures = true;
const scenezip::Keep keepSceneZips(apkPaths); // each scene.zip unpacked once for the whole load
{
std::lock_guard<std::mutex> l(g_sceneFogMutex);
g_sceneFog = SceneFog{};
} // textures stay compressed; the renderer streams them in
const auto t0 = std::chrono::steady_clock::now();
// Footprint and vista parse in parallel (each decodes its own textures).
std::vector<std::unique_ptr<SceneLoader>> loaders(apkPaths.size());
std::vector<std::unique_ptr<OpaLoader>> clays(apkPaths.size()); // pre-HSL Clay homes (cascadia.apk...)
std::vector<std::unique_ptr<GltfLoader>> gltfs(apkPaths.size()); // the oldest custom homes (_world_model.gltf.ovrscene)
std::vector<char> ok(apkPaths.size(), 0);
std::vector<std::vector<MeshData>> models(apkPaths.size()); // imported Blender / Unity models
std::vector<std::string> modelErrors(apkPaths.size());
scene.sourceFiles.resize(apkPaths.size());
std::vector<std::thread> threads;
for (size_t i = 0; i < apkPaths.size(); ++i) {
loaders[i] = std::make_unique<SceneLoader>();
loaders[i]->verbose = std::getenv("FH_VERBOSE") != nullptr;
threads.emplace_back([&, i] {
if (isModelPath(apkPaths[i])) {
if (importModel(apkPaths[i], models[i], scene.sourceFiles[i], modelErrors[i])) ok[i] = 4;
return;
}
if (loaders[i]->load(apkPaths[i])) { ok[i] = 1; return; }
clays[i] = std::make_unique<OpaLoader>(); // not HSL: a Clay (.opa) home?
if (clays[i]->load(apkPaths[i])) { ok[i] = 2; return; }
clays[i].reset();
gltfs[i] = std::make_unique<GltfLoader>(); // not .opa: a Clay .ovrscene home (glTF in scene.zip: SideQuest custom homes)?
if (gltfs[i]->load(apkPaths[i])) ok[i] = 3;
else gltfs[i].reset();
});
}
for (auto& t : threads) t.join();
for (size_t i = 0; i < apkPaths.size(); ++i)
if (!ok[i]) {
error = !modelErrors[i].empty() ? modelErrors[i] : "not a loadable home (HSL v203+, Clay .opa, glTF .ovrscene) or model (glTF, GLB, FBX, OBJ): " + apkPaths[i];
return false;
}
for (size_t i = 0; i < apkPaths.size(); ++i) {
HslSourceInfo info;
readManifest(apkPaths[i], info.package, info.version);
info.format = ok[i] == 1 ? "HSL" : ok[i] == 2 ? "Clay (.opa)" : ok[i] == 3 ? "Clay (.ovrscene)" : "Model";
if (ok[i] == 4) {
const std::string file = apkPaths[i].substr(apkPaths[i].find_last_of("/\\") + 1);
info.package = file;
std::string ext = file.substr(file.find_last_of('.') + 1);
for (char& c : ext) c = static_cast<char>(std::toupper(static_cast<unsigned char>(c)));
info.format = "Model (" + ext + ")";
}
// Split homes (Haven-era) ship com.meta.shell.env.footprint.<room> + ...vista.<world>; "prod.vista" is
// Cascadia, a whole home. Without a package, the file name decides.
std::string n = info.package.empty() ? apkPaths[i].substr(apkPaths[i].find_last_of("/\\") + 1) : info.package;
for (char& c : n) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const bool named = !info.package.empty();
info.role = ok[i] == 4 ? "Model" : ok[i] != 1 ? "Home"
: n.find(named ? ".footprint." : "footprint") != std::string::npos ? "Footprint"
: n.find(named ? ".vista." : "vista") != std::string::npos ? "Vista" : "Home";
scene.sources.push_back(info);
}
const auto t1 = std::chrono::steady_clock::now();
// Baked lightmaps: each APK's meshes against its own overrides, then the companion's (the vista
// ships the footprint's lightmaps and vice versa).
for (size_t i = 0; i < loaders.size(); ++i) {
loaders[i]->applyLightmapOverrides(loaders[i]->meshes);
for (size_t j = 0; j < loaders.size(); ++j)
if (j != i) loaders[i]->applyLightmapOverrides(loaders[i]->meshes, loaders[j].get());
}
size_t lightmapped = 0;
for (size_t i = 0; i < loaders.size(); ++i) {
if (ok[i] == 4) { // imported model: the built-in unlit programs
addClayPrograms(scene);
for (MeshData& md : models[i]) {
md.shaderPath = md.alphaTest ? kClayCutoutProgram : kClayProgram;
scene.meshes.push_back(std::move(md));
scene.meshSource.push_back(static_cast<int>(i));
}
std::fprintf(stderr, "[scene] model %s: %zu parts\n", apkPaths[i].c_str(), models[i].size());
continue;
}
if (clays[i] || gltfs[i]) { // Clay / glTF home: built-in programs
addClayPrograms(scene);
// The home animates here (below: the first home, the rest only models): its skinned meshes skin on the GPU.
if (clays[i] && clays[i]->hasAnimation() && i == 0 && std::count(ok.begin(), ok.end(), 4) + 1 == static_cast<long>(apkPaths.size()))
clays[i]->prepareGpuSkin();
// Base textures: one shared, still-compressed source per owner. The loader lets meshes cut from the same
// part BORROW the owner's texture (texShareSrc) and empties their own copy — unresolved, those uploaded
// nothing and drew white. Every user of a texture now points at the same source: decoded once in the
// background, uploaded once.
std::vector<MeshData>& cm = clays[i] ? clays[i]->meshes : gltfs[i]->meshes;
std::vector<std::shared_ptr<TexSource>> srcOf(cm.size());
for (size_t j = 0; j < cm.size(); ++j) {
MeshData& o = cm[j];
if (o.srcAstc.empty() || !o.srcAstcBw || !o.srcAstcBh || o.texW <= 1) continue;
auto t = std::make_shared<TexSource>();
t->astc = std::move(o.srcAstc);
o.srcAstc.clear();
t->w = o.texW; t->h = o.texH; t->bw = o.srcAstcBw; t->bh = o.srcAstcBh;
t->mips = std::max<u32>(1, o.srcAstcMips);
srcOf[j] = t;
}
for (size_t j = 0; j < cm.size(); ++j) {
MeshData& md = cm[j];
const size_t owner = md.texShareSrc >= 0 && static_cast<size_t>(md.texShareSrc) < cm.size() ? md.texShareSrc : j;
if (srcOf[owner]) {
md.texSrc = srcOf[owner];
std::vector<u8>().swap(md.texRGBA);
} else if (owner != j && !md.texSrc && cm[owner].texSrc) {
md.texSrc = cm[owner].texSrc; // glTF: the owner's compressed source
} else if (owner != j && md.texRGBA.empty()) {
md.texRGBA = cm[owner].texRGBA; // no compressed source: copy the pixels
}
if (md.texSrc && !std::getenv("FH_CLAYDBG")) std::vector<u8>().swap(md.texRGBA); // the loader's small preview: drawn from texSrc
}
size_t n = 0;
for (auto& md : cm) {
if (!md.texSrc && md.texW * md.texH <= 1) // flat fallback colour: no real texture
std::fprintf(stderr, "[clay] untextured '%s' (%u verts) colour %u,%u,%u,%u lightmap %d\n", md.name.c_str(), md.nVerts,
md.texRGBA.size() >= 4 ? md.texRGBA[0] : 0, md.texRGBA.size() >= 4 ? md.texRGBA[1] : 0,
md.texRGBA.size() >= 4 ? md.texRGBA[2] : 0, md.texRGBA.size() >= 4 ? md.texRGBA[3] : 0, (int)md.hasLightmap);
if (!md.skinBindPos.empty()) md.shaderPath = md.alphaTest ? kClaySkinnedCutoutProgram : kClaySkinnedProgram;
else md.shaderPath = md.alphaTest ? kClayCutoutProgram : kClayProgram;
std::memcpy(md.curTint, md.tint, sizeof md.curTint);
if (md.hasLightmap) // ShellEnv: diffuse x lightmap x lightmappower
for (int c = 0; c < 3; ++c) md.curTint[c] *= md.lightmapPower[c];
else if (std::getenv("FH_CLAYDBG")) { // diagnostics: what lights a mesh without a lightmap?
double avg[3] = {0, 0, 0};
const size_t px = md.texRGBA.size() / 4;
for (size_t q = 0; q < px; q += std::max<size_t>(1, px / 4096))
for (int c = 0; c < 3; ++c) avg[c] += md.texRGBA[q * 4 + c];
const double cnt = static_cast<double>(px / std::max<size_t>(1, px / 4096));
std::fprintf(stderr, "[clay] no lightmap: '%s' tex %ux%u avg %.0f,%.0f,%.0f blend %d iblLit %d\n", md.name.c_str(), md.texW,
md.texH, avg[0] / std::max(1.0, cnt), avg[1] / std::max(1.0, cnt), avg[2] / std::max(1.0, cnt),
(int)md.useBlend, (int)md.iblLit);
if (avg[0] + avg[1] + avg[2] == 0) {
size_t nz = 0;
for (size_t q = 0; q < md.texRGBA.size(); ++q) nz += md.texRGBA[q] != 0;
std::vector<u8> re;
const bool ok = !md.srcAstc.empty() &&
astc::decodeASTC(md.srcAstc.data(), static_cast<u32>(md.srcAstc.size()), md.texW, md.texH,
md.srcAstcBw, md.srcAstcBh, re, false, 1);
size_t nz2 = 0;
for (u8 b : re) nz2 += b != 0;
std::fprintf(stderr, "[clay] texRGBA %zu bytes, %zu non-zero; src %zu bytes (%ux%u blocks) re-decode %s, %zu non-zero\n",
md.texRGBA.size(), nz, md.srcAstc.size(), md.srcAstcBw, md.srcAstcBh, ok ? "ok" : "failed", nz2);
}
}
scene.meshes.push_back(std::move(md));
scene.meshSource.push_back(static_cast<int>(i));
++n;
}
std::fprintf(stderr, "[scene] %s home %s: %zu meshes\n", clays[i] ? "Clay" : "glTF .ovrscene", apkPaths[i].c_str(), n);
// The loader keeps evaluating the animation (anim_bake.h: as a .fenv's does); it works on the scene's copy of its
// meshes (a single Clay / glTF home: the scene's meshes ARE that loader's meshes, same order).
// Models may follow the home: they sit after its meshes, which the animator indexes from 0.
if (i == 0 && std::count(ok.begin(), ok.end(), 4) + 1 == static_cast<long>(apkPaths.size())) {
if (clays[i] && clays[i]->hasAnimation()) {
std::shared_ptr<OpaLoader> L(std::move(clays[i]));
scene.animDuration = L->animDuration();
animbake::adopt(scene, L);
} else if (gltfs[i] && gltfs[i]->hasAnimation()) {
std::shared_ptr<GltfLoader> L(std::move(gltfs[i]));
scene.animDuration = L->animDuration;
animbake::adopt(scene, L);
}
if (scene.animate) {
scene.animatedMeshes = findAnimatedMeshes(scene.animate, scene.meshes, scene.animDuration);
std::fprintf(stderr, "[scene] animation: %.1f s loop, %zu animated meshes\n", scene.animDuration,
scene.animatedMeshes.size());
}
}
continue;
}
const int base = static_cast<int>(scene.meshes.size());
for (auto& g : loaders[i]->animGroups) {
if (!(g.skel.ok() && g.clip.ok() && !g.skinRecs.empty())) continue;
HslAnimGroup a;
a.skel = g.skel;
a.clip = g.clip;
for (const auto& r : g.skinRecs) a.meshes.push_back(base + static_cast<int>(r.meshIdx));
scene.anims.push_back(std::move(a));
}
{ // the world layer: the footprint's spawns first, then the vista's additions
HomeWorld& w = loaders[i]->world;
auto add = [](auto& to, auto& from) { to.insert(to.end(), std::make_move_iterator(from.begin()), std::make_move_iterator(from.end())); };
add(scene.world.spawns, w.spawns); add(scene.world.seats, w.seats); add(scene.world.hotspots, w.hotspots);
add(scene.world.colliders, w.colliders); add(scene.world.wallSpots, w.wallSpots);
if (!scene.world.empty()) scene.world.origin = "meta";
}
for (auto& md : loaders[i]->meshes) {
if (md.hasLightmap) ++lightmapped;
if (insideOutPalms(md)) for (size_t t = 0; t + 2 < md.indices.size(); t += 3) std::swap(md.indices[t + 1], md.indices[t + 2]);
scene.meshes.push_back(std::move(md));
scene.meshSource.push_back(static_cast<int>(i));
}
scanSceneZip(apkPaths[i], scene);
}
// The ambience is the vista's (the world outside you hear: Calming's, Central's), the footprint's only without one
// (Haven's footprint carries a stand-in bed of its own, Central's).
for (auto p = apkPaths.rbegin(); p != apkPaths.rend(); ++p)
if (scene.ambientOgg.empty()) scene.ambientOgg = findAmbientOgg(*p, scene.ambientSource, scene.ambientName);
// HSL rigs: the timeline spans the longest clip.
if (scene.animDuration <= 0)
for (const auto& a : scene.anims)
if (a.clip.nFrames > 1 && a.clip.fps > 0) scene.animDuration = std::max(scene.animDuration, a.clip.nFrames / a.clip.fps);
const auto t2 = std::chrono::steady_clock::now();
std::fprintf(stderr, "[scene] %zu APK(s), %zu meshes (%zu lightmapped), %zu shader programs, ambient %s (%.3f, %.3f, %.3f); "
"parse %.1fs, lightmaps+shaders %.1fs, %zu animated rigs\n",
apkPaths.size(), scene.meshes.size(), lightmapped, scene.programs.size(),
scene.hasEnvAmbient ? "from env" : "synthesized", scene.ambientRGB[0], scene.ambientRGB[1], scene.ambientRGB[2],
std::chrono::duration<double>(t1 - t0).count(), std::chrono::duration<double>(t2 - t1).count(), scene.anims.size());
if (scene.meshes.empty()) { error = "home has no meshes"; return false; }
simplifyWorldMeshes(scene.world);
return true;
}
std::vector<WorldCollider> geometryColliders(const HslScene& scene) {
std::vector<WorldCollider> out;
for (const MeshData& md : scene.meshes) {
if (md.isSkybox || md.useBlend || md.additive || md.overlayKind || md.indices.empty()) continue;
WorldCollider c;
c.name = md.name;
c.kind = WorldCollider::Mesh;
float m[16];
if (md.hasWorldMatrix) std::memcpy(m, md.worldMatrix, sizeof m); else trsToMat4(md.transform, m);
c.positions.resize(md.positions.size());
float lo[3] = {1e30f, 1e30f, 1e30f}, hi[3] = {-1e30f, -1e30f, -1e30f};
for (size_t v = 0; v + 2 < md.positions.size(); v += 3)
for (int k = 0; k < 3; ++k) {
const float x = m[k] * md.positions[v] + m[4 + k] * md.positions[v + 1] + m[8 + k] * md.positions[v + 2] + m[12 + k];
c.positions[v + k] = x;
lo[k] = std::min(lo[k], x);
hi[k] = std::max(hi[k], x);
}
if (hi[0] - lo[0] > 200 || hi[1] - lo[1] > 200 || hi[2] - lo[2] > 200) continue; // the vista's backdrop
c.indices = md.indices;
simplifySurfaceMesh(c.positions, c.indices, kSurfaceCell); // (world space)
out.push_back(std::move(c));
}
return out;
}
int walkMeshUse(const MeshData& md) {
if (md.isSkybox || md.additive || md.overlayKind || md.indices.empty() || md.positions.empty()) return 0;
if (!md.useBlend) return 1;
// See-through: glass blocks; fog, light shafts, glow cards, spray and the like are only drawn.
std::string n = md.name;
for (char& c : n) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
for (const char* fx : {"fog", "cloud", "mist", "haze", "smoke", "ray", "shaft", "beam", "glow", "light", "splash", "waterfall", "particle",
"dust", "flare", "halo", "volum", "spray", "steam", "fx", "card", "decal", "shadow", "water", "sky"})
if (n.find(fx) != std::string::npos) return 0;
return 2;
}
std::vector<WalkSource> drawnWalkSources(const HslScene& scene, const HomeWorld& world, const std::vector<const float*>* meshWorld,
const std::vector<char>* removed, bool leaveLinked) {
// meshes a collider stands for (Studio's surfaces: "<mesh index>:<name>") are left to it (the bake)
std::vector<char> linked(scene.meshes.size(), 0);
if (leaveLinked)
for (const WorldCollider& c : world.colliders) {
if (c.mesh.empty() || c.mesh.find("#floor") != std::string::npos) continue; // (a drawn floor: the mesh still counts)
const size_t colon = c.mesh.find(':');
char* endp = nullptr;
const long i = std::strtol(c.mesh.c_str(), &endp, 10);
if (colon != std::string::npos && endp == c.mesh.c_str() + colon && i >= 0 && static_cast<size_t>(i) < linked.size()) linked[i] = 1;
}
// skinned creatures (birds, butterflies) move about: not something to bump into. (Clay's animatedMeshes also
// lists floors whose tint or UVs animate, so it isn't used.)
for (const HslAnimGroup& a : scene.anims) for (int m : a.meshes) if (m >= 0 && static_cast<size_t>(m) < linked.size()) linked[m] = 1;
std::vector<WalkSource> src;
src.reserve(scene.meshes.size());
std::vector<std::string> names;
for (const MeshData& md : scene.meshes) names.push_back(md.name);
const std::string prefix = walkSharedPrefix(names);
for (size_t m = 0; m < scene.meshes.size(); ++m) {
const MeshData& md = scene.meshes[m];
const int use = walkMeshUse(md);
if (!use || linked[m] || (removed && m < removed->size() && (*removed)[m])) continue;
if (scene.apkPaths.size() > 1 && m < scene.meshSource.size() && scene.meshSource[m] != 0) continue; // a vista: scenery, never walked
WalkSource s;
s.pos = md.positions.data(); s.verts = md.positions.size() / 3; s.idx = md.indices.data(); s.indices = md.indices.size();
if (meshWorld && m < meshWorld->size() && (*meshWorld)[m]) std::memcpy(s.world, (*meshWorld)[m], sizeof s.world);
else if (md.hasWorldMatrix) std::memcpy(s.world, md.worldMatrix, sizeof s.world);
else trsToMat4(md.transform, s.world);
s.noFloor = use == 2;
if (md.alphaTest) { // cut-out: foliage (blocks; never floor) unless it's laid flat like ground (raked sand, a grass mat)
double up = 0, all = 0;
for (size_t t = 0; t + 2 < md.indices.size(); t += 3) {
float p[3][3];
bool ok = true;
for (int k = 0; k < 3 && ok; ++k) {
const uint32_t v = md.indices[t + k];
if (v * 3 + 2 >= md.positions.size()) { ok = false; break; }
for (int j = 0; j < 3; ++j) p[k][j] = s.world[j] * md.positions[v * 3] + s.world[4 + j] * md.positions[v * 3 + 1] + s.world[8 + j] * md.positions[v * 3 + 2] + s.world[12 + j];
}
if (!ok) continue;
const float e1[3] = {p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]}, e2[3] = {p[2][0] - p[0][0], p[2][1] - p[0][1], p[2][2] - p[0][2]};
const float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]};
const float L = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
all += L * 0.5f;
if (L > 0 && std::fabs(n[1]) / L > 0.9f) up += L * 0.5f;
}
s.cutout = !(up >= 4.0 && up >= all * 0.8);
}
s.maxSize = 200; // bigger: the vista's backdrop
s.tag = static_cast<int>(m);
s.hint = walkNameHint(md.name, prefix);
s.name = md.name;
src.push_back(s);
}
return src;
}
namespace {
// The home's invisible walls: tall solid boxes (Meta's, of any name) with nothing drawn on their sides. They keep a
// Quest user in the spot the home was made to be seen from; a box that is a drawn wall's collision is kept.
std::vector<char> invisibleWalls(const HomeWorld& world, const std::vector<WalkSource>& drawn) {
std::vector<char> out(world.colliders.size(), 0);
struct Cand { size_t i; float lo[3], hi[3]; };
std::vector<Cand> cands;
for (size_t i = 0; i < world.colliders.size(); ++i) {
const WorldCollider& c = world.colliders[i];
if (c.kind != WorldCollider::Box || c.role != WorldCollider::Solid || !c.mesh.empty()) continue;
const float up = std::sqrt(c.world[4] * c.world[4] + c.world[5] * c.world[5] + c.world[6] * c.world[6]);
if (2 * c.half[1] * up < 1.5f) continue; // a wall (a low ramp or kerb stays)
Cand k{i, {1e30f, 1e30f, 1e30f}, {-1e30f, -1e30f, -1e30f}};
for (int v = 0; v < 8; ++v) {
const float l[3] = {(v & 1 ? 1 : -1) * c.half[0], (v & 2 ? 1 : -1) * c.half[1], (v & 4 ? 1 : -1) * c.half[2]};
for (int j = 0; j < 3; ++j) {
const float w = c.world[j] * l[0] + c.world[4 + j] * l[1] + c.world[8 + j] * l[2] + c.world[12 + j];
k.lo[j] = std::min(k.lo[j], w - 0.3f); k.hi[j] = std::max(k.hi[j], w + 0.3f);
}
}
cands.push_back(k);
}
if (cands.empty()) return out;
// the drawn triangles near them, in a 1 m hash
std::vector<std::array<float, 9>> tris;
std::unordered_map<int64_t, std::vector<uint32_t>> cell;
auto key = [](int x, int y, int z) { return (static_cast<int64_t>(x & 0x1fffff) << 42) | (static_cast<int64_t>(y & 0x1fffff) << 21) | (z & 0x1fffff); };
for (const WalkSource& s : drawn) {
std::vector<float> w(s.verts * 3);
for (size_t v = 0; v < s.verts; ++v)
for (int j = 0; j < 3; ++j) w[v * 3 + j] = s.world[j] * s.pos[v * 3] + s.world[4 + j] * s.pos[v * 3 + 1] + s.world[8 + j] * s.pos[v * 3 + 2] + s.world[12 + j];
for (size_t t = 0; t + 2 < s.indices; t += 3) {
std::array<float, 9> tr;
float lo[3] = {1e30f, 1e30f, 1e30f}, hi[3] = {-1e30f, -1e30f, -1e30f};
bool bad = false;
for (int k = 0; k < 3; ++k) {
const uint32_t v = s.idx[t + k];
if (v >= s.verts) { bad = true; break; }
for (int j = 0; j < 3; ++j) { tr[k * 3 + j] = w[v * 3 + j]; lo[j] = std::min(lo[j], w[v * 3 + j]); hi[j] = std::max(hi[j], w[v * 3 + j]); }
}
if (bad) continue;
bool close = false;
for (const Cand& c : cands) close |= lo[0] <= c.hi[0] && hi[0] >= c.lo[0] && lo[1] <= c.hi[1] && hi[1] >= c.lo[1] && lo[2] <= c.hi[2] && hi[2] >= c.lo[2];
if (!close || hi[0] - lo[0] > 40 || hi[1] - lo[1] > 40 || hi[2] - lo[2] > 40) continue;
const uint32_t id = static_cast<uint32_t>(tris.size());
tris.push_back(tr);
for (int x = static_cast<int>(std::floor(lo[0])); x <= static_cast<int>(std::floor(hi[0])); ++x)
for (int y = static_cast<int>(std::floor(lo[1])); y <= static_cast<int>(std::floor(hi[1])); ++y)
for (int z = static_cast<int>(std::floor(lo[2])); z <= static_cast<int>(std::floor(hi[2])); ++z) cell[key(x, y, z)].push_back(id);
}
}
auto dist2 = [](const float* p, const std::array<float, 9>& t) { // point to triangle (Ericson), squared
const float* a = &t[0]; const float* b = &t[3]; const float* c = &t[6];
float ab[3], ac[3], ap[3];
for (int k = 0; k < 3; ++k) { ab[k] = b[k] - a[k]; ac[k] = c[k] - a[k]; ap[k] = p[k] - a[k]; }
auto dot = [](const float* u, const float* v) { return u[0] * v[0] + u[1] * v[1] + u[2] * v[2]; };
float q[3];
const float d1 = dot(ab, ap), d2 = dot(ac, ap);
float bp[3], cp[3];
for (int k = 0; k < 3; ++k) { bp[k] = p[k] - b[k]; cp[k] = p[k] - c[k]; }
const float d3 = dot(ab, bp), d4 = dot(ac, bp), d5 = dot(ab, cp), d6 = dot(ac, cp);
const float vc = d1 * d4 - d3 * d2, vb = d5 * d2 - d1 * d6, va = d3 * d6 - d5 * d4;
if (d1 <= 0 && d2 <= 0) std::copy_n(a, 3, q);
else if (d3 >= 0 && d4 <= d3) std::copy_n(b, 3, q);
else if (d6 >= 0 && d5 <= d6) std::copy_n(c, 3, q);
else if (vc <= 0 && d1 >= 0 && d3 <= 0) { const float v = d1 / (d1 - d3); for (int k = 0; k < 3; ++k) q[k] = a[k] + v * ab[k]; }
else if (vb <= 0 && d2 >= 0 && d6 <= 0) { const float w = d2 / (d2 - d6); for (int k = 0; k < 3; ++k) q[k] = a[k] + w * ac[k]; }
else if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { const float w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); for (int k = 0; k < 3; ++k) q[k] = b[k] + w * (c[k] - b[k]); }
else { const float den = 1 / (va + vb + vc), v = vb * den, w = vc * den; for (int k = 0; k < 3; ++k) q[k] = a[k] + ab[k] * v + ac[k] * w; }
return (p[0] - q[0]) * (p[0] - q[0]) + (p[1] - q[1]) * (p[1] - q[1]) + (p[2] - q[2]) * (p[2] - q[2]);
};
for (const Cand& k : cands) {
const WorldCollider& c = world.colliders[k.i];
int samples = 0, drawnHits = 0;
for (int face = 0; face < 4; ++face) { // its four sides (local +-X, +-Z)
const int ax = face < 2 ? 0 : 2, other = face < 2 ? 2 : 0;
const float sgn = face & 1 ? -1.0f : 1.0f;
const int na = std::clamp(static_cast<int>(2 * c.half[other] / 0.3f), 1, 12), nb = std::clamp(static_cast<int>(2 * c.half[1] / 0.3f), 1, 12);
for (int i = 0; i < na; ++i)
for (int j = 0; j < nb; ++j) {
float l[3];
l[ax] = sgn * c.half[ax];
l[other] = ((i + 0.5f) / na * 2 - 1) * c.half[other];
l[1] = ((j + 0.5f) / nb * 2 - 1) * c.half[1];
float p[3];
for (int q = 0; q < 3; ++q) p[q] = c.world[q] * l[0] + c.world[4 + q] * l[1] + c.world[8 + q] * l[2] + c.world[12 + q];
++samples;
auto it = cell.find(key(static_cast<int>(std::floor(p[0])), static_cast<int>(std::floor(p[1])), static_cast<int>(std::floor(p[2]))));
if (it == cell.end()) continue;
for (uint32_t t : it->second) if (dist2(p, tris[t]) < 0.15f * 0.15f) { ++drawnHits; break; }
}
}
out[k.i] = drawnHits < samples * 0.2f;
}
return out;
}
} // namespace
std::shared_ptr<const WalkGrid> bakeWalkGrid(const HslScene& scene, const HomeWorld& world, const WalkParams& params, WalkReport* report,
const std::vector<const float*>* meshWorld, const std::vector<char>* removed) {
std::vector<WalkSource> src = drawnWalkSources(scene, world, meshWorld, removed, true);
std::vector<std::vector<float>> sp;
std::vector<std::vector<uint32_t>> si;
const size_t meshSources = src.size();
// Meta's mesh colliders are mostly copies of drawn meshes (their own collision meshes don't read): one is enough.
auto keyOf = [](const float* m, const float* pos, size_t verts, size_t indices) {
float a[3], b[3];
for (int k = 0; k < 3; ++k) {
a[k] = m[k] * pos[0] + m[4 + k] * pos[1] + m[8 + k] * pos[2] + m[12 + k];
const float* q = pos + (verts - 1) * 3;
b[k] = m[k] * q[0] + m[4 + k] * q[1] + m[8 + k] * q[2] + m[12 + k];
}
char buf[160];
std::snprintf(buf, sizeof buf, "%zu/%zu/%.2f,%.2f,%.2f/%.2f,%.2f,%.2f", verts, indices, a[0], a[1], a[2], b[0], b[1], b[2]);
return std::string(buf);
};
std::set<std::string> seen;
for (size_t i = 0; i < meshSources; ++i) if (src[i].verts) seen.insert(keyOf(src[i].world, src[i].pos, src[i].verts, src[i].indices));
HomeWorld cw;
cw.colliders.reserve(world.colliders.size());
const std::vector<char> invisible = params.metaBounds < 0.5f ? invisibleWalls(world, drawnWalkSources(scene, world, meshWorld, removed, false))
: std::vector<char>(world.colliders.size(), 0);
for (size_t ci = 0; ci < world.colliders.size(); ++ci) {
const WorldCollider& c = world.colliders[ci];
if (invisible[ci]) { // walked past: left out (keeps report tags)
WorldCollider stub;
stub.role = WorldCollider::Trigger;
cw.colliders.push_back(std::move(stub));
continue;
}
if (c.kind == WorldCollider::Mesh && c.role == WorldCollider::Solid && c.positions.size() >= 3 &&
!seen.insert(keyOf(c.world, c.positions.data(), c.positions.size() / 3, c.indices.size())).second) {
WorldCollider stub; // (keeps the indices: report tags)
stub.role = WorldCollider::Trigger;
cw.colliders.push_back(std::move(stub));
continue;
}
cw.colliders.push_back(c);
}
walkColliderSources(cw, src, sp, si, static_cast<int>(scene.meshes.size()));
// (a vista's meshes aren't sources, so the colliders' extent counts like the meshes': a home whose floor and
// furniture are all Studio surfaces is all colliders)
return buildWalkGrid(src, walkSeeds(world), params, report);
}
// Baked walkable areas, kept between opens (a big home's bake is most of a second): keyed by this build, every file
// the home was read from (path, size, time) and its world layer, so any change bakes afresh. Under
// %LOCALAPPDATA%\FrameStudio\cache\walk or ~/.cache/frame-home/walk; the 300 most recent are kept.
static std::filesystem::path walkCacheDir() {
#ifdef _WIN32
if (const char* l = std::getenv("LOCALAPPDATA")) return std::filesystem::u8path(std::string(l) + "\\FrameStudio\\cache\\walk");
#endif
if (const char* x = std::getenv("XDG_CACHE_HOME")) return std::filesystem::u8path(std::string(x) + "/frame-home/walk");
const char* h = std::getenv("HOME");
return std::filesystem::u8path(std::string(h ? h : "/tmp") + "/.cache/frame-home/walk");
}
static std::filesystem::path walkCachePath(const HslScene& scene, const HomeWorld& w) {
uint64_t k = 1469598103934665603ull;
auto mix = [&](const std::string& t) { for (unsigned char c : t) k = (k ^ c) * 1099511628211ull; k = (k ^ 0xff) * 1099511628211ull; };
mix(__DATE__ " " __TIME__); // this build's bake
std::vector<std::string> files = scene.apkPaths;
for (const auto& v : scene.sourceFiles) files.insert(files.end(), v.begin(), v.end());
if (!scene.fenvPath.empty()) files.push_back(scene.fenvPath);
for (const std::string& f : files) {
std::error_code ec;
const std::filesystem::path p = std::filesystem::u8path(f);
mix(f);
mix(std::to_string(std::filesystem::file_size(p, ec)));
mix(std::to_string(static_cast<long long>(std::filesystem::last_write_time(p, ec).time_since_epoch().count())));
}
mix(worldToJson(w));
char name[32];
std::snprintf(name, sizeof name, "%016llx.fwg", static_cast<unsigned long long>(k));
return walkCacheDir() / name;
}
static void pruneWalkCache() {
std::error_code ec;
std::vector<std::pair<std::filesystem::file_time_type, std::filesystem::path>> f;
for (const auto& e : std::filesystem::directory_iterator(walkCacheDir(), ec))
if (e.path().extension() == ".fwg") f.push_back({e.last_write_time(ec), e.path()});
if (f.size() <= 300) return;
std::sort(f.begin(), f.end());
for (size_t i = 0; i + 300 < f.size(); ++i) std::filesystem::remove(f[i].second, ec);
}
static HomeWorld finishWalkWorld(const HslScene& scene, HomeWorld w);
HomeWorld walkWorld(const HslScene& scene) {
HomeWorld w = scene.world;
simplifyWorldMeshes(w); // (a world.json merged in after loading: Frame Home's from Studio; simplified ones don't change)
if (!w.walkGridCache && !w.walkGrid.empty()) {
w.walkGridCache = decodeWalkGrid(w.walkGrid);
if (!w.walkGridCache) std::fprintf(stderr, "[walk] the saved walkable area doesn't read: baking one\n");
}
static const bool noWalkCache = std::getenv("FH_NO_WALK_CACHE") != nullptr;
const std::filesystem::path cached = w.walkGridCache || noWalkCache ? std::filesystem::path() : walkCachePath(scene, w);
if (!w.walkGridCache && !cached.empty()) {
std::ifstream f(cached, std::ios::binary);
const std::string text((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (!text.empty() && (w.walkGridCache = decodeWalkGrid(text))) {
std::error_code ec;
std::filesystem::last_write_time(cached, std::filesystem::file_time_type::clock::now(), ec);
std::fprintf(stderr, "[walk] walkable area from the cache: %.1f m2\n", w.walkGridCache->walkArea);
return finishWalkWorld(scene, std::move(w));
}
}
if (!w.walkGridCache) {
WalkReport r;
w.walkGridCache = bakeWalkGrid(scene, w, WalkParams{}, &r);
std::fprintf(stderr, "[walk] walkable area baked: %.1f m2 in %d area(s) (%d left out, %.1f m2), %.1f m2 of seams bridged; %zu triangles, %dx%d cells, %zu spans, %.0f ms\n",
r.walkArea, r.areas, r.unreached, r.unreachedArea, r.bridgedArea, r.triangles, r.nx, r.nz, r.spans, r.ms);
if (w.walkGridCache && !cached.empty()) {
std::error_code ec;
std::filesystem::create_directories(cached.parent_path(), ec);
const std::filesystem::path tmp = cached.string() + ".part";
{ std::ofstream o(tmp, std::ios::binary); o << encodeWalkGrid(*w.walkGridCache); }
std::filesystem::rename(tmp, cached, ec);
if (ec) std::filesystem::remove(tmp, ec);
pruneWalkCache();
}
// (a home no one has set up in Frame Studio walks on its meshes as they are: flat enough is floor, steeper
// blocks — what Studio's Auto-generate makes their surfaces; nothing more is added)
} else {
std::fprintf(stderr, "[walk] walkable area saved with the home: %.1f m2\n", w.walkGridCache->walkArea);
}
return finishWalkWorld(scene, std::move(w));
}
// (the rest of walkWorld: what doesn't come from the walk bake)
static HomeWorld finishWalkWorld(const HslScene& scene, HomeWorld w) {
// Gravity homes walk on colliders (GravityWalker): the geometry's floors and walls stand in while nothing blocks.
if (!w.gravity.empty()) {
bool solid = false;
for (const WorldCollider& c : w.colliders) solid |= c.role == WorldCollider::Solid || c.role == WorldCollider::Wall || c.role == WorldCollider::Object;
if (!solid) {
std::vector<WorldCollider> geo = geometryColliders(scene);
w.colliders.insert(w.colliders.end(), std::make_move_iterator(geo.begin()), std::make_move_iterator(geo.end()));
}
}
return w;
}
const ShaderProgramSrc* findProgram(const HslScene& scene, const std::string& shaderPath) {
if (shaderPath.empty()) return nullptr;
auto it = scene.programs.find(shaderPath);
if (it != scene.programs.end()) return &it->second;
const std::string want = surfaceName(shaderPath);
if (want.empty()) return nullptr;
for (const auto& kv : scene.programs)
if (surfaceName(kv.first) == want) return &kv.second;
return nullptr;
}
+102
View File
@@ -0,0 +1,102 @@
#pragma once
// A Meta HSL home (v203–v210) loaded for rendering. On Quest a home is a FOOTPRINT (the room, e.g.
// Haven2025) plus a VISTA (the world outside, e.g. Calming) loaded together; the vista also ships the
// baked lightmaps for the footprint's meshes. Both load here as one scene: the editor's SceneLoader
// meshes from each APK, every shader program they ship (forward pass, SMOL-V decoded + names restored),
// the baked lightmaps resolved across the pair, and the real ambient light.
#include "core/types.h"
#include "core/world.h"
#include "walkgrid.h"
#include "loaders/rendskel_parser.h"
#include <functional>
#include <map>
#include <string>
#include <vector>
struct ShaderProgramSrc {
std::string path; // scene.zip asset path without "content/" (matches MeshData::shaderPath)
std::vector<u32> vert, frag;
// Made by the bake (shader_bake.h; a .fenv carries them), else by the renderer at load. prepared: the two below are
// made (fragAlpha empty: the shader has no alpha-only form).
bool prepared = false;
std::vector<u32> vertInvariant, fragAlpha;
// Per distinct set of material values: the fragment shader with them baked in, by shaderbake::variantKey (frag
// empty: nothing bakes in, the shader as shipped).
struct Variant { uint64_t key = 0; std::vector<u32> frag, fragAlpha; };
std::vector<Variant> variants;
};
// A skeleton + its clip driving skinned meshes (birds, butterflies, creatures). Bone indices of those meshes
// are already skeleton joint indices, so the sampled per-joint skin matrices go straight to the GPU.
struct HslAnimGroup {
RendSkel skel;
RendClip clip;
std::vector<int> meshes; // HslScene::meshes indices
};
// What one APK of the home is (the editor's info panel).
struct HslSourceInfo {
std::string package, version; // from AndroidManifest.xml: com.meta.shell.env.footprint.haven2025, 206.0.0.0.70
std::string format; // "HSL", "Clay (.opa)" or "Clay (.ovrscene)" (SideQuest-style custom homes: glTF in scene.zip)
std::string role; // "Footprint" (the room), "Vista" (the world outside) or "Home" (both in one)
};
struct HslScene {
std::vector<std::string> apkPaths; // footprint first, then the vista (if any)
std::vector<MeshData> meshes; // all APKs' meshes, in apkPaths order
std::vector<int> meshSource; // per mesh: index into apkPaths
std::vector<HslAnimGroup> anims;
std::map<std::string, ShaderProgramSrc> programs; // by asset path
float ambientRGB[3] = {0.95f, 0.90f, 0.83f}; // radiance; replaced by the env's .lprb L00 when present
bool hasEnvAmbient = false;
// Clay / glTF homes: evaluates the home's authored animation at t seconds (node motion, skinning, UV flipbooks
// and scrolls such as the fire, tint fades, vertex-animation textures) by rewriting the affected meshes'
// positions / uvs / curTint on the CPU. Empty for HSL homes: their rigs and shaders animate from the frame time.
std::function<void(float, std::vector<MeshData>&)> animate;
// The same animation as data (anim_bake.h), for a bake: what a .fenv plays it from. Set with animate; it reads the
// animator, so call it from the thread that animates the scene.
std::function<void(std::vector<u8>&)> saveAnimation;
std::vector<int> animatedMeshes; // the meshes animate() changes (their vertices re-upload every frame)
float animDuration = 0; // the authored loop length in seconds (0 = none authored)
std::vector<u8> ambientOgg; // the home's looping ambience (_BACKGROUND_LOOP.ogg), if it ships one
std::string ambientSource; // where it came from: "Ogg loop", "FMOD bank" or empty (none)
std::string ambientName; // its own name in the home: the Ogg file's, or the bank sound's ("amb_CalmingVista_...")
std::vector<HslSourceInfo> sources; // per apkPaths entry
std::vector<std::vector<std::string>> sourceFiles; // per apkPaths entry: the files an imported model read
std::string fenvPath, fenvEdits; // opened from a .fenv: the file, and its edits.json (extracted; "" if none)
HomeWorld world; // spawns, seats, colliders, walkable areas (Meta's, or a .fenv's world.json)
};
// Loads one or more APKs as a single home (footprint + vista). Returns false (with `error`) when an APK
// is not an HSL home or fails to parse.
bool loadHslScene(const std::vector<std::string>& apkPaths, HslScene& scene, std::string& error);
inline bool loadHslScene(const std::string& apkPath, HslScene& scene, std::string& error) {
return loadHslScene(std::vector<std::string>{apkPath}, scene, error);
}
// The world to walk in: the home's own (Meta's converted, or a .fenv's) when it has floors or walls; else floors and
// walls taken from the drawn geometry (opaque, not the sky, not distant scenery) — Clay homes and homes like Theater
// ship no collision at all. Spawns, seats and wall spots are kept either way.
// The world carries its walkable area (walkGridCache): the one Frame Studio baked (world.json), else one baked here from
// the drawn geometry plus the colliders (bakeWalkGrid). Gravity homes also get the geometry's floors and walls as
// colliders while nothing blocks (the gravity walker reads colliders).
HomeWorld walkWorld(const HslScene& scene);
std::vector<WorldCollider> geometryColliders(const HslScene& scene); // floors + walls from the drawn meshes
// Bakes the home's walkable area from everything drawn that's solid (opaque, not the sky or the distant backdrop;
// see-through solids block but aren't floor) and the world's colliders (roles override: a mesh a collider is
// made from — "mesh" names it — is left to that collider). meshWorld: per mesh, its world matrix when it was moved
// (Frame Studio; nullptr: as loaded); removed: per mesh, left out (deleted). Report tags: mesh index, or
// meshes.size() + collider index.
std::shared_ptr<const WalkGrid> bakeWalkGrid(const HslScene& scene, const HomeWorld& world, const WalkParams& params, WalkReport* report = nullptr,
const std::vector<const float*>* meshWorld = nullptr, const std::vector<char>* removed = nullptr);
// The home's drawn solid meshes as walk-bake sources, in world space (meshWorld / removed: Frame Studio's edits).
// leaveLinked: meshes a Studio surface stands for are left out (the bake; the world generators want them all).
std::vector<WalkSource> drawnWalkSources(const HslScene& scene, const HomeWorld& world, const std::vector<const float*>* meshWorld = nullptr,
const std::vector<char>* removed = nullptr, bool leaveLinked = true);
// Is this mesh in the walkable-area bake: 0 no, 1 solid, 2 blocks but isn't floor (see-through).
int walkMeshUse(const MeshData& md);
// Finds the program a mesh's material references (exact asset path, else same ".surface" name).
const ShaderProgramSrc* findProgram(const HslScene& scene, const std::string& shaderPath);
+850
View File
@@ -0,0 +1,850 @@
#pragma once
// Moving around a home in Frame Home, the way Meta's homes do it:
// you start at the home's spawn point, its floor on your real floor, facing where the home faces
// left stick walks (head-relative, moveSpeed m/s at full push, default 2; a light push strolls), easing up to speed
// and to a stop in a fraction of a second; walls stop you and slide you along them, the floor follows ramps
// exactly and eases you up and down steps (roomscale walking too). Walking off (setOptions walk 0): the stick
// does nothing.
// right stick left / right turns about your head: snap turns of snapAngle degrees (30 / 45 / 90) on a flick, or
// smooth turning (up to 120 degrees/s, by how far the stick is pushed)
// right stick forward aims a teleport arc from the right hand, drawn by Frame Home itself (guide(), guide_geom.h)
// with a marker where you'll land. The arc stops at the first thing it meets: a floor you can stand on (or a
// seat) is the landing; a wall, a ceiling or the side of furniture means nowhere — unless it's low, then you land
// at its foot. Let go to go there: instantly behind a quick fade to black (a blink), or as a 0.25 s
// eased dash along the ground (slide). Landing near a teleport hotspot snaps onto it. Teleport off: the stick
// forward does nothing. In gravity homes a slide is an instant blink (the walker re-orients you on landing).
// seats show a faint marker near you; pointing a hand at one lights it up (with a short pointer line), and pulling
// that hand's trigger sits you in it (a slide or a blink, as teleports go).
// landing on a seat sits you in it: eyes at a seated height over the seat, facing its way. Seated you can turn
// (snap turn, or turn your head) but stay put; walking, teleporting or stepping more than half a metre away
// stands you up — beside the seat (its exit point) when you walk off.
// with the Steam menu open the controllers belong to the menu: nothing moves you until it's closed.
// falling (off an edge, roomscale) is real; falling further than the world's fallLimit puts you back at the spawn.
// homes with gravity zones (walls you walk up, planets) are walked by GravityWalker (gravity.h) instead.
// The rig is worldFromTracking: SteamVR's standing space (y = 0 on your floor) placed into the home.
#include "gravity.h"
#include "guide_geom.h"
#include "world_runtime.h"
#include <openvr.h>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
class Locomotion {
public:
// Input actions (written next to Frame Home on first run) and the teleport ring overlay.
bool init(const std::filesystem::path& dir) {
namespace fs = std::filesystem;
std::error_code ec;
fs::create_directories(dir, ec);
const char* binding = R"({ "bindings": { "/actions/main": {
"sources": [
{ "path": "/user/hand/left/input/thumbstick", "mode": "joystick", "inputs": { "position": { "output": "/actions/main/in/left_stick" } } },
{ "path": "/user/hand/right/input/thumbstick", "mode": "joystick", "inputs": { "position": { "output": "/actions/main/in/right_stick" } } },
{ "path": "/user/hand/left/input/trigger", "mode": "button", "inputs": { "click": { "output": "/actions/main/in/left_trigger" } } },
{ "path": "/user/hand/right/input/trigger", "mode": "button", "inputs": { "click": { "output": "/actions/main/in/right_trigger" } } } ],
"poses": [
{ "output": "/actions/main/in/left_aim", "path": "/user/hand/left/pose/tip" },
{ "output": "/actions/main/in/right_aim", "path": "/user/hand/right/pose/tip" } ] } },
"controller_type": "%TYPE%", "description": "Frame Home: walk, turn, teleport", "name": "Frame Home" })";
const char* types[] = {"frame_controller", "knuckles", "oculus_touch", "vive_cosmos_controller"};
std::string defaults;
for (const char* t : types) {
std::string b = binding;
b.replace(b.find("%TYPE%"), 6, t);
std::ofstream(dir / (std::string("bindings_") + t + ".json")) << b;
defaults += std::string(defaults.empty() ? "" : ",\n ") + R"({ "controller_type": ")" + t + R"(", "binding_url": "bindings_)" + t + R"(.json" })";
}
// The headset's eye tracker (foveated rendering follows your gaze).
std::ofstream(dir / "bindings_frame_hmd.json") << R"({ "bindings": { "/actions/main": {
"eyetracking": [ { "path": "/user/head/eyetracking", "output": "/actions/main/in/gaze" } ] } },
"controller_type": "frame_hmd", "description": "Frame Home: eye tracking", "name": "Frame Home" })";
defaults += R"(,
{ "controller_type": "frame_hmd", "binding_url": "bindings_frame_hmd.json" })";
std::ofstream(dir / "actions.json") << R"({
"default_bindings": [
)" << defaults << R"( ],
"actions": [
{ "name": "/actions/main/in/left_stick", "type": "vector2" },
{ "name": "/actions/main/in/right_stick", "type": "vector2" },
{ "name": "/actions/main/in/left_aim", "type": "pose" },
{ "name": "/actions/main/in/right_aim", "type": "pose" },
{ "name": "/actions/main/in/left_trigger", "type": "boolean" },
{ "name": "/actions/main/in/right_trigger", "type": "boolean" },
{ "name": "/actions/main/in/gaze", "type": "eyetracking" } ],
"action_sets": [ { "name": "/actions/main", "usage": "leftright" } ],
"localization": [ { "language_tag": "en_US", "/actions/main": "Frame Home",
"/actions/main/in/left_stick": "Walk", "/actions/main/in/right_stick": "Turn and teleport",
"/actions/main/in/left_aim": "Left hand", "/actions/main/in/right_aim": "Teleport aim",
"/actions/main/in/left_trigger": "Sit: left hand", "/actions/main/in/right_trigger": "Sit: right hand", "/actions/main/in/gaze": "Where you look" } ]
})";
vr::IVRInput* in = vr::VRInput();
if (!in || in->SetActionManifestPath(fs::absolute(dir / "actions.json").c_str()) != vr::VRInputError_None) return false;
in->GetActionSetHandle("/actions/main", &set_);
in->GetActionHandle("/actions/main/in/left_stick", &leftStick_);
in->GetActionHandle("/actions/main/in/right_stick", &rightStick_);
in->GetActionHandle("/actions/main/in/right_aim", &rightAim_);
in->GetActionHandle("/actions/main/in/left_aim", &leftAim_);
in->GetActionHandle("/actions/main/in/right_trigger", &trigger_[0]);
in->GetActionHandle("/actions/main/in/left_trigger", &trigger_[1]);
in->GetActionHandle("/actions/main/in/gaze", &gaze_);
// The landing ring: a flat overlay placed in the room each frame while aiming — only when Frame Home can't draw
// the arc itself (setRingFallback).
if (vr::VROverlay() && vr::VROverlay()->CreateOverlay("framehome.teleport", "Teleport", &ring_) == vr::VROverlayError_None) {
const int n = 128;
std::vector<uint8_t> px(static_cast<size_t>(n) * n * 4);
for (int y = 0; y < n; ++y)
for (int x = 0; x < n; ++x) {
const float dx = (x + 0.5f) / n * 2 - 1, dy = (y + 0.5f) / n * 2 - 1, r = std::sqrt(dx * dx + dy * dy);
const float ring = std::clamp(1 - std::fabs(r - 0.82f) / 0.12f, 0.0f, 1.0f), dot = std::clamp((0.18f - r) / 0.05f, 0.0f, 1.0f);
const float a = std::max(ring, dot);
uint8_t* p = &px[(static_cast<size_t>(y) * n + x) * 4];
p[0] = 255; p[1] = 168; p[2] = 70; p[3] = static_cast<uint8_t>(a * 230);
}
vr::VROverlay()->SetOverlayRaw(ring_, px.data(), n, n, 4);
vr::VROverlay()->SetOverlayWidthInMeters(ring_, 0.55f);
}
return ok_ = true;
}
// A new home: its world, and the player at its spawn.
void setWorld(HomeWorld world) {
world_ = std::move(world);
runtime_.build(world_);
fallLimit_ = world_.fallLimit > 1 ? world_.fallLimit : 30.0f;
gravityMode_ = !world_.gravity.empty();
toSpawn();
if (gravityMode_) walker_.build(world_);
}
const HomeWorld& world() const { return world_; } // as walked (the debug overlay draws it)
const WalkGrid* walkGrid() const { return runtime_.grid(); }
// Back to the first spawn, standing, nothing under way (a new home, or a fall too far).
void toSpawn() {
float x = 0, z = 0, y = 0, yaw = 0;
if (!world_.spawns.empty()) { const WorldPoint& s = world_.spawns[0]; x = s.pos[0]; y = s.pos[1]; z = s.pos[2]; yaw = s.yaw; }
float f;
if (runtime_.floorAt(x, z, y + 0.5f, 0.6f, 3.0f, f)) y = f;
rigYaw_ = yaw; rigX_ = x; rigZ_ = z; floorY_ = targetFloor_ = y;
seated_ = -1;
aiming_ = valid_ = false; // a teleport aimed in the last home doesn't land here
seat_ = -1;
vel_[0] = vel_[1] = stepV_ = 0;
pointed_[0] = pointed_[1] = -1;
guide_ = LocoGuide();
if (ring_ && vr::VROverlay()) vr::VROverlay()->HideOverlay(ring_);
if (blinkT_ >= 0) blinkIn(); // a jump pending in the last home is dropped, eyes open
blinkT_ = slideT_ = -1;
fallV_ = fallen_ = 0;
}
// Movement options (Settings), applied live. walk: 0 off, 1 smooth. teleport: 0 off, 1 instant (blink), 2 slide.
// turn: 0 snap (by snapAngleDeg), 1 smooth. moveSpeed: m/s at full stick.
void setOptions(int walk, int teleport, int turn, int snapAngleDeg, float moveSpeed) {
walk_ = walk; teleport_ = teleport; turn_ = turn;
snap_ = static_cast<float>(std::clamp(snapAngleDeg, 5, 180)) * 0.01745329f;
speed_ = std::clamp(moveSpeed, 0.1f, 10.0f);
walker_.setSpeed(speed_);
if (teleport_ == 0 && aiming_) dropAim(); // an arc being aimed when teleport is switched off
}
bool gravityMode() const { return gravityMode_; }
int respawns() const { return respawns_ + (gravityMode_ ? walker_.respawns() : 0); }
int seated() const { return seated_; }
// Where you look, in standing space, for the frame being rendered (false: no eye tracking right now — none on
// this headset, eyes closed, or the Steam menu has the input).
bool gaze(float origin[3], float target[3]) const {
if (!ok_ || !gaze_) return false;
vr::VREyeTrackingData_t e = {};
if (vr::VRInput()->GetEyeTrackingDataForNextFrame(gaze_, vr::TrackingUniverseStanding, &e, sizeof e) != vr::VRInputError_None ||
!e.bActive || !e.bValid)
return false;
for (int k = 0; k < 3; ++k) { origin[k] = e.vGazeOrigin.v[k]; target[k] = e.vGazeTarget.v[k]; }
return true;
}
void setPoseValid(bool v) { poseValid_ = v; }
// Test hooks (tools/loco_test): what landing on a seat, and walking off it, do.
void testSit(int seat, const float headT[16]) { float R[16], h[3]; rig(R); apply(R, &headT[12], h); sit(seat, R, headT, h); }
void testWalkOff(const float headT[16]) { float R[16], h[3]; rig(R); apply(R, &headT[12], h); if (seated_ >= 0) standUp(h, true); }
// Sticks (and the right hand's aim pose, 3x4 row-major as OpenVR gives it) without SteamVR; from now on update()
// reads these instead of the controllers.
void testInput(float lx, float ly, float rx, float ry, const float* aim34 = nullptr) {
test_ = true;
testIn_[0] = lx; testIn_[1] = ly; testIn_[2] = rx; testIn_[3] = ry;
testAimOk_ = aim34 != nullptr;
if (aim34) std::copy(aim34, aim34 + 12, testAim_);
}
// The triggers (0 right, 1 left) and the left hand's aim pose, as testInput's.
void testButtons(bool rightTrigger, bool leftTrigger, const float* leftAim34 = nullptr) {
test_ = true;
testTrig_[0] = rightTrigger; testTrig_[1] = leftTrigger;
testLeftOk_ = leftAim34 != nullptr;
if (leftAim34) std::copy(leftAim34, leftAim34 + 12, testLeft_);
}
bool testTarget(float t[3]) const { std::copy(target_, target_ + 3, t); return aiming_ && valid_; }
int testTargetSeat() const { return aiming_ && valid_ ? seat_ : -1; }
int pointedSeat(int hand) const { return pointed_[hand & 1]; } // 0 right, 1 left; -1: none
// A straight ray (world) against the seats' pointable volumes, stopped by walls and floors in front: the seat
// hit, or -1, and how far along (metres). Hitting the chair itself (its back, from behind) counts as the seat.
int seatOnRay(const float o[3], const float dir[3], float maxLen, float& t) const {
const float d[3] = {dir[0] * maxLen, dir[1] * maxLen, dir[2] * maxLen};
float ts, tb = 2, n[3];
const WorldCollider* shape = nullptr;
int i = seatOnSegment(o, d, ts);
if (!runtime_.hasFloor() || runtime_.trace(o, d, tb, n, &shape) == WorldRuntime::None) tb = 2;
if (i >= 0 && ts <= tb) { t = ts * maxLen; return i; }
if (tb > 1) return -1;
const float q[3] = {o[0] + d[0] * tb, o[1] + d[1] * tb, o[2] + d[2] * tb};
if ((i = seatOf(q, shape)) >= 0) t = tb * maxLen;
return i;
}
// What to draw this frame (after update()): the arc and its landing, seat markers, the pointer.
const LocoGuide& guide() const { return guide_; }
// Frame Home draws the guide itself: the SteamVR ring overlay only stands in when it can't (fallback = true).
void setRingFallback(bool on) { ringOn_ = on; if (!on && ring_ && vr::VROverlay()) vr::VROverlay()->HideOverlay(ring_); }
bool sliding() const { return slideT_ >= 0; }
// worldFromTracking (column-major).
void rig(float m[16]) const {
if (gravityMode_) { // the walker's frame, with the tracking floor lifted by lift_ (an unset floor)
walker_.rig(m);
for (int k = 0; k < 3; ++k) m[12 + k] += m[4 + k] * lift_;
return;
}
const float c = std::cos(rigYaw_), s = std::sin(rigYaw_);
const float r[16] = {c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, rigX_, seated_ >= 0 ? seatRigY_ : floorY_ + lift_, rigZ_, 1};
std::copy(r, r + 16, m);
}
// Once per frame, with the head's tracking-space pose (column-major, as from OpenVR).
void update(float dt, const float headT[16]) {
dt = std::min(dt, 0.05f);
checkEyeHeight(dt, headT[13]);
guide_.seats.clear();
guide_.pointer = false;
if (gravityMode_ && !ok_ && !test_) { // no controllers: the walker still holds you to the ground (and lets you fall)
float h[16];
std::copy(headT, headT + 16, h);
h[13] += lift_;
walker_.update(dt, h, 0, 0, 0);
return;
}
float R[16];
rig(R);
float head[3];
apply(R, &headT[12], head);
if (ok_ || test_) {
vr::InputAnalogActionData_t l = {}, r = {};
bool trig[2] = {false, false};
if (ok_) {
vr::VRActiveActionSet_t active = {};
active.ulActionSet = set_;
vr::VRInput()->UpdateActionState(&active, sizeof active, 1);
vr::VRInput()->GetAnalogActionData(leftStick_, &l, sizeof l, vr::k_ulInvalidInputValueHandle);
vr::VRInput()->GetAnalogActionData(rightStick_, &r, sizeof r, vr::k_ulInvalidInputValueHandle);
for (int h = 0; h < 2; ++h) {
vr::InputDigitalActionData_t b = {};
if (trigger_[h] && vr::VRInput()->GetDigitalActionData(trigger_[h], &b, sizeof b, vr::k_ulInvalidInputValueHandle) == vr::VRInputError_None)
trig[h] = b.bActive && b.bState;
}
if (l.bActive != ownInput_) { ownInput_ = l.bActive; std::fprintf(stderr, "frame-home: controller input %s\n", ownInput_ ? "ours" : "taken (Steam menu open?)"); }
} else {
l.bActive = r.bActive = true;
l.x = testIn_[0]; l.y = testIn_[1]; r.x = testIn_[2]; r.y = testIn_[3];
trig[0] = testTrig_[0]; trig[1] = testTrig_[1];
}
// The Steam menu open: the controllers are the menu's (pointing at and clicking the dock), not the home's.
// An aim in progress is dropped, not jumped to (the stick reads as let go).
menuAim_ = false;
bool menu = false;
if (ok_ && vr::VROverlay() && vr::VROverlay()->IsDashboardVisible()) {
l.bActive = r.bActive = false;
menu = true;
if (aiming_) dropAim();
}
// Walk: the stick in the direction the head faces (flattened). Seated, it stands you up first. Walking
// off, the stick is dead (a teleport-only user with a worn stick doesn't drift); mid-dash or blink too.
const bool walk = l.bActive && walk_ != 0 && slideT_ < 0 && blinkT_ < 0;
float lx = 0, ly = 0;
if (walk) grav::shapeStick(l.x, l.y, lx, ly);
if (gravityMode_) { updateGravity(dt, headT, lx, ly, r); return; }
// Walking and teleport both off: the stick still stands you up from a seat (else only stepping away would)
if (seated_ >= 0 && walk_ == 0 && teleport_ == 0 && l.bActive) {
float sx = 0, sy = 0;
grav::shapeStick(l.x, l.y, sx, sy);
if ((sx != 0 || sy != 0) && slideT_ < 0 && blinkT_ < 0) { leaveSeat(headT, head, R); rig(R); apply(R, &headT[12], head); }
}
if ((lx != 0 || ly != 0) && seated_ >= 0) { leaveSeat(headT, head, R); rig(R); apply(R, &headT[12], head); }
// The velocity eases toward the stick's (grav::approach); a jump or a seat stops it dead.
grav::V want;
if (lx != 0 || ly != 0) {
float fwd[3];
const float hz[3] = {-headT[8], -headT[9], -headT[10]}; // the head's -Z in tracking space
dir(R, hz, fwd);
const float fl = std::sqrt(fwd[0] * fwd[0] + fwd[2] * fwd[2]);
if (fl > 1e-4f) {
const float fx = fwd[0] / fl, fz = fwd[2] / fl;
want = grav::V((fx * ly - fz * lx) * speed_, 0, (fz * ly + fx * lx) * speed_);
}
}
grav::V v(vel_[0], 0, vel_[1]);
if (slideT_ >= 0 || blinkT_ >= 0 || seated_ >= 0 || walk_ == 0) v = grav::V();
else grav::approach(v, want, speed_, dt);
vel_[0] = v.x; vel_[1] = v.z;
if (v.x != 0 || v.z != 0) {
float x = head[0], z = head[2];
const float dx = v.x * dt, dz = v.z * dt;
if (runtime_.hasFloor()) runtime_.move(x, z, floorY_, dx, dz); else { x += dx; z += dz; }
float f;
if (!runtime_.hasFloor() || runtime_.floorAt(x, z, floorY_, 0.45f, 1.2f, f)) { // never walk off an edge
rigX_ += x - head[0]; rigZ_ += z - head[2];
vel_[0] = (x - head[0]) / dt; vel_[1] = (z - head[2]) / dt; // what a wall left of it: no pent-up push
head[0] = x; head[2] = z;
} else {
vel_[0] = vel_[1] = 0;
}
}
// Turn, about the head (so you spin in place, not round the room's origin); momentum turns with you.
const float rx = r.bActive ? r.x : 0, ry = r.bActive ? r.y : 0;
if (const float a = turnBy(dt, rx, ry)) {
const float c = std::cos(a), s = std::sin(a);
const float ox = rigX_ - head[0], oz = rigZ_ - head[2];
rigX_ = head[0] + c * ox + s * oz;
rigZ_ = head[2] - s * ox + c * oz;
rigYaw_ += a;
const float vx = vel_[0], vz = vel_[1];
vel_[0] = c * vx + s * vz; vel_[1] = -s * vx + c * vz;
rig(R);
}
// Teleport: aim while the stick is forward, go on release (one jump at a time).
if (teleport_ == 0) { if (aiming_) dropAim(); }
else if (ry > 0.7f && std::fabs(rx) < 0.6f) aim(R, head, dt);
else if (aiming_ && ry < 0.4f) {
aiming_ = false;
guide_.aiming = false;
if (ring_) vr::VROverlay()->HideOverlay(ring_);
if (valid_ && slideT_ < 0 && blinkT_ < 0) {
std::copy(target_, target_ + 3, jump_); // aiming again mid-blink mustn't move it
jumpSeat_ = seat_;
startJump(headT, head, R, teleport_ == 2);
}
}
// Seats: pointing a hand at one lights it; that hand's trigger (pressed now) sits you in it.
for (int h = 0; h < 2; ++h) {
const bool press = trig[h] && !trigHeld_[h] && !menu;
trigHeld_[h] = trig[h];
pointed_[h] = -1;
if (menu || (h == 0 && aiming_) || world_.seats.empty()) continue; // the right hand is aiming the arc
float m[12];
if (!(h == 0 ? handPose(m) : leftPose(m))) continue;
float o[3], d[3];
const float po[3] = {m[3], m[7], m[11]}, pd[3] = {-m[2], -m[6], -m[10]};
apply(R, po, o);
dir(R, pd, d);
float t;
const int i = seatOnRay(o, d, kPointReach, t);
if (i < 0 || i == seated_) continue;
pointed_[h] = i;
if (!guide_.pointer) {
guide_.pointer = true;
// to the seat itself (the ray may have stopped on the chair's collider, short of it)
const float* sp = world_.seats[i].pos;
for (int k = 0; k < 3; ++k) { guide_.pointerFrom[k] = o[k]; guide_.pointerTo[k] = sp[k] + (k == 1 ? 0.08f : 0.0f); }
}
if (press && slideT_ < 0 && blinkT_ < 0) {
seatTarget(i, jump_);
jumpSeat_ = i;
if (aiming_) dropAim();
std::fprintf(stderr, "frame-home: trigger on %s\n", world_.seats[i].name.c_str());
startJump(headT, head, R, teleport_ == 2);
}
}
}
// Seat markers: the seats right by you (fading in from 1.2 m to 2 m away), and one you point or aim at from
// anywhere; never the one you're in.
for (size_t i = 0; i < world_.seats.size(); ++i) {
const WorldPoint& st = world_.seats[i];
if (static_cast<int>(i) == seated_) continue;
const float dx = st.pos[0] - head[0], dz = st.pos[2] - head[2], dist = std::hypot(dx, dz);
const bool hot = pointed_[0] == static_cast<int>(i) || pointed_[1] == static_cast<int>(i) || (aiming_ && valid_ && seat_ == static_cast<int>(i));
const float near = std::clamp((kMarkerReach - dist) / (kMarkerReach - 1.2f), 0.0f, 1.0f);
if (!hot && near <= 0) continue;
guide_.seats.push_back({{st.pos[0], st.pos[1], st.pos[2]}, st.yaw, hot, near});
}
// A blink: the jump happens once the view is black, then it fades back in.
if (blinkT_ >= 0 && (blinkT_ -= dt) <= 0) { blinkT_ = -1; land(headT, head); blinkIn(); }
// A dash in progress: eased (slow, fast, slow) so it starts and stops without a jolt; the floor under the
// way isn't followed (the dash may cross a gap or a step the arc flew over).
if (slideT_ >= 0) {
const float t0 = slideT_, t1 = std::min(1.0f, slideT_ + dt / kSlide), de = ease(t1) - ease(t0);
rigX_ += slideDX_ * de; rigZ_ += slideDZ_ * de;
head[0] += slideDX_ * de; head[2] += slideDZ_ * de;
floorY_ = targetFloor_ = slideY0_ + (jump_[1] - slideY0_) * ease(t1);
fallV_ = fallen_ = stepV_ = 0;
slideT_ = t1;
if (t1 >= 1) {
slideT_ = -1;
floorY_ = targetFloor_ = jump_[1];
if (jumpSeat_ >= 0) { rig(R); sit(jumpSeat_, R, headT, head); }
}
lastHead_[0] = head[0]; lastHead_[1] = head[2];
return;
}
// Never inside anything: the eyes stay kHeadClear from whatever is solid at their height, and the body doesn't
// stand in a coffee table, however you got there (the stick slides along edges already; this is moving for
// real, leaning, bending over a table). The world moves back instead.
if (const WalkGrid* g = runtime_.grid(); g && seated_ < 0) {
float push[2];
if (g->headPush(head[0], head[1], head[2], kHeadClear, push) ||
g->bodyPush(head[0], floorY_, head[2], push)) {
rigX_ += push[0]; rigZ_ += push[1];
head[0] += push[0]; head[2] += push[1];
}
}
const float walked = std::hypot(head[0] - lastHead_[0], head[2] - lastHead_[1]); // stick and roomscale
lastHead_[0] = head[0]; lastHead_[1] = head[2];
if (seated_ >= 0) {
// Seated: turning is free, moving isn't. Leaning is fine; stepping away (roomscale) stands you up there.
const WorldPoint& st = world_.seats[seated_];
if (std::hypot(head[0] - st.pos[0], head[2] - st.pos[2]) > 0.55f) standUp(head, false);
else {
// Sitting down (or up) for real slowly brings the eyes back to the seated height; quick head
// movements still show.
const float want = st.pos[1] + kSeatedEye - headT[13];
seatRigY_ += (want - seatRigY_) * std::min(1.0f, dt * 0.7f);
return;
}
}
// The floor under the head (stick or roomscale walking). A ramp is followed exactly; a step is eased
// (critically damped: no jolt as it starts or ends). Nothing within a step below: you fall, and land on
// whatever is under you; too far, and you're back at the spawn.
// The floor is looked for within a step of where you are (down stairs: the next tread, never the floor under the
// staircase), then a hand's width round (a stair's edge, a seam): only with none there do you fall.
float f;
bool on = runtime_.hasFloor() && runtime_.floorAt(head[0], head[2], floorY_, 0.45f, 0.45f, f);
if (!on && runtime_.grid()) {
float nx = head[0], nz = head[2];
on = runtime_.grid()->roomAt(nx, floorY_, nz, 0.2f) && runtime_.floorAt(nx, nz, floorY_, 0.45f, 0.45f, f);
}
if (on) {
const float d = f - targetFloor_;
if (std::fabs(d) <= walked * grav::kRampSlope + 0.004f) floorY_ += d;
targetFloor_ = f;
fallV_ = fallen_ = 0;
} else if (runtime_.hasFloor()) {
stepV_ = 0;
fallV_ = std::min(fallV_ + 9.8f * dt, 55.0f);
const float stepD = fallV_ * dt;
if (runtime_.floorAt(head[0], head[2], floorY_, 0.0f, stepD + 0.05f, f)) { floorY_ = targetFloor_ = f; fallV_ = fallen_ = 0; return; }
floorY_ -= stepD;
targetFloor_ = floorY_;
fallen_ += stepD;
if (fallen_ > fallLimit_) {
++respawns_;
std::fprintf(stderr, "frame-home: fell %.0f m: back to the spawn\n", fallen_);
toSpawn();
}
return;
}
const float left = targetFloor_ - floorY_;
floorY_ = targetFloor_ - grav::smoothDamp(left, 0, stepV_, left > 0 ? grav::kStepUp : grav::kStepDown, dt);
}
private:
// Scale is true metres end to end (Meta's homes are authored in metres, floor at y = 0), so the home looks its
// real size only when the tracking floor is the real floor. A headset whose standing space has no floor (its
// origin at the head) puts the eyes at floor level and every room looks giant: after 2 s of tracking with the
// eyes under 0.6 m, lift them to a standing 1.65 m. FH_EYE_HEIGHT=<metres> forces an eye height; 0 = never lift.
void checkEyeHeight(float dt, float headY) {
if (liftDecided_ || headY == 0 || !poseValid_) return; // only real head poses count (not asleep / lost)
static const char* forced = std::getenv("FH_EYE_HEIGHT");
if (forced) {
const float want = static_cast<float>(std::atof(forced));
lift_ = want > 0 ? want - headY : 0;
liftDecided_ = true;
std::fprintf(stderr, "frame-home: eye height forced to %.2f m (tracking %.2f m)\n", want, headY);
return;
}
lowTime_ = headY < 0.6f ? lowTime_ + dt : 0;
seenTime_ += dt;
if (lowTime_ > 2.0f) {
lift_ = 1.65f - headY;
liftDecided_ = true;
std::fprintf(stderr, "frame-home: tracking floor looks unset (eyes %.2f m over it): lifting to 1.65 m\n", headY);
} else if (seenTime_ > 4.0f) {
liftDecided_ = true; // a real floor: true scale, no lift
}
}
// Sits in seat i: the head over the seat, still facing the way you were, eyes kSeatedEye over its surface (whatever
// your real height: you may be standing in the room).
static constexpr float kSeatedEye = 0.78f;
static constexpr float kArcSpeed = 10.0f; // the teleport arc's launch speed (m/s)
static constexpr float kHeadClear = 0.15f; // the eyes never come closer than this to anything solid (m)
void sit(int i, const float R[16], const float headT[16], float head[3]) {
const WorldPoint& st = world_.seats[i];
rigX_ += st.pos[0] - head[0];
rigZ_ += st.pos[2] - head[2];
head[0] = st.pos[0]; head[2] = st.pos[2];
(void)R;
seatRigY_ = st.pos[1] + kSeatedEye - headT[13];
seated_ = i;
std::fprintf(stderr, "frame-home: sitting in %s\n", st.name.c_str());
}
// Moving the stick in a seat: out to its stand-up spot (Frame Studio's "stand up here"; half a metre in front when
// it names none), the way a teleport goes there — a dash with slide teleports, else a blink or a jump.
void leaveSeat(const float headT[16], float head[3], const float R[16]) {
if (seated_ < 0) return;
const WorldPoint& st = world_.seats[seated_];
const float c = std::cos(st.yaw), s = std::sin(st.yaw);
float ex = st.exit[0], ez = st.exit[2];
if (std::hypot(ex, ez) < 0.05f) { ex = 0; ez = -0.5f; }
jump_[0] = st.pos[0] + c * ex + s * ez;
jump_[2] = st.pos[2] - s * ex + c * ez;
float f;
if (!runtime_.hasFloor() || !runtime_.floorAt(jump_[0], jump_[2], st.pos[1], 0.3f, 2.0f, f)) { standUp(head, true); return; }
jump_[1] = f;
jumpSeat_ = -1;
std::fprintf(stderr, "frame-home: getting up from %s\n", st.name.c_str());
startJump(headT, head, R, teleport_ == 2);
if (slideT_ < 0 && blinkT_ < 0) seated_ = -1; // (a jump: done; a dash or a blink: under way)
}
// Leaves the seat. byStick: you walked off — you step out at the seat's exit point (beside it, or half a metre
// in front when the seat has none). Otherwise you stood up where your head is.
void standUp(float head[3], bool byStick) {
const WorldPoint& st = world_.seats[seated_];
seated_ = -1;
float to[3] = {head[0], 0, head[2]};
if (byStick) {
const float c = std::cos(st.yaw), s = std::sin(st.yaw);
float ex = st.exit[0], ez = st.exit[2];
if (std::hypot(ex, ez) < 0.05f) { ex = 0; ez = -0.5f; } // forward (-Z) when the seat names no exit
to[0] = st.pos[0] + c * ex + s * ez;
to[2] = st.pos[2] - s * ex + c * ez;
}
float f = st.pos[1] - 0.45f;
if (runtime_.hasFloor() && runtime_.floorAt(to[0], to[2], st.pos[1], 0.3f, 2.0f, f)) {}
else if (byStick) { to[0] = head[0]; to[2] = head[2]; runtime_.floorAt(to[0], to[2], st.pos[1], 0.3f, 2.0f, f); }
rigX_ += to[0] - head[0];
rigZ_ += to[2] - head[2];
head[0] = to[0]; head[2] = to[2];
floorY_ = targetFloor_ = f;
std::fprintf(stderr, "frame-home: stood up from %s\n", st.name.c_str());
}
// Gravity zones: the same sticks and teleport, but with the walker's up.
void updateGravity(float dt, const float headT[16], float lx, float ly, const vr::InputAnalogActionData_t& r) {
float h[16];
std::copy(headT, headT + 16, h);
h[13] += lift_;
const float rx = r.bActive ? r.x : 0, ry = r.bActive ? r.y : 0;
walker_.update(dt, h, lx, ly, turnBy(dt, rx, ry));
// Teleport: aim along the walker's gravity, go on release. A slide is a blink here: the landing may turn
// your up (onto a wall), which a straight glide can't follow.
if (teleport_ == 0) { if (aiming_) dropAim(); }
else if (ry > 0.7f && std::fabs(rx) < 0.6f) {
if (!aiming_) guide_.aimTime = 0;
aiming_ = true;
valid_ = false;
guide_.aimTime += dt;
float m[12];
guide_.arc.clear();
if (handPose(m)) {
const float po[3] = {m[3], m[7] + lift_, m[11]}, pd[3] = {-m[2], -m[6], -m[10]};
valid_ = walker_.aim(po, pd, target_, targetN_, &guide_.arc, &h[12]);
}
guide_.aiming = !guide_.arc.empty();
guide_.valid = valid_;
guide_.seat = false;
std::copy(target_, target_ + 3, guide_.target);
std::copy(targetN_, targetN_ + 3, guide_.normal);
if (ring_ && ringOn_) {
if (!valid_) vr::VROverlay()->HideOverlay(ring_);
else { // tracking up is the walker's up, so the ring lies flat in tracking space
const grav::V t = walker_.toTracking(grav::V(target_));
vr::HmdMatrix34_t tm = {{{1, 0, 0, t.x}, {0, 0, 1, t.y + 0.02f - lift_}, {0, -1, 0, t.z}}};
vr::VROverlay()->SetOverlayTransformAbsolute(ring_, vr::TrackingUniverseStanding, &tm);
vr::VROverlay()->ShowOverlay(ring_);
}
}
} else if (aiming_ && ry < 0.4f) {
aiming_ = false;
guide_.aiming = false;
if (ring_) vr::VROverlay()->HideOverlay(ring_);
if (valid_ && blinkT_ < 0) {
std::copy(target_, target_ + 3, jump_);
std::copy(targetN_, targetN_ + 3, jumpN_);
if (blinkOut()) blinkT_ = kBlinkOut;
else walker_.teleport(jump_, h, jumpN_);
}
}
if (blinkT_ >= 0 && (blinkT_ -= dt) <= 0) { blinkT_ = -1; walker_.teleport(jump_, h, jumpN_); blinkIn(); }
}
// This frame's turn (radians, + = left) from the right stick's x. Snap: one snap_ per flick (the stick must
// come back near centre to arm the next). Smooth: up to kTurnRate past the deadzone, not while aiming (an arc
// held slightly off-centre shouldn't spin you).
static constexpr float kTurnRate = 2.0944f; // 120 degrees/s
float turnBy(float dt, float rx, float ry) {
if (turn_ == 1) return aiming_ || std::fabs(ry) > 0.7f ? 0.0f : -dead(rx) * kTurnRate * dt;
if (!turnArmed_ && std::fabs(rx) < 0.3f) turnArmed_ = true;
if (turnArmed_ && std::fabs(rx) > 0.75f && std::fabs(ry) < 0.6f) { turnArmed_ = false; return rx > 0 ? -snap_ : snap_; }
return 0;
}
// Instant teleports blink: fade to black, jump, fade back. In system mode Frame Home isn't the scene app and
// the compositor may ignore the fade; then the blink is just a 80 ms wait.
static constexpr float kBlinkOut = 0.08f, kSlide = 0.25f;
bool blinkOut() const { if (!ok_ || !vr::VRCompositor()) return false; vr::VRCompositor()->FadeToColor(kBlinkOut, 0, 0, 0, 1); return true; }
void blinkIn() const { if (ok_ && vr::VRCompositor()) vr::VRCompositor()->FadeToColor(0.12f, 0, 0, 0, 0); }
static float ease(float t) { return t * t * (3 - 2 * t); }
// Off to jump_ (and into seat jumpSeat_ when >= 0): a 0.25 s dash (slide), or a blink — instant behind a fade.
void startJump(const float headT[16], float head[3], const float R[16], bool slide) {
vel_[0] = vel_[1] = 0;
if (slide) {
// The dash: the rig glides by the head's offset to the target, its height from where you are
// (standing, or a seat's eye height) to the target's floor. The arc found that floor.
slideT_ = 0;
slideDX_ = jump_[0] - head[0]; slideDZ_ = jump_[2] - head[2];
slideY0_ = R[13] - lift_;
seated_ = -1;
} else if (blinkOut()) blinkT_ = kBlinkOut;
else land(headT, head); // no compositor of ours: just jump
}
// Puts the head over jump_ on its floor (a jumped-to seat sits you in it).
void land(const float headT[16], float head[3]) {
seated_ = -1; // teleporting off a seat stands you up
rigX_ += jump_[0] - head[0];
rigZ_ += jump_[2] - head[2];
floorY_ = targetFloor_ = jump_[1];
fallV_ = fallen_ = stepV_ = 0;
vel_[0] = vel_[1] = 0;
head[0] = jump_[0]; head[2] = jump_[2];
if (jumpSeat_ >= 0) { float R[16]; rig(R); sit(jumpSeat_, R, headT, head); }
}
void dropAim() { aiming_ = valid_ = guide_.aiming = false; if (ring_ && vr::VROverlay()) vr::VROverlay()->HideOverlay(ring_); }
// The right hand's aim (3x4 row-major, tracking space): the controller, the test's, or the menu's.
bool handPose(float m[12]) const {
if (test_) { if (testAimOk_) std::copy(testAim_, testAim_ + 12, m); return testAimOk_; }
vr::InputPoseActionData_t pose = {};
if (vr::VRInput()->GetPoseActionDataForNextFrame(rightAim_, vr::TrackingUniverseStanding, &pose, sizeof pose, vr::k_ulInvalidInputValueHandle) == vr::VRInputError_None &&
pose.bActive && pose.pose.bPoseIsValid) { std::memcpy(m, pose.pose.mDeviceToAbsoluteTracking.m, 12 * sizeof(float)); return true; }
if (menuAim_) { std::memcpy(m, menuAimPose_, 12 * sizeof(float)); return true; }
return false;
}
bool leftPose(float m[12]) const {
if (test_) { if (testLeftOk_) std::copy(testLeft_, testLeft_ + 12, m); return testLeftOk_; }
vr::InputPoseActionData_t pose = {};
if (!leftAim_ || vr::VRInput()->GetPoseActionDataForNextFrame(leftAim_, vr::TrackingUniverseStanding, &pose, sizeof pose, vr::k_ulInvalidInputValueHandle) != vr::VRInputError_None ||
!pose.bActive || !pose.pose.bPoseIsValid) return false;
std::memcpy(m, pose.pose.mDeviceToAbsoluteTracking.m, 12 * sizeof(float));
return true;
}
GravityWalker walker_;
float targetN_[3] = {0, 1, 0}; // the teleport target's surface normal
bool gravityMode_ = false;
float fallV_ = 0, fallen_ = 0, fallLimit_ = 30;
int respawns_ = 0;
int seated_ = -1;
float seatRigY_ = 0;
bool ownInput_ = false;
bool menuAim_ = false;
float menuAimPose_[12] = {};
float lift_ = 0, lowTime_ = 0, seenTime_ = 0;
bool poseValid_ = true;
bool liftDecided_ = false;
bool ok_ = false;
vr::VRActionSetHandle_t set_ = 0;
vr::VRActionHandle_t leftStick_ = 0, rightStick_ = 0, rightAim_ = 0, leftAim_ = 0, gaze_ = 0;
vr::VRActionHandle_t trigger_[2] = {0, 0}; // 0 right, 1 left
bool trigHeld_[2] = {false, false}; // last frame's (a press is the edge)
int pointed_[2] = {-1, -1}; // the seat each hand points at
vr::VROverlayHandle_t ring_ = 0;
bool ringOn_ = true;
LocoGuide guide_;
float vel_[2] = {0, 0}; // walking velocity (world x, z), eased
float stepV_ = 0; // a step's easing rate
float lastHead_[2] = {0, 0};
// Seats: a ball of kSeatRadius over each seat point is what the arc and pointing hands hit (and the chair's own
// surfaces within kSeatOwn of it); markers show within kMarkerReach, pointing reaches kPointReach.
static constexpr float kSeatRadius = 0.35f, kSeatOwn = 0.65f, kSeatLift = 0.1f, kMarkerReach = 2.0f, kPointReach = 10.0f; // markers: only seats right by you
HomeWorld world_;
WorldRuntime runtime_;
float rigX_ = 0, rigZ_ = 0, rigYaw_ = 0, floorY_ = 0, targetFloor_ = 0;
bool turnArmed_ = true, aiming_ = false, valid_ = false;
float target_[3] = {0, 0, 0};
int seat_ = -1;
// Options (setOptions) and the jump in flight: where it lands (copied from the aim on release), a blink's time
// to black, a dash's progress 0..1 (-1: none).
int walk_ = 1, teleport_ = 1, turn_ = 0;
float snap_ = 0.5236f, speed_ = 2.0f;
float jump_[3] = {0, 0, 0}, jumpN_[3] = {0, 1, 0};
int jumpSeat_ = -1;
float blinkT_ = -1, slideT_ = -1, slideDX_ = 0, slideDZ_ = 0, slideY0_ = 0;
bool test_ = false, testAimOk_ = false, testLeftOk_ = false, testTrig_[2] = {false, false};
float testIn_[4] = {}, testAim_[12] = {}, testLeft_[12] = {};
// Where you stand to sit in seat i: over it, on the floor under it (the seat's own top when it is one).
void seatTarget(int i, float t[3]) const {
const WorldPoint& s = world_.seats[i];
t[0] = s.pos[0]; t[1] = s.pos[1] - 0.45f; t[2] = s.pos[2];
float f;
if (runtime_.floorAt(t[0], t[2], s.pos[1], 0.2f, 2.0f, f)) t[1] = f;
}
// The seat whose furniture a surface hit at q likely belongs to (within kSeatOwn of the seat point), or -1.
int seatNear(const float q[3]) const {
int best = -1;
float bd = kSeatOwn * kSeatOwn;
for (size_t i = 0; i < world_.seats.size(); ++i) {
const float* p = world_.seats[i].pos;
const float dx = q[0] - p[0], dy = q[1] - (p[1] + kSeatLift), dz = q[2] - p[2], dd = dx * dx + dy * dy + dz * dz;
if (dd < bd) { bd = dd; best = static_cast<int>(i); }
}
return best;
}
// seatNear, or the seat whose own collider `shape` is (centred on it: Meta gives chairs a ball or box that can
// be well over a metre across).
int seatOf(const float q[3], const WorldCollider* shape) const {
const int i = seatNear(q);
return i >= 0 || !shape ? i : seatNear(&shape->world[12]);
}
// The first seat ball a segment o + d t, t in [0, 1], enters, and where (t).
int seatOnSegment(const float o[3], const float d[3], float& t) const {
int best = -1;
t = 2;
for (size_t i = 0; i < world_.seats.size(); ++i) {
const float* p = world_.seats[i].pos;
const float oc[3] = {o[0] - p[0], o[1] - (p[1] + kSeatLift), o[2] - p[2]};
const float A = d[0] * d[0] + d[1] * d[1] + d[2] * d[2], B = oc[0] * d[0] + oc[1] * d[1] + oc[2] * d[2];
const float C = oc[0] * oc[0] + oc[1] * oc[1] + oc[2] * oc[2] - kSeatRadius * kSeatRadius, disc = B * B - A * C;
if (A < 1e-12f || disc < 0 || C < 0) continue; // (a hand inside one isn't pointing)
const float th = (-B - std::sqrt(disc)) / A;
if (th >= 0 && th <= 1 && th < t) { t = th; best = static_cast<int>(i); }
}
return best;
}
static float dead(float v) { return std::fabs(v) < 0.15f ? 0.0f : (v - std::copysign(0.15f, v)) / 0.85f; }
static void apply(const float m[16], const float p[3], float o[3]) { for (int k = 0; k < 3; ++k) o[k] = m[k] * p[0] + m[4 + k] * p[1] + m[8 + k] * p[2] + m[12 + k]; }
static void dir(const float m[16], const float d[3], float o[3]) { for (int k = 0; k < 3; ++k) o[k] = m[k] * d[0] + m[4 + k] * d[1] + m[8 + k] * d[2]; }
float headYaw(const float R[16], const float headT[16]) const {
float f[3];
const float hz[3] = {-headT[8], -headT[9], -headT[10]};
dir(R, hz, f);
return std::atan2(-f[0], -f[2]);
}
// The teleport arc from the right hand: a parabola (10 m/s: about 10 m on the flat) traced in 30 ms segments until
// it meets something; a seat's ball sits you in it, and where else it ends puts you somewhere if it can
// (WorldRuntime::landingFor: a floor, the floor by a table's top, a wall's or furniture's foot, the floor under a
// ceiling). Leaves don't stop it. Measured (tools/walktest/teleportfeel): every floor in sight 1.5-10 m away is
// reachable.
void aim(const float R[16], const float head[3], float dt) {
if (!aiming_) guide_.aimTime = 0;
aiming_ = true;
valid_ = false;
seat_ = -1;
guide_.aimTime += dt;
guide_.arc.clear();
targetN_[0] = targetN_[2] = 0; targetN_[1] = 1;
float m[12];
if (handPose(m)) {
const float po[3] = {m[3], m[7], m[11]}, pd[3] = {-m[2], -m[6], -m[10]};
float o[3], d[3];
apply(R, po, o);
dir(R, pd, d);
traceArc(head, o, d);
}
if (valid_ && seat_ < 0) { // snap onto a seat aimed right at, or a hotspot
float best = 0.3f;
for (size_t i = 0; i < world_.seats.size(); ++i) {
const float* s = world_.seats[i].pos, dd = std::hypot(s[0] - target_[0], s[2] - target_[2]);
if (dd < best) { best = dd; seat_ = static_cast<int>(i); }
}
if (seat_ >= 0) seatTarget(seat_, target_);
else {
for (const WorldPoint& h : world_.hotspots)
if (std::hypot(h.pos[0] - target_[0], h.pos[2] - target_[2]) < 1.0f) { target_[0] = h.pos[0]; target_[1] = h.pos[1]; target_[2] = h.pos[2]; break; }
}
}
if (seat_ >= 0) { targetN_[0] = targetN_[2] = 0; targetN_[1] = 1; }
guide_.aiming = !guide_.arc.empty();
guide_.valid = valid_;
guide_.seat = seat_ >= 0;
std::copy(target_, target_ + 3, guide_.target);
std::copy(targetN_, targetN_ + 3, guide_.normal);
if (!ring_ || !ringOn_) return;
if (!valid_) { vr::VROverlay()->HideOverlay(ring_); return; }
// tracking = rig^-1 * world; the ring lies flat (rotated -90 degrees about X).
const float c = std::cos(rigYaw_), s = std::sin(rigYaw_);
float Rn[16];
rig(Rn);
const float wx = target_[0] - rigX_, wy = target_[1] + 0.02f - Rn[13], wz = target_[2] - rigZ_;
const float tx = c * wx - s * wz, tz = s * wx + c * wz;
vr::HmdMatrix34_t t = {{{1, 0, 0, tx}, {0, 0, 1, wy}, {0, -1, 0, tz}}};
vr::VROverlay()->SetOverlayTransformAbsolute(ring_, vr::TrackingUniverseStanding, &t);
vr::VROverlay()->ShowOverlay(ring_);
}
// The arc's flight from o along d (world), into guide_.arc; sets valid_, target_, targetN_, seat_.
void traceArc(const float head[3], const float o[3], const float d[3]) {
auto keep = [&](const float* q) { guide_.arc.insert(guide_.arc.end(), q, q + 3); };
keep(o);
if (!runtime_.hasFloor()) return; // nothing to land on anywhere
{ // a hand reached through a wall (standing right at it) doesn't aim from the far side
const float hs[3] = {o[0] - head[0], o[1] - head[1], o[2] - head[2]};
float t, n[3];
if (runtime_.trace(head, hs, t, n) == WorldRuntime::Block) return;
}
float p[3] = {o[0], o[1], o[2]}, v[3] = {d[0] * kArcSpeed, d[1] * kArcSpeed, d[2] * kArcSpeed};
for (int i = 0; i < 90; ++i) {
const float dt = 0.03f;
const float seg[3] = {v[0] * dt, (v[1] - 4.9f * dt) * dt, v[2] * dt};
float t, n[3], ts;
const WorldCollider* shape = nullptr;
const WorldRuntime::Hit h = runtime_.trace(p, seg, t, n, &shape);
const int si = seatOnSegment(p, seg, ts);
if (si >= 0 && (h == WorldRuntime::None || ts <= t)) {
const float q[3] = {p[0] + seg[0] * ts, p[1] + seg[1] * ts, p[2] + seg[2] * ts};
keep(q);
seat_ = si;
seatTarget(si, target_);
valid_ = true;
return;
}
if (h != WorldRuntime::None) {
const float q[3] = {p[0] + seg[0] * t, p[1] + seg[1] * t, p[2] + seg[2] * t};
keep(q);
if (h == WorldRuntime::Block && (seat_ = seatOf(q, shape)) >= 0) { // the chair's back or arm: the chair
seatTarget(seat_, target_);
valid_ = true;
} else if (runtime_.landingFor(h, q, n, target_)) {
valid_ = true;
const bool onIt = h == WorldRuntime::Land && std::fabs(target_[1] - q[1]) < 0.05f;
for (int k = 0; k < 3; ++k) targetN_[k] = onIt ? n[k] : (k == 1 ? 1.0f : 0.0f);
}
return;
}
for (int k = 0; k < 3; ++k) p[k] += seg[k];
keep(p);
v[1] -= 9.8f * dt;
if (p[1] < floorY_ - 30) return;
}
}
};
+1988
View File
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
#pragma once
// Walking while the Steam menu is open. SteamVR gives a scene app (Frame Home) no controller input while its
// dashboard — the Steam menu — is up, even when you're looking away from it. frame-switcher, an overlay app that
// always runs, takes the sticks through a global-priority action set (steamvr.globalActionSetPriority) whenever the
// menu is up, Frame Home is the environment, and that hand isn't pointing at a panel, and passes them to Frame Home
// through a shared page (/dev/shm/framehome-sticks). Pointing at the menu still drives the menu.
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstring>
struct MenuSticks {
static constexpr uint32_t kMagic = 0x46534b31; // "FSK1"
uint32_t magic;
std::atomic<uint32_t> seq; // odd while being written
double time; // CLOCK_MONOTONIC seconds when written (both processes share the clock)
float lx, ly, rx, ry; // thumbsticks
uint8_t left, right; // which hands are taken for walking / turning
uint8_t aimValid;
float aim[12]; // right hand's aim pose in standing space (3x4, row-major)
};
inline double menuNow() { return std::chrono::duration<double>(std::chrono::steady_clock::now().time_since_epoch()).count(); }
inline MenuSticks* menuSticksMap(bool create) {
const int fd = open("/dev/shm/framehome-sticks", O_RDWR | (create ? O_CREAT : 0), 0600);
if (fd < 0) return nullptr;
if (create && ftruncate(fd, sizeof(MenuSticks)) != 0) { close(fd); return nullptr; }
struct stat st;
if (fstat(fd, &st) != 0 || st.st_size < static_cast<off_t>(sizeof(MenuSticks))) { close(fd); return nullptr; } // not sized yet
void* p = mmap(nullptr, sizeof(MenuSticks), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
return p == MAP_FAILED ? nullptr : static_cast<MenuSticks*>(p);
}
// A consistent copy (seqlock); false when there is nothing recent (within maxAge seconds).
inline bool menuSticksRead(const MenuSticks* m, MenuSticks& out, double maxAge = 0.15) {
if (!m || m->magic != MenuSticks::kMagic) return false;
for (int tries = 0; tries < 4; ++tries) {
const uint32_t a = m->seq.load(std::memory_order_acquire);
if (a & 1) continue;
std::memcpy(static_cast<void*>(&out), static_cast<const void*>(m), sizeof out);
std::atomic_thread_fence(std::memory_order_acquire);
if (m->seq.load(std::memory_order_relaxed) == a) return menuNow() - out.time < maxAge;
}
return false;
}
inline void menuSticksWrite(MenuSticks* m, const MenuSticks& v) {
if (!m) return;
uint32_t s = m->seq.load(std::memory_order_relaxed);
if (s & 1) ++s; // a writer killed mid-write left it odd: start even again
m->seq.store(s + 1, std::memory_order_release);
std::atomic_thread_fence(std::memory_order_release);
m->time = v.time; m->lx = v.lx; m->ly = v.ly; m->rx = v.rx; m->ry = v.ry;
m->left = v.left; m->right = v.right; m->aimValid = v.aimValid;
std::memcpy(m->aim, v.aim, sizeof m->aim);
m->magic = MenuSticks::kMagic;
std::atomic_thread_fence(std::memory_order_release);
m->seq.store(s + 2, std::memory_order_release);
}
+295
View File
@@ -0,0 +1,295 @@
#include "model_import.h"
#define CGLTF_IMPLEMENTATION
#include "cgltf.h"
#include "ufbx.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <map>
extern "C" unsigned char* stbi_load_from_memory(const unsigned char* buffer, int len, int* x, int* y, int* comp, int req_comp);
extern "C" void stbi_image_free(void* retval_from_stbi_load);
namespace {
namespace fs = std::filesystem;
std::string lower(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
std::vector<u8> readFile(const std::string& path) {
std::ifstream f(fs::u8path(path), std::ios::binary);
return std::vector<u8>((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}
struct Image { std::vector<u8> rgba; u32 w = 0, h = 0; bool alpha = false; };
bool decode(const u8* data, size_t n, Image& img) {
int w = 0, h = 0, comp = 0;
unsigned char* px = stbi_load_from_memory(data, static_cast<int>(n), &w, &h, &comp, 4);
if (!px) return false;
img.w = static_cast<u32>(w);
img.h = static_cast<u32>(h);
img.rgba.assign(px, px + static_cast<size_t>(w) * h * 4);
stbi_image_free(px);
for (size_t i = 3; i < img.rgba.size() && !img.alpha; i += 4) img.alpha = img.rgba[i] < 250;
return true;
}
// A finished part: texture (or a flat colour), colour factor, blend mode.
void finish(MeshData& md, const Image* img, const float color[4], bool blend, bool cutout, float cutoff) {
md.nVerts = static_cast<u32>(md.positions.size() / 3);
md.nIdx = static_cast<u32>(md.indices.size());
md.hasTexture = true;
if (img && !img->rgba.empty()) {
md.texRGBA = img->rgba;
md.texW = img->w;
md.texH = img->h;
} else {
md.texRGBA = {255, 255, 255, 255};
md.texW = md.texH = 1;
}
for (int k = 0; k < 4; ++k) md.tint[k] = md.curTint[k] = color[k];
md.useBlend = blend;
md.alphaTest = cutout;
md.alphaCutoff = cutoff;
md.doubleSided = true;
}
bool importGltf(const std::string& path, std::vector<MeshData>& meshes, std::vector<std::string>& files, std::string& error) {
cgltf_options opt = {};
cgltf_data* data = nullptr;
if (cgltf_parse_file(&opt, path.c_str(), &data) != cgltf_result_success) { error = "not a readable glTF file: " + path; return false; }
if (cgltf_load_buffers(&opt, data, path.c_str()) != cgltf_result_success) { cgltf_free(data); error = "glTF buffers missing next to " + path; return false; }
// Accessor / buffer-view ranges, index ranges and node loops are only checked here; the reads below trust them.
if (cgltf_validate(data) != cgltf_result_success) { cgltf_free(data); error = "malformed glTF (failed validation): " + path; return false; }
files.push_back(path);
const fs::path dir = fs::u8path(path).parent_path();
for (cgltf_size b = 0; b < data->buffers_count; ++b)
if (data->buffers[b].uri && std::strncmp(data->buffers[b].uri, "data:", 5) != 0) files.push_back((dir / fs::u8path(data->buffers[b].uri)).u8string());
std::map<const cgltf_image*, Image> images;
auto image = [&](const cgltf_texture_view& view) -> const Image* {
if (!view.texture || !view.texture->image) return nullptr;
const cgltf_image* im = view.texture->image;
auto it = images.find(im);
if (it != images.end()) return &it->second;
Image& out = images[im];
if (im->buffer_view && im->buffer_view->buffer->data) {
const cgltf_buffer_view& bv = *im->buffer_view;
if (bv.offset > bv.buffer->size || bv.size > bv.buffer->size - bv.offset) return &out; // range past the buffer
const u8* p = static_cast<const u8*>(im->buffer_view->buffer->data) + im->buffer_view->offset;
decode(p, im->buffer_view->size, out);
} else if (im->uri && std::strncmp(im->uri, "data:", 5) == 0) {
const char* comma = std::strchr(im->uri, ',');
if (comma) {
const size_t len = std::strlen(comma + 1), size = len / 4 * 3;
void* bytes = nullptr;
if (cgltf_load_buffer_base64(&opt, size, comma + 1, &bytes) == cgltf_result_success) {
decode(static_cast<const u8*>(bytes), size, out);
std::free(bytes);
}
}
} else if (im->uri) {
std::string rel = im->uri;
cgltf_decode_uri(rel.data());
rel.resize(std::strlen(rel.c_str()));
const std::string full = (dir / fs::u8path(rel)).u8string();
const std::vector<u8> bytes = readFile(full);
if (!bytes.empty() && decode(bytes.data(), bytes.size(), out)) files.push_back(full);
}
return &out;
};
for (cgltf_size n = 0; n < data->nodes_count; ++n) {
const cgltf_node& node = data->nodes[n];
if (!node.mesh) continue;
float world[16];
cgltf_node_transform_world(&node, world);
for (cgltf_size p = 0; p < node.mesh->primitives_count; ++p) {
const cgltf_primitive& prim = node.mesh->primitives[p];
if (prim.type != cgltf_primitive_type_triangles) continue;
const cgltf_accessor *pos = nullptr, *uv = nullptr, *col = nullptr;
for (cgltf_size a = 0; a < prim.attributes_count; ++a) {
const cgltf_attribute& at = prim.attributes[a];
if (at.type == cgltf_attribute_type_position) pos = at.data;
else if (at.type == cgltf_attribute_type_texcoord && at.index == 0) uv = at.data;
else if (at.type == cgltf_attribute_type_color && at.index == 0) col = at.data;
}
if (!pos || pos->count == 0) continue;
MeshData md;
md.name = std::string(node.name ? node.name : node.mesh->name ? node.mesh->name : "mesh") + (node.mesh->primitives_count > 1 ? "_" + std::to_string(p) : "");
for (const cgltf_node* a = node.parent; a; a = a->parent) // the nodes above it in the file
if (a->name) md.path = md.path.empty() ? std::string(a->name) : std::string(a->name) + "/" + md.path;
md.positions.resize(pos->count * 3);
md.uvs.assign(pos->count * 2, 0.0f);
for (cgltf_size v = 0; v < pos->count; ++v) {
cgltf_accessor_read_float(pos, v, &md.positions[v * 3], 3);
if (uv) cgltf_accessor_read_float(uv, v, &md.uvs[v * 2], 2);
}
if (col) {
md.colors.resize(pos->count * 4);
for (cgltf_size v = 0; v < pos->count; ++v) {
float c[4] = {1, 1, 1, 1};
cgltf_accessor_read_float(col, v, c, cgltf_num_components(col->type));
for (int k = 0; k < 4; ++k) md.colors[v * 4 + k] = static_cast<u8>(std::clamp(c[k], 0.0f, 1.0f) * 255.0f + 0.5f);
}
}
if (prim.indices) {
md.indices.resize(prim.indices->count);
// Clamp out-of-range indices (degenerate triangle) rather than reading past the vertex arrays.
for (cgltf_size i = 0; i < prim.indices->count; ++i)
md.indices[i] = static_cast<u32>(std::min<cgltf_size>(cgltf_accessor_read_index(prim.indices, i), pos->count - 1));
} else {
md.indices.resize(pos->count);
for (cgltf_size i = 0; i < pos->count; ++i) md.indices[i] = static_cast<u32>(i);
}
std::memcpy(md.worldMatrix, world, sizeof world);
md.hasWorldMatrix = true;
float color[4] = {1, 1, 1, 1};
const Image* img = nullptr;
bool blend = false, cutout = false;
float cutoff = 0.5f;
if (prim.material) {
const cgltf_material& m = *prim.material;
if (m.has_pbr_metallic_roughness) {
std::memcpy(color, m.pbr_metallic_roughness.base_color_factor, sizeof color);
img = image(m.pbr_metallic_roughness.base_color_texture);
} else if (m.has_pbr_specular_glossiness) {
std::memcpy(color, m.pbr_specular_glossiness.diffuse_factor, sizeof color);
img = image(m.pbr_specular_glossiness.diffuse_texture);
}
blend = m.alpha_mode == cgltf_alpha_mode_blend;
cutout = m.alpha_mode == cgltf_alpha_mode_mask;
cutoff = m.alpha_cutoff;
if (m.unlit && !img) {} // unlit materials draw the same way here
}
finish(md, img, color, blend, cutout, cutoff);
meshes.push_back(std::move(md));
}
}
cgltf_free(data);
if (meshes.empty()) { error = "no triangle meshes in " + path; return false; }
return true;
}
bool importUfbx(const std::string& path, std::vector<MeshData>& meshes, std::vector<std::string>& files, std::string& error) {
ufbx_load_opts opts = {};
opts.target_axes = ufbx_axes_right_handed_y_up;
opts.target_unit_meters = 1.0f;
opts.space_conversion = UFBX_SPACE_CONVERSION_MODIFY_GEOMETRY;
opts.load_external_files = true;
ufbx_error err;
ufbx_scene* scene = ufbx_load_file(path.c_str(), &opts, &err);
if (!scene) {
char msg[512];
ufbx_format_error(msg, sizeof msg, &err);
error = "couldn't read " + path + ": " + msg;
return false;
}
files.push_back(path);
const fs::path dir = fs::u8path(path).parent_path();
const bool flipV = true; // FBX / OBJ texture space starts bottom-left; images load top-first
std::map<const ufbx_texture*, Image> images;
auto image = [&](const ufbx_texture* t) -> const Image* {
if (!t) return nullptr;
auto it = images.find(t);
if (it != images.end()) return &it->second;
Image& out = images[t];
if (t->content.size) {
decode(static_cast<const u8*>(t->content.data), t->content.size, out);
return &out;
}
for (const ufbx_string& s : {t->filename, t->absolute_filename, t->relative_filename}) {
if (!s.length) continue;
std::string f(s.data, s.length);
std::vector<u8> bytes = readFile(f);
if (bytes.empty()) { f = (dir / fs::u8path(f).filename()).u8string(); bytes = readFile(f); } // moved next to the model
if (!bytes.empty() && decode(bytes.data(), bytes.size(), out)) { files.push_back(f); break; }
}
return &out;
};
std::vector<uint32_t> tri;
for (size_t ni = 0; ni < scene->nodes.count; ++ni) {
const ufbx_node* node = scene->nodes.data[ni];
const ufbx_mesh* mesh = node->mesh;
if (!mesh) continue;
tri.resize(mesh->max_face_triangles * 3);
const ufbx_matrix& g = node->geometry_to_world;
const float world[16] = {static_cast<float>(g.m00), static_cast<float>(g.m10), static_cast<float>(g.m20), 0,
static_cast<float>(g.m01), static_cast<float>(g.m11), static_cast<float>(g.m21), 0,
static_cast<float>(g.m02), static_cast<float>(g.m12), static_cast<float>(g.m22), 0,
static_cast<float>(g.m03), static_cast<float>(g.m13), static_cast<float>(g.m23), 1};
for (size_t pi = 0; pi < mesh->material_parts.count; ++pi) {
const ufbx_mesh_part& part = mesh->material_parts.data[pi];
if (part.num_triangles == 0) continue;
MeshData md;
md.name = std::string(node->name.data, node->name.length) + (mesh->material_parts.count > 1 ? "_" + std::to_string(pi) : "");
for (const ufbx_node* a = node->parent; a && !a->is_root; a = a->parent) // the nodes above it in the file
if (a->name.length) md.path = md.path.empty() ? std::string(a->name.data, a->name.length) : std::string(a->name.data, a->name.length) + "/" + md.path;
for (size_t fi = 0; fi < part.face_indices.count; ++fi) {
const ufbx_face face = mesh->faces.data[part.face_indices.data[fi]];
const uint32_t n = ufbx_triangulate_face(tri.data(), tri.size(), mesh, face);
for (uint32_t k = 0; k < n * 3; ++k) {
const uint32_t ix = tri[k];
const ufbx_vec3 p = ufbx_get_vertex_vec3(&mesh->vertex_position, ix);
md.positions.insert(md.positions.end(), {static_cast<float>(p.x), static_cast<float>(p.y), static_cast<float>(p.z)});
if (mesh->vertex_uv.exists) {
const ufbx_vec2 t = ufbx_get_vertex_vec2(&mesh->vertex_uv, ix);
md.uvs.insert(md.uvs.end(), {static_cast<float>(t.x), flipV ? 1.0f - static_cast<float>(t.y) : static_cast<float>(t.y)});
} else {
md.uvs.insert(md.uvs.end(), {0.0f, 0.0f});
}
if (mesh->vertex_color.exists) {
const ufbx_vec4 c = ufbx_get_vertex_vec4(&mesh->vertex_color, ix);
for (double v : {c.x, c.y, c.z, c.w}) md.colors.push_back(static_cast<u8>(std::clamp(v, 0.0, 1.0) * 255.0 + 0.5));
}
md.indices.push_back(static_cast<u32>(md.indices.size()));
}
}
std::memcpy(md.worldMatrix, world, sizeof world);
md.hasWorldMatrix = true;
float color[4] = {1, 1, 1, 1};
const Image* img = nullptr;
bool blend = false;
const ufbx_material* mat = pi < mesh->materials.count ? mesh->materials.data[pi] : nullptr;
if (mat) {
const ufbx_material_map& base = mat->pbr.base_color.has_value ? mat->pbr.base_color : mat->fbx.diffuse_color;
if (base.has_value) for (int k = 0; k < 3; ++k) color[k] = static_cast<float>(base.value_vec4.v[k]);
if (mat->pbr.base_factor.has_value && mat->pbr.base_factor.value_real > 0)
for (int k = 0; k < 3; ++k) color[k] *= static_cast<float>(mat->pbr.base_factor.value_real);
img = image(mat->pbr.base_color.texture ? mat->pbr.base_color.texture : mat->fbx.diffuse_color.texture);
if (img && img->rgba.size() > 4) color[0] = color[1] = color[2] = 1.0f; // the texture carries the colour
const double opacity = mat->pbr.opacity.has_value ? mat->pbr.opacity.value_real : 1.0;
color[3] = static_cast<float>(opacity);
blend = opacity < 0.999 || (mat->pbr.opacity.texture != nullptr);
}
finish(md, img, color, blend, img && img->alpha && !blend, 0.5f);
meshes.push_back(std::move(md));
}
}
ufbx_free_scene(scene);
if (meshes.empty()) { error = "no meshes in " + path; return false; }
std::sort(files.begin(), files.end());
files.erase(std::unique(files.begin(), files.end()), files.end());
return true;
}
} // namespace
bool isModelPath(const std::string& path) {
const std::string ext = lower(fs::u8path(path).extension().u8string());
return ext == ".gltf" || ext == ".glb" || ext == ".fbx" || ext == ".obj";
}
bool importModel(const std::string& path, std::vector<MeshData>& meshes, std::vector<std::string>& files, std::string& error) {
const std::string ext = lower(fs::u8path(path).extension().u8string());
if (ext == ".gltf" || ext == ".glb") return importGltf(path, meshes, files, error);
if (ext == ".fbx" || ext == ".obj") return importUfbx(path, meshes, files, error);
error = "unsupported model type: " + path;
return false;
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// Models from Blender or Unity placed into a home: glTF 2.0 (.gltf / .glb — Blender's native export, Unity's
// glTFast / UnityGLTF), FBX and OBJ (both tools' classic exports). Each mesh part becomes one MeshData drawn
// with the built-in unlit textured program (the same one Clay homes use): base-colour texture x colour factor,
// alpha blending or cut-out from the material. Units become metres and up becomes +Y.
#include "core/types.h"
#include <string>
#include <vector>
bool isModelPath(const std::string& path);
// `files` receives every file the model read (itself, external buffers, textures), so a .fenv can carry them.
bool importModel(const std::string& path, std::vector<MeshData>& meshes, std::vector<std::string>& files, std::string& error);
+360
View File
@@ -0,0 +1,360 @@
#pragma once
// OverlayTexture: hands a CPU-drawn RGBA canvas to a SteamVR overlay through Vulkan images this process owns
// (SetOverlayTexture), instead of SetOverlayRaw. SetOverlayRaw makes SteamVR create a new texture on every call,
// and the dashboard can show that texture missing or half-made for a frame: the panel flickered whenever it
// redrew (pointer moves, stats refreshes). Here the canvas goes into one of three images in turn (staging buffer,
// copy, wait for the copy's fence), so the image SteamVR is showing is never the one being written.
//
// Vulkan is loaded at run time (dlopen libvulkan.so.1): no link dependency, and if anything is missing init()
// fails and the caller keeps using SetOverlayRaw.
#ifndef VK_NO_PROTOTYPES
#define VK_NO_PROTOTYPES
#endif
#include <vulkan/vulkan.h>
#include <openvr.h>
#include <dlfcn.h>
#include <algorithm>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <sstream>
#include <string>
#include <vector>
class OverlayTexture {
public:
static constexpr int kSlots = 3;
// w x h RGBA8 (sRGB-encoded bytes, like SetOverlayRaw takes). Needs VR_Init done (the HMD's GPU comes from
// VRSystem()->GetOutputDevice, the extensions from VRCompositor()).
bool init(uint32_t w, uint32_t h, std::string& err) {
w_ = w; h_ = h;
if (!(lib_ = dlopen("libvulkan.so.1", RTLD_NOW | RTLD_LOCAL)) && !(lib_ = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL))) return fail(err, "no libvulkan");
gipa_ = reinterpret_cast<PFN_vkGetInstanceProcAddr>(dlsym(lib_, "vkGetInstanceProcAddr"));
if (!gipa_) return fail(err, "no vkGetInstanceProcAddr");
#define OT_GLOBAL(n) n = reinterpret_cast<PFN_##n>(gipa_(VK_NULL_HANDLE, #n)); if (!n) return fail(err, "missing " #n)
OT_GLOBAL(vkCreateInstance);
OT_GLOBAL(vkEnumerateInstanceExtensionProperties);
#undef OT_GLOBAL
if (!vr::VRCompositor() || !vr::VRSystem()) return fail(err, "no compositor");
// Instance, with the extensions SteamVR wants (those this loader has).
std::vector<std::string> want = split(instanceExtensions());
uint32_t n = 0;
vkEnumerateInstanceExtensionProperties(nullptr, &n, nullptr);
std::vector<VkExtensionProperties> have(n);
vkEnumerateInstanceExtensionProperties(nullptr, &n, have.data());
std::vector<const char*> ie;
for (const std::string& e : want)
if (std::any_of(have.begin(), have.end(), [&](const VkExtensionProperties& p) { return e == p.extensionName; })) ie.push_back(e.c_str());
VkApplicationInfo app = {VK_STRUCTURE_TYPE_APPLICATION_INFO};
app.pApplicationName = "frame-switcher";
app.apiVersion = VK_API_VERSION_1_1;
VkInstanceCreateInfo ici = {VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO};
ici.pApplicationInfo = &app;
ici.enabledExtensionCount = static_cast<uint32_t>(ie.size());
ici.ppEnabledExtensionNames = ie.data();
if (VkResult r = vkCreateInstance(&ici, nullptr, &inst_); r != VK_SUCCESS) return fail(err, "vkCreateInstance " + std::to_string(r));
if (!loadInstance()) return fail(err, "missing Vulkan entry points");
// The GPU the headset is on (the only one on the Frame, but ask).
uint64_t out = 0;
vr::VRSystem()->GetOutputDevice(&out, vr::TextureType_Vulkan, reinterpret_cast<VkInstance_T*>(inst_));
pd_ = reinterpret_cast<VkPhysicalDevice>(out);
if (!pd_) {
uint32_t c = 1;
VkPhysicalDevice first = VK_NULL_HANDLE;
vkEnumeratePhysicalDevices(inst_, &c, &first);
pd_ = first;
}
if (!pd_) return fail(err, "no GPU");
uint32_t qn = 0;
vkGetPhysicalDeviceQueueFamilyProperties(pd_, &qn, nullptr);
std::vector<VkQueueFamilyProperties> qf(qn);
vkGetPhysicalDeviceQueueFamilyProperties(pd_, &qn, qf.data());
qf_ = UINT32_MAX;
for (uint32_t i = 0; i < qn; ++i) if (qf[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) { qf_ = i; break; }
if (qf_ == UINT32_MAX) return fail(err, "no graphics queue");
// Device, with SteamVR's device extensions (external memory: SteamVR copies the image out on our queue).
uint32_t dn = vr::VRCompositor()->GetVulkanDeviceExtensionsRequired(reinterpret_cast<VkPhysicalDevice_T*>(pd_), nullptr, 0);
std::string dext(dn, '\0');
if (dn) vr::VRCompositor()->GetVulkanDeviceExtensionsRequired(reinterpret_cast<VkPhysicalDevice_T*>(pd_), &dext[0], dn);
devExt_ = split(dext.c_str());
uint32_t en = 0;
vkEnumerateDeviceExtensionProperties(pd_, nullptr, &en, nullptr);
std::vector<VkExtensionProperties> dhave(en);
vkEnumerateDeviceExtensionProperties(pd_, nullptr, &en, dhave.data());
std::vector<const char*> de;
for (const std::string& e : devExt_)
if (std::any_of(dhave.begin(), dhave.end(), [&](const VkExtensionProperties& p) { return e == p.extensionName; })) de.push_back(e.c_str());
const float prio = 1.0f;
VkDeviceQueueCreateInfo qci = {VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO};
qci.queueFamilyIndex = qf_;
qci.queueCount = 1;
qci.pQueuePriorities = &prio;
VkDeviceCreateInfo dci = {VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO};
dci.queueCreateInfoCount = 1;
dci.pQueueCreateInfos = &qci;
dci.enabledExtensionCount = static_cast<uint32_t>(de.size());
dci.ppEnabledExtensionNames = de.data();
if (VkResult r = vkCreateDevice(pd_, &dci, nullptr, &dev_); r != VK_SUCCESS) return fail(err, "vkCreateDevice " + std::to_string(r));
vkGetDeviceQueue(dev_, qf_, 0, &queue_);
VkPhysicalDeviceMemoryProperties mp;
vkGetPhysicalDeviceMemoryProperties(pd_, &mp);
VkCommandPoolCreateInfo pci = {VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO};
pci.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
pci.queueFamilyIndex = qf_;
if (vkCreateCommandPool(dev_, &pci, nullptr, &pool_) != VK_SUCCESS) return fail(err, "vkCreateCommandPool");
const VkDeviceSize bytes = static_cast<VkDeviceSize>(w_) * h_ * 4;
for (Slot& s : slots_) {
VkImageCreateInfo ii = {VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO};
ii.imageType = VK_IMAGE_TYPE_2D;
ii.format = format();
ii.extent = {w_, h_, 1};
ii.mipLevels = 1;
ii.arrayLayers = 1;
ii.samples = VK_SAMPLE_COUNT_1_BIT;
ii.tiling = VK_IMAGE_TILING_OPTIMAL;
ii.usage = VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
ii.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED;
if (vkCreateImage(dev_, &ii, nullptr, &s.image) != VK_SUCCESS) return fail(err, "vkCreateImage");
VkMemoryRequirements mr;
vkGetImageMemoryRequirements(dev_, s.image, &mr);
if (!allocate(mp, mr, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, s.imageMem) && !allocate(mp, mr, 0, s.imageMem)) return fail(err, "image memory");
vkBindImageMemory(dev_, s.image, s.imageMem, 0);
VkBufferCreateInfo bi = {VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bi.size = bytes;
bi.usage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT;
if (vkCreateBuffer(dev_, &bi, nullptr, &s.staging) != VK_SUCCESS) return fail(err, "vkCreateBuffer");
vkGetBufferMemoryRequirements(dev_, s.staging, &mr);
if (!allocate(mp, mr, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, s.stagingMem)) return fail(err, "staging memory");
vkBindBufferMemory(dev_, s.staging, s.stagingMem, 0);
if (vkMapMemory(dev_, s.stagingMem, 0, bytes, 0, &s.mapped) != VK_SUCCESS) return fail(err, "vkMapMemory");
VkCommandBufferAllocateInfo ai = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO};
ai.commandPool = pool_;
ai.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
ai.commandBufferCount = 1;
if (vkAllocateCommandBuffers(dev_, &ai, &s.cmd) != VK_SUCCESS) return fail(err, "vkAllocateCommandBuffers");
VkFenceCreateInfo fi = {VK_STRUCTURE_TYPE_FENCE_CREATE_INFO};
fi.flags = VK_FENCE_CREATE_SIGNALED_BIT;
if (vkCreateFence(dev_, &fi, nullptr, &s.fence) != VK_SUCCESS) return fail(err, "vkCreateFence");
}
ok_ = true;
return true;
}
// Copies `rgba` (w x h x 4) into the next image and shows it on `ov`. False: the caller should fall back.
bool upload(vr::VROverlayHandle_t ov, const uint8_t* rgba, std::string& err) {
if (!ok_) return false;
Slot& s = slots_[next_];
next_ = (next_ + 1) % kSlots;
// This slot was last shown two uploads ago: its copy finished long since, but don't assume.
if (vkWaitForFences(dev_, 1, &s.fence, VK_TRUE, 1000000000ull) != VK_SUCCESS) { err = "fence timeout"; return false; }
vkResetFences(dev_, 1, &s.fence);
std::memcpy(s.mapped, rgba, static_cast<size_t>(w_) * h_ * 4);
vkResetCommandBuffer(s.cmd, 0);
VkCommandBufferBeginInfo bi = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
vkBeginCommandBuffer(s.cmd, &bi);
VkImageMemoryBarrier b = {VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
b.image = s.image;
b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
// After SteamVR's copy out of it (on this queue, in TRANSFER_SRC_OPTIMAL) → ready for ours in.
b.oldLayout = s.used ? VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL : VK_IMAGE_LAYOUT_UNDEFINED;
b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
b.srcAccessMask = s.used ? VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_SHADER_READ_BIT : 0;
b.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
vkCmdPipelineBarrier(s.cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
VkBufferImageCopy c = {};
c.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
c.imageExtent = {w_, h_, 1};
vkCmdCopyBufferToImage(s.cmd, s.staging, s.image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &c);
// SteamVR takes Vulkan overlay images in TRANSFER_SRC_OPTIMAL (it copies them out).
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL;
b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
b.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT;
b.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_SHADER_READ_BIT;
vkCmdPipelineBarrier(s.cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, 1, &b);
vkEndCommandBuffer(s.cmd);
VkSubmitInfo si = {VK_STRUCTURE_TYPE_SUBMIT_INFO};
si.commandBufferCount = 1;
si.pCommandBuffers = &s.cmd;
if (VkResult r = vkQueueSubmit(queue_, 1, &si, s.fence); r != VK_SUCCESS) { err = "vkQueueSubmit " + std::to_string(r); return false; }
s.used = true;
// The whole image is in before SteamVR looks at it.
if (vkWaitForFences(dev_, 1, &s.fence, VK_TRUE, 1000000000ull) != VK_SUCCESS) { err = "copy fence timeout"; return false; }
vr::VRVulkanTextureData_t vt = {};
vt.m_nImage = reinterpret_cast<uint64_t>(s.image);
vt.m_pDevice = reinterpret_cast<VkDevice_T*>(dev_);
vt.m_pPhysicalDevice = reinterpret_cast<VkPhysicalDevice_T*>(pd_);
vt.m_pInstance = reinterpret_cast<VkInstance_T*>(inst_);
vt.m_pQueue = reinterpret_cast<VkQueue_T*>(queue_);
vt.m_nQueueFamilyIndex = qf_;
vt.m_nWidth = w_;
vt.m_nHeight = h_;
vt.m_nFormat = format();
vt.m_nSampleCount = 1;
vr::Texture_t tex = {&vt, vr::TextureType_Vulkan, vr::ColorSpace_Auto};
const vr::EVROverlayError oe = vr::VROverlay()->SetOverlayTexture(ov, &tex);
if (oe != vr::VROverlayError_None) { err = std::string("SetOverlayTexture: ") + vr::VROverlay()->GetOverlayErrorNameFromEnum(oe); return false; }
return true;
}
// After VR_Shutdown (vrclient still holds the images until then).
void destroy() {
if (dev_) {
vkDeviceWaitIdle(dev_);
for (Slot& s : slots_) {
if (s.fence) vkDestroyFence(dev_, s.fence, nullptr);
if (s.staging) vkDestroyBuffer(dev_, s.staging, nullptr);
if (s.stagingMem) vkFreeMemory(dev_, s.stagingMem, nullptr);
if (s.image) vkDestroyImage(dev_, s.image, nullptr);
if (s.imageMem) vkFreeMemory(dev_, s.imageMem, nullptr);
s = Slot();
}
if (pool_) vkDestroyCommandPool(dev_, pool_, nullptr);
vkDestroyDevice(dev_, nullptr);
}
if (inst_ && vkDestroyInstance) vkDestroyInstance(inst_, nullptr);
dev_ = VK_NULL_HANDLE; inst_ = VK_NULL_HANDLE; pool_ = VK_NULL_HANDLE;
ok_ = false;
// libvulkan stays loaded: drivers don't like being unloaded under a live process.
}
bool ok() const { return ok_; }
// The canvas holds sRGB-encoded bytes. UNORM with ColorSpace_Auto (= gamma for 8-bit formats) shows them as
// SetOverlayRaw did; FH_SWITCHER_SRGB=1 tries the _SRGB format instead.
static VkFormat format() {
static const VkFormat f = [] { const char* e = std::getenv("FH_SWITCHER_SRGB"); return e && *e == '1' ? VK_FORMAT_R8G8B8A8_SRGB : VK_FORMAT_R8G8B8A8_UNORM; }();
return f;
}
private:
struct Slot {
VkImage image = VK_NULL_HANDLE;
VkDeviceMemory imageMem = VK_NULL_HANDLE;
VkBuffer staging = VK_NULL_HANDLE;
VkDeviceMemory stagingMem = VK_NULL_HANDLE;
void* mapped = nullptr;
VkCommandBuffer cmd = VK_NULL_HANDLE;
VkFence fence = VK_NULL_HANDLE;
bool used = false;
};
static std::vector<std::string> split(const std::string& s) {
std::vector<std::string> out;
std::istringstream in(s);
for (std::string w; in >> w;) out.push_back(w);
return out;
}
static std::string instanceExtensions() {
const uint32_t n = vr::VRCompositor()->GetVulkanInstanceExtensionsRequired(nullptr, 0);
std::string ext(n, '\0');
if (n) vr::VRCompositor()->GetVulkanInstanceExtensionsRequired(&ext[0], n);
return ext.c_str();
}
bool fail(std::string& err, const std::string& what) {
err = what;
destroy();
return false;
}
bool allocate(const VkPhysicalDeviceMemoryProperties& mp, const VkMemoryRequirements& mr, VkMemoryPropertyFlags want, VkDeviceMemory& out) {
for (uint32_t i = 0; i < mp.memoryTypeCount; ++i)
if ((mr.memoryTypeBits & (1u << i)) && (mp.memoryTypes[i].propertyFlags & want) == want) {
VkMemoryAllocateInfo ai = {VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO};
ai.allocationSize = mr.size;
ai.memoryTypeIndex = i;
return vkAllocateMemory(dev_, &ai, nullptr, &out) == VK_SUCCESS;
}
return false;
}
bool loadInstance() {
#define OT_INST(n) n = reinterpret_cast<PFN_##n>(gipa_(inst_, #n)); if (!n) return false
OT_INST(vkDestroyInstance);
OT_INST(vkEnumeratePhysicalDevices);
OT_INST(vkGetPhysicalDeviceQueueFamilyProperties);
OT_INST(vkGetPhysicalDeviceMemoryProperties);
OT_INST(vkEnumerateDeviceExtensionProperties);
OT_INST(vkCreateDevice);
OT_INST(vkDestroyDevice);
OT_INST(vkGetDeviceQueue);
OT_INST(vkDeviceWaitIdle);
OT_INST(vkCreateImage);
OT_INST(vkDestroyImage);
OT_INST(vkGetImageMemoryRequirements);
OT_INST(vkBindImageMemory);
OT_INST(vkCreateBuffer);
OT_INST(vkDestroyBuffer);
OT_INST(vkGetBufferMemoryRequirements);
OT_INST(vkBindBufferMemory);
OT_INST(vkAllocateMemory);
OT_INST(vkFreeMemory);
OT_INST(vkMapMemory);
OT_INST(vkCreateCommandPool);
OT_INST(vkDestroyCommandPool);
OT_INST(vkAllocateCommandBuffers);
OT_INST(vkResetCommandBuffer);
OT_INST(vkBeginCommandBuffer);
OT_INST(vkEndCommandBuffer);
OT_INST(vkCmdPipelineBarrier);
OT_INST(vkCmdCopyBufferToImage);
OT_INST(vkQueueSubmit);
OT_INST(vkCreateFence);
OT_INST(vkDestroyFence);
OT_INST(vkWaitForFences);
OT_INST(vkResetFences);
#undef OT_INST
return true;
}
uint32_t w_ = 0, h_ = 0;
void* lib_ = nullptr;
PFN_vkGetInstanceProcAddr gipa_ = nullptr;
VkInstance inst_ = VK_NULL_HANDLE;
VkPhysicalDevice pd_ = VK_NULL_HANDLE;
VkDevice dev_ = VK_NULL_HANDLE;
VkQueue queue_ = VK_NULL_HANDLE;
uint32_t qf_ = 0;
VkCommandPool pool_ = VK_NULL_HANDLE;
std::vector<std::string> devExt_;
Slot slots_[kSlots];
int next_ = 0;
bool ok_ = false;
PFN_vkCreateInstance vkCreateInstance = nullptr;
PFN_vkEnumerateInstanceExtensionProperties vkEnumerateInstanceExtensionProperties = nullptr;
PFN_vkDestroyInstance vkDestroyInstance = nullptr;
PFN_vkEnumeratePhysicalDevices vkEnumeratePhysicalDevices = nullptr;
PFN_vkGetPhysicalDeviceQueueFamilyProperties vkGetPhysicalDeviceQueueFamilyProperties = nullptr;
PFN_vkGetPhysicalDeviceMemoryProperties vkGetPhysicalDeviceMemoryProperties = nullptr;
PFN_vkEnumerateDeviceExtensionProperties vkEnumerateDeviceExtensionProperties = nullptr;
PFN_vkCreateDevice vkCreateDevice = nullptr;
PFN_vkDestroyDevice vkDestroyDevice = nullptr;
PFN_vkGetDeviceQueue vkGetDeviceQueue = nullptr;
PFN_vkDeviceWaitIdle vkDeviceWaitIdle = nullptr;
PFN_vkCreateImage vkCreateImage = nullptr;
PFN_vkDestroyImage vkDestroyImage = nullptr;
PFN_vkGetImageMemoryRequirements vkGetImageMemoryRequirements = nullptr;
PFN_vkBindImageMemory vkBindImageMemory = nullptr;
PFN_vkCreateBuffer vkCreateBuffer = nullptr;
PFN_vkDestroyBuffer vkDestroyBuffer = nullptr;
PFN_vkGetBufferMemoryRequirements vkGetBufferMemoryRequirements = nullptr;
PFN_vkBindBufferMemory vkBindBufferMemory = nullptr;
PFN_vkAllocateMemory vkAllocateMemory = nullptr;
PFN_vkFreeMemory vkFreeMemory = nullptr;
PFN_vkMapMemory vkMapMemory = nullptr;
PFN_vkCreateCommandPool vkCreateCommandPool = nullptr;
PFN_vkDestroyCommandPool vkDestroyCommandPool = nullptr;
PFN_vkAllocateCommandBuffers vkAllocateCommandBuffers = nullptr;
PFN_vkResetCommandBuffer vkResetCommandBuffer = nullptr;
PFN_vkBeginCommandBuffer vkBeginCommandBuffer = nullptr;
PFN_vkEndCommandBuffer vkEndCommandBuffer = nullptr;
PFN_vkCmdPipelineBarrier vkCmdPipelineBarrier = nullptr;
PFN_vkCmdCopyBufferToImage vkCmdCopyBufferToImage = nullptr;
PFN_vkQueueSubmit vkQueueSubmit = nullptr;
PFN_vkCreateFence vkCreateFence = nullptr;
PFN_vkDestroyFence vkDestroyFence = nullptr;
PFN_vkWaitForFences vkWaitForFences = nullptr;
PFN_vkResetFences vkResetFences = nullptr;
};
+42
View File
@@ -0,0 +1,42 @@
#pragma once
// SteamVR's panels (overlays) of every app. OpenVR can't list them, but a handle is (slot << 32 | serial) with both
// small, so probing that range finds them all; a whole sweep takes about 0.1 s. Used to tell whether a hand points
// at a panel (frame-switcher) and to dock free-floating panels on a home's wall spots (Frame Home).
#include <openvr.h>
#include <cstring>
#include <string>
#include <vector>
struct OverlayInfo {
vr::VROverlayHandle_t handle = 0;
std::string key;
vr::VROverlayTransformType type = vr::VROverlayTransform_Absolute;
};
inline std::vector<OverlayInfo> listOverlays() {
std::vector<OverlayInfo> out;
vr::IVROverlay* ov = vr::VROverlay();
if (!ov) return out;
for (uint64_t slot = 0; slot < 256; ++slot)
for (uint64_t serial = 0; serial < 1024; ++serial) {
const vr::VROverlayHandle_t h = slot << 32 | serial;
char key[vr::k_unVROverlayMaxKeyLength] = {};
vr::EVROverlayError e = vr::VROverlayError_None;
if (!ov->GetOverlayKey(h, key, sizeof key, &e) || e != vr::VROverlayError_None || !key[0]) continue;
OverlayInfo o;
o.handle = h;
o.key = key;
ov->GetOverlayTransformType(h, &o.type);
out.push_back(std::move(o));
}
return out;
}
// Panels a person looks at and points at: not cursors, laser pointers, thumbnails or Frame Home's own.
inline bool isUserPanel(const OverlayInfo& o) {
if (o.type == vr::VROverlayTransform_Cursor || o.type == vr::VROverlayTransform_DashboardThumb) return false;
for (const char* p : {"framehome.", "system.pointer", "system.cursor", "gamescope.", "XRService"})
if (o.key.rfind(p, 0) == 0) return false;
return o.key.find(".thumb") == std::string::npos && o.key.find("cursor") == std::string::npos;
}
+177
View File
@@ -0,0 +1,177 @@
#pragma once
// Wall spots at work: a SteamVR panel (a desktop or game window, any free-floating overlay) let go of near one of
// the home's wall spots docks onto it — flush with the wall, facing the room, no wider than the spot — and stays
// there as you walk and teleport (panels live in tracking space, so a docked one is re-placed from the home's frame
// whenever you move). Grab it away and it's free again, back at its own size. Docked panels are re-placed only when
// the rig moves: setting one every frame would overwrite a grab before it could be seen.
// Meta's homes mark these spots with WallPlacementComponent; Frame Studio edits them (World > Wall Spot).
#include "core/world.h"
#include "overlays.h"
#include <openvr.h>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <map>
#include <mutex>
#include <thread>
#include <vector>
class PanelDock {
public:
~PanelDock() { stop(); }
// Before VR_Shutdown: the scanner calls into SteamVR's client library, which shutting down unloads.
void stop() { stop_ = true; if (scanner_.joinable()) scanner_.join(); }
void setWorld(const HomeWorld& w) {
undockAll();
spots_ = w.wallSpots;
if (!spots_.empty() && !scanner_.joinable()) scanner_ = std::thread([this] { scanLoop(); });
}
// Once per frame with worldFromTracking.
void update(const float rig[16]) {
if (spots_.empty()) return;
std::vector<OverlayInfo> panels;
{ std::lock_guard<std::mutex> l(mutex_); panels = panels_; }
float inv[16];
invertRigid(rig, inv);
const auto now = std::chrono::steady_clock::now();
vr::IVROverlay* ov = vr::VROverlay();
// Panels whose app went away: their state (and any spot they held) goes too.
for (auto it = state_.begin(); it != state_.end();) {
bool present = false;
for (const OverlayInfo& p : panels) present |= p.handle == it->first;
it = present ? std::next(it) : state_.erase(it);
}
for (const OverlayInfo& p : panels) {
if (!ov->IsOverlayVisible(p.handle)) continue;
vr::VROverlayTransformType tt;
if (ov->GetOverlayTransformType(p.handle, &tt) != vr::VROverlayError_None || tt != vr::VROverlayTransform_Absolute) continue;
vr::ETrackingUniverseOrigin origin;
vr::HmdMatrix34_t cur;
if (ov->GetOverlayTransformAbsolute(p.handle, &origin, &cur) != vr::VROverlayError_None) continue;
if (origin != vr::TrackingUniverseStanding) continue; // the spots are placed in standing space
State& s = state_[p.handle];
if (s.spot >= 0) {
// Docked: still where we put it? Then keep it on the wall as the rig moves; else it was grabbed away.
if (differs(cur, s.placed, 0.03f)) { undock(p.handle, s, "moved away"); s.still = now; s.last = cur; continue; }
// Re-placed only once the rig has moved a few millimetres since it was placed (per panel, so one
// hidden while you teleported is put right when it shows again). Tiny continuous rig changes —
// seated eye-height easing, step easing — would otherwise overwrite a grab every frame.
float moved = 0;
for (int k = 0; k < 16; ++k) moved = std::max(moved, std::fabs(rig[k] - s.rig[k]));
if (moved < 0.005f) continue;
vr::HmdMatrix34_t m = spotInTracking(s.spot, inv, s.flip);
ov->SetOverlayTransformAbsolute(p.handle, origin, &m);
s.placed = m;
std::memcpy(s.rig, rig, sizeof s.rig);
continue;
}
if (differs(cur, s.last, 0.002f)) { s.last = cur; s.still = now; continue; } // still moving
if (now - s.still < std::chrono::milliseconds(500) || s.cooldown > now) continue;
// At rest: dock onto the nearest free spot within 0.6 m.
float pt[3];
for (int k = 0; k < 3; ++k) pt[k] = rig[k] * cur.m[0][3] + rig[4 + k] * cur.m[1][3] + rig[8 + k] * cur.m[2][3] + rig[12 + k];
int best = -1;
float bestD = 0.6f;
for (size_t i = 0; i < spots_.size(); ++i) {
if (taken(static_cast<int>(i))) continue;
const float* c = &spots_[i].world[12];
const float d = std::sqrt((pt[0] - c[0]) * (pt[0] - c[0]) + (pt[1] - c[1]) * (pt[1] - c[1]) + (pt[2] - c[2]) * (pt[2] - c[2]));
if (d < bestD) { bestD = d; best = static_cast<int>(i); }
}
if (best < 0) { s.cooldown = now + std::chrono::milliseconds(500); continue; }
// Face the panel the way it was facing (towards the user), whichever way the spot's frame points.
const float* z = &spots_[best].world[8];
float pz[3];
for (int k = 0; k < 3; ++k) pz[k] = rig[k] * cur.m[0][2] + rig[4 + k] * cur.m[1][2] + rig[8 + k] * cur.m[2][2];
s.flip = z[0] * pz[0] + z[1] * pz[1] + z[2] * pz[2] < 0;
ov->GetOverlayWidthInMeters(p.handle, &s.width);
const float fit = std::min(s.width, spots_[best].width);
ov->SetOverlayWidthInMeters(p.handle, fit);
vr::HmdMatrix34_t m = spotInTracking(best, inv, s.flip);
ov->SetOverlayTransformAbsolute(p.handle, origin, &m);
s.placed = m;
std::memcpy(s.rig, rig, sizeof s.rig);
s.spot = best;
std::fprintf(stderr, "frame-home: docked panel %s on wall spot %s\n", p.key.c_str(), spots_[best].name.c_str());
}
}
private:
struct State {
int spot = -1;
bool flip = false;
float width = 1; // its own width, given back when it leaves the wall
vr::HmdMatrix34_t placed = {}, last = {};
float rig[16] = {}; // the rig it was last placed for
std::chrono::steady_clock::time_point still{}, cooldown{};
};
std::vector<WorldWallSpot> spots_;
std::map<vr::VROverlayHandle_t, State> state_;
std::vector<OverlayInfo> panels_;
std::mutex mutex_;
std::thread scanner_;
std::atomic<bool> stop_{false};
// The panel list, refreshed every 2 s off the frame loop (a sweep takes ~0.1 s).
void scanLoop() {
while (!stop_) {
std::vector<OverlayInfo> found;
for (const OverlayInfo& o : listOverlays())
if (isUserPanel(o) && o.key.rfind("system.", 0) != 0) found.push_back(o);
{ std::lock_guard<std::mutex> l(mutex_); panels_ = std::move(found); }
for (int i = 0; i < 20 && !stop_; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
bool taken(int spot) const {
for (const auto& kv : state_) if (kv.second.spot == spot) return true;
return false;
}
void undock(vr::VROverlayHandle_t h, State& s, const char* why) {
vr::VROverlay()->SetOverlayWidthInMeters(h, s.width);
std::fprintf(stderr, "frame-home: panel left wall spot %s (%s)\n", spots_[s.spot].name.c_str(), why);
s.spot = -1;
s.cooldown = std::chrono::steady_clock::now() + std::chrono::seconds(2);
}
void undockAll() {
for (auto& kv : state_) if (kv.second.spot >= 0) undock(kv.first, kv.second, "new home");
state_.clear();
}
// The spot's frame in tracking space, 1 cm off the wall, optionally turned to face the other way.
vr::HmdMatrix34_t spotInTracking(int i, const float inv[16], bool flip) const {
float w[16];
std::memcpy(w, spots_[i].world, 64);
for (int c = 0; c < 3; ++c) { // unit axes (a scaled spot frame would scale the panel)
const float L = std::sqrt(w[c * 4] * w[c * 4] + w[c * 4 + 1] * w[c * 4 + 1] + w[c * 4 + 2] * w[c * 4 + 2]);
if (L > 1e-6f) for (int r = 0; r < 3; ++r) w[c * 4 + r] /= L;
}
if (flip) for (int r = 0; r < 3; ++r) { w[r] = -w[r]; w[8 + r] = -w[8 + r]; } // 180 degrees about Y
for (int r = 0; r < 3; ++r) w[12 + r] += w[8 + r] * 0.01f;
float t[16];
for (int c = 0; c < 4; ++c)
for (int r = 0; r < 4; ++r) t[c * 4 + r] = inv[r] * w[c * 4] + inv[4 + r] * w[c * 4 + 1] + inv[8 + r] * w[c * 4 + 2] + inv[12 + r] * w[c * 4 + 3];
vr::HmdMatrix34_t m;
for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) m.m[r][c] = t[c * 4 + r];
return m;
}
static void invertRigid(const float m[16], float o[16]) {
for (int r = 0; r < 3; ++r) for (int c = 0; c < 3; ++c) o[c * 4 + r] = m[r * 4 + c];
for (int r = 0; r < 3; ++r) o[12 + r] = -(o[r] * m[12] + o[4 + r] * m[13] + o[8 + r] * m[14]);
o[3] = o[7] = o[11] = 0; o[15] = 1;
}
static bool differs(const vr::HmdMatrix34_t& a, const vr::HmdMatrix34_t& b, float eps) {
for (int r = 0; r < 3; ++r) for (int c = 0; c < 4; ++c) if (std::fabs(a.m[r][c] - b.m[r][c]) > eps) return true;
return false;
}
};
+156
View File
@@ -0,0 +1,156 @@
#include "panorama.h"
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <thread>
namespace {
constexpr float kPi = 3.14159265358979f;
// Camera-to-world rotation for a view yawed about +Y then pitched about +X (looks down -Z at 0, 0).
void basis(float yaw, float pitch, float right[3], float up[3], float back[3]) {
const float cy = std::cos(yaw), sy = std::sin(yaw), cp = std::cos(pitch), sp = std::sin(pitch);
right[0] = cy; right[1] = 0; right[2] = -sy;
up[0] = sy * sp; up[1] = cp; up[2] = cy * sp;
back[0] = sy * cp; back[1] = -sp; back[2] = cy * cp;
}
struct Face {
float right[3], up[3], back[3];
std::vector<u8> rgba;
};
} // namespace
bool renderPanorama(HslRenderer& r, const PanoramaView& view, uint32_t width, std::vector<u8>& rgb) {
const uint32_t w = std::max<uint32_t>(64, width & ~1u), h = w / 2;
// A face spans 90 degrees = w/4 equirect pixels; a little more keeps it sharp after resampling.
const uint32_t n = std::min<uint32_t>(4096, (w / 4) * 5 / 4);
r.resize(n, n);
// Reversed-Z infinite projection with a 90-degree square frustum (what the renderer expects).
Mat4 proj = {};
proj.m[0] = 1.0f;
proj.m[5] = -1.0f;
proj.m[14] = 0.05f;
proj.m[11] = -1.0f;
const float faces[6][2] = {{0, 0}, {kPi * 0.5f, 0}, {kPi, 0}, {-kPi * 0.5f, 0}, {0, kPi * 0.5f}, {0, -kPi * 0.5f}};
Face f[6];
for (int i = 0; i < 6; ++i) {
basis(faces[i][0], faces[i][1], f[i].right, f[i].up, f[i].back);
EyeView e;
e.proj = proj;
// view = inverse(rotation | eye): rows are the basis vectors, translation -R^T * eye.
Mat4& v = e.view;
std::memset(v.m, 0, sizeof v.m);
for (int k = 0; k < 3; ++k) {
v.m[k * 4 + 0] = f[i].right[k];
v.m[k * 4 + 1] = f[i].up[k];
v.m[k * 4 + 2] = f[i].back[k];
}
auto dot = [&](const float* a) { return a[0] * view.eye[0] + a[1] * view.eye[1] + a[2] * view.eye[2]; };
v.m[12] = -dot(f[i].right);
v.m[13] = -dot(f[i].up);
v.m[14] = -dot(f[i].back);
v.m[15] = 1;
std::memcpy(e.position, view.eye, 12);
EyeView eyes[2] = {e, e};
r.render(eyes, 0.0f);
if (!r.readback(0, f[i].rgba)) return false;
}
rgb.assign(static_cast<size_t>(w) * h * 3, 0);
const float yaw = view.yawDeg * kPi / 180.0f;
auto rows = [&](uint32_t y0, uint32_t y1) {
for (uint32_t y = y0; y < y1; ++y) {
const float lat = (0.5f - (y + 0.5f) / h) * kPi;
const float cl = std::cos(lat), sl = std::sin(lat);
for (uint32_t x = 0; x < w; ++x) {
const float lon = ((x + 0.5f) / w - 0.5f) * 2.0f * kPi - yaw;
const float d[3] = {std::sin(lon) * cl, sl, -std::cos(lon) * cl};
// The face this direction points into, and where it lands on that face's image.
int best = 0;
float bx = 0, by = 0, bz = -1e9f;
for (int i = 0; i < 6; ++i) {
const float vx = d[0] * f[i].right[0] + d[1] * f[i].right[1] + d[2] * f[i].right[2];
const float vy = d[0] * f[i].up[0] + d[1] * f[i].up[1] + d[2] * f[i].up[2];
const float vz = -(d[0] * f[i].back[0] + d[1] * f[i].back[1] + d[2] * f[i].back[2]);
if (vz > bz) { bz = vz; bx = vx; by = vy; best = i; }
}
const float px = (bx / bz * 0.5f + 0.5f) * n - 0.5f, py = (0.5f - by / bz * 0.5f) * n - 0.5f;
const int x0 = std::clamp(static_cast<int>(std::floor(px)), 0, static_cast<int>(n) - 1);
const int y0i = std::clamp(static_cast<int>(std::floor(py)), 0, static_cast<int>(n) - 1);
const int x1 = std::min(x0 + 1, static_cast<int>(n) - 1), y1i = std::min(y0i + 1, static_cast<int>(n) - 1);
const float fx = std::clamp(px - x0, 0.0f, 1.0f), fy = std::clamp(py - y0i, 0.0f, 1.0f);
const u8* img = f[best].rgba.data();
u8* out = &rgb[(static_cast<size_t>(y) * w + x) * 3];
for (int c = 0; c < 3; ++c) {
const float a = img[(y0i * n + x0) * 4 + c], b = img[(y0i * n + x1) * 4 + c];
const float cc = img[(y1i * n + x0) * 4 + c], dd = img[(y1i * n + x1) * 4 + c];
out[c] = static_cast<u8>(std::lround((a + (b - a) * fx) * (1 - fy) + (cc + (dd - cc) * fx) * fy));
}
}
}
};
const uint32_t threads = std::max(1u, std::min(16u, std::thread::hardware_concurrency()));
std::vector<std::thread> pool;
for (uint32_t t = 0; t < threads; ++t) pool.emplace_back(rows, h * t / threads, h * (t + 1) / threads);
for (auto& t : pool) t.join();
return true;
}
float panoramaDomeRadius(const HslRenderer& r, const HslScene& scene, const float eye[3], float fallback) {
constexpr int kRays = 72;
std::vector<float> nearest(kRays, 1e30f);
for (size_t m = 0; m < r.meshCount(); ++m) {
if (!r.meshVisible(m)) continue;
const MeshData& md = scene.meshes[r.meshSceneIndex(m)];
if (md.isSkybox) continue;
const float* W = r.meshWorld(m);
const size_t nv = md.positions.size() / 3;
std::vector<float> P(nv * 3);
for (size_t v = 0; v < nv; ++v)
for (int k = 0; k < 3; ++k)
P[v * 3 + k] = W[k] * md.positions[v * 3] + W[4 + k] * md.positions[v * 3 + 1] + W[8 + k] * md.positions[v * 3 + 2] + W[12 + k];
for (size_t t = 0; t + 2 < md.indices.size(); t += 3) {
const u32 ia = md.indices[t], ib = md.indices[t + 1], ic = md.indices[t + 2];
if (ia >= nv || ib >= nv || ic >= nv) continue;
const float* a = &P[ia * 3]; const float* b = &P[ib * 3]; const float* c = &P[ic * 3];
// Only triangles spanning eye height can be hit by horizontal rays.
if (std::min({a[1], b[1], c[1]}) > eye[1] || std::max({a[1], b[1], c[1]}) < eye[1]) continue;
const float e1[3] = {b[0] - a[0], b[1] - a[1], b[2] - a[2]}, e2[3] = {c[0] - a[0], c[1] - a[1], c[2] - a[2]};
const float tv[3] = {eye[0] - a[0], eye[1] - a[1], eye[2] - a[2]};
for (int i = 0; i < kRays; ++i) {
const float ang = 2.0f * kPi * i / kRays;
const float d[3] = {std::sin(ang), 0, -std::cos(ang)};
const float p[3] = {d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]};
const float det = e1[0] * p[0] + e1[1] * p[1] + e1[2] * p[2];
if (std::fabs(det) < 1e-12f) continue;
const float id = 1.0f / det;
const float u = (tv[0] * p[0] + tv[1] * p[1] + tv[2] * p[2]) * id;
if (u < 0 || u > 1) continue;
const float q[3] = {tv[1] * e1[2] - tv[2] * e1[1], tv[2] * e1[0] - tv[0] * e1[2], tv[0] * e1[1] - tv[1] * e1[0]};
const float v = (d[0] * q[0] + d[1] * q[1] + d[2] * q[2]) * id;
if (v < 0 || u + v > 1) continue;
const float dist = (e2[0] * q[0] + e2[1] * q[1] + e2[2] * q[2]) * id;
if (dist > 0.05f && dist < nearest[i]) nearest[i] = dist;
}
}
}
std::vector<float> hits;
for (float d : nearest) if (d < 500.0f) hits.push_back(d);
if (hits.size() < kRays / 4) return fallback;
std::nth_element(hits.begin(), hits.begin() + hits.size() / 2, hits.end());
return hits[hits.size() / 2];
}
bool writePng(const std::string& path, const std::vector<u8>& rgb, uint32_t w, uint32_t h) {
stbi_write_png_compression_level = 6;
return stbi_write_png(path.c_str(), static_cast<int>(w), static_cast<int>(h), 3, rgb.data(), static_cast<int>(w * 3)) != 0;
}
+30
View File
@@ -0,0 +1,30 @@
#pragma once
// Homes as the Steam Frame's native environment.
//
// SteamVR's compositor draws its environment itself from an equirectangular background image
// (8192x4096, like its own night_mountains.png), optionally dome-projected: the image is projected
// from the viewer's eye height onto a sphere of a given radius with a flat floor, so the floor and the
// room's walls appear at roughly the right distance. No app runs; the compositor owns it.
//
// A home is rendered with its own Meta shaders into six 90-degree faces around the viewer and
// resampled into that layout. The image centre is the direction `yawDeg` faces (0 = -Z, the home's
// forward), with the image's right-hand side to the viewer's right.
#include "hsl_renderer.h"
#include "hsl_scene.h"
#include <string>
#include <vector>
struct PanoramaView {
float eye[3] = {0, 1.6f, 0}; // where the viewer stands (eye height above the home's floor)
float yawDeg = 0; // direction that becomes the image centre
};
// Renders width x width/2 RGB8 (sRGB). Leaves the renderer resized to the face size.
bool renderPanorama(HslRenderer& r, const PanoramaView& view, uint32_t width, std::vector<u8>& rgb);
// Median horizontal distance from `eye` to the home's visible geometry: the dome radius that puts the
// projected walls where they are. Falls back to `fallback` when nothing is hit (open outdoor homes).
float panoramaDomeRadius(const HslRenderer& r, const HslScene& scene, const float eye[3], float fallback = 10.0f);
bool writePng(const std::string& path, const std::vector<u8>& rgb, uint32_t w, uint32_t h);
+159
View File
@@ -0,0 +1,159 @@
#pragma once
// Frame Home's performance and quality settings: ~/frame-home/settings.json on the Steam Frame. Frame Home reads it
// when it starts and again whenever it changes; the Environments panel (Settings) and Frame Studio (Frame > Headset
// Settings) edit it. Frame Home writes how it's doing to stats.json beside it for those two to show.
#include "core/tinyjson.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <string>
struct PerfSettings {
// Resolution, as a share of SteamVR's recommended size per eye. Auto keeps each frame inside the display's time
// budget by moving it between 70% and 150%: sharper whenever the home is light enough.
bool autoResolution = true;
float resolution = 1.0f;
int msaa = 4; // anti-aliasing samples: 1 (off), 2, 4
int anisotropy = 8; // texture filtering: 4, 8, 16
// Foveated rendering: full detail where you look, coarser toward the edges, and the time that frees goes to
// resolution (with Auto). 0 off, 1 fixed (the lens centre), 2 eye tracked (follows your gaze).
int foveation = 2;
int foveationLevel = 2; // 1 gentle, 2 medium, 3 strong
// Moving around (applies live).
int walk = 1; // left stick: 0 off, 1 smooth walking
int teleport = 1; // right stick forward: 0 off, 1 instant (a quick blink), 2 slide (glide there)
int turn = 0; // right stick left / right: 0 snap, 1 smooth
int snapAngle = 30; // 30, 45, 90 degrees
float moveSpeed = 2.0f; // walking speed, m/s: 1.4 slow, 2 normal, 3 fast
int vignette = 0; // comfort: darken the edges of view while walking or turning smoothly: 0 off, 1 gentle, 2 strong
int seatMarkers = 1; // the seats near you shown as markers: 0 hidden (pointing still finds them), 1 shown
// The home itself (applies live).
float volume = 1.0f; // all of the home's sound: 0 .. 1
int animations = 1; // 0: the home holds still (animated homes cost less), 1 plays
int menuRate = 1; // behind the Steam menu: 0 half rate (the GPU goes to the menu and its apps), 1 full
// The environment's own frame rate: 0 the display's, 1 half (drawn every other frame and turned with your head in
// between by the compositor layer, as Meta's home runs: each frame gets twice the GPU time).
int frameRate = 0;
// The headset's temperature to stay at or under, degrees C (0: no limit). Above it the edges of view get coarser,
// a little at a time, until it's back (where you look stays sharp). 86 is the Steam Frame's own resting level with
// SteamVR running; past the mid-90s it slows its GPU to a third.
int tempLimit = 86;
static constexpr float kAutoMin = 0.7f, kAutoMax = 1.5f;
std::string toJson() const {
char b[640];
std::snprintf(b, sizeof b,
"{\"autoResolution\":%s,\"resolution\":%.2f,\"msaa\":%d,\"anisotropy\":%d,\"foveation\":%d,\"foveationLevel\":%d,"
"\"walk\":%d,\"teleport\":%d,\"turn\":%d,\"snapAngle\":%d,\"moveSpeed\":%.1f,\"vignette\":%d,\"seatMarkers\":%d,"
"\"volume\":%.2f,\"animations\":%d,\"menuRate\":%d,\"frameRate\":%d,\"tempLimit\":%d}\n",
autoResolution ? "true" : "false", resolution, msaa, anisotropy, foveation, foveationLevel, walk, teleport, turn, snapAngle, moveSpeed,
vignette, seatMarkers, volume, animations, menuRate, frameRate, tempLimit);
return b;
}
bool fromJson(const std::string& j) {
// The parser takes a file cut short ("{\"msaa\":2,") as a whole object: a settings file must end its object.
const size_t last = j.find_last_not_of(" \t\r\n");
if (last == std::string::npos || j[last] != '}') return false;
try {
const tinyjson::Value d = tinyjson::parse(j);
if (!d.isObject()) return false; // empty, cut short, or not settings
if (d.has("autoResolution")) autoResolution = d["autoResolution"].boolVal;
if (d.has("resolution")) resolution = static_cast<float>(d["resolution"].asFloat());
if (d.has("msaa")) msaa = static_cast<int>(d["msaa"].asFloat());
if (d.has("anisotropy")) anisotropy = static_cast<int>(d["anisotropy"].asFloat());
if (d.has("foveation")) foveation = static_cast<int>(d["foveation"].asFloat());
if (d.has("foveationLevel")) foveationLevel = static_cast<int>(d["foveationLevel"].asFloat());
if (d.has("walk")) walk = static_cast<int>(d["walk"].asFloat());
if (d.has("teleport")) teleport = static_cast<int>(d["teleport"].asFloat());
if (d.has("turn")) turn = static_cast<int>(d["turn"].asFloat());
if (d.has("snapAngle")) snapAngle = static_cast<int>(d["snapAngle"].asFloat());
if (d.has("moveSpeed")) moveSpeed = static_cast<float>(d["moveSpeed"].asFloat());
if (d.has("vignette")) vignette = static_cast<int>(d["vignette"].asFloat());
if (d.has("seatMarkers")) seatMarkers = static_cast<int>(d["seatMarkers"].asFloat());
if (d.has("volume")) volume = static_cast<float>(d["volume"].asFloat());
if (d.has("animations")) animations = static_cast<int>(d["animations"].asFloat());
if (d.has("menuRate")) menuRate = static_cast<int>(d["menuRate"].asFloat());
if (d.has("frameRate")) frameRate = static_cast<int>(d["frameRate"].asFloat());
if (d.has("tempLimit")) tempLimit = static_cast<int>(d["tempLimit"].asFloat());
} catch (...) {
return false;
}
clamp();
return true;
}
void clamp() {
resolution = std::clamp(resolution, 0.5f, 2.0f);
msaa = msaa >= 4 ? 4 : msaa >= 2 ? 2 : 1;
anisotropy = anisotropy >= 16 ? 16 : anisotropy >= 8 ? 8 : 4;
foveation = std::clamp(foveation, 0, 2);
foveationLevel = std::clamp(foveationLevel, 1, 3);
walk = std::clamp(walk, 0, 1);
teleport = std::clamp(teleport, 0, 2);
turn = std::clamp(turn, 0, 1);
snapAngle = snapAngle >= 90 ? 90 : snapAngle >= 45 ? 45 : 30;
if (!std::isfinite(moveSpeed)) moveSpeed = 2.0f;
moveSpeed = std::clamp(moveSpeed, 0.5f, 4.0f);
vignette = std::clamp(vignette, 0, 2);
seatMarkers = std::clamp(seatMarkers, 0, 1);
if (!std::isfinite(volume)) volume = 1.0f;
volume = std::clamp(volume, 0.0f, 1.0f);
animations = std::clamp(animations, 0, 1);
menuRate = std::clamp(menuRate, 0, 1);
frameRate = std::clamp(frameRate, 0, 1);
tempLimit = tempLimit <= 0 ? 0 : std::clamp(tempLimit, 70, 95);
}
// What changing from `o` to this needs: true when Frame Home must restart (render targets, samplers and the
// render pass are built for these); resolution and foveation strength apply live.
bool needsRestart(const PerfSettings& o) const {
return msaa != o.msaa || anisotropy != o.anisotropy || (foveation > 0) != (o.foveation > 0);
}
bool operator==(const PerfSettings& o) const {
return autoResolution == o.autoResolution && std::abs(resolution - o.resolution) < 1e-3f && msaa == o.msaa &&
anisotropy == o.anisotropy && foveation == o.foveation && foveationLevel == o.foveationLevel && walk == o.walk &&
teleport == o.teleport && turn == o.turn && snapAngle == o.snapAngle && std::abs(moveSpeed - o.moveSpeed) < 1e-3f &&
vignette == o.vignette && seatMarkers == o.seatMarkers && std::abs(volume - o.volume) < 1e-3f && animations == o.animations &&
menuRate == o.menuRate && frameRate == o.frameRate && tempLimit == o.tempLimit;
}
};
// Frame Home's debug tools: ~/frame-home/debug.json, beside settings.json. The Environments panel's Debug page and
// Frame Studio (Frame > Headset Debug) edit it and Frame Home applies it within a second. They're for finding out
// why a home looks or runs wrong, not for keeping: Frame Home clears the file each time it starts.
struct DebugSettings {
int view = 0; // HslRenderer::DebugView: 0 normal, 1 wireframe, 2 meshes, 3 GPU cost, 4 overdraw
int world = 0; // the world layer drawn over the home: walkable area, surfaces, spawns, seats, wall spots
int hideSeeThrough = 0, hideCutout = 0, hideSky = 0; // leave those parts of the home out
int shot = 0; // asks for a picture of what's shown (logs/frame-<time>.png): the time it was asked
// (Unix seconds); one from before Frame Home started is old
bool any() const { return view || world || hideSeeThrough || hideCutout || hideSky; }
std::string toJson() const {
char b[256];
std::snprintf(b, sizeof b, "{\"view\":%d,\"world\":%d,\"hideSeeThrough\":%d,\"hideCutout\":%d,\"hideSky\":%d,\"shot\":%d}\n",
view, world, hideSeeThrough, hideCutout, hideSky, shot);
return b;
}
bool fromJson(const std::string& j) {
const size_t last = j.find_last_not_of(" \t\r\n");
if (last == std::string::npos || j[last] != '}') return false;
try {
const tinyjson::Value d = tinyjson::parse(j);
if (!d.isObject()) return false;
auto get = [&](const char* k, int& v) { if (d.has(k)) v = static_cast<int>(d[k].asFloat()); };
get("view", view); get("world", world); get("hideSeeThrough", hideSeeThrough); get("hideCutout", hideCutout);
get("hideSky", hideSky); get("shot", shot);
} catch (...) {
return false;
}
view = std::clamp(view, 0, 4);
for (int* v : {&world, &hideSeeThrough, &hideCutout, &hideSky}) *v = std::clamp(*v, 0, 1);
return true;
}
unsigned hideBits() const { return (hideSeeThrough ? 1u : 0u) | (hideCutout ? 2u : 0u) | (hideSky ? 4u : 0u); } // HslRenderer::DebugHide
bool operator==(const DebugSettings& o) const {
return view == o.view && world == o.world && hideSeeThrough == o.hideSeeThrough && hideCutout == o.hideCutout && hideSky == o.hideSky &&
shot == o.shot;
}
};
+63
View File
@@ -0,0 +1,63 @@
#pragma once
// Running copies of a Frame program, for the installers. SteamVR's own bookkeeping (GetApplicationProcessId) loses
// track of an app once a utility process started from the same binary has connected, so look at /proc instead.
#include <signal.h>
#include <unistd.h>
#include <filesystem>
#include <fstream>
#include <iterator>
#include <string>
#include <vector>
// Processes named `name` running without arguments: the environment / the panel, not tools (--snapshot, --list).
inline std::vector<pid_t> runningCopies(const char* name) {
std::vector<pid_t> out;
std::error_code ec;
for (const auto& e : std::filesystem::directory_iterator("/proc", ec)) {
const std::string id = e.path().filename().string();
if (id.empty() || id.find_first_not_of("0123456789") != std::string::npos) continue;
const pid_t pid = static_cast<pid_t>(std::stol(id));
if (pid == getpid()) continue;
std::string comm;
std::getline(std::ifstream(e.path() / "comm"), comm);
if (comm != name) continue;
std::ifstream c(e.path() / "cmdline", std::ios::binary);
const std::string cmd((std::istreambuf_iterator<char>(c)), std::istreambuf_iterator<char>());
size_t args = 0;
for (char ch : cmd) args += ch == '\0';
if (args <= 1) out.push_back(pid);
}
return out;
}
// Asks them to quit, then insists after 5 s.
// Running copies of `name` (other than this process) started with `arg` among their arguments.
inline std::vector<pid_t> runningWith(const char* name, const std::string& arg) {
std::vector<pid_t> out;
std::error_code ec;
for (const auto& e : std::filesystem::directory_iterator("/proc", ec)) {
const std::string id = e.path().filename().string();
if (id.empty() || id.find_first_not_of("0123456789") != std::string::npos) continue;
const pid_t pid = static_cast<pid_t>(std::stol(id));
if (pid == getpid()) continue;
std::string comm;
std::getline(std::ifstream(e.path() / "comm"), comm);
if (comm != name) continue;
std::ifstream c(e.path() / "cmdline", std::ios::binary);
for (std::string a; std::getline(c, a, '\0');) if (a == arg) { out.push_back(pid); break; }
}
return out;
}
inline void stopCopies(const char* name) {
const std::vector<pid_t> pids = runningCopies(name);
for (pid_t p : pids) kill(p, SIGTERM);
for (int i = 0; i < 50; ++i) {
bool alive = false;
for (pid_t p : pids) alive |= kill(p, 0) == 0;
if (!alive) return;
usleep(100000);
}
for (pid_t p : pids) kill(p, SIGKILL);
}
+487
View File
@@ -0,0 +1,487 @@
#include "scene_bake.h"
#include "visibility_bake.h"
#include "surface_mesh.h"
#include "walkgrid.h"
#include "tex_mips.h"
#include "tex_cache.h"
#include "core/parallel.h"
#include "shader_bake.h"
#include "bake_io.h"
#include "anim_bake.h"
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <algorithm>
#include <cstring>
#include <map>
#include <memory>
#include <mutex>
namespace {
using bakeio::Writer;
using bakeio::Reader;
// Shared buffers (one texture chain serves many meshes) are written once, then referenced by index.
template <typename T> struct Table {
std::map<const T*, u32> index;
std::vector<const T*> items;
u32 ref(const std::shared_ptr<const T>& p) {
if (!p) return 0xFFFFFFFFu;
auto it = index.find(p.get());
if (it != index.end()) return it->second;
index[p.get()] = static_cast<u32>(items.size());
items.push_back(p.get());
return static_cast<u32>(items.size() - 1);
}
};
void writeMesh(Writer& w, const MeshData& m, Table<TexSource>& tex, Table<std::vector<u16>>& half, const std::vector<u32>* indices = nullptr) {
w.str(m.name);
w.str(m.path);
w.u(static_cast<u32>(m.components.size()));
for (const EnvComponent& c : m.components) {
w.str(c.cls); w.str(c.shortCls); w.pod(c.version);
w.u(static_cast<u32>(c.fields.size()));
for (const auto& kv : c.fields) { w.str(kv.first); w.str(kv.second); }
}
w.b(m.isSkybox);
w.vec(m.positions); w.vec(m.uvs); w.vec(m.colors); w.vec(m.uvs3); w.vec(m.uvs4); w.vec(indices ? *indices : m.indices);
w.u(m.nVerts); w.u(indices ? static_cast<u32>(indices->size()) : m.nIdx);
w.vec(m.texRGBA); w.u(m.texW); w.u(m.texH); w.b(m.hasTexture); w.pod(m.texShareSrc);
w.vec(m.srcAstc); w.u(m.srcAstcBw); w.u(m.srcAstcBh); w.u(m.srcAstcMips);
w.vec(m.normalRGBA); w.u(m.normalW); w.u(m.normalH); w.b(m.hasNormal);
w.vec(m.ormRGBA); w.u(m.ormW); w.u(m.ormH); w.b(m.hasOrm);
w.str(m.ormTexName);
w.vec(m.emissiveRGBA); w.u(m.emissiveW); w.u(m.emissiveH); w.b(m.hasEmissive);
w.vec(m.lmRGBA); w.u(m.lmW); w.u(m.lmH); w.b(m.hasLightmap);
w.u(half.ref(m.lmHalf)); w.u(tex.ref(m.lmSrc)); w.u(tex.ref(m.lmLdrSrc));
w.vec(m.cubeRGBA); w.u(m.cubeSize); w.u(tex.ref(m.cubeSrc));
w.u(tex.ref(m.texSrc)); w.u(tex.ref(m.normalSrc)); w.u(tex.ref(m.ormSrc)); w.u(tex.ref(m.emissiveSrc));
w.u(static_cast<u32>(m.namedTex.size()));
for (const auto& nt : m.namedTex) { w.u(nt.first); w.u(tex.ref(nt.second)); }
w.u(static_cast<u32>(m.namedHdr.size()));
for (const auto& nh : m.namedHdr) { w.u(nh.first); w.u(half.ref(nh.second.half)); w.u(nh.second.w); w.u(nh.second.h); w.u(tex.ref(nh.second.src)); }
w.vec(m.reflCubeHalf); w.u(m.reflCubeSize); w.u(m.reflCubeMips); w.u(tex.ref(m.reflCubeSrc));
w.fa(m.lmUvScaleOffset, 4);
w.vec(m.vatRaw); w.u(m.vatW); w.u(m.vatH); w.b(m.hasVat);
w.fa(m.tint, 4);
w.f(m.atlasCellIndex); w.f(m.vatTrackIndex); w.f(m.vatRateFactor); w.f(m.vatTimeOffset);
w.u(static_cast<u32>(m.matOverrides.size()));
for (const MatOverride& o : m.matOverrides) { w.str(o.name); w.fa(o.v, 4); }
w.pod(m.shaderIng); w.str(m.shaderPath); w.str(m.meshPath); w.b(m.tiled);
w.vec(m.matParamsBlob); w.vec(m.constParams); w.vec(m.texSlots);
w.vec(m.astcRaw); w.u(m.astcBw); w.u(m.astcBh);
w.pod(m.transform);
w.fa(m.worldMatrix, 16); w.b(m.hasWorldMatrix);
w.vec(m.boneIndices); w.vec(m.boneWeights); w.b(m.hasBones); w.vec(m.bonePalette);
w.b(m.useBlend); w.b(m.additive); w.b(m.alphaTest); w.f(m.alphaCutoff); w.pod(m.srcAlphaMode); w.f(m.srcFactorA);
w.pod(m.overlayKind); w.b(m.doubleSided); w.b(m.dynamicVerts); w.pod(m.gltfMeshIndex);
w.b(m.iblLit); w.b(m.iblFullSpec); w.b(m.bakeLightmapVtx);
w.vec(m.uvs2); w.b(m.alphaToCoverage); w.vec(m.normals); w.vec(m.tangents);
w.fa(m.lightmapPower, 3); w.f(m.metallic); w.f(m.roughness); w.f(m.speciblDiffScale); w.f(m.speciblSpecScale);
w.fa(m.albedoFactor, 3); w.fa(m.curTint, 4); w.b(m.animatedTintAlpha);
w.fa(m.animXform, 16); w.fa(m.uvXform, 6); w.vec(m.skinBindPos); w.vec(m.skinJoints); w.fa(m.skinBox, 6);
}
void readMesh(Reader& r, MeshData& m, const std::vector<std::shared_ptr<const TexSource>>& tex,
const std::vector<std::shared_ptr<const std::vector<u16>>>& half, u32 version) {
const bool v3 = version >= 3; // v2 (.fenv saved before HDR maps stayed ASTC) still opens
auto texRef = [&](u32 i) { return i < tex.size() ? tex[i] : nullptr; };
auto halfRef = [&](u32 i) { return i < half.size() ? half[i] : nullptr; };
m.name = r.str();
if (version >= 7) m.path = r.str();
m.components.resize(r.count(16));
for (EnvComponent& c : m.components) {
if (r.bad) return;
c.cls = r.str(); c.shortCls = r.str(); c.version = r.pod<int>();
c.fields.resize(r.count(8));
for (auto& kv : c.fields) { if (r.bad) return; kv.first = r.str(); kv.second = r.str(); }
}
m.isSkybox = r.b();
m.positions = r.vec<float>(); m.uvs = r.vec<float>(); m.colors = r.vec<u8>(); m.uvs3 = r.vec<float>(); m.uvs4 = r.vec<float>();
m.indices = r.vec<u32>();
m.nVerts = r.u(); m.nIdx = r.u();
m.texRGBA = r.vec<u8>(); m.texW = r.u(); m.texH = r.u(); m.hasTexture = r.b(); m.texShareSrc = r.pod<int>();
m.srcAstc = r.vec<u8>(); m.srcAstcBw = r.u(); m.srcAstcBh = r.u(); m.srcAstcMips = r.u();
m.normalRGBA = r.vec<u8>(); m.normalW = r.u(); m.normalH = r.u(); m.hasNormal = r.b();
m.ormRGBA = r.vec<u8>(); m.ormW = r.u(); m.ormH = r.u(); m.hasOrm = r.b();
m.ormTexName = r.str();
m.emissiveRGBA = r.vec<u8>(); m.emissiveW = r.u(); m.emissiveH = r.u(); m.hasEmissive = r.b();
m.lmRGBA = r.vec<u8>(); m.lmW = r.u(); m.lmH = r.u(); m.hasLightmap = r.b();
m.lmHalf = halfRef(r.u()); if (v3) m.lmSrc = texRef(r.u());
if (version >= 9) m.lmLdrSrc = texRef(r.u());
m.cubeRGBA = r.vec<u8>(); m.cubeSize = r.u(); if (v3) m.cubeSrc = texRef(r.u());
m.texSrc = texRef(r.u()); m.normalSrc = texRef(r.u()); m.ormSrc = texRef(r.u()); m.emissiveSrc = texRef(r.u());
m.namedTex.resize(r.count(8));
for (auto& nt : m.namedTex) { if (r.bad) return; nt.first = r.u(); nt.second = texRef(r.u()); }
m.namedHdr.resize(r.count(v3 ? 20 : 16));
for (auto& nh : m.namedHdr) { if (r.bad) return; nh.first = r.u(); nh.second.half = halfRef(r.u()); nh.second.w = r.u(); nh.second.h = r.u(); if (v3) nh.second.src = texRef(r.u()); }
m.reflCubeHalf = r.vec<u16>(); m.reflCubeSize = r.u(); m.reflCubeMips = r.u();
if (version >= 10) m.reflCubeSrc = texRef(r.u());
r.fa(m.lmUvScaleOffset, 4);
m.vatRaw = r.vec<u8>(); m.vatW = r.u(); m.vatH = r.u(); m.hasVat = r.b();
r.fa(m.tint, 4);
m.atlasCellIndex = r.f(); m.vatTrackIndex = r.f(); m.vatRateFactor = r.f(); m.vatTimeOffset = r.f();
m.matOverrides.resize(r.count(20));
for (MatOverride& o : m.matOverrides) { if (r.bad) return; o.name = r.str(); r.fa(o.v, 4); }
m.shaderIng = r.pod<u64>(); m.shaderPath = r.str(); m.meshPath = r.str(); m.tiled = r.b();
m.matParamsBlob = r.vec<u8>(); m.constParams = r.vec<ConstParam>(); m.texSlots = r.vec<u32>();
m.astcRaw = r.vec<u8>(); m.astcBw = r.u(); m.astcBh = r.u();
m.transform = r.pod<Transform>();
r.fa(m.worldMatrix, 16); m.hasWorldMatrix = r.b();
m.boneIndices = r.vec<u8>(); m.boneWeights = r.vec<u8>(); m.hasBones = r.b(); m.bonePalette = r.vec<u32>();
m.useBlend = r.b(); m.additive = r.b(); m.alphaTest = r.b(); m.alphaCutoff = r.f(); m.srcAlphaMode = r.pod<int>(); m.srcFactorA = r.f();
m.overlayKind = r.pod<int>(); m.doubleSided = r.b(); m.dynamicVerts = r.b(); m.gltfMeshIndex = r.pod<int>();
m.iblLit = r.b(); m.iblFullSpec = r.b(); m.bakeLightmapVtx = r.b();
m.uvs2 = r.vec<float>(); m.alphaToCoverage = r.b(); m.normals = r.vec<float>(); m.tangents = r.vec<float>();
r.fa(m.lightmapPower, 3); m.metallic = r.f(); m.roughness = r.f(); m.speciblDiffScale = r.f(); m.speciblSpecScale = r.f();
r.fa(m.albedoFactor, 3); r.fa(m.curTint, 4); m.animatedTintAlpha = r.b();
if (version >= 13) { r.fa(m.animXform, 16); r.fa(m.uvXform, 6); m.skinBindPos = r.vec<float>(); m.skinJoints = r.vec<float>(); r.fa(m.skinBox, 6); }
}
// The world layer in binary (world.json is the editable form): mesh colliders and walkable surfaces are
// megabytes of floats, which as text were the largest part of some blobs and slow to parse.
void writePoint(Writer& w, const WorldPoint& p) { w.str(p.name); w.fa(p.pos, 3); w.f(p.yaw); w.fa(p.exit, 3); }
WorldPoint readPoint(Reader& r) { WorldPoint p; p.name = r.str(); r.fa(p.pos, 3); p.yaw = r.f(); r.fa(p.exit, 3); return p; }
// Mesh colliders made from a drawn mesh (Meta's, whose physics mesh is the render mesh) are written as a reference
// to that mesh instead of a second copy of its vertices.
int sameMesh(const WorldCollider& c, const std::vector<MeshData>& meshes) {
for (size_t m = 0; m < meshes.size(); ++m) {
const MeshData& md = meshes[m];
if (md.positions.size() == c.positions.size() && md.indices.size() == c.indices.size() && !c.positions.empty() &&
std::memcmp(md.positions.data(), c.positions.data(), c.positions.size() * 4) == 0 &&
std::memcmp(md.indices.data(), c.indices.data(), c.indices.size() * 4) == 0)
return static_cast<int>(m);
}
return -1;
}
void writeWorld(Writer& w, const HomeWorld& wd, const std::vector<MeshData>& meshes, const std::vector<char>* thinned, const std::string& walkGrid) {
w.str(wd.origin);
for (const auto* list : {&wd.spawns, &wd.seats, &wd.hotspots}) {
w.u(static_cast<u32>(list->size()));
for (const WorldPoint& p : *list) writePoint(w, p);
}
w.u(static_cast<u32>(wd.colliders.size()));
for (const WorldCollider& c : wd.colliders) {
w.str(c.name); w.pod(c.kind); w.pod(c.role); w.fa(c.world, 16); w.fa(c.half, 3); w.f(c.radius); w.f(c.length);
int ref = c.kind == WorldCollider::Mesh ? sameMesh(c, meshes) : -1;
if (ref >= 0 && thinned && static_cast<size_t>(ref) < thinned->size() && (*thinned)[ref]) ref = -1; // its mesh lost triangles: its own
w.pod(ref);
if (ref < 0) { w.vec(c.positions); w.vec(c.indices); }
w.str(c.mesh);
}
w.vec(wd.walkPositions); w.vec(wd.walkIndices);
w.u(static_cast<u32>(wd.wallSpots.size()));
for (const WorldWallSpot& ws : wd.wallSpots) { w.str(ws.name); w.fa(ws.world, 16); w.f(ws.width); w.f(ws.height); }
w.u(static_cast<u32>(wd.gravity.size()));
for (const WorldGravity& g : wd.gravity) { w.str(g.name); w.pod(g.kind); w.fa(g.world, 16); w.fa(g.half, 3); w.f(g.radius); w.pod(g.priority); }
w.f(wd.fallLimit);
w.str(walkGrid);
}
void readWorld(Reader& r, HomeWorld& wd, u32 version, std::vector<std::pair<size_t, int>>& refs) {
wd = HomeWorld{};
wd.origin = r.str();
for (auto* list : {&wd.spawns, &wd.seats, &wd.hotspots})
for (u32 n = r.u(); n-- && !r.bad;) list->push_back(readPoint(r));
wd.colliders.resize(r.count(93));
for (WorldCollider& c : wd.colliders) {
if (r.bad) break;
c.name = r.str(); c.kind = r.pod<int>();
if (version >= 4) c.role = r.pod<int>(); else c.role = r.b() ? WorldCollider::Trigger : WorldCollider::Solid;
r.fa(c.world, 16); r.fa(c.half, 3); c.radius = r.f(); c.length = r.f();
const int ref = version >= 5 ? r.pod<int>() : -1;
if (ref >= 0) refs.emplace_back(&c - wd.colliders.data(), ref); // filled from the mesh once meshes are read
else { c.positions = r.vec<float>(); c.indices = r.vec<u32>(); }
if (version >= 4) c.mesh = r.str();
}
wd.walkPositions = r.vec<float>(); wd.walkIndices = r.vec<u32>();
wd.wallSpots.resize(r.count(76));
for (WorldWallSpot& ws : wd.wallSpots) { if (r.bad) break; ws.name = r.str(); r.fa(ws.world, 16); ws.width = r.f(); ws.height = r.f(); }
if (version >= 6) {
wd.gravity.resize(r.count(92));
for (WorldGravity& g : wd.gravity) { if (r.bad) break; g.name = r.str(); g.kind = r.pod<int>(); r.fa(g.world, 16); r.fa(g.half, 3); g.radius = r.f(); g.priority = r.pod<int>(); }
wd.fallLimit = r.f();
}
if (version >= 11) { // its mesh colliders were simplified by the bake
wd.walkGrid = r.str();
for (WorldCollider& c : wd.colliders) c.simplified = c.kind == WorldCollider::Mesh;
}
worldAdoptLegacyWalkable(wd);
}
constexpr u32 kMagic = 0x4E435346; // "FSCN"
// The block footprints ASTC has (2D): the renderer divides by them and picks its Vulkan format from them.
bool astcFootprint(u32 bw, u32 bh) {
static const u8 k[][2] = {{4, 4}, {5, 4}, {5, 5}, {6, 5}, {6, 6}, {8, 5}, {8, 6}, {8, 8}, {10, 5}, {10, 6}, {10, 8}, {10, 10}, {12, 10}, {12, 12}};
for (const auto& f : k) if (f[0] == bw && f[1] == bh) return true;
return false;
}
// What the readers index by without checking, checked once here: anything a bake can't have written fails the load.
bool sane(HslScene& s) {
for (MeshData& m : s.meshes) {
m.nVerts = static_cast<u32>(std::min<size_t>(m.nVerts, m.positions.size() / 3));
m.nIdx = static_cast<u32>(std::min<size_t>(m.nIdx, m.indices.size()));
if (m.cubeSize && (m.cubeSrc ? m.cubeSrc->faces != 6 : m.cubeRGBA.size() / 24 / m.cubeSize < m.cubeSize)) return false;
if ((!m.skinBindPos.empty() && (m.skinBindPos.size() < static_cast<size_t>(m.nVerts) * 3 || m.boneIndices.size() < static_cast<size_t>(m.nVerts) * 4 ||
m.boneWeights.size() < static_cast<size_t>(m.nVerts) * 4)) ||
m.skinJoints.size() % 16 || m.skinJoints.size() > 256 * 16)
return false;
}
for (const HslAnimGroup& g : s.anims) {
const size_t joints = g.skel.joints.size(), keys = static_cast<size_t>(std::max(0, g.clip.nFrames)) * std::max(0, g.clip.nJoints);
if (g.clip.nFrames < 0 || g.clip.nJoints < 0) return false;
// empty is fine (sampled as the bind pose); a partial array is damage
if ((!g.skel.invBind.empty() && g.skel.invBind.size() < joints * 16) || (!g.clip.quats.empty() && g.clip.quats.size() / 4 < keys))
return false;
}
s.meshSource.resize(s.meshes.size(), 0); // per mesh, always (Studio indexes it by mesh)
return true;
}
} // namespace
bool bakeScene(const HslScene& scene, std::vector<u8>& out, std::string& error, const BakeOptions* options) {
// A Clay / glTF home's animation goes in as data (anim_bake.h), and the meshes it writes as a fresh load has them
// (the animation at t = 0): Frame Studio plays it, so they may be anywhere in it now. When that moved vertices, the
// bake works from a copy of the scene with them back (the walkable area and the colliders read the vertices).
std::vector<u8> anim;
std::vector<std::pair<size_t, MeshData>> posed;
if (scene.saveAnimation) {
scene.saveAnimation(anim);
if (!animbake::startPose(anim, scene.meshes, posed)) { error = "this home's animation couldn't be stored"; return false; }
} else if (scene.animate) {
error = "this home's animation can't be stored";
return false;
}
std::unique_ptr<HslScene> atStart;
for (const auto& [m, md] : posed)
if (md.positions.size() != scene.meshes[m].positions.size() ||
std::memcmp(md.positions.data(), scene.meshes[m].positions.data(), md.positions.size() * sizeof(float))) {
atStart = std::make_unique<HslScene>(scene);
for (auto& [i, pmd] : posed) atStart->meshes[i] = std::move(pmd);
posed.clear();
break;
}
const HslScene& s = atStart ? *atStart : scene;
std::vector<const MeshData*> meshes(s.meshes.size());
for (size_t i = 0; i < meshes.size(); ++i) meshes[i] = &s.meshes[i];
for (const auto& [m, md] : posed) meshes[m] = &md;
const VisibilityResult* vis = options ? options->visibility : nullptr;
if (vis && vis->changed.size() != s.meshes.size()) vis = nullptr;
out.clear();
// Meshes first into their own buffer: that fills the shared-texture tables, which go ahead of them.
std::vector<u8> meshBytes;
Writer mw{meshBytes};
Table<TexSource> tex;
Table<std::vector<u16>> half;
for (size_t i = 0; i < s.meshes.size(); ++i) writeMesh(mw, *meshes[i], tex, half, vis && vis->changed[i] ? &vis->indices[i] : nullptr);
Writer w{out};
w.u(kMagic); w.u(kSceneBakeVersion);
w.u(static_cast<u32>(s.apkPaths.size()));
for (size_t i = 0; i < s.apkPaths.size(); ++i) {
w.str(s.apkPaths[i]);
const HslSourceInfo info = i < s.sources.size() ? s.sources[i] : HslSourceInfo{};
w.str(info.package); w.str(info.version); w.str(info.format); w.str(info.role);
}
w.vec(s.meshSource);
w.fa(s.ambientRGB, 3); w.b(s.hasEnvAmbient);
{ // the world's fog: a global the HSTF parser sets while loading (renderer reads it per frame)
std::lock_guard<std::mutex> l(g_sceneFogMutex);
w.b(g_sceneFog.has); w.fa(g_sceneFog.color, 4); w.f(g_sceneFog.start); w.f(g_sceneFog.density); w.b(g_sceneFog.travel);
}
w.f(s.animDuration); w.vec(s.animatedMeshes);
w.vec(s.ambientOgg); w.str(s.ambientSource); w.str(s.ambientName);
{ // the mesh colliders as every load simplifies them (simplifyWorldMeshes): done here once, not at each load
HomeWorld world = s.world;
simplifyWorldMeshes(world);
// The walkable area goes in baked: the optimizer's, else Frame Studio's (as edited there), else the one Frame
// Home would bake at load (the same bake, with its defaults), so the headset never bakes one (0.3-0.5 s).
std::string walkGrid = options && !options->walkGrid.empty() ? options->walkGrid : s.world.walkGrid;
if (walkGrid.empty())
if (const std::shared_ptr<const WalkGrid> g = bakeWalkGrid(s, world, WalkParams{})) walkGrid = encodeWalkGrid(*g);
writeWorld(w, world, s.meshes, vis ? &vis->changed : nullptr, walkGrid);
}
// The shaders as the Frame's GPU runs them (shader_bake.h): each program's prepared forms, and per distinct set
// of material values its meshes have, the fragment shader with them baked in.
std::map<std::string, ShaderProgramSrc> programs = s.programs;
const auto ts = std::chrono::steady_clock::now();
shaderbake::bakePrograms(s.meshes, [&](const MeshData& md) -> std::string {
const ShaderProgramSrc* src = findProgram(s, md.shaderPath);
if (!src && md.isSkybox) // (as the renderer: the shell's skybox shader ships in renderer_module)
for (const auto& kv : s.programs)
if (shaderbake::contains(shaderbake::lc(kv.first), "skybox")) { src = &kv.second; break; }
for (const auto& kv : s.programs) if (&kv.second == src) return kv.first;
return std::string();
}, s.ambientRGB, s.hasEnvAmbient, programs);
{
size_t specialized = 0;
for (const auto& kv : programs) for (const auto& v : kv.second.variants) specialized += !v.frag.empty();
std::fprintf(stderr, "[bake] shaders: %zu programs prepared, %zu specialized for their materials in %.0f ms\n", programs.size(), specialized,
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - ts).count());
}
w.u(static_cast<u32>(programs.size()));
for (const auto& kv : programs) {
const ShaderProgramSrc& p = kv.second;
w.str(kv.first); w.str(p.path); w.vec(p.vert); w.vec(p.frag);
w.b(p.prepared); w.vec(p.vertInvariant); w.vec(p.fragAlpha);
w.u(static_cast<u32>(p.variants.size()));
for (const auto& v : p.variants) { w.pod(v.key); w.vec(v.frag); w.vec(v.fragAlpha); }
}
w.u(static_cast<u32>(s.anims.size()));
for (const HslAnimGroup& g : s.anims) {
w.u(static_cast<u32>(g.skel.joints.size()));
for (const RendJoint& j : g.skel.joints) { w.fa(j.pos, 3); w.fa(j.quat, 4); w.f(j.scale); w.pod(j.parent); w.str(j.name); }
w.vec(g.skel.bind); w.vec(g.skel.invBind);
w.pod(g.clip.nJoints); w.pod(g.clip.nFrames); w.f(g.clip.fps); w.vec(g.clip.quats); w.vec(g.clip.trans);
w.vec(g.clip.acl ? hzAclBytes(g.clip.acl) : std::vector<uint8_t>());
w.pod(g.clip.aclJoints);
w.vec(g.meshes);
}
w.vec(anim);
w.u(static_cast<u32>(tex.items.size()));
// Textures shipped without smaller levels get their chain here (tex_mips.h): sampled natively on the Frame at full
// size instead of being shrunk and rebuilt at every load. FH_NO_BAKE_MIPS=1 keeps them as shipped.
static const bool bakeMips = !std::getenv("FH_NO_BAKE_MIPS");
const auto tm0 = std::chrono::steady_clock::now();
size_t mipped = 0, cached = 0;
// Each chain from the texture cache when an earlier open or save built it (tex_cache.h), else made here and kept
// there: a save of the same home again (and every auto-save) makes none. The ones made run a texture per core.
std::vector<std::shared_ptr<TexSource>> chains(tex.items.size());
std::atomic<size_t> fromCache{0};
if (bakeMips)
par::forEach(tex.items.size(), [&](size_t i) {
const TexSource& t = *tex.items[i];
if (!texmips::wants(t)) return;
if ((chains[i] = texcache::load(t))) { ++fromCache; return; }
if ((chains[i] = texmips::withMips(t, 1))) texcache::store(*chains[i], texcache::key(t));
});
cached = fromCache;
for (size_t i = 0; i < tex.items.size(); ++i) {
const TexSource* t = tex.items[i];
if (chains[i]) { t = chains[i].get(); ++mipped; }
w.vec(t->astc); w.u(t->w); w.u(t->h); w.u(t->bw); w.u(t->bh); w.u(t->mips); w.b(t->hdr); w.u(t->faces);
}
if (mipped)
std::fprintf(stderr, "[bake] gave %zu textures their smaller levels (%zu from the cache) in %.1f s\n", mipped, cached,
std::chrono::duration<double>(std::chrono::steady_clock::now() - tm0).count());
w.u(static_cast<u32>(half.items.size()));
for (const std::vector<u16>* h : half.items) w.vec(*h);
w.u(static_cast<u32>(s.meshes.size()));
out.insert(out.end(), meshBytes.begin(), meshBytes.end());
return true;
}
bool optimizeForBake(const HslScene& scene, const std::vector<ObjectEdit>* edits, VisibilityResult& vis, BakeOptions& options) {
vis = VisibilityResult();
options = BakeOptions();
if (std::getenv("FH_NO_VIS_CULL")) { vis.skipped = "turned off (FH_NO_VIS_CULL)"; return false; }
if (scene.animate && !scene.saveAnimation) { vis.skipped = "the home's animation can't be stored (it isn't baked)"; return false; }
// Everything a CPU animation writes stays whole (and hides nothing): its motion isn't in what's seen from where.
VisibilityOptions vo;
if (scene.saveAnimation) {
std::vector<u8> anim;
scene.saveAnimation(anim);
vo.pinned = animbake::targets(anim);
}
const HomeWorld w = walkWorld(scene);
if (!bakeVisibility(scene, w, w.walkGridCache.get(), edits, vis, vo)) {
std::fprintf(stderr, "[optimize] nothing left out: %s\n", vis.skipped.c_str());
return false;
}
options.visibility = &vis;
options.walkGrid = encodeWalkGrid(*w.walkGridCache);
if (std::getenv("FH_VIS_DRYRUN")) { // diagnostics: everything as optimized (the walkable area stored) but no triangle left out
vis.changed.assign(vis.changed.size(), 0);
vis.trisAfter = vis.trisBefore;
}
std::fprintf(stderr, "[optimize] from %zu eye spots: %zu of %zu triangles left out (%.1f%%), %zu meshes emptied, %zu thinned, %.1f s\n",
vis.samples, vis.trisBefore - vis.trisAfter, vis.trisBefore, vis.trisBefore ? 100.0 * (vis.trisBefore - vis.trisAfter) / vis.trisBefore : 0.0,
vis.meshesEmptied, vis.meshesTouched, vis.ms / 1000);
return true;
}
bool unbakeScene(const u8* data, size_t size, HslScene& s, std::string& error) {
Reader r{data, data + size};
if (r.u() != kMagic) { error = "not a baked scene"; return false; }
const u32 version = r.u();
if (version < 2 || version > kSceneBakeVersion) { error = "baked scene from a different Frame Studio version"; return false; }
s = HslScene{};
s.apkPaths.resize(r.count(20));
for (size_t i = 0; i < s.apkPaths.size() && !r.bad; ++i) {
s.apkPaths[i] = r.str();
HslSourceInfo info;
info.package = r.str(); info.version = r.str(); info.format = r.str(); info.role = r.str();
s.sources.push_back(info);
}
s.meshSource = r.vec<int>();
r.fa(s.ambientRGB, 3); s.hasEnvAmbient = r.b();
SceneFog fog;
fog.has = r.b(); r.fa(fog.color, 4); fog.start = r.f(); fog.density = r.f(); fog.travel = r.b();
s.animDuration = r.f(); s.animatedMeshes = r.vec<int>();
s.ambientOgg = r.vec<u8>(); s.ambientSource = r.str();
if (version >= 8) s.ambientName = r.str();
std::vector<std::pair<size_t, int>> meshRefs;
if (version >= 3) readWorld(r, s.world, version, meshRefs); else worldFromJson(r.str(), s.world);
for (u32 n = r.u(); n-- && !r.bad;) {
const std::string key = r.str();
ShaderProgramSrc p;
p.path = r.str(); p.vert = r.vec<u32>(); p.frag = r.vec<u32>();
if (version >= 12) {
p.prepared = r.b(); p.vertInvariant = r.vec<u32>(); p.fragAlpha = r.vec<u32>();
p.variants.resize(r.count(20));
for (auto& v : p.variants) { if (r.bad) break; v.key = r.pod<uint64_t>(); v.frag = r.vec<u32>(); v.fragAlpha = r.vec<u32>(); }
if (p.prepared && p.vertInvariant.empty()) p.prepared = false; // (a damaged file: made at load instead)
}
s.programs[key] = std::move(p);
}
s.anims.resize(r.count(44));
for (HslAnimGroup& g : s.anims) {
if (r.bad) break;
g.skel.joints.resize(r.count(40));
for (RendJoint& j : g.skel.joints) { if (r.bad) break; r.fa(j.pos, 3); r.fa(j.quat, 4); j.scale = r.f(); j.parent = r.pod<int>(); j.name = r.str(); }
g.skel.bind = r.vec<float>(); g.skel.invBind = r.vec<float>();
g.clip.nJoints = r.pod<int>(); g.clip.nFrames = r.pod<int>(); g.clip.fps = r.f(); g.clip.quats = r.vec<float>(); g.clip.trans = r.vec<float>();
const std::vector<u8> acl = r.vec<u8>();
g.clip.acl = acl.empty() ? nullptr : hzAclCreate(acl.data(), acl.size()); // owned for the scene's life, as when parsed
g.clip.aclJoints = r.pod<int>();
g.meshes = r.vec<int>();
}
const std::vector<u8> anim = version >= 13 ? r.vec<u8>() : std::vector<u8>();
std::vector<std::shared_ptr<const TexSource>> tex(r.count(version >= 3 ? 29 : 25));
for (auto& t : tex) {
if (r.bad) break;
auto ts = std::make_shared<TexSource>();
ts->astc = r.vec<u8>(); ts->w = r.u(); ts->h = r.u(); ts->bw = r.u(); ts->bh = r.u(); ts->mips = r.u(); ts->hdr = r.b(); if (version >= 3) ts->faces = r.u();
if (!astcFootprint(ts->bw, ts->bh) || !ts->w || !ts->h || ts->w > 16384 || ts->h > 16384 || (ts->faces != 1 && ts->faces != 6) ||
(ts->faces == 6 && ts->w != ts->h)) { r.bad = true; break; }
u32 chain = 1; // a mip count past the image's own chain: the levels it can have
while ((std::max(ts->w, ts->h) >> chain) != 0) ++chain;
ts->mips = std::min(std::max(ts->mips, 1u), chain);
t = ts;
}
std::vector<std::shared_ptr<const std::vector<u16>>> half(r.count(4));
for (auto& h : half) { if (r.bad) break; h = std::make_shared<std::vector<u16>>(r.vec<u16>()); }
s.meshes.resize(r.count(64));
for (MeshData& m : s.meshes) { if (r.bad) break; readMesh(r, m, tex, half, version); }
for (const auto& [ci, mi] : meshRefs) {
if (ci >= s.world.colliders.size() || mi >= static_cast<int>(s.meshes.size())) { r.bad = true; break; }
s.world.colliders[ci].positions = s.meshes[mi].positions;
s.world.colliders[ci].indices = s.meshes[mi].indices;
}
if (!r.bad && !anim.empty() && !animbake::load(anim, s.meshes.size(), s)) r.bad = true; // (it plays the meshes as read)
if (r.bad || !sane(s)) { error = "baked scene is truncated or damaged"; s = HslScene{}; return false; }
std::lock_guard<std::mutex> l(g_sceneFogMutex);
g_sceneFog = fog;
g_deferTextures = true;
return true;
}
+34
View File
@@ -0,0 +1,34 @@
#pragma once
// A loaded home, baked: everything HslScene holds after the slow part of loading (APK unzip, HSTF / RENDMESH /
// RENDSHAD parsing, SMOL-V decode, lightmap HDR decode, override resolution) written as one binary blob, so a
// .fenv opens in a fraction of the time. Textures (HDR lightmaps too) stay as their cooked ASTC chains (the Frame's GPU samples them
// directly), so the blob is GPU-ready and about the size of the home's own data.
//
// Clay (.opa / .ovrscene) and glTF homes animate on the CPU: their animation goes in as data too (anim_bake.h), played by
// the loader's own code from it.
#include "hsl_scene.h"
#include <string>
#include <vector>
constexpr u32 kSceneBakeVersion = 13; // 2: world; 3: HDR maps as ASTC, binary world; 4: roles; 5: mesh refs; 6: gravity; 7: mesh paths; 8: ambience name; 9: compressed 8-bit lightmaps; 10: reflection cube as ASTC; 11: walkable area (and simplified colliders); 12: prepared and specialized shaders; 13: Clay / glTF CPU animation
struct VisibilityResult;
struct ObjectEdit;
// What a bake leaves out or adds beyond the scene itself: the visibility pass's index lists (visibility_bake.h) and
// the walkable area to carry (the scene's own when empty).
struct BakeOptions {
const VisibilityResult* visibility = nullptr;
std::string walkGrid;
};
// False (with `error`) when the scene can't be baked (an animation that can't be stored).
bool bakeScene(const HslScene& scene, std::vector<u8>& out, std::string& error, const BakeOptions* options = nullptr);
// The mesh optimizer for a save: the visibility pass over the scene's walkable area (Frame Studio's object edits kept
// whole), its result in `vis` and the options for bakeScene in `options` (the walkable area it was worked out from
// goes with it: Frame Home must not bake one from the thinned meshes). False when it didn't run (`vis.skipped`).
// FH_NO_VIS_CULL=1: never.
bool optimizeForBake(const HslScene& scene, const std::vector<ObjectEdit>* edits, VisibilityResult& vis, BakeOptions& options);
bool unbakeScene(const u8* data, size_t size, HslScene& scene, std::string& error);
+62
View File
@@ -0,0 +1,62 @@
#pragma once
// GENERATED by tools/gen_clay_shader.py --selection: the selection-highlight overlay shaders. Do not edit by hand.
// vert: in vec3 position @0, vec2 uv @1; set0 b0 uniform { mat4 mvp } frag: set0 b1 sampler2D tex -> rgb, a=1
#include <cstddef>
#include <cstdint>
static const uint32_t kSelectionVertSpirv[] = {
0x07230203,0x00010000,0x00000000,0x00000022,0x00000000,0x00020011,0x00000001,0x0003000e,
0x00000000,0x00000001,0x0009000f,0x00000000,0x00000001,0x6e69616d,0x00000000,0x00000014,
0x00000015,0x00000016,0x00000017,0x00040005,0x00000001,0x6e69616d,0x00000000,0x00050005,
0x00000013,0x656c6573,0x6f697463,0x0000006e,0x00050005,0x00000014,0x69736f70,0x6e6f6974,
0x00000000,0x00030005,0x00000015,0x00007675,0x00030005,0x00000016,0x00565576,0x00040006,
0x0000000a,0x00000000,0x0070766d,0x00040047,0x00000014,0x0000001e,0x00000000,0x00040047,
0x00000015,0x0000001e,0x00000001,0x00040047,0x00000016,0x0000001e,0x00000000,0x00040047,
0x00000017,0x0000000b,0x00000000,0x00050048,0x0000000a,0x00000000,0x00000023,0x00000000,
0x00040048,0x0000000a,0x00000000,0x00000005,0x00050048,0x0000000a,0x00000000,0x00000007,
0x00000010,0x00030047,0x0000000a,0x00000002,0x00040047,0x00000013,0x00000022,0x00000000,
0x00040047,0x00000013,0x00000021,0x00000000,0x00020013,0x00000002,0x00030021,0x00000003,
0x00000002,0x00030016,0x00000004,0x00000020,0x00040015,0x00000005,0x00000020,0x00000001,
0x00040017,0x00000006,0x00000004,0x00000002,0x00040017,0x00000007,0x00000004,0x00000003,
0x00040017,0x00000008,0x00000004,0x00000004,0x00040018,0x00000009,0x00000008,0x00000004,
0x0003001e,0x0000000a,0x00000009,0x00040020,0x0000000b,0x00000002,0x0000000a,0x00040020,
0x0000000c,0x00000002,0x00000009,0x00040020,0x0000000d,0x00000001,0x00000007,0x00040020,
0x0000000e,0x00000001,0x00000006,0x00040020,0x0000000f,0x00000003,0x00000006,0x00040020,
0x00000010,0x00000003,0x00000008,0x0004002b,0x00000004,0x00000011,0x3f800000,0x0004002b,
0x00000005,0x00000012,0x00000000,0x0004003b,0x0000000b,0x00000013,0x00000002,0x0004003b,
0x0000000d,0x00000014,0x00000001,0x0004003b,0x0000000e,0x00000015,0x00000001,0x0004003b,
0x0000000f,0x00000016,0x00000003,0x0004003b,0x00000010,0x00000017,0x00000003,0x00050036,
0x00000002,0x00000001,0x00000000,0x00000003,0x000200f8,0x00000018,0x00050041,0x0000000c,
0x00000019,0x00000013,0x00000012,0x0004003d,0x00000009,0x0000001a,0x00000019,0x0004003d,
0x00000007,0x0000001b,0x00000014,0x00050051,0x00000004,0x0000001c,0x0000001b,0x00000000,
0x00050051,0x00000004,0x0000001d,0x0000001b,0x00000001,0x00050051,0x00000004,0x0000001e,
0x0000001b,0x00000002,0x00070050,0x00000008,0x0000001f,0x0000001c,0x0000001d,0x0000001e,
0x00000011,0x00050091,0x00000008,0x00000020,0x0000001a,0x0000001f,0x0003003e,0x00000017,
0x00000020,0x0004003d,0x00000006,0x00000021,0x00000015,0x0003003e,0x00000016,0x00000021,
0x000100fd,0x00010038,
};
static const size_t kSelectionVertSize = sizeof(kSelectionVertSpirv);
static const uint32_t kSelectionFragSpirv[] = {
0x07230203,0x00010000,0x00000000,0x00000018,0x00000000,0x00020011,0x00000001,0x0003000e,
0x00000000,0x00000001,0x0007000f,0x00000004,0x00000001,0x6e69616d,0x00000000,0x0000000d,
0x0000000e,0x00030010,0x00000001,0x00000007,0x00040005,0x00000001,0x6e69616d,0x00000000,
0x00030005,0x0000000c,0x00786574,0x00030005,0x0000000d,0x00565576,0x00050005,0x0000000e,
0x4374756f,0x726f6c6f,0x00000000,0x00040047,0x0000000d,0x0000001e,0x00000000,0x00040047,
0x0000000e,0x0000001e,0x00000000,0x00040047,0x0000000c,0x00000022,0x00000000,0x00040047,
0x0000000c,0x00000021,0x00000001,0x00020013,0x00000002,0x00030021,0x00000003,0x00000002,
0x00030016,0x00000004,0x00000020,0x00040017,0x00000005,0x00000004,0x00000002,0x00040017,
0x00000006,0x00000004,0x00000004,0x00090019,0x00000007,0x00000004,0x00000001,0x00000000,
0x00000000,0x00000000,0x00000001,0x00000000,0x0003001b,0x00000008,0x00000007,0x00040020,
0x00000009,0x00000000,0x00000008,0x00040020,0x0000000a,0x00000001,0x00000005,0x00040020,
0x0000000b,0x00000003,0x00000006,0x0004002b,0x00000004,0x0000000f,0x3f800000,0x0004003b,
0x00000009,0x0000000c,0x00000000,0x0004003b,0x0000000a,0x0000000d,0x00000001,0x0004003b,
0x0000000b,0x0000000e,0x00000003,0x00050036,0x00000002,0x00000001,0x00000000,0x00000003,
0x000200f8,0x00000010,0x0004003d,0x00000008,0x00000011,0x0000000c,0x0004003d,0x00000005,
0x00000012,0x0000000d,0x00050057,0x00000006,0x00000013,0x00000011,0x00000012,0x00050051,
0x00000004,0x00000014,0x00000013,0x00000000,0x00050051,0x00000004,0x00000015,0x00000013,
0x00000001,0x00050051,0x00000004,0x00000016,0x00000013,0x00000002,0x00070050,0x00000006,
0x00000017,0x00000014,0x00000015,0x00000016,0x0000000f,0x0003003e,0x0000000e,0x00000017,
0x000100fd,0x00010038,
};
static const size_t kSelectionFragSize = sizeof(kSelectionFragSpirv);
+408
View File
@@ -0,0 +1,408 @@
#pragma once
// The shaders as the Frame's GPU runs them, made once: Frame Studio (scene_bake) stores the results in the .fenv, and
// the renderer makes the same here for a home opened straight from its APK. The renderer checks a baked result
// against what it would make (the key of the uniform values it bakes in) and makes its own when they differ, so a
// .fenv baked by an older build never draws wrong.
// - invariantPosition: the vertex shader with its position Invariant (a cut-out's depth pass and its shading pass
// compute every position bit for bit alike)
// - alphaOnly: the fragment shader writing only its alpha (the cut-out depth pass)
// - specialize: per material, the uniform values that never change baked in as constants (spv_specialize.h)
// The set-2 (material) uniform buffers are filled here too, by the same code for both, so the bytes a baked shader
// assumes are the bytes the renderer uploads.
#include "core/types.h"
#include "hsl_scene.h"
#include "spv_reflect.h"
#include "spv_specialize.h"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <functional>
#include <map>
#include <set>
#include <string>
#include <vector>
namespace shaderbake {
inline std::string lc(std::string s) {
for (char& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
return s;
}
inline bool contains(const std::string& s, const char* sub) { return s.find(sub) != std::string::npos; }
// The vertex shader with its position marked Invariant. (An OpMemberDecorate / OpDecorate ... Invariant after the
// BuiltIn Position one.)
inline std::vector<u32> invariantPosition(const std::vector<u32>& code) {
if (code.size() < 5 || code[0] != 0x07230203u) return code;
std::vector<u32> out(code.begin(), code.begin() + 5);
out.reserve(code.size() + 4);
for (size_t i = 5; i < code.size();) {
const u32 words = code[i] >> 16, op = code[i] & 0xFFFF;
if (!words || i + words > code.size()) return code;
out.insert(out.end(), code.begin() + i, code.begin() + i + words);
if (op == 72 && words == 5 && code[i + 3] == 11 && code[i + 4] == 0) // OpMemberDecorate s m BuiltIn Position
out.insert(out.end(), {(4u << 16) | 72u, code[i + 1], code[i + 2], 18u});
else if (op == 71 && words == 4 && code[i + 2] == 11 && code[i + 3] == 0) // OpDecorate v BuiltIn Position
out.insert(out.end(), {(3u << 16) | 71u, code[i + 1], 18u});
i += words;
}
return out;
}
// The fragment shader made to write only its colour's alpha: every whole-vec4 store to the Location 0 output becomes
// (a,a,a,a) of the same value. Discards and everything the alpha depends on stay as they are, so alpha-to-coverage
// picks the same samples; the lighting the colour needed no longer reaches an output and the driver drops it. Empty
// when the output is written any other way (a component at a time).
inline std::vector<u32> alphaOnly(const std::vector<u32>& code) {
if (code.size() < 5 || code[0] != 0x07230203u) return {};
u32 outVar = 0, bound = code[3];
std::map<u32, u32> vec4Of; // OpTypeVector id (4 floats) -> its float type id
std::map<u32, u32> ptrPointee; // Output pointer type -> pointee
std::map<u32, u32> varType; // variable -> its pointer type
std::set<u32> loc0;
for (size_t i = 5; i < code.size();) {
const u32 words = code[i] >> 16, op = code[i] & 0xFFFF;
if (!words || i + words > code.size()) return {};
if (op == 71 && words == 4 && code[i + 2] == 30 && code[i + 3] == 0) loc0.insert(code[i + 1]); // Location 0
if (op == 23 && words == 4 && code[i + 3] == 4) vec4Of[code[i + 1]] = code[i + 2]; // OpTypeVector
if (op == 32 && words == 4 && code[i + 2] == 3) ptrPointee[code[i + 1]] = code[i + 3]; // OpTypePointer Output
if (op == 59 && words >= 4 && code[i + 3] == 3 && loc0.count(code[i + 2])) { outVar = code[i + 2]; varType[outVar] = code[i + 1]; }
i += words;
}
if (!outVar || !ptrPointee.count(varType[outVar]) || !vec4Of.count(ptrPointee[varType[outVar]])) return {};
const u32 vecT = ptrPointee[varType[outVar]], floatT = vec4Of[vecT];
std::vector<u32> out(code.begin(), code.begin() + 5);
for (size_t i = 5; i < code.size();) {
const u32 words = code[i] >> 16, op = code[i] & 0xFFFF;
if ((op == 65 || op == 66) && words >= 4 && code[i + 3] == outVar) return {}; // OpAccessChain into it
if (op == 62 && words >= 3 && code[i + 1] == outVar) { // OpStore out, v
const u32 a = bound++, v = bound++;
out.insert(out.end(), {(5u << 16) | 81u, floatT, a, code[i + 2], 3u}); // a = OpCompositeExtract v[3]
out.insert(out.end(), {(7u << 16) | 80u, vecT, v, a, a, a, a}); // (a,a,a,a)
out.insert(out.end(), {(3u << 16) | 62u, outVar, v});
i += words;
continue;
}
out.insert(out.end(), code.begin() + i, code.begin() + i + words);
i += words;
}
out[3] = bound;
return out;
}
// A program's descriptor sets as the renderer lays them out: the union of both stages, the larger declaration of a
// block winning, sorted by binding.
inline void mergedSets(const spv::Module& vr, const spv::Module& fr, std::vector<spv::Resource> sets[3]) {
for (const spv::Module* m : {&vr, &fr}) {
for (const auto& r : m->resources) {
if (r.set > 2) continue;
auto& v = sets[r.set];
auto f = std::find_if(v.begin(), v.end(), [&](const spv::Resource& x) { return x.binding == r.binding; });
if (f == v.end()) v.push_back(r);
else if (f->size < r.size) { f->size = r.size; f->members = r.members; }
}
}
for (int s = 0; s < 3; ++s)
std::sort(sets[s].begin(), sets[s].end(), [](const spv::Resource& a, const spv::Resource& b) { return a.binding < b.binding; });
}
// Whether the vertex stage reads bone indices (its skinning palette may be an unnamed block).
inline bool boned(const spv::Module& vr) {
for (const auto& in : vr.inputs) {
const std::string n = lc(in.name);
if (contains(n, "boneind") || contains(n, "joint") || contains(n, "indices")) return true;
}
return false;
}
// A per-material uniform block, as HSR Studio supplies it: zero, then the material's cooked constants bound by
// MurmurHash3("<block>.<member>") (matParams, lightprobesParams.L00 and lightBakerParams are all authored this way),
// then the entity's per-instance overrides. Returns which members the material authored.
inline std::vector<bool> fillBlock(const spv::Resource& r, const MeshData& md, float* f) {
const size_t cap = std::max<size_t>(r.size, 16);
u8* bytes = reinterpret_cast<u8*>(f);
std::memset(bytes, 0, cap);
std::vector<bool> authored(r.members.size(), false);
const std::string block = r.name.empty() ? std::string("matParams") : r.name;
for (size_t mi = 0; mi < r.members.size(); ++mi) {
const auto& m = r.members[mi];
const std::string full = block + "." + m.name;
const u32 want = murmur3_x86_32(full.data(), full.size(), 0);
for (const auto& cp : md.constParams) {
if (cp.nameHash != want) continue;
if (static_cast<size_t>(cp.blobOffset) + cp.byteSize > md.matParamsBlob.size() || m.offset + cp.byteSize > cap) break;
std::memcpy(bytes + m.offset, md.matParamsBlob.data() + cp.blobOffset, cp.byteSize);
authored[mi] = true;
break;
}
}
const std::string prefix = block + ".";
for (const auto& ov : md.matOverrides) {
if (ov.name.rfind(prefix, 0) != 0) continue;
const std::string member = ov.name.substr(prefix.size());
for (size_t mi = 0; mi < r.members.size(); ++mi) {
const auto& m = r.members[mi];
if (m.name != member || m.offset >= cap) continue;
const size_t n = std::min<size_t>(std::min<u32>(m.size ? m.size : 4, 16), cap - m.offset);
if (m.integer) { // overrides arrive as floats: store them as the integers the shader reads
for (size_t k = 0; k * 4 < n; ++k) { const int32_t iv = static_cast<int32_t>(ov.v[k]); std::memcpy(bytes + m.offset + k * 4, &iv, 4); }
} else {
std::memcpy(bytes + m.offset, ov.v, n);
}
authored[mi] = true;
break;
}
}
// HSR's one non-zero default: a dielectric's Fresnel F0 at n = 1.5.
for (size_t mi = 0; mi < r.members.size(); ++mi)
if (!authored[mi] && r.members[mi].name == "incidentReflectivityF0" && r.members[mi].offset + 4 <= cap) {
const float f0 = 0.04f;
std::memcpy(bytes + r.members[mi].offset, &f0, 4);
}
return authored;
}
// matParams, as HSR Studio fills it: zero (a value the material does not author is zero, never a guess), then the
// material's cooked constants bound by MurmurHash3("matParams."+member), then the entity's per-instance overrides.
inline void fillMatParams(const spv::Resource& r, const MeshData& md, const std::string& programPath, float* f) {
fillBlock(r, md, f);
// Desert Terrace 210's palms (palmtreefoliage) light from the standard lightmap, not their custom HDR one: with it
// (useCustomLightmap 1, as authored) every trunk lit a flat bright yellow, nothing like the Clay twin's dark,
// side-lit trunks; with the standard lightmap trunks and fronds match it (checked side by side, 2026-10-20).
if (programPath.find("palmtreefoliage") != std::string::npos)
for (const auto& m : r.members)
if (m.name == "useCustomLightmap" && m.offset + 4 <= std::max<size_t>(r.size, 16)) { const int32_t zero = 0; std::memcpy(reinterpret_cast<u8*>(f) + m.offset, &zero, 4); }
// FH_MATSET=member=value,...: diagnostics, a material constant overridden for every mesh that has it (ints too).
if (const char* e = std::getenv("FH_MATSET")) {
std::string rest = e;
while (!rest.empty()) {
const size_t c = rest.find(',');
const std::string kv = rest.substr(0, c);
rest = c == std::string::npos ? "" : rest.substr(c + 1);
const size_t eq = kv.find('=');
if (eq == std::string::npos) continue;
for (const auto& m : r.members)
if (m.name == kv.substr(0, eq) && m.offset + 4 <= std::max<size_t>(r.size, 16)) {
if (m.name.rfind("use", 0) == 0 || m.name.find("Skip") != std::string::npos) {
const int32_t v = std::atoi(kv.c_str() + eq + 1);
std::memcpy(reinterpret_cast<u8*>(f) + m.offset, &v, 4);
} else {
f[m.offset / 4] = static_cast<float>(std::atof(kv.c_str() + eq + 1));
}
}
}
}
}
enum class Set2 { Material, Bones, Probe, Baker, Instance, Other };
// What a set-2 buffer holds, routed by name. The bone palette: named in most shaders; vista's ibl/ibltint declare it
// as an unnamed runtime array (left zero, every vertex collapsed to the origin and the whole architecture vanished).
inline Set2 set2Kind(const spv::Resource& r, bool bonedProgram) {
const std::string n = lc(r.name);
if (contains(n, "skinning") || contains(n, "sbskin") || contains(n, "bonemat") || (bonedProgram && r.name.empty() && r.size == 0 && r.binding != 0))
return Set2::Bones;
if (contains(n, "matparam") || (n.empty() && r.binding == 0)) return Set2::Material;
if (contains(n, "lightprobe")) return Set2::Probe;
if (contains(n, "lightbaker")) return Set2::Baker;
if (contains(n, "instance")) return Set2::Instance;
return Set2::Other;
}
// A set-2 buffer's contents (`f` holds max(r.size, 16) bytes at least), but for the bone palette (the renderer's).
// ambient: the scene's ambient light as the renderer uses it.
inline void fillSet2(const spv::Resource& r, Set2 kind, const MeshData& md, const std::string& programPath, const float ambient[3], float* f) {
std::memset(f, 0, std::max<size_t>(r.size, 16));
if (kind == Set2::Material) {
fillMatParams(r, md, programPath, f);
} else if (kind == Set2::Probe) {
// L00 is authored by the material (HSR: "matl"); the higher SH bands are runtime probe data: zero.
const std::vector<bool> got = fillBlock(r, md, f);
if (std::find(got.begin(), got.end(), true) == got.end()) {
for (int k = 0; k < 3; ++k) f[k] = ambient[k] * 3.5449f;
f[3] = 1;
}
} else if (kind == Set2::Baker) {
// {uvScale, uvOffset}, used as-is (no V-flip: HSR samples the lightmap with the raw UV2). The entity's
// lightingParams atlas patch outranks the material's: materials author the neutral (1,1)/(0,0), and with it
// every Theater wall sampled the whole shared 2048 atlas.
const std::vector<bool> got = fillBlock(r, md, f);
static const float kNeutral[4] = {1, 1, 0, 0};
if (std::find(got.begin(), got.end(), true) == got.end() || std::memcmp(md.lmUvScaleOffset, kNeutral, 16) != 0)
std::memcpy(f, md.lmUvScaleOffset, 16);
if (const char* e = std::getenv("FH_LBSET")) // diagnostics: every lightmap's uvScale.xy, uvOffset.xy
std::sscanf(e, "%f,%f,%f,%f", &f[0], &f[1], &f[2], &f[3]);
} else if (kind == Set2::Instance) {
f[0] = md.atlasCellIndex >= 0 ? md.atlasCellIndex : 0;
f[1] = md.vatTrackIndex >= 0 ? md.vatTrackIndex : 0;
f[2] = md.vatRateFactor >= 0 ? md.vatRateFactor : 1;
f[3] = md.vatTimeOffset >= 0 ? md.vatTimeOffset : 0;
}
}
// The bytes of the scene-wide uniform buffers that never change while the scene shows, as every program's shaders
// see them: the frame globals (set 0, binding 0: zero but for the white point, and reverseZ with FH_REVERSEZ; the
// matrices, camera, time, viewport and fog change and are never folded) and lightUniforms (set 1: zero but for the
// five members HSR fills with 1, for every program's layout of it).
struct SceneConstants {
std::vector<u8> globals, globalsFoldable, light;
};
// globalsSize / lightSize: the renderer's buffers. The globals' member list is the largest any program declares.
inline SceneConstants sceneConstants(const std::map<std::string, ShaderProgramSrc>& programs, uint32_t globalsSize, uint32_t lightSize) {
SceneConstants sc;
sc.globals.assign(globalsSize, 0);
sc.globalsFoldable.assign(globalsSize, 1);
sc.light.assign(lightSize, 0);
std::vector<spv::Member> globalMembers;
for (const auto& kv : programs) {
spv::Module vr, fr;
if (!spv::reflect(kv.second.vert, vr) || !spv::reflect(kv.second.frag, fr)) continue;
std::vector<spv::Resource> sets[3];
mergedSets(vr, fr, sets);
for (const auto& r : sets[0])
if (r.binding == 0 && r.kind == spv::ResKind::UniformBuffer && r.members.size() > globalMembers.size()) globalMembers = r.members;
for (const auto& r : sets[1]) {
const std::string n = lc(r.name);
if (r.kind != spv::ResKind::UniformBuffer || contains(n, "item") || contains(n, "grid")) continue;
for (const auto& m : r.members) {
const std::string mn = lc(m.name);
// HSR's derived lightUniforms: these five are 1 (byte 112-156, 168-172), all else zero.
const bool one = mn == "iblreflectiontint" || mn == "ibldiffusetint" || mn == "externalaverageluminance" ||
mn == "iblreflectionmaxadaptationfactor" || mn == "iblreflectionluminanceinverse";
if (!one || m.integer || m.offset + m.size > lightSize) continue;
const float v = 1.0f;
for (uint32_t k = 0; k * 4 < m.size; ++k) std::memcpy(&sc.light[m.offset + k * 4], &v, 4);
}
}
}
static const bool revZ = std::getenv("FH_REVERSEZ") != nullptr;
for (const auto& m : globalMembers) {
const std::string n = lc(m.name);
const bool perFrame = contains(n, "clipfromworld") || contains(n, "viewfromworld") || contains(n, "worldcamerapos") || n == "fogcolor" ||
n == "fogstart" || n == "fogdensity" || n == "time" || n == "worldtime" || contains(n, "viewportsize");
if (perFrame || m.offset + m.size > globalsSize) {
for (uint32_t k = m.offset; k < std::min(m.offset + m.size, globalsSize); ++k) sc.globalsFoldable[k] = 0;
continue;
}
const float one = 1;
if (n == "whitepoint" || (n == "reversez" && revZ)) std::memcpy(&sc.globals[m.offset], &one, 4);
}
return sc;
}
// The fetch for spvspec::specialize: the scene's constant images for sets 0 and 1, the mesh's buffers for set 2.
// set2: binding -> bytes (the bone palette left out).
inline spvspec::Fetch fetchFor(const SceneConstants& sc, const std::vector<spv::Resource> sets[3],
const std::map<uint32_t, std::vector<u8>>& set2) {
return [&sc, sets, &set2](uint32_t set, uint32_t binding, spvspec::BlockBytes& out) -> bool {
const spv::Resource* res = nullptr;
for (const auto& r : sets[std::min<uint32_t>(set, 2)]) if (r.binding == binding) res = &r;
if (!res || res->kind != spv::ResKind::UniformBuffer) return false;
if (set == 0) {
out.data = sc.globals.data();
out.size = static_cast<uint32_t>(sc.globals.size());
const std::vector<u8>* mask = &sc.globalsFoldable;
out.foldable = [mask](uint32_t o, uint32_t n) {
for (uint32_t k = o; k < o + n; ++k) if (k >= mask->size() || !(*mask)[k]) return false;
return true;
};
return true;
}
if (set == 1) { // lightUniforms (light items and the grid are storage buffers)
const std::string n = lc(res->name);
if (contains(n, "item") || contains(n, "grid")) return false;
out.data = sc.light.data();
out.size = static_cast<uint32_t>(sc.light.size());
return true;
}
const auto it = set2.find(binding);
if (it == set2.end()) return false;
out.data = it->second.data();
out.size = static_cast<uint32_t>(it->second.size());
return true;
};
}
// Which float values specialize bakes in: 0 none, 1 zeros, 2 zeros and +-1 (the default; FH_SPEC_FLOATS).
inline int floatsMode() {
static const int m = std::getenv("FH_SPEC_FLOATS") ? std::atoi(std::getenv("FH_SPEC_FLOATS")) : 2;
return m;
}
// The key of a mesh's specialization: every byte of the blocks its fragment shader reads constant members of (in
// blocksRead's order), and the float mode. The same in the bake and the renderer for the same values.
inline uint64_t variantKey(const std::vector<std::pair<uint32_t, uint32_t>>& blocks, const spvspec::Fetch& fetch, int floats) {
uint64_t key = 1469598103934665603ull;
auto mix = [&](const u8* d, size_t n) { for (size_t k = 0; k < n; ++k) key = (key ^ d[k]) * 0x100000001B3ull; };
const uint32_t mode = static_cast<uint32_t>(floats);
mix(reinterpret_cast<const u8*>(&mode), 4);
for (const auto& sb : blocks) {
spvspec::BlockBytes bb;
const uint32_t tag[3] = {sb.first, sb.second, fetch(sb.first, sb.second, bb) ? bb.size : 0};
mix(reinterpret_cast<const u8*>(tag), sizeof tag);
if (bb.data) mix(bb.data, bb.size);
if (bb.foldable) // (the globals' mask is part of what was baked in)
for (uint32_t k = 0; k < bb.size; ++k) { const u8 f = bb.foldable(k, 1) ? 1 : 0; mix(&f, 1); }
}
return key;
}
// Frame Studio's half: every program's prepared shaders and, per distinct set of material values its meshes have,
// the specialized fragment shader (and its alpha-only form), stored in the programs for the .fenv.
// programOf: the key in `programs` of the program the renderer draws a mesh with (empty: none).
inline void bakePrograms(const std::vector<MeshData>& meshes, const std::function<std::string(const MeshData&)>& programOf,
const float sceneAmbient[3], bool envAmbient, std::map<std::string, ShaderProgramSrc>& programs,
uint32_t globalsSize = 1024, uint32_t lightSize = 2048) {
const SceneConstants sc = sceneConstants(programs, globalsSize, lightSize);
float ambient[3];
for (int k = 0; k < 3; ++k) ambient[k] = sceneAmbient[k] * (envAmbient ? 1.0f : 1.8f);
struct Info { std::vector<spv::Resource> sets[3]; bool boned = false; std::vector<std::pair<uint32_t, uint32_t>> blocks; bool ok = false; };
std::map<std::string, Info> info;
for (auto& kv : programs) {
ShaderProgramSrc& p = kv.second;
p.vertInvariant = invariantPosition(p.vert);
p.fragAlpha = alphaOnly(p.frag);
p.variants.clear();
p.prepared = true;
Info& in = info[kv.first];
spv::Module vr, fr;
if (!spv::reflect(p.vert, vr) || !spv::reflect(p.frag, fr)) continue;
mergedSets(vr, fr, in.sets);
in.boned = boned(vr);
in.blocks = spvspec::blocksRead(p.frag);
in.ok = true;
}
const int floats = floatsMode();
for (const MeshData& md : meshes) {
const auto pi = programs.find(programOf(md));
if (pi == programs.end()) continue;
const Info& in = info[pi->first];
if (!in.ok || in.blocks.empty()) continue;
std::map<uint32_t, std::vector<u8>> set2;
for (const auto& r : in.sets[2]) {
if (r.kind != spv::ResKind::UniformBuffer || !r.size) continue;
const Set2 kind = set2Kind(r, in.boned);
if (kind == Set2::Bones) continue;
std::vector<float> f((std::max<size_t>(r.size, 16) + 3) / 4, 0.0f);
fillSet2(r, kind, md, pi->second.path, ambient, f.data());
set2[r.binding].assign(reinterpret_cast<const u8*>(f.data()), reinterpret_cast<const u8*>(f.data()) + r.size);
}
const spvspec::Fetch fetch = fetchFor(sc, in.sets, set2);
const uint64_t key = variantKey(in.blocks, fetch, floats);
ShaderProgramSrc& p = pi->second;
if (std::any_of(p.variants.begin(), p.variants.end(), [&](const ShaderProgramSrc::Variant& v) { return v.key == key; })) continue;
uint32_t folded = 0;
ShaderProgramSrc::Variant v;
v.key = key;
v.frag = spvspec::specialize(p.frag, fetch, &folded, -1, floats);
if (!folded) v.frag.clear(); // (nothing to bake in: the shader as shipped)
else v.fragAlpha = alphaOnly(v.frag);
p.variants.push_back(std::move(v));
}
}
} // namespace shaderbake
+236
View File
@@ -0,0 +1,236 @@
#pragma once
// Minimal SPIR-V reflection for Meta HSL shaders: vertex inputs, descriptor resources (by set/binding,
// with names and uniform-block member offsets), push-constant block, image dimensionality and the
// MultiView capability. Names come from OpName/OpMemberName — present in v206 shaders and restored for
// stripped v210 ones by spirv_rehydrate.h before reflection.
#include <algorithm>
#include <cstdint>
#include <functional>
#include <map>
#include <string>
#include <vector>
namespace spv {
enum class ResKind { UniformBuffer, StorageBuffer, Sampler, SampledImage, CombinedImageSampler, StorageImage };
enum class ImageDim { D2, D3, Cube };
struct Member { std::string name; uint32_t offset = 0, size = 0; bool integer = false; }; // integer: int/uint/bool scalar, vector or array
struct Resource {
ResKind kind = ResKind::UniformBuffer;
uint32_t set = 0, binding = 0, count = 1;
std::string name; // variable name, else block type name
uint32_t size = 0; // buffers: byte size of the block (0 for runtime arrays)
std::vector<Member> members; // buffers: top-level members
ImageDim dim = ImageDim::D2; // images
bool depthImage = false;
};
struct Input { uint32_t location = 0; std::string name; uint32_t components = 1; bool integer = false; uint32_t width = 32; };
struct Module {
uint32_t stage = 0xFFFFFFFFu; // SPIR-V execution model: 0 vertex, 4 fragment
bool multiview = false;
std::vector<Input> inputs; // vertex stage only
std::vector<Resource> resources;
uint32_t pushSize = 0;
std::vector<Member> pushMembers; // flattened one level: "pushConstants.worldFromModel"
};
inline std::string readString(const uint32_t* w, size_t n) {
std::string s(reinterpret_cast<const char*>(w), n * 4);
return s.substr(0, s.find('\0'));
}
inline bool reflect(const std::vector<uint32_t>& code, Module& out) {
if (code.size() < 5 || code[0] != 0x07230203u) return false;
struct Type { uint32_t op = 0; std::vector<uint32_t> a; };
std::map<uint32_t, Type> types;
std::map<uint32_t, std::string> names;
std::map<std::pair<uint32_t, uint32_t>, std::string> memberNames;
std::map<uint32_t, std::map<uint32_t, uint32_t>> deco; // id -> decoration -> value
std::map<std::pair<uint32_t, uint32_t>, std::map<uint32_t, uint32_t>> mdeco; // (struct, member) -> deco -> value
std::map<uint32_t, uint32_t> constants; // id -> 32-bit value
struct Var { uint32_t id, ptrType, storage; };
std::vector<Var> vars;
for (size_t i = 5; i < code.size();) {
uint32_t op = code[i] & 0xFFFF, wc = code[i] >> 16;
if (wc == 0 || i + wc > code.size()) return false;
const uint32_t* a = &code[i + 1];
uint32_t n = wc - 1;
switch (op) {
case 17: if (n >= 1 && a[0] == 4439) out.multiview = true; break; // OpCapability MultiView
case 15: if (out.stage == 0xFFFFFFFFu && n >= 1) out.stage = a[0]; break; // OpEntryPoint
case 5: if (n >= 2) names[a[0]] = readString(a + 1, n - 1); break; // OpName
case 6: if (n >= 3) memberNames[{a[0], a[1]}] = readString(a + 2, n - 2); break;
case 71: if (n >= 2) deco[a[0]][a[1]] = n >= 3 ? a[2] : 1; break; // OpDecorate
case 72: if (n >= 3) mdeco[{a[0], a[1]}][a[2]] = n >= 4 ? a[3] : 1; break; // OpMemberDecorate
case 43: if (n >= 3) constants[a[1]] = a[2]; break; // OpConstant
case 59: if (n >= 3) vars.push_back({a[1], a[0], a[2]}); break; // OpVariable
default:
if ((op >= 19 && op <= 32) || op == 27) { // OpType*
if (n >= 1) { Type t; t.op = op; t.a.assign(a + 1, a + n); types[a[0]] = t; }
}
break;
}
i += wc;
}
// Malformed modules can make types self-referential (recursion forever) or a DAG of wide structs (exponential
// walk): cap both the nesting depth and the total work. Real HSL types nest a handful of levels.
constexpr int kMaxTypeDepth = 32;
size_t typeBudget = 1u << 20;
// Byte size of a type as laid out in a buffer block (uses Offset/ArrayStride/MatrixStride decorations).
std::function<uint32_t(uint32_t, uint32_t, int)> sizeOf;
sizeOf = [&](uint32_t id, uint32_t matrixStride, int depth) -> uint32_t {
if (depth > kMaxTypeDepth || !typeBudget) return 0;
--typeBudget;
auto it = types.find(id);
if (it == types.end()) return 0;
const Type& t = it->second;
switch (t.op) {
case 21: case 22: return t.a.empty() ? 4 : t.a[0] / 8; // int / float
case 20: return 4; // bool
case 23: return t.a.size() >= 2 ? sizeOf(t.a[0], 0, depth + 1) * t.a[1] : 0; // vector
case 24: { // matrix (columns)
if (t.a.empty()) return 0;
uint32_t cols = t.a.size() >= 2 ? t.a[1] : 4;
return matrixStride ? matrixStride * cols : sizeOf(t.a[0], 0, depth + 1) * cols;
}
case 28: { // array
if (t.a.empty()) return 0;
uint32_t len = t.a.size() >= 2 && constants.count(t.a[1]) ? constants[t.a[1]] : 1;
auto d = deco.find(id);
uint32_t stride = d != deco.end() && d->second.count(6) ? d->second.at(6) : sizeOf(t.a[0], matrixStride, depth + 1);
return stride * len;
}
case 29: return 0; // runtime array
case 30: { // struct
uint32_t end = 0;
for (uint32_t m = 0; m < t.a.size(); ++m) {
auto md = mdeco.find({id, m});
uint32_t off = md != mdeco.end() && md->second.count(35) ? md->second.at(35) : 0;
uint32_t ms = md != mdeco.end() && md->second.count(7) ? md->second.at(7) : 0;
end = std::max(end, off + sizeOf(t.a[m], ms, depth + 1));
}
return end;
}
default: return 0;
}
};
// int / uint / bool at the bottom of a vector / matrix / array type
std::function<bool(uint32_t, int)> isInteger = [&](uint32_t id, int depth) -> bool {
if (depth > kMaxTypeDepth) return false;
auto it = types.find(id);
if (it == types.end()) return false;
const Type& t = it->second;
if (t.op == 20 || t.op == 21) return true;
if ((t.op == 23 || t.op == 24 || t.op == 28 || t.op == 29) && !t.a.empty()) return isInteger(t.a[0], depth + 1);
return false;
};
auto membersOf = [&](uint32_t structId) {
std::vector<Member> ms;
auto it = types.find(structId);
if (it == types.end() || it->second.op != 30) return ms;
for (uint32_t m = 0; m < it->second.a.size(); ++m) {
auto md = mdeco.find({structId, m});
Member mm;
mm.offset = md != mdeco.end() && md->second.count(35) ? md->second.at(35) : 0;
uint32_t mstride = md != mdeco.end() && md->second.count(7) ? md->second.at(7) : 0;
mm.size = sizeOf(it->second.a[m], mstride, 0);
mm.integer = isInteger(it->second.a[m], 0);
auto nm = memberNames.find({structId, m});
mm.name = nm != memberNames.end() ? nm->second : std::string();
ms.push_back(mm);
}
return ms;
};
auto pointee = [&](uint32_t ptrType) -> uint32_t {
auto it = types.find(ptrType);
return it != types.end() && it->second.op == 32 && it->second.a.size() >= 2 ? it->second.a[1] : 0;
};
for (const Var& v : vars) {
uint32_t ty = pointee(v.ptrType);
auto dv = deco.find(v.id);
const bool builtin = dv != deco.end() && dv->second.count(11);
if (v.storage == 1 /*Input*/ && out.stage == 0 && !builtin) {
if (dv == deco.end() || !dv->second.count(30)) continue;
Input in;
in.location = dv->second.at(30);
in.name = names.count(v.id) ? names[v.id] : std::string();
auto t = types.find(ty);
uint32_t scalar = ty;
if (t != types.end() && t->second.op == 23 && t->second.a.size() >= 2) { in.components = t->second.a[1]; scalar = t->second.a[0]; }
auto st = types.find(scalar);
if (st != types.end()) { in.integer = st->second.op == 21; in.width = st->second.a.empty() ? 32 : st->second.a[0]; }
out.inputs.push_back(in);
continue;
}
if (v.storage == 9 /*PushConstant*/) {
out.pushSize = std::max(out.pushSize, sizeOf(ty, 0, 0));
for (const Member& m : membersOf(ty)) {
// One level of nesting: pushConstants { worldFromModel, color }.
auto mt = types.find(ty);
uint32_t idx = 0;
for (; idx < mt->second.a.size(); ++idx) {
auto md = mdeco.find({ty, idx});
if ((md != mdeco.end() && md->second.count(35) ? md->second.at(35) : 0) == m.offset) break;
}
uint32_t inner = idx < mt->second.a.size() ? mt->second.a[idx] : 0;
auto it = types.find(inner);
if (it != types.end() && it->second.op == 30) {
for (Member sm : membersOf(inner)) { sm.offset += m.offset; sm.name = m.name + "." + sm.name; out.pushMembers.push_back(sm); }
} else {
out.pushMembers.push_back(m);
}
}
continue;
}
if (v.storage != 0 && v.storage != 2 && v.storage != 12) continue;
if (dv == deco.end() || !dv->second.count(34) || !dv->second.count(33)) continue;
Resource r;
r.set = dv->second.at(34);
r.binding = dv->second.at(33);
r.name = names.count(v.id) ? names[v.id] : std::string();
uint32_t base = ty;
auto bt = types.find(base);
if (bt != types.end() && (bt->second.op == 28 || bt->second.op == 29) && !bt->second.a.empty()) {
if (bt->second.op == 28 && bt->second.a.size() >= 2 && constants.count(bt->second.a[1])) r.count = constants[bt->second.a[1]];
base = bt->second.a[0];
bt = types.find(base);
}
if (bt == types.end()) continue;
switch (bt->second.op) {
case 30: {
auto db = deco.find(base);
bool bufferBlock = db != deco.end() && db->second.count(3);
r.kind = (v.storage == 12 || bufferBlock) ? ResKind::StorageBuffer : ResKind::UniformBuffer;
r.size = sizeOf(base, 0, 0);
r.members = membersOf(base);
if (r.name.empty() && names.count(base)) r.name = names[base];
break;
}
case 26: r.kind = ResKind::Sampler; break;
case 25: case 27: {
if (bt->second.op == 27 && bt->second.a.empty()) continue;
uint32_t img = bt->second.op == 27 ? bt->second.a[0] : base;
auto it = types.find(img);
if (it == types.end() || it->second.a.size() < 6) continue;
uint32_t dim = it->second.a[1];
r.dim = dim == 3 ? ImageDim::Cube : dim == 2 ? ImageDim::D3 : ImageDim::D2;
r.depthImage = it->second.a[2] == 1;
r.kind = bt->second.op == 27 ? ResKind::CombinedImageSampler
: it->second.a[5] == 2 ? ResKind::StorageImage : ResKind::SampledImage;
break;
}
default: continue;
}
out.resources.push_back(r);
}
return true;
}
} // namespace spv
+267
View File
@@ -0,0 +1,267 @@
#pragma once
// A fragment shader with the uniform values that never change for one material baked in as constants: an OpLoad of a
// uniform block member reached through constant indices (matParams flags and layers, lightUniforms.iblReflectionTint,
// globals.whitePoint ...) whose value is integer, or floats of exactly 0, 1 or -1, becomes an OpCopyObject of an
// OpConstant / OpConstantComposite holding the exact bytes the buffer holds. The bindings and layouts stay as they were (the buffers are still bound, just no longer read there),
// and the driver's compiler folds the constants and drops what they make dead (a mix() by 0, a multiply by 1, a
// branch on a flag). The same bits reach the same instructions: the picture doesn't change.
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <functional>
#include <map>
#include <vector>
namespace spvspec {
// What a block's bytes are: the data and which of them may be folded (others are left as loads).
struct BlockBytes {
const uint8_t* data = nullptr;
uint32_t size = 0;
std::function<bool(uint32_t offset, uint32_t size)> foldable; // empty: all of it
};
using Fetch = std::function<bool(uint32_t set, uint32_t binding, BlockBytes& out)>;
namespace detail {
enum : uint32_t {
OpDecorate = 71, OpMemberDecorate = 72, OpTypeBool = 20, OpTypeInt = 21, OpTypeFloat = 22, OpTypeVector = 23,
OpTypeMatrix = 24, OpTypeArray = 28, OpTypeStruct = 30, OpTypePointer = 32, OpConstant = 43, OpConstantComposite = 44,
OpFunction = 54, OpVariable = 59, OpLoad = 61, OpAccessChain = 65, OpInBoundsAccessChain = 66, OpCopyObject = 83,
DecBlock = 2, DecRowMajor = 4, DecArrayStride = 6, DecMatrixStride = 7, DecBinding = 33, DecDescriptorSet = 34, DecOffset = 35,
StorageUniform = 2,
};
struct Type { uint32_t op = 0; std::vector<uint32_t> a; };
struct Member { uint32_t offset = 0xFFFFFFFFu, matrixStride = 0; bool rowMajor = false; };
struct Ptr { uint32_t set = 0, binding = 0, offset = 0, type = 0, matrixStride = 0; bool rowMajor = false; };
struct Module {
std::map<uint32_t, Type> types;
std::map<uint32_t, std::map<uint32_t, Member>> members; // struct id -> member -> layout
std::map<uint32_t, uint32_t> arrayStride, set, binding, constU32;
std::map<uint32_t, bool> block;
std::map<uint32_t, Ptr> ptrs; // uniform member pointers reached by constant indices
std::map<uint32_t, uint32_t> ptrPointee; // uniform pointer type -> pointee
};
inline bool scan(const std::vector<uint32_t>& code, Module& m) {
if (code.size() < 5 || code[0] != 0x07230203u) return false;
for (size_t i = 5; i < code.size();) {
const uint32_t words = code[i] >> 16, op = code[i] & 0xFFFF;
if (!words || i + words > code.size()) return false;
const uint32_t* w = &code[i];
if (op == OpDecorate && words >= 3) {
if (w[2] == DecDescriptorSet && words >= 4) m.set[w[1]] = w[3];
else if (w[2] == DecBinding && words >= 4) m.binding[w[1]] = w[3];
else if (w[2] == DecArrayStride && words >= 4) m.arrayStride[w[1]] = w[3];
else if (w[2] == DecBlock) m.block[w[1]] = true;
} else if (op == OpMemberDecorate && words >= 4) {
Member& mm = m.members[w[1]][w[2]];
if (w[3] == DecOffset && words >= 5) mm.offset = w[4];
else if (w[3] == DecMatrixStride && words >= 5) mm.matrixStride = w[4];
else if (w[3] == DecRowMajor) mm.rowMajor = true;
} else if (op >= OpTypeBool && op <= OpTypePointer && words >= 2) {
m.types[w[1]] = {op, std::vector<uint32_t>(w + 2, w + words)};
if (op == OpTypePointer && words >= 4 && w[2] == StorageUniform) m.ptrPointee[w[1]] = w[3];
} else if (op == OpConstant && words == 4) {
const auto t = m.types.find(w[1]);
if (t != m.types.end() && t->second.op == OpTypeInt && t->second.a.size() >= 1 && t->second.a[0] == 32) m.constU32[w[2]] = w[3];
} else if (op == OpVariable && words >= 4 && w[3] == StorageUniform) {
const auto pt = m.ptrPointee.find(w[1]);
if (pt != m.ptrPointee.end() && m.block.count(pt->second) && m.set.count(w[2]) && m.binding.count(w[2]))
m.ptrs[w[2]] = {m.set[w[2]], m.binding[w[2]], 0, pt->second, 0, false};
} else if (op == OpFunction) {
break;
}
i += words;
}
return true;
}
inline uint32_t scalarBytes(const Module& m, uint32_t t) {
const auto it = m.types.find(t);
if (it == m.types.end()) return 0;
if ((it->second.op == OpTypeFloat || it->second.op == OpTypeInt) && !it->second.a.empty() && it->second.a[0] == 32) return 4;
return 0;
}
// One step down an access chain: (type, offset) of member / element `index`.
inline bool step(const Module& m, Ptr& p, uint32_t index) {
const auto it = m.types.find(p.type);
if (it == m.types.end()) return false;
const Type& t = it->second;
if (t.op == OpTypeStruct) {
if (index >= t.a.size()) return false;
const auto ms = m.members.find(p.type);
if (ms == m.members.end()) return false;
const auto mm = ms->second.find(index);
if (mm == ms->second.end() || mm->second.offset == 0xFFFFFFFFu) return false;
p.offset += mm->second.offset;
p.matrixStride = mm->second.matrixStride;
p.rowMajor = mm->second.rowMajor;
p.type = t.a[index];
return true;
}
if (t.op == OpTypeArray) {
const auto st = m.arrayStride.find(p.type);
if (st == m.arrayStride.end()) return false;
p.offset += index * st->second;
p.type = t.a[0];
return true;
}
if (t.op == OpTypeMatrix) {
if (!p.matrixStride || p.rowMajor) return false;
p.offset += index * p.matrixStride;
p.type = t.a[0];
return true;
}
if (t.op == OpTypeVector) {
const uint32_t b = scalarBytes(m, t.a[0]);
if (!b) return false;
p.offset += index * b;
p.type = t.a[0];
return true;
}
return false;
}
// The bytes a value of type `type` at `p` spans (0 when it can't be built as a constant).
inline uint32_t extent(const Module& m, const Ptr& p) {
const auto it = m.types.find(p.type);
if (it == m.types.end()) return 0;
const Type& t = it->second;
if (t.op == OpTypeFloat || t.op == OpTypeInt) return scalarBytes(m, p.type);
if (t.op == OpTypeVector) return scalarBytes(m, t.a[0]) * (t.a.size() >= 2 ? t.a[1] : 0);
uint32_t end = 0, n = 0;
if (t.op == OpTypeMatrix) n = t.a.size() >= 2 ? t.a[1] : 0;
else if (t.op == OpTypeArray) { const auto c = m.constU32.find(t.a.size() >= 2 ? t.a[1] : 0); n = c == m.constU32.end() ? 0 : c->second; }
else if (t.op == OpTypeStruct) n = static_cast<uint32_t>(t.a.size());
else return 0;
for (uint32_t k = 0; k < n; ++k) {
Ptr q = p;
if (!step(m, q, k)) return 0;
const uint32_t e = extent(m, q);
if (!e) return 0;
end = std::max(end, q.offset - p.offset + e);
}
return end;
}
} // namespace detail
// The blocks (set, binding) this shader loads constant-indexed members of: what a specialization depends on.
inline std::vector<std::pair<uint32_t, uint32_t>> blocksRead(const std::vector<uint32_t>& code) {
detail::Module m;
std::vector<std::pair<uint32_t, uint32_t>> out;
if (!detail::scan(code, m)) return out;
for (const auto& p : m.ptrs) {
const std::pair<uint32_t, uint32_t> sb = {p.second.set, p.second.binding};
if (std::find(out.begin(), out.end(), sb) == out.end()) out.push_back(sb);
}
return out;
}
// `code` with the foldable loads replaced (integers, and floats that are 0, 1 or -1); `folded` counts them. The input
// itself when nothing could be folded.
inline std::vector<uint32_t> specialize(const std::vector<uint32_t>& code, const Fetch& fetch, uint32_t* folded = nullptr, int limit = -1,
int floats = 2) {
using namespace detail;
uint32_t count = 0;
if (!folded) folded = &count;
*folded = 0;
Module m;
if (!scan(code, m)) return code;
std::map<std::pair<uint32_t, uint32_t>, BlockBytes> blocks;
std::map<std::pair<uint32_t, uint32_t>, bool> have;
uint32_t bound = code[3];
std::vector<uint32_t> consts; // new constants, before the first function
std::map<std::pair<uint32_t, std::vector<uint32_t>>, uint32_t> made; // (type, words) -> id
std::function<uint32_t(const Ptr&, const BlockBytes&)> build = [&](const Ptr& p, const BlockBytes& bb) -> uint32_t {
const auto it = m.types.find(p.type);
if (it == m.types.end()) return 0;
const Type& t = it->second;
std::vector<uint32_t> parts;
uint32_t op = OpConstantComposite;
if (t.op == OpTypeFloat || t.op == OpTypeInt) {
if (scalarBytes(m, p.type) != 4 || p.offset + 4 > bb.size) return 0;
uint32_t v;
std::memcpy(&v, bb.data + p.offset, 4);
// Floats only when exactly 0, 1 or -1: what they make dead (a multiply by 1, a layer mixed in by 0) goes
// exactly, while folding any other value let the compiler round an expression its own way (a last bit
// apart on 0.02% of Desert Terrace's pixels for its palms' colorIntensity), and saves next to nothing.
if (t.op == OpTypeFloat && floats < 1) return 0;
if (t.op == OpTypeFloat && (v & 0x7FFFFFFFu) != 0 && (floats < 2 || (v & 0x7FFFFFFFu) != 0x3F800000u)) return 0;
parts.push_back(v);
op = OpConstant;
} else {
uint32_t n = 0;
if (t.op == OpTypeVector || t.op == OpTypeMatrix) n = t.a.size() >= 2 ? t.a[1] : 0;
else if (t.op == OpTypeArray) { const auto c = m.constU32.find(t.a.size() >= 2 ? t.a[1] : 0); n = c == m.constU32.end() ? 0 : c->second; }
else if (t.op == OpTypeStruct) n = static_cast<uint32_t>(t.a.size());
if (!n || n > 1024) return 0;
for (uint32_t k = 0; k < n; ++k) {
Ptr q = p;
if (!step(m, q, k)) return 0;
const uint32_t id = build(q, bb);
if (!id) return 0;
parts.push_back(id);
}
}
const auto key = std::make_pair(p.type * 2 + (op == OpConstant ? 1u : 0u), parts);
if (const auto f = made.find(key); f != made.end()) return f->second;
const uint32_t id = bound++;
consts.push_back(((3u + static_cast<uint32_t>(parts.size())) << 16) | op);
consts.push_back(p.type);
consts.push_back(id);
consts.insert(consts.end(), parts.begin(), parts.end());
made[key] = id;
return id;
};
std::vector<uint32_t> body; // everything from the first OpFunction on, rewritten
size_t firstFunction = code.size();
for (size_t i = 5; i < code.size();) {
const uint32_t words = code[i] >> 16, op = code[i] & 0xFFFF;
if (!words || i + words > code.size()) return code;
if (op == OpFunction && firstFunction == code.size()) firstFunction = i;
const uint32_t* w = &code[i];
if (i >= firstFunction) {
if ((op == OpAccessChain || op == OpInBoundsAccessChain) && words >= 4) {
const auto base = m.ptrs.find(w[3]);
if (base != m.ptrs.end()) {
Ptr p = base->second;
bool ok = true;
for (uint32_t k = 4; k < words && ok; ++k) {
const auto c = m.constU32.find(w[k]);
ok = c != m.constU32.end() && step(m, p, c->second);
}
if (ok) m.ptrs[w[2]] = p;
}
} else if (op == OpLoad && words >= 4) {
const auto pp = m.ptrs.find(w[3]);
if (pp != m.ptrs.end() && pp->second.type == w[1] && (limit < 0 || static_cast<int>(*folded) < limit)) {
const Ptr& p = pp->second;
const auto sb = std::make_pair(p.set, p.binding);
if (!have.count(sb)) { BlockBytes bb; have[sb] = fetch(p.set, p.binding, bb); blocks[sb] = bb; }
const BlockBytes& bb = blocks[sb];
const uint32_t ext = have[sb] ? extent(m, p) : 0;
if (ext && p.offset + ext <= bb.size && (!bb.foldable || bb.foldable(p.offset, ext))) {
if (const uint32_t id = build(p, bb)) {
body.insert(body.end(), {(4u << 16) | OpCopyObject, w[1], w[2], id});
++*folded;
i += words;
continue;
}
}
}
}
body.insert(body.end(), w, w + words);
}
i += words;
}
if (consts.empty()) return code;
std::vector<uint32_t> out(code.begin(), code.begin() + static_cast<std::ptrdiff_t>(firstFunction));
out.insert(out.end(), consts.begin(), consts.end());
out.insert(out.end(), body.begin(), body.end());
out[3] = bound;
return out;
}
} // namespace spvspec
+322
View File
@@ -0,0 +1,322 @@
#pragma once
// Surfaces made from a home's meshes (Studio's object Surfaces, Frame Home's mesh colliders) are for walking on and
// bumping into, not for looking at: a sofa drawn with 50 000 triangles of stitching and cushion folds collides the same
// as a few thousand. simplifySurfaceMesh clusters the vertices on a grid (2.5 cm by default, a quarter of the walkable
// area's 10 cm cells, so nothing you can walk on, step up or bump into moves noticeably) and keeps one of each
// triangle left: slivers collapsed to a line or a point go, and so does the back of a two-sided face (it lands on
// the front). The vertices left are compacted. It's idempotent: simplifying a simplified mesh changes nothing.
#include "core/world.h"
#include <algorithm>
#include <array>
#include <cstdio>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <unordered_set>
#include <vector>
constexpr float kSurfaceCell = 0.025f; // m
// cell is in the mesh's own units (a surface stored in its object's frame: kSurfaceCell / the frame's scale).
inline void simplifySurfaceMesh(std::vector<float>& pos, std::vector<uint32_t>& idx, float cell) {
const size_t nv = pos.size() / 3;
if (!(cell > 0) || idx.size() < 3 || nv == 0) return;
constexpr int64_t kOff = int64_t(1) << 20;
auto key = [&](size_t v) -> uint64_t {
const int64_t q[3] = {static_cast<int64_t>(std::floor(pos[v * 3] / cell)), static_cast<int64_t>(std::floor(pos[v * 3 + 1] / cell)),
static_cast<int64_t>(std::floor(pos[v * 3 + 2] / cell))};
uint64_t k = 0;
for (int i = 0; i < 3; ++i) k = (k << 21) | static_cast<uint64_t>(std::clamp<int64_t>(q[i] + kOff, 0, (int64_t(1) << 21) - 1));
return k;
};
// clusters: each the average of the vertices in its cell
std::unordered_map<uint64_t, uint32_t> clusterOf;
clusterOf.reserve(nv);
std::vector<uint32_t> remap(nv);
std::vector<double> sum;
std::vector<uint32_t> count;
for (size_t v = 0; v < nv; ++v) {
auto [it, fresh] = clusterOf.try_emplace(key(v), static_cast<uint32_t>(count.size()));
if (fresh) { sum.insert(sum.end(), {0.0, 0.0, 0.0}); count.push_back(0); }
const uint32_t c = it->second;
remap[v] = c;
for (int k = 0; k < 3; ++k) sum[c * 3 + k] += pos[v * 3 + k];
++count[c];
}
std::vector<float> cp(count.size() * 3);
for (size_t c = 0; c < count.size(); ++c)
for (int k = 0; k < 3; ++k) cp[c * 3 + k] = static_cast<float>(sum[c * 3 + k] / count[c]);
// triangles: collapsed ones go, each one once (either winding)
std::unordered_set<uint64_t> seen;
seen.reserve(idx.size() / 3);
std::vector<uint32_t> out;
out.reserve(idx.size());
const bool dedupe = count.size() < (size_t(1) << 21);
const float minArea2 = cell * cell * 0.02f; // (twice the area)
for (size_t t = 0; t + 2 < idx.size(); t += 3) {
if (idx[t] >= nv || idx[t + 1] >= nv || idx[t + 2] >= nv) continue;
const uint32_t a = remap[idx[t]], b = remap[idx[t + 1]], c = remap[idx[t + 2]];
if (a == b || b == c || a == c) continue;
const float* A = &cp[a * 3]; const float* B = &cp[b * 3]; const float* C = &cp[c * 3];
const float e1[3] = {B[0] - A[0], B[1] - A[1], B[2] - A[2]}, e2[3] = {C[0] - A[0], C[1] - A[1], C[2] - A[2]};
const float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]};
if (std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]) < minArea2) continue;
if (dedupe) {
uint32_t s[3] = {a, b, c};
std::sort(s, s + 3);
if (!seen.insert((uint64_t(s[0]) << 42) | (uint64_t(s[1]) << 21) | s[2]).second) continue;
}
out.insert(out.end(), {a, b, c});
}
// compact: only the clusters still used
std::vector<uint32_t> used(count.size(), UINT32_MAX);
std::vector<float> np;
np.reserve(cp.size());
for (uint32_t& v : out) {
if (used[v] == UINT32_MAX) { used[v] = static_cast<uint32_t>(np.size() / 3); np.insert(np.end(), &cp[v * 3], &cp[v * 3] + 3); }
v = used[v];
}
pos.swap(np);
idx.swap(out);
}
// The cell in a frame's own units: kSurfaceCell over the frame's (largest) scale, so a scaled-up object isn't coarser.
inline float surfaceCellFor(const float* world) {
float s = 0;
for (int c = 0; c < 3; ++c) s = std::max(s, std::sqrt(world[c * 4] * world[c * 4] + world[c * 4 + 1] * world[c * 4 + 1] + world[c * 4 + 2] * world[c * 4 + 2]));
return s > 1e-6f ? kSurfaceCell / s : kSurfaceCell;
}
// The world's mesh colliders, simplified for walking (Meta's stand-ins are the entities' render meshes, and worlds an
// older Studio saved copied whole meshes). Ones already simplified don't change.
inline void simplifyWorldMeshes(HomeWorld& w) {
size_t before = 0, after = 0;
for (WorldCollider& c : w.colliders) {
if (c.kind != WorldCollider::Mesh || c.simplified) continue;
c.simplified = true;
if (c.indices.size() <= 3000) continue;
before += c.indices.size() / 3;
simplifySurfaceMesh(c.positions, c.indices, surfaceCellFor(c.world));
after += c.indices.size() / 3;
}
if (after < before) std::fprintf(stderr, "[world] mesh colliders simplified: %zu -> %zu triangles\n", before, after);
}
// ── Surfaces you place (boxes and meshes not made from a home object): two that overlap become one ──
// A Box or Mesh collider's triangles in world space, 9 floats each (spheres and capsules: none).
inline std::vector<float> colliderTriangles(const WorldCollider& c) {
std::vector<float> out;
auto xf = [&](float x, float y, float z, float* o) {
for (int k = 0; k < 3; ++k) o[k] = c.world[k] * x + c.world[4 + k] * y + c.world[8 + k] * z + c.world[12 + k];
};
if (c.kind == WorldCollider::Box) {
float v[8][3];
for (int k = 0; k < 8; ++k) xf((k & 1 ? 1 : -1) * c.half[0], (k & 2 ? 1 : -1) * c.half[1], (k & 4 ? 1 : -1) * c.half[2], v[k]);
static const int f[36] = {0, 2, 3, 0, 3, 1, 4, 5, 7, 4, 7, 6, 0, 1, 5, 0, 5, 4, 2, 6, 7, 2, 7, 3, 0, 4, 6, 0, 6, 2, 1, 3, 7, 1, 7, 5};
for (int i : f) out.insert(out.end(), v[i], v[i] + 3);
} else if (c.kind == WorldCollider::Mesh) {
const size_t nv = c.positions.size() / 3;
for (size_t t = 0; t + 2 < c.indices.size(); t += 3)
for (int k = 0; k < 3; ++k) {
const uint32_t i = c.indices[t + k];
float p[3] = {0, 0, 0};
if (i < nv) xf(c.positions[i * 3], c.positions[i * 3 + 1], c.positions[i * 3 + 2], p);
out.insert(out.end(), p, p + 3);
}
}
return out;
}
namespace surface_detail {
// Does segment p-q cross triangle abc? (Möller-Trumbore on the segment's length)
inline bool segmentHits(const float* p, const float* q, const float* a, const float* b, const float* c) {
const float d[3] = {q[0] - p[0], q[1] - p[1], q[2] - p[2]};
const float e1[3] = {b[0] - a[0], b[1] - a[1], b[2] - a[2]}, e2[3] = {c[0] - a[0], c[1] - a[1], c[2] - a[2]};
const float h[3] = {d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]};
const float det = e1[0] * h[0] + e1[1] * h[1] + e1[2] * h[2];
if (std::fabs(det) < 1e-12f) return false;
const float inv = 1 / det, s[3] = {p[0] - a[0], p[1] - a[1], p[2] - a[2]};
const float u = (s[0] * h[0] + s[1] * h[1] + s[2] * h[2]) * inv;
if (u < 0 || u > 1) return false;
const float qv[3] = {s[1] * e1[2] - s[2] * e1[1], s[2] * e1[0] - s[0] * e1[2], s[0] * e1[1] - s[1] * e1[0]};
const float v = (d[0] * qv[0] + d[1] * qv[1] + d[2] * qv[2]) * inv;
if (v < 0 || u + v > 1) return false;
const float t = (e2[0] * qv[0] + e2[1] * qv[1] + e2[2] * qv[2]) * inv;
return t >= 0 && t <= 1;
}
// Is p inside the closed triangle soup? (crossings of a ray from p, slightly tilted so it misses edges)
inline bool inside(const std::vector<float>& tris, const float* p) {
const float far[3] = {p[0] + 1000.0f, p[1] + 0.731f, p[2] + 0.377f};
int n = 0;
for (size_t t = 0; t + 8 < tris.size(); t += 9) n += segmentHits(p, far, &tris[t], &tris[t + 3], &tris[t + 6]);
return n & 1;
}
// The volume a soup encloses (0 for an open sheet: a flat plate has no inside to bury anything in).
inline float volume(const std::vector<float>& t) {
double v = 0;
for (size_t i = 0; i + 8 < t.size(); i += 9)
v += t[i] * (t[i + 4] * t[i + 8] - t[i + 5] * t[i + 7]) - t[i + 1] * (t[i + 3] * t[i + 8] - t[i + 5] * t[i + 6]) + t[i + 2] * (t[i + 3] * t[i + 7] - t[i + 4] * t[i + 6]);
return static_cast<float>(std::fabs(v) / 6);
}
inline void bounds(const std::vector<float>& tris, float* lo, float* hi) {
for (int k = 0; k < 3; ++k) { lo[k] = 1e30f; hi[k] = -1e30f; }
for (size_t i = 0; i < tris.size(); ++i) { lo[i % 3] = std::min(lo[i % 3], tris[i]); hi[i % 3] = std::max(hi[i % 3], tris[i]); }
}
} // namespace surface_detail
// Do two triangle soups (colliderTriangles) touch: an edge of one through a face of the other, or one inside the other.
inline bool surfacesTouch(const std::vector<float>& a, const std::vector<float>& b) {
using namespace surface_detail;
if (a.empty() || b.empty()) return false;
float alo[3], ahi[3], blo[3], bhi[3];
bounds(a, alo, ahi);
bounds(b, blo, bhi);
for (int k = 0; k < 3; ++k) if (alo[k] > bhi[k] + 1e-3f || blo[k] > ahi[k] + 1e-3f) return false;
auto edgesThrough = [](const std::vector<float>& x, const std::vector<float>& y) {
for (size_t t = 0; t + 8 < x.size(); t += 9)
for (int e = 0; e < 3; ++e)
for (size_t u = 0; u + 8 < y.size(); u += 9)
if (segmentHits(&x[t + e * 3], &x[t + (e + 1) % 3 * 3], &y[u], &y[u + 3], &y[u + 6])) return true;
return false;
};
return edgesThrough(a, b) || edgesThrough(b, a) || (volume(b) > 1e-6f && inside(b, &a[0])) || (volume(a) > 1e-6f && inside(a, &b[0]));
}
namespace surface_detail {
// The parts of triangle abc outside a box (its 6 planes cut it; what's inside all of them goes), as triangles. On a
// face of the box counts as inside unless `onIsOut` (two surfaces sharing a face: one of them keeps it).
inline void outsideBox(const float* tri, const WorldCollider& box, std::vector<float>& out, bool onIsOut) {
using Poly = std::vector<std::array<float, 3>>;
struct Plane { float u[3], c, h, sgn; };
std::vector<Plane> planes;
for (int axis = 0; axis < 3; ++axis) {
float u[3] = {box.world[axis * 4], box.world[axis * 4 + 1], box.world[axis * 4 + 2]};
const float L = std::sqrt(u[0] * u[0] + u[1] * u[1] + u[2] * u[2]);
if (L < 1e-9f) { out.insert(out.end(), tri, tri + 9); return; }
for (float& x : u) x /= L;
const float c = u[0] * box.world[12] + u[1] * box.world[13] + u[2] * box.world[14], h = box.half[axis] * L;
for (float sgn : {1.0f, -1.0f}) planes.push_back({{u[0], u[1], u[2]}, c, h, sgn});
}
const float eps = onIsOut ? -1e-5f : 1e-5f; // inside a side: distance past it <= eps
auto dist = [&](const Plane& p, const float* x) { return p.sgn * (p.u[0] * x[0] + p.u[1] * x[1] + p.u[2] * x[2] - p.c) - p.h; };
bool allIn = true;
for (const Plane& p : planes) {
int outN = 0;
for (int k = 0; k < 3; ++k) outN += dist(p, tri + k * 3) > eps;
if (outN == 3) { out.insert(out.end(), tri, tri + 9); return; } // wholly outside one side: kept whole
allIn &= outN == 0;
}
if (allIn) return; // wholly inside: buried
std::vector<Poly> inside{{{tri[0], tri[1], tri[2]}, {tri[3], tri[4], tri[5]}, {tri[6], tri[7], tri[8]}}};
std::vector<Poly> keep;
for (const Plane& pl : planes) {
{ // the part outside this side is kept; the part inside goes on to the next side
std::vector<Poly> next;
for (const Poly& P : inside) {
Poly in, outP;
for (size_t i = 0; i < P.size(); ++i) {
const auto& A = P[i];
const auto& B = P[(i + 1) % P.size()];
const float da = dist(pl, A.data()) - eps, db = dist(pl, B.data()) - eps;
(da <= 0 ? in : outP).push_back(A);
if ((da < 0 && db > 0) || (da > 0 && db < 0)) {
const float t = da / (da - db);
const std::array<float, 3> X = {A[0] + (B[0] - A[0]) * t, A[1] + (B[1] - A[1]) * t, A[2] + (B[2] - A[2]) * t};
in.push_back(X);
outP.push_back(X);
}
}
if (outP.size() >= 3) keep.push_back(outP);
if (in.size() >= 3) next.push_back(in);
}
inside.swap(next);
}
}
for (const Poly& P : keep)
for (size_t i = 1; i + 1 < P.size(); ++i)
for (const auto* v : {&P[0], &P[i], &P[i + 1]}) out.insert(out.end(), v->begin(), v->end());
}
} // namespace surface_detail
// Two surfaces as one, in world space. Boxes (and combined surfaces' boxes, kept in WorldCollider::boxes) give the
// exact outside of their union: each box's faces cut where they go into another box (a face two share is kept once,
// the earlier box's). Any other mesh (a sheet, an imported shape) adds its faces less the ones wholly buried in a
// solid part, and the result then can't be cut exactly again (no boxes kept). Welded.
inline WorldCollider combineSurfaces(const WorldCollider& a, const WorldCollider& b) {
using namespace surface_detail;
std::vector<WorldCollider> boxes; // world frames
std::vector<std::vector<float>> meshes; // other parts' world triangles
for (const WorldCollider* x : {&a, &b}) {
if (x->kind == WorldCollider::Box) { boxes.push_back(*x); continue; }
if (x->boxes.empty()) { meshes.push_back(colliderTriangles(*x)); continue; }
for (size_t i = 0; i + 19 <= x->boxes.size(); i += 19) {
WorldCollider bx;
bx.kind = WorldCollider::Box;
float m[16];
for (int r = 0; r < 4; ++r)
for (int c = 0; c < 4; ++c) {
float v = 0;
for (int k = 0; k < 4; ++k) v += x->world[k * 4 + r] * x->boxes[i + c * 4 + k];
m[c * 4 + r] = v;
}
std::copy_n(m, 16, bx.world);
std::copy_n(&x->boxes[i + 16], 3, bx.half);
boxes.push_back(bx);
}
}
WorldCollider c;
c.name = a.name;
c.kind = WorldCollider::Mesh;
c.role = a.role;
c.hidden = a.hidden && b.hidden;
c.placed = true;
auto add = [&](const float* t) {
for (int k = 0; k < 9; ++k) c.positions.push_back(t[k]);
const uint32_t i = static_cast<uint32_t>(c.indices.size());
c.indices.insert(c.indices.end(), {i, i + 1, i + 2});
};
std::vector<std::vector<float>> boxTris(boxes.size());
for (size_t i = 0; i < boxes.size(); ++i) boxTris[i] = colliderTriangles(boxes[i]);
auto buriedIn = [&](const float* t, const std::vector<float>& other) { // wholly inside a solid soup
if (volume(other) <= 1e-6f) return false;
for (int k = 0; k < 3; ++k) { // its corners pulled a little towards the middle (a corner on the face isn't in)
float p[3];
for (int j = 0; j < 3; ++j) p[j] = t[k * 3 + j] * 0.98f + (t[j] + t[3 + j] + t[6 + j]) / 3 * 0.02f;
if (!inside(other, p)) return false;
}
return true;
};
for (size_t i = 0; i < boxes.size(); ++i) {
std::vector<float> cur = boxTris[i];
for (size_t j = 0; j < boxes.size() && !cur.empty(); ++j) {
if (j == i) continue;
std::vector<float> next;
for (size_t t = 0; t + 8 < cur.size(); t += 9) outsideBox(&cur[t], boxes[j], next, i < j);
cur.swap(next);
}
for (size_t t = 0; t + 8 < cur.size(); t += 9) {
bool buried = false;
for (const auto& m : meshes) buried |= buriedIn(&cur[t], m);
if (!buried) add(&cur[t]);
}
}
for (size_t i = 0; i < meshes.size(); ++i) {
std::vector<float> cur = meshes[i];
for (size_t j = 0; j < boxes.size() && !cur.empty(); ++j) {
std::vector<float> next;
for (size_t t = 0; t + 8 < cur.size(); t += 9) outsideBox(&cur[t], boxes[j], next, false);
cur.swap(next);
}
for (size_t t = 0; t + 8 < cur.size(); t += 9) {
bool buried = false;
for (size_t k = 0; k < meshes.size(); ++k) if (k != i) buried |= buriedIn(&cur[t], meshes[k]);
if (!buried) add(&cur[t]);
}
}
if (meshes.empty())
for (const WorldCollider& bx : boxes) { c.boxes.insert(c.boxes.end(), bx.world, bx.world + 16); c.boxes.insert(c.boxes.end(), bx.half, bx.half + 3); }
simplifySurfaceMesh(c.positions, c.indices, 0.001f); // shared corners welded
return c;
}
+1985
View File
File diff suppressed because it is too large. Load diff
+171
View File
@@ -0,0 +1,171 @@
#pragma once
// A disk cache of mip chains for textures shipped without one. The first time a home opens, such a texture is drawn
// as before (decoded, shrunk to maxDecodedSide, RGBA mips: slow, heavy in memory) while its real ASTC chain is built
// in the background (tex_mips.h: the full-size level exactly as shipped) and saved here; every later open uploads
// that chain compressed, with nothing decoded. Entries are keyed by the texture's own bytes, so any home (APK, .fenv,
// model) that carries the same texture shares one. Kept under 2 GB (the least recently used go first).
#include "tex_mips.h"
#include <atomic>
#include <condition_variable>
#include <cstdio>
#include <cstring>
#include <deque>
#include <filesystem>
#include <mutex>
#include <set>
#include <string>
#include <system_error>
#include <thread>
#ifndef _WIN32
#include <sys/resource.h>
#include <sys/syscall.h>
#include <unistd.h>
#else
#include <windows.h>
#endif
namespace texcache {
// FRAME_TEX_CACHE, else %LOCALAPPDATA%\FrameStudio\cache\texmips (Windows) or ~/.cache/frame-home/texmips.
inline std::filesystem::path dir() {
if (const char* e = std::getenv("FRAME_TEX_CACHE")) return std::filesystem::u8path(e);
#ifdef _WIN32
if (const char* l = std::getenv("LOCALAPPDATA")) return std::filesystem::u8path(std::string(l) + "\\FrameStudio\\cache\\texmips");
#endif
if (const char* x = std::getenv("XDG_CACHE_HOME")) return std::filesystem::u8path(std::string(x) + "/frame-home/texmips");
const char* h = std::getenv("HOME");
return std::filesystem::u8path(std::string(h ? h : "/tmp") + "/.cache/frame-home/texmips");
}
inline uint64_t key(const TexSource& t) {
uint64_t k = 1469598103934665603ull;
auto mix = [&](uint64_t x) { k = (k ^ x) * 0x100000001B3ull; k ^= k >> 29; };
mix(t.w); mix(t.h); mix(t.bw); mix(t.bh);
const size_t n = std::min<size_t>(t.astc.size(), astcMip0Bytes(t.w, t.h, t.bw, t.bh));
size_t i = 0;
for (; i + 8 <= n; i += 8) { uint64_t x; std::memcpy(&x, t.astc.data() + i, 8); mix(x); }
for (; i < n; ++i) mix(t.astc[i]);
mix(n);
return k;
}
inline std::filesystem::path pathFor(uint64_t k) {
char name[32];
std::snprintf(name, sizeof name, "%016llx.ftxm", static_cast<unsigned long long>(k));
return dir() / name;
}
struct Header { char magic[4]; uint32_t version, w, h, bw, bh, mips; uint64_t bytes; };
// The cached chain for `t`, or null.
inline std::shared_ptr<TexSource> load(const TexSource& t) {
if (!texmips::wants(t)) return nullptr;
const std::filesystem::path p = pathFor(key(t));
FILE* f = std::fopen(p.string().c_str(), "rb");
if (!f) return nullptr;
Header hd = {};
std::shared_ptr<TexSource> out;
if (std::fread(&hd, sizeof hd, 1, f) == 1 && std::memcmp(hd.magic, "FTXM", 4) == 0 && hd.version == 1 && hd.w == t.w && hd.h == t.h &&
hd.bw == t.bw && hd.bh == t.bh && hd.mips == astcFullMipCount(t.w, t.h) && hd.bytes < (1ull << 31)) {
auto s = std::make_shared<TexSource>();
s->w = t.w; s->h = t.h; s->bw = t.bw; s->bh = t.bh; s->mips = hd.mips; s->hdr = t.hdr; s->faces = 1;
s->astc.resize(static_cast<size_t>(hd.bytes));
if (std::fread(s->astc.data(), 1, s->astc.size(), f) == s->astc.size()) out = std::move(s);
}
std::fclose(f);
if (out) { // used: the last to be pruned
std::error_code ec;
std::filesystem::last_write_time(p, std::filesystem::file_time_type::clock::now(), ec);
}
return out;
}
inline void prune(uint64_t limit) {
std::error_code ec;
std::vector<std::pair<std::filesystem::file_time_type, std::filesystem::path>> files;
uint64_t total = 0;
for (const auto& e : std::filesystem::directory_iterator(dir(), ec)) {
if (e.path().extension() != ".ftxm") continue;
const uint64_t s = e.file_size(ec);
total += s;
files.push_back({e.last_write_time(ec), e.path()});
}
if (total <= limit) return;
std::sort(files.begin(), files.end());
for (const auto& f : files) {
if (total <= limit) break;
const uint64_t s = std::filesystem::file_size(f.second, ec);
if (std::filesystem::remove(f.second, ec)) total -= s;
}
}
inline bool store(const TexSource& chain, uint64_t k) {
std::error_code ec;
std::filesystem::create_directories(dir(), ec);
const std::filesystem::path p = pathFor(k), tmp = p.string() + ".part";
FILE* f = std::fopen(tmp.string().c_str(), "wb");
if (!f) return false;
Header hd = {{'F', 'T', 'X', 'M'}, 1, chain.w, chain.h, chain.bw, chain.bh, chain.mips, chain.astc.size()};
const bool ok = std::fwrite(&hd, sizeof hd, 1, f) == 1 && std::fwrite(chain.astc.data(), 1, chain.astc.size(), f) == chain.astc.size();
if (std::fclose(f) != 0 || !ok) { std::filesystem::remove(tmp, ec); return false; }
std::filesystem::rename(tmp, p, ec);
if (ec) { std::filesystem::remove(tmp, ec); return false; }
prune(2ull << 30);
return true;
}
// Builds and saves `t`'s chain on a background thread at the lowest priority, one texture at a time, two encoder
// threads (never in the way of a frame). Each texture once per run.
class Builder {
public:
static Builder& get() { static Builder b; return b; }
void add(std::shared_ptr<const TexSource> t) {
if (!t || !texmips::wants(*t)) return;
std::lock_guard<std::mutex> l(m_);
if (!queued_.insert(t.get()).second) return;
q_.push_back(std::move(t));
if (!th_.joinable()) th_ = std::thread([this] { run(); });
cv_.notify_one();
}
size_t pending() { std::lock_guard<std::mutex> l(m_); return q_.size() + (busy_ ? 1 : 0); }
~Builder() { // never holds up an exit: a texture half encoded is left as a .part file
{ std::lock_guard<std::mutex> l(m_); stop_ = true; q_.clear(); }
cv_.notify_all();
if (th_.joinable()) th_.detach();
}
private:
void run() {
#ifdef _WIN32
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_LOWEST);
#else
setpriority(PRIO_PROCESS, static_cast<id_t>(syscall(SYS_gettid)), 19); // (its encoder threads inherit it)
#endif
for (;;) {
std::shared_ptr<const TexSource> t;
{
std::unique_lock<std::mutex> l(m_);
cv_.wait(l, [&] { return stop_ || !q_.empty(); });
if (stop_) return;
t = std::move(q_.front());
q_.pop_front();
busy_ = true;
}
const uint64_t k = key(*t);
std::error_code ec;
if (t.use_count() > 1 && !std::filesystem::exists(pathFor(k), ec)) // (held only here: its home is closed)
if (std::shared_ptr<TexSource> chain = texmips::withMips(*t, 2)) store(*chain, k);
std::lock_guard<std::mutex> l(m_);
busy_ = false;
}
}
std::mutex m_;
std::condition_variable cv_;
std::deque<std::shared_ptr<const TexSource>> q_;
std::set<const TexSource*> queued_;
std::thread th_;
bool stop_ = false, busy_ = false;
};
} // namespace texcache
+89
View File
@@ -0,0 +1,89 @@
#pragma once
// Mip chains for textures that ship without one. Several Meta homes (Desert, Cascadia) carry 2048-8192 ASTC textures
// with only their full-size level: drawn as they are they shimmer at a distance, so the renderer decodes them at load,
// shrinks them to 2048 and builds the smaller levels in RGBA: blurrier than the original, slow to load, heavy in memory.
// Baking a .fenv gives them a real chain instead: the full-size level stays exactly as shipped, and each smaller one is
// box-filtered from the level above it and encoded to ASTC in the same block size. The GPU then samples them natively,
// full sharpness up close and no shimmer far away.
#include "core/types.h"
#include "loaders/rendtxtr_parser.h"
#include "astcenc.h"
#include <algorithm>
#include <memory>
#include <thread>
#include <vector>
namespace texmips {
// Worth a chain: an LDR 2D texture bigger than 256 with no smaller levels.
inline bool wants(const TexSource& t) {
return !t.hdr && t.faces == 1 && t.mips <= 1 && std::max(t.w, t.h) > 256 && astcDimsOk(t.w, t.h, t.bw, t.bh) &&
t.astc.size() >= astcMip0Bytes(t.w, t.h, t.bw, t.bh);
}
// `t` with its full chain, or null when it can't be made (the texture is then kept as it was). threads: encoder
// threads (0 = every core).
inline std::shared_ptr<TexSource> withMips(const TexSource& t, unsigned threads = 0) {
if (!wants(t)) return nullptr;
std::vector<u8> level;
const u32 mip0 = astcMip0Bytes(t.w, t.h, t.bw, t.bh);
if (!astc::decodeASTC(t.astc.data(), mip0, t.w, t.h, t.bw, t.bh, level, false, 0)) return nullptr;
astcenc_config cfg = {};
if (astcenc_config_init(ASTCENC_PRF_LDR, t.bw, t.bh, 1, ASTCENC_PRE_FAST, 0, &cfg) != ASTCENC_SUCCESS) return nullptr;
unsigned n = threads ? threads : std::thread::hardware_concurrency();
n = std::clamp(n, 1u, 16u);
astcenc_context* ctx = nullptr;
if (astcenc_context_alloc(&cfg, n, &ctx, nullptr) != ASTCENC_SUCCESS) return nullptr;
auto out = std::make_shared<TexSource>(t);
out->astc.assign(t.astc.begin(), t.astc.begin() + mip0); // the full-size level, exactly as shipped
const u32 levels = astcFullMipCount(t.w, t.h);
u32 w = t.w, h = t.h;
bool ok = true;
const astcenc_swizzle swz = {ASTCENC_SWZ_R, ASTCENC_SWZ_G, ASTCENC_SWZ_B, ASTCENC_SWZ_A};
for (u32 l = 1; l < levels && ok; ++l) {
// 2x2 box filter (an odd edge folds its last row / column in)
const u32 nw = std::max(1u, w / 2), nh = std::max(1u, h / 2);
std::vector<u8> next(static_cast<size_t>(nw) * nh * 4);
for (u32 y = 0; y < nh; ++y)
for (u32 x = 0; x < nw; ++x) {
const u32 x0 = std::min(w - 1, x * 2), x1 = std::min(w - 1, x * 2 + 1), y0 = std::min(h - 1, y * 2), y1 = std::min(h - 1, y * 2 + 1);
for (int c = 0; c < 4; ++c) {
const unsigned s = level[(static_cast<size_t>(y0) * w + x0) * 4 + c] + level[(static_cast<size_t>(y0) * w + x1) * 4 + c] +
level[(static_cast<size_t>(y1) * w + x0) * 4 + c] + level[(static_cast<size_t>(y1) * w + x1) * 4 + c];
next[(static_cast<size_t>(y) * nw + x) * 4 + c] = static_cast<u8>((s + 2) / 4);
}
}
level.swap(next);
w = nw;
h = nh;
const size_t bytes = static_cast<size_t>((w + t.bw - 1) / t.bw) * ((h + t.bh - 1) / t.bh) * 16;
const size_t at = out->astc.size();
out->astc.resize(at + bytes);
void* px = level.data();
astcenc_image img = {};
img.dim_x = w; img.dim_y = h; img.dim_z = 1;
img.data_type = ASTCENC_TYPE_U8;
img.data = &px;
std::vector<astcenc_error> e(n, ASTCENC_SUCCESS);
if (n > 1 && static_cast<size_t>(w) * h >= 256 * 256) { // big levels on every core, the small ones on one
std::vector<std::thread> th;
for (unsigned i = 0; i < n; ++i) th.emplace_back([&, i] { e[i] = astcenc_compress_image(ctx, &img, &swz, out->astc.data() + at, bytes, i); });
for (auto& x : th) x.join();
} else {
// A context made for n threads waits for all n in compress: one thread uses a context of its own.
astcenc_context* one = nullptr;
if (astcenc_context_alloc(&cfg, 1, &one, nullptr) != ASTCENC_SUCCESS) { ok = false; break; }
e[0] = astcenc_compress_image(one, &img, &swz, out->astc.data() + at, bytes, 0);
astcenc_context_free(one);
}
for (astcenc_error x : e) ok = ok && x == ASTCENC_SUCCESS;
astcenc_compress_reset(ctx);
}
astcenc_context_free(ctx);
if (!ok) return nullptr;
out->mips = levels;
return out;
}
} // namespace texmips
+74
View File
@@ -0,0 +1,74 @@
#pragma once
// The headset's temperature, for Frame Home's thermal governor: the hottest of the GPU and CPU sensors the kernel
// lists in /sys/class/thermal (thermal_zone*/type "gpuss-*", "cpu*", "cpuss*"), in degrees C. The Steam Frame throttles
// its GPU clock (903 MHz down to ~310) once these reach the mid-90s, and every frame then costs up to three times as
// long: keeping them at the headset's ordinary level keeps the clock, and the frame rate, where they belong.
// Elsewhere (the PC, a desktop Linux with no such zones) it reads nothing and the governor stays out of the way.
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
class ThermalSensors {
public:
ThermalSensors() {
std::error_code ec;
for (const auto& e : std::filesystem::directory_iterator("/sys/class/thermal", ec)) {
const std::string name = e.path().filename().string();
if (name.rfind("thermal_zone", 0) != 0) continue;
std::ifstream t(e.path() / "type");
std::string type;
std::getline(t, type);
if (type.rfind("gpuss", 0) == 0 || type.rfind("cpu", 0) == 0) zones_.push_back((e.path() / "temp").string());
}
}
bool available() const { return !zones_.empty(); }
// The hottest zone now, in degrees C (0 when there are none).
float hottest() const {
float best = 0;
for (const std::string& z : zones_) {
FILE* f = std::fopen(z.c_str(), "r");
if (!f) continue;
long milli = 0;
if (std::fscanf(f, "%ld", &milli) == 1) best = std::max(best, milli / 1000.0f);
std::fclose(f);
}
return best;
}
private:
std::vector<std::string> zones_;
};
// Holds the temperature at a limit by adding foveation strength (coarser toward the edges of view, where you aren't
// looking: the cheapest work to give up) a little at a time while it's above, and handing it back once it's below.
// The headset heats and cools over minutes, so it steps slowly and waits while the temperature is already falling
// (the last step is still taking effect): otherwise it overshoots to the strongest setting and stays there. Smoothed so
// a passing spike doesn't move it; each step small enough that the change isn't seen.
class ThermalGovernor {
public:
// limit: degrees C to stay at or under (0: off). Called about once a second.
float update(float tempC, float limit, float dtSec) {
if (limit <= 0 || tempC <= 0) { extra_ = 0; return 0; }
smooth_ = smooth_ <= 0 ? tempC : smooth_ + (tempC - smooth_) * std::min(1.0f, dtSec / 8.0f);
trend_ = trend_ <= 0 ? smooth_ : trend_ + (smooth_ - trend_) * std::min(1.0f, dtSec / 30.0f); // ~30 s behind
const bool falling = smooth_ < trend_ - 0.2f;
since_ += dtSec;
if (smooth_ > limit + 0.5f && !falling && since_ >= 6.0f) { // hot and not yet easing: a little more off the edges
extra_ = std::min(kMax, extra_ + 0.0625f);
since_ = 0;
} else if (smooth_ < limit - 1.0f && extra_ > 0 && since_ >= 8.0f) { // under the limit again: give it back
extra_ = std::max(0.0f, extra_ - 0.0625f);
since_ = 0;
}
return extra_;
}
float extra() const { return extra_; }
float smoothed() const { return smooth_; }
static constexpr float kMax = 2.0f; // foveation strength it may add (on top of the setting)
private:
float extra_ = 0, smooth_ = 0, trend_ = 0, since_ = 0;
};
+474
View File
@@ -0,0 +1,474 @@
#pragma once
// The mesh optimizer's visibility pass (Frame Studio's save, fenv-tool): geometry the player can never see is left out
// of the baked scene. Seen from where: everywhere the player can be (every walkable floor of the walk grid and a band
// past its edges for leaning and room-scale steps, spawns, seats, teleport spots), at eye heights from crouching to
// tiptoe, in every direction (a cube of six 90-degree views per spot). From each spot the solid, still geometry is
// drawn into a depth cube (a CPU rasterizer); a triangle is seen when its screen box, widened a pixel, reaches past
// that depth anywhere (conservative: coverage is the box, depth its nearest corner, the occluders shrunk by a pixel).
// What no spot sees goes: single triangles of still, solid meshes (the backs of walls, under floors, behind them),
// and whole meshes of anything else (see-through, cut-out, moved by its shader) whose widened bounds no spot sees.
// The seen triangles' neighbours stay too (a ring round everything seen). Never touched: the sky, animated, skinned
// and edited meshes. Vertices aren't removed (a shader may number them): only index lists shrink.
#include "hsl_scene.h"
#include "walkgrid.h"
#include "home_edits.h"
#include "core/parallel.h"
#include <algorithm>
#include <array>
#include <atomic>
#include <memory>
#include <chrono>
#include <cmath>
#include <cstring>
#include <limits>
#include <map>
#include <set>
#include <unordered_map>
#include <vector>
struct VisibilityResult {
std::vector<std::vector<u32>> indices; // per mesh: what's kept (valid where changed[m])
std::vector<char> changed;
size_t samples = 0, trisBefore = 0, trisAfter = 0, meshesEmptied = 0, meshesTouched = 0;
double ms = 0;
std::string skipped; // why nothing was done ("" when it ran)
};
namespace visbake {
// Whether a vertex shader puts vertices where the mesh data says (and leaves them there): it reads no clock, view or
// other per-frame value (only clipFromWorld, the camera position for fog, reverseZ) and no texture (vertex animation
// textures). A bone palette it may read (vista's ibl skins every mesh) is identity for a mesh nothing animates: the
// caller rules out the animated ones. SPIR-V scan of its access chains.
inline bool staticVertexShader(const std::vector<u32>& code) {
if (code.size() < 5 || code[0] != 0x07230203u) return false;
std::map<u32, u32> set, binding, pointee, varPtr, varStorage, constant;
std::map<std::pair<u32, u32>, u32> memberOffset;
for (size_t i = 5; i < code.size();) {
const u32 words = code[i] >> 16, op = code[i] & 0xFFFF;
if (!words || i + words > code.size()) return false;
const u32* a = &code[i + 1];
if ((op >= 87 && op <= 98) || (op >= 305 && op <= 320)) return false; // image sampling / fetch / read
if (op == 71 && words >= 4 && a[1] == 34) set[a[0]] = a[2]; // DescriptorSet
if (op == 71 && words >= 4 && a[1] == 33) binding[a[0]] = a[2]; // Binding
if (op == 72 && words >= 5 && a[2] == 35) memberOffset[{a[0], a[1]}] = a[3]; // member Offset
if (op == 32 && words >= 4) pointee[a[0]] = a[2]; // OpTypePointer
if (op == 43 && words >= 4) constant[a[1]] = a[2]; // OpConstant
if (op == 59 && words >= 4) { varPtr[a[1]] = a[0]; varStorage[a[1]] = a[2]; } // OpVariable
i += words;
}
u32 globals = 0;
for (const auto& v : varStorage)
if (v.second == 2 && set.count(v.first) && set[v.first] == 0 && binding.count(v.first) && binding[v.first] == 0) globals = v.first;
const u32 globalsStruct = globals ? pointee[varPtr[globals]] : 0;
for (size_t i = 5; i < code.size();) {
const u32 words = code[i] >> 16, op = code[i] & 0xFFFF;
const u32* a = &code[i + 1];
if ((op == 65 || op == 66) && words >= 4) { // OpAccessChain
const u32 base = a[2];
const auto st = varStorage.find(base);
if (st != varStorage.end()) {
if (base == globals) {
if (words < 5 || !constant.count(a[3])) return false;
const auto off = memberOffset.find({globalsStruct, constant[a[3]]});
if (off == memberOffset.end()) return false;
if (off->second != 0 && off->second != 384 && off->second != 604) return false; // clipFromWorld, worldCameraPos, reverseZ
}
}
}
i += words;
}
return true;
}
// Whether a fragment shader leaves coverage and depth to the rasterizer (no discard, no depth or mask written): an
// opaque mesh drawn with it hides what's behind it everywhere it covers.
inline bool solidFragmentShader(const std::vector<u32>& code) {
if (code.size() < 5 || code[0] != 0x07230203u) return false;
for (size_t i = 5; i < code.size();) {
const u32 words = code[i] >> 16, op = code[i] & 0xFFFF;
if (!words || i + words > code.size()) return false;
if (op == 252 || op == 4416 || op == 5380) return false;
if ((op == 71 && words >= 4 && code[i + 2] == 11 && (code[i + 3] == 22 || code[i + 3] == 20)) ||
(op == 72 && words >= 5 && code[i + 3] == 11 && (code[i + 4] == 22 || code[i + 4] == 20)))
return false;
i += words;
}
return true;
}
constexpr float kNear = 0.02f;
// Cube face f (0..5: +X -X +Y -Y +Z -Z) of a point v relative to the eye: its depth along the face's axis, and the two
// across (u, w). In view when |u|, |w| <= depth.
inline float faceOf(const float* v, int f, float& u, float& w) {
const int ax = f >> 1;
u = v[(ax + 1) % 3];
w = v[(ax + 2) % 3];
return (f & 1) ? -v[ax] : v[ax];
}
// One face's depth: the nearest occluder per pixel, then the "far" pyramid the tests read (level 0: the farthest of each
// pixel's 3 x 3, so an occluder edge can't hide what a slightly different eye would see past it; then halves, farthest).
struct FaceDepth {
int n = 0, levels = 0;
std::vector<float> z;
std::vector<std::vector<float>> farZ;
void reset(int size) {
n = size;
z.assign(static_cast<size_t>(n) * n, std::numeric_limits<float>::infinity());
levels = 0;
for (int s = n; s >= 1; s /= 2) ++levels;
farZ.resize(levels);
}
void build() {
farZ[0].resize(z.size());
for (int y = 0; y < n; ++y)
for (int x = 0; x < n; ++x) {
float m = 0;
for (int j = std::max(0, y - 1); j <= std::min(n - 1, y + 1); ++j)
for (int i = std::max(0, x - 1); i <= std::min(n - 1, x + 1); ++i) m = std::max(m, z[static_cast<size_t>(j) * n + i]);
farZ[0][static_cast<size_t>(y) * n + x] = m;
}
for (int l = 1, s = n / 2; l < levels; ++l, s /= 2) {
const int ps = s * 2;
farZ[l].resize(static_cast<size_t>(s) * s);
for (int y = 0; y < s; ++y)
for (int x = 0; x < s; ++x) {
const float* a = &farZ[l - 1][static_cast<size_t>(y * 2) * ps + x * 2];
farZ[l][static_cast<size_t>(y) * s + x] = std::max(std::max(a[0], a[1]), std::max(a[ps], a[ps + 1]));
}
}
}
// Whether anything in pixels [x0, x1] x [y0, y1] lies farther than depth d (something there at d could be seen).
bool farther(int x0, int y0, int x1, int y1, float d) const {
int l = 0;
while (l + 1 < levels && ((x1 >> l) - (x0 >> l) > 2 || (y1 >> l) - (y0 >> l) > 2)) ++l;
const int s = n >> l;
const float lim = d * (1.0f - 1e-4f) - 1e-4f;
for (int y = y0 >> l; y <= (y1 >> l) && y < s; ++y)
for (int x = x0 >> l; x <= (x1 >> l) && x < s; ++x)
if (farZ[l][static_cast<size_t>(y) * s + x] > lim) return true;
return false;
}
};
// Whether points (eye-relative) are all off face f's view on the same side (in front of the eye too: depth >= kNear).
inline bool outsideFace(const float (*v)[3], int count, int f) {
int out[5] = {0, 0, 0, 0, 0};
for (int k = 0; k < count; ++k) {
float u, w;
const float d = faceOf(v[k], f, u, w);
out[0] += d < kNear; out[1] += u > d; out[2] += u < -d; out[3] += w > d; out[4] += w < -d;
}
for (int k = 0; k < 5; ++k) if (out[k] == count) return true;
return false;
}
// A polygon (eye-relative points) clipped to depth >= kNear on face f, as screen points (pixels) and depths.
inline int clipToFace(const float (*v)[3], int count, int f, int n, float (*out)[3]) {
float in[8][3], d[8];
for (int k = 0; k < count; ++k) {
float u, w;
d[k] = faceOf(v[k], f, u, w);
in[k][0] = u; in[k][1] = w; in[k][2] = d[k];
}
int m = 0;
for (int k = 0; k < count; ++k) {
const int j = (k + 1) % count;
const bool a = d[k] >= kNear, b = d[j] >= kNear;
if (a) { std::memcpy(out[m], in[k], sizeof out[m]); ++m; }
if (a != b) {
const float t = (kNear - d[k]) / (d[j] - d[k]);
for (int c = 0; c < 3; ++c) out[m][c] = in[k][c] + (in[j][c] - in[k][c]) * t;
out[m][2] = kNear;
++m;
}
}
const float h = n * 0.5f;
for (int k = 0; k < m; ++k) { out[k][0] = (out[k][0] / out[k][2] + 1) * h; out[k][1] = (out[k][1] / out[k][2] + 1) * h; }
return m;
}
// Fills an occluder triangle (eye-relative) into face f's nearest depths (pixel centres inside, depth from 1/z).
inline void rasterOccluder(const float (*v)[3], int f, FaceDepth& fd) {
float p[8][3];
const int m = clipToFace(v, 3, f, fd.n, p);
for (int k = 1; k + 1 < m; ++k) {
const float* a = p[0];
const float* b = p[k];
const float* c = p[k + 1];
float area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
if (std::fabs(area) < 1e-6f) continue;
const int x0 = std::max(0, static_cast<int>(std::floor(std::min({a[0], b[0], c[0]})))), x1 = std::min(fd.n - 1, static_cast<int>(std::ceil(std::max({a[0], b[0], c[0]}))));
const int y0 = std::max(0, static_cast<int>(std::floor(std::min({a[1], b[1], c[1]})))), y1 = std::min(fd.n - 1, static_cast<int>(std::ceil(std::max({a[1], b[1], c[1]}))));
if (x0 > x1 || y0 > y1) continue;
const float ia = 1 / area, za = 1 / a[2], zb = 1 / b[2], zc = 1 / c[2];
for (int y = y0; y <= y1; ++y) {
const float py = y + 0.5f;
for (int x = x0; x <= x1; ++x) {
const float px = x + 0.5f;
const float w0 = ((b[0] - px) * (c[1] - py) - (b[1] - py) * (c[0] - px)) * ia;
const float w1 = ((c[0] - px) * (a[1] - py) - (c[1] - py) * (a[0] - px)) * ia;
const float w2 = 1 - w0 - w1;
if (w0 < 0 || w1 < 0 || w2 < 0) continue;
const float iz = w0 * za + w1 * zb + w2 * zc;
if (iz <= 0) continue;
float& dst = fd.z[static_cast<size_t>(y) * fd.n + x];
dst = std::min(dst, 1 / iz);
}
}
}
}
// Whether a polygon (eye-relative: a triangle, or a box's 8 corners as a cloud) can be seen on face f: its screen box
// widened a pixel reaches something farther than its nearest point. Partly behind the eye: seen if on the face at all.
inline bool seenOnFace(const float (*v)[3], int count, int f, const FaceDepth& fd, bool cloud) {
float u, w;
float minD = std::numeric_limits<float>::infinity();
bool behind = false;
float lo[2] = {1e30f, 1e30f}, hi[2] = {-1e30f, -1e30f};
const float h = fd.n * 0.5f;
for (int k = 0; k < count; ++k) {
const float d = faceOf(v[k], f, u, w);
if (d < kNear) { behind = true; continue; }
minD = std::min(minD, d);
const float sx = (u / d + 1) * h, sy = (w / d + 1) * h;
lo[0] = std::min(lo[0], sx); hi[0] = std::max(hi[0], sx);
lo[1] = std::min(lo[1], sy); hi[1] = std::max(hi[1], sy);
}
if (behind) {
if (cloud) return true; // a box round the eye, or reaching behind it: seen
float p[8][3];
const int m = clipToFace(v, count, f, fd.n, p);
if (m < 3) return false;
lo[0] = lo[1] = 1e30f; hi[0] = hi[1] = -1e30f;
for (int k = 0; k < m; ++k) { lo[0] = std::min(lo[0], p[k][0]); hi[0] = std::max(hi[0], p[k][0]); lo[1] = std::min(lo[1], p[k][1]); hi[1] = std::max(hi[1], p[k][1]); }
minD = kNear;
}
const int x0 = std::max(0, static_cast<int>(std::floor(lo[0])) - 1), x1 = std::min(fd.n - 1, static_cast<int>(std::floor(hi[0])) + 1);
const int y0 = std::max(0, static_cast<int>(std::floor(lo[1])) - 1), y1 = std::min(fd.n - 1, static_cast<int>(std::floor(hi[1])) + 1);
if (x0 > x1 || y0 > y1) return false;
return fd.farther(x0, y0, x1, y1, minD);
}
// Whether an eye at height y over cell (cx, cz) is out in the open: off the grid, or clear of every solid span there.
inline bool eyeFree(const WalkGrid& g, int cx, int cz, float y) {
if (!g.inside(cx, cz)) return true;
for (const WalkSpan* s = g.begin(cx, cz); s != g.end(cx, cz); ++s)
if (y >= s->lo - 0.08f && y <= s->hi + 0.08f) return false;
return true;
}
} // namespace visbake
struct VisibilityOptions {
float spacing = 0.35f; // between spots on the floor (m); more where the area ends
float margin = 0.4f; // how far past the area's edges the head goes (leaning, a step in the room)
float heights[4] = {0.35f, 0.9f, 1.45f, 2.0f}; // eye heights over the floor: crouching to tiptoe
int face = 256; // depth cube face size (pixels; a power of two)
size_t maxSpots = 9000; // more than this: wider spacing
std::vector<int> pinned; // meshes kept whole besides the animated ones (all a CPU animation writes)
};
// The pass. `grid`: the home's walkable area (walkWorld); `edits`: Frame Studio's object edits (their meshes are kept
// whole and hide nothing). False (result.skipped says why) when it can't vouch for the home.
inline bool bakeVisibility(const HslScene& scene, const HomeWorld& world, const WalkGrid* grid, const std::vector<ObjectEdit>* edits,
VisibilityResult& out, const VisibilityOptions& opt = VisibilityOptions()) {
using namespace visbake;
const auto t0 = std::chrono::steady_clock::now();
out = VisibilityResult();
const size_t nm = scene.meshes.size();
out.indices.resize(nm);
out.changed.assign(nm, 0);
if (!grid || grid->walkArea <= 0) { out.skipped = "no walkable area"; return false; }
if (!world.gravity.empty()) { out.skipped = "gravity zones (you can stand on walls)"; return false; }
// What each mesh is: 2 still and solid (triangles go, and it hides what's behind it), 1 still but see-through or
// cut-out (goes whole, hides nothing), 0 kept whole.
std::vector<char> kind(nm, 0);
std::vector<char> pinned(nm, 0);
for (int m : scene.animatedMeshes) if (m >= 0 && static_cast<size_t>(m) < nm) pinned[m] = 1;
for (int m : opt.pinned) if (m >= 0 && static_cast<size_t>(m) < nm) pinned[m] = 1;
for (const HslAnimGroup& a : scene.anims) for (int m : a.meshes) if (m >= 0 && static_cast<size_t>(m) < nm) pinned[m] = 1;
if (edits) for (const ObjectEdit& e : *edits) if (e.index >= 0 && static_cast<size_t>(e.index) < nm) pinned[e.index] = 1;
std::map<std::string, std::pair<bool, bool>> progKind; // shader path -> (still vertices, solid fragments)
for (size_t m = 0; m < nm; ++m) {
const MeshData& md = scene.meshes[m];
if (pinned[m] || md.isSkybox || md.overlayKind || md.dynamicVerts || md.hasVat || !md.skinJoints.empty() || md.indices.size() < 3 ||
md.positions.empty())
continue;
auto it = progKind.find(md.shaderPath);
if (it == progKind.end()) {
const ShaderProgramSrc* p = findProgram(scene, md.shaderPath);
std::string path = md.shaderPath;
for (char& c : path) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
const bool vege = path.find("vege") != std::string::npos || path.find("veg_") != std::string::npos || path.find("billboard") != std::string::npos;
it = progKind.emplace(md.shaderPath, std::make_pair(p && !vege && staticVertexShader(p->vert), p && solidFragmentShader(p->frag))).first;
}
if (!it->second.first) continue;
const bool solid = !md.useBlend && !md.additive && !md.alphaTest && !md.alphaToCoverage && it->second.second;
kind[m] = solid ? 2 : 1;
}
// World-space triangles: the solid ones (occluders, tested one by one), boxes for the rest.
struct Tri { float v[3][3]; u32 mesh, index; };
std::vector<Tri> tris;
std::vector<std::array<float, 6>> boxes(nm);
std::vector<size_t> firstTri(nm + 1, 0);
for (size_t m = 0; m < nm; ++m) {
firstTri[m] = tris.size();
const MeshData& md = scene.meshes[m];
out.trisBefore += md.indices.size() / 3;
if (!kind[m]) continue;
float wm[16];
if (md.hasWorldMatrix) std::memcpy(wm, md.worldMatrix, sizeof wm); else trsToMat4(md.transform, wm);
auto xf = [&](u32 vi, float* o) {
const float* p = &md.positions[static_cast<size_t>(vi) * 3];
for (int j = 0; j < 3; ++j) o[j] = wm[j] * p[0] + wm[4 + j] * p[1] + wm[8 + j] * p[2] + wm[12 + j];
};
std::array<float, 6> bb = {1e30f, 1e30f, 1e30f, -1e30f, -1e30f, -1e30f};
for (size_t t = 0; t + 2 < md.indices.size(); t += 3) {
Tri tr;
bool ok = true;
for (int k = 0; k < 3 && ok; ++k) {
const u32 vi = md.indices[t + k];
if (static_cast<size_t>(vi) * 3 + 2 >= md.positions.size()) { ok = false; break; }
xf(vi, tr.v[k]);
for (int j = 0; j < 3; ++j) { bb[j] = std::min(bb[j], tr.v[k][j]); bb[3 + j] = std::max(bb[3 + j], tr.v[k][j]); }
}
if (!ok) { kind[m] = 0; break; }
if (kind[m] == 2) { tr.mesh = static_cast<u32>(m); tr.index = static_cast<u32>(t); tris.push_back(tr); }
}
if (!kind[m]) { tris.resize(firstTri[m]); continue; }
// a see-through / cut-out mesh's box, widened (a little sway, an offset the material adds)
for (int j = 0; j < 3; ++j) { const float g = 0.25f + 0.1f * (bb[3 + j] - bb[j]); bb[j] -= g; bb[3 + j] += g; }
boxes[m] = bb;
}
firstTri[nm] = tris.size();
// Where the eyes can be.
const WalkParams& P = grid->p;
std::vector<std::array<float, 3>> spots;
auto addHeights = [&](float x, float y, float z, const float* hs, int n) { for (int k = 0; k < n; ++k) spots.push_back({x, y + hs[k], z}); };
float spacing = opt.spacing;
for (int pass = 0; pass < 6; ++pass) {
spots.clear();
const int step = std::max(1, static_cast<int>(std::lround(spacing / P.cell)));
for (int cz = 0; cz < grid->nz; ++cz)
for (int cx = 0; cx < grid->nx; ++cx)
for (const WalkSpan* s = grid->begin(cx, cz); s != grid->end(cx, cz); ++s) {
if (!(s->flags & kWalkOk)) continue;
static const int nb[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
bool edge = false;
for (const auto& d : nb)
if (!grid->walkNear(cx + d[0], cz + d[1], s->hi, P.step, kWalkOk)) {
edge = true;
// past the edge: the head over it (a lean, a step in the room), as far as the margin goes
// before the eye would be in something (a wall, the sofa: from inside, everything shows)
if ((cx + cz) % std::max(1, step / 2) != 0) continue;
const int reach = std::max(1, static_cast<int>(std::lround(opt.margin / P.cell)));
for (float hh : opt.heights) {
const float y = s->hi + hh;
int k = 0;
while (k < reach && eyeFree(*grid, cx + d[0] * (k + 1), cz + d[1] * (k + 1), y)) ++k;
if (k > 0) spots.push_back({grid->centerX(cx + d[0] * k), y, grid->centerZ(cz + d[1] * k)});
}
}
if (edge ? (cx + cz) % std::max(1, step / 2) == 0 : (cx % step == 0 && cz % step == 0))
addHeights(grid->centerX(cx), s->hi, grid->centerZ(cz), opt.heights, 4);
}
if (spots.size() <= opt.maxSpots) break;
spacing *= std::sqrt(static_cast<float>(spots.size()) / opt.maxSpots) * 1.05f;
}
static const float seatH[3] = {0.5f, 0.8f, 1.1f};
for (const WorldPoint& p : world.seats) addHeights(p.pos[0], p.pos[1], p.pos[2], seatH, 3);
for (const auto* list : {&world.spawns, &world.hotspots})
for (const WorldPoint& p : *list) addHeights(p.pos[0], p.pos[1], p.pos[2], opt.heights, 4);
if (const char* e = std::getenv("FH_VIS_SPOTS")) { // diagnostics: only the first n spots (spread evenly)
const size_t n = std::max<size_t>(1, std::strtoul(e, nullptr, 10));
if (n < spots.size()) { std::vector<std::array<float, 3>> few; for (size_t i = 0; i < n; ++i) few.push_back(spots[i * spots.size() / n]); spots.swap(few); }
}
out.samples = spots.size();
if (spots.empty()) { out.skipped = "nowhere to stand"; return false; }
// From every spot, six faces: occluders in, then what's seen marked.
std::unique_ptr<std::atomic<uint8_t>[]> seenTri(new std::atomic<uint8_t>[tris.size() ? tris.size() : 1]);
std::unique_ptr<std::atomic<uint8_t>[]> seenMesh(new std::atomic<uint8_t>[nm ? nm : 1]);
for (size_t i = 0; i < tris.size(); ++i) seenTri[i].store(0, std::memory_order_relaxed);
for (size_t i = 0; i < nm; ++i) seenMesh[i].store(0, std::memory_order_relaxed);
const int faceN = opt.face;
par::forEach(spots.size(), [&](size_t si) {
thread_local FaceDepth fds[6];
const float* e = spots[si].data();
for (int f = 0; f < 6; ++f) fds[f].reset(faceN);
float v[8][3];
for (const Tri& t : tris) {
for (int k = 0; k < 3; ++k) for (int j = 0; j < 3; ++j) v[k][j] = t.v[k][j] - e[j];
for (int f = 0; f < 6; ++f) if (!outsideFace(v, 3, f)) rasterOccluder(v, f, fds[f]);
}
for (int f = 0; f < 6; ++f) fds[f].build();
for (size_t i = 0; i < tris.size(); ++i) {
if (seenTri[i].load(std::memory_order_relaxed)) continue;
for (int k = 0; k < 3; ++k) for (int j = 0; j < 3; ++j) v[k][j] = tris[i].v[k][j] - e[j];
for (int f = 0; f < 6; ++f)
if (!outsideFace(v, 3, f) && seenOnFace(v, 3, f, fds[f], false)) { seenTri[i].store(1, std::memory_order_relaxed); break; }
}
for (size_t m = 0; m < nm; ++m) {
if (kind[m] != 1 || seenMesh[m].load(std::memory_order_relaxed)) continue;
const auto& b = boxes[m];
if (e[0] >= b[0] && e[0] <= b[3] && e[1] >= b[1] && e[1] <= b[4] && e[2] >= b[2] && e[2] <= b[5]) { seenMesh[m].store(1); continue; }
for (int k = 0; k < 8; ++k) {
v[k][0] = b[(k & 1) ? 3 : 0] - e[0];
v[k][1] = b[(k & 2) ? 4 : 1] - e[1];
v[k][2] = b[(k & 4) ? 5 : 2] - e[2];
}
for (int f = 0; f < 6; ++f)
if (!outsideFace(v, 8, f) && seenOnFace(v, 8, f, fds[f], true)) { seenMesh[m].store(1, std::memory_order_relaxed); break; }
}
});
// What's kept: the seen triangles and one ring round them (triangles touching a seen one's corner), seen meshes.
for (size_t m = 0; m < nm; ++m) {
const MeshData& md = scene.meshes[m];
if (kind[m] == 1 && !seenMesh[m].load()) {
out.changed[m] = 1;
++out.meshesEmptied;
continue;
}
if (kind[m] != 2) { out.trisAfter += md.indices.size() / 3; continue; }
std::set<std::array<float, 3>> corners;
bool all = true;
for (size_t i = firstTri[m]; i < firstTri[m + 1]; ++i) {
if (!seenTri[i].load()) { all = false; continue; }
for (int k = 0; k < 3; ++k) {
const float* p = &md.positions[static_cast<size_t>(md.indices[tris[i].index + k]) * 3];
corners.insert({p[0], p[1], p[2]});
}
}
if (all) { out.trisAfter += md.indices.size() / 3; continue; }
std::vector<u32>& keep = out.indices[m];
for (size_t i = firstTri[m]; i < firstTri[m + 1]; ++i) {
const u32 t = tris[i].index;
bool k = seenTri[i].load() != 0;
static const bool noRing = std::getenv("FH_VIS_NORING") != nullptr; // diagnostics: the seen ones alone
for (int c = 0; c < 3 && !k && !noRing; ++c) {
const float* p = &md.positions[static_cast<size_t>(md.indices[t + c]) * 3];
k = corners.count({p[0], p[1], p[2]}) != 0;
}
if (k) keep.insert(keep.end(), md.indices.begin() + t, md.indices.begin() + t + 3);
}
out.changed[m] = 1;
out.trisAfter += keep.size() / 3;
if (keep.empty()) ++out.meshesEmptied; else ++out.meshesTouched;
}
out.ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
if (std::getenv("FH_VIS_DEBUG")) // per mesh: what it was taken for and what's left
for (size_t m = 0; m < nm; ++m) {
const MeshData& md = scene.meshes[m];
std::fprintf(stderr, "[optimize] %-50s %s %6zu -> %6zu tris [%s]\n", md.name.c_str(), kind[m] == 2 ? "solid" : kind[m] == 1 ? "whole" : "kept ",
md.indices.size() / 3, out.changed[m] ? out.indices[m].size() / 3 : md.indices.size() / 3, md.shaderPath.c_str());
}
return true;
}
+1601
View File
File diff suppressed because it is too large. Load diff
+1069
View File
File diff suppressed because it is too large. Load diff
+454
View File
@@ -0,0 +1,454 @@
#pragma once
// The world layer at run time: what the player stands on and bumps into. Built once per home from HomeWorld:
// floors — upward faces of floor + walls surfaces, everything not too steep on walkable floors, box tops
// walls — steep faces of floor + walls surfaces, every face of wall surfaces (a Wall part is all wall, whatever
// its slope: Studio's Wall + Floor split leaves 40-45 degree faces in it), plus box / sphere / capsule
// colliders that block
// (each collider's role says which it gives: see WorldCollider::Role; triggers give neither).
// Both sit in a 1 m grid over XZ, so a query touches a handful of triangles instead of hundreds of thousands.
// A world with a walkable area (HomeWorld::walkGrid / walkGridCache, src/walkgrid.h) answers from it instead: floors,
// walking, edges and teleport landings all come from the one baked area (seams bridged, furniture and ledges
// blocked, the body's radius kept from walls), so Frame Home walks exactly what Frame Studio shows. Box / sphere /
// capsule colliders still report which one a teleport arc hit (seats).
// Frame Home walks and teleports with it; Frame Studio previews the same answers.
#include "core/world.h"
#include "walkgrid.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <unordered_map>
#include <vector>
class WorldRuntime {
public:
struct Tri { float a[3], b[3], c[3]; float n[3]; bool solid = false; }; // solid: a floor that blocks from below
void build(const HomeWorld& w) {
floors_.clear(); walls_.clear(); floorGrid_.clear(); wallGrid_.clear(); shapes_.clear();
grid_ = w.walkGridCache;
if (!grid_ && !w.walkGrid.empty()) grid_ = decodeWalkGrid(w.walkGrid);
if (grid_ && grid_->any()) {
for (const WorldCollider& c : w.colliders)
if (c.kind != WorldCollider::Mesh && (c.role == WorldCollider::Solid || c.role == WorldCollider::Wall || c.role == WorldCollider::Object)) shapes_.push_back(c);
hasFloor_ = true;
return;
}
grid_.reset();
// floorUp: how upright a face must be to stand on (0 = never floor); walls: steep faces block.
// allWall: every face blocks (the Wall role), not just the steep ones.
auto addMesh = [&](const std::vector<float>& p, const std::vector<u32>& idx, float floorUp, bool walls, bool solidFloor, bool allWall = false) {
const size_t nv = p.size() / 3;
for (size_t t = 0; t + 2 < idx.size(); t += 3) {
if (idx[t] >= nv || idx[t + 1] >= nv || idx[t + 2] >= nv) continue;
Tri tr;
std::copy_n(&p[idx[t] * 3], 3, tr.a); std::copy_n(&p[idx[t + 1] * 3], 3, tr.b); std::copy_n(&p[idx[t + 2] * 3], 3, tr.c);
const float e1[3] = {tr.b[0] - tr.a[0], tr.b[1] - tr.a[1], tr.b[2] - tr.a[2]}, e2[3] = {tr.c[0] - tr.a[0], tr.c[1] - tr.a[1], tr.c[2] - tr.a[2]};
float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]};
const float L = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
if (L < 1e-9f) continue;
for (float& v : n) v /= L;
std::copy_n(n, 3, tr.n);
tr.solid = solidFloor;
const float up = std::fabs(n[1]); // winding varies: a floor faces up either way
if (floorUp > 0 && up > floorUp) insert(floors_, floorGrid_, tr);
else if (walls && (allWall || up < 0.7f)) insert(walls_, wallGrid_, tr);
}
};
if (!w.walkIndices.empty()) addMesh(w.walkPositions, w.walkIndices, 0.01f, false, false); // legacy walkable triangles
for (const WorldCollider& c : w.colliders) {
if (c.role == WorldCollider::Trigger) continue;
const float floorUp = c.role == WorldCollider::Solid ? 0.7f : c.role == WorldCollider::Floor || c.role == WorldCollider::Walkable ? 0.5f : 0.0f;
const bool blocks = c.role == WorldCollider::Solid || c.role == WorldCollider::Wall || c.role == WorldCollider::Object;
if (c.kind == WorldCollider::Mesh) { addMesh(worldMeshPositions(c), c.indices, floorUp, blocks, blocks, c.role == WorldCollider::Wall || c.role == WorldCollider::Object); continue; }
if (blocks) shapes_.push_back(c);
if (c.kind == WorldCollider::Box && floorUp > 0) { // a box's top is floor (steps, platforms, rugs)
std::vector<float> p;
const float s[4][2] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}};
for (auto& q : s) {
const float l[3] = {q[0] * c.half[0], c.half[1], q[1] * c.half[2]};
for (int k = 0; k < 3; ++k) p.push_back(c.world[k] * l[0] + c.world[4 + k] * l[1] + c.world[8 + k] * l[2] + c.world[12 + k]);
}
addMesh(p, {0, 1, 2, 0, 2, 3}, 0.01f, false, blocks);
}
}
hasFloor_ = !floors_.empty();
}
bool hasFloor() const { return hasFloor_; }
const WalkGrid* grid() const { return grid_.get(); } // the walkable area answering, if any
// Highest floor under (x, z) between y - maxDown and y + maxUp; false when there is none.
bool floorAt(float x, float z, float y, float maxUp, float maxDown, float& out) const {
if (grid_) return grid_->floorAt(x, z, y, maxUp, maxDown, out);
bool found = false;
float best = -1e30f;
forCell(floorGrid_, x, z, [&](uint32_t i) {
const Tri& t = floors_[i];
float h;
if (!heightOn(t, x, z, h) || h > y + maxUp || h < y - maxDown) return;
if (h > best) { best = h; found = true; }
});
if (found) out = best;
return found;
}
// Moves a standing body (feet at y, `radius` wide, `height` tall) from (x, z) by (dx, dz), stopping at walls
// and sliding along them.
void move(float& x, float& z, float y, float dx, float dz, float radius = 0.2f, float height = 1.7f) const {
if (grid_) { grid_->move(x, z, y, dx, dz); return; } // (the area keeps the body's radius from edges)
for (int pass = 0; pass < 2; ++pass) { // try the full step, then slide
const float nx = x + dx, nz = z + dz;
float push[3];
if (!blocked(nx, y, nz, radius, height, push)) { x = nx; z = nz; return; }
// remove the component of the step that goes into the wall
const float pl = std::sqrt(push[0] * push[0] + push[2] * push[2]);
if (pl < 1e-6f) return;
const float px = push[0] / pl, pz = push[2] / pl, into = dx * px + dz * pz;
if (into >= 0) return;
dx -= into * px; dz -= into * pz;
}
}
// First floor hit along a segment (teleport arcs are traced as short segments).
bool raycastFloor(const float o[3], const float d[3], float maxT, float hit[3]) const {
const float len = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
if (len < 1e-9f) return false;
const int steps = std::max(1, static_cast<int>(maxT * len / 0.5f));
float bestT = 1e30f;
for (int s = 0; s <= steps; ++s) { // visit the cells the segment crosses
const float t = maxT * s / steps;
const float x = o[0] + d[0] * t, z = o[2] + d[2] * t;
forCell(floorGrid_, x, z, [&](uint32_t i) {
float th;
if (rayTri(o, d, floors_[i], th) && th >= 0 && th <= maxT && th < bestT) bestT = th;
});
}
if (bestT > maxT) return false;
for (int k = 0; k < 3; ++k) hit[k] = o[k] + d[k] * bestT;
// a wall in front of the landing blocks it
return !wallBetween(o, hit);
}
// What a straight segment o + d t, t in [0, 1], meets first: a floor from above (Land), anything solid — walls,
// ceilings and the undersides of solid floors, blocking shapes — (Block), or nothing. Teleport arcs are traced
// with it segment by segment, so they stop at the first wall instead of flying through it. n: the surface's
// normal facing the segment's start.
enum Hit { None = 0, Land = 1, Block = 2 };
// hitShape (optional): the box / sphere / capsule collider hit, when that's what stopped it.
Hit trace(const float o[3], const float d[3], float& tOut, float n[3], const WorldCollider** hitShape = nullptr) const {
if (hitShape) *hitShape = nullptr;
float best = 2;
Hit kind = None;
if (grid_) {
kind = static_cast<Hit>(grid_->trace(o, d, best, n));
for (const WorldCollider& c : shapes_) { // which chair it was (seats)
float th, sn[3];
if (!rayShape(c, o, d, th, sn) || th > 1) continue;
if (th < best - 1e-4f || (kind != Land && th < best)) { best = th; kind = Block; std::copy_n(sn, 3, n); if (hitShape) *hitShape = &c; }
}
tOut = best;
return kind;
}
auto consider = [&](const Tri& tr, bool floor) {
float th;
if (!rayTri(o, d, tr, th) || th < 0 || th > 1 || th >= best) return;
const float side = tr.n[0] * d[0] + tr.n[1] * d[1] + tr.n[2] * d[2] < 0 ? 1.0f : -1.0f;
const float fn[3] = {tr.n[0] * side, tr.n[1] * side, tr.n[2] * side};
Hit k = Block;
if (floor) {
if (fn[1] > 0) k = Land; // onto it from above
else if (!tr.solid) return; // under a walkable area: it doesn't stop you
}
best = th; kind = k;
std::copy_n(fn, 3, n);
};
walkCells(o, d, [&](int64_t key) {
if (auto it = floorGrid_.find(key); it != floorGrid_.end()) for (uint32_t i : it->second) consider(floors_[i], true);
if (auto it = wallGrid_.find(key); it != wallGrid_.end()) for (uint32_t i : it->second) consider(walls_[i], false);
});
for (const auto* g : {&floorGrid_, &wallGrid_}) {
auto it = g->find(kBig);
if (it == g->end()) continue;
for (uint32_t i : it->second) consider(g == &floorGrid_ ? floors_[i] : walls_[i], g == &floorGrid_);
}
// Blocking shapes. A box's top is also a floor triangle: landing on it wins a tie.
for (const WorldCollider& c : shapes_) {
float th, sn[3];
if (!rayShape(c, o, d, th, sn) || th > 1) continue;
if (th < best - 1e-4f || (kind != Land && th < best)) { best = th; kind = Block; std::copy_n(sn, 3, n); if (hitShape) *hitShape = &c; }
}
tOut = best;
return kind;
}
// Where a teleport arc that met something at q (h: what it was, n: its normal facing the arc) puts you, into out:
// a floor from above: there, or the nearest place you fit within 30 cm (an edge, a seam);
// the top of something you can't stand on (a table, a sofa's back): the floor beside it;
// a wall, a railing, furniture's side, at any height: the floor at its foot (in front of it);
// a ceiling or a floor's underside: the floor under it.
// Each with headroom over it. False: nowhere you fit within reach of it.
bool landingFor(Hit h, const float q[3], const float n[3], float out[3]) const {
auto standAt = [&](float x, float y, float z, float nudge) {
if (!roomAt(x, y, z, nudge)) return false;
float f;
if (floorAt(x, z, y, 0.1f, 0.1f, f)) y = f;
const float from[3] = {x, y + 0.05f, z}, up[3] = {0, 1.2f, 0};
float t, hn[3];
if (trace(from, up, t, hn) == Block) return false; // no headroom (under a table)
out[0] = x; out[1] = y; out[2] = z;
return true;
};
float f;
if (h == Land) {
if (standAt(q[0], q[1], q[2], 0.3f)) return true;
return floorAt(q[0], q[2], q[1] - 0.2f, 0.0f, 2.0f, f) && standAt(q[0], f, q[2], 0.6f); // a top: the floor by it
}
if (h != Block) return false;
if (n[1] < -0.7f) return floorAt(q[0], q[2], q[1] - 0.1f, 0.0f, 3.0f, f) && standAt(q[0], f, q[2], 0.3f); // under a ceiling
const float hl = std::sqrt(n[0] * n[0] + n[2] * n[2]);
if (hl < 1e-4f) return false;
const float x = q[0] + n[0] / hl * 0.3f, z = q[2] + n[2] / hl * 0.3f; // a wall or a side: in front of its foot
return floorAt(x, z, q[1], 0.0f, 3.0f, f) && standAt(x, f, z, 0.3f);
}
// Somewhere to stand at (x, y, z): pushed up to `nudge` metres out of walls the body would be in (landing at the
// foot of a wall or beside a sofa), still on a floor within a step. False: no room.
bool roomAt(float& x, float y, float& z, float nudge = 0.3f) const {
if (grid_) return grid_->roomAt(x, y, z, nudge);
float push[3];
for (int i = 0; i < 3; ++i) {
if (!blocked(x, y, z, 0.2f, 1.7f, push)) return true;
const float pl = std::sqrt(push[0] * push[0] + push[2] * push[2]);
if (pl < 1e-6f) return false;
const float nx = x + push[0] / pl * nudge / 3, nz = z + push[2] / pl * nudge / 3;
float f;
if (!floorAt(nx, nz, y, 0.1f, 0.1f, f)) return false;
x = nx; z = nz;
}
return !blocked(x, y, z, 0.2f, 1.7f, push);
}
private:
std::vector<Tri> floors_, walls_;
std::unordered_map<int64_t, std::vector<uint32_t>> floorGrid_, wallGrid_;
std::vector<WorldCollider> shapes_;
std::shared_ptr<const WalkGrid> grid_;
bool hasFloor_ = false;
static int64_t key(int cx, int cz) { return (static_cast<int64_t>(cx) << 32) ^ static_cast<uint32_t>(cz); }
static int cell(float v) { return static_cast<int>(std::floor(v)); }
static void insert(std::vector<Tri>& list, std::unordered_map<int64_t, std::vector<uint32_t>>& grid, const Tri& t) {
const uint32_t id = static_cast<uint32_t>(list.size());
list.push_back(t);
const float x0 = std::min({t.a[0], t.b[0], t.c[0]}), x1 = std::max({t.a[0], t.b[0], t.c[0]});
const float z0 = std::min({t.a[2], t.b[2], t.c[2]}), z1 = std::max({t.a[2], t.b[2], t.c[2]});
if (x1 - x0 > 400 || z1 - z0 > 400) { grid[kBig].push_back(id); return; } // one huge ground quad: every query sees it
for (int cx = cell(x0); cx <= cell(x1); ++cx)
for (int cz = cell(z0); cz <= cell(z1); ++cz) grid[key(cx, cz)].push_back(id);
}
static constexpr int64_t kBig = INT64_MIN; // triangles too big to grid
template <typename F> static void forCell(const std::unordered_map<int64_t, std::vector<uint32_t>>& grid, float x, float z, F&& f) {
auto it = grid.find(key(cell(x), cell(z)));
if (it != grid.end()) for (uint32_t i : it->second) f(i);
auto big = grid.find(kBig);
if (big != grid.end()) for (uint32_t i : big->second) f(i);
}
// Every grid cell the segment o + d t, t in [0, 1], crosses over XZ (Amanatides & Woo), each once.
template <typename F> static void walkCells(const float o[3], const float d[3], F&& f) {
int cx = cell(o[0]), cz = cell(o[2]);
const int ex = cell(o[0] + d[0]), ez = cell(o[2] + d[2]);
const int sx = d[0] > 0 ? 1 : -1, sz = d[2] > 0 ? 1 : -1;
const float ix = std::fabs(d[0]) > 1e-9f ? 1 / std::fabs(d[0]) : 1e30f, iz = std::fabs(d[2]) > 1e-9f ? 1 / std::fabs(d[2]) : 1e30f;
float tx = std::fabs(d[0]) > 1e-9f ? ((sx > 0 ? cx + 1 : cx) - o[0]) / d[0] : 1e30f;
float tz = std::fabs(d[2]) > 1e-9f ? ((sz > 0 ? cz + 1 : cz) - o[2]) / d[2] : 1e30f;
for (int guard = 0; guard < 4096; ++guard) {
f(key(cx, cz));
if (cx == ex && cz == ez) break;
if (tx < tz) { if (tx > 1) break; tx += ix; cx += sx; }
else { if (tz > 1) break; tz += iz; cz += sz; }
}
}
// A segment against a box / sphere / capsule collider (in its frame, as blocked() reads it).
static bool rayShape(const WorldCollider& c, const float o[3], const float d[3], float& t, float n[3]) {
float lo[3], ld[3], s2[3];
const float rel[3] = {o[0] - c.world[12], o[1] - c.world[13], o[2] - c.world[14]};
for (int a = 0; a < 3; ++a) {
const float* ax = &c.world[a * 4];
s2[a] = ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2];
const float inv = s2[a] > 1e-12f ? 1 / s2[a] : 0;
lo[a] = (rel[0] * ax[0] + rel[1] * ax[1] + rel[2] * ax[2]) * inv;
ld[a] = (d[0] * ax[0] + d[1] * ax[1] + d[2] * ax[2]) * inv;
}
float ln[3] = {0, 0, 0};
t = 1e30f;
auto sphere = [&](const float cc[3], float r) {
const float oc[3] = {lo[0] - cc[0], lo[1] - cc[1], lo[2] - cc[2]};
const float A = ld[0] * ld[0] + ld[1] * ld[1] + ld[2] * ld[2], B = oc[0] * ld[0] + oc[1] * ld[1] + oc[2] * ld[2];
const float C = oc[0] * oc[0] + oc[1] * oc[1] + oc[2] * oc[2] - r * r, disc = B * B - A * C;
if (A < 1e-12f || disc < 0 || C < 0) return; // (starting inside: not a hit)
const float th = (-B - std::sqrt(disc)) / A;
if (th < 0 || th >= t) return;
t = th;
for (int k = 0; k < 3; ++k) ln[k] = oc[k] + ld[k] * th;
};
if (c.kind == WorldCollider::Box) {
float t0 = 0, t1 = 1;
int axis = -1;
float sgn = 0;
for (int a = 0; a < 3; ++a) {
if (std::fabs(ld[a]) < 1e-12f) { if (std::fabs(lo[a]) > c.half[a]) return false; continue; }
float ta = (-c.half[a] - lo[a]) / ld[a], tb = (c.half[a] - lo[a]) / ld[a];
float sa = -1;
if (ta > tb) { std::swap(ta, tb); sa = 1; }
if (ta > t0) { t0 = ta; axis = a; sgn = sa; }
t1 = std::min(t1, tb);
if (t0 > t1) return false;
}
if (axis < 0) return false; // starts inside
t = t0;
ln[axis] = sgn;
} else if (c.kind == WorldCollider::Sphere) {
const float z[3] = {0, 0, 0};
sphere(z, c.radius);
} else if (c.kind == WorldCollider::Capsule) {
const float h = c.length * 0.5f, r = c.radius;
const float top[3] = {0, h, 0}, bot[3] = {0, -h, 0};
sphere(top, r); sphere(bot, r);
const float A = ld[0] * ld[0] + ld[2] * ld[2], B = lo[0] * ld[0] + lo[2] * ld[2], C = lo[0] * lo[0] + lo[2] * lo[2] - r * r;
const float disc = B * B - A * C;
if (A > 1e-12f && disc >= 0 && C > 0) {
const float th = (-B - std::sqrt(disc)) / A, y = lo[1] + ld[1] * th;
if (th >= 0 && th < t && std::fabs(y) <= h) { t = th; ln[0] = lo[0] + ld[0] * th; ln[1] = 0; ln[2] = lo[2] + ld[2] * th; }
}
} else {
return false;
}
if (t > 1) return false;
// the local normal back to world (scaled axes: divide by the scale)
float wn[3] = {0, 0, 0};
for (int a = 0; a < 3; ++a) {
const float k = s2[a] > 1e-12f ? ln[a] / s2[a] : 0;
for (int j = 0; j < 3; ++j) wn[j] += c.world[a * 4 + j] * k;
}
const float L = std::sqrt(wn[0] * wn[0] + wn[1] * wn[1] + wn[2] * wn[2]);
for (int j = 0; j < 3; ++j) n[j] = L > 1e-9f ? wn[j] / L : (j == 1 ? 1.0f : 0.0f);
return true;
}
static bool heightOn(const Tri& t, float x, float z, float& h) {
const float d = (t.b[2] - t.c[2]) * (t.a[0] - t.c[0]) + (t.c[0] - t.b[0]) * (t.a[2] - t.c[2]);
if (std::fabs(d) < 1e-12f) return false;
const float l1 = ((t.b[2] - t.c[2]) * (x - t.c[0]) + (t.c[0] - t.b[0]) * (z - t.c[2])) / d;
const float l2 = ((t.c[2] - t.a[2]) * (x - t.c[0]) + (t.a[0] - t.c[0]) * (z - t.c[2])) / d;
const float l3 = 1 - l1 - l2;
if (l1 < -1e-4f || l2 < -1e-4f || l3 < -1e-4f) return false;
h = l1 * t.a[1] + l2 * t.b[1] + l3 * t.c[1];
return true;
}
static bool rayTri(const float o[3], const float d[3], const Tri& t, float& out) {
const float e1[3] = {t.b[0] - t.a[0], t.b[1] - t.a[1], t.b[2] - t.a[2]}, e2[3] = {t.c[0] - t.a[0], t.c[1] - t.a[1], t.c[2] - t.a[2]};
const float p[3] = {d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]};
const float det = e1[0] * p[0] + e1[1] * p[1] + e1[2] * p[2];
if (std::fabs(det) < 1e-12f) return false;
const float inv = 1 / det, s[3] = {o[0] - t.a[0], o[1] - t.a[1], o[2] - t.a[2]};
const float u = (s[0] * p[0] + s[1] * p[1] + s[2] * p[2]) * inv;
if (u < 0 || u > 1) return false;
const float q[3] = {s[1] * e1[2] - s[2] * e1[1], s[2] * e1[0] - s[0] * e1[2], s[0] * e1[1] - s[1] * e1[0]};
const float v = (d[0] * q[0] + d[1] * q[1] + d[2] * q[2]) * inv;
if (v < 0 || u + v > 1) return false;
out = (e2[0] * q[0] + e2[1] * q[1] + e2[2] * q[2]) * inv;
return true;
}
// Closest point on a triangle to p (Ericson), for body-vs-wall tests.
static void closest(const Tri& t, const float p[3], float out[3]) {
auto sub = [](const float* a, const float* b, float* o) { for (int k = 0; k < 3; ++k) o[k] = a[k] - b[k]; };
auto dot = [](const float* a, const float* b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; };
float ab[3], ac[3], ap[3], bp[3], cp[3];
sub(t.b, t.a, ab); sub(t.c, t.a, ac); sub(p, t.a, ap);
const float d1 = dot(ab, ap), d2 = dot(ac, ap);
if (d1 <= 0 && d2 <= 0) { std::copy_n(t.a, 3, out); return; }
sub(p, t.b, bp);
const float d3 = dot(ab, bp), d4 = dot(ac, bp);
if (d3 >= 0 && d4 <= d3) { std::copy_n(t.b, 3, out); return; }
const float vc = d1 * d4 - d3 * d2;
if (vc <= 0 && d1 >= 0 && d3 <= 0) { const float v = d1 / (d1 - d3); for (int k = 0; k < 3; ++k) out[k] = t.a[k] + v * ab[k]; return; }
sub(p, t.c, cp);
const float d5 = dot(ab, cp), d6 = dot(ac, cp);
if (d6 >= 0 && d5 <= d6) { std::copy_n(t.c, 3, out); return; }
const float vb = d5 * d2 - d1 * d6;
if (vb <= 0 && d2 >= 0 && d6 <= 0) { const float w = d2 / (d2 - d6); for (int k = 0; k < 3; ++k) out[k] = t.a[k] + w * ac[k]; return; }
const float va = d3 * d6 - d5 * d4;
if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) {
const float w = (d4 - d3) / ((d4 - d3) + (d5 - d6));
for (int k = 0; k < 3; ++k) out[k] = t.b[k] + w * (t.c[k] - t.b[k]);
return;
}
const float den = 1 / (va + vb + vc), v = vb * den, w = vc * den;
for (int k = 0; k < 3; ++k) out[k] = t.a[k] + ab[k] * v + ac[k] * w;
}
// Is a body at (x, y..y+height, z) inside a wall? `push` = direction out of it (horizontal).
bool blocked(float x, float y, float z, float r, float height, float push[3]) const {
push[0] = push[1] = push[2] = 0;
bool hit = false;
const float knee = y + 0.35f; // low clutter (rugs, thresholds) is stepped over
for (float hy : {knee, y + height * 0.55f, y + height - 0.1f}) {
const float p[3] = {x, hy, z};
for (int cx = cell(x - r); cx <= cell(x + r); ++cx)
for (int cz = cell(z - r); cz <= cell(z + r); ++cz) {
auto it = wallGrid_.find(key(cx, cz));
if (it == wallGrid_.end()) continue;
for (uint32_t i : it->second) {
float q[3];
closest(walls_[i], p, q);
const float dx = p[0] - q[0], dy = p[1] - q[1], dz = p[2] - q[2];
if (dx * dx + dy * dy + dz * dz < r * r) { push[0] += dx; push[2] += dz; hit = true; }
}
}
for (const WorldCollider& c : shapes_) {
float inv[3], l[3]; // NOLINT
const float rel[3] = {p[0] - c.world[12], p[1] - c.world[13], p[2] - c.world[14]};
for (int a = 0; a < 3; ++a) { // local coords (axes may be scaled)
const float* ax = &c.world[a * 4];
const float s2 = ax[0] * ax[0] + ax[1] * ax[1] + ax[2] * ax[2];
inv[a] = s2 > 1e-12f ? 1 / s2 : 0;
l[a] = (rel[0] * ax[0] + rel[1] * ax[1] + rel[2] * ax[2]) * inv[a];
}
const float sx = std::sqrt(c.world[0] * c.world[0] + c.world[1] * c.world[1] + c.world[2] * c.world[2]);
const float rl = sx > 1e-6f ? r / sx : r;
bool in = false;
if (c.kind == WorldCollider::Box) in = std::fabs(l[0]) < c.half[0] + rl && std::fabs(l[1]) < c.half[1] + rl && std::fabs(l[2]) < c.half[2] + rl;
else if (c.kind == WorldCollider::Sphere) in = l[0] * l[0] + l[1] * l[1] + l[2] * l[2] < (c.radius + rl) * (c.radius + rl);
else if (c.kind == WorldCollider::Capsule) {
const float yy = std::clamp(l[1], -c.length * 0.5f, c.length * 0.5f);
in = l[0] * l[0] + (l[1] - yy) * (l[1] - yy) + l[2] * l[2] < (c.radius + rl) * (c.radius + rl);
}
if (in) { push[0] += p[0] - c.world[12]; push[2] += p[2] - c.world[14]; hit = true; } // (tested from the knee up: low steps pass)
}
}
return hit;
}
bool wallBetween(const float a[3], const float b[3]) const {
const float lift[3] = {b[0], b[1] + 0.5f, b[2]}; // test from the target, half a metre up
const float dl[3] = {a[0] - lift[0], a[1] - lift[1], a[2] - lift[2]};
const float len = std::sqrt(dl[0] * dl[0] + dl[1] * dl[1] + dl[2] * dl[2]);
if (len < 1e-6f) return false;
const int steps = std::max(1, static_cast<int>(len / 0.5f));
for (int s = 0; s <= steps; ++s) {
const float t = static_cast<float>(s) / steps * 0.98f;
const float x = lift[0] + dl[0] * t, z = lift[2] + dl[2] * t;
bool hit = false;
forCell(wallGrid_, x, z, [&](uint32_t i) { float th; if (!hit && rayTri(lift, dl, walls_[i], th) && th > 0.02f && th < 0.98f) hit = true; });
if (hit) return true;
}
return false;
}
};
+105
View File
@@ -0,0 +1,105 @@
#pragma once
// A world's own render settings: what that one home runs best at on the Steam Frame, over the headset's settings.json.
// A .fenv carries them in its manifest ("settings" in fenv.json); an APK home in envs/ in "<apk>.settings.json"
// beside it. Every field is optional: unset, the headset's own setting applies. Frame Home applies them while the
// world is shown (resolution, foveation strength and frame rate live; MSAA and filtering by restarting itself, as a
// settings change does); `fenv-tool tune` and Frame Studio's Calculate Best Settings work them out on the headset.
#include "perf_settings.h"
#include <cmath>
#include <cstdio>
#include <string>
struct WorldSettings {
float resolution = 0; // a fixed resolution scale (0.5 .. 2); 0: the headset's (fixed or auto)
int msaa = 0; // 1, 2, 4; 0: the headset's
int anisotropy = 0; // 4, 8, 16; 0: the headset's
float foveation = 0; // foveation strength, 1 gentle .. 3 strong, in between too; 0: the headset's level
int frameRate = -1; // 0 the display's, 1 half; -1: the headset's
std::string tuned; // how they were found ("" when set by hand)
bool empty() const { return resolution <= 0 && msaa <= 0 && anisotropy <= 0 && foveation <= 0 && frameRate < 0; }
bool operator==(const WorldSettings& o) const {
return std::abs(resolution - o.resolution) < 1e-3f && msaa == o.msaa && anisotropy == o.anisotropy &&
std::abs(foveation - o.foveation) < 1e-3f && frameRate == o.frameRate && tuned == o.tuned;
}
bool operator!=(const WorldSettings& o) const { return !(*this == o); }
// The headset's settings with this world's on top.
PerfSettings apply(PerfSettings p) const {
if (resolution > 0) { p.autoResolution = false; p.resolution = resolution; }
if (msaa > 0) p.msaa = msaa;
if (anisotropy > 0) p.anisotropy = anisotropy;
if (foveation > 0) p.foveationLevel = static_cast<int>(std::lround(foveation));
if (frameRate >= 0) p.frameRate = frameRate;
p.clamp();
return p;
}
// The foveation strength to draw with: this world's, else the headset's level.
float foveationStrength(const PerfSettings& headset) const { return foveation > 0 ? foveation : static_cast<float>(headset.foveationLevel); }
void clamp() {
if (!std::isfinite(resolution) || resolution <= 0) resolution = 0;
else resolution = std::clamp(resolution, 0.5f, 2.0f);
msaa = msaa <= 0 ? 0 : msaa >= 4 ? 4 : msaa >= 2 ? 2 : 1;
anisotropy = anisotropy <= 0 ? 0 : anisotropy >= 16 ? 16 : anisotropy >= 8 ? 8 : 4;
if (!std::isfinite(foveation) || foveation <= 0) foveation = 0;
else foveation = std::clamp(foveation, 1.0f, 3.0f);
frameRate = frameRate < 0 ? -1 : std::min(frameRate, 1);
}
// Only the fields that are set ("{}" when none).
std::string toJson() const {
std::string j;
char b[96];
auto add = [&](const char* k, const char* v) { j += std::string(j.empty() ? "" : ", ") + "\"" + k + "\": " + v; };
if (resolution > 0) { std::snprintf(b, sizeof b, "%.2f", resolution); add("resolution", b); }
if (msaa > 0) { std::snprintf(b, sizeof b, "%d", msaa); add("msaa", b); }
if (anisotropy > 0) { std::snprintf(b, sizeof b, "%d", anisotropy); add("anisotropy", b); }
if (foveation > 0) { std::snprintf(b, sizeof b, "%.3f", foveation); add("foveation", b); }
if (frameRate >= 0) { std::snprintf(b, sizeof b, "%d", frameRate); add("frameRate", b); }
if (!tuned.empty()) {
std::string q = "\"";
for (char c : tuned) {
if (c == '"' || c == '\\') { q += '\\'; q += c; }
else if (static_cast<unsigned char>(c) >= 0x20) q += c;
}
add("tuned", (q + "\"").c_str());
}
return "{" + j + "}";
}
bool fromValue(const tinyjson::Value& d) {
if (!d.isObject()) return false;
*this = WorldSettings();
if (d.has("resolution")) resolution = static_cast<float>(d["resolution"].asFloat());
if (d.has("msaa")) msaa = static_cast<int>(d["msaa"].asFloat());
if (d.has("anisotropy")) anisotropy = static_cast<int>(d["anisotropy"].asFloat());
if (d.has("foveation")) foveation = static_cast<float>(d["foveation"].asFloat());
if (d.has("frameRate")) frameRate = static_cast<int>(d["frameRate"].asFloat());
if (d.has("tuned")) tuned = d["tuned"].asString();
clamp();
return true;
}
bool fromJson(const std::string& j) {
const size_t last = j.find_last_not_of(" \t\r\n");
if (last == std::string::npos || j[last] != '}') return false;
try { return fromValue(tinyjson::parse(j)); } catch (...) { return false; }
}
// "1.40x, 2x MSAA, foveation 1.5" (what is set), or "the headset's settings".
std::string describe() const {
if (empty()) return "the headset's settings";
std::string s;
char b[64];
auto add = [&](const char* t) { s += std::string(s.empty() ? "" : ", ") + t; };
if (resolution > 0) { std::snprintf(b, sizeof b, "resolution %.0f%%", resolution * 100); add(b); }
if (msaa > 0) { std::snprintf(b, sizeof b, "%dx MSAA", msaa); add(b); }
if (anisotropy > 0) { std::snprintf(b, sizeof b, "%dx filtering", anisotropy); add(b); }
if (foveation > 0) { std::snprintf(b, sizeof b, "foveation %.3g", foveation); add(b); }
if (frameRate >= 0) add(frameRate ? "half frame rate" : "full frame rate");
return s;
}
};
// An APK home's settings file, beside it in envs/ (a .fenv keeps them in its manifest instead).
inline std::string worldSettingsPathFor(const std::string& homePath) { return homePath + ".settings.json"; }
+152
View File
@@ -0,0 +1,152 @@
# Frame Studio — desktop app for Windows, Linux and macOS (same renderer as Frame Home, in a window with an editor
# UI). Everything it needs is vendored: Vulkan is loaded at runtime through volk (MoltenVK on macOS); the window is
# Win32 on Windows and GLFW elsewhere (studio/platform.h has the rest of the system). Presets: CMakePresets.json.
cmake_minimum_required(VERSION 3.16)
project(frame_studio LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
set(ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..")
set(TP "${ROOT}/third_party")
if(WIN32)
add_compile_definitions(NOMINMAX WIN32_LEAN_AND_MEAN _CRT_SECURE_NO_WARNINGS VK_USE_PLATFORM_WIN32_KHR)
endif()
if(MSVC)
add_compile_options(/utf-8 /bigobj /W3 /wd4244 /wd4267 /wd4305)
endif()
add_library(volk STATIC "${TP}/volk/volk.c")
target_include_directories(volk PUBLIC "${TP}/volk" "${TP}/vulkan-headers/include")
# LZMA SDK (public domain): the .fenv scene coder (src/fenv_lzma.cpp), with its threaded match finder.
add_library(lzma STATIC "${TP}/lzma/LzmaEnc.c" "${TP}/lzma/LzmaDec.c" "${TP}/lzma/LzFind.c" "${TP}/lzma/LzFindOpt.c"
"${TP}/lzma/LzFindMt.c" "${TP}/lzma/Threads.c" "${TP}/lzma/CpuArch.c")
target_include_directories(lzma PUBLIC "${TP}/lzma")
add_library(miniz STATIC "${TP}/miniz/miniz.c" "${TP}/miniz/miniz_tdef.c" "${TP}/miniz/miniz_tinfl.c" "${TP}/miniz/miniz_zip.c")
target_include_directories(miniz PUBLIC "${TP}/miniz")
# APKs are signed and intact; re-checksumming every extracted asset was ~20% of load time.
target_compile_definitions(miniz PRIVATE MINIZ_DISABLE_ZIP_READER_CRC32_CHECKS)
add_library(smolv STATIC "${TP}/smolv/smolv.cpp")
target_include_directories(smolv PUBLIC "${TP}/smolv")
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(ASTCENC_CLI OFF CACHE BOOL "" FORCE)
set(ASTCENC_DECOMPRESSOR OFF CACHE BOOL "" FORCE)
set(ASTCENC_UNIVERSAL_BUILD OFF CACHE BOOL "" FORCE)
# astcenc's SIMD: NEON on ARM (Windows on ARM, Apple Silicon, ARM Linux), SSE4.1 on x86-64.
# (on macOS the architecture asked for decides: an Apple Silicon Mac builds the Intel version too)
if(CMAKE_OSX_ARCHITECTURES)
set(STUDIO_ARCH "${CMAKE_OSX_ARCHITECTURES}")
else()
set(STUDIO_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
endif()
if(STUDIO_ARCH MATCHES "^(arm64|ARM64|aarch64|AARCH64)$")
set(ASTCENC_ISA_NEON ON CACHE BOOL "" FORCE)
set(STUDIO_ASTCENC astcenc-neon-static)
else()
set(ASTCENC_ISA_SSE41 ON CACHE BOOL "" FORCE)
set(STUDIO_ASTCENC astcenc-sse4.1-static)
endif()
add_subdirectory("${TP}/astcenc" astcenc EXCLUDE_FROM_ALL)
# Ogg Opus ambience (Clay homes' _BACKGROUND_LOOP.ogg): Xiph libopus + libogg + opusfile (BSD), decode only.
set(OPUS_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
set(OPUS_BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(OPUS_INSTALL_PKG_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
set(OPUS_INSTALL_CMAKE_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
add_subdirectory("${TP}/opus" opus EXCLUDE_FROM_ALL)
add_library(ogg STATIC "${TP}/ogg/src/bitwise.c" "${TP}/ogg/src/framing.c")
target_include_directories(ogg PUBLIC "${TP}/ogg/include")
add_library(opusfile STATIC "${TP}/opusfile/src/info.c" "${TP}/opusfile/src/internal.c"
"${TP}/opusfile/src/opusfile.c" "${TP}/opusfile/src/stream.c")
target_include_directories(opusfile PUBLIC "${TP}/opusfile/include" PRIVATE "${TP}/opusfile/src")
target_compile_definitions(opusfile PRIVATE OP_DISABLE_HTTP)
target_link_libraries(opusfile PUBLIC opus ogg)
# The window: Win32 on Windows; GLFW (X11 on Linux, Cocoa on macOS) elsewhere.
if(WIN32)
set(STUDIO_IMGUI_BACKEND "${TP}/imgui/backends/imgui_impl_win32.cpp")
set(STUDIO_PLATFORM platform_win32.cpp)
else()
set(GLFW_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(GLFW_BUILD_DOCS OFF CACHE BOOL "" FORCE)
set(GLFW_INSTALL OFF CACHE BOOL "" FORCE)
# (Wayland sessions run it through XWayland; GLFW's own Wayland backend needs wayland-scanner to build)
set(GLFW_BUILD_WAYLAND OFF CACHE BOOL "" FORCE)
add_subdirectory("${TP}/glfw" glfw EXCLUDE_FROM_ALL)
set(STUDIO_IMGUI_BACKEND "${TP}/imgui/backends/imgui_impl_glfw.cpp")
set(STUDIO_PLATFORM platform_posix.cpp "${TP}/tinyfiledialogs/tinyfiledialogs.c")
endif()
add_library(imgui STATIC
"${TP}/imgui/imgui.cpp" "${TP}/imgui/imgui_draw.cpp" "${TP}/imgui/imgui_tables.cpp" "${TP}/imgui/imgui_widgets.cpp"
${STUDIO_IMGUI_BACKEND} "${TP}/imgui/backends/imgui_impl_vulkan.cpp"
"${TP}/imguizmo/ImGuizmo.cpp")
target_include_directories(imgui PUBLIC "${TP}/imgui" "${TP}/imguizmo")
target_compile_definitions(imgui PUBLIC IMGUI_IMPL_VULKAN_USE_VOLK GLFW_INCLUDE_NONE)
target_link_libraries(imgui PUBLIC volk)
if(NOT WIN32)
target_link_libraries(imgui PUBLIC glfw)
endif()
add_executable(frame-studio WIN32 MACOSX_BUNDLE
studio.cpp
${STUDIO_PLATFORM}
"${ROOT}/src/hsl_scene.cpp"
"${ROOT}/src/scene_bake.cpp"
"${ROOT}/src/anim_bake.cpp"
"${ROOT}/src/fenv_lzma.cpp"
"${ROOT}/src/model_import.cpp"
"${TP}/ufbx/ufbx.c"
"${ROOT}/src/hsl_renderer.cpp"
"${TP}/qhe/cook/hzanim_acl.cpp"
"${TP}/stb/stb_image_impl.c"
"${TP}/stb/stb_vorbis_impl.c"
"${TP}/miniaudio/miniaudio_impl.c")
set_target_properties(frame-studio PROPERTIES OUTPUT_NAME "Frame Studio")
# ACL's NEON code mixes vector types GCC keeps apart by default (as the Frame's own build: CMakeLists.txt)
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64|ARM64|AARCH64)$")
set_source_files_properties("${TP}/qhe/cook/hzanim_acl.cpp" PROPERTIES COMPILE_OPTIONS "-flax-vector-conversions")
endif()
target_compile_definitions(frame-studio PRIVATE FH_USE_VOLK)
target_include_directories(frame-studio PRIVATE
"${ROOT}/src"
"${TP}/cgltf"
"${TP}/ufbx"
"${TP}/qhe"
"${TP}/stb"
"${TP}/miniaudio"
"${TP}/acl/includes"
"${TP}/rtm/includes"
"${TP}/astcenc/Source"
"${TP}/tinyfiledialogs")
target_link_libraries(frame-studio PRIVATE imgui volk miniz lzma smolv opusfile ${STUDIO_ASTCENC})
if(WIN32)
target_link_libraries(frame-studio PRIVATE dwmapi comdlg32 shell32)
else()
find_package(Threads REQUIRED)
target_link_libraries(frame-studio PRIVATE glfw Threads::Threads ${CMAKE_DL_LIBS} m)
endif()
if(APPLE)
set_target_properties(frame-studio PROPERTIES MACOSX_BUNDLE_BUNDLE_NAME "Frame Studio" MACOSX_BUNDLE_GUI_IDENTIFIER "com.framestudio.app")
endif()
# Frame Home's headset side (arm64 binaries + install.sh) ships beside the exe, in frame\: Frame > Install on Steam Frame.
add_custom_command(TARGET frame-studio POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory "${ROOT}/frame/payload" "$<TARGET_FILE_DIR:frame-studio>/frame")
if(MINGW) # MinGW builds (x64, x86 and ARM64): wWinMain entry point, no runtime DLLs to ship
target_link_options(frame-studio PRIVATE -municode -static)
endif()
if(WIN32 AND CMAKE_SIZEOF_VOID_P EQUAL 4) # 32-bit: up to 4 GB of address space on 64-bit Windows instead of 2 (big homes)
if(MSVC)
target_link_options(frame-studio PRIVATE /LARGEADDRESSAWARE)
else()
target_link_options(frame-studio PRIVATE "LINKER:--large-address-aware")
endif()
endif()
+34
View File
@@ -0,0 +1,34 @@
@echo off
REM Builds Frame Studio with MSVC (any Visual Studio with the C++ workload). Output: <build dir>\Frame Studio.exe
REM Usage: build_windows.bat [x64 ^| x86 ^| arm64] [build dir]
REM x64 (default): 64-bit PCs. x86: 32-bit Windows. arm64: Windows on ARM (Snapdragon laptops, Surface Pro X...).
REM Build dir default: %LOCALAPPDATA%\FrameStudio\build-<arch> (local disk, fast).
REM Building for x86 or arm64 needs Visual Studio's matching C++ tools ("MSVC ... x64/x86" is always there; for
REM ARM64 add "MSVC ... ARM64/ARM64EC build tools" in the Visual Studio Installer).
setlocal
set "SRC=%~dp0"
set "ARCH=%~1"
if "%ARCH%"=="" set "ARCH=x64"
set "BDIR=%~2"
if "%BDIR%"=="" set "BDIR=%LOCALAPPDATA%\FrameStudio\build-%ARCH%"
REM vcvarsall's host_target: built on this PC's own architecture, for the one asked for
set "HOSTARCH=amd64"
if /i "%PROCESSOR_ARCHITECTURE%"=="ARM64" set "HOSTARCH=arm64"
if /i "%ARCH%"=="x64" set "VCARCH=%HOSTARCH%_amd64"
if /i "%ARCH%"=="x86" set "VCARCH=%HOSTARCH%_x86"
if /i "%ARCH%"=="arm64" set "VCARCH=%HOSTARCH%_arm64"
if "%VCARCH%"=="" ( echo ERROR: unknown architecture "%ARCH%": x64, x86 or arm64 & exit /b 1 )
if "%VCARCH%"=="amd64_amd64" set "VCARCH=amd64"
if "%VCARCH%"=="arm64_arm64" set "VCARCH=arm64"
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSPATH=%%i"
if "%VSPATH%"=="" ( echo ERROR: Visual Studio with the C++ toolset not found & exit /b 1 )
call "%VSPATH%\VC\Auxiliary\Build\vcvarsall.bat" %VCARCH% >nul || ( echo ERROR: no MSVC tools for %ARCH% & exit /b 1 )
where cmake >nul 2>nul || set "PATH=%VSPATH%\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin;%PATH%"
where ninja >nul 2>nul || set "PATH=%VSPATH%\Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja;%PATH%"
cmake -S "%SRC%." -B "%BDIR%" -G Ninja -DCMAKE_BUILD_TYPE=Release || exit /b 1
cmake --build "%BDIR%" --target frame-studio || exit /b 1
echo Built "%BDIR%\Frame Studio.exe" (%ARCH%)
+104
View File
@@ -0,0 +1,104 @@
#pragma once
// Frame pacing for Frame Studio's window: one frame per display refresh while the window is in use, a few per
// second when it's in the background, and none at all once nothing on screen can change (no input for a moment,
// nothing animating, loading or saving) until the next input arrives.
//
// Without it the UI loop drew as fast as the GPU allowed (MAILBOX presents never block): ~75% of an RTX 3060 for a
// home sitting still on a 164 Hz monitor.
//
// The wait happens before the frame reads input, so pacing adds no input latency: the frame starts as late as it can.
// The window code (Win32 or GLFW) supplies what the pacer can't know: the display's rate, whether the window has
// focus, and how to sleep until input arrives.
#ifdef _WIN32
#include <windows.h>
#endif
#include <algorithm>
#include <chrono>
#include <functional>
#include <thread>
class FramePacer {
public:
using Clock = std::chrono::steady_clock;
// Frames per second while in the background (another window has focus).
static constexpr double kBackgroundFps = 30.0;
// After the last input, keep drawing at full rate this long (ImGui hover effects, tooltips and drags settle).
static constexpr double kSettleSeconds = 1.0;
// Idle waits wake at least this often, so background work's progress (saving, uploading) still shows.
static constexpr double kIdleWakeSeconds = 0.25;
// isFocused: the window has the keyboard. waitForInput(seconds): sleep until input arrives or the time is up.
FramePacer(std::function<bool()> isFocused, std::function<void(double)> waitForInput)
: isFocused_(std::move(isFocused)), waitForInput_(std::move(waitForInput)) {
#ifdef _WIN32
// High-resolution waitable timer (Windows 10 1803+); a plain one (~1-15 ms granularity) otherwise.
timer_ = CreateWaitableTimerExW(nullptr, nullptr, CREATE_WAITABLE_TIMER_HIGH_RESOLUTION, TIMER_ALL_ACCESS);
if (!timer_) timer_ = CreateWaitableTimerExW(nullptr, nullptr, 0, TIMER_ALL_ACCESS);
#endif
lastInput_ = nextFrame_ = Clock::now();
}
~FramePacer() {
#ifdef _WIN32
if (timer_) CloseHandle(timer_);
#endif
}
FramePacer(const FramePacer&) = delete;
FramePacer& operator=(const FramePacer&) = delete;
// FS_FPS=n overrides the display's rate (0: unlimited, the old behaviour, for comparisons).
void setFpsOverride(double fps) { fpsOverride_ = fps; }
// The refresh rate of the display the window is on (the window code tells it again when the window moves).
void setDisplayFps(double fps) { displayFps_ = fps > 1 ? fps : 60.0; }
double displayFps() const { return displayFps_; }
// Call for every input from the user (keyboard, mouse, resize, focus, file drop).
void noteInput() { lastInput_ = Clock::now(); }
// Blocks until the next frame is due. `sceneChanging`: something on screen moves by itself (animation playing,
// a home loading, a save's progress, a fly-through). An idle wait ends early when input arrives.
void waitForNextFrame(bool sceneChanging) {
const bool focused = isFocused_();
const bool settled = std::chrono::duration<double>(Clock::now() - lastInput_).count() > kSettleSeconds;
if (settled && !sceneChanging) {
// Nothing can change until the user does something: sleep until input.
waitForInput_(kIdleWakeSeconds);
nextFrame_ = Clock::now() - period(displayFps_); // the frame after waking starts at once
return;
}
const double fps = fpsOverride_ >= 0 ? fpsOverride_ : focused ? displayFps_ : kBackgroundFps;
if (fps <= 0) return;
// A frame that ran late starts the next period from now instead of rushing to catch up.
nextFrame_ = std::max(nextFrame_ + period(fps), Clock::now());
// Input arriving meanwhile waits for the frame (at most one refresh): waking for each event would let a
// 1000 Hz mouse drive the frame rate.
sleepUntil(nextFrame_);
}
private:
static Clock::duration period(double fps) {
return std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(1.0 / fps));
}
void sleepUntil(Clock::time_point when) {
const auto wait = when - Clock::now();
if (wait <= Clock::duration::zero()) return;
#ifdef _WIN32
// Windows' Sleep has ~1-15 ms granularity: a high-resolution timer hits the refresh.
LARGE_INTEGER due;
due.QuadPart = -static_cast<LONGLONG>(std::chrono::duration_cast<std::chrono::nanoseconds>(wait).count() / 100); // relative, 100 ns units
if (timer_ && SetWaitableTimer(timer_, &due, 0, nullptr, nullptr, FALSE)) { WaitForSingleObject(timer_, INFINITE); return; }
#endif
std::this_thread::sleep_until(when);
}
std::function<bool()> isFocused_;
std::function<void(double)> waitForInput_;
#ifdef _WIN32
HANDLE timer_ = nullptr;
#endif
double displayFps_ = 60;
double fpsOverride_ = -1;
Clock::time_point lastInput_, nextFrame_;
};
+55
View File
@@ -0,0 +1,55 @@
#pragma once
// What Frame Studio needs from the operating system, behind one interface: platform_win32.cpp (Windows) and
// platform_posix.cpp (Linux, macOS). Text and paths cross it as UTF-8 std::string (or fs::path); the rest of Studio
// never includes a system header.
#include <cstdio>
#include <ctime>
#include <filesystem>
#include <string>
#include <vector>
namespace platform {
namespace fs = std::filesystem;
// ── Text ──
std::wstring widen(const std::string& utf8);
std::string narrow(const std::wstring& wide);
// ── Places ──
fs::path exeDir(); // the folder Frame Studio runs from (the Frame payload ships beside it)
fs::path userDataDir(); // %APPDATA% / $XDG_DATA_HOME (~/.local/share) / ~/Library/Application Support
fs::path documentsDir(); // Documents (the user's own; ~ when there's none)
FILE* openFile(const fs::path& path, const char* mode); // fopen with a Unicode path everywhere
std::tm localTime(std::time_t t); // (thread-safe localtime)
// ── Other programs ──
// Runs a command line (no console window); stdout and stderr land in `out`. Returns its exit code (-1: not started).
// The line is in the platform's shell syntax as far as quoting goes: "double quotes" around arguments work on both.
int run(const std::string& commandLine, const fs::path& workingDir, std::string& out);
// ssh / scp, ready to prefix arguments: quoted when it's a path, with a trailing space.
std::string sshTool(const char* name);
// Opens a folder in the file browser, or a file / URL in its usual program.
void openPath(const std::string& pathOrUrl);
// ── Dialogs ── (modal; they block until answered)
// The window dialogs belong to (Windows: its HWND), so they come up over it. Null: none.
void setDialogOwner(void* nativeWindow);
enum class Icon { Info, Warning, Error, Question };
void message(const std::string& title, const std::string& text, Icon icon = Icon::Info);
enum class Answer { Yes, No, Cancel };
Answer ask(const std::string& title, const std::string& text, bool withCancel, Icon icon = Icon::Question);
// One line of a file dialog's type list: "Meta home" with {"*.apk"}. "All files" is always added after them.
struct FileFilter {
std::string description;
std::vector<std::string> patterns;
};
// The files picked (empty: cancelled). `multiple`: several may be picked at once.
std::vector<std::string> openFiles(const std::string& title, const std::vector<FileFilter>& filters, bool multiple);
// Where to save (empty: cancelled). `suggestedName` is filled in; `extension` ("fenv", no dot) is added to a name
// typed without one. Asks before replacing a file that exists.
std::string saveFile(const std::string& title, const std::string& suggestedName, const std::vector<FileFilter>& filters,
const std::string& extension);
} // namespace platform
+240
View File
@@ -0,0 +1,240 @@
// platform.h on Linux and macOS: dialogs through tinyfiledialogs (zenity / kdialog on Linux, the system's own panels
// on macOS), programs through /bin/sh, folders after the XDG base directories (Linux) or ~/Library (macOS).
#include "platform.h"
#include <tinyfiledialogs.h>
#include <fcntl.h>
#include <spawn.h>
#include <sys/wait.h>
#include <unistd.h>
#ifdef __APPLE__
#include <mach-o/dyld.h>
#endif
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <mutex>
extern char** environ;
namespace platform {
namespace {
fs::path home() {
const char* h = std::getenv("HOME");
return h && *h ? fs::path(h) : fs::temp_directory_path();
}
// The text a dialog shows: tinyfiledialogs refuses quotes (it hands the text to a shell), so they become the
// typographic ones ("Couldn't" reads the same as "Couldn’t").
std::string dialogText(const std::string& s) {
std::string out;
for (char c : s) out += c == '\'' || c == '`' ? "’" : c == '"' ? "”" : std::string(1, c);
return out;
}
// One argument for /bin/sh: in single quotes, its own single quotes closed, escaped and reopened.
std::string shellQuote(const std::string& s) {
std::string q = "'";
for (char c : s) q += c == '\'' ? std::string("'\\''") : std::string(1, c);
return q + "'";
}
const char* iconName(Icon icon) {
switch (icon) {
case Icon::Warning: return "warning";
case Icon::Error: return "error";
case Icon::Question: return "question";
default: return "info";
}
}
// tinyfiledialogs takes one filter: every pattern of every line, described by the first line.
struct OneFilter {
std::vector<std::string> patterns;
std::vector<const char*> pointers;
std::string description;
explicit OneFilter(const std::vector<FileFilter>& filters) {
for (const FileFilter& f : filters) {
if (description.empty()) description = f.description;
patterns.insert(patterns.end(), f.patterns.begin(), f.patterns.end());
}
for (const std::string& p : patterns) pointers.push_back(p.c_str());
}
};
} // namespace
// wchar_t is UTF-32 here: decode and encode UTF-8 directly.
std::wstring widen(const std::string& s) {
std::wstring w;
w.reserve(s.size());
for (size_t i = 0; i < s.size();) {
const unsigned char c = static_cast<unsigned char>(s[i]);
const int extra = c < 0x80 ? 0 : c < 0xE0 ? 1 : c < 0xF0 ? 2 : 3;
uint32_t cp = extra == 0 ? c : extra == 1 ? c & 0x1F : extra == 2 ? c & 0x0F : c & 0x07;
if (extra > 0 && i + extra >= s.size()) { w += L'�'; break; } // a sequence cut short
for (int k = 1; k <= extra; ++k) cp = (cp << 6) | (static_cast<unsigned char>(s[i + k]) & 0x3F);
w += static_cast<wchar_t>(cp);
i += 1 + extra;
}
return w;
}
std::string narrow(const std::wstring& w) {
std::string s;
s.reserve(w.size());
for (wchar_t wc : w) {
const uint32_t cp = static_cast<uint32_t>(wc);
if (cp < 0x80) s += static_cast<char>(cp);
else if (cp < 0x800) { s += static_cast<char>(0xC0 | (cp >> 6)); s += static_cast<char>(0x80 | (cp & 0x3F)); }
else if (cp < 0x10000) {
s += static_cast<char>(0xE0 | (cp >> 12)); s += static_cast<char>(0x80 | ((cp >> 6) & 0x3F)); s += static_cast<char>(0x80 | (cp & 0x3F));
} else {
s += static_cast<char>(0xF0 | (cp >> 18)); s += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
s += static_cast<char>(0x80 | ((cp >> 6) & 0x3F)); s += static_cast<char>(0x80 | (cp & 0x3F));
}
}
return s;
}
fs::path exeDir() {
std::error_code ec;
#ifdef __APPLE__
char buf[4096];
uint32_t size = sizeof buf;
if (_NSGetExecutablePath(buf, &size) == 0) return fs::canonical(buf, ec).parent_path();
return fs::current_path(ec);
#else
const fs::path self = fs::read_symlink("/proc/self/exe", ec);
return ec ? fs::current_path(ec) : self.parent_path();
#endif
}
fs::path userDataDir() {
#ifdef __APPLE__
return home() / "Library" / "Application Support";
#else
const char* xdg = std::getenv("XDG_DATA_HOME");
return xdg && *xdg ? fs::path(xdg) : home() / ".local" / "share";
#endif
}
fs::path documentsDir() {
static const fs::path found = [] {
std::error_code ec;
#ifndef __APPLE__
// xdg-user-dirs' answer (localized folder names), when it's set up
std::string out;
if (run("xdg-user-dir DOCUMENTS", {}, out) == 0) {
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
if (!out.empty() && fs::is_directory(out, ec) && fs::path(out) != home()) return fs::path(out);
}
#endif
const fs::path docs = home() / "Documents";
return fs::is_directory(docs, ec) ? docs : home();
}();
return found;
}
FILE* openFile(const fs::path& path, const char* mode) { return std::fopen(path.c_str(), mode); }
std::tm localTime(std::time_t t) {
std::tm tm = {};
localtime_r(&t, &tm);
return tm;
}
int run(const std::string& commandLine, const fs::path& workingDir, std::string& out) {
// Both ends close on exec: a command started meanwhile by another thread (Send to Frame and Install run at once)
// must not inherit this pipe, or its reader never sees the end. The child's own copies (dup2) stay open.
int pipeFds[2];
{
static std::mutex creating; // (no pipe2 on macOS: made and marked as one step)
std::lock_guard<std::mutex> lock(creating);
if (::pipe(pipeFds) != 0) return -1;
::fcntl(pipeFds[0], F_SETFD, FD_CLOEXEC);
::fcntl(pipeFds[1], F_SETFD, FD_CLOEXEC);
}
posix_spawn_file_actions_t actions;
posix_spawn_file_actions_init(&actions);
posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0);
posix_spawn_file_actions_adddup2(&actions, pipeFds[1], 1);
posix_spawn_file_actions_adddup2(&actions, pipeFds[1], 2);
posix_spawn_file_actions_addclose(&actions, pipeFds[0]);
posix_spawn_file_actions_addclose(&actions, pipeFds[1]);
// (the working directory through the shell: posix_spawn_file_actions_addchdir isn't everywhere yet)
const std::string line = workingDir.empty() ? commandLine : "cd " + shellQuote(workingDir.string()) + " && " + commandLine;
const char* argv[] = {"/bin/sh", "-c", line.c_str(), nullptr};
pid_t pid = -1;
const int spawned = posix_spawn(&pid, "/bin/sh", &actions, nullptr, const_cast<char* const*>(argv), environ);
posix_spawn_file_actions_destroy(&actions);
::close(pipeFds[1]);
if (spawned != 0) { ::close(pipeFds[0]); out = "could not start: " + commandLine; return -1; }
char buf[4096];
for (ssize_t n; (n = ::read(pipeFds[0], buf, sizeof buf)) > 0;) out.append(buf, static_cast<size_t>(n));
::close(pipeFds[0]);
int status = 0;
while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) {}
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
std::string sshTool(const char* name) { return std::string(name) + " "; }
void openPath(const std::string& pathOrUrl) {
#ifdef __APPLE__
const std::string cmd = "open " + shellQuote(pathOrUrl) + " >/dev/null 2>&1 &";
#else
const std::string cmd = "xdg-open " + shellQuote(pathOrUrl) + " >/dev/null 2>&1 &";
#endif
std::string ignored;
run(cmd, {}, ignored);
}
void setDialogOwner(void*) {}
void message(const std::string& title, const std::string& text, Icon icon) {
tinyfd_messageBox(dialogText(title).c_str(), dialogText(text).c_str(), "ok", iconName(icon), 1);
}
Answer ask(const std::string& title, const std::string& text, bool withCancel, Icon icon) {
const int r = tinyfd_messageBox(dialogText(title).c_str(), dialogText(text).c_str(), withCancel ? "yesnocancel" : "yesno", iconName(icon), 1);
// tinyfiledialogs: 1 yes; yesnocancel: 2 no, 0 cancel; yesno: 0 no
return r == 1 ? Answer::Yes : withCancel && r == 0 ? Answer::Cancel : Answer::No;
}
std::vector<std::string> openFiles(const std::string& title, const std::vector<FileFilter>& filters, bool multiple) {
const OneFilter f(filters);
const char* picked = tinyfd_openFileDialog(dialogText(title).c_str(), nullptr, static_cast<int>(f.pointers.size()),
f.pointers.empty() ? nullptr : f.pointers.data(),
f.description.empty() ? nullptr : f.description.c_str(), multiple ? 1 : 0);
std::vector<std::string> out;
if (!picked) return out;
// several files come back joined by '|'
std::string all = picked;
for (size_t at = 0; at <= all.size();) {
const size_t bar = all.find('|', at);
const std::string one = all.substr(at, bar == std::string::npos ? std::string::npos : bar - at);
if (!one.empty()) out.push_back(one);
if (bar == std::string::npos) break;
at = bar + 1;
}
return out;
}
std::string saveFile(const std::string& title, const std::string& suggestedName, const std::vector<FileFilter>& filters,
const std::string& extension) {
const OneFilter f(filters);
const char* picked = tinyfd_saveFileDialog(dialogText(title).c_str(), suggestedName.c_str(), static_cast<int>(f.pointers.size()),
f.pointers.empty() ? nullptr : f.pointers.data(),
f.description.empty() ? nullptr : f.description.c_str());
if (!picked) return {};
std::string path = picked;
if (!extension.empty() && fs::path(path).extension().empty()) path += "." + extension;
return path;
}
} // namespace platform
+197
View File
@@ -0,0 +1,197 @@
// platform.h on Windows: Win32 dialogs and message boxes, CreateProcess, the shell's folders.
#include "platform.h"
#include <windows.h> // before the other Win32 headers
#include <commdlg.h>
#include <shellapi.h>
#include <shlobj.h>
#include <mutex>
namespace platform {
namespace {
HWND g_owner = nullptr;
}
std::wstring widen(const std::string& s) {
if (s.empty()) return {};
const int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
std::wstring w(n, L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), w.data(), n);
return w;
}
std::string narrow(const std::wstring& w) {
if (w.empty()) return {};
const int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
std::string s(n, '\0');
WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), s.data(), n, nullptr, nullptr);
return s;
}
fs::path exeDir() {
std::wstring buf(MAX_PATH, L'\0');
for (;;) {
const DWORD n = GetModuleFileNameW(nullptr, buf.data(), static_cast<DWORD>(buf.size()));
if (n < buf.size()) { buf.resize(n); break; }
buf.resize(buf.size() * 2);
}
return fs::path(buf).parent_path();
}
fs::path userDataDir() {
wchar_t buf[MAX_PATH] = {};
const DWORD n = GetEnvironmentVariableW(L"APPDATA", buf, MAX_PATH);
return n ? fs::path(buf) : fs::temp_directory_path();
}
fs::path documentsDir() {
wchar_t buf[MAX_PATH] = {};
return SUCCEEDED(SHGetFolderPathW(nullptr, CSIDL_PERSONAL, nullptr, 0, buf)) ? fs::path(buf) : userDataDir();
}
FILE* openFile(const fs::path& path, const char* mode) { return _wfopen(path.c_str(), widen(mode).c_str()); }
std::tm localTime(std::time_t t) {
std::tm tm = {};
localtime_s(&tm, &t);
return tm;
}
int run(const std::string& commandLine, const fs::path& workingDir, std::string& out) {
// Inheritable handles exist only between CreatePipe and the CloseHandle after CreateProcess. Send to Frame and
// Install run commands from two threads: without this lock one child could inherit the other's pipe, whose
// reader then never sees end-of-file until that child exits.
static std::mutex spawn;
std::unique_lock<std::mutex> spawning(spawn);
SECURITY_ATTRIBUTES sa = {sizeof sa, nullptr, TRUE};
HANDLE rd = nullptr, wr = nullptr;
if (!CreatePipe(&rd, &wr, &sa, 0)) return -1;
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
HANDLE nul = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, nullptr);
STARTUPINFOW si = {sizeof si};
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = nul;
si.hStdOutput = si.hStdError = wr;
PROCESS_INFORMATION pi = {};
std::wstring cmd = widen(commandLine);
const std::wstring cwd = workingDir.wstring();
const BOOL ok = CreateProcessW(nullptr, cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr,
cwd.empty() ? nullptr : cwd.c_str(), &si, &pi);
CloseHandle(wr);
if (nul != INVALID_HANDLE_VALUE) CloseHandle(nul);
spawning.unlock();
if (!ok) { CloseHandle(rd); out = "could not start: " + commandLine; return -1; }
char buf[4096];
DWORD n = 0;
while (ReadFile(rd, buf, sizeof buf, &n, nullptr) && n) out.append(buf, n);
CloseHandle(rd);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return static_cast<int>(code);
}
// Windows' OpenSSH when installed, else Git for Windows' copy, else whatever PATH finds.
std::string sshTool(const char* name) {
wchar_t found[MAX_PATH] = {};
const std::wstring exe = widen(name) + L".exe";
if (SearchPathW(nullptr, exe.c_str(), nullptr, MAX_PATH, found, nullptr)) return "\"" + narrow(found) + "\" ";
wchar_t win[MAX_PATH] = {};
GetSystemDirectoryW(win, MAX_PATH);
std::vector<fs::path> tries = {fs::path(win) / L"OpenSSH" / exe};
for (const wchar_t* pf : {L"ProgramW6432", L"ProgramFiles", L"LOCALAPPDATA"}) {
wchar_t d[MAX_PATH] = {};
if (GetEnvironmentVariableW(pf, d, MAX_PATH)) {
tries.push_back(fs::path(d) / L"Git" / L"usr" / L"bin" / exe);
tries.push_back(fs::path(d) / L"Programs" / L"Git" / L"usr" / L"bin" / exe);
}
}
std::error_code ec;
for (const fs::path& t : tries) if (fs::exists(t, ec)) return "\"" + narrow(t.wstring()) + "\" ";
return std::string(name) + " ";
}
void openPath(const std::string& pathOrUrl) {
ShellExecuteW(nullptr, L"open", widen(pathOrUrl).c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
void setDialogOwner(void* nativeWindow) { g_owner = static_cast<HWND>(nativeWindow); }
namespace {
UINT iconFlag(Icon icon) {
switch (icon) {
case Icon::Warning: return MB_ICONWARNING;
case Icon::Error: return MB_ICONERROR;
case Icon::Question: return MB_ICONQUESTION;
default: return MB_ICONINFORMATION;
}
}
// "Meta home (*.apk)\0*.apk\0...All files\0*.*\0\0", as OPENFILENAMEW wants it.
std::wstring filterString(const std::vector<FileFilter>& filters) {
std::wstring s;
for (const FileFilter& f : filters) {
std::wstring patterns;
for (const std::string& p : f.patterns) patterns += (patterns.empty() ? L"" : L";") + widen(p);
s += widen(f.description) + L" (" + patterns + L")" + std::wstring(1, L'\0') + patterns + std::wstring(1, L'\0');
}
s += L"All files";
s += L'\0';
s += L"*.*";
s += L'\0';
return s; // (the string's own terminator ends the list)
}
} // namespace
void message(const std::string& title, const std::string& text, Icon icon) {
MessageBoxW(g_owner, widen(text).c_str(), widen(title).c_str(), MB_OK | iconFlag(icon));
}
Answer ask(const std::string& title, const std::string& text, bool withCancel, Icon icon) {
const int r = MessageBoxW(g_owner, widen(text).c_str(), widen(title).c_str(), (withCancel ? MB_YESNOCANCEL : MB_YESNO) | iconFlag(icon));
return r == IDYES ? Answer::Yes : r == IDNO ? Answer::No : Answer::Cancel;
}
std::vector<std::string> openFiles(const std::string& title, const std::vector<FileFilter>& filters, bool multiple) {
std::vector<wchar_t> buf(multiple ? 32768 : 4096, L'\0');
const std::wstring filter = filterString(filters), wtitle = widen(title);
OPENFILENAMEW ofn = {sizeof ofn};
ofn.hwndOwner = g_owner;
ofn.lpstrFilter = filter.c_str();
ofn.lpstrFile = buf.data();
ofn.nMaxFile = static_cast<DWORD>(buf.size());
ofn.lpstrTitle = wtitle.c_str();
ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR | (multiple ? OFN_ALLOWMULTISELECT | OFN_EXPLORER : 0);
if (!GetOpenFileNameW(&ofn)) return {};
// Multi-select returns "dir\0file1\0file2\0\0"; a single pick returns "full path\0".
std::vector<std::string> picked;
const wchar_t* p = buf.data();
const std::wstring first = p;
p += first.size() + 1;
if (!*p) picked.push_back(narrow(first));
for (; *p; p += std::wcslen(p) + 1) picked.push_back(narrow((fs::path(first) / p).wstring()));
return picked;
}
std::string saveFile(const std::string& title, const std::string& suggestedName, const std::vector<FileFilter>& filters,
const std::string& extension) {
std::vector<wchar_t> buf(4096, L'\0');
const std::wstring name = widen(suggestedName), filter = filterString(filters), wtitle = widen(title), ext = widen(extension);
std::wcsncpy(buf.data(), name.c_str(), buf.size() - 1);
OPENFILENAMEW ofn = {sizeof ofn};
ofn.hwndOwner = g_owner;
ofn.lpstrFilter = filter.c_str();
ofn.lpstrDefExt = ext.empty() ? nullptr : ext.c_str();
ofn.lpstrFile = buf.data();
ofn.nMaxFile = static_cast<DWORD>(buf.size());
ofn.lpstrTitle = wtitle.c_str();
ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
if (!GetSaveFileNameW(&ofn)) return {};
return narrow(buf.data());
}
} // namespace platform
+8284
View File
File diff suppressed because it is too large. Load diff
+94
View File
@@ -0,0 +1,94 @@
// Frame Home never puts your eyes inside things or your feet through a floor (Locomotion over a baked walkable area):
// walking down an open staircase along its side, and walking / leaning into a wall for real.
#include "locomotion.h"
#include <cstdio>
static int fails = 0;
static void ck(bool ok, const char* w, float a = 0, float b = 0, float c = 0) { std::printf("%s %s (%.2f %.2f %.2f)\n", ok ? "PASS" : "FAIL", w, a, b, c); fails += !ok; }
static WorldCollider box(float x0, float y0, float z0, float x1, float y1, float z1) {
WorldCollider c; c.kind = WorldCollider::Box; c.role = WorldCollider::Solid;
c.half[0] = (x1 - x0) / 2; c.half[1] = (y1 - y0) / 2; c.half[2] = (z1 - z0) / 2;
c.world[12] = (x0 + x1) / 2; c.world[13] = (y0 + y1) / 2; c.world[14] = (z0 + z1) / 2;
return c;
}
static void bake(HomeWorld& w) {
std::vector<WalkSource> src;
std::vector<std::vector<float>> P;
std::vector<std::vector<uint32_t>> I;
walkColliderSources(w, src, P, I);
w.walkGridCache = buildWalkGrid(src, walkSeeds(w), WalkParams{}, nullptr);
}
int main() {
{ // an open staircase: a 1.8 m landing, 8 treads 20 cm down, 30 cm deep, 1 m wide, the ground all round and under
HomeWorld w;
w.colliders.push_back(box(-8, -0.1f, -8, 8, 0, 8));
w.colliders.push_back(box(-4, 0, -1, -2, 1.8f, 1));
for (int i = 0; i < 8; ++i) w.colliders.push_back(box(-2 + 0.3f * i, 1.8f - 0.2f * (i + 1) - 0.05f, -0.5f, -2 + 0.3f * (i + 1), 1.8f - 0.2f * (i + 1), 0.5f));
WorldPoint sp; sp.pos[0] = -3; sp.pos[1] = 1.8f; sp.pos[2] = 0.38f; sp.yaw = -1.5708f; // facing +X, down the stairs
w.spawns.push_back(sp);
WorldPoint g; g.pos[0] = 4; g.pos[1] = 0; g.pos[2] = 0; w.hotspots.push_back(g);
bake(w);
Locomotion loco;
loco.setWorld(w);
float head[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1};
loco.testInput(0, 1, 0, 0);
float worst = 1e9f, x = -3, y = 0;
for (int i = 0; i < 900 && x < 0.5f; ++i) {
loco.update(1.0f / 90, head);
float R[16];
loco.rig(R);
x = R[0] * head[12] + R[4] * head[13] + R[8] * head[14] + R[12];
y = R[1] * head[12] + R[5] * head[13] + R[9] * head[14] + R[13];
float tread = 0; // the top under the eyes
if (x < -2) tread = 1.8f;
else if (x < 0.4f) tread = std::max(0.0f, 1.8f - 0.2f * (std::floor((x + 2) / 0.3f) + 1));
worst = std::min(worst, (y - 1.7f) - tread);
}
loco.testInput(0, 0, 0, 0);
for (int i = 0; i < 90; ++i) loco.update(1.0f / 90, head); // the last step eases down
{ float R[16]; loco.rig(R); y = R[1] * head[12] + R[5] * head[13] + R[9] * head[14] + R[13]; }
ck(x > 0.3f, "walked down the stairs along their open side", x, y);
ck(worst > -0.25f, "the feet never sink more than a quarter step under the tread (never through it)", worst);
ck(std::fabs(y - 1.7f) < 0.05f, "at the bottom, standing on the ground", y);
}
{ // a wall 1 m ahead: walk into it for real (no stick): the eyes stop short of it
HomeWorld w;
w.colliders.push_back(box(-8, -0.1f, -8, 8, 0, 8));
w.colliders.push_back(box(0.9f, 0, -2, 1.1f, 2.5f, 2));
WorldPoint sp; sp.pos[0] = 0; sp.pos[1] = 0; sp.pos[2] = 0; w.spawns.push_back(sp);
bake(w);
Locomotion loco;
loco.setWorld(w);
float head[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1};
float most = -1e9f;
for (int i = 0; i < 300; ++i) {
head[12] = std::min(1.6f, i * 0.008f); // stepping forward 1.6 m in the room
head[13] = 1.70f - (i > 200 ? 0.6f : 0.0f); // then bending down
loco.update(1.0f / 90, head);
float R[16];
loco.rig(R);
most = std::max(most, R[0] * head[12] + R[4] * head[13] + R[8] * head[14] + R[12]);
}
ck(most <= 0.9f - 0.14f, "walking into a wall for real, the eyes stay 15 cm off it", most);
}
{ // a coffee table (45 cm high) in the way: walking into it for real, you're kept at its edge
HomeWorld w;
w.colliders.push_back(box(-8, -0.1f, -8, 8, 0, 8));
w.colliders.push_back(box(0.8f, 0, -0.5f, 1.6f, 0.45f, 0.5f));
WorldPoint sp; sp.pos[0] = 0; sp.pos[1] = 0; sp.pos[2] = 0; w.spawns.push_back(sp);
bake(w);
Locomotion loco;
loco.setWorld(w);
float head[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1};
float most = -1e9f;
for (int i = 0; i < 250; ++i) {
head[12] = std::min(1.2f, i * 0.008f);
loco.update(1.0f / 90, head);
float R[16];
loco.rig(R);
most = std::max(most, R[0] * head[12] + R[4] * head[13] + R[8] * head[14] + R[12]);
}
ck(most <= 0.85f, "walking into a coffee table for real, you stop at its edge", most);
}
std::printf("%d failed\n", fails);
return fails != 0;
}
+410
View File
@@ -0,0 +1,410 @@
#include "locomotion.h"
#include "hsl_scene.h"
#include <cstdio>
static int fails = 0;
static void ck(bool ok, const char* w, float a = 0, float b = 0, float c = 0) { std::printf("%s %s (%.2f %.2f %.2f)\n", ok ? "PASS" : "FAIL", w, a, b, c); fails += !ok; }
int main() {
HomeWorld w;
WorldCollider fl; fl.kind = WorldCollider::Mesh; fl.role = WorldCollider::Floor;
fl.positions = {-10, 0, -10, 10, 0, -10, 10, 0, 10, -10, 0, 10}; fl.indices = {0, 2, 1, 0, 3, 2}; w.colliders.push_back(fl);
WorldPoint seat; seat.name = "Armchair"; seat.pos[0] = 2; seat.pos[1] = 0.45f; seat.pos[2] = -1; seat.yaw = 1.5708f; w.seats.push_back(seat);
Locomotion loco;
loco.setWorld(w);
float head[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1}; // standing, looking down -Z
auto eye = [&](float* o) { float R[16]; loco.rig(R); for (int k = 0; k < 3; ++k) o[k] = R[k] * head[12] + R[4 + k] * head[13] + R[8 + k] * head[14] + R[12 + k]; };
auto facing = [&]() { float R[16]; loco.rig(R); const float f[3] = {-(R[8]), 0, -(R[10])}; return std::atan2(-f[0], -f[2]); };
float e[3];
loco.testSit(0, head); eye(e);
ck(loco.seated() == 0, "landing on a seat sits you in it");
ck(std::fabs(e[0] - 2) < 1e-3f && std::fabs(e[2] + 1) < 1e-3f, "your head is over the seat", e[0], e[1], e[2]);
ck(std::fabs(e[1] - (0.45f + 0.78f)) < 1e-3f, "your eyes are at seated height over it (standing in the room)", e[1]);
ck(std::fabs(facing()) < 0.02f, "sitting keeps the way you face (no turn to the seat's front)", facing());
for (int i = 0; i < 30; ++i) loco.update(1.0f / 90, head);
eye(e);
ck(loco.seated() == 0 && std::fabs(e[0] - 2) < 1e-3f, "sitting still keeps you in the seat", e[0], e[1], e[2]);
head[13] = 1.20f; // you sit down for real
for (int i = 0; i < 400; ++i) loco.update(1.0f / 90, head);
eye(e);
ck(std::fabs(e[1] - 1.23f) < 0.03f, "sitting down for real eases back to seated eye height", e[1]);
float R0[16]; loco.rig(R0);
head[12] = 0.3f; // leaning: stays seated
loco.update(1.0f / 90, head);
ck(loco.seated() == 0, "leaning keeps you seated");
head[12] = 0.8f; // stepping away in the room
loco.update(1.0f / 90, head);
eye(e);
ck(loco.seated() == -1, "stepping 0.8 m away stands you up", e[0], e[1], e[2]);
ck(std::fabs(e[1] - 1.20f) < 0.02f, "standing again, the floor is the floor", e[1]);
head[12] = 0; head[13] = 1.70f;
loco.testSit(0, head);
loco.testWalkOff(head);
eye(e);
ck(loco.seated() == -1 && std::fabs(e[0] - 1.5f) < 0.02f && std::fabs(e[2] + 1) < 0.02f && std::fabs(e[1] - 1.70f) < 0.02f,
"walking off steps you out half a metre in front of the seat, standing", e[0], e[1], e[2]);
{ // the stick in a seat (slide teleports): you dash out to its stand-up spot (none named: half a metre in front)
Locomotion l2; l2.setWorld(w); l2.setOptions(1, 2, 0, 30, 2);
float hd[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1};
l2.testSit(0, hd);
l2.testInput(0, 1, 0, 0);
l2.update(1.0f / 90, hd);
l2.testInput(0, 0, 0, 0);
for (int i = 0; i < 90; ++i) l2.update(1.0f / 90, hd);
float R2[16]; l2.rig(R2);
const float ex = R2[0] * hd[12] + R2[4] * hd[13] + R2[8] * hd[14] + R2[12], ey = R2[1] * hd[12] + R2[5] * hd[13] + R2[9] * hd[14] + R2[13],
ez = R2[2] * hd[12] + R2[6] * hd[13] + R2[10] * hd[14] + R2[14];
ck(l2.seated() == -1 && std::fabs(ex - 1.5f) < 0.05f && std::fabs(ez + 1) < 0.05f && std::fabs(ey - 1.70f) < 0.05f,
"the stick in a seat dashes you out to its stand-up spot, standing", ex, ey, ez);
}
{ // falling off a small platform (roomscale) in an ordinary home: you fall, then you're back at the spawn
HomeWorld w2;
WorldCollider pl; pl.kind = WorldCollider::Mesh; pl.role = WorldCollider::Solid;
pl.positions = {-1, 0, -1, 1, 0, -1, 1, 0, 1, -1, 0, 1}; pl.indices = {0, 2, 1, 0, 3, 2}; w2.colliders.push_back(pl);
w2.fallLimit = 15;
Locomotion l2; l2.setWorld(w2);
float hd[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1};
for (int i = 0; i < 10; ++i) l2.update(1.0f / 90, hd);
hd[12] = 2.5f; // step off the edge in the room
int frames = 0;
float lowest = 1e9f;
while (l2.respawns() == 0 && frames < 900) { l2.update(1.0f / 90, hd); float R[16]; l2.rig(R); lowest = std::min(lowest, R[13]); ++frames; }
ck(l2.respawns() == 1 && lowest < -14.0f, "stepping off a platform you fall, and after 15 m you're back at the spawn", frames / 90.0f, lowest);
}
{ // a home with a gravity zone goes to the gravity walker
HomeWorld w3;
WorldCollider s3; s3.kind = WorldCollider::Sphere; s3.role = WorldCollider::Solid; s3.radius = 10; w3.colliders.push_back(s3);
WorldGravity g3; g3.kind = WorldGravity::Planet; g3.radius = 40; w3.gravity.push_back(g3);
WorldPoint sp3; sp3.pos[1] = 10; w3.spawns.push_back(sp3);
Locomotion l3; l3.setWorld(w3);
float hd[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1};
for (int i = 0; i < 30; ++i) l3.update(1.0f / 90, hd);
float R[16]; l3.rig(R);
const float ey = R[4] * hd[12] + R[5] * hd[13] + R[6] * hd[14] + R[13];
ck(l3.gravityMode() && std::fabs(ey - 11.7f) < 0.05f, "a planet home stands you on the planet (gravity walker)", ey);
}
{ // movement options (setOptions), on a plain 20 m floor, nothing near the spawn
HomeWorld w4;
w4.colliders.push_back(fl);
const float dt = 1.0f / 80;
float hd[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1};
auto eyeOf = [&](Locomotion& l, float* o) { float R[16]; l.rig(R); for (int k = 0; k < 3; ++k) o[k] = R[k] * hd[12] + R[4 + k] * hd[13] + R[8 + k] * hd[14] + R[12 + k]; };
auto yawOf = [&](Locomotion& l) { float R[16]; l.rig(R); return std::atan2(R[8], R[10]); };
auto run = [&](Locomotion& l, int n) { for (int i = 0; i < n; ++i) l.update(dt, hd); };
// The right hand at chest height, pointing forward and 30 degrees down (row-major 3x4).
const float aimDown[12] = {1, 0, 0, 0.2f, 0, 0.866f, 0.5f, 1.2f, 0, -0.5f, 0.866f, 0};
float e0[3], e1[3], t[3];
{
Locomotion l; l.setWorld(w4);
l.setOptions(0, 1, 0, 30, 2);
l.testInput(0, 1, 0, 0); eyeOf(l, e0); run(l, 80); eyeOf(l, e1);
ck(std::hypot(e1[0] - e0[0], e1[2] - e0[2]) < 1e-4f, "walk off: a full left stick for 1 s doesn't move you", e1[0], e1[2]);
l.setOptions(1, 1, 0, 30, 3);
run(l, 80); eyeOf(l, e1);
// (0.15 s ramp from a standstill: 1 s at full stick covers 3 * (1 - 0.075) m)
ck(std::fabs(e1[2] - e0[2] + 3 * (1 - grav::kAccel / 2)) < 0.05f && std::fabs(e1[0] - e0[0]) < 1e-3f, "walk on at 3 m/s: 1 s forward goes 3 m (less the ramp up)", e1[0], e1[2]);
}
{
Locomotion l; l.setWorld(w4);
l.setOptions(1, 1, 1, 30, 2);
const float y0 = yawOf(l);
eyeOf(l, e0);
l.testInput(0, 0, 1, 0); run(l, 40); // 0.5 s full right
const float y1 = yawOf(l);
run(l, 40); // 1 s
const float y2 = yawOf(l);
eyeOf(l, e1);
ck(std::fabs((y1 - y0) + 1.0472f) < 0.01f && std::fabs((y2 - y0) + 2.0944f) < 0.01f, "smooth turn: 60 degrees in 0.5 s, 120 in 1 s (right = clockwise)", y1 - y0, y2 - y0);
ck(std::hypot(e1[0] - e0[0], e1[2] - e0[2]) < 1e-4f, "smooth turn spins about the head", e1[0], e1[2]);
l.testInput(0, 0, 0.575f, 0); const float y3 = yawOf(l); run(l, 40);
ck(std::fabs((yawOf(l) - y3) + 0.5236f) < 0.01f, "half deflection past the deadzone turns half as fast", yawOf(l) - y3);
l.testInput(0, 0, 0.1f, 0); const float y4 = yawOf(l); run(l, 40);
ck(std::fabs(yawOf(l) - y4) < 1e-6f, "inside the deadzone: no turn", yawOf(l) - y4);
}
{
Locomotion l; l.setWorld(w4);
l.setOptions(1, 1, 0, 45, 2);
const float y0 = yawOf(l);
l.testInput(0, 0, -1, 0); run(l, 20); // held: one snap
const float y1 = yawOf(l);
l.testInput(0, 0, 0, 0); run(l, 2); l.testInput(0, 0, -1, 0); run(l, 2);
ck(std::fabs((y1 - y0) - 0.7854f) < 1e-4f && std::fabs((yawOf(l) - y0) - 1.5708f) < 1e-4f, "snap angle 45: a held flick turns 45 degrees once, the next another 45", y1 - y0, yawOf(l) - y0);
}
{
Locomotion l; l.setWorld(w4);
l.setOptions(1, 2, 0, 30, 2);
l.testInput(0, 0, 0, 1, aimDown); run(l, 3);
const bool aimed = l.testTarget(t);
eyeOf(l, e0);
ck(aimed && t[2] < -1.5f && std::fabs(t[1]) < 1e-3f, "aiming forward and down finds the floor ahead", t[0], t[1], t[2]);
l.testInput(0, 0, 0, 0); run(l, 10); // released; 10 frames = 0.125 s = half the dash
eyeOf(l, e1);
const float frac = (e1[2] - e0[2]) / (t[2] - e0[2]);
ck(l.sliding() && std::fabs(frac - 0.5f) < 0.02f, "slide: halfway through the dash, halfway there", frac);
run(l, 8); const bool still = l.sliding();
run(l, 4); eyeOf(l, e1);
ck(still && !l.sliding() && std::fabs(e1[0] - t[0]) < 1e-3f && std::fabs(e1[2] - t[2]) < 1e-3f && std::fabs(e1[1] - 1.70f) < 1e-3f,
"slide: after 0.25 s you're standing at the target", e1[0], e1[1], e1[2]);
l.testInput(1, 0, 0, 0); run(l, 1); // the stick works again afterwards (sanity)
}
{
Locomotion l; l.setWorld(w4);
l.setOptions(1, 1, 0, 30, 2); // instant (no compositor here: no blink delay)
l.testInput(0, 0, 0, 1, aimDown); run(l, 3); l.testTarget(t);
l.testInput(0, 0, 0, 0); run(l, 1); eyeOf(l, e1);
ck(std::fabs(e1[0] - t[0]) < 1e-3f && std::fabs(e1[2] - t[2]) < 1e-3f, "instant teleport lands on release", e1[0], e1[2]);
}
{
Locomotion l; l.setWorld(w4);
l.setOptions(1, 0, 0, 30, 2);
eyeOf(l, e0);
l.testInput(0, 0, 0, 1, aimDown); run(l, 10);
const bool aimed = l.testTarget(t);
l.testInput(0, 0, 0, 0); run(l, 30); eyeOf(l, e1);
ck(!aimed && std::hypot(e1[0] - e0[0], e1[2] - e0[2]) < 1e-4f, "teleport off: the stick forward aims nothing and goes nowhere", e1[0], e1[2]);
l.setOptions(1, 1, 0, 30, 2);
l.testInput(0, 0, 0, 1, aimDown); run(l, 2);
ck(l.testTarget(t), "teleport back on: aiming works again live");
}
{ // gravity homes honour them too
HomeWorld w5;
WorldCollider s5; s5.kind = WorldCollider::Sphere; s5.role = WorldCollider::Solid; s5.radius = 10; w5.colliders.push_back(s5);
WorldGravity g5; g5.kind = WorldGravity::Planet; g5.radius = 40; w5.gravity.push_back(g5);
WorldPoint sp5; sp5.pos[1] = 10; w5.spawns.push_back(sp5);
Locomotion l; l.setWorld(w5);
l.setOptions(0, 1, 1, 30, 2);
run(l, 30); eyeOf(l, e0);
l.testInput(0, 1, 0, 0); run(l, 80); eyeOf(l, e1);
ck(std::hypot(e1[0] - e0[0], e1[2] - e0[2]) < 1e-3f, "gravity home, walk off: the stick doesn't move you", e1[0], e1[2]);
l.setOptions(1, 1, 1, 30, 1);
run(l, 40); eyeOf(l, e1);
const float arc = 10.0f * std::atan2(std::hypot(e1[0], e1[2]), e1[1]);
ck(std::fabs(arc - (0.5f - grav::kAccel / 2)) < 0.03f, "gravity home, walk on at 1 m/s: 0.5 s walks 0.5 m round the planet, less the ramp up", arc);
float R0[16], R1[16]; l.rig(R0);
l.testInput(0, 0, 1, 0); run(l, 40); l.rig(R1);
const float cosA = R0[0] * R1[0] + R0[1] * R1[1] + R0[2] * R1[2];
ck(std::fabs(std::acos(std::clamp(cosA, -1.0f, 1.0f)) - 1.0472f) < 0.02f, "gravity home, smooth turn: 60 degrees in 0.5 s", std::acos(std::clamp(cosA, -1.0f, 1.0f)));
}
}
// ── Walking feel, teleport limits, seats ──
auto handPose = [](const float p[3], float dx, float dy, float dz, float* m) { // at p, pointing along d (3x4 row-major)
const float l = std::sqrt(dx * dx + dy * dy + dz * dz);
const float z[3] = {-dx / l, -dy / l, -dz / l};
float x[3] = {z[2], 0, -z[0]}; // up x z
const float xl = std::sqrt(x[0] * x[0] + x[2] * x[2]);
if (xl < 1e-4f) { x[0] = 1; x[2] = 0; } else { x[0] /= xl; x[2] /= xl; }
const float y[3] = {z[1] * x[2] - z[2] * x[1], z[2] * x[0] - z[0] * x[2], z[0] * x[1] - z[1] * x[0]};
for (int r = 0; r < 3; ++r) { m[r * 4] = x[r]; m[r * 4 + 1] = y[r]; m[r * 4 + 2] = z[r]; m[r * 4 + 3] = p[r]; }
};
auto quadC = [](int role, std::vector<float> pts) {
WorldCollider q; q.kind = WorldCollider::Mesh; q.role = role; q.positions = pts; q.indices = {0, 1, 2, 0, 2, 3}; return q;
};
const float dt = 1.0f / 80;
float hd[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1.70f, 0, 1};
auto eyeOf = [&](Locomotion& l, float* o) { float R[16]; l.rig(R); for (int k = 0; k < 3; ++k) o[k] = R[k] * hd[12] + R[4 + k] * hd[13] + R[8 + k] * hd[14] + R[12 + k]; };
auto run = [&](Locomotion& l, int n) { for (int i = 0; i < n; ++i) l.update(dt, hd); };
HomeWorld flat;
flat.colliders.push_back(quadC(WorldCollider::Floor, {-20, 0, -20, 20, 0, -20, 20, 0, 20, -20, 0, 20}));
{
Locomotion l; l.setWorld(flat); l.setOptions(1, 1, 0, 30, 2);
float a[3], b[3];
l.testInput(0, 1, 0, 0);
eyeOf(l, a); run(l, 1); eyeOf(l, b);
const float first = std::fabs(b[2] - a[2]) / dt;
run(l, 5); eyeOf(l, a); run(l, 1); eyeOf(l, b);
const float mid = std::fabs(b[2] - a[2]) / dt;
run(l, 20); eyeOf(l, a); run(l, 1); eyeOf(l, b);
const float full = std::fabs(b[2] - a[2]) / dt;
ck(first < 0.3f && mid > 0.7f && mid < 1.6f && std::fabs(full - 2) < 0.01f, "walking eases up to speed in ~0.15 s (no instant start)", first, mid, full);
l.testInput(0, 0, 0, 0);
eyeOf(l, a); run(l, 1); eyeOf(l, b);
const float after = std::fabs(b[2] - a[2]) / dt;
run(l, 10); eyeOf(l, a); run(l, 1); eyeOf(l, b);
ck(after > 1.5f && std::fabs(b[2] - a[2]) < 1e-5f, "letting go slows you to a stop within ~0.12 s (no instant stop)", after, std::fabs(b[2] - a[2]) / dt);
l.testInput(0, 0.575f, 0, 0); run(l, 40); eyeOf(l, a); run(l, 1); eyeOf(l, b);
const float half = std::fabs(b[2] - a[2]) / dt;
ck(half > 0.5f && half < 0.8f, "half a push walks about a third of full speed (small push = slow)", half);
l.testInput(0.1f, 0.1f, 0, 0); run(l, 40); eyeOf(l, a); run(l, 1); eyeOf(l, b);
ck(std::hypot(b[0] - a[0], b[2] - a[2]) < 1e-6f, "inside the round deadzone: no drift");
}
{ // a 0.2 m step up 1 m ahead, and a 20 degree ramp beyond it
HomeWorld w = flat;
w.colliders.push_back(quadC(WorldCollider::Solid, {-5, 0.2f, -1, 5, 0.2f, -1, 5, 0.2f, -4, -5, 0.2f, -4}));
const float rise = std::tan(20 * 3.14159f / 180) * 4;
w.colliders.push_back(quadC(WorldCollider::Floor, {-5, 0.2f, -4, 5, 0.2f, -4, 5, 0.2f + rise, -8, -5, 0.2f + rise, -8}));
Locomotion l; l.setWorld(w); l.setOptions(1, 1, 0, 30, 1);
l.testInput(0, 1, 0, 0);
float e[3], prev = 1.7f, maxJump = 0;
float onRampErr = 0;
for (int i = 0; i < 80 * 7; ++i) {
run(l, 1); eyeOf(l, e);
maxJump = std::max(maxJump, std::fabs(e[1] - prev));
prev = e[1];
if (e[2] < -4.6f && e[2] > -7.5f) {
const float surface = 0.2f + rise * (-4 - e[2]) / 4;
onRampErr = std::max(onRampErr, std::fabs(e[1] - 1.7f - surface));
}
}
ck(maxJump < 0.025f, "a 0.2 m step is eased: never more than 2.5 cm in one frame", maxJump);
ck(onRampErr < 0.01f, "a ramp is followed exactly: eyes stay 1.7 m over it walking up", onRampErr);
}
{ // teleporting: a wall 3 m tall across z = -3 on a 40 m floor
HomeWorld w = flat;
w.colliders.push_back(quadC(WorldCollider::Solid, {-10, 0, -3, 10, 0, -3, 10, 3, -3, -10, 3, -3}));
Locomotion l; l.setWorld(w); l.setOptions(1, 1, 0, 30, 2);
float m[12], t[3];
const float hand[3] = {0.2f, 1.2f, 0};
handPose(hand, 0, 0.25f, -1, m); // lobbed over the wall's top? no: into it
l.testInput(0, 0, 0, 1, m); run(l, 2);
const bool overWall = l.testTarget(t);
ck(!overWall || t[2] > -3, "aiming at the floor behind a wall: nowhere (or in front of it), never behind", overWall, t[2]);
ck(l.guide().aiming && l.guide().arc.size() >= 6 && l.guide().arc[l.guide().arc.size() - 1] > -3.01f, "the drawn arc stops at the wall", l.guide().arc[l.guide().arc.size() - 1]);
handPose(hand, 0, 0.1f, -1, m); // at the wall, ~0.6 m up
l.testInput(0, 0, 0, 1, m); run(l, 2);
ck(l.testTarget(t) && t[2] > -3 && t[2] < -2.5f && std::fabs(t[1]) < 0.05f, "aiming at a wall: you land at its foot, in front of it", t[1], t[2]);
handPose(hand, 0, 0, -1, m); // at its foot (hits 0.3 m up): lands in front
l.testInput(0, 0, 0, 1, m); run(l, 2);
ck(l.testTarget(t) && t[2] > -3 && t[2] < -2.6f, "aiming at a wall's foot: you land in front of it", t[2]);
const float atWall[3] = {0.2f, 1.2f, -3.2f}; // standing at the wall, hand through it
float hd2[16]; std::copy(hd, hd + 16, hd2); hd2[14] = -2.85f;
handPose(atWall, 0, -0.5f, -1, m);
l.testInput(0, 0, 0, 1, m); l.update(dt, hd2); l.update(dt, hd2);
ck(!l.testTarget(t), "a hand reached through the wall aims nothing on the far side");
l.testInput(0, 0, 0, 0); run(l, 2);
}
{ // a ceiling at 2.2 m with a floor on top (a storey above): aiming up never lands on it
HomeWorld w = flat;
w.colliders.push_back(quadC(WorldCollider::Solid, {-10, 2.2f, -10, 10, 2.2f, -10, 10, 2.2f, 10, -10, 2.2f, 10}));
Locomotion l; l.setWorld(w); l.setOptions(1, 1, 0, 30, 2);
float m[12], t[3];
const float hand[3] = {0.2f, 1.2f, 0};
handPose(hand, 0, 1, -1, m);
l.testInput(0, 0, 0, 1, m); run(l, 2);
ck(!l.testTarget(t) || t[1] < 0.1f, "aiming up into a ceiling: invalid, never onto the storey above", t[1]);
}
{ // low things: a 0.3 m kerb at z = -1.4 (the arc hits its face low), a table top, a box marked Wall
const float hand[3] = {0.2f, 1.2f, 0};
float m[12], t[3];
handPose(hand, 0, -0.5f, -0.866f, m); // lands ~1.53 m ahead on bare floor
{
HomeWorld w = flat;
w.colliders.push_back(quadC(WorldCollider::Solid, {-5, 0, -1.4f, 5, 0, -1.4f, 5, 0.3f, -1.4f, -5, 0.3f, -1.4f}));
Locomotion l; l.setWorld(w); l.setOptions(1, 1, 0, 30, 2);
l.testInput(0, 0, 0, 1, m); run(l, 2);
ck(l.testTarget(t) && t[2] > -1.4f && t[2] < -1.0f, "the arc hits a kerb's face low: you land at its foot, in front", t[2]);
}
{
HomeWorld w = flat; // a table top 0.7 m up over z -1.2..-2.2
w.colliders.push_back(quadC(WorldCollider::Solid, {-1, 0.7f, -1.2f, 1, 0.7f, -1.2f, 1, 0.7f, -2.2f, -1, 0.7f, -2.2f}));
Locomotion l; l.setWorld(w); l.setOptions(1, 1, 0, 30, 2);
l.testInput(0, 0, 0, 1, m); run(l, 2);
ck(!l.testTarget(t), "an arc landing under a table: no headroom, invalid");
float m2[12];
handPose(hand, 0, -0.15f, -1, m2); // flatter: comes down on the table top
l.testInput(0, 0, 0, 1, m2); run(l, 2);
ck(l.testTarget(t) && std::fabs(t[1] - 0.7f) < 1e-3f, "a solid table top is floor (the existing rule): you can land on it", t[1], t[2]);
}
{
HomeWorld w = flat;
WorldCollider b; b.kind = WorldCollider::Box; b.role = WorldCollider::Wall;
b.world[12] = 0; b.world[13] = 0.4f; b.world[14] = -1.7f; b.half[0] = 1; b.half[1] = 0.4f; b.half[2] = 0.5f;
w.colliders.push_back(b);
Locomotion l; l.setWorld(w); l.setOptions(1, 1, 0, 30, 2);
float m2[12];
handPose(hand, 0, 0, -1, m2);
l.testInput(0, 0, 0, 1, m2); run(l, 2);
ck(!l.testTarget(t) || std::fabs(t[1]) < 0.05f, "a box marked Wall is never landed on (its top): only the floor round it", t[1], t[2]);
const bool r = l.testTarget(t);
ck(l.guide().arc.size() >= 6 && l.guide().arc[l.guide().arc.size() - 3 + 1] <= 0.81f, "the arc stops on the Wall box", r, l.guide().arc[l.guide().arc.size() - 2]);
}
}
{ // seats: pointable, a trigger sits you, markers near you
HomeWorld w = flat;
WorldPoint s1; s1.name = "Sofa"; s1.pos[0] = 0; s1.pos[1] = 0.45f; s1.pos[2] = -3; s1.yaw = 0; w.seats.push_back(s1);
WorldPoint s2; s2.name = "Far bench"; s2.pos[0] = 15; s2.pos[1] = 0.45f; s2.pos[2] = 0; w.seats.push_back(s2);
Locomotion l; l.setWorld(w); l.setOptions(1, 1, 0, 30, 2);
const float o[3] = {0.2f, 1.2f, 0}, toSeat[3] = {-0.2f / 3.076f, -0.65f / 3.076f, -3 / 3.076f};
float t;
ck(l.seatOnRay(o, toSeat, 10, t) == 0 && t > 2.5f && t < 3.1f, "a ray at the sofa hits its seat volume", t);
const float away[3] = {1, 0, 0};
ck(l.seatOnRay(o, away, 10, t) == -1, "a ray elsewhere hits no seat");
run(l, 1);
ck(l.guide().seats.empty(), "markers: only seats right by you (the sofa 3 m away and the bench 15 m away aren't shown)", static_cast<float>(l.guide().seats.size()));
float m[12];
const float lh[3] = {-0.2f, 1.2f, 0};
handPose(lh, 0.2f, -0.65f, -3, m);
l.testInput(0, 0, 0, 0);
l.testButtons(false, false, m); run(l, 1);
ck(l.pointedSeat(1) == 0 && l.guide().seats.size() == 1 && l.guide().seats[0].hot && l.guide().pointer, "pointing the left hand at it lights its marker and shows the pointer");
l.testButtons(false, true, m); run(l, 2);
float e[3]; eyeOf(l, e);
ck(l.seated() == 0 && std::fabs(e[2] + 3) < 1e-3f && std::fabs(e[1] - (0.45f + 0.78f)) < 1e-3f, "the trigger while pointing sits you in it", e[0], e[1], e[2]);
run(l, 1);
ck(l.guide().seats.empty(), "seated: your own seat's marker is hidden");
l.testButtons(false, false, nullptr);
// standing again elsewhere, a slide setting dashes you into it
Locomotion l2; l2.setWorld(w); l2.setOptions(1, 2, 0, 30, 2);
l2.testInput(0, 0, 0, 0);
l2.testButtons(false, false, m); run(l2, 1);
l2.testButtons(false, true, m); run(l2, 1);
const bool dashing = l2.sliding();
run(l2, 30);
ck(dashing && l2.seated() == 0, "with slide teleports the trigger dashes you into the seat", dashing, static_cast<float>(l2.seated()));
// the teleport arc onto the seat ball
Locomotion l3; l3.setWorld(w); l3.setOptions(1, 1, 0, 30, 2);
bool seatAimed = false;
for (float up = -0.4f; up <= 0.4f && !seatAimed; up += 0.025f) { // some aim at it (whatever the arc's speed)
handPose(o, -0.065f, up, -1, m);
l3.testInput(0, 0, 0, 1, m); run(l3, 2);
seatAimed = l3.testTargetSeat() == 0 && l3.guide().seat;
}
ck(seatAimed, "the teleport arc hits the seat's volume: it targets the seat", static_cast<float>(l3.testTargetSeat()));
}
{ // a home with no world layer: floors and walls come from its drawn meshes (walkWorld -> geometryColliders)
HslScene sc;
auto mesh = [&](const char* name, std::vector<float> p) {
MeshData md; md.name = name; md.positions = p; md.nVerts = 4; md.indices = {0, 1, 2, 0, 2, 3}; sc.meshes.push_back(md);
};
mesh("floor", {-20, 0, -20, 20, 0, -20, 20, 0, 20, -20, 0, 20});
mesh("wall", {-10, 0, -3, 10, 0, -3, 10, 3, -3, -10, 3, -3});
Locomotion l; l.setWorld(walkWorld(sc)); l.setOptions(1, 1, 0, 30, 2);
float m[12], t[3];
const float hand[3] = {0.2f, 1.2f, 0};
handPose(hand, 0, 0.25f, -1, m);
l.testInput(0, 0, 0, 1, m); run(l, 2);
const bool ok = l.testTarget(t);
ck(!ok || t[2] > -3, "geometry home: the drawn wall stops the arc (never lands behind it)", ok, t[2]);
handPose(hand, 0, -0.5f, -0.866f, m);
l.testInput(0, 0, 0, 1, m); run(l, 2);
ck(l.testTarget(t) && t[2] < 0 && t[2] > -3 && std::fabs(t[1]) < 0.05f, "geometry home: the drawn floor in front is landable", t[1], t[2]);
l.testInput(0, 1, 0, 0); run(l, 80 * 3);
float e[3]; eyeOf(l, e);
ck(e[2] > -3 && e[2] < -2.6f, "geometry home: walking into the drawn wall stops you at it", e[2]);
}
{ // a Wall-role part blocks at any slope: a 42 degree face (|n.y| 0.74) used to be neither floor nor wall
HomeWorld w = flat;
const float top = 2 * std::tan(42 * 3.14159f / 180);
w.colliders.push_back(quadC(WorldCollider::Wall, {-5, 0, -1, 5, 0, -1, 5, top, -3, -5, top, -3}));
Locomotion l; l.setWorld(w); l.setOptions(1, 1, 0, 30, 2);
l.testInput(0, 1, 0, 0); run(l, 80 * 3);
float e[3]; eyeOf(l, e);
ck(e[2] > -1.6f, "walking into a 42 degree Wall-role face stops you", e[2]);
float m[12], t[3];
const float hand[3] = {0.2f, 1.2f, 0};
Locomotion l2; l2.setWorld(w); l2.setOptions(1, 1, 0, 30, 2);
handPose(hand, 0, 0.1f, -1, m);
l2.testInput(0, 0, 0, 1, m); run(l2, 2);
ck(!l2.testTarget(t) || t[2] > -1.0f, "and a teleport arc can't land on or behind it", t[2]);
}
{ // Meta's chairs carry a big ball collider: hitting it counts as pointing at the chair
HomeWorld w = flat;
WorldPoint s1; s1.name = "Chair"; s1.pos[0] = 0; s1.pos[1] = 0.45f; s1.pos[2] = -4; w.seats.push_back(s1);
WorldCollider b; b.kind = WorldCollider::Sphere; b.role = WorldCollider::Solid; b.radius = 0.6f;
b.world[0] = b.world[5] = b.world[10] = 2.45f; b.world[13] = 0.45f; b.world[14] = -4; // 1.47 m across in world
w.colliders.push_back(b);
Locomotion l; l.setWorld(w);
const float o[3] = {0.2f, 1.2f, 0}, d[3] = {-0.2f / 4.05f, -0.65f / 4.05f, -4 / 4.05f};
float t;
ck(l.seatOnRay(o, d, 10, t) == 0 && t < 3.0f, "a ray stopped by the chair's own (big) collider points at the chair", t);
}
std::printf("%d failed\n", fails);
return fails;
}
+91
View File
@@ -0,0 +1,91 @@
cmake_minimum_required(VERSION 3.2...3.25)
project(acl CXX)
set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} "${PROJECT_SOURCE_DIR}/cmake")
include(CMakeUtils)
include(CMakeCompiler)
include(CMakePlatforms)
set(USE_AVX_INSTRUCTIONS false CACHE BOOL "Use AVX instructions")
set(USE_POPCNT_INSTRUCTIONS false CACHE BOOL "Use POPCOUNT instructions")
set(USE_SIMD_INSTRUCTIONS true CACHE BOOL "Use SIMD instructions")
set(USE_SJSON true CACHE BOOL "Use SJSON")
set(CPU_INSTRUCTION_SET false CACHE STRING "CPU instruction set")
set(BUILD_BENCHMARK_EXE false CACHE BOOL "Enable the benchmark projects")
set(ENABLE_ALL_WARNINGS false CACHE BOOL "Enable all warnings")
set(IS_CROSS_COMPILING false CACHE BOOL "Whether or not we are cross-compiling")
set(TEST_DATA_DIR "" CACHE STRING "The full path of the regression test data directory")
set(DECOMP_DATA_DIR "" CACHE STRING "The full path of the decompression data directory")
if(CMAKE_CONFIGURATION_TYPES)
set(CMAKE_CONFIGURATION_TYPES Debug Release)
set(CMAKE_CONFIGURATION_TYPES "${CMAKE_CONFIGURATION_TYPES}" CACHE STRING "Reset the configurations to what we need" FORCE)
endif()
# Determine if we should include unit tests
set(INCLUDE_UNIT_TESTS true)
if(PLATFORM_IOS)
# Catch2 2.13 doesn't compile on iOS with Xcode 8 and 9
if(PLATFORM_XCODE_VERSION MATCHES "^8\\.")
set(INCLUDE_UNIT_TESTS false)
elseif(PLATFORM_XCODE_VERSION MATCHES "^9\\.")
set(INCLUDE_UNIT_TESTS false)
endif()
endif()
# Grab all of our include files
file(GLOB_RECURSE ACL_INCLUDE_FILES LIST_DIRECTORIES false
${PROJECT_SOURCE_DIR}/includes/*.h
${PROJECT_SOURCE_DIR}/docs/*.md
${PROJECT_SOURCE_DIR}/cmake/*.cmake
${PROJECT_SOURCE_DIR}/test_data/configs/*.sjson
${PROJECT_SOURCE_DIR}/tools/release_scripts/*.py
)
create_source_groups("${ACL_INCLUDE_FILES}" ${PROJECT_SOURCE_DIR})
file(GLOB ACL_ROOT_FILES LIST_DIRECTORIES false
${PROJECT_SOURCE_DIR}/*.md
${PROJECT_SOURCE_DIR}/*.py
${PROJECT_SOURCE_DIR}/.gitignore)
# Add every natvis file
file(GLOB_RECURSE NATVIS_FILES LIST_DIRECTORIES false
${PROJECT_SOURCE_DIR}/*.natvis)
if(MSVC)
# With MSVC, the custom target creates a Utility project. Even though it isn't a proper C++ project
# that compiles into something, MSVC will still parse the code and report intellisense information.
# A utility project doesn't have an editable field in its properties to add an include directory
# but adding it anyway like this is enough to make sure the headers are found by intellisense.
include_directories("${PROJECT_SOURCE_DIR}/includes")
endif()
# Create a dummy target so they show up in the IDE
add_custom_target(${PROJECT_NAME} SOURCES ${ACL_INCLUDE_FILES} ${ACL_ROOT_FILES} ${NATVIS_FILES})
# Enable CTest
enable_testing()
# Add other projects
add_subdirectory("${PROJECT_SOURCE_DIR}/tools/acl_compressor")
if(BUILD_BENCHMARK_EXE)
add_subdirectory("${PROJECT_SOURCE_DIR}/tools/acl_decompressor")
endif()
if(INCLUDE_UNIT_TESTS)
add_subdirectory("${PROJECT_SOURCE_DIR}/tests")
endif()
# Custom regression testing (requires SJSON support)
if(USE_SJSON)
if(PLATFORM_ANDROID)
add_subdirectory("${PROJECT_SOURCE_DIR}/tools/regression_tester_android")
elseif(PLATFORM_IOS)
add_subdirectory("${PROJECT_SOURCE_DIR}/tools/regression_tester_ios")
endif()
endif()
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017 Nicholas Frechette & Animation Compression Library contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+134
View File
@@ -0,0 +1,134 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2019 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/compressed_database.h"
#include "acl/core/compressed_tracks.h"
#include "acl/core/error_result.h"
#include "acl/core/iallocator.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/compression/compression_settings.h"
#include "acl/compression/output_stats.h"
#include "acl/compression/track_array.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
//////////////////////////////////////////////////////////////////////////
// Compresses a track array with uniform sampling.
//
// This compression algorithm is the simplest by far and as such it offers
// the fastest compression and decompression. Every sample is retained and
// every track has the same number of samples playing back at the same
// sample rate. This means that when we sample at a particular time within
// the clip, we can trivially calculate the offsets required to read the
// desired data. All the data is sorted in order to ensure all reads are
// as contiguous as possible for optimal cache locality during decompression.
//
// allocator: The allocator instance to use to allocate and free memory.
// track_list: The track list to compress.
// settings: The compression settings to use.
// out_compressed_tracks: The resulting compressed tracks. The caller owns the returned memory and must free it.
// out_stats: Stat output structure.
//////////////////////////////////////////////////////////////////////////
error_result compress_track_list(iallocator& allocator, const track_array& track_list, const compression_settings& settings,
compressed_tracks*& out_compressed_tracks, output_stats& out_stats);
//////////////////////////////////////////////////////////////////////////
// Compresses a transform track array using its additive base with uniform sampling.
//
// This compression algorithm is the simplest by far and as such it offers
// the fastest compression and decompression. Every sample is retained and
// every track has the same number of samples playing back at the same
// sample rate. This means that when we sample at a particular time within
// the clip, we can trivially calculate the offsets required to read the
// desired data. All the data is sorted in order to ensure all reads are
// as contiguous as possible for optimal cache locality during decompression.
//
// allocator: The allocator instance to use to allocate and free memory.
// track_list: The track list to compress.
// settings: The compression settings to use.
// out_compressed_tracks: The resulting compressed tracks. The caller owns the returned memory and must free it.
// out_stats: Stat output structure.
//////////////////////////////////////////////////////////////////////////
error_result compress_track_list(iallocator& allocator, const track_array_qvvf& track_list, const compression_settings& settings,
const track_array_qvvf& additive_base_track_list, additive_clip_format8 additive_format,
compressed_tracks*& out_compressed_tracks, output_stats& out_stats);
//////////////////////////////////////////////////////////////////////////
// Takes a list of compressed track instances that contain the contributing error metadata and uses their data to build
// a new database instance. Each compressed track instance will be duplicated and split between a new instance and the
// database.
//
// allocator: The allocator instance to use
// settings: The settings to use when creating the database
// compressed_tracks_list: The list of compressed tracks to build the database with (must have contributing error metadata)
// num_compressed_tracks: The number of compressed track instances in the above list
// out_compressed_tracks: The output list of compressed tracks bound to the output database (array allocated by the caller (must be large enough); compressed_tracks instances allocated by the function)
// out_database: The output database (allocated by the function)
//////////////////////////////////////////////////////////////////////////
error_result build_database(iallocator& allocator, const compression_database_settings& settings,
const compressed_tracks* const* compressed_tracks_list, uint32_t num_compressed_tracks,
compressed_tracks** out_compressed_tracks, compressed_database*& out_database);
//////////////////////////////////////////////////////////////////////////
// Takes a compressed database with inline bulk data and duplicates it into
// a new database instance where the bulk data lives in separate buffers.
//
// allocator: The allocator instance to use to allocate the new database and its bulk data.
// database: The source database to split with inline bulk data.
// out_split_database: The new database without inline bulk data.
// out_bulk_data_medium: The new database's bulk data for the medium importance tier.
// out_bulk_data_low: The new database's bulk data for the low importance tier.
//////////////////////////////////////////////////////////////////////////
error_result split_database_bulk_data(iallocator& allocator, const compressed_database& database, compressed_database*& out_split_database, uint8_t*& out_bulk_data_medium, uint8_t*& out_bulk_data_low);
//////////////////////////////////////////////////////////////////////////
// Takes a compressed database and strips the specified quality tier from it.
// The database is duplicated including its remaining bulk data (if inline).
// Only the medium/lowest tiers can be stripped.
//
// allocator: The allocator instance to use to allocate the new database.
// database: The source database to strip.
// tier: The quality tier to strip.
// out_stripped_database: The new database without the specified quality tier.
//////////////////////////////////////////////////////////////////////////
error_result strip_database_quality_tier(iallocator& allocator, const compressed_database& database, quality_tier tier, compressed_database*& out_stripped_database);
ACL_IMPL_VERSION_NAMESPACE_END
}
#include "acl/compression/impl/compress.database.impl.h"
#include "acl/compression/impl/compress.scalar.impl.h"
#include "acl/compression/impl/compress.transform.impl.h"
#include "acl/compression/impl/compress.impl.h"
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,76 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2019 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/error.h"
#include "acl/core/impl/compiler_utils.h"
#include <cstdint>
#include <cstring>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
////////////////////////////////////////////////////////////////////////////////
// compression_level8 represents how aggressively we attempt to reduce the memory
// footprint. Higher levels will try more permutations and bit rates. The higher
// the level, the slower the compression but the smaller the memory footprint.
enum class compression_level8 : uint8_t
{
lowest = 0, // Same as medium for now
low = 1, // Same as medium for now
medium = 2,
high = 3,
highest = 4,
// Automatic attempts to pick the best compression level based on various
// properties of the input clip: clip length, longest transform chain, etc.
// This offers a nice balance between size and speed for the majority of clips.
automatic = 100,
//lossless = 255, // Not implemented, reserved
};
//////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////
// Returns a string representing the compression level.
// TODO: constexpr
inline const char* get_compression_level_name(compression_level8 level);
//////////////////////////////////////////////////////////////////////////
// Returns the compression level from its string representation.
inline bool get_compression_level(const char* level_name, compression_level8& out_level);
ACL_IMPL_VERSION_NAMESPACE_END
}
#include "acl/compression/impl/compression_level.impl.h"
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,276 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2017 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/core/error_result.h"
#include "acl/core/hash.h"
#include "acl/core/track_formats.h"
#include "acl/core/track_types.h"
#include "acl/core/range_reduction_types.h"
#include "acl/compression/compression_level.h"
#include "acl/compression/transform_error_metrics.h"
#include <rtm/scalarf.h>
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
//////////////////////////////////////////////////////////////////////////
// Encapsulates all the compression settings related to database usage.
struct compression_database_settings
{
//////////////////////////////////////////////////////////////////////////
// What proportions we should use when distributing our frames based on
// their importance to the overall error contribution. If a sample doesn't
// go into the medium or low importance tiers, it will end up in the high
// importance tier stored within each compressed track instance.
// Proportion values must be between 0.0 and 1.0 and their sum as well.
// If the sum is less than 1.0, remaining frames are considered to have high
// importance. A low importance proportion of 30% means that the least important
// 30% of frames will end up in that corresponding database tier.
// Note that only movable frames can end up in the database as some frames must remain
// within the compressed track instance. A frame is movable if it isn't the first or last
// frame of its segment.
// Defaults to '0.0' (the medium importance tier is empty)
float medium_importance_tier_proportion = 0.0F;
//////////////////////////////////////////////////////////////////////////
// See above for details.
// Defaults to '0.5' (the least important 50% of frames are moved to the database)
float low_importance_tier_proportion = 0.5F;
//////////////////////////////////////////////////////////////////////////
// How large should each chunk be, in bytes.
// This value must be at least 4 KB and ideally it should be a multiple of
// the virtual memory page size used on the platform that will decompress
// from the database.
// Defaults to '1 MB'
uint32_t max_chunk_size = 1 * 1024 * 1024;
//////////////////////////////////////////////////////////////////////////
// Calculates a hash from the internal state to uniquely identify a configuration.
uint32_t get_hash() const;
//////////////////////////////////////////////////////////////////////////
// Checks if everything is valid and if it isn't, returns an error string.
// Returns nullptr if the settings are valid.
error_result is_valid() const;
};
//////////////////////////////////////////////////////////////////////////
// Encapsulates all the optional metadata compression settings.
struct compression_metadata_settings
{
//////////////////////////////////////////////////////////////////////////
// Whether to include the optional metadata for the track list name
// Defaults to 'false'
bool include_track_list_name = false;
//////////////////////////////////////////////////////////////////////////
// Whether to include the optional metadata for track names
// Defaults to 'false'
bool include_track_names = false;
//////////////////////////////////////////////////////////////////////////
// Whether to include the optional metadata for parent track indices
// Transform tracks only
// Defaults to 'false'
bool include_parent_track_indices = false;
//////////////////////////////////////////////////////////////////////////
// Whether to include the optional metadata for track descriptions
// For transforms, also enables the parent track indices metadata
// Defaults to 'false'
bool include_track_descriptions = false;
//////////////////////////////////////////////////////////////////////////
// Whether to include the optional metadata for the contributing error
// of each frame. These are sorted from lowest to largest error.
// This is required when the compressed tracks will later be merged into
// a database.
// Transform tracks only
// Defaults to 'false'
bool include_contributing_error = false;
//////////////////////////////////////////////////////////////////////////
// Calculates a hash from the internal state to uniquely identify a configuration.
uint32_t get_hash() const;
//////////////////////////////////////////////////////////////////////////
// Checks if everything is valid and if it isn't, returns an error string.
// Returns nullptr if the settings are valid.
error_result is_valid() const;
};
//////////////////////////////////////////////////////////////////////////
// Encapsulates all frame stripping compression settings.
// A keyframe represents all samples at a particular point in time.
// Keyframe stripping is a destructive process and visual fidelity can degrade
// considerably. If a keyframe is identified as a stripping candidate, it is
// entirely removed and reconstructed through linear interpolation of its
// remaining neighbors. This is destructive unless the keyframe can be reconstructed
// below the desired precision threshold, in which case it is a trivial keyframe.
//
// Removing whole keyframes ensures that decompression remains very fast.
// However, enabling keyframe stripping does add a fixed amount of overhead
// during decompression. This overhead should be below 200 instructions and
// be on the order of nanoseconds depending on the processor and its clock speed.
//
// Note that stripping is not always appropriate and it may yield unacceptable
// results. Here are known problematic scenarios:
// - high velocity animations can lose a lot of momentum, yielding a swimming-
// like motion
// - synchronized animations can become out of sync if keyframes are unevenly
// removed leading to missed contacts
// - contacts and motion apex can be missed if key keyframes are removed
//
// Transform tracks only.
struct compression_keyframe_stripping_settings
{
//////////////////////////////////////////////////////////////////////////
// Returns whether or not keyframe stripping is enabled.
// Keyframe stripping is enabled when either 'proportion' or 'threshold'
// or both are enabled.
// See [compression_keyframe_stripping_settings] for details.
// Defaults to 'false'
bool is_enabled() const;
//////////////////////////////////////////////////////////////////////////
// The minimum proportion of keyframes that should be stripped.
// Proportion value must be between 0.0 and 1.0.
// Defaults to '0.0' (no stripping)
float proportion = 0.0F;
//////////////////////////////////////////////////////////////////////////
// The threshold error below which to strip keyframes.
// Keyframes that yield an error below or equal to this threshold will
// be removed. Keyframes that contribute more error than the threshold
// are retained.
// Defaults to '0.0' centimeters (no stripping)
float threshold = 0.0F;
//////////////////////////////////////////////////////////////////////////
// Whether or not to strip trivial keyframes.
// If the error introduced by removing a keyframe is below the precision
// threshold for every transform, it is trivial. It means that if we reconstruct
// the stripped keyframe, the error introduced is below our precision threshold.
//
// Note that if you intend to use per-track rounding during decompression, you
// should keep this feature disabled. See sample_rounding_policy::per_track for
// details.
//
// Defaults to 'false' (no stripping)
bool strip_trivial = false;
//////////////////////////////////////////////////////////////////////////
// Calculates a hash from the internal state to uniquely identify a configuration.
uint32_t get_hash() const;
//////////////////////////////////////////////////////////////////////////
// Checks if everything is valid and if it isn't, returns an error string.
// Returns nullptr if the settings are valid.
error_result is_valid() const;
};
//////////////////////////////////////////////////////////////////////////
// Encapsulates all the compression settings.
struct compression_settings
{
//////////////////////////////////////////////////////////////////////////
// The compression level determines how aggressively we attempt to reduce the memory
// footprint. Higher levels will try more permutations and bit rates. The higher
// the level, the slower the compression but the smaller the memory footprint.
// Transform tracks only.
compression_level8 level = compression_level8::low;
//////////////////////////////////////////////////////////////////////////
// The rotation, translation, and scale formats to use. See functions get_rotation_format(..) and get_vector_format(..)
// Defaults to raw: 'quatf_full' and 'vector3f_full'
// Transform tracks only.
rotation_format8 rotation_format = rotation_format8::quatf_full;
vector_format8 translation_format = vector_format8::vector3f_full;
vector_format8 scale_format = vector_format8::vector3f_full;
//////////////////////////////////////////////////////////////////////////
// The error metric to use.
// Defaults to 'null', this value must be set manually!
// Transform tracks only.
itransform_error_metric* error_metric = nullptr;
//////////////////////////////////////////////////////////////////////////
// Whether or not to enable database support on the output compressed clip.
// This enables the required metadata which will later be stripped once
// the database is built.
// Transform tracks only.
bool enable_database_support = false;
//////////////////////////////////////////////////////////////////////////
// Detects if the last sample matches the first for every sub-track.
// If it is the case, it is removed and the wrap looping policy is used
// otherwise we use the clamp policy and every sample is retained.
// See `sample_looping_policy` for details.
bool optimize_loops = false;
//////////////////////////////////////////////////////////////////////////
// Keyframe stripping related settings. See [compression_keyframe_stripping_settings].
// Transform tracks only.
compression_keyframe_stripping_settings keyframe_stripping;
//////////////////////////////////////////////////////////////////////////
// These are optional metadata that can be added to compressed clips.
compression_metadata_settings metadata;
//////////////////////////////////////////////////////////////////////////
// Calculates a hash from the internal state to uniquely identify a configuration.
uint32_t get_hash() const;
//////////////////////////////////////////////////////////////////////////
// Checks if everything is valid and if it isn't, returns an error string.
error_result is_valid() const;
};
//////////////////////////////////////////////////////////////////////////
// Returns raw compression settings. No compression is performed and
// samples are all retained with full precision.
compression_settings get_raw_compression_settings();
//////////////////////////////////////////////////////////////////////////
// Returns the recommended and default compression settings. These have
// been tested in a wide range of scenarios and perform best overall.
compression_settings get_default_compression_settings();
ACL_IMPL_VERSION_NAMESPACE_END
}
#include "acl/compression/impl/compression_settings.impl.h"
ACL_IMPL_FILE_PRAGMA_POP
+59
View File
@@ -0,0 +1,59 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2020 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/compression/track_array.h"
#include "acl/core/compressed_tracks.h"
#include "acl/core/error_result.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/core/iallocator.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
//////////////////////////////////////////////////////////////////////////
// Convert a track array instance into a raw compressed tracks instance.
// This is a lossless process.
//////////////////////////////////////////////////////////////////////////
error_result convert_track_list(iallocator& allocator, const track_array& track_list, compressed_tracks*& out_compressed_tracks);
//////////////////////////////////////////////////////////////////////////
// Convert a compressed tracks instance into a track array instance.
// This is a lossless process if all the metadata is present.
//////////////////////////////////////////////////////////////////////////
error_result convert_track_list(iallocator& allocator, const compressed_tracks& tracks, track_array& out_track_list);
ACL_IMPL_VERSION_NAMESPACE_END
}
#include "acl/compression/impl/convert.impl.h"
ACL_IMPL_FILE_PRAGMA_POP
+89
View File
@@ -0,0 +1,89 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2022 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
// track_type8 needs to be fully defined to be able to specialize some templates
#include "acl/core/track_types.h"
#include <cstdint>
////////////////////////////////////////////////////////////////////////////////
// This header provides forward declarations for all public ACL types.
// Forward declaring symbols from a 3rd party library is a bad idea, use this
// header instead.
// See also: https://blog.libtorrent.org/2017/12/forward-declarations-and-abi/
////////////////////////////////////////////////////////////////////////////////
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
enum class compression_level8 : uint8_t;
struct compression_database_settings;
struct compression_metadata_settings;
struct compression_settings;
enum class pre_process_actions;
enum class pre_process_precision_policy;
struct pre_process_settings_t;
enum class stat_logging;
struct output_stats;
struct track_error;
class track_array;
template<track_type8 track_type_> class track_array_typed;
using track_array_float1f = track_array_typed<track_type8::float1f>;
using track_array_float2f = track_array_typed<track_type8::float2f>;
using track_array_float3f = track_array_typed<track_type8::float3f>;
using track_array_float4f = track_array_typed<track_type8::float4f>;
using track_array_vector4f = track_array_typed<track_type8::vector4f>;
using track_array_qvvf = track_array_typed<track_type8::qvvf>;
class track;
template<track_type8 track_type_> class track_typed;
using track_float1f = track_typed<track_type8::float1f>;
using track_float2f = track_typed<track_type8::float2f>;
using track_float3f = track_typed<track_type8::float3f>;
using track_float4f = track_typed<track_type8::float4f>;
using track_vector4f = track_typed<track_type8::vector4f>;
using track_qvvf = track_typed<track_type8::qvvf>;
enum class additive_clip_format8 : uint8_t;
class itransform_error_metric;
class qvvf_transform_error_metric;
class qvvf_precise_transform_error_metric;
class qvvf_matrix3x4f_transform_error_metric;
template<additive_clip_format8 additive_format> class additive_qvvf_transform_error_metric;
ACL_IMPL_VERSION_NAMESPACE_END
}
@@ -0,0 +1,168 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2020 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/error.h"
#include "acl/core/iallocator.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/compression/impl/clip_context.h"
#include "acl/compression/impl/segment_context.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
// We use template arguments to support lambdas and avoid using std::function since they might allocate memory
// without using our allocator.
// group_filter_action_type = std::function<bool(animation_track_type8 group_type, uint32_t bone_index)>
template<typename group_filter_action_type>
inline void get_num_sub_tracks(const segment_context& segment,
group_filter_action_type& group_filter_action,
uint32_t& out_num_rotation_sub_tracks, uint32_t& out_num_translation_sub_tracks, uint32_t& out_num_scale_sub_tracks)
{
uint32_t num_rotation_sub_tracks = 0;
uint32_t num_translation_sub_tracks = 0;
uint32_t num_scale_sub_tracks = 0;
for (uint32_t bone_index = 0; bone_index < segment.num_bones; ++bone_index)
{
const transform_streams& bone_stream = segment.bone_streams[bone_index];
if (bone_stream.output_index == k_invalid_track_index)
continue; // Stripped
if (group_filter_action(animation_track_type8::rotation, bone_index))
num_rotation_sub_tracks++;
if (group_filter_action(animation_track_type8::translation, bone_index))
num_translation_sub_tracks++;
if (group_filter_action(animation_track_type8::scale, bone_index))
num_scale_sub_tracks++;
}
out_num_rotation_sub_tracks = num_rotation_sub_tracks;
out_num_translation_sub_tracks = num_translation_sub_tracks;
out_num_scale_sub_tracks = num_scale_sub_tracks;
}
inline void get_num_animated_sub_tracks(const segment_context& segment,
uint32_t& out_num_animated_rotation_sub_tracks, uint32_t& out_num_animated_translation_sub_tracks, uint32_t& out_num_animated_scale_sub_tracks)
{
const auto animated_group_filter_action = [&segment](animation_track_type8 group_type, uint32_t bone_index)
{
const transform_streams& bone_stream = segment.bone_streams[bone_index];
if (group_type == animation_track_type8::rotation)
return !bone_stream.is_rotation_constant;
else if (group_type == animation_track_type8::translation)
return !bone_stream.is_translation_constant;
else
return !bone_stream.is_scale_constant;
};
get_num_sub_tracks(segment, animated_group_filter_action, out_num_animated_rotation_sub_tracks, out_num_animated_translation_sub_tracks, out_num_animated_scale_sub_tracks);
}
// We use template arguments to support lambdas and avoid using std::function since they might allocate memory
// without using our allocator.
// group_filter_action_type = std::function<bool(animation_track_type8 group_type, uint32_t bone_index)>
// group_entry_action_type = std::function<void(animation_track_type8 group_type, uint32_t group_size, uint32_t bone_index)>
// group_flush_action_type = std::function<void(animation_track_type8 group_type, uint32_t group_size)>
template<typename group_filter_action_type, typename group_entry_action_type, typename group_flush_action_type>
inline void group_writer(const segment_context& segment, const uint32_t* output_bone_mapping, uint32_t num_output_bones,
group_filter_action_type& group_filter_action, group_entry_action_type& group_entry_action, group_flush_action_type& group_flush_action)
{
// Data is ordered in groups of 4 animated sub-tracks (e.g rot0, rot1, rot2, rot3)
// Groups are sorted per sub-track type. All rotation groups come first followed by translations then scales.
// The last group of each sub-track may or may not have padding. The last group might be less than 4 sub-tracks.
const auto group_writer_impl = [output_bone_mapping, num_output_bones, &group_filter_action, &group_entry_action, &group_flush_action](animation_track_type8 group_type)
{
uint32_t group_size = 0;
for (uint32_t output_index = 0; output_index < num_output_bones; ++output_index)
{
const uint32_t bone_index = output_bone_mapping[output_index];
if (group_filter_action(group_type, bone_index))
{
group_entry_action(group_type, group_size, bone_index);
group_size++;
}
if (group_size == 4)
{
// Group full, write it out and move onto to the next group
group_flush_action(group_type, group_size);
group_size = 0;
}
}
// If group has leftover tracks, write it out
if (group_size != 0)
group_flush_action(group_type, group_size);
};
// Output sub-tracks in natural order: rot, trans, scale
group_writer_impl(animation_track_type8::rotation);
group_writer_impl(animation_track_type8::translation);
if (segment.clip->has_scale)
group_writer_impl(animation_track_type8::scale);
}
// We use template arguments to support lambdas and avoid using std::function since they might allocate memory
// without using our allocator.
// group_filter_action_type = std::function<bool(animation_track_type8 group_type, uint32_t bone_index)>
// group_entry_action_type = std::function<void(animation_track_type8 group_type, uint32_t group_size, uint32_t bone_index)>
// group_flush_action_type = std::function<void(animation_track_type8 group_type, uint32_t group_size)>
template<typename group_filter_action_type, typename group_entry_action_type, typename group_flush_action_type>
inline void animated_group_writer(const segment_context& segment, const uint32_t* output_bone_mapping, uint32_t num_output_bones,
group_filter_action_type& group_filter_action, group_entry_action_type& group_entry_action, group_flush_action_type& group_flush_action)
{
const auto animated_group_filter_action = [&segment, &group_filter_action](animation_track_type8 group_type, uint32_t bone_index)
{
const transform_streams& bone_stream = segment.bone_streams[bone_index];
if (group_type == animation_track_type8::rotation)
return !bone_stream.is_rotation_constant && group_filter_action(group_type, bone_index);
else if (group_type == animation_track_type8::translation)
return !bone_stream.is_translation_constant && group_filter_action(group_type, bone_index);
else
return !bone_stream.is_scale_constant && group_filter_action(group_type, bone_index);
};
group_writer(segment, output_bone_mapping, num_output_bones, animated_group_filter_action, group_entry_action, group_flush_action);
}
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,259 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2017 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/additive_utils.h"
#include "acl/core/bitset.h"
#include "acl/core/iallocator.h"
#include "acl/core/iterator.h"
#include "acl/core/error.h"
#include "acl/core/sample_looping_policy.h"
#include "acl/core/track_formats.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/compression/compression_settings.h"
#include "acl/compression/track_array.h"
#include "acl/compression/impl/segment_context.h"
#include "acl/compression/impl/topology_metadata.h"
#include <rtm/quatf.h>
#include <rtm/vector4f.h>
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
//////////////////////////////////////////////////////////////////////////
// Simple iterator utility class to allow easy looping
class bone_chain_iterator
{
public:
bone_chain_iterator(const uint32_t* chain, bitset_description chain_desc, uint32_t bone_index, uint32_t offset)
: m_bone_chain(chain)
, m_bone_chain_desc(chain_desc)
, m_bone_index(bone_index)
, m_offset(offset)
{}
bone_chain_iterator& operator++()
{
ACL_ASSERT(m_offset <= m_bone_index, "Cannot increment the iterator, it is no longer valid");
// Skip the current bone
m_offset++;
// Iterate until we find the next bone part of the chain or until we reach the end of the chain
// TODO: Use clz or similar to find the next set bit starting at the current index
while (m_offset < m_bone_index && !bitset_test(m_bone_chain, m_bone_chain_desc, m_offset))
m_offset++;
return *this;
}
uint32_t operator*() const
{
ACL_ASSERT(m_offset <= m_bone_index, "Returned bone index doesn't belong to the bone chain");
ACL_ASSERT(bitset_test(m_bone_chain, m_bone_chain_desc, m_offset), "Returned bone index doesn't belong to the bone chain");
return m_offset;
}
// We only compare the offset in the bone chain. Two iterators on the same bone index
// from two different or equal chains will be equal.
bool operator==(const bone_chain_iterator& other) const { return m_offset == other.m_offset; }
bool operator!=(const bone_chain_iterator& other) const { return m_offset != other.m_offset; }
private:
const uint32_t* m_bone_chain;
bitset_description m_bone_chain_desc;
uint32_t m_bone_index;
uint32_t m_offset;
};
//////////////////////////////////////////////////////////////////////////
// Simple bone chain container to allow easy looping
//
// A bone chain allows looping over all bones up to a specific bone starting
// at the root bone.
//////////////////////////////////////////////////////////////////////////
struct bone_chain
{
bone_chain(const uint32_t* chain, bitset_description chain_desc, uint32_t bone_index)
: m_bone_chain(chain)
, m_bone_chain_desc(chain_desc)
, m_bone_index(bone_index)
{
// We don't know where this bone chain starts, find the root bone
// TODO: Use clz or similar to find the next set bit starting at the current index
uint32_t root_index = 0;
while (!bitset_test(chain, chain_desc, root_index))
root_index++;
m_root_index = root_index;
}
acl_impl::bone_chain_iterator begin() const { return acl_impl::bone_chain_iterator(m_bone_chain, m_bone_chain_desc, m_bone_index, m_root_index); }
acl_impl::bone_chain_iterator end() const { return acl_impl::bone_chain_iterator(m_bone_chain, m_bone_chain_desc, m_bone_index, m_bone_index + 1); }
const uint32_t* m_bone_chain;
bitset_description m_bone_chain_desc;
uint32_t m_root_index;
uint32_t m_bone_index;
};
// Metadata per transform
struct transform_metadata
{
// The transform chain this transform belongs to (points into leaf_transform_chains in owner context)
const uint32_t* transform_chain = nullptr;
// Parent transform index of this transform, invalid if at the root
uint32_t parent_index = k_invalid_track_index;
// The precision value from the track description for this transform
float precision = 0.0F;
// The local space shell distance from the track description for this transform
float shell_distance = 0.0F;
};
// Rigid shell information per transform
struct rigid_shell_metadata_t
{
// Dominant local space shell distance (from transform tip)
float local_shell_distance; // TODO: rename this, remove 'local' from name, it is implied
// Precision required on the surface of the rigid shell
float precision;
// The index of the dominant transform (current transform index for leaf transforms)
uint32_t dominant_transform_index;
};
// Represents the working space for a clip (raw or lossy)
struct clip_context
{
// List of segments contained (num_segments present)
// Raw contexts only have a single segment
segment_context* segments = nullptr;
// List of clip wide range information for each transform (num_bones present)
transform_range* ranges = nullptr;
// List of metadata for each transform (num_bones present)
// TODO: Same for raw/lossy/additive clip context, can we share?
transform_metadata* metadata = nullptr;
// List of bit sets for each leaf transform to track transform chains (num_leaf_transforms present)
// TODO: Same for raw/lossy/additive clip context, can we share?
uint32_t* leaf_transform_chains = nullptr;
// List of transform indices sorted by parent first then sibling transforms are sorted by their transform index (num_bones present)
// TODO: Same for raw/lossy/additive clip context, can we share?
uint32_t* sorted_transforms_parent_first = nullptr;
// List of shell metadata for each transform (num_bones present)
// Data is aggregate of whole clip
// Shared between all clip contexts, not owned
const rigid_shell_metadata_t* clip_shell_metadata = nullptr;
// Topology metadata
// Data is aggregate of whole clip
// Shared between all clip contexts, not owned
const clip_topology_t* topology = nullptr;
// Optional if we request it in the compression settings
// Sorted by stripping order within this clip
keyframe_stripping_metadata_t* contributing_error = nullptr;
uint32_t num_segments = 0;
uint32_t num_bones = 0; // TODO: Rename num_transforms
uint32_t num_samples_allocated = 0;
uint32_t num_samples = 0;
float sample_rate = 0.0F;
float duration = 0.0F;
sample_looping_policy looping_policy = sample_looping_policy::non_looping;
additive_clip_format8 additive_format = additive_clip_format8::none;
bool are_rotations_normalized = false;
bool are_translations_normalized = false;
bool are_scales_normalized = false;
bool has_scale = false;
bool has_additive_base = false;
bool has_stripped_keyframes = false;
uint32_t num_leaf_transforms = 0;
iallocator* allocator = nullptr; // Never null if the context is initialized
// Stat tracking
uint32_t decomp_touched_bytes = 0;
uint32_t decomp_touched_cache_lines = 0;
//////////////////////////////////////////////////////////////////////////
bool is_initialized() const { return allocator != nullptr; }
iterator<segment_context> segment_iterator() { return iterator<segment_context>(segments, num_segments); }
const_iterator<segment_context> segment_iterator() const { return const_iterator<segment_context>(segments, num_segments); }
bone_chain get_bone_chain(uint32_t bone_index) const
{
ACL_ASSERT(bone_index < num_bones, "Invalid bone index: %u >= %u", bone_index, num_bones);
const transform_metadata& meta = metadata[bone_index];
return bone_chain(meta.transform_chain, bitset_description::make_from_num_bits(num_bones), bone_index);
}
};
template<class clip_adapter_t>
void sort_transform_indices_parent_first(
const clip_adapter_t& clip,
uint32_t* sorted_transforms_parent_first,
uint32_t num_transforms);
bool initialize_clip_context(
iallocator& allocator,
const track_array_qvvf& track_list,
const compression_settings& settings,
additive_clip_format8 additive_format,
clip_context& out_clip_context);
void destroy_clip_context(clip_context& context);
constexpr bool segment_context_has_scale(const segment_context& segment) { return segment.clip->has_scale; }
constexpr bool bone_streams_has_scale(const transform_streams& bone_streams) { return segment_context_has_scale(*bone_streams.segment); }
}
ACL_IMPL_VERSION_NAMESPACE_END
}
#include "acl/compression/impl/clip_context.impl.h"
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,312 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2023 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/additive_utils.h"
#include "acl/core/bitset.h"
#include "acl/core/iallocator.h"
#include "acl/core/iterator.h"
#include "acl/core/error.h"
#include "acl/core/sample_looping_policy.h"
#include "acl/core/track_formats.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/compression/compression_settings.h"
#include "acl/compression/track_array.h"
#include "acl/compression/impl/segment_context.h"
#include "acl/compression/impl/transform_clip_adapters.h"
#include "acl/math/quatf.h"
#include <rtm/quatf.h>
#include <rtm/vector4f.h>
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
template<class clip_adapter_t>
inline void sort_transform_indices_parent_first(
const clip_adapter_t& clip,
uint32_t* sorted_transforms_parent_first,
uint32_t num_transforms)
{
static_assert(std::is_base_of<transform_clip_adapter_t, clip_adapter_t>::value, "Clip adapter must derive from transform_clip_adapter_t");
// We sort our transform indices by parent first
// If two transforms have the same parent index, we sort them by their transform index
const auto sort_predicate = [&clip](const uint32_t lhs_transform_index, const uint32_t rhs_transform_index)
{
const uint32_t lhs_parent_index = clip.get_transform_parent_index(lhs_transform_index);
const uint32_t rhs_parent_index = clip.get_transform_parent_index(rhs_transform_index);
// If the transforms don't have the same parent, sort by the parent index
// We add 1 to parent indices to cause the invalid index to wrap around to 0
// since parents come first, they'll have the lowest value
if (lhs_parent_index != rhs_parent_index)
return (lhs_parent_index + 1) < (rhs_parent_index + 1);
// Both transforms have the same parent, sort by their index
return lhs_transform_index < rhs_transform_index;
};
std::sort(sorted_transforms_parent_first, sorted_transforms_parent_first + num_transforms, sort_predicate);
}
inline bool initialize_clip_context(iallocator& allocator, const track_array_qvvf& track_list, const compression_settings& settings, additive_clip_format8 additive_format, clip_context& out_clip_context)
{
const uint32_t num_transforms = track_list.get_num_tracks();
const uint32_t num_samples = track_list.get_num_samples_per_track();
const float sample_rate = track_list.get_sample_rate();
const sample_looping_policy looping_policy = track_list.get_looping_policy();
// Create a single segment with the whole clip
out_clip_context.segments = allocate_type_array<segment_context>(allocator, 1);
out_clip_context.ranges = nullptr;
out_clip_context.metadata = allocate_type_array<transform_metadata>(allocator, num_transforms);
out_clip_context.leaf_transform_chains = nullptr;
out_clip_context.sorted_transforms_parent_first = allocate_type_array<uint32_t>(allocator, num_transforms);
out_clip_context.clip_shell_metadata = nullptr;
out_clip_context.topology = nullptr;
out_clip_context.contributing_error = nullptr;
out_clip_context.num_segments = 1;
out_clip_context.num_bones = num_transforms;
out_clip_context.num_samples_allocated = num_samples;
out_clip_context.num_samples = num_samples;
out_clip_context.sample_rate = sample_rate;
out_clip_context.duration = track_list.get_finite_duration();
out_clip_context.looping_policy = looping_policy;
out_clip_context.additive_format = additive_format;
out_clip_context.are_rotations_normalized = false;
out_clip_context.are_translations_normalized = false;
out_clip_context.are_scales_normalized = false;
out_clip_context.has_additive_base = additive_format != additive_clip_format8::none;
out_clip_context.num_leaf_transforms = 0;
out_clip_context.allocator = &allocator;
bool are_samples_valid = true;
segment_context& segment = out_clip_context.segments[0];
transform_streams* bone_streams = allocate_type_array<transform_streams>(allocator, num_transforms);
for (uint32_t transform_index = 0; transform_index < num_transforms; ++transform_index)
{
const track_qvvf& track = track_list[transform_index];
const track_desc_transformf& desc = track.get_description();
transform_streams& bone_stream = bone_streams[transform_index];
bone_stream.segment = &segment;
bone_stream.bone_index = transform_index;
bone_stream.parent_bone_index = desc.parent_index;
bone_stream.output_index = desc.output_index;
bone_stream.default_value = desc.default_value;
bone_stream.rotations = rotation_track_stream(allocator, num_samples, sizeof(rtm::quatf), sample_rate, rotation_format8::quatf_full);
bone_stream.translations = translation_track_stream(allocator, num_samples, sizeof(rtm::vector4f), sample_rate, vector_format8::vector3f_full);
bone_stream.scales = scale_track_stream(allocator, num_samples, sizeof(rtm::vector4f), sample_rate, vector_format8::vector3f_full);
// Constant and default detection is handled during sub-track compacting
bone_stream.is_rotation_constant = false;
bone_stream.is_rotation_default = false;
bone_stream.is_translation_constant = false;
bone_stream.is_translation_default = false;
bone_stream.is_scale_constant = false;
bone_stream.is_scale_default = false;
for (uint32_t sample_index = 0; sample_index < num_samples; ++sample_index)
{
const rtm::qvvf& transform = track[sample_index];
// If we request raw data and we are already normalized, retain the original value
// otherwise we normalize for safety
rtm::quatf rotation;
if (settings.rotation_format != rotation_format8::quatf_full || !rtm::quat_is_normalized(transform.rotation))
rotation = quat_normalize_stable(transform.rotation);
else
rotation = transform.rotation;
are_samples_valid &= rtm::quat_is_finite(rotation);
are_samples_valid &= rtm::vector_is_finite3(transform.translation);
are_samples_valid &= rtm::vector_is_finite3(transform.scale);
bone_stream.rotations.set_raw_sample(sample_index, rotation);
bone_stream.translations.set_raw_sample(sample_index, transform.translation);
bone_stream.scales.set_raw_sample(sample_index, transform.scale);
}
transform_metadata& metadata = out_clip_context.metadata[transform_index];
metadata.transform_chain = nullptr;
metadata.parent_index = desc.parent_index;
metadata.precision = desc.precision;
metadata.shell_distance = desc.shell_distance;
out_clip_context.sorted_transforms_parent_first[transform_index] = transform_index;
}
out_clip_context.has_scale = true; // Scale detection is handled during sub-track compacting
out_clip_context.decomp_touched_bytes = 0;
out_clip_context.decomp_touched_cache_lines = 0;
segment.bone_streams = bone_streams;
segment.clip = &out_clip_context;
segment.ranges = nullptr;
segment.contributing_error = nullptr;
segment.num_samples_allocated = num_samples;
segment.num_samples = num_samples;
segment.num_bones = num_transforms;
segment.clip_sample_offset = 0;
segment.segment_index = 0;
segment.are_rotations_normalized = false;
segment.are_translations_normalized = false;
segment.are_scales_normalized = false;
segment.animated_rotation_bit_size = 0;
segment.animated_translation_bit_size = 0;
segment.animated_scale_bit_size = 0;
segment.animated_pose_bit_size = 0;
segment.animated_data_size = 0;
segment.range_data_size = 0;
segment.total_header_size = 0;
// Initialize our hierarchy information
if (num_transforms != 0)
{
// Calculate which bones are leaf bones that have no children
bitset_description bone_bitset_desc = bitset_description::make_from_num_bits(num_transforms);
const size_t bitset_size = bone_bitset_desc.get_size();
uint32_t* is_leaf_bitset = allocate_type_array<uint32_t>(allocator, bitset_size);
bitset_reset(is_leaf_bitset, bone_bitset_desc, false);
// By default everything is marked as a leaf
// We'll then iterate on every transform and mark their parent as non-leaf
bitset_set_range(is_leaf_bitset, bone_bitset_desc, 0, num_transforms, true);
#if defined(ACL_HAS_ASSERT_CHECKS)
uint32_t num_root_bones = 0;
#endif
// Move and validate the input data
for (uint32_t transform_index = 0; transform_index < num_transforms; ++transform_index)
{
const transform_metadata& metadata = out_clip_context.metadata[transform_index];
const bool is_root = metadata.parent_index == k_invalid_track_index;
// If we have a parent, mark it as not being a leaf bone (it has at least one child)
if (!is_root)
bitset_set(is_leaf_bitset, bone_bitset_desc, metadata.parent_index, false);
#if defined(ACL_HAS_ASSERT_CHECKS)
if (is_root)
num_root_bones++;
#endif
}
const uint32_t num_leaf_transforms = bitset_count_set_bits(is_leaf_bitset, bone_bitset_desc);
out_clip_context.num_leaf_transforms = num_leaf_transforms;
// Build our transform chains
// Each leaf transform is part of a unique chain
// When a leaf is found, we assign it a new chain. We then iterate through the parent
// transforms, adding them to the chain
// Each non-leaf transform visited is assigned the first chain that contains it
// This allows easy traversal between one transform and its parents
uint32_t* leaf_transform_chains = allocate_type_array<uint32_t>(allocator, num_leaf_transforms * bitset_size);
out_clip_context.leaf_transform_chains = leaf_transform_chains;
uint32_t leaf_index = 0;
for (uint32_t transform_index = 0; transform_index < num_transforms; ++transform_index)
{
if (!bitset_test(is_leaf_bitset, bone_bitset_desc, transform_index))
continue; // Skip non-leaf bones
uint32_t* bone_chain = leaf_transform_chains + (leaf_index * bitset_size);
bitset_reset(bone_chain, bone_bitset_desc, false);
uint32_t chain_bone_index = transform_index;
while (chain_bone_index != k_invalid_track_index)
{
bitset_set(bone_chain, bone_bitset_desc, chain_bone_index, true);
transform_metadata& metadata = out_clip_context.metadata[chain_bone_index];
// We assign a bone chain the first time we find a bone that isn't part of one already
if (metadata.transform_chain == nullptr)
metadata.transform_chain = bone_chain;
chain_bone_index = metadata.parent_index;
}
leaf_index++;
}
ACL_ASSERT(num_root_bones > 0, "No root bone found. The root bones must have a parent index = 0xFFFF");
ACL_ASSERT(leaf_index == num_leaf_transforms, "Invalid number of leaf bone found");
deallocate_type_array(allocator, is_leaf_bitset, bitset_size);
// We sort our transform indices by parent first
// If two transforms have the same parent index, we sort them by their transform index
sort_transform_indices_parent_first(
transform_clip_context_adapter_t(out_clip_context),
out_clip_context.sorted_transforms_parent_first,
num_transforms);
}
return are_samples_valid;
}
inline void destroy_clip_context(clip_context& context)
{
if (context.allocator == nullptr)
return; // Not initialized
iallocator& allocator = *context.allocator;
for (segment_context& segment : context.segment_iterator())
destroy_segment_context(allocator, segment);
deallocate_type_array(allocator, context.segments, context.num_segments);
deallocate_type_array(allocator, context.ranges, context.num_bones);
deallocate_type_array(allocator, context.metadata, context.num_bones);
bitset_description bone_bitset_desc = bitset_description::make_from_num_bits(context.num_bones);
deallocate_type_array(allocator, context.leaf_transform_chains, size_t(context.num_leaf_transforms) * bone_bitset_desc.get_size());
deallocate_type_array(allocator, context.sorted_transforms_parent_first, context.num_bones);
deallocate_type_array(allocator, context.contributing_error, context.num_samples);
}
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,86 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2019 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/bitset.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/core/iallocator.h"
#include "acl/core/track_desc.h"
#include "acl/compression/impl/track_list_context.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
inline bool is_scalarf_track_constant(const track& track_, const track_range& range)
{
const track_desc_scalarf& desc = track_.get_description<track_desc_scalarf>();
return range.is_constant(desc.precision);
}
inline void extract_constant_tracks(track_list_context& context)
{
ACL_ASSERT(context.is_valid(), "Invalid context");
const bitset_description bitset_desc = bitset_description::make_from_num_bits(context.num_tracks);
context.constant_tracks_bitset = allocate_type_array<uint32_t>(*context.allocator, bitset_desc.get_size());
bitset_reset(context.constant_tracks_bitset, bitset_desc, false);
for (uint32_t track_index = 0; track_index < context.num_tracks; ++track_index)
{
const track& mut_track = context.track_list[track_index];
const track_range& range = context.range_list[track_index];
bool is_constant = false;
switch (range.category)
{
case track_category8::scalarf:
is_constant = is_scalarf_track_constant(mut_track, range);
break;
case track_category8::scalard:
case track_category8::transformf:
case track_category8::transformd:
default:
ACL_ASSERT(false, "Invalid track category");
break;
}
bitset_set(context.constant_tracks_bitset, bitset_desc, track_index, is_constant);
}
}
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,93 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2020 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
// Included only once from compress.h
#include "acl/version.h"
#include "acl/core/buffer_tag.h"
#include "acl/core/compressed_tracks.h"
#include "acl/core/error.h"
#include "acl/core/error_result.h"
#include "acl/core/floating_point_control.h"
#include "acl/core/iallocator.h"
#include "acl/compression/compression_settings.h"
#include "acl/compression/output_stats.h"
#include "acl/compression/track_array.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
inline error_result compress_track_list(iallocator& allocator, const track_array& track_list, const compression_settings& settings, compressed_tracks*& out_compressed_tracks, output_stats& out_stats)
{
using namespace acl_impl;
error_result result = track_list.is_valid();
if (result.any())
return result;
// Disable floating point exceptions during compression because we leverage all SIMD lanes
// and we might intentionally divide by zero, etc.
scope_disable_fp_exceptions fp_off;
if (track_list.get_track_category() == track_category8::transformf)
result = compress_transform_track_list(allocator, track_array_cast<track_array_qvvf>(track_list), settings, nullptr, additive_clip_format8::none, out_compressed_tracks, out_stats);
else
result = compress_scalar_track_list(allocator, track_list, settings, out_compressed_tracks, out_stats);
return result;
}
inline error_result compress_track_list(iallocator& allocator, const track_array_qvvf& track_list, const compression_settings& settings, const track_array_qvvf& additive_base_track_list, additive_clip_format8 additive_format, compressed_tracks*& out_compressed_tracks, output_stats& out_stats)
{
using namespace acl_impl;
error_result result = track_list.is_valid();
if (result.any())
return result;
if (additive_format != additive_clip_format8::none)
{
result = additive_base_track_list.is_valid();
if (result.any())
return result;
}
// Disable floating point exceptions during compression because we leverage all SIMD lanes
// and we might intentionally divide by zero, etc.
scope_disable_fp_exceptions fp_off;
return compress_transform_track_list(allocator, track_list, settings, &additive_base_track_list, additive_format, out_compressed_tracks, out_stats);
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,275 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2020 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
// Included only once from compress.h
#include "acl/version.h"
#include "acl/core/buffer_tag.h"
#include "acl/core/compressed_tracks.h"
#include "acl/core/compressed_tracks_version.h"
#include "acl/core/error.h"
#include "acl/core/error_result.h"
#include "acl/core/iallocator.h"
#include "acl/core/scope_profiler.h"
#include "acl/core/impl/bit_cast.impl.h"
#include "acl/compression/compression_settings.h"
#include "acl/compression/output_stats.h"
#include "acl/compression/track_array.h"
#include "acl/compression/impl/track_list_context.h"
#include "acl/compression/impl/compact.scalar.h"
#include "acl/compression/impl/normalize.scalar.h"
#include "acl/compression/impl/optimize_looping.scalar.h"
#include "acl/compression/impl/quantize.scalar.h"
#include "acl/compression/impl/track_range_impl.h"
#include "acl/compression/impl/write_compression_stats_impl.h"
#include "acl/compression/impl/write_track_data_impl.h"
#include "acl/compression/impl/write_track_metadata.h"
#if defined(ACL_USE_SJSON)
#include <sjson/writer.h>
#endif
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
inline error_result compress_scalar_track_list(iallocator& allocator, const track_array& track_list, const compression_settings& settings, compressed_tracks*& out_compressed_tracks, output_stats& out_stats)
{
(void)out_stats;
#if defined(ACL_USE_SJSON)
scope_profiler compression_time;
#endif
track_list_context context;
if (!initialize_context(allocator, track_list, context))
return error_result("Some samples are not finite");
// Wrap instead of clamp if we loop
optimize_looping(context, settings);
// Extract our ranges now, we need it for compacting the constant tracks
extract_track_ranges(context);
// Compact and collapse the constant tracks
extract_constant_tracks(context);
// Normalize our samples into the track wide ranges per track
normalize_tracks(context);
// Find how many bits we need per track and quantize everything
quantize_tracks(context);
// Done transforming our input tracks, time to pack them into their final form
const uint32_t per_track_metadata_size = write_track_metadata(context, nullptr);
const uint32_t constant_values_size = write_track_constant_values(context, nullptr);
const uint32_t range_values_size = write_track_range_values(context, nullptr);
const uint32_t animated_num_bits = write_track_animated_values(context, nullptr);
const uint32_t animated_values_size = (animated_num_bits + 7) / 8; // Round up to nearest byte
const uint32_t num_bits_per_frame = context.num_samples != 0 ? (animated_num_bits / context.num_samples) : 0;
uint32_t buffer_size = 0;
buffer_size += sizeof(raw_buffer_header); // Header
buffer_size += sizeof(tracks_header); // Header
buffer_size += sizeof(scalar_tracks_header); // Header
ACL_ASSERT(is_aligned_to(buffer_size, alignof(track_metadata)), "Invalid alignment");
buffer_size += per_track_metadata_size; // Per track metadata
buffer_size = align_to(buffer_size, 4); // Align constant values
buffer_size += constant_values_size; // Constant values
ACL_ASSERT(is_aligned_to(buffer_size, 4), "Invalid alignment");
buffer_size += range_values_size; // Range values
ACL_ASSERT(is_aligned_to(buffer_size, 4), "Invalid alignment");
buffer_size += animated_values_size; // Animated values
// Optional metadata
const uint32_t metadata_start_offset = align_to(buffer_size, 4);
const uint32_t metadata_track_list_name_size = settings.metadata.include_track_list_name ? write_track_list_name(track_list, nullptr) : 0;
const uint32_t metadata_track_names_size = settings.metadata.include_track_names ? write_track_names(track_list, context.track_output_indices, context.num_output_tracks, nullptr) : 0;
const uint32_t metadata_track_descriptions_size = settings.metadata.include_track_descriptions ? write_track_descriptions(track_list, context.track_output_indices, context.num_output_tracks, nullptr) : 0;
uint32_t metadata_size = 0;
metadata_size += metadata_track_list_name_size;
metadata_size = align_to(metadata_size, 4);
metadata_size += metadata_track_names_size;
metadata_size = align_to(metadata_size, 4);
metadata_size += metadata_track_descriptions_size;
if (metadata_size != 0)
{
buffer_size = align_to(buffer_size, 4);
buffer_size += metadata_size;
buffer_size = align_to(buffer_size, 4);
buffer_size += sizeof(optional_metadata_header);
}
else
buffer_size += 15; // Ensure we have sufficient padding for unaligned 16 byte loads
uint8_t* buffer = allocate_type_array_aligned<uint8_t>(allocator, buffer_size, alignof(compressed_tracks));
std::memset(buffer, 0, buffer_size);
uint8_t* buffer_start = buffer;
out_compressed_tracks = bit_cast<compressed_tracks*>(buffer);
raw_buffer_header* buffer_header = safe_ptr_cast<raw_buffer_header>(buffer);
buffer += sizeof(raw_buffer_header);
tracks_header* header = safe_ptr_cast<tracks_header>(buffer);
buffer += sizeof(tracks_header);
// Write our primary header
header->tag = static_cast<uint32_t>(buffer_tag32::compressed_tracks);
header->version = compressed_tracks_version16::latest;
header->algorithm_type = algorithm_type8::uniformly_sampled;
header->track_type = track_list.get_track_type();
header->num_tracks = context.num_output_tracks;
header->num_samples = context.num_output_tracks != 0 ? context.num_samples : 0;
header->sample_rate = context.num_output_tracks != 0 ? context.sample_rate : 0.0F;
header->set_is_wrap_optimized(context.looping_policy == sample_looping_policy::wrap);
header->set_has_metadata(metadata_size != 0);
// Write our scalar tracks header
scalar_tracks_header* scalars_header = safe_ptr_cast<scalar_tracks_header>(buffer);
buffer += sizeof(scalar_tracks_header);
scalars_header->num_bits_per_frame = num_bits_per_frame;
const uint8_t* packed_data_start_offset = buffer - sizeof(scalar_tracks_header); // Relative to our header
scalars_header->metadata_per_track = uint32_t(buffer - packed_data_start_offset);
buffer += per_track_metadata_size;
buffer = align_to(buffer, 4);
scalars_header->track_constant_values = uint32_t(buffer - packed_data_start_offset);
buffer += constant_values_size;
scalars_header->track_range_values = uint32_t(buffer - packed_data_start_offset);
buffer += range_values_size;
scalars_header->track_animated_values = uint32_t(buffer - packed_data_start_offset);
buffer += animated_values_size;
if (metadata_size != 0)
{
buffer = align_to(buffer, 4);
buffer += metadata_size;
buffer = align_to(buffer, 4);
buffer += sizeof(optional_metadata_header);
}
else
buffer += 15;
(void)buffer_start; // Avoid VS2017 bug, it falsely reports this variable as unused even when asserts are enabled
ACL_ASSERT((buffer_start + buffer_size) == buffer, "Buffer size and pointer mismatch");
// Write our compressed data
track_metadata* per_track_metadata = scalars_header->get_track_metadata();
write_track_metadata(context, per_track_metadata);
float* constant_values = scalars_header->get_track_constant_values();
write_track_constant_values(context, constant_values);
float* range_values = scalars_header->get_track_range_values();
write_track_range_values(context, range_values);
uint8_t* animated_values = scalars_header->get_track_animated_values();
write_track_animated_values(context, animated_values);
// Optional metadata header is last
uint32_t writter_metadata_track_list_name_size = 0;
uint32_t written_metadata_track_names_size = 0;
uint32_t written_metadata_track_descriptions_size = 0;
if (metadata_size != 0)
{
optional_metadata_header* metadada_header = bit_cast<optional_metadata_header*>(buffer_start + buffer_size - sizeof(optional_metadata_header));
uint32_t metadata_offset = metadata_start_offset;
if (settings.metadata.include_track_list_name)
{
metadada_header->track_list_name = metadata_offset;
writter_metadata_track_list_name_size = write_track_list_name(track_list, metadada_header->get_track_list_name(*out_compressed_tracks));
metadata_offset += writter_metadata_track_list_name_size;
}
else
metadada_header->track_list_name = invalid_ptr_offset();
if (settings.metadata.include_track_names)
{
metadata_offset = align_to(metadata_offset, 4);
metadada_header->track_name_offsets = metadata_offset;
written_metadata_track_names_size = write_track_names(track_list, context.track_output_indices, context.num_output_tracks, metadada_header->get_track_name_offsets(*out_compressed_tracks));
metadata_offset += written_metadata_track_names_size;
}
else
metadada_header->track_name_offsets = invalid_ptr_offset();
metadada_header->parent_track_indices = invalid_ptr_offset(); // Not supported for scalar tracks
if (settings.metadata.include_track_descriptions)
{
metadata_offset = align_to(metadata_offset, 4);
metadada_header->track_descriptions = metadata_offset;
written_metadata_track_descriptions_size = write_track_descriptions(track_list, context.track_output_indices, context.num_output_tracks, metadada_header->get_track_descriptions(*out_compressed_tracks));
metadata_offset += written_metadata_track_descriptions_size;
}
else
metadada_header->track_descriptions = invalid_ptr_offset();
}
ACL_ASSERT(writter_metadata_track_list_name_size == metadata_track_list_name_size, "Wrote too little or too much data");
ACL_ASSERT(written_metadata_track_names_size == metadata_track_names_size, "Wrote too little or too much data");
ACL_ASSERT(written_metadata_track_descriptions_size == metadata_track_descriptions_size, "Wrote too little or too much data");
// Finish the raw buffer header
buffer_header->size = buffer_size;
buffer_header->hash = hash32(safe_ptr_cast<const uint8_t>(header), buffer_size - sizeof(raw_buffer_header)); // Hash everything but the raw buffer header
#if defined(ACL_HAS_ASSERT_CHECKS)
if (metadata_size == 0)
{
for (const uint8_t* padding = buffer - 15; padding < buffer; ++padding)
ACL_ASSERT(*padding == 0, "Padding was overwritten");
}
#endif
#if defined(ACL_USE_SJSON)
compression_time.stop();
if (out_stats.logging != stat_logging::none)
write_compression_stats(context, *out_compressed_tracks, compression_time, out_stats);
#endif
return error_result();
}
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,632 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2021 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
// Included only once from compress.h
#include "acl/version.h"
#include "acl/core/buffer_tag.h"
#include "acl/core/compressed_tracks.h"
#include "acl/core/compressed_tracks_version.h"
#include "acl/core/error.h"
#include "acl/core/error_result.h"
#include "acl/core/floating_point_control.h"
#include "acl/core/iallocator.h"
#include "acl/core/scope_profiler.h"
#include "acl/core/impl/bit_cast.impl.h"
#include "acl/compression/compression_settings.h"
#include "acl/compression/output_stats.h"
#include "acl/compression/track_array.h"
#include "acl/compression/impl/clip_context.h"
#include "acl/compression/impl/compression_stats.h"
#include "acl/compression/impl/track_stream.h"
#include "acl/compression/impl/convert_rotation.transform.h"
#include "acl/compression/impl/compact.transform.h"
#include "acl/compression/impl/keyframe_stripping.h"
#include "acl/compression/impl/normalize.transform.h"
#include "acl/compression/impl/optimize_looping.transform.h"
#include "acl/compression/impl/quantize.transform.h"
#include "acl/compression/impl/segment.transform.h"
#include "acl/compression/impl/topology_metadata.h"
#include "acl/compression/impl/write_segment_data.h"
#include "acl/compression/impl/write_stats.h"
#include "acl/compression/impl/write_stream_data.h"
#include "acl/compression/impl/write_sub_track_types.h"
#include "acl/compression/impl/write_track_metadata.h"
#include <rtm/quatf.h>
#include <rtm/vector4f.h>
#if defined(ACL_USE_SJSON)
#include <sjson/writer.h>
#endif
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
inline bool has_trivial_default_values(const track_array_qvvf& track_list, additive_clip_format8 additive_format, const clip_context& lossy_clip_context)
{
// Additive clips and regular clips can have zero/one scale and be trivial
bool all_scales_zero = true;
bool all_scales_one = true;
const rtm::vector4f zero = rtm::vector_zero();
const rtm::vector4f one = rtm::vector_set(1.0F);
for (const track_qvvf& track : track_list)
{
const track_desc_transformf& desc = track.get_description();
if (!rtm::quat_near_identity(desc.default_value.rotation, 0.0F))
return false; // Not the identity
if (!rtm::vector_all_near_equal3(desc.default_value.translation, zero, 0.0F))
return false; // Not zero
if (!rtm::vector_all_near_equal3(desc.default_value.scale, zero, 0.0F))
all_scales_zero = false; // Not zero
if (!rtm::vector_all_near_equal3(desc.default_value.scale, one, 0.0F))
all_scales_one = false; // Not one
}
if (lossy_clip_context.has_scale)
{
if (additive_format == additive_clip_format8::additive1 && !all_scales_zero)
return false; // Not zero
else if (!all_scales_one)
return false; // Not one
}
// Every default value was trivial
return true;
}
// These values are hard-coded
// They were selected based on empirical data
inline compression_level8 find_best_compression_level(const clip_context& lossy_clip_context)
{
// Very long clips stay need to be fast
if (lossy_clip_context.num_samples > 500)
return compression_level8::medium;
uint32_t longest_chain_length = 0;
const bitset_description transform_bitset_desc = bitset_description::make_from_num_bits(lossy_clip_context.num_bones);
for (uint32_t leaf_index = 0; leaf_index < lossy_clip_context.num_leaf_transforms; ++leaf_index)
{
const uint32_t* transform_chain = lossy_clip_context.leaf_transform_chains + (leaf_index * transform_bitset_desc.get_size());
const uint32_t num_chain_transforms = bitset_count_set_bits(transform_chain, transform_bitset_desc);
longest_chain_length = std::max<uint32_t>(longest_chain_length, num_chain_transforms);
}
// Clips with shorter transform chains are fast and can try harder
if (longest_chain_length < 18)
return compression_level8::highest;
else if (longest_chain_length < 25)
return compression_level8::high;
else
return compression_level8::medium;
}
inline error_result compress_transform_track_list(iallocator& allocator, const track_array_qvvf& track_list, compression_settings settings,
const track_array_qvvf* additive_base_track_list, additive_clip_format8 additive_format,
compressed_tracks*& out_compressed_tracks, output_stats& out_stats)
{
error_result result = settings.is_valid();
if (result.any())
return result;
compression_stats_t compression_stats;
(void)compression_stats;
#if defined(ACL_USE_SJSON)
scope_profiler compression_time;
scope_profiler initialization_time;
#endif
// Segmenting settings are an implementation detail
compression_segmenting_settings segmenting_settings;
// If we enable database support or keyframe stripping, include the metadata we need
bool remove_contributing_error = false;
if (settings.enable_database_support)
settings.metadata.include_contributing_error = true;
else if (settings.keyframe_stripping.is_enabled())
{
// If we only enable the contributing error for keyframe stripping, make sure to strip it afterwards
remove_contributing_error = !settings.metadata.include_contributing_error;
settings.metadata.include_contributing_error = true;
}
// If every track is retains full precision, we disable segmenting since it provides no benefit
if (!is_rotation_format_variable(settings.rotation_format) && !is_vector_format_variable(settings.translation_format) && !is_vector_format_variable(settings.scale_format))
{
if (settings.metadata.include_contributing_error)
return error_result("Raw tracks have no contributing error");
segmenting_settings.ideal_num_samples = 0xFFFFFFFF;
segmenting_settings.max_num_samples = 0xFFFFFFFF;
}
if (settings.metadata.include_contributing_error && segmenting_settings.max_num_samples > 32)
return error_result("Cannot have more than 32 samples per segment when calculating the contributing error per frame");
// If we want the optional track descriptions, make sure to include the parent track indices
if (settings.metadata.include_track_descriptions)
settings.metadata.include_parent_track_indices = true;
ACL_ASSERT(settings.is_valid().empty(), "Invalid compression settings");
ACL_ASSERT(segmenting_settings.is_valid().empty(), "Invalid segmenting settings");
// Variable bit rate tracks need range reduction
// Full precision tracks do not need range reduction since samples are stored raw
range_reduction_flags8 range_reduction = range_reduction_flags8::none;
if (is_rotation_format_variable(settings.rotation_format))
range_reduction |= range_reduction_flags8::rotations;
if (is_vector_format_variable(settings.translation_format))
range_reduction |= range_reduction_flags8::translations;
if (is_vector_format_variable(settings.scale_format))
range_reduction |= range_reduction_flags8::scales;
// If we have no additive base, our additive format is always none
if (additive_base_track_list == nullptr || additive_base_track_list->is_empty())
additive_format = additive_clip_format8::none;
clip_context raw_clip_context;
if (!initialize_clip_context(allocator, track_list, settings, additive_format, raw_clip_context))
return error_result("Some samples are not finite");
clip_context lossy_clip_context;
initialize_clip_context(allocator, track_list, settings, additive_format, lossy_clip_context);
const bool is_additive = additive_format != additive_clip_format8::none;
clip_context additive_base_clip_context;
if (is_additive && !initialize_clip_context(allocator, *additive_base_track_list, settings, additive_format, additive_base_clip_context))
return error_result("Some base samples are not finite");
if (settings.level == compression_level8::automatic)
settings.level = find_best_compression_level(lossy_clip_context);
// Clip topology metadata, not specific to clip context
clip_topology_t clip_topology;
build_clip_topology(allocator, track_list, clip_topology);
raw_clip_context.topology = &clip_topology;
lossy_clip_context.topology = &clip_topology;
if (is_additive)
additive_base_clip_context.topology = &clip_topology;
// Topology dependent data, not specific to clip context
const uint32_t num_input_transforms = raw_clip_context.num_bones;
rigid_shell_metadata_t* clip_shell_metadata = compute_clip_shell_distances(
allocator,
transform_clip_context_adapter_t(raw_clip_context),
transform_clip_context_adapter_t(additive_base_clip_context));
#if defined(ACL_IMPL_DEBUG_ENABLE_DOMINANT_DESCENDANTS)
compute_dominance_map(
allocator,
transform_clip_context_adapter_t(raw_clip_context),
transform_clip_context_adapter_t(additive_base_clip_context),
*settings.error_metric,
clip_topology);
#endif
raw_clip_context.clip_shell_metadata = clip_shell_metadata;
lossy_clip_context.clip_shell_metadata = clip_shell_metadata;
if (is_additive)
additive_base_clip_context.clip_shell_metadata = clip_shell_metadata;
#if defined(ACL_USE_SJSON)
compression_stats.initialization_elapsed_seconds = initialization_time.get_elapsed_seconds();
#endif
// Wrap instead of clamp if we loop
optimize_looping(lossy_clip_context, additive_base_clip_context, settings, compression_stats);
// Convert our rotations if we need to
convert_rotation_streams(allocator, lossy_clip_context, settings.rotation_format, compression_stats);
// Extract our clip ranges now, we need it for compacting the constant streams
extract_clip_bone_ranges(allocator, lossy_clip_context, compression_stats);
// Compact and collapse the constant streams
compact_constant_streams(allocator, lossy_clip_context, additive_base_clip_context, track_list, settings, compression_stats);
uint32_t clip_range_data_size = 0;
if (range_reduction != range_reduction_flags8::none)
{
// Normalize our samples into the clip wide ranges per bone
normalize_clip_streams(lossy_clip_context, range_reduction, compression_stats);
clip_range_data_size = get_clip_range_data_size(lossy_clip_context, range_reduction, settings.rotation_format);
}
segment_streams(allocator, lossy_clip_context, segmenting_settings, compression_stats);
// If we have a single segment, skip segment range reduction since it won't help
if (range_reduction != range_reduction_flags8::none && lossy_clip_context.num_segments > 1)
{
// Extract and fixup our segment wide ranges per bone
extract_segment_bone_ranges(allocator, lossy_clip_context, compression_stats);
// Normalize our samples into the segment wide ranges per bone
normalize_segment_streams(lossy_clip_context, range_reduction, compression_stats);
}
// Find how many bits we need per sub-track and quantize everything
quantize_streams(allocator, lossy_clip_context, settings, segmenting_settings, raw_clip_context, additive_base_clip_context, out_stats, compression_stats);
uint32_t num_output_bones = 0;
uint32_t* output_bone_mapping = create_output_track_mapping(allocator, track_list, num_output_bones);
// Calculate the pose size, we need it to estimate savings when stripping keyframes
calculate_animated_data_size(lossy_clip_context, output_bone_mapping, num_output_bones);
// Remove whole keyframes as needed
strip_keyframes(lossy_clip_context, settings, compression_stats);
// Compression is done! Time to pack things.
#if defined(ACL_USE_SJSON)
scope_profiler output_packing_time;
#endif
if (remove_contributing_error)
settings.metadata.include_contributing_error = false;
const bool has_trivial_defaults = has_trivial_default_values(track_list, additive_format, lossy_clip_context);
const uint32_t constant_data_size = get_constant_data_size(lossy_clip_context);
uint32_t num_animated_variable_sub_tracks_padded = 0;
const uint32_t format_per_track_data_size = get_format_per_track_data_size(lossy_clip_context, settings.rotation_format, settings.translation_format, settings.scale_format, &num_animated_variable_sub_tracks_padded);
const uint32_t num_sub_tracks_per_bone = lossy_clip_context.has_scale ? 3U : 2U;
// Calculate how many sub-track packed entries we have
// Each sub-track is 2 bits packed within a 32 bit entry
// For each sub-track type, we round up to simplify bookkeeping
// For example, if we have 3 tracks made up of rotation/translation we'll have one entry for each with unused padding
// All rotation types come first, followed by all translation types, and with scale types at the end when present
const uint32_t num_sub_track_entries = ((num_output_bones + k_num_sub_tracks_per_packed_entry - 1) / k_num_sub_tracks_per_packed_entry) * num_sub_tracks_per_bone;
const uint32_t packed_sub_track_buffer_size = num_sub_track_entries * sizeof(packed_sub_track_types);
// Adding an extra index at the end to delimit things, the index is always invalid: 0xFFFFFFFF
const uint32_t segment_start_indices_size = lossy_clip_context.num_segments > 1 ? (uint32_t(sizeof(uint32_t)) * (lossy_clip_context.num_segments + 1)) : 0;
const uint32_t segment_header_type_size = lossy_clip_context.has_stripped_keyframes ? sizeof(stripped_segment_header_t) : sizeof(segment_header);
const uint32_t segment_headers_size = segment_header_type_size * lossy_clip_context.num_segments;
uint32_t buffer_size = 0;
// Per clip data
buffer_size += sizeof(raw_buffer_header); // Header
buffer_size += sizeof(tracks_header); // Header
buffer_size += sizeof(transform_tracks_header); // Header
const uint32_t clip_header_size = buffer_size;
buffer_size = align_to(buffer_size, 4); // Align segment start indices
buffer_size += segment_start_indices_size; // Segment start indices
buffer_size = align_to(buffer_size, 4); // Align segment headers
buffer_size += segment_headers_size; // Segment headers
buffer_size = align_to(buffer_size, 4); // Align sub-track types
const uint32_t clip_segment_header_size = buffer_size - clip_header_size;
buffer_size += packed_sub_track_buffer_size; // Packed sub-track types sorted by type
buffer_size = align_to(buffer_size, 4); // Align constant track data
buffer_size += constant_data_size; // Constant track data
buffer_size = align_to(buffer_size, 4); // Align range data
buffer_size += clip_range_data_size; // Range data
const uint32_t clip_data_size = buffer_size - clip_segment_header_size - clip_header_size;
if (are_all_enum_flags_set(out_stats.logging, stat_logging::detailed))
{
constexpr uint32_t k_cache_line_byte_size = 64;
lossy_clip_context.decomp_touched_bytes = clip_header_size + clip_data_size;
lossy_clip_context.decomp_touched_bytes += sizeof(uint32_t) * 4; // We touch at most 4 segment start indices
lossy_clip_context.decomp_touched_bytes += segment_header_type_size * 2; // We touch at most 2 segment headers
lossy_clip_context.decomp_touched_cache_lines = align_to(clip_header_size, k_cache_line_byte_size) / k_cache_line_byte_size;
lossy_clip_context.decomp_touched_cache_lines += align_to(clip_data_size, k_cache_line_byte_size) / k_cache_line_byte_size;
lossy_clip_context.decomp_touched_cache_lines += 1; // All 4 segment start indices should fit in a cache line
lossy_clip_context.decomp_touched_cache_lines += 1; // Both segment headers should fit in a cache line
}
// Per segment data
for (segment_context& segment : lossy_clip_context.segment_iterator())
{
const uint32_t header_start = buffer_size;
buffer_size += format_per_track_data_size; // Format per track data
// TODO: Alignment only necessary with 16bit per component (segment constant tracks), need to fix scalar decoding path
buffer_size = align_to(buffer_size, 2); // Align range data
buffer_size += segment.range_data_size; // Range data
const uint32_t header_end = buffer_size;
// TODO: Variable bit rate doesn't need alignment
buffer_size = align_to(buffer_size, 4); // Align animated data
buffer_size += segment.animated_data_size; // Animated track data
segment.segment_data_size = buffer_size - header_start;
segment.total_header_size = header_end - header_start;
}
const uint32_t segment_data_size = buffer_size - clip_data_size - clip_segment_header_size - clip_header_size;
// Optional metadata
const uint32_t metadata_start_offset = align_to(buffer_size, 4);
const uint32_t metadata_track_list_name_size = settings.metadata.include_track_list_name ? write_track_list_name(track_list, nullptr) : 0;
const uint32_t metadata_track_names_size = settings.metadata.include_track_names ? write_track_names(track_list, output_bone_mapping, num_output_bones, nullptr) : 0;
const uint32_t metadata_parent_track_indices_size = settings.metadata.include_parent_track_indices ? write_parent_track_indices(track_list, output_bone_mapping, num_output_bones, nullptr) : 0;
const uint32_t metadata_track_descriptions_size = settings.metadata.include_track_descriptions ? write_track_descriptions(track_list, output_bone_mapping, num_output_bones, nullptr) : 0;
const uint32_t metadata_contributing_error_size = settings.metadata.include_contributing_error ? write_contributing_error(lossy_clip_context, nullptr) : 0;
uint32_t metadata_size = 0;
metadata_size += metadata_track_list_name_size;
metadata_size = align_to(metadata_size, 4);
metadata_size += metadata_track_names_size;
metadata_size = align_to(metadata_size, 4);
metadata_size += metadata_parent_track_indices_size;
metadata_size = align_to(metadata_size, 4);
metadata_size += metadata_track_descriptions_size;
metadata_size = align_to(metadata_size, 4);
metadata_size += metadata_contributing_error_size;
if (metadata_size != 0)
{
buffer_size = align_to(buffer_size, 4);
buffer_size += metadata_size;
buffer_size = align_to(buffer_size, 4);
buffer_size += sizeof(optional_metadata_header);
}
else
buffer_size += 15; // Ensure we have sufficient padding for unaligned 16 byte loads
uint8_t* buffer = allocate_type_array_aligned<uint8_t>(allocator, buffer_size, alignof(compressed_tracks));
std::memset(buffer, 0, buffer_size);
uint8_t* buffer_start = buffer;
out_compressed_tracks = bit_cast<compressed_tracks*>(buffer);
raw_buffer_header* buffer_header = safe_ptr_cast<raw_buffer_header>(buffer);
buffer += sizeof(raw_buffer_header);
tracks_header* header = safe_ptr_cast<tracks_header>(buffer);
buffer += sizeof(tracks_header);
// Write our primary header
header->tag = static_cast<uint32_t>(buffer_tag32::compressed_tracks);
header->version = compressed_tracks_version16::latest;
header->algorithm_type = algorithm_type8::uniformly_sampled;
header->track_type = track_list.get_track_type();
header->num_tracks = num_output_bones;
header->num_samples = num_output_bones != 0 ? lossy_clip_context.num_samples : 0;
header->sample_rate = num_output_bones != 0 ? lossy_clip_context.sample_rate : 0.0F;
header->set_rotation_format(settings.rotation_format);
header->set_translation_format(settings.translation_format);
header->set_scale_format(settings.scale_format);
header->set_has_scale(lossy_clip_context.has_scale);
// Our default scale is 1.0 if we have no additive base or if we don't use 'additive1', otherwise it is 0.0
header->set_default_scale(!is_additive || additive_format != additive_clip_format8::additive1 ? 1U : 0U);
header->set_has_database(false);
header->set_has_trivial_default_values(has_trivial_defaults);
header->set_has_stripped_keyframes(lossy_clip_context.has_stripped_keyframes);
header->set_is_wrap_optimized(lossy_clip_context.looping_policy == sample_looping_policy::wrap);
header->set_has_metadata(metadata_size != 0);
// Write our transform tracks header
transform_tracks_header* transforms_header = safe_ptr_cast<transform_tracks_header>(buffer);
buffer += sizeof(transform_tracks_header);
transforms_header->num_segments = lossy_clip_context.num_segments;
transforms_header->num_animated_variable_sub_tracks = num_animated_variable_sub_tracks_padded;
get_num_constant_samples(lossy_clip_context, transforms_header->num_constant_rotation_samples, transforms_header->num_constant_translation_samples, transforms_header->num_constant_scale_samples);
get_num_animated_sub_tracks(lossy_clip_context.segments[0],
transforms_header->num_animated_rotation_sub_tracks, transforms_header->num_animated_translation_sub_tracks, transforms_header->num_animated_scale_sub_tracks);
const uint32_t segment_start_indices_offset = align_to<uint32_t>(sizeof(transform_tracks_header), 4); // Relative to the start of our transform_tracks_header
transforms_header->database_header_offset = invalid_ptr_offset();
transforms_header->segment_headers_offset = align_to(segment_start_indices_offset + segment_start_indices_size, 4);
transforms_header->sub_track_types_offset = align_to(transforms_header->segment_headers_offset + segment_headers_size, 4);
transforms_header->constant_track_data_offset = align_to(transforms_header->sub_track_types_offset + packed_sub_track_buffer_size, 4);
transforms_header->clip_range_data_offset = align_to(transforms_header->constant_track_data_offset + constant_data_size, 4);
uint32_t written_segment_start_indices_size = 0;
if (lossy_clip_context.num_segments > 1)
written_segment_start_indices_size = write_segment_start_indices(lossy_clip_context, transforms_header->get_segment_start_indices());
const uint32_t segment_data_start_offset = transforms_header->clip_range_data_offset + clip_range_data_size;
const uint32_t written_segment_headers_size = write_segment_headers(lossy_clip_context, settings, transforms_header->get_segment_headers(), segment_data_start_offset);
uint32_t written_sub_track_buffer_size = 0;
written_sub_track_buffer_size += write_packed_sub_track_types(lossy_clip_context, transforms_header->get_sub_track_types(), output_bone_mapping, num_output_bones);
uint32_t written_constant_data_size = 0;
if (constant_data_size != 0)
written_constant_data_size = write_constant_track_data(lossy_clip_context, settings.rotation_format, transforms_header->get_constant_track_data(), constant_data_size, output_bone_mapping, num_output_bones);
uint32_t written_clip_range_data_size = 0;
if (range_reduction != range_reduction_flags8::none)
written_clip_range_data_size = write_clip_range_data(lossy_clip_context, range_reduction, transforms_header->get_clip_range_data(), clip_range_data_size, output_bone_mapping, num_output_bones);
const uint32_t written_segment_data_size = write_segment_data(lossy_clip_context, settings, range_reduction, transforms_header->get_segment_headers(), *transforms_header, output_bone_mapping, num_output_bones);
// Optional metadata header is last
uint32_t writter_metadata_track_list_name_size = 0;
uint32_t written_metadata_track_names_size = 0;
uint32_t written_metadata_parent_track_indices_size = 0;
uint32_t written_metadata_track_descriptions_size = 0;
uint32_t written_metadata_contributing_error_size = 0;
if (metadata_size != 0)
{
optional_metadata_header* metadata_header = bit_cast<optional_metadata_header*>(buffer_start + buffer_size - sizeof(optional_metadata_header));
uint32_t metadata_offset = metadata_start_offset; // Relative to the start of our compressed_tracks
if (settings.metadata.include_track_list_name)
{
metadata_header->track_list_name = metadata_offset;
writter_metadata_track_list_name_size = write_track_list_name(track_list, metadata_header->get_track_list_name(*out_compressed_tracks));
metadata_offset += writter_metadata_track_list_name_size;
}
else
metadata_header->track_list_name = invalid_ptr_offset();
if (settings.metadata.include_track_names)
{
metadata_offset = align_to(metadata_offset, 4);
metadata_header->track_name_offsets = metadata_offset;
written_metadata_track_names_size = write_track_names(track_list, output_bone_mapping, num_output_bones, metadata_header->get_track_name_offsets(*out_compressed_tracks));
metadata_offset += written_metadata_track_names_size;
}
else
metadata_header->track_name_offsets = invalid_ptr_offset();
if (settings.metadata.include_parent_track_indices)
{
metadata_offset = align_to(metadata_offset, 4);
metadata_header->parent_track_indices = metadata_offset;
written_metadata_parent_track_indices_size = write_parent_track_indices(track_list, output_bone_mapping, num_output_bones, metadata_header->get_parent_track_indices(*out_compressed_tracks));
metadata_offset += written_metadata_parent_track_indices_size;
}
else
metadata_header->parent_track_indices = invalid_ptr_offset();
if (settings.metadata.include_track_descriptions)
{
metadata_offset = align_to(metadata_offset, 4);
metadata_header->track_descriptions = metadata_offset;
written_metadata_track_descriptions_size = write_track_descriptions(track_list, output_bone_mapping, num_output_bones, metadata_header->get_track_descriptions(*out_compressed_tracks));
metadata_offset += written_metadata_track_descriptions_size;
}
else
metadata_header->track_descriptions = invalid_ptr_offset();
if (settings.metadata.include_contributing_error)
{
metadata_offset = align_to(metadata_offset, 4);
metadata_header->contributing_error = metadata_offset;
written_metadata_contributing_error_size = write_contributing_error(lossy_clip_context, metadata_header->get_contributing_error(*out_compressed_tracks));
metadata_offset += written_metadata_contributing_error_size;
}
else
metadata_header->contributing_error = invalid_ptr_offset();
}
// Finish the compressed tracks raw buffer header
buffer_header->size = buffer_size;
buffer_header->hash = hash32(safe_ptr_cast<const uint8_t>(header), buffer_size - sizeof(raw_buffer_header)); // Hash everything but the raw buffer header
#if defined(ACL_USE_SJSON)
compression_stats.output_packing_elapsed_seconds = output_packing_time.get_elapsed_seconds();
#endif
#if defined(ACL_HAS_ASSERT_CHECKS)
{
// Make sure we wrote the right amount of data
buffer = align_to(buffer, 4); // Align segment start indices
buffer += written_segment_start_indices_size;
buffer = align_to(buffer, 4); // Align segment headers
buffer += written_segment_headers_size;
buffer = align_to(buffer, 4); // Align sub-track types
buffer += written_sub_track_buffer_size;
buffer = align_to(buffer, 4); // Align constant track data
buffer += written_constant_data_size;
buffer = align_to(buffer, 4); // Align range data
buffer += written_clip_range_data_size;
buffer += written_segment_data_size;
if (metadata_size != 0)
{
buffer = align_to(buffer, 4);
buffer += metadata_size;
buffer = align_to(buffer, 4);
buffer += sizeof(optional_metadata_header);
}
else
buffer += 15; // Ensure we have sufficient padding for unaligned 16 byte loads
(void)buffer_start; // Avoid VS2017 bug, it falsely reports this variable as unused even when asserts are enabled
ACL_ASSERT(written_segment_start_indices_size == segment_start_indices_size, "Wrote too little or too much data");
ACL_ASSERT(written_segment_headers_size == segment_headers_size, "Wrote too little or too much data");
ACL_ASSERT(written_segment_data_size == segment_data_size, "Wrote too little or too much data");
ACL_ASSERT(written_sub_track_buffer_size == packed_sub_track_buffer_size, "Wrote too little or too much data");
ACL_ASSERT(written_constant_data_size == constant_data_size, "Wrote too little or too much data");
ACL_ASSERT(written_clip_range_data_size == clip_range_data_size, "Wrote too little or too much data");
ACL_ASSERT(writter_metadata_track_list_name_size == metadata_track_list_name_size, "Wrote too little or too much data");
ACL_ASSERT(written_metadata_track_names_size == metadata_track_names_size, "Wrote too little or too much data");
ACL_ASSERT(written_metadata_parent_track_indices_size == metadata_parent_track_indices_size, "Wrote too little or too much data");
ACL_ASSERT(written_metadata_track_descriptions_size == metadata_track_descriptions_size, "Wrote too little or too much data");
ACL_ASSERT(written_metadata_contributing_error_size == metadata_contributing_error_size, "Wrote too little or too much data");
ACL_ASSERT(uint32_t(buffer - buffer_start) == buffer_size, "Wrote too little or too much data");
if (metadata_size == 0)
{
for (const uint8_t* padding = buffer - 15; padding < buffer; ++padding)
ACL_ASSERT(*padding == 0, "Padding was overwritten");
}
}
#else
(void)written_segment_start_indices_size;
(void)written_segment_headers_size;
(void)written_sub_track_buffer_size;
(void)written_constant_data_size;
(void)written_clip_range_data_size;
(void)written_segment_data_size;
(void)segment_data_size;
(void)buffer_start;
#endif
#if defined(ACL_USE_SJSON)
compression_stats.total_elapsed_seconds = compression_time.stop().get_elapsed_seconds();
if (out_stats.logging != stat_logging::none)
write_stats(allocator, track_list, lossy_clip_context, *out_compressed_tracks, settings, segmenting_settings, range_reduction, raw_clip_context, additive_base_clip_context, compression_stats, out_stats);
#endif
deallocate_type_array(allocator, output_bone_mapping, num_output_bones);
deallocate_type_array(allocator, clip_shell_metadata, num_input_transforms);
destroy_clip_context(lossy_clip_context);
destroy_clip_context(raw_clip_context);
destroy_clip_context(additive_base_clip_context);
return error_result();
}
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,118 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2023 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
// Included only once from compression_level.h
#include "acl/version.h"
#include "acl/core/impl/compiler_utils.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
inline const char* get_compression_level_name(compression_level8 level)
{
switch (level)
{
case compression_level8::lowest: return "lowest";
case compression_level8::low: return "low";
case compression_level8::medium: return "medium";
case compression_level8::high: return "high";
case compression_level8::highest: return "highest";
case compression_level8::automatic: return "automatic";
default: return "<Invalid>";
}
}
inline bool get_compression_level(const char* level_name, compression_level8& out_level)
{
ACL_ASSERT(level_name != nullptr, "Level name cannot be null");
if (level_name == nullptr)
return false;
const char* level_lowest = "Lowest"; // ACL_DEPRECATED Legacy name, keep for backwards compatibility, remove in 3.0
const char* level_lowest_new = "lowest";
if (std::strncmp(level_name, level_lowest, std::strlen(level_lowest)) == 0
|| std::strncmp(level_name, level_lowest_new, std::strlen(level_lowest_new)) == 0)
{
out_level = compression_level8::lowest;
return true;
}
const char* level_low = "Low"; // ACL_DEPRECATED Legacy name, keep for backwards compatibility, remove in 3.0
const char* level_low_new = "low";
if (std::strncmp(level_name, level_low, std::strlen(level_low)) == 0
|| std::strncmp(level_name, level_low_new, std::strlen(level_low_new)) == 0)
{
out_level = compression_level8::low;
return true;
}
const char* level_medium = "Medium"; // ACL_DEPRECATED Legacy name, keep for backwards compatibility, remove in 3.0
const char* level_medium_new = "medium";
if (std::strncmp(level_name, level_medium, std::strlen(level_medium)) == 0
|| std::strncmp(level_name, level_medium_new, std::strlen(level_medium_new)) == 0)
{
out_level = compression_level8::medium;
return true;
}
const char* level_highest = "Highest"; // ACL_DEPRECATED Legacy name, keep for backwards compatibility, remove in 3.0
const char* level_highest_new = "highest";
if (std::strncmp(level_name, level_highest, std::strlen(level_highest)) == 0
|| std::strncmp(level_name, level_highest_new, std::strlen(level_highest_new)) == 0)
{
out_level = compression_level8::highest;
return true;
}
const char* level_high = "High"; // ACL_DEPRECATED Legacy name, keep for backwards compatibility, remove in 3.0
const char* level_high_new = "high";
if (std::strncmp(level_name, level_high, std::strlen(level_high)) == 0
|| std::strncmp(level_name, level_high_new, std::strlen(level_high_new)) == 0)
{
out_level = compression_level8::high;
return true;
}
const char* level_automatic = "automatic";
if (std::strncmp(level_name, level_automatic, std::strlen(level_automatic)) == 0)
{
out_level = compression_level8::automatic;
return true;
}
return false;
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,175 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2020 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
// Included only once from compression_settings.h
#include "acl/version.h"
#include "acl/core/error_result.h"
#include "acl/core/hash.h"
#include "acl/core/track_formats.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
inline uint32_t compression_database_settings::get_hash() const
{
uint32_t hash_value = 0;
hash_value = hash_combine(hash_value, hash32(max_chunk_size));
hash_value = hash_combine(hash_value, hash32(medium_importance_tier_proportion));
hash_value = hash_combine(hash_value, hash32(low_importance_tier_proportion));
return hash_value;
}
inline error_result compression_database_settings::is_valid() const
{
if (max_chunk_size < 4 * 1024)
return error_result("max_chunk_size must be greater or equal to 4 KB");
if (align_to(max_chunk_size, 4 * 1024) != max_chunk_size)
return error_result("max_chunk_size must be a multiple of 4 KB");
if (!rtm::scalar_is_finite(medium_importance_tier_proportion) || medium_importance_tier_proportion < 0.0F || medium_importance_tier_proportion > 1.0F)
return error_result("medium_importance_tier_proportion must be in the range [0.0, 1.0]");
if (!rtm::scalar_is_finite(low_importance_tier_proportion) || low_importance_tier_proportion < 0.0F || low_importance_tier_proportion > 1.0F)
return error_result("low_importance_tier_proportion must be in the range [0.0, 1.0]");
// Add an epsilon to account for arithmetic imprecision
const float database_proportion = low_importance_tier_proportion + medium_importance_tier_proportion;
const float epsilon = 1.0e-5F;
if (database_proportion < epsilon || database_proportion > (1.0F + epsilon))
return error_result("The sum of medium_importance_tier_proportion + low_importance_tier_proportion must be in the range [0.0, 1.0]");
return error_result();
}
inline uint32_t compression_metadata_settings::get_hash() const
{
uint32_t hash_value = 0;
hash_value = hash_combine(hash_value, hash32(include_track_list_name));
hash_value = hash_combine(hash_value, hash32(include_track_names));
hash_value = hash_combine(hash_value, hash32(include_parent_track_indices));
hash_value = hash_combine(hash_value, hash32(include_track_descriptions));
hash_value = hash_combine(hash_value, hash32(include_contributing_error));
return hash_value;
}
inline error_result compression_metadata_settings::is_valid() const
{
return error_result();
}
inline bool compression_keyframe_stripping_settings::is_enabled() const
{
return proportion > 0.0F || threshold > 0.0F || strip_trivial;
}
inline uint32_t compression_keyframe_stripping_settings::get_hash() const
{
uint32_t hash_value = 0;
hash_value = hash_combine(hash_value, hash32(proportion));
hash_value = hash_combine(hash_value, hash32(threshold));
hash_value = hash_combine(hash_value, hash32(strip_trivial));
return hash_value;
}
inline error_result compression_keyframe_stripping_settings::is_valid() const
{
if (!rtm::scalar_is_finite(proportion) || proportion < 0.0F || proportion > 1.0F)
return error_result("proportion must be in the range [0.0, 1.0]");
if (!rtm::scalar_is_finite(threshold) || threshold < 0.0F)
return error_result("threshold must be positive definite");
return error_result();
}
inline uint32_t compression_settings::get_hash() const
{
uint32_t hash_value = 0;
hash_value = hash_combine(hash_value, hash32(level));
hash_value = hash_combine(hash_value, hash32(rotation_format));
hash_value = hash_combine(hash_value, hash32(translation_format));
hash_value = hash_combine(hash_value, hash32(scale_format));
if (error_metric != nullptr)
hash_value = hash_combine(hash_value, error_metric->get_hash());
hash_value = hash_combine(hash_value, enable_database_support);
hash_value = hash_combine(hash_value, optimize_loops);
hash_value = hash_combine(hash_value, keyframe_stripping.get_hash());
hash_value = hash_combine(hash_value, metadata.get_hash());
return hash_value;
}
inline error_result compression_settings::is_valid() const
{
if (error_metric == nullptr)
return error_result("error_metric cannot be NULL");
const error_result keyframe_stripping_result = keyframe_stripping.is_valid();
if (keyframe_stripping_result.any())
return keyframe_stripping_result;
const error_result metadata_result = metadata.is_valid();
if (metadata_result.any())
return metadata_result;
if (keyframe_stripping.is_enabled() && enable_database_support)
return error_result("Cannot enable keyframe stripping with database support");
return error_result();
}
inline compression_settings get_raw_compression_settings()
{
return compression_settings();
}
inline compression_settings get_default_compression_settings()
{
compression_settings settings;
settings.level = compression_level8::automatic;
settings.rotation_format = rotation_format8::quatf_drop_w_variable;
settings.translation_format = vector_format8::vector3f_variable;
settings.scale_format = vector_format8::vector3f_variable;
settings.optimize_loops = true;
settings.keyframe_stripping.strip_trivial = true;
return settings;
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,67 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2024 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/impl/compiler_utils.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
// Holds compression timing stats for diagnostics
struct compression_stats_t
{
#if defined(ACL_USE_SJSON)
// Total compression time
double total_elapsed_seconds = 0.0;
// High level passes
double initialization_elapsed_seconds = 0.0;
double optimize_looping_elapsed_seconds = 0.0;
double convert_rotations_elapsed_seconds = 0.0;
double extract_clip_ranges_elapsed_seconds = 0.0;
double compact_constant_sub_tracks_elapsed_seconds = 0.0;
double normalize_clip_elapsed_seconds = 0.0;
double segmenting_elapsed_seconds = 0.0;
double extract_segment_ranges_elapsed_seconds = 0.0;
double normalize_segment_elapsed_seconds = 0.0;
double bit_rate_optimization_elapsed_seconds = 0.0;
double keyframe_stripping_elapsed_seconds = 0.0;
double output_packing_elapsed_seconds = 0.0;
#endif
};
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,259 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2020 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
// Included only once from convert.h
#include "acl/version.h"
#include "acl/compression/compress.h"
#include "acl/compression/track_array.h"
#include "acl/core/compressed_tracks.h"
#include "acl/core/error_result.h"
#include "acl/core/iallocator.h"
#include "acl/core/track_formats.h"
#include "acl/core/impl/bit_cast.impl.h"
#include "acl/core/impl/debug_track_writer.h"
#include "acl/decompression/decompress.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
struct raw_sampling_decompression_settings final : public decompression_settings
{
// Disable normalization. This is only safe if we know the input data is already normalized!
static constexpr rotation_normalization_policy_t get_rotation_normalization_policy() { return rotation_normalization_policy_t::never; }
// Only support raw formats. This ensures we don't interpolate when we can avoid it to maintain the original
// accuracy and it strips the code we don't need for faster decompression.
static constexpr bool is_rotation_format_supported(rotation_format8 format) { return format == rotation_format8::quatf_full; }
static constexpr bool is_translation_format_supported(vector_format8 format) { return format == vector_format8::vector3f_full; }
static constexpr bool is_scale_format_supported(vector_format8 format) { return format == vector_format8::vector3f_full; }
};
}
inline error_result convert_track_list(iallocator& allocator, const track_array& track_list, compressed_tracks*& out_compressed_tracks)
{
compression_settings settings = get_raw_compression_settings();
qvvf_transform_error_metric error_metric;
settings.error_metric = &error_metric;
// Include all the metadata
settings.metadata.include_track_list_name = true;
settings.metadata.include_track_names = true;
settings.metadata.include_parent_track_indices = true;
settings.metadata.include_track_descriptions = true;
output_stats stats;
return compress_track_list(allocator, track_list, settings, out_compressed_tracks, stats);
}
inline error_result convert_track_list(iallocator& allocator, const compressed_tracks& tracks, track_array& out_track_list)
{
error_result error = tracks.is_valid(false);
if (error.any())
return error;
const uint32_t num_tracks = tracks.get_num_tracks();
const track_type8 track_type = tracks.get_track_type();
const uint32_t num_samples = tracks.get_num_samples_per_track();
const float sample_rate = tracks.get_sample_rate();
const float duration = tracks.get_finite_duration();
track_array result(allocator, num_tracks);
result.set_name(string(allocator, tracks.get_name()));
// Setup our track metadata and allocate memory
bool success = true;
for (uint32_t track_index = 0; track_index < num_tracks; ++track_index)
{
track& track_ = result[track_index];
track_desc_scalarf desc_scalar;
track_desc_transformf desc_transform;
bool got_description = false;
switch (track_type)
{
case track_type8::float1f:
got_description = tracks.get_track_description(track_index, desc_scalar);
track_ = track_float1f::make_reserve(desc_scalar, allocator, num_samples, sample_rate);
break;
case track_type8::float2f:
got_description = tracks.get_track_description(track_index, desc_scalar);
track_ = track_float2f::make_reserve(desc_scalar, allocator, num_samples, sample_rate);
break;
case track_type8::float3f:
got_description = tracks.get_track_description(track_index, desc_scalar);
track_ = track_float3f::make_reserve(desc_scalar, allocator, num_samples, sample_rate);
break;
case track_type8::float4f:
got_description = tracks.get_track_description(track_index, desc_scalar);
track_ = track_float4f::make_reserve(desc_scalar, allocator, num_samples, sample_rate);
break;
case track_type8::vector4f:
got_description = tracks.get_track_description(track_index, desc_scalar);
track_ = track_vector4f::make_reserve(desc_scalar, allocator, num_samples, sample_rate);
break;
case track_type8::qvvf:
got_description = tracks.get_track_description(track_index, desc_transform);
track_ = track_qvvf::make_reserve(desc_transform, allocator, num_samples, sample_rate);
break;
default:
ACL_ASSERT(false, "Unexpected track type");
break;
}
success &= got_description;
track_.set_name(string(allocator, tracks.get_track_name(track_index)));
}
if (!success)
return error_result("Metadata was missing from the input");
// Disable floating point exceptions since decompression assumes it
scope_disable_fp_exceptions fp_off;
// Decompress and populate our track data
const acl_impl::tracks_header& header = acl_impl::get_tracks_header(tracks);
if (track_type == track_type8::qvvf &&
header.get_rotation_format() == rotation_format8::quatf_full &&
header.get_translation_format() == vector_format8::vector3f_full &&
header.get_scale_format() == vector_format8::vector3f_full)
{
// Our input transform data uses full precision, retain it
decompression_context<acl_impl::raw_sampling_decompression_settings> context;
context.initialize(tracks);
acl_impl::debug_track_writer writer(allocator, track_type, num_tracks);
writer.initialize_with_defaults(result);
for (uint32_t sample_index = 0; sample_index < num_samples; ++sample_index)
{
const float sample_time = rtm::scalar_min(float(sample_index) / sample_rate, duration);
// Round to nearest to land directly on a sample
context.seek(sample_time, sample_rounding_policy::nearest);
context.decompress_tracks(writer);
for (uint32_t track_index = 0; track_index < num_tracks; ++track_index)
{
track& track_ = result[track_index];
switch (track_type)
{
case track_type8::float1f:
*acl_impl::bit_cast<float*>(track_[sample_index]) = writer.read_float1(track_index);
break;
case track_type8::float2f:
rtm::vector_store2(writer.read_float2(track_index), acl_impl::bit_cast<rtm::float2f*>(track_[sample_index]));
break;
case track_type8::float3f:
rtm::vector_store3(writer.read_float3(track_index), acl_impl::bit_cast<rtm::float3f*>(track_[sample_index]));
break;
case track_type8::float4f:
rtm::vector_store(writer.read_float4(track_index), acl_impl::bit_cast<rtm::float4f*>(track_[sample_index]));
break;
case track_type8::vector4f:
*acl_impl::bit_cast<rtm::vector4f*>(track_[sample_index]) = writer.read_vector4(track_index);
break;
case track_type8::qvvf:
*acl_impl::bit_cast<rtm::qvvf*>(track_[sample_index]) = writer.read_qvv(track_index);
break;
default:
ACL_ASSERT(false, "Unexpected track type");
break;
}
}
}
}
else
{
decompression_context<decompression_settings> context;
context.initialize(tracks);
acl_impl::debug_track_writer writer(allocator, track_type, num_tracks);
if (track_type == track_type8::qvvf)
writer.initialize_with_defaults(result);
for (uint32_t sample_index = 0; sample_index < num_samples; ++sample_index)
{
const float sample_time = rtm::scalar_min(float(sample_index) / sample_rate, duration);
// Round to nearest to land directly on a sample
context.seek(sample_time, sample_rounding_policy::nearest);
context.decompress_tracks(writer);
for (uint32_t track_index = 0; track_index < num_tracks; ++track_index)
{
track& track_ = result[track_index];
switch (track_type)
{
case track_type8::float1f:
*acl_impl::bit_cast<float*>(track_[sample_index]) = writer.read_float1(track_index);
break;
case track_type8::float2f:
rtm::vector_store2(writer.read_float2(track_index), acl_impl::bit_cast<rtm::float2f*>(track_[sample_index]));
break;
case track_type8::float3f:
rtm::vector_store3(writer.read_float3(track_index), acl_impl::bit_cast<rtm::float3f*>(track_[sample_index]));
break;
case track_type8::float4f:
rtm::vector_store(writer.read_float4(track_index), acl_impl::bit_cast<rtm::float4f*>(track_[sample_index]));
break;
case track_type8::vector4f:
*acl_impl::bit_cast<rtm::vector4f*>(track_[sample_index]) = writer.read_vector4(track_index);
break;
case track_type8::qvvf:
*acl_impl::bit_cast<rtm::qvvf*>(track_[sample_index]) = writer.read_qvv(track_index);
break;
default:
ACL_ASSERT(false, "Unexpected track type");
break;
}
}
}
}
out_track_list = std::move(result);
return error_result();
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,134 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2017 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/iallocator.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/core/error.h"
#include "acl/core/scope_profiler.h"
#include "acl/core/track_formats.h"
#include "acl/compression/impl/clip_context.h"
#include "acl/compression/impl/compression_stats.h"
#include <rtm/quatf.h>
#include <rtm/vector4f.h>
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
inline rtm::vector4f RTM_SIMD_CALL convert_rotation(rtm::vector4f_arg0 rotation, rotation_format8 from, rotation_format8 to)
{
ACL_ASSERT(from == rotation_format8::quatf_full, "Source rotation format must be a full precision quaternion");
(void)from;
const rotation_format8 high_precision_format = get_rotation_variant(to) == rotation_variant8::quat ? rotation_format8::quatf_full : rotation_format8::quatf_drop_w_full;
switch (high_precision_format)
{
case rotation_format8::quatf_full:
// Original format, nothing to do
return rotation;
case rotation_format8::quatf_drop_w_full:
// Drop W, we just ensure it is positive and write it back, the W component can be ignored afterwards
return rtm::quat_to_vector(rtm::quat_ensure_positive_w(rtm::vector_to_quat(rotation)));
case rotation_format8::quatf_drop_w_variable:
default:
ACL_ASSERT(false, "Invalid or unsupported rotation format: " ACL_ASSERT_STRING_FORMAT_SPECIFIER, get_rotation_format_name(to));
return rotation;
}
}
inline void convert_rotation_streams(iallocator& allocator, segment_context& segment, rotation_format8 rotation_format)
{
const rotation_format8 high_precision_format = get_rotation_variant(rotation_format) == rotation_variant8::quat ? rotation_format8::quatf_full : rotation_format8::quatf_drop_w_full;
for (transform_streams& bone_stream : segment.bone_iterator())
{
// We convert our rotation stream in place. We assume that the original format is quatf_full stored as rtm::quatf
// For all other formats, we keep the same sample size and either keep Quat_32 or use rtm::vector4f
ACL_ASSERT(bone_stream.rotations.get_sample_size() == sizeof(rtm::quatf), "Unexpected rotation sample size. %u != %zu", bone_stream.rotations.get_sample_size(), sizeof(rtm::quatf));
const uint32_t num_samples = bone_stream.rotations.get_num_samples();
const float sample_rate = bone_stream.rotations.get_sample_rate();
rotation_track_stream converted_stream(allocator, num_samples, sizeof(rtm::quatf), sample_rate, high_precision_format);
for (uint32_t sample_index = 0; sample_index < num_samples; ++sample_index)
{
rtm::quatf rotation = bone_stream.rotations.get_raw_sample<rtm::quatf>(sample_index);
switch (high_precision_format)
{
case rotation_format8::quatf_full:
// Original format, nothing to do
break;
case rotation_format8::quatf_drop_w_full:
// Drop W, we just ensure it is positive and write it back, the W component can be ignored afterwards
rotation = rtm::quat_ensure_positive_w(rotation);
break;
case rotation_format8::quatf_drop_w_variable:
default:
ACL_ASSERT(false, "Invalid or unsupported rotation format: " ACL_ASSERT_STRING_FORMAT_SPECIFIER, get_rotation_format_name(high_precision_format));
break;
}
converted_stream.set_raw_sample(sample_index, rotation);
}
bone_stream.rotations = std::move(converted_stream);
}
}
inline void convert_rotation_streams(
iallocator& allocator,
clip_context& context,
rotation_format8 rotation_format,
compression_stats_t& compression_stats)
{
(void)compression_stats;
#if defined(ACL_USE_SJSON)
scope_profiler convert_rotations_time;
#endif
for (segment_context& segment : context.segment_iterator())
convert_rotation_streams(allocator, segment, rotation_format);
#if defined(ACL_USE_SJSON)
compression_stats.convert_rotations_elapsed_seconds = convert_rotations_time.get_elapsed_seconds();
#endif
}
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
@@ -0,0 +1,151 @@
#pragma once
////////////////////////////////////////////////////////////////////////////////
// The MIT License (MIT)
//
// Copyright (c) 2023 Nicholas Frechette & Animation Compression Library contributors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
////////////////////////////////////////////////////////////////////////////////
#include "acl/version.h"
#include "acl/core/scope_profiler.h"
#include "acl/core/impl/compiler_utils.h"
#include "acl/compression/compression_settings.h"
#include "acl/compression/impl/compression_stats.h"
#include <cstdint>
ACL_IMPL_FILE_PRAGMA_PUSH
namespace acl
{
ACL_IMPL_VERSION_NAMESPACE_BEGIN
namespace acl_impl
{
inline void strip_keyframes(clip_context& lossy_clip_context, const compression_settings& settings, compression_stats_t& compression_stats)
{
if (!settings.keyframe_stripping.is_enabled())
return; // We don't want to strip keyframes, nothing to do
(void)compression_stats;
#if defined(ACL_USE_SJSON)
scope_profiler keyframe_stripping_time;
#endif
const bitset_description hard_keyframes_desc = bitset_description::make_from_num_bits<32>();
const uint32_t num_keyframes = lossy_clip_context.num_samples;
const keyframe_stripping_metadata_t* contributing_error_per_keyframe = lossy_clip_context.contributing_error;
// Initialize which keyframes are retained, we'll strip them later
for (segment_context& segment : lossy_clip_context.segment_iterator())
bitset_set_range(&segment.hard_keyframes, hard_keyframes_desc, 0, segment.num_samples, true);
// First determine how many we wish to strip based on proportion
// A frame is movable if it isn't the first or last frame of a segment
// If we have more than 1 frame, we can remove 2 frames per segment
// We know that the only way to get a segment with 1 frame is if the whole clip contains
// a single frame and thus has one segment
// If we have 0 or 1 frame, none are movable
const uint32_t num_movable_frames = num_keyframes >= 2 ? (num_keyframes - (lossy_clip_context.num_segments * 2)) : 0;
uint32_t num_keyframes_to_strip = 0;
// First, we strip the trivial keyframes
if (settings.keyframe_stripping.strip_trivial)
{
// We only strip the ones that have the lowest error
// Since the error depends on the order in which keyframes are removed,
// a trivial keyframe might only become trivial after another keyframe that
// isn't trivial is removed.
for (; num_keyframes_to_strip < num_movable_frames; ++num_keyframes_to_strip)
{
if (!contributing_error_per_keyframe[num_keyframes_to_strip].is_keyframe_trivial)
break; // Found the first non-trivial keyframe
}
}
// Then scan starting until we find our threshold if its above the proportion
// We removed trivial keyframes above, meaning the error should be below our desired
// threshold or it's low enough not to matter
// Since the error depends on the order in which keyframes are removed, we process
// the threshold first as it could decrease
for (; num_keyframes_to_strip < num_movable_frames; ++num_keyframes_to_strip)
{
if (contributing_error_per_keyframe[num_keyframes_to_strip].stripping_error > settings.keyframe_stripping.threshold)
break; // The error exceeds our threshold, stop stripping keyframes
}
// Make sure to at least remove as many as the proportion we want
const uint32_t num_proportion_keyframes_to_strip = std::min<uint32_t>(num_movable_frames, uint32_t(settings.keyframe_stripping.proportion * float(num_keyframes)));
num_keyframes_to_strip = std::max<uint32_t>(num_keyframes_to_strip, num_proportion_keyframes_to_strip);
ACL_ASSERT(num_keyframes_to_strip <= num_movable_frames, "Cannot strip more than the number of movable keyframes");
// Check if stripping our keyframes actually saves any memory
// Stripping requires us to add a small header per segment, we need to offset that cost
uint32_t num_bits_saved = 0;
for (uint32_t index = 0; index < num_keyframes_to_strip; ++index)
{
const keyframe_stripping_metadata_t& contributing_error = contributing_error_per_keyframe[index];
const segment_context& segment = lossy_clip_context.segments[contributing_error.segment_index];
num_bits_saved += segment.animated_pose_bit_size;
}
const uint32_t num_bytes_segment_overhead = sizeof(stripped_segment_header_t) - sizeof(segment_header);
const uint32_t num_bytes_stripping_overhead = lossy_clip_context.num_segments * num_bytes_segment_overhead;
const uint32_t num_bytes_saved = num_bits_saved / 8; // Round down to a full byte to be conservative
// If we don't save enough memory, don't strip anything
if (num_bytes_saved <= num_bytes_stripping_overhead)
num_keyframes_to_strip = 0;
// Now that we know how many keyframes to strip, remove them
for (uint32_t index = 0; index < num_keyframes_to_strip; ++index)
{
const keyframe_stripping_metadata_t& contributing_error = contributing_error_per_keyframe[index];
segment_context& segment = lossy_clip_context.segments[contributing_error.segment_index];
const uint32_t segment_keyframe_index = contributing_error.keyframe_index - segment.clip_sample_offset;
ACL_ASSERT(segment_keyframe_index != 0 && segment_keyframe_index < (segment.num_samples - 1), "Cannot strip the first and last sample of a segment");
bitset_set(&segment.hard_keyframes, hard_keyframes_desc, segment_keyframe_index, false);
// Update how much animated we have
const uint32_t num_stored_samples = bitset_count_set_bits(&segment.hard_keyframes, hard_keyframes_desc);
const uint32_t num_animated_data_bits = segment.animated_pose_bit_size * num_stored_samples;
segment.animated_data_size = align_to(num_animated_data_bits, 8) / 8;
}
lossy_clip_context.has_stripped_keyframes = num_keyframes_to_strip != 0;
#if defined(ACL_USE_SJSON)
compression_stats.keyframe_stripping_elapsed_seconds = keyframe_stripping_time.get_elapsed_seconds();
#endif
}
}
ACL_IMPL_VERSION_NAMESPACE_END
}
ACL_IMPL_FILE_PRAGMA_POP
Loaded 100 of 1176 files, more files were not shown because too many files have changed in this diff. Show more