diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c8e56ad..c31faf1 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -20,7 +20,7 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - name: Build the library and the dev app working-directory: apps/android-dev - run: ./gradlew :splatkit:assembleRelease :app:assembleRelease --no-daemon + run: ./gradlew :splatkit:assembleRelease :app:assembleRelease :splatkit:testDebugUnitTest --no-daemon - name: Keep the AAR uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml new file mode 100644 index 0000000..307e1bb --- /dev/null +++ b/.github/workflows/engine.yml @@ -0,0 +1,18 @@ +name: splatkit-engine + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Configure + run: cmake -S packages/splatkit-engine -B build/splatkit-engine -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build/splatkit-engine --parallel + - name: Test + run: ctest --test-dir build/splatkit-engine --output-on-failure diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..8ce0c9c --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,22 @@ +name: splatkit-ios + +on: + push: + branches: [main] + pull_request: + +jobs: + # The Metal code and its tests build on macOS; the device library is the same source + # with the iOS toolchain flags, built here to keep it compiling. + test: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Configure (macOS, tests) + run: cmake -S packages/splatkit-ios -B build/ios-mac -DCMAKE_BUILD_TYPE=Release + - name: Build + run: cmake --build build/ios-mac --parallel + - name: Test + run: ctest --test-dir build/ios-mac --output-on-failure + - name: Build for iOS + run: scripts/build-ios.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 55e6633..74ea6d6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,4 +14,4 @@ jobs: with: packages: "ndk;27.1.12297006 cmake;3.22.1" - name: clang-format and clang-tidy - run: scripts/lint-cpp.sh + run: ANDROID_NDK_HOME="$ANDROID_HOME/ndk/27.1.12297006" scripts/lint-cpp.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..21e6700 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,6 @@ +# SplatKit agents + +SDK integration or verification: read [the harness](docs/AGENT_HARNESS.md). +Implementation: follow [CONTRIBUTING.md](CONTRIBUTING.md); terminology lives in [CONTEXT.md](CONTEXT.md). +Keep documentation terse; link code/contracts instead of duplicating them. +Report skipped checks, approximation limits and emulator/physical-device provenance explicitly. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..8552048 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,89 @@ +# SplatKit + +An engine that walks Gaussian splat worlds on phones: a shared C++ core (formats, sorting, navigation, level of detail) and one renderer per platform. +This glossary is the vocabulary of the code, the docs and the conversation; it names concepts, not implementations. + +## Language + +### The world + +**Splat**: +One Gaussian: a position, a covariance, a colour and an opacity. +_Avoid_: point, gaussian, particle + +**Cloud**: +The splats of a world decoded into memory, structure of arrays, in the engine's own frame. +_Avoid_: point cloud, dataset, buffer + +**World**: +What a host asks the engine to show: a source of splats plus, optionally, a collider. A world is one file today and a tileset tomorrow; the host does not know which. +_Avoid_: scene, model, asset, map + +**Collider**: +The triangle mesh the walk camera collides with; the world's floor and walls for navigation only, never drawn. +_Avoid_: mesh, geometry, nav mesh + +**Source**: +Where a world's bytes come from: a file, an asset, a content provider or a URL. Fetching a source puts its bytes on disk; it says nothing about how they are drawn. +_Avoid_: URI (that is the wire format of a source), download + +### Drawing + +**Frame**: +One presented image. The engine draws a frame only when something changed. + +**Sort**: +Ordering splats by camera depth in the direction required by compositing. +_Avoid_: depth sort, z-order + +**Cull**: +Rejecting splats outside the view or below the configured visibility threshold. + +**Render scale**: +The size of the render target relative to the surface, 0.1 to 2. Below 1 the frame is upscaled, above 1 supersampled. +_Avoid_: resolution, resolution mode, quality (that is the preset) + +**Preset**: +A named quality setting (low, medium, high, ultra) that fixes the render scale, the harmonics degree and the splat budget together. +_Avoid_: mode, profile, level + +**Splat budget**: +Selection capacity; zero disables reduction. +_Avoid_: limit, cap, max splats + +### Level of detail + +**Node**: +One entry of the in-memory hierarchy over a cloud: a leaf is a splat of the file, an interior node is one splat standing in for its children. + +**Tree**: +Offline/load-time hierarchy. +_Avoid_: LOD, octree (a tree of nodes is not an octree of tiles) + +**Selection**: +Covering cut; capacity≠quality. + +### Scale + +**Tile**: +A cube of the world at one level, stored as its own spz file, with its splats in spatial order. +_Avoid_: chunk, cell, block, node (a node is inside a cloud, a tile is a cloud) + +**Screen tile**: +A rectangular group of image pixels composited together; unrelated to a world's streaming tiles. + +**Level**: +How coarse a tile is: level 0 is the file's splats, each level up stands in for the eight tiles below it with fewer, larger splats. Made offline, never on the phone. +_Avoid_: LOD, mip, layer + +**Tileset**: +The index of a tiled world: every tile's bounds, level, file and children, plus the size of the smallest splat each stands in for. +_Avoid_: manifest, catalogue, tree + +**Streaming**: +Loading and dropping tiles by what the camera can see while walking, so memory holds a neighbourhood, never the world. +_Avoid_: downloading (that is fetching a source), lazy loading + +**Residency budget**: +The most tile bytes held in memory at once; what streaming evicts against. +_Avoid_: cache size, memory limit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76e245e..ed23866 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,6 +29,22 @@ adb logcat -s SplatKit Debug builds load the Khronos validation layer; a pull request must leave it silent. +## Build and run the iOS dev app + +Needs Xcode 26 with the iOS platform and the Metal toolchain, CMake, [xcodegen](https://github.com/yonaskolb/XcodeGen), an iPhone, and a `.spz` plus its collider `.glb` in `apps/ios-dev/SplatKitDev/Resources`. + +``` +scripts/build-ios.sh # the static libraries, into build/ios/lib +cd apps/ios-dev +xcodegen generate +xcodebuild -scheme SplatKitDev -configuration Release -destination "id=" -allowProvisioningUpdates build +xcrun devicectl device install app --device build/Build/Products/Release-iphoneos/SplatKitDev.app +xcrun devicectl device process launch --device --console com.splatkit.devapp -- --gyro 0 +``` + +The app reads its switches from the command line (`--world`, `--tileset`, `--collider`, `--residency`, `--scale`, `--pose`, `--benchmark`, `--capture`; see `LaunchArgs.swift`) and worlds from its Documents folder, which `devicectl device copy to` fills. +The Objective-C++ sources build with warnings as errors; `scripts/lint-cpp.sh` formats them. + ## Lint the C++ ``` @@ -51,8 +67,9 @@ The script configures both packages for the Android target and lints tests and t ## Code layout `packages/splat-core` has no graphics dependency and is shared by every engine: formats, sorting, the level of detail tree, navigation, file mapping, the world loader and the visibility policy, each with tests. -`packages/splatkit-android` owns everything Vulkan and Android. -Its C++ is one `SplatEngine` (`cpp/engine`) that owns a `VulkanSplatRenderer` (surface, swapchain, pipelines, the world on the GPU), the camera, the sorter, a `Benchmark` and a `StatsPublisher`; `jni/` is the boundary to Kotlin and knows nothing else. +`packages/splatkit-engine` is the engine without a graphics API: one `SplatEngine` that owns the camera, the sorter or the streamer, a `Benchmark` and a `StatsPublisher`, and draws through the `SplatRenderer` interface; the GPU record layout (`GpuSplat` and the packing) lives here so every renderer uploads the same bytes. +`packages/splatkit-android` owns everything Vulkan and Android: `VulkanSplatRenderer` (surface, swapchain, pipelines, the world on the GPU) implements the interface, `AndroidEngine` wires it under the engine, and `jni/` is the boundary to Kotlin and knows nothing else. +`packages/splatkit-ios` owns everything Metal and iOS: `MetalSplatRenderer` implements the interface over a `CAMetalLayer`, `SKSplatEngine` is the Objective-C boundary to Swift, and `Sources/SplatKit` is the Swift layer (render thread, motion, `SplatMetalView`). Its Kotlin has three layers: `com.splatkit` is the public API (`SplatSurfaceView` and the value types), `com.splatkit.engine` the JNI boundary and the render thread, `com.splatkit.input` touch and the gyroscope. The engine does not know what is hosting it; [ADR 0013](docs/adr/0013-engine-modules.md) records the split. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a4080e2 --- /dev/null +++ b/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "SplatKit", + platforms: [.iOS(.v17)], + products: [.library(name: "SplatKit", targets: ["SplatKit"])], + targets: [ + .binaryTarget( + name: "SplatKitCore", + url: "https://github.com/Xget7/splatkit-ios/releases/download/v0.1.0-alpha.2/SplatKitCore.xcframework.zip", + checksum: "477f26f4ac6c70a42c56209ae87450b2adefa5bf32cd54059148566944ec71e7" + ), + .target( + name: "SplatKit", + dependencies: ["SplatKitCore"], + path: "packages/splatkit-ios/Sources/SplatKit", + linkerSettings: [ + .linkedLibrary("c++"), .linkedLibrary("z"), + .linkedFramework("Metal"), .linkedFramework("QuartzCore"), + .linkedFramework("Foundation"), .linkedFramework("CoreGraphics"), + .linkedFramework("ImageIO"), .linkedFramework("UniformTypeIdentifiers"), + ] + ), + ] +) diff --git a/README.md b/README.md index 1a656a2..2baa4da 100644 --- a/README.md +++ b/README.md @@ -1,262 +1,62 @@ # SplatKit -[![Maven Central](https://img.shields.io/maven-central/v/io.github.xget7/splatkit-android?label=Maven%20Central)](https://central.sonatype.com/artifact/io.github.xget7/splatkit-android) -[![splatkit-android](https://github.com/Xget7/splatkit-android/actions/workflows/android.yml/badge.svg)](https://github.com/Xget7/splatkit-android/actions/workflows/android.yml) -[![splat-core](https://github.com/Xget7/splatkit-android/actions/workflows/core.yml/badge.svg)](https://github.com/Xget7/splatkit-android/actions/workflows/core.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +Native Gaussian splatting SDKs for Android/Vulkan and iOS/Metal, sharing a C++17 engine. +Experimental alpha: APIs and quality/performance tradeoffs are still evolving. -Real-time Gaussian splatting engine for Android on Vulkan: SPZ scenes, CPU sort and cull, spherical harmonics, level of detail, and walk navigation with colliders. +[Android API](packages/splatkit-android/README.md) · [iOS / SwiftPM](https://github.com/Xget7/splatkit-ios) · [Agent harness](docs/AGENT_HARNESS.md) -Walking through a 500k splat kitchen on a Xiaomi Mi 9 +## Use -*A 500k splat World Labs kitchen on a Xiaomi Mi 9 (Adreno 640, 2019): native 1080p, 30 to 60 fps, collision against the room, on screen joystick. Recorded on the device; the frame times are the HUD's own.* +Android: API 29+, Vulkan 1.1, arm64-v8a. The GPU path additionally checks subgroup and memory limits. +See the [Android releases](https://github.com/Xget7/splatkit-android/releases) for artifacts. +Maven `0.1.0-alpha04` predates GPU ordering; `0.1.0-alpha05` is the new GPU integration. +To build current source: -SplatKit loads a 3D Gaussian splat scene, such as a World Labs Marble world, and lets the user walk through it with touch, an on screen joystick or the phone's gyroscope. -It ships as a single Android view that you drop into any layout, Compose tree or cross platform view manager. - -```kotlin -implementation("io.github.xget7:splatkit-android:0.1.0-alpha04") -``` - -## Table of contents - -- [Features](#features) -- [Requirements](#requirements) -- [Installation](#installation) -- [Quick start](#quick-start) -- [Configuration](#configuration) -- [How it works](#how-it-works) -- [Performance](#performance) -- [Project status and roadmap](#project-status-and-roadmap) -- [Documentation](#documentation) -- [Contributing](#contributing) -- [License](#license) - -## Features - -- **Walkable scenes.** - Load a splat world together with its collider mesh and the camera walks on the floor, climbs stairs and stops at walls. - Without a collider the camera flies freely. -- **Vulkan renderer.** - The splats are sorted back to front, culled against the view and drawn as blended quads through a Vulkan 1.1 pipeline written for mobile GPUs. -- **Input built in.** - One finger looks around, two fingers walk, double tap toggles the gyroscope. - A joystick view and a stats overlay are included and optional. -- **Quality presets.** - Four presets from `LOW` to `ULTRA` trade resolution, view dependent colour and splat budget against frame time. - Every value behind a preset can be adjusted on its own. -- **Measured, not guessed.** - Every performance claim in this repository comes with the device, the scene and the number that produced it. - The full log lives in [docs/BENCHMARKS.md](docs/BENCHMARKS.md). -- **Small, dependency free core.** - Format decoding, sorting, math and navigation live in a C++17 library with no graphics or platform code, so a second renderer can reuse them unchanged. -- **No app, no account, no cloud.** - MIT licensed source you can read, change, build and measure on your own hardware. - -## Requirements - -| | Minimum | -|---|---| -| Android | 10 (API 29) | -| GPU | Vulkan 1.1 capable | -| ABI | `arm64-v8a` only | -| Input format | SPZ versions 2 to 4, GLB for colliders | - -The AAR does not include x86 or x86_64 binaries, so the view cannot load on the Android emulator. -Develop and test on a physical arm64 device. - -## Installation - -Add the dependency to your module's `build.gradle.kts`: - -```kotlin -dependencies { - implementation("io.github.xget7:splatkit-android:0.1.0-alpha04") -} -``` - -The library is published on Maven Central, so no extra repository is needed. -It ships its own ProGuard consumer rules; nothing has to be added for R8. - -Declare the game category in your app's manifest so Android's power management and vendor game modes treat the app as a game: - -```xml - -``` - -To build against a local checkout instead of the published artifact, see the [library README](packages/splatkit-android/README.md#use-it). - -## Quick start - -Put a `SplatSurfaceView` on screen, forward the activity lifecycle, and hand it the bytes of an SPZ file. -World Labs exports both the `.spz` world and the `.glb` collider for every scene. - -```kotlin -class WorldActivity : Activity() { - private lateinit var splatView: SplatSurfaceView - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - splatView = SplatSurfaceView(this) - setContentView(splatView) - - splatView.listener = object : SplatSurfaceView.Listener { - override fun onWorldReady(splatCount: Int) { /* hide the spinner */ } - override fun onWorldFailed(message: String) { /* show the error */ } - } - - // Reading the file is the only part that needs a background thread. - // Decoding and GPU upload run on the engine's own threads. - Thread { - splatView.loadWorld(assets.open("world.spz").use { it.readBytes() }) - splatView.loadCollider(assets.open("collider.glb").use { it.readBytes() }) - }.start() - - splatView.setMotionEnabled(true) // gyroscope drives the look direction - } - - override fun onResume() { super.onResume(); splatView.resume() } - override fun onPause() { splatView.pause(); super.onPause() } - override fun onDestroy() { splatView.release(); super.onDestroy() } -} +```sh +cd apps/android-dev +./gradlew :splatkit:assembleRelease :splatkit:testDebugUnitTest ``` -`onWorldReady` fires on the main thread once the world is on the GPU. -With a collider loaded the camera switches to walk mode and `onColliderReady` fires. - -### Jetpack Compose - -`SplatSurfaceView` is a plain `SurfaceView`, so it goes inside an `AndroidView` and gets `resume`, `pause` and `release` from a `DisposableEffect` on the lifecycle: - -```kotlin -AndroidView( - factory = { context -> SplatSurfaceView(context).also { view = it } }, - modifier = Modifier.fillMaxSize(), -) -``` - -### React Native, Flutter and other hosts - -A view manager wraps the same view: create it, map props to the properties below, map commands to `loadWorld`, `cameraPose` and `setWalkVelocity`, and turn `Listener` calls into events. -Everything on the view is safe to call from the main thread. - -## Configuration - -Pick a preset with `applyQuality(RenderQuality.MEDIUM)`, or take a preset and change one value with `copy`. -The default is `HIGH`. - -| Preset | Render scale | Harmonics degree | Splat budget | Intended for | -|---|---|---|---|---| -| `LOW` | 0.5 | 0 | 500k | Phones that cannot hold 30 fps at medium, or battery saving | -| `MEDIUM` | 0.7 | 1 | all | 60 fps on a 2019 flagship, hard to tell from full resolution at arm's length | -| `HIGH` | 1.0 | 3 | all | Every pixel, every splat, every harmonic: what the reference rasterizer draws | -| `ULTRA` | 1.5 | 3 | all | Supersampling for flagship GPUs and still captures | - -The individual settings are also available on the view: - -| Property | What it controls | -|---|---| -| `renderScale` | Fraction of the surface resolution the splats are drawn at, 0.1 to 2. | -| `shDegree` | Spherical harmonics degree drawn, 0 to 3. These carry the view dependent colour, such as glints on water and leaves. | -| `maxShDegree` | Highest harmonics degree kept in GPU memory. A memory cap, not a quality setting. | -| `splatBudget` | Most splats drawn per frame through a level of detail tree. 0 draws them all. | -| `cullMarginDegrees` | Angular margin kept drawn around the view so a fast turn never shows an empty edge. | -| `linearBlending` | Blend in linear light instead of the encoded space the scene was trained in. Off by default. | -| `lookSensitivity`, `walkSensitivity` | Gesture tuning. | -| `cameraPose` | Read or set the camera position and orientation, for teleporting or restoring a viewpoint. | - -`readStats()` returns frames per second, frame and GPU milliseconds, sort time and splat count from any thread. -`startBenchmark(seconds)` runs a reproducible turn in place and logs the frame time distribution. -The [library README](packages/splatkit-android/README.md#api) documents every member. - -## How it works - -The repository is a monorepo with two packages and one app: - -``` -packages/splat-core/ C++17 core: formats, sorting, math, navigation. No graphics, no platform code. -packages/splatkit-android/ Android library: Vulkan renderer, camera, input, SplatSurfaceView. -apps/android-dev/ Development and benchmark app. -docs/adr/ Architecture decision records, one file per decision. -docs/BENCHMARKS.md Every measurement, with device, commit and settings. -docs/ROADMAP.md What works, what is next, what is open, what is deferred and why. -``` - -**splat-core** decodes SPZ and GLB files into a single internal coordinate frame, orders the splats spatially at load time, sorts them back to front on a background thread while the camera moves, and provides the collider grid, raycasts and character controller that make a scene walkable. -It has no graphics dependency and no host framework in it. -The same core can sit under a Metal or WebGPU engine without touching the formats, the sorting or the navigation. - -**splatkit-android** owns the Vulkan context, swapchain and frame loop. -The engine sorts on movement and culls on rotation, blends in the encoded colour space the scene was trained in, and draws only when the camera, the world or the surface has changed, so a still scene costs no GPU time. -A Choreographer driven render thread and a JNI boundary connect it to the Kotlin view. - -Every non obvious choice is written down in [docs/adr](docs/adr), from why the project is Android first and Vulkan direct to why blending happens in the encoded space and why the CPU sort is the baseline. - -## Performance - -Measured on a Xiaomi Mi 9 (Adreno 640, Vulkan 1.1, Android 11), release build, phone cooled below 48 C before each run. - -| Scene | Splats | Preset | GPU ms p50 | -|---|---|---|---| -| World Labs kitchen | 500k | `HIGH` (scale 1.0) | 14.0 | -| World Labs house | 2M | `HIGH` (scale 1.0) | 19.4 | -| World Labs house | 2M | `MEDIUM` (scale 0.7) | 13.6, holds 60 fps | -| World Labs house | 2M | `LOW` | 12.4 | -| World Labs house | 2M | `ULTRA` | 39.1 | - -Every optimisation in the engine started with a measurement before it and ended with the same measurement after. -Attempts that did not pay off are recorded too, with their numbers, so nobody has to repeat them. -See [docs/BENCHMARKS.md](docs/BENCHMARKS.md) for the full log and the image quality comparison against the reference renderer. - -## Project status and roadmap - -**Experimental, pre alpha.** -The API will change until 0.1.0. - -Works today: - -- World Labs worlds in SPZ versions 2 to 4, with walk mode from the exported GLB collider. -- Touch, joystick and gyroscope input. -- Four quality presets, level of detail budget, frustum culling and spherical harmonics up to degree 3. -- Published AAR on Maven Central with CI on every pull request. - -Open for contribution, each with a stated proof of success in [docs/ROADMAP.md](docs/ROADMAP.md): +iOS: add [splatkit-ios](https://github.com/Xget7/splatkit-ios) to Swift Package Manager, version `0.1.0-alpha.2`. +Use the native view, forward lifecycle and load worlds asynchronously; see each SDK's README for examples. -- PLY, `.splat` and SOG input formats. - Until then the `ply2spz` tool in `splat-core` converts a PLY offline. -- GPU sorting behind a feature flag. -- Fewer blended fragments, the main cost on Adreno. -- Validation on Mali GPUs (Samsung, Pixel). -- Swapchain pre-rotation, chunked world upload, persisted pipeline cache. +## Implemented scope -Not planned inside this engine: iOS, macOS or visionOS, and host framework bindings. -The core is written so that a Metal engine and a React Native or Flutter binding can each live in their own package. +| Capability | Metal | Vulkan | +|---|---|---| +| GPU visibility, compaction, stable radix, indirect drawing | Yes | Yes, capability-gated | +| Offline `.lodsplat` and GPU hierarchical selection | Yes | Yes | +| 16-bit quantized depth / two radix passes | Opt-in approximation | Internal opt-in approximation | +| SH degrees 0–3, walk/fly, touch, motion, loaded/drawn stats | Yes | Yes | +| Hybrid compute screen tiles | Experimental | Not implemented | +| React Native GPU controls | Pending | Pending | -## Documentation +`splat-core` owns formats, hierarchy and navigation; `splatkit-engine` owns orchestration; +each native SDK owns its GPU resources and view lifecycle. +CPU loading/preprocessing and a bounded compatibility ordering path remain. +GPU rendering does not mean zero CPU work. -| Document | What it covers | -|---|---| -| [packages/splatkit-android/README.md](packages/splatkit-android/README.md) | Full API reference, hosting in Compose and cross platform frameworks, logging, internal layout | -| [packages/splat-core/README.md](packages/splat-core/README.md) | Core domains, building and testing, converting PLY files | -| [docs/adr](docs/adr) | Architecture decision records | -| [docs/BENCHMARKS.md](docs/BENCHMARKS.md) | Benchmark log and image quality comparison | -| [docs/ROADMAP.md](docs/ROADMAP.md) | Status, next steps, open items, deferred items | -| [CONTRIBUTING.md](CONTRIBUTING.md) | Building the core, running the dev app, linting, what a pull request needs | +## Validation and limits -## Contributing +Vulkan passed 128 stable-sort cases through 3M, visibility, LOD-to-indirect integration, +and upload-pressure checks on an arm64 Android emulator using the Mac GPU. +Kitchen 500k rendered with Vulkan validation enabled and no captured errors. +These are functional checks, **not physical Android benchmarks**. -Contributions are welcome. -Most open items need a real device more than deep Vulkan knowledge: running the dev app on a Mali phone and attaching logs is already a valuable report. +Vulkan limits include at most 3M visibility survivors and 2.2M LOD-selected nodes. +Overflow fails closed; source residency depends on driver buffer limits and available memory. +LOD parents, subpixel culling and depth quantization can change the image. +There is no universal 10M/30/60 FPS or lossless guarantee. -1. Read [CONTRIBUTING.md](CONTRIBUTING.md) for how to build the core, run the dev app and lint the C++. -2. Pick an item from [docs/ROADMAP.md](docs/ROADMAP.md). - Each one says what it touches and how to prove it works. -3. Open a pull request with the measurement that backs the change. - A performance change without a number from a device will be asked for one. +[Backend contracts/evidence](packages/splatkit-android/docs/VULKAN.md) · +[Parity decision](docs/adr/0021-mobile-backend-parity.md) · +[Historical device measurements](docs/BENCHMARKS.md) -Bug reports with a crash log or a black screen and the device model are equally welcome. +## Contribute -## License +Use the [agent harness](docs/AGENT_HARNESS.md) and [build guide](CONTRIBUTING.md). +Include device/driver, world, settings and logs with performance reports. +Next acceptance work: physical Adreno/Mali, lifecycle stress, reference-image comparisons, +then Vulkan hybrid tiles and React Native integration. -SplatKit is released under the [MIT License](LICENSE). -Third party licenses are listed in [THIRD_PARTY_LICENSES.txt](THIRD_PARTY_LICENSES.txt). +[MIT license](LICENSE) · [Third-party licenses](THIRD_PARTY_LICENSES.txt). diff --git a/apps/android-dev/app/src/main/java/com/splatkit/devapp/MainActivity.kt b/apps/android-dev/app/src/main/java/com/splatkit/devapp/MainActivity.kt index 7f7c1f7..f3fccdd 100644 --- a/apps/android-dev/app/src/main/java/com/splatkit/devapp/MainActivity.kt +++ b/apps/android-dev/app/src/main/java/com/splatkit/devapp/MainActivity.kt @@ -116,6 +116,8 @@ class MainActivity : Activity() { if (intent?.hasExtra("shdraw") == true) splatView.shDegree = intent.getIntExtra("shdraw", 3) // --ei budget 500000 draws at most that many splats per frame through a level of detail tree. if (intent?.hasExtra("budget") == true) splatView.splatBudget = intent.getIntExtra("budget", 0) + // --ei residency N holds at most N splats of a tiled world on the GPU. + if (intent?.hasExtra("residency") == true) splatView.residencyBudget = intent.getIntExtra("residency", 2_000_000) // --ez linear true blends in linear light, the old default, 40% slower. if (intent?.hasExtra("linear") == true) splatView.linearBlending = intent.getBooleanExtra("linear", false) // --ef margin 20 widens the angular margin the cull keeps drawn around the view. @@ -136,6 +138,10 @@ class MainActivity : Activity() { // --ez bytes true goes through the ByteArray overloads instead of the files. splatView.loadWorld(externalFile(worldPath).readBytes()) colliderPath?.let { splatView.loadCollider(externalFile(it).readBytes()) } + } else if (worldPath.endsWith(".json")) { + // --es world winter/tileset.json streams a tiled world made by splat-tile. + splatView.loadTiledWorld(externalFile(worldPath)) + colliderPath?.let { splatView.loadCollider(externalFile(it)) } } else { splatView.loadWorld(externalFile(worldPath)) colliderPath?.let { splatView.loadCollider(externalFile(it)) } diff --git a/docs/AGENT_HARNESS.md b/docs/AGENT_HARNESS.md new file mode 100644 index 0000000..6025026 --- /dev/null +++ b/docs/AGENT_HARNESS.md @@ -0,0 +1,22 @@ +# SDK harness + +Host integration: [Android](../packages/splatkit-android/README.md), [iOS](../packages/splatkit-ios/README.md). +Use native views, forward lifecycle, load asynchronously; renderer internals stay private. +React Native GPU controls remain pending. + +```sh +python3 scripts/sdk_harness.py plan android +python3 scripts/sdk_harness.py check android +python3 scripts/sdk_harness.py check engine +python3 scripts/sdk_harness.py check metal +python3 scripts/sdk_harness.py android-log capture.log --pid 123 --expected-splats 500000 --environment emulator +python3 -m unittest discover -s scripts/tests +``` + +Requires Python 3.9+, CMake/native toolchain; Android needs the pinned SDK/NDK and JDK. +Builds may fetch dependencies. +JSON stdout; logs in `build/sdk-harness`; failures exit nonzero. +Skipped tests remain unvalidated. +Log checks require one-session `logcat -v threadtime` evidence and the app PID. +Visual acceptance and physical-device performance require separate evidence. +Device launch/install/capture is explicit and outside this harness. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index a21b541..50b228f 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -1,5 +1,26 @@ # Benchmark log +## ISS experiments, 2026-09-11/12 + +Physical A19 Pro snapshot means below; Mac checks are separate. +Command intervals overlap: HUD sort includes visibility/radix, not isolated sort. +Quality remains unapproved; short runs do not establish sustained 30/60 FPS. +Exact settings, hashes, memory samples and limitations remain in the linked artifacts. + +| Run | FPS mean | Evidence | +|---|---:|---| +| Original hybrid, partial | 11.06 | [JSON](benchmarks/2026-09-11-iss-metal-512.json) | +| Initial LOD 1.2M, quality rejected | 35.75 | [JSON](benchmarks/2026-09-12-iss-metal-lod-phone.json) | +| Guarded LOD 2.2M, full orbit | 21.63 | [JSON](benchmarks/2026-09-12-iss-metal-lod-guarded.json) | +| LOD hardware / hybrid | 23.11 / 18.92 | [JSON](benchmarks/2026-09-12-iss-lod-hybrid.json) | +| 16-bit radix | 23.36 | [JSON](benchmarks/2026-09-12-iss-radix16.json) | +| Horizontal framing, 10 seconds | 28.12 | [JSON](benchmarks/2026-09-12-iss-horizontal-smoke.json) | + +Horizontal framing changes the visible set; it is not a radix A/B. +[Prepared startup](benchmarks/2026-09-12-iss-prepared-start.json) waits for GPU completion, not quality. +[Initial Mac LOD](benchmarks/2026-09-12-iss-metal-lod.json) and [interior traversal](benchmarks/2026-09-12-iss-metal-lod-sse.json) retain rejected image comparisons and the uninstrumented signal-9 phone failure. +Guarded measurements observed a ~3.54 GB process limit, not a guaranteed 5 GB. + Every optimisation starts with a measurement of the state before it, recorded here, and ends with the same measurement after. Device: Xiaomi Mi 9, Adreno 640, Vulkan 1.1.128, Android 11, release build, phone cooled below 48 C before each run. Command: `adb shell am start -n com.splatkit.devapp/.MainActivity --es world --ez benchmark true [--ef scale S] [--ef seconds N]`, one full turn in place, GPU time from timestamp queries. diff --git a/docs/adr/0019-offline-lod-files-and-native-gpu-selection.md b/docs/adr/0019-offline-lod-files-and-native-gpu-selection.md new file mode 100644 index 0000000..e68ba77 --- /dev/null +++ b/docs/adr/0019-offline-lod-files-and-native-gpu-selection.md @@ -0,0 +1,4 @@ +# 0019 · 2026-09-12 + +Experimental: float32 LOD; GPU selection/CPU fallback; original leaves retained. +ISS quality rejected. diff --git a/docs/adr/0020-interior-lod-traversal-and-explicit-quality-pressure.md b/docs/adr/0020-interior-lod-traversal-and-explicit-quality-pressure.md new file mode 100644 index 0000000..4f07824 --- /dev/null +++ b/docs/adr/0020-interior-lod-traversal-and-explicit-quality-pressure.md @@ -0,0 +1,4 @@ +# 0020 · 2026-09-12 + +Experimental: v2 interior-only traversal; capacity-denied parents retain coverage and report pressure. +ISS quality rejected. diff --git a/docs/adr/0021-mobile-backend-parity.md b/docs/adr/0021-mobile-backend-parity.md new file mode 100644 index 0000000..951b379 --- /dev/null +++ b/docs/adr/0021-mobile-backend-parity.md @@ -0,0 +1,19 @@ +# 0021 - Mobile backend parity + +Decision: share host contracts and LOD data through [SplatRenderer](../../packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h), keeping GPU implementations native. + +Both backends integrate GPU LOD → visibility/compaction → radix → indirect drawing. +Metal additionally offers experimental hybrid screen tiles; Vulkan does not. +[Vulkan contracts and evidence](../../packages/splatkit-android/docs/VULKAN.md) distinguish implemented functionality from device acceptance. + +Gate [subgroups](https://docs.vulkan.org/guide/latest/subgroups.html), memory and dispatch limits at runtime. Retain bounded CPU fallback, never unsafe raw multi-million-splat draws. +World streaming tiles and screen raster tiles are separate concepts. + +Tradeoff: shared moment-matched parents and covering cuts preserve coverage, not exact images. +Quantized depth, subpixel rejection and transmittance termination are approximations. +Transparent splats are not reliable opaque Hi-Z occluders. +[Optimized hierarchies](https://repo-sam.inria.fr/fungraph/hierarchical-3d-gaussians/) and [Mobile-GS](https://xiaobiaodu.github.io/mobile-gs-project/) require further representation/quality work. + +Merge gate: builds, tests, lint and validation-clean integration evidence via the [harness](../AGENT_HARNESS.md). +Production quality/performance acceptance additionally requires reference images, lifecycle stress and sustained physical-device timings with world/settings/driver provenance. +The emulator verifies functionality, not Android speed. No lossless, universal 30/60 FPS or state-of-art claim follows. diff --git a/docs/benchmarks/2026-09-11-iss-metal-512.csv b/docs/benchmarks/2026-09-11-iss-metal-512.csv new file mode 100644 index 0000000..ed2dedc --- /dev/null +++ b/docs/benchmarks/2026-09-11-iss-metal-512.csv @@ -0,0 +1,20 @@ +timestamp,fps,frame_est_ms,gpu_ms,sort_ms,drawn,loaded,x,y,z,yaw,pitch +2026-09-11 23:25:35.387,11.4,87.719298,69.6,104.1,4256742,9999999,0,-41.3,19.91,0,1.06 +2026-09-11 23:25:37.492,12.1,82.644628,65.4,98.2,4047123,9999999,0,-44.1,13.89,0,1.21 +2026-09-11 23:25:39.748,12.5,80.000000,69.7,95.7,3892772,9999999,0,-46.07,7.1,0,1.37 +2026-09-11 23:25:41.923,13.1,76.335878,68.3,83.7,3806766,9999999,0,-46.94,0.3,0,1.52 +2026-09-11 23:25:44.041,13.2,75.757576,68,83.4,3736799,9999999,0,-46.79,-6.34,-3.14,1.47 +2026-09-11 23:25:46.178,13,76.923077,69.1,85.7,3698332,9999999,0,-45.65,-12.94,-3.14,1.33 +2026-09-11 23:25:48.391,12.5,80.000000,70.5,89.8,3775672,9999999,0,-43.44,-19.54,-3.14,1.17 +2026-09-11 23:25:50.610,11.9,84.033613,67.3,99.4,3905443,9999999,0,-40.24,-25.72,-3.14,1.02 +2026-09-11 23:25:52.663,11.5,86.956522,69.5,103.6,4147294,9999999,0,-36.47,-30.93,-3.14,0.87 +2026-09-11 23:25:54.877,10.5,95.238095,76.6,113.5,4596738,9999999,0,-31.64,-35.86,-3.14,0.72 +2026-09-11 23:25:57.209,10.1,99.009901,84.5,114.2,4902574,9999999,0,-25.75,-40.22,-3.14,0.56 +2026-09-11 23:25:59.407,9.9,101.010101,89.2,111.8,4991467,9999999,0,-19.62,-43.41,-3.14,0.4 +2026-09-11 23:26:01.685,10.2,98.039216,89,109.3,5043098,9999999,0,-12.88,-45.67,-3.14,0.24 +2026-09-11 23:26:03.858,9.5,105.263158,92.5,115.2,5309029,9999999,0,-6.12,-46.81,-3.14,0.09 +2026-09-11 23:26:05.897,10,100.000000,89.2,112,5246792,9999999,0,0.26,-46.94,-3.14,-0.05 +2026-09-11 23:26:08.103,9.8,102.040816,90.1,115.2,5112492,9999999,0,7.11,-46.07,-3.14,-0.2 +2026-09-11 23:26:10.182,9.6,104.166667,89.5,117.8,5041570,9999999,0,13.4,-44.28,-3.14,-0.35 +2026-09-11 23:26:12.246,9.7,103.092784,92.3,114.6,4874371,9999999,0,19.32,-41.63,-3.14,-0.49 +2026-09-11 23:26:14.297,9.7,103.092784,88.1,116.3,4774353,9999999,0,24.76,-38.18,-3.14,-0.64 diff --git a/docs/benchmarks/2026-09-11-iss-metal-512.json b/docs/benchmarks/2026-09-11-iss-metal-512.json new file mode 100644 index 0000000..e426025 --- /dev/null +++ b/docs/benchmarks/2026-09-11-iss-metal-512.json @@ -0,0 +1,88 @@ +{ + "requestedWindow": { + "start": "2026-09-11 23:25:35.000", + "end": "2026-09-11 23:26:35.000", + "timezone": "America/Argentina/Buenos_Aires" + }, + "sampleCount": 19, + "first": "2026-09-11 23:25:35.387", + "last": "2026-09-11 23:26:14.297", + "latestAvailable": "2026-09-11 23:26:14.297", + "stats": { + "fps": { + "mean": 11.06315789473684, + "min": 9.5, + "max": 13.2, + "p50": 10.5, + "p95": 13.2 + }, + "frame_est_ms": { + "mean": 91.64863752080826, + "min": 75.75757575757576, + "max": 105.26315789473684, + "p50": 95.23809523809524, + "p95": 105.26315789473684 + }, + "gpu_ms": { + "mean": 78.86315789473684, + "min": 65.4, + "max": 92.5, + "p50": 76.6, + "p95": 92.5 + }, + "sort_ms": { + "mean": 104.39473684210526, + "min": 83.4, + "max": 117.8, + "p50": 109.3, + "p95": 117.8 + }, + "drawn": { + "mean": 4482075.105263158, + "min": 3698332, + "max": 5309029, + "p50": 4596738, + "p95": 5309029 + } + }, + "errors": [], + "tileDiagnostics": [ + "2026-09-11 23:25:46.254 SplatKitDev[47711:5628463] SplatKit I: hybrid tiles: 0 compute, 12464 hardware, whole-frame guard 1", + "2026-09-11 23:25:56.613 SplatKitDev[47711:5628463] SplatKit I: hybrid tiles: 0 compute, 12464 hardware, whole-frame guard 1", + "2026-09-11 23:26:08.722 SplatKitDev[47711:5628463] SplatKit I: hybrid tiles: 0 compute, 12464 hardware, whole-frame guard 1" + ], + "status": "partial window: 19 periodic log samples spanning 38.910 seconds; not a complete 60-second capture", + "build": { + "commit": "704dd1750969dcdccc1e81a650d3f0c0ec456405", + "dirty": true, + "shaderSHA256": "2325ebb1fc2e4569469686586c55bf8190c3da0c036f2cba84c8a57f71dea626", + "hostSHA256": "a315ebae7111c8897c376af80eb6545bb72822b8b59be4e1a21ab09738df35af" + }, + "configuration": { + "device": "iPhone 17 Pro", + "gpu": "Apple A19 Pro", + "world": "iss_10M.spz", + "loadedSplats": 9999999, + "shDegree": 1, + "width": 1206, + "height": 2622, + "scale": 1, + "orbitRadiusMeters": 45, + "orbitDegreesPerSecond": 4, + "culling": true, + "minPixelRadius": 1, + "tileRaster": true, + "tileCandidates": 512, + "maxFootprintTiles": 16, + "visibleCountGuard": false + }, + "methodology": { + "sampling": "Arithmetic means of periodic console samples, approximately every 2 seconds; not per-frame averages or a full orbit. Loading and initial capture excluded. Temperature and thermal state not measured.", + "frame": "Estimated per sample as 1000 / FPS from the rounded log value, matching the HUD's cadence definition approximately; not a GPU frame timestamp.", + "gpu": "Last completed render command duration at each sample, including tile binning/compositing, hardware raster and final blit.", + "sort": "Last completed visibility/projection/culling plus radix-sort command duration at each sample, NOT isolated radix-sort timing.", + "nonAdditive": "GPU and sort samples may refer to different in-flight frames; do not add them to infer frame time.", + "backend": "Whole-frame footprint guard active in available tile diagnostics: 0 compute tiles / 12464 hardware tiles. No compute-only ISS speedup demonstrated.", + "stream": "Log stops advancing at 23:26:14.297 in this snapshot; reason not established. No GPU error appears in the captured log." + } +} diff --git a/docs/benchmarks/2026-09-12-iss-horizontal-smoke.json b/docs/benchmarks/2026-09-12-iss-horizontal-smoke.json new file mode 100644 index 0000000..5429802 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-horizontal-smoke.json @@ -0,0 +1,100 @@ +{ + "date": "2026-09-12", + "purpose": "camera framing visual smoke, not comparable to vertical benchmarks", + "configuration": { + "world": "iss_10M-sse-depth10-sh1.lodsplat", + "lod_capacity": 2200000, + "sh": 1, + "scale": 1, + "viewport": [ + 1206, + 2622 + ], + "depth_key_bits": 16, + "tile_raster": true, + "min_pixel_radius": 1, + "orbit_horizontal": true, + "axis": "X", + "radius_m": 45, + "speed_degrees_s": 4, + "start_degrees": 90, + "run_seconds": 10 + }, + "executable_sha256": "f33a38bb556608eb0a9ad259157d74f87a3cb0a2d46652915e91533317db79d0", + "log": "/tmp/splatkit-stability.k0lBJw/iss-horizontal-phone.log", + "capture": "/tmp/splatkit-stability.k0lBJw/horizontal-phone.png", + "samples": [ + { + "time_local": "2026-09-12 03:37:34.375", + "fps": 31.1, + "estimated_frame_ms": 32.154340836012864, + "gpu_ms": 34.8, + "sort_ms": 20.2, + "lod_ms": 9.2, + "drawn": 1955354, + "selected": 2199991 + }, + { + "time_local": "2026-09-12 03:37:36.441", + "fps": 30.2, + "estimated_frame_ms": 33.11258278145696, + "gpu_ms": 33.5, + "sort_ms": 23.6, + "lod_ms": 9.3, + "drawn": 1958804, + "selected": 2199995 + }, + { + "time_local": "2026-09-12 03:37:38.526", + "fps": 28.4, + "estimated_frame_ms": 35.21126760563381, + "gpu_ms": 38.9, + "sort_ms": 22.2, + "lod_ms": 9.6, + "drawn": 1964523, + "selected": 2199952 + }, + { + "time_local": "2026-09-12 03:37:40.607", + "fps": 27, + "estimated_frame_ms": 37.03703703703704, + "gpu_ms": 43.5, + "sort_ms": 22.2, + "lod_ms": 10.8, + "drawn": 1970646, + "selected": 2199982 + }, + { + "time_local": "2026-09-12 03:37:42.631", + "fps": 23.9, + "estimated_frame_ms": 41.84100418410042, + "gpu_ms": 46.6, + "sort_ms": 23.1, + "lod_ms": 10, + "drawn": 1970888, + "selected": 2199998 + } + ], + "mean": { + "fps": 28.119999999999997, + "estimated_frame_ms": 35.871246488848215, + "gpu_ms": 39.459999999999994, + "sort_ms": 22.26, + "lod_ms": 9.780000000000001, + "drawn": 1964043, + "selected": 2199983.6 + }, + "sampled_peak_process_bytes": 2826864496, + "steady_process_bytes": 1361249152, + "metal_allocated_bytes": 1063256064, + "thermal_states": [ + 0 + ], + "stop_reason": "duration-complete", + "caveats": [ + "Periodic snapshot means, not per-frame timings.", + "Command intervals overlap; cannot sum.", + "Changed roll changes visible content; do not use this as radix A/B.", + "10-second smoke does not establish sustained performance." + ] +} diff --git a/docs/benchmarks/2026-09-12-iss-lod-hybrid.csv b/docs/benchmarks/2026-09-12-iss-lod-hybrid.csv new file mode 100644 index 0000000..85e01c5 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-lod-hybrid.csv @@ -0,0 +1,20 @@ +mode,time_local,fps,estimated_frame_ms,gpu_ms,sort_ms,lod_ms,drawn,selected +lod_hardware,2026-09-12 03:00:54.766,26.2,38.16793893129771,33.5,42.1,4.2,1992869,2199995 +lod_hardware,2026-09-12 03:00:56.838,25.1,39.8406374501992,36.2,44.6,4.2,1994128,2199998 +lod_hardware,2026-09-12 03:00:58.923,23.3,42.918454935622314,38,47.5,4.3,1994009,2199975 +lod_hardware,2026-09-12 03:01:00.959,21.7,46.082949308755765,39.3,50.1,4.3,1995119,2199988 +lod_hardware,2026-09-12 03:01:03.016,21.7,46.082949308755765,40.8,50.5,4.2,1980179,2199998 +lod_hardware,2026-09-12 03:01:05.041,21.8,45.87155963302752,39.8,50,4.1,1983405,2199999 +lod_hardware,2026-09-12 03:01:07.155,22.4,44.642857142857146,39.5,49.5,4,1969567,2199999 +lod_hardware,2026-09-12 03:01:09.258,22.9,43.66812227074236,37.5,48.3,3.7,1969327,2199972 +lod_hardware,2026-09-12 03:01:11.328,22.7,44.05286343612335,37.3,47.7,3.6,1973599,2199952 +lod_hardware,2026-09-12 03:01:13.387,23.3,42.918454935622314,37.1,47.9,3.5,1977026,2200000 +lod_hybrid,2026-09-12 03:15:01.051,21.1,47.393364928909946,42.5,50.9,4,1992875,2199991 +lod_hybrid,2026-09-12 03:15:03.183,20.5,48.78048780487805,44,53,4,1994519,2199995 +lod_hybrid,2026-09-12 03:15:05.319,19.2,52.083333333333336,48.1,57.4,4.1,1994073,2199987 +lod_hybrid,2026-09-12 03:15:07.420,18,55.55555555555556,48.3,60.2,4.1,1994827,2199998 +lod_hybrid,2026-09-12 03:15:09.580,17.9,55.8659217877095,50.5,60.8,4,1977115,2199989 +lod_hybrid,2026-09-12 03:15:11.688,18.1,55.24861878453038,49.8,59.3,3.7,1983760,2199974 +lod_hybrid,2026-09-12 03:15:13.885,18.3,54.6448087431694,48.9,58.6,3.5,1970775,2199977 +lod_hybrid,2026-09-12 03:15:16.056,18.5,54.054054054054056,48.2,58.9,3.3,1970794,2199993 +lod_hybrid,2026-09-12 03:15:18.208,18.7,53.475935828877006,47.8,58.6,3.1,1974028,2199988 diff --git a/docs/benchmarks/2026-09-12-iss-lod-hybrid.json b/docs/benchmarks/2026-09-12-iss-lod-hybrid.json new file mode 100644 index 0000000..f39acd9 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-lod-hybrid.json @@ -0,0 +1,249 @@ +{ + "date": "2026-09-12", + "status": "experimental_functional_performance_regression_quality_not_accepted", + "device": "iPhone 17 Pro / Apple A19 Pro / iOS 26.6.1", + "configuration": { + "world": "iss_10M-sse-depth10-sh1.lodsplat", + "loaded_splats": 9999999, + "resident_nodes": 11744642, + "selection_capacity": 2200000, + "sh": 1, + "viewport": [ + 1206, + 2622 + ], + "radius_m": 45, + "speed_degrees_s": 4, + "start_degrees": 90, + "min_pixel_radius": 1, + "duration_after_first_frame_s": 20, + "capture_delay_from_session_start_s": 8, + "resource_guard_mib": 2800 + }, + "phone": [ + { + "name": "iss-prepared", + "log": "/tmp/splatkit-stability.k0lBJw/iss-prepared.log", + "sample_count": 10, + "mean": { + "fps": { + "mean": 23.110000000000003, + "min": 21.7, + "max": 26.2 + }, + "estimated_frame_ms": { + "mean": 43.42467873530035, + "min": 38.16793893129771, + "max": 46.082949308755765 + }, + "gpu_ms": { + "mean": 37.900000000000006, + "min": 33.5, + "max": 40.8 + }, + "sort_ms": { + "mean": 47.81999999999999, + "min": 42.1, + "max": 50.5 + }, + "lod_ms": { + "mean": 4.01, + "min": 3.5, + "max": 4.3 + }, + "drawn": { + "mean": 1982922.8, + "min": 1969327, + "max": 1995119 + }, + "selected": { + "mean": 2199987.6, + "min": 2199952, + "max": 2200000 + } + }, + "resources": { + "sampled_peak_bytes": 2801370968, + "steady_footprint_bytes": { + "mean": 1335274978.6666667, + "min": 1335198520, + "max": 1335329592 + }, + "steady_metal_bytes": { + "mean": 1037598720, + "min": 1037598720, + "max": 1037598720 + }, + "thermal_states": [ + 0 + ] + }, + "tile_samples": [] + }, + { + "name": "iss-lod-hybrid-phone", + "log": "/tmp/splatkit-stability.k0lBJw/iss-lod-hybrid-phone.log", + "sample_count": 9, + "mean": { + "fps": { + "mean": 18.92222222222222, + "min": 17.9, + "max": 21.1 + }, + "estimated_frame_ms": { + "mean": 53.01134231344636, + "min": 47.393364928909946, + "max": 55.8659217877095 + }, + "gpu_ms": { + "mean": 47.56666666666666, + "min": 42.5, + "max": 50.5 + }, + "sort_ms": { + "mean": 57.522222222222226, + "min": 50.9, + "max": 60.8 + }, + "lod_ms": { + "mean": 3.755555555555555, + "min": 3.1, + "max": 4.1 + }, + "drawn": { + "mean": 1983640.6666666667, + "min": 1970775, + "max": 1994827 + }, + "selected": { + "mean": 2199988, + "min": 2199974, + "max": 2199998 + } + }, + "resources": { + "sampled_peak_bytes": 2827519808, + "steady_footprint_bytes": { + "mean": 1361568982.0952382, + "min": 1361412944, + "max": 1361576784 + }, + "steady_metal_bytes": { + "mean": 1063256064, + "min": 1063256064, + "max": 1063256064 + }, + "thermal_states": [ + 0 + ] + }, + "tile_samples": [ + { + "time_local": "2026-09-12 03:15:00.035", + "compute": 5257, + "nonempty": 589, + "hardware": 7207, + "invalid": 0 + }, + { + "time_local": "2026-09-12 03:15:00.107", + "compute": 5257, + "nonempty": 589, + "hardware": 7207, + "invalid": 0 + }, + { + "time_local": "2026-09-12 03:15:00.154", + "compute": 5279, + "nonempty": 597, + "hardware": 7185, + "invalid": 0 + }, + { + "time_local": "2026-09-12 03:15:06.059", + "compute": 4323, + "nonempty": 308, + "hardware": 8141, + "invalid": 0 + }, + { + "time_local": "2026-09-12 03:15:12.677", + "compute": 3151, + "nonempty": 530, + "hardware": 9313, + "invalid": 0 + }, + { + "time_local": "2026-09-12 03:15:19.171", + "compute": 4235, + "nonempty": 401, + "hardware": 8229, + "invalid": 0 + } + ] + } + ], + "mac": { + "configuration": "same fixed pose (0,-2,43) looking at (0,-2,-2), SH1, 1206x2622, 2.2M capacity, 1px cutoff", + "selected": 2199983, + "drawn": 1996050, + "hardware_warm_gpu_command_ms": [ + 14.7, + 17.04 + ], + "hybrid_warm_gpu_command_ms": [ + 90.51, + 25.26 + ], + "compute_tiles": 5257, + "nonempty_compute_tiles": 589, + "hardware_tiles": 7207, + "hybrid_vs_hardware_diff": { + "rgb_mae_255": 0.005890540517178494, + "max_channel_delta_255": 52, + "pixels_above_2": 498 + }, + "hardware_repeat_diff": { + "rgb_mae_255": 0.0032914501987899303, + "max_channel_delta_255": 28, + "pixels_above_2": 467 + } + }, + "verification": { + "lod_hybrid_overflow_integration": "passed on Mac GPU, also with Metal shader validation", + "full_mac_suite": { + "total": 196, + "passed": 192, + "skipped": 4, + "failed": 0 + }, + "phone_stop": "duration-complete", + "phone_gpu_errors": 0, + "phone_tile_invalid_inputs": 0 + }, + "caveats": [ + "Two sequential short phone runs, not randomized repeated A/B trials or sustained full orbits.", + "Only periodic loaded-scene samples before the duration-stop marker are included; one in-flight hybrid sample after pause is excluded.", + "Frame cadence is estimated from rounded FPS. GPU and sort command intervals overlap; sort is visibility/radix command time, not isolated radix cost.", + "Native PNG capture occurs relative to session start, so different load durations produce different phone capture poses; only Mac fixed-pose images were numerically compared.", + "The strict 2/255 per-channel parity diagnostic failed for the real ISS hybrid image and also for a hardware-only repeat. The source of these localized differences is not established; no exact parity or seam-free visual acceptance claim.", + "Most compute tiles contain only background, while dense/large-footprint regions still use hardware. Global hardware geometry submission remains, masked on completed compute tiles.", + "The LOD cut and its approximation errors are unchanged by enabling tile rasterization.", + "Memory peak values are sampled at 2 Hz and can miss short spikes. All sampled thermal states were nominal.", + "Combined mode remains opt-in and is not promoted to an accepted SDK performance option." + ], + "provenance": { + "head": "704dd1750969dcdccc1e81a650d3f0c0ec456405", + "dirty": true, + "phone_executable_sha256": "1abf49741b423b421bcf11f3f19f7cd96a275f84b3c9af6efaf022422031efa7" + }, + "artifacts": { + "hardware_mac_log": "/tmp/splatkit-stability.k0lBJw/lod-hardware-mac.log", + "hybrid_mac_log": "/tmp/splatkit-stability.k0lBJw/lod-hybrid-mac.log", + "hardware_repeat_mac_log": "/tmp/splatkit-stability.k0lBJw/lod-hardware-repeat-mac.log", + "hardware_mac_capture": "/tmp/splatkit-stability.k0lBJw/lod-hardware.png", + "hybrid_mac_capture": "/tmp/splatkit-stability.k0lBJw/lod-hybrid.png", + "phone_capture": "/tmp/splatkit-stability.k0lBJw/lod-hybrid-phone.png", + "ctest_log": "/tmp/splatkit-stability.k0lBJw/combined-final-ctest.log" + } +} diff --git a/docs/benchmarks/2026-09-12-iss-metal-lod-guarded-resources.csv b/docs/benchmarks/2026-09-12-iss-metal-lod-guarded-resources.csv new file mode 100644 index 0000000..d615763 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-metal-lod-guarded-resources.csv @@ -0,0 +1,238 @@ +capacity,time_local,elapsed,footprint,peak,available,metal,thermal,loaded,drawn +1200000,2026-09-12 02:46:48.091,-1,1433225384,1433225384,2106767192,74629120,0,0,0 +1200000,2026-09-12 02:46:48.591,-1,1433422232,1433422232,2106570344,74629120,0,0,0 +1200000,2026-09-12 02:46:49.091,-1,1585515240,1585515240,1954477336,74629120,0,0,0 +1200000,2026-09-12 02:46:49.591,-1,1597344560,1597344560,1942648016,74629120,0,0,0 +1200000,2026-09-12 02:46:50.091,-1,1596704624,1597344560,1943287952,74629120,0,0,0 +1200000,2026-09-12 02:46:50.590,-1,1810204528,1810204528,1729607824,971980800,0,0,0 +1200000,2026-09-12 02:46:51.091,-1,2389100400,2389100400,1150892176,971980800,0,0,0 +1200000,2026-09-12 02:46:51.591,0,2721941360,2721941360,818051216,958578688,0,9999999,0 +1200000,2026-09-12 02:46:52.091,0.5,2721826672,2721941360,818165904,958578688,0,9999999,1103762 +1200000,2026-09-12 02:46:52.591,1,2721236848,2721941360,818755728,958578688,0,9999999,1102207 +1200000,2026-09-12 02:46:53.091,1.5,2721335152,2721941360,818657424,958578688,0,9999999,1102062 +1200000,2026-09-12 02:46:53.591,2,2274952784,2721941360,1265039792,958578688,0,9999999,1103156 +1200000,2026-09-12 02:46:54.091,2.5,2274952784,2721941360,1265039792,958578688,0,9999999,1102643 +1200000,2026-09-12 02:46:54.591,3,1852359960,2721941360,1687632616,958578688,0,9999999,1101560 +1200000,2026-09-12 02:46:55.091,3.5,1852310808,2721941360,1687681768,958578688,0,9999999,1101354 +1200000,2026-09-12 02:46:55.591,4,1382810568,2721941360,2157182008,958578688,0,9999999,1103139 +1200000,2026-09-12 02:46:56.091,4.5,1382810568,2721941360,2157182008,958578688,0,9999999,1097582 +1200000,2026-09-12 02:46:56.591,5,1241187176,2721941360,2298805400,958578688,0,9999999,1094289 +1200000,2026-09-12 02:46:57.091,5.5,1241187176,2721941360,2298805400,958578688,0,9999999,1100298 +1200000,2026-09-12 02:46:57.591,6,1240744808,2721941360,2299247768,958578688,0,9999999,1099019 +1200000,2026-09-12 02:46:58.091,6.5,1240744808,2721941360,2299247768,958578688,0,9999999,1095414 +1200000,2026-09-12 02:46:58.591,7,1240548200,2721941360,2299444376,958578688,0,9999999,1091456 +1200000,2026-09-12 02:46:59.091,7.5,1240548200,2721941360,2299444376,958578688,0,9999999,1090951 +1200000,2026-09-12 02:46:59.591,8,1240548200,2721941360,2299444376,958578688,0,9999999,1088633 +1200000,2026-09-12 02:47:00.091,8.5,1240695656,2721941360,2299296920,958578688,0,9999999,1085674 +1200000,2026-09-12 02:47:00.591,9,1240695656,2721941360,2299296920,958578688,0,9999999,1081257 +1200000,2026-09-12 02:47:01.091,9.5,1240695656,2721941360,2299296920,958578688,0,9999999,1081265 +1200000,2026-09-12 02:47:01.591,10,1240695656,2721941360,2299296920,958578688,0,9999999,1079284 +1200000,2026-09-12 02:47:02.091,10.5,1240695656,2721941360,2299296920,958578688,0,9999999,1076725 +1200000,2026-09-12 02:47:02.591,11,1240695656,2721941360,2299296920,958578688,0,9999999,1073378 +1200000,2026-09-12 02:47:03.091,11.5,1240695656,2721941360,2299296920,958578688,0,9999999,1071064 +1200000,2026-09-12 02:47:03.591,12,1240695656,2721941360,2299296920,958578688,0,9999999,1074802 +1200000,2026-09-12 02:47:04.091,12.5,1240695656,2721941360,2299296920,958578688,0,9999999,1074802 +1200000,2026-09-12 02:47:04.591,13,1240548200,2721941360,2299444376,958578688,0,9999999,1077664 +1200000,2026-09-12 02:47:05.091,13.5,1240646504,2721941360,2299346072,958578688,0,9999999,1078949 +1200000,2026-09-12 02:47:05.591,14,1240646504,2721941360,2299346072,958578688,0,9999999,1078949 +1200000,2026-09-12 02:47:06.091,14.499,1240613736,2721941360,2299378840,958578688,0,9999999,1080126 +1200000,2026-09-12 02:47:06.591,15,1240695656,2721941360,2299296920,958578688,0,9999999,1079201 +1200000,2026-09-12 02:47:07.091,15.5,1240695656,2721941360,2299296920,958578688,0,9999999,1078979 +1200000,2026-09-12 02:47:07.591,16,1240695656,2721941360,2299296920,958578688,0,9999999,1078081 +1200000,2026-09-12 02:47:08.091,16.5,1240548200,2721941360,2299444376,958578688,0,9999999,1074461 +1200000,2026-09-12 02:47:08.591,17,1240548200,2721941360,2299444376,958578688,0,9999999,1074076 +1200000,2026-09-12 02:47:09.091,17.5,1240695656,2721941360,2299296920,958578688,0,9999999,1073895 +1200000,2026-09-12 02:47:09.591,18,1240695656,2721941360,2299296920,958578688,0,9999999,1074103 +1200000,2026-09-12 02:47:10.091,18.5,1240695656,2721941360,2299296920,958578688,0,9999999,1074282 +1200000,2026-09-12 02:47:10.591,19,1240695656,2721941360,2299296920,958578688,0,9999999,1072437 +1200000,2026-09-12 02:47:11.091,19.5,1240548200,2721941360,2299444376,958578688,0,9999999,1068461 +1200000,2026-09-12 02:47:11.591,20,1240548200,2721941360,2299444376,958578688,0,9999999,1071472 +1200000,2026-09-12 02:47:12.091,20.5,1240695656,2721941360,2299296920,958578688,0,9999999,1072097 +2200000,2026-09-12 02:47:46.482,-1,1597229872,1597229872,1942762704,74629120,0,0,0 +2200000,2026-09-12 02:47:46.983,-1,1596671856,1597229872,1943320720,74629120,0,0,0 +2200000,2026-09-12 02:47:47.483,-1,2389034864,2389034864,1150957712,1051000832,0,0,0 +2200000,2026-09-12 02:47:47.982,0,2801026928,2801026928,738965648,1037598720,0,9999999,0 +2200000,2026-09-12 02:47:48.482,0.5,2801092464,2801092464,738900112,1037598720,0,9999999,1991665 +2200000,2026-09-12 02:47:48.983,1,2355037752,2801092464,1184954824,1037598720,0,9999999,1994983 +2200000,2026-09-12 02:47:49.483,1.5,2355136056,2801092464,1184856520,1037598720,0,9999999,1993427 +2200000,2026-09-12 02:47:49.982,2,1744077960,2801092464,1795914616,1037598720,0,9999999,1995455 +2200000,2026-09-12 02:47:50.483,2.5,1743930504,2801092464,1796062072,1037598720,0,9999999,1995975 +2200000,2026-09-12 02:47:50.982,3,1320256312,2801092464,2219736264,1037598720,0,9999999,1994374 +2200000,2026-09-12 02:47:51.483,3.5,1320256312,2801092464,2219736264,1037598720,0,9999999,1992984 +2200000,2026-09-12 02:47:51.982,4,1320256312,2801092464,2219736264,1037598720,0,9999999,1993359 +2200000,2026-09-12 02:47:52.483,4.5,1320256312,2801092464,2219736264,1037598720,0,9999999,1995643 +2200000,2026-09-12 02:47:52.983,5,1320256312,2801092464,2219736264,1037598720,0,9999999,1995109 +2200000,2026-09-12 02:47:53.483,5.5,1320256312,2801092464,2219736264,1037598720,0,9999999,1995109 +2200000,2026-09-12 02:47:53.983,6,1320174392,2801092464,2219818184,1037598720,0,9999999,1993484 +2200000,2026-09-12 02:47:54.483,6.501,1320256312,2801092464,2219736264,1037598720,0,9999999,1995289 +2200000,2026-09-12 02:47:54.983,7.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1982036 +2200000,2026-09-12 02:47:55.483,7.5,1320174392,2801092464,2219818184,1037598720,0,9999999,1978389 +2200000,2026-09-12 02:47:55.983,8,1320174392,2801092464,2219818184,1037598720,0,9999999,1970524 +2200000,2026-09-12 02:47:56.483,8.5,1320174392,2801092464,2219818184,1037598720,0,9999999,1966218 +2200000,2026-09-12 02:47:56.983,9.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1981182 +2200000,2026-09-12 02:47:57.483,9.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1983986 +2200000,2026-09-12 02:47:57.983,10.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1980744 +2200000,2026-09-12 02:47:58.483,10.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1979337 +2200000,2026-09-12 02:47:58.983,11.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1973160 +2200000,2026-09-12 02:47:59.483,11.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1971113 +2200000,2026-09-12 02:47:59.983,12.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1972014 +2200000,2026-09-12 02:48:00.483,12.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1970734 +2200000,2026-09-12 02:48:00.983,13.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1969279 +2200000,2026-09-12 02:48:01.483,13.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1969032 +2200000,2026-09-12 02:48:01.983,14.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1973779 +2200000,2026-09-12 02:48:02.483,14.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1976269 +2200000,2026-09-12 02:48:02.983,15.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1972485 +2200000,2026-09-12 02:48:03.483,15.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1974159 +2200000,2026-09-12 02:48:03.983,16.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1975343 +2200000,2026-09-12 02:48:04.483,16.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1977307 +2200000,2026-09-12 02:48:04.983,17.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1977761 +2200000,2026-09-12 02:48:05.483,17.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1976905 +2200000,2026-09-12 02:48:05.983,18.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1976905 +2200000,2026-09-12 02:48:06.483,18.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1975236 +2200000,2026-09-12 02:48:06.983,19.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1973489 +2200000,2026-09-12 02:48:07.483,19.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1973213 +2200000,2026-09-12 02:48:07.983,20.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1972930 +2200000,2026-09-12 02:48:08.483,20.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1972470 +2200000,2026-09-12 02:48:08.983,21.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1971989 +2200000,2026-09-12 02:48:09.483,21.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1969225 +2200000,2026-09-12 02:48:09.982,22,1320174392,2801092464,2219818184,1037598720,0,9999999,1966740 +2200000,2026-09-12 02:48:10.483,22.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1965976 +2200000,2026-09-12 02:48:10.983,23.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1967440 +2200000,2026-09-12 02:48:11.483,23.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1968427 +2200000,2026-09-12 02:48:11.983,24.001,1320174392,2801092464,2219818184,1037598720,0,9999999,1970749 +2200000,2026-09-12 02:48:12.483,24.501,1320174392,2801092464,2219818184,1037598720,0,9999999,1970758 +2200000,2026-09-12 02:48:12.983,25.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1971853 +2200000,2026-09-12 02:48:13.483,25.5,1320141624,2801092464,2219850952,1037598720,0,9999999,1972423 +2200000,2026-09-12 02:48:13.983,26.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1973701 +2200000,2026-09-12 02:48:14.483,26.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1972042 +2200000,2026-09-12 02:48:14.983,27.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1971876 +2200000,2026-09-12 02:48:15.483,27.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1975235 +2200000,2026-09-12 02:48:15.983,28.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1975899 +2200000,2026-09-12 02:48:16.483,28.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1974601 +2200000,2026-09-12 02:48:16.983,29.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1967708 +2200000,2026-09-12 02:48:17.483,29.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1974043 +2200000,2026-09-12 02:48:17.983,30.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1974043 +2200000,2026-09-12 02:48:18.483,30.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1970723 +2200000,2026-09-12 02:48:18.983,31.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1960961 +2200000,2026-09-12 02:48:19.483,31.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1964544 +2200000,2026-09-12 02:48:19.983,32.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1971171 +2200000,2026-09-12 02:48:20.483,32.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1973111 +2200000,2026-09-12 02:48:20.983,33.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1970306 +2200000,2026-09-12 02:48:21.483,33.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1970902 +2200000,2026-09-12 02:48:21.983,34.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1971290 +2200000,2026-09-12 02:48:22.483,34.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1971772 +2200000,2026-09-12 02:48:22.983,35.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1975786 +2200000,2026-09-12 02:48:23.483,35.501,1320141624,2801092464,2219850952,1037598720,0,9999999,1973593 +2200000,2026-09-12 02:48:23.983,36.001,1320141624,2801092464,2219850952,1037598720,0,9999999,1970779 +2200000,2026-09-12 02:48:24.483,36.5,1320141624,2801092464,2219850952,1037598720,0,9999999,1971955 +2200000,2026-09-12 02:48:24.983,37.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1971875 +2200000,2026-09-12 02:48:25.483,37.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1965156 +2200000,2026-09-12 02:48:25.983,38.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1970494 +2200000,2026-09-12 02:48:26.483,38.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1933745 +2200000,2026-09-12 02:48:26.983,39.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1933745 +2200000,2026-09-12 02:48:27.483,39.501,1320026936,2801092464,2219965640,1037598720,0,9999999,1961008 +2200000,2026-09-12 02:48:27.983,40.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1953093 +2200000,2026-09-12 02:48:28.483,40.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1956517 +2200000,2026-09-12 02:48:28.983,41.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1973727 +2200000,2026-09-12 02:48:29.483,41.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1979268 +2200000,2026-09-12 02:48:29.983,42.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1982234 +2200000,2026-09-12 02:48:30.483,42.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1984114 +2200000,2026-09-12 02:48:30.983,43.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1979758 +2200000,2026-09-12 02:48:31.483,43.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1963839 +2200000,2026-09-12 02:48:31.983,44.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1950498 +2200000,2026-09-12 02:48:32.483,44.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1944369 +2200000,2026-09-12 02:48:32.983,45.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1947718 +2200000,2026-09-12 02:48:33.483,45.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1947310 +2200000,2026-09-12 02:48:33.983,46.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1946130 +2200000,2026-09-12 02:48:34.483,46.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1944435 +2200000,2026-09-12 02:48:34.983,47.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1938985 +2200000,2026-09-12 02:48:35.483,47.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1939411 +2200000,2026-09-12 02:48:35.983,48.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1939492 +2200000,2026-09-12 02:48:36.483,48.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1941684 +2200000,2026-09-12 02:48:36.983,49.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1942112 +2200000,2026-09-12 02:48:37.483,49.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1938834 +2200000,2026-09-12 02:48:37.983,50.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1938398 +2200000,2026-09-12 02:48:38.483,50.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1938548 +2200000,2026-09-12 02:48:38.983,51.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1938519 +2200000,2026-09-12 02:48:39.483,51.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1938023 +2200000,2026-09-12 02:48:39.983,52.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1940183 +2200000,2026-09-12 02:48:40.483,52.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1940993 +2200000,2026-09-12 02:48:40.983,53.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1942589 +2200000,2026-09-12 02:48:41.483,53.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1944185 +2200000,2026-09-12 02:48:41.983,54.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1944924 +2200000,2026-09-12 02:48:42.483,54.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1944924 +2200000,2026-09-12 02:48:42.983,55.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1946334 +2200000,2026-09-12 02:48:43.483,55.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1945901 +2200000,2026-09-12 02:48:43.983,56.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1945676 +2200000,2026-09-12 02:48:44.483,56.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1947864 +2200000,2026-09-12 02:48:44.983,57.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1948659 +2200000,2026-09-12 02:48:45.483,57.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1949449 +2200000,2026-09-12 02:48:45.983,58.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1952078 +2200000,2026-09-12 02:48:46.483,58.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1954256 +2200000,2026-09-12 02:48:46.983,59.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1949403 +2200000,2026-09-12 02:48:47.483,59.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1953201 +2200000,2026-09-12 02:48:47.983,60.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1955546 +2200000,2026-09-12 02:48:48.483,60.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1961222 +2200000,2026-09-12 02:48:48.983,61.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1950323 +2200000,2026-09-12 02:48:49.483,61.501,1320108856,2801092464,2219883720,1037598720,0,9999999,1963295 +2200000,2026-09-12 02:48:49.983,62.001,1320108856,2801092464,2219883720,1037598720,0,9999999,1971129 +2200000,2026-09-12 02:48:50.483,62.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1976023 +2200000,2026-09-12 02:48:50.983,63.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1976023 +2200000,2026-09-12 02:48:51.483,63.501,1319994168,2801092464,2219998408,1037598720,0,9999999,1974880 +2200000,2026-09-12 02:48:51.983,64.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1977030 +2200000,2026-09-12 02:48:52.483,64.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1972184 +2200000,2026-09-12 02:48:52.983,65.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1968743 +2200000,2026-09-12 02:48:53.483,65.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1969842 +2200000,2026-09-12 02:48:53.983,66.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1971387 +2200000,2026-09-12 02:48:54.483,66.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1971702 +2200000,2026-09-12 02:48:54.983,67.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1973521 +2200000,2026-09-12 02:48:55.483,67.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1971007 +2200000,2026-09-12 02:48:55.983,68.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1972368 +2200000,2026-09-12 02:48:56.483,68.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1972147 +2200000,2026-09-12 02:48:56.983,69,1320076088,2801092464,2219916488,1037598720,0,9999999,1973248 +2200000,2026-09-12 02:48:57.483,69.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1973050 +2200000,2026-09-12 02:48:57.983,70,1320076088,2801092464,2219916488,1037598720,0,9999999,1980327 +2200000,2026-09-12 02:48:58.483,70.5,1320076088,2801092464,2219916488,1037598720,0,9999999,1982002 +2200000,2026-09-12 02:48:58.983,71.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1985703 +2200000,2026-09-12 02:48:59.483,71.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1989655 +2200000,2026-09-12 02:48:59.983,72.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1988597 +2200000,2026-09-12 02:49:00.484,72.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1988597 +2200000,2026-09-12 02:49:00.983,73.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1986880 +2200000,2026-09-12 02:49:01.483,73.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1985740 +2200000,2026-09-12 02:49:01.983,74.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1989176 +2200000,2026-09-12 02:49:02.483,74.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1989490 +2200000,2026-09-12 02:49:02.983,75.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1991114 +2200000,2026-09-12 02:49:03.484,75.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1983673 +2200000,2026-09-12 02:49:03.983,76.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1982747 +2200000,2026-09-12 02:49:04.484,76.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1977909 +2200000,2026-09-12 02:49:04.984,77.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1971348 +2200000,2026-09-12 02:49:05.484,77.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1988085 +2200000,2026-09-12 02:49:05.984,78.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1990902 +2200000,2026-09-12 02:49:06.484,78.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1993614 +2200000,2026-09-12 02:49:06.984,79.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1997074 +2200000,2026-09-12 02:49:07.484,79.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1994941 +2200000,2026-09-12 02:49:07.984,80.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1994561 +2200000,2026-09-12 02:49:08.484,80.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1994992 +2200000,2026-09-12 02:49:08.983,81,1320076088,2801092464,2219916488,1037598720,0,9999999,1992477 +2200000,2026-09-12 02:49:09.484,81.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1990446 +2200000,2026-09-12 02:49:09.984,82.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1982522 +2200000,2026-09-12 02:49:10.484,82.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1986143 +2200000,2026-09-12 02:49:10.984,83.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1986143 +2200000,2026-09-12 02:49:11.484,83.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1988428 +2200000,2026-09-12 02:49:11.984,84.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1990921 +2200000,2026-09-12 02:49:12.484,84.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1992953 +2200000,2026-09-12 02:49:12.984,85.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1994644 +2200000,2026-09-12 02:49:13.484,85.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1996536 +2200000,2026-09-12 02:49:13.984,86.001,1320076088,2801092464,2219916488,1037598720,0,9999999,2001088 +2200000,2026-09-12 02:49:14.484,86.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1999916 +2200000,2026-09-12 02:49:14.984,87.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1998903 +2200000,2026-09-12 02:49:15.484,87.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1996414 +2200000,2026-09-12 02:49:15.984,88.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1995870 +2200000,2026-09-12 02:49:16.484,88.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1995058 +2200000,2026-09-12 02:49:16.983,89,1320076088,2801092464,2219916488,1037598720,0,9999999,1992729 +2200000,2026-09-12 02:49:17.484,89.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1992854 +2200000,2026-09-12 02:49:17.984,90.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1996768 +2200000,2026-09-12 02:49:18.484,90.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1992262 +2200000,2026-09-12 02:49:18.984,91.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1994889 +2200000,2026-09-12 02:49:19.484,91.501,1320076088,2801092464,2219916488,1037598720,0,9999999,1996279 +2200000,2026-09-12 02:49:19.984,92.001,1320076088,2801092464,2219916488,1037598720,0,9999999,1993564 diff --git a/docs/benchmarks/2026-09-12-iss-metal-lod-guarded.csv b/docs/benchmarks/2026-09-12-iss-metal-lod-guarded.csv new file mode 100644 index 0000000..e7ec267 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-metal-lod-guarded.csv @@ -0,0 +1,55 @@ +capacity,time_local,fps,estimated_frame_ms,gpu_ms,visibility_radix_ms,lod_ms,drawn,selected +1200000,2026-09-12 02:46:52.275,39.4,25.38071065989848,30.5,19.7,8.8,1102207,1199998 +1200000,2026-09-12 02:46:54.345,36.8,27.17391304347826,33.3,20.1,8.6,1101560,1200000 +1200000,2026-09-12 02:46:56.389,36,27.77777777777778,38.7,16.8,4.4,1094289,1199995 +1200000,2026-09-12 02:46:58.457,37.2,26.881720430107524,33.7,20.1,7.6,1091456,1199984 +1200000,2026-09-12 02:47:00.480,37.7,26.52519893899204,31.1,20.9,8.4,1081257,1199999 +1200000,2026-09-12 02:47:02.533,38.5,25.974025974025974,29.5,21.5,9.2,1073378,1199998 +1200000,2026-09-12 02:47:04.591,38,26.31578947368421,31.8,19.5,3,1077664,1199998 +1200000,2026-09-12 02:47:06.640,39.8,25.12562814070352,31.2,17.6,3.1,1078979,1199993 +1200000,2026-09-12 02:47:08.690,39.8,25.12562814070352,31.8,18.2,2.8,1073895,1199998 +1200000,2026-09-12 02:47:10.744,38.6,25.906735751295336,29.9,19.4,7,1068461,1199999 +2200000,2026-09-12 02:47:48.806,26,38.46153846153846,34.9,44.2,4.3,1994983,2199928 +2200000,2026-09-12 02:47:50.889,23.8,42.016806722689076,37.7,37.6,4.3,1994374,2199996 +2200000,2026-09-12 02:47:52.972,21.6,46.29629629629629,39.4,49.6,4.3,1995109,2199999 +2200000,2026-09-12 02:47:55.033,21.6,46.29629629629629,40.7,50.5,4.2,1978389,2199966 +2200000,2026-09-12 02:47:57.061,21.9,45.662100456621005,39.8,50,4.1,1983986,2200000 +2200000,2026-09-12 02:47:59.217,22.5,44.44444444444444,38.5,49.1,4,1971113,2199988 +2200000,2026-09-12 02:48:01.327,23.5,42.5531914893617,37.9,48.4,3.7,1969032,2199989 +2200000,2026-09-12 02:48:03.407,23.2,43.10344827586207,37,48.2,3.5,1974159,2199997 +2200000,2026-09-12 02:48:05.482,23.1,43.290043290043286,37.5,48.7,3.5,1976905,2199998 +2200000,2026-09-12 02:48:07.554,23.3,42.918454935622314,37,47.9,3.3,1972930,2199994 +2200000,2026-09-12 02:48:09.619,23.2,43.10344827586207,37.5,47.7,3.3,1966740,2199939 +2200000,2026-09-12 02:48:11.734,22.4,44.642857142857146,38.9,49.8,3.3,1970749,2199954 +2200000,2026-09-12 02:48:13.834,21.6,46.29629629629629,40.6,51.5,3.4,1973701,2199990 +2200000,2026-09-12 02:48:15.894,21.2,47.16981132075472,41.1,52.6,3.4,1975899,2199974 +2200000,2026-09-12 02:48:17.991,20.8,48.07692307692307,42.2,53.4,3.7,1970723,2199968 +2200000,2026-09-12 02:48:20.133,20.5,48.78048780487805,42.9,53.7,4,1973111,2200000 +2200000,2026-09-12 02:48:22.233,20.7,48.309178743961354,43.4,53.2,4,1971772,2199992 +2200000,2026-09-12 02:48:24.376,20.6,48.543689320388346,42.1,53,4.1,1971955,2199994 +2200000,2026-09-12 02:48:26.444,22,45.45454545454545,39.1,49.8,4.2,1933745,2199998 +2200000,2026-09-12 02:48:28.540,20.7,48.309178743961354,44,53.4,4.3,1973727,2199998 +2200000,2026-09-12 02:48:30.638,21.6,46.29629629629629,40.1,50,4.3,1979758,2199975 +2200000,2026-09-12 02:48:32.778,22.4,44.642857142857146,39.4,50.1,4.4,1947718,2199994 +2200000,2026-09-12 02:48:34.791,21.8,45.87155963302752,39.4,51,4.5,1938985,2199998 +2200000,2026-09-12 02:48:36.809,21.7,46.082949308755765,40,51.4,4.4,1942112,2199994 +2200000,2026-09-12 02:48:38.876,21.7,46.082949308755765,39.7,51.1,4.3,1938519,2200000 +2200000,2026-09-12 02:48:40.931,21.2,47.16981132075472,41.4,52.7,4.1,1942589,2199993 +2200000,2026-09-12 02:48:43.032,20.8,48.07692307692307,42.3,53.3,4.1,1945901,2199995 +2200000,2026-09-12 02:48:45.177,20.4,49.01960784313726,42.8,54.3,4,1949449,2199998 +2200000,2026-09-12 02:48:47.341,20.3,49.26108374384236,43.2,54.7,3.9,1953201,2199999 +2200000,2026-09-12 02:48:49.393,20,50,44.2,55.3,3.7,1963295,2199979 +2200000,2026-09-12 02:48:51.531,20.3,49.26108374384236,42.9,54.8,3.5,1977030,2199936 +2200000,2026-09-12 02:48:53.669,20.7,48.309178743961354,42.3,52.6,3.4,1971387,2199996 +2200000,2026-09-12 02:48:55.777,21,47.61904761904762,41.5,52.3,3.2,1972368,2199926 +2200000,2026-09-12 02:48:57.873,21,47.61904761904762,42.6,52.6,3.3,1980327,2199983 +2200000,2026-09-12 02:48:59.982,20.8,48.07692307692307,43.5,52.7,3.4,1988597,2199954 +2200000,2026-09-12 02:49:02.093,20.9,47.84688995215311,41.5,52.9,3.5,1989490,2199986 +2200000,2026-09-12 02:49:04.195,20.9,47.84688995215311,41.6,53,3.8,1977909,2199993 +2200000,2026-09-12 02:49:06.295,20.9,47.84688995215311,41.8,52.8,4,1993614,2199998 +2200000,2026-09-12 02:49:08.385,21.1,47.393364928909946,41.9,52.6,4.3,1994992,2199992 +2200000,2026-09-12 02:49:10.482,20.8,48.07692307692307,42.3,53.8,4.4,1986143,2199991 +2200000,2026-09-12 02:49:12.557,21.5,46.51162790697674,41,51.1,4.4,1994644,2199997 +2200000,2026-09-12 02:49:14.570,22,45.45454545454545,40.2,49.9,4.4,1998903,2199996 +2200000,2026-09-12 02:49:16.721,22.3,44.84304932735426,40,49.6,4.4,1992729,2199986 +2200000,2026-09-12 02:49:18.786,21.4,46.728971962616825,41.9,52,4.5,1994889,2199985 diff --git a/docs/benchmarks/2026-09-12-iss-metal-lod-guarded.json b/docs/benchmarks/2026-09-12-iss-metal-lod-guarded.json new file mode 100644 index 0000000..3c16ab0 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-metal-lod-guarded.json @@ -0,0 +1,225 @@ +{ + "date": "2026-09-12", + "status": "guarded_execution_completed_quality_not_accepted", + "device": "iPhone 17 Pro / Apple A19 Pro / iOS 26.6.1", + "timezone": "America/Argentina/Buenos_Aires", + "configuration": { + "world": "iss_10M-sse-depth10-sh1.lodsplat", + "source_splats": 9999999, + "resident_nodes": 11744642, + "sh": 1, + "viewport": [ + 1206, + 2622 + ], + "scale": 1, + "orbit_radius_m": 45, + "orbit_speed_degrees_s": 4, + "orbit_start_degrees": 90, + "min_pixel_radius": 1, + "tile_raster": false, + "resource_sample_hz": 2, + "footprint_guard_mib": 2800, + "minimum_available_process_mib": 256, + "stop_on_thermal_states": [ + 2, + 3 + ] + }, + "runs": [ + { + "name": "iss-1200k", + "capacity": 1200000, + "log": "/tmp/splatkit-stability.k0lBJw/iss-1200k.log", + "status": "duration-complete", + "sample_count": 10, + "sample_window_local": [ + "2026-09-12 02:46:52.275", + "2026-09-12 02:47:10.744" + ], + "sample_span_seconds": 18.4689998626709, + "monitored_loaded_seconds": 20.5, + "metrics": { + "fps": { + "mean": 38.18, + "min": 36, + "max": 39.8 + }, + "estimated_frame_ms": { + "mean": 26.218712833066668, + "min": 25.12562814070352, + "max": 27.77777777777778 + }, + "gpu_ms": { + "mean": 32.15, + "min": 29.5, + "max": 38.7 + }, + "visibility_radix_ms": { + "mean": 19.38, + "min": 16.8, + "max": 21.5 + }, + "lod_ms": { + "mean": 6.29, + "min": 2.8, + "max": 9.2 + }, + "drawn": { + "mean": 1084314.6, + "min": 1068461, + "max": 1102207 + }, + "selected": { + "mean": 1199996.2, + "min": 1199984, + "max": 1200000 + } + }, + "resources": { + "observed_peak_bytes": 2721941360, + "steady_footprint_bytes": { + "mean": 1240653951.2727273, + "min": 1240548200, + "max": 1240695656 + }, + "metal_bytes": { + "mean": 958578688, + "min": 958578688, + "max": 958578688 + }, + "thermal_states": [ + 0 + ], + "minimum_available_process_bytes": 818051216, + "sampled_current_process_limit_bytes": 3539992576 + }, + "errors": [] + }, + { + "name": "iss-2200k", + "capacity": 2200000, + "log": "/tmp/splatkit-stability.k0lBJw/iss-2200k.log", + "status": "duration-complete", + "sample_count": 44, + "sample_window_local": [ + "2026-09-12 02:47:48.806", + "2026-09-12 02:49:18.786" + ], + "sample_span_seconds": 89.98000001907349, + "monitored_loaded_seconds": 92.001, + "metrics": { + "fps": { + "mean": 21.62954545454545, + "min": 20, + "max": 26 + }, + "estimated_frame_ms": { + "mean": 46.35607972364002, + "min": 38.46153846153846, + "max": 50 + }, + "gpu_ms": { + "mean": 40.675000000000004, + "min": 34.9, + "max": 44.2 + }, + "visibility_radix_ms": { + "mean": 51.11136363636363, + "min": 37.6, + "max": 55.3 + }, + "lod_ms": { + "mean": 3.9340909090909104, + "min": 3.2, + "max": 4.5 + }, + "drawn": { + "mean": 1972901.1590909092, + "min": 1933745, + "max": 1998903 + }, + "selected": { + "mean": 2199984.227272727, + "min": 2199926, + "max": 2200000 + } + }, + "resources": { + "observed_peak_bytes": 2801092464, + "steady_footprint_bytes": { + "mean": 1320112629.2848485, + "min": 1319994168, + "max": 1320174392 + }, + "metal_bytes": { + "mean": 1037598720, + "min": 1037598720, + "max": 1037598720 + }, + "thermal_states": [ + 0 + ], + "minimum_available_process_bytes": 738900112, + "sampled_current_process_limit_bytes": 3539992576 + }, + "errors": [] + } + ], + "caveats": [ + "Arithmetic means of periodic performance snapshots, not per-frame averages.", + "Frame cadence is estimated from rounded FPS; command intervals can overlap and must not be added.", + "HUD sort measures the visibility/radix command interval, not isolated radix kernels.", + "The 1.2M run is a 20.5-second partial orbit; the 2.2M run rendered for 92.001 seconds, with a performance-sample span of 89.980 seconds. These are different windows, not a controlled speedup ratio.", + "Memory peaks are observations at 2 Hz, not guaranteed maxima. Guards cannot cancel in-flight work or protect against every transient allocation spike.", + "Available process memory is remaining allowance under the current dirty-memory limit, not device-wide free RAM. This limit can change.", + "Metal allocation is a separate resource-allocation metric, not additive to process footprint.", + "Thermal state 0 means nominal, not a measured temperature in degrees.", + "Neither successful run establishes the cause of the preceding uninstrumented SIGKILL or guarantees long-session stability.", + "The LOD algorithm and parent quality were unchanged in these runs; the previously rejected approximation is not visually approved." + ], + "hypotheses": [ + { + "hypothesis": "Process-memory pressure or growth caused the previous failure.", + "prediction": "Repeated load/orbit shows approaching process allowance, growth, a warning, or a matching Jetsam report.", + "result": "No matching report for the prior failure. Current 2.2M run settled near 1.32 GB and stayed below the 2800 MiB guard; prior cause remains unproven." + }, + { + "hypothesis": "Larger selected/drawn workload increases GPU frame cost.", + "prediction": "Raising only the selection capacity increases drawn count and command intervals.", + "result": "Observed about 1.08M versus 1.97M drawn with higher render/order intervals. Unequal orbit windows prevent a controlled ratio or an isolated-kernel attribution." + }, + { + "hypothesis": "Thermal pressure explains the severe prior slowdown.", + "prediction": "The slowdown recurs alongside serious/critical thermal state.", + "result": "All current resource samples were nominal; prior thermal state was not recorded, so retrospective attribution is unavailable." + }, + { + "hypothesis": "A user action or external process terminated the prior app.", + "prediction": "SIGKILL occurs without an app crash/Jetsam report and does not recur in an attended bounded run.", + "result": "Current runs reached duration-complete; neither this nor absence of a report proves who issued the prior signal." + } + ], + "verification": { + "release_build": "passed", + "kitchen_duration_guard": "passed, 10 seconds", + "gpu_command_errors": 0, + "unexpected_termination_during_measured_runs": false, + "quality_acceptance": "not established", + "performance_target_30fps_at_2200000": "not met" + }, + "provenance": { + "head": "704dd1750969dcdccc1e81a650d3f0c0ec456405", + "dirty": true, + "ios_executable_sha256": "28bc066736d1440ce6386e2720a3628793c0006f78c0f5254d65cc3ed3e861d3", + "resource_monitor_swift_sha256": "b6d36c774cec9a9e5ac2c6c430c2b98b728734a5439a22aaa3aac7754e88ddcf", + "metal_lod_mm_sha256": "b377b0b0be41d0df23037d7ed2f5a55ed9074c7e1f47def5130ff3c4e5078f6c", + "shader_sha256": "5815c9079ff0ffe2b0a19f8b93a6a45700a2597f8b4cbffa0ff8fce62b164073" + }, + "artifacts": { + "performance_csv": "docs/benchmarks/2026-09-12-iss-metal-lod-guarded.csv", + "resource_csv": "docs/benchmarks/2026-09-12-iss-metal-lod-guarded-resources.csv", + "build_log": "/tmp/splatkit-stability.k0lBJw/app-build.log", + "kitchen_log": "/tmp/splatkit-stability.k0lBJw/kitchen.log" + } +} diff --git a/docs/benchmarks/2026-09-12-iss-metal-lod-phone.csv b/docs/benchmarks/2026-09-12-iss-metal-lod-phone.csv new file mode 100644 index 0000000..9e1b7a7 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-metal-lod-phone.csv @@ -0,0 +1,46 @@ +timestamp,fps,frame_cadence_estimated_ms,gpu_render_command_ms,visibility_radix_command_ms,lod_command_ms,drawn,selected,resident_nodes,x,y,z +2026-09-12 01:17:55.607,37.6,26.595745,25.6,27,10.3,712241,1200000,11744642,0,-27.02,35.4 +2026-09-12 01:17:57.654,40,25,26.8,26.2,10.4,664245,1200000,11744642,0,-32.06,31.49 +2026-09-12 01:17:59.705,39.7,25.188917,24.7,25.3,12.4,625061,1200000,11744642,0,-36.56,26.82 +2026-09-12 01:18:01.708,40,25,23.2,23.9,19.2,579270,1200000,11744642,0,-40.27,21.68 +2026-09-12 01:18:03.725,44,22.727273,21.3,22.7,17.6,542255,1200000,11744642,0,-43.19,16.12 +2026-09-12 01:18:05.763,44.4,22.522523,20.7,22.5,16.1,514403,1200000,11744642,0,-45.35,10.06 +2026-09-12 01:18:07.800,45.6,21.929825,20.4,22,16.6,499468,1199999,11744642,0,-46.62,3.81 +2026-09-12 01:18:09.859,46.7,21.413276,20.6,22.3,16.8,489802,1199999,11744642,0,-47,-2.62 +2026-09-12 01:18:11.913,45,22.222222,20.9,22.5,17.7,466383,1199998,11744642,0,-46.44,-9.08 +2026-09-12 01:18:14.002,44.1,22.675737,21.7,23.2,18.3,467308,1199999,11744642,0,-44.94,-15.45 +2026-09-12 01:18:16.105,43.1,23.201856,21.8,24.2,17.7,471752,1199997,11744642,0,-42.52,-21.58 +2026-09-12 01:18:18.131,42,23.809524,23.3,23.7,19.6,482414,1199998,11744642,0,-39.37,-27.07 +2026-09-12 01:18:20.227,40.7,24.570025,24.9,25.6,21,527561,1200000,11744642,0,-35.3,-32.26 +2026-09-12 01:18:22.260,35.9,27.855153,26.7,28.1,12.2,586014,1200000,11744642,0,-30.69,-36.67 +2026-09-12 01:18:24.293,34.4,29.069767,30,26.5,12.8,591439,1200000,11744642,0,-25.49,-40.38 +2026-09-12 01:18:26.383,34.7,28.818444,29,26.5,12.1,597760,1200000,11744642,0,-19.63,-43.4 +2026-09-12 01:18:28.415,36.7,27.247956,27.4,26.3,12.3,608686,1200000,11744642,0,-13.6,-45.48 +2026-09-12 01:18:30.506,33,30.30303,34.5,24.6,11.2,657425,1200000,11744642,0,-7.17,-46.7 +2026-09-12 01:18:32.602,37.2,26.88172,24.2,27.1,12.2,632055,1200000,11744642,0,-0.58,-46.98 +2026-09-12 01:18:34.640,40,25,23.4,24.9,16.1,594992,1200000,11744642,0,5.77,-46.32 +2026-09-12 01:18:36.705,36,27.777778,26.1,27.7,17.6,594640,1199999,11744642,0,12.06,-44.75 +2026-09-12 01:18:38.804,37.4,26.737968,26.2,28,17.9,576161,1200000,11744642,0,18.15,-42.23 +2026-09-12 01:18:40.817,35.4,28.248588,27.5,27.4,23.2,568152,1199997,11744642,0,23.56,-39.04 +2026-09-12 01:18:42.856,35.6,28.089888,27,28.1,17.7,565588,1199999,11744642,0,28.58,-35.01 +2026-09-12 01:18:44.882,34,29.411765,28.6,30,16.4,567842,1199998,11744642,0,32.91,-30.39 +2026-09-12 01:18:46.939,32.8,30.487805,27.3,31.7,16.3,568344,1199998,11744642,0,36.63,-25.08 +2026-09-12 01:18:49.013,34,29.411765,27.4,31.6,16.2,565788,1199999,11744642,0,39.55,-19.28 +2026-09-12 01:18:51.039,33.3,30.03003,27.4,31.9,16.6,548290,1200000,11744642,0,41.58,-13.23 +2026-09-12 01:18:53.123,34.8,28.735632,27,27.7,17.8,524174,1199999,11744642,0,42.74,-6.79 +2026-09-12 01:18:55.213,36.9,27.100271,26.1,26.6,20.4,484414,1200000,11744642,0,42.97,-0.26 +2026-09-12 01:18:57.278,36.8,27.173913,25.1,28.3,23.2,480531,1199998,11744642,0,42.25,6.19 +2026-09-12 01:18:59.352,37.2,26.88172,24.9,29,23.5,494922,1199999,11744642,0,40.61,12.47 +2026-09-12 01:19:01.368,35.9,27.855153,25.5,29.8,24.3,509469,1200000,11744642,0,38.16,18.31 +2026-09-12 01:19:03.473,35.6,28.089888,26.5,29.4,24.2,527654,1200000,11744642,0,34.76,23.96 +2026-09-12 01:19:05.515,33.8,29.585799,28.3,29.5,22.1,560325,1200000,11744642,0,30.68,28.93 +2026-09-12 01:19:07.607,32,31.25,31.3,31.2,25.9,609158,1199997,11744642,0,25.85,33.34 +2026-09-12 01:19:09.654,29.9,33.444816,31.2,33.5,18.4,631127,1199998,11744642,0,20.53,36.96 +2026-09-12 01:19:11.830,29.1,34.364261,31.6,36.2,17.5,656154,1199998,11744642,0,14.35,39.93 +2026-09-12 01:19:13.901,27.5,36.363636,34.4,36.6,16.2,697959,1199997,11744642,0,8.15,41.84 +2026-09-12 01:19:15.960,27.5,36.363636,34.8,36.8,16.5,719352,1199999,11744642,0,1.79,42.84 +2026-09-12 01:19:18.017,27.8,35.971223,33.2,36.6,16.4,708056,1200000,11744642,0,-4.7,42.92 +2026-09-12 01:19:20.068,27.9,35.842294,34.5,37.3,17,706098,1199999,11744642,0,-11.08,42.08 +2026-09-12 01:19:22.230,24.9,40.160643,39.2,40,17.1,707045,1200000,11744642,0,-17.57,40.22 +2026-09-12 01:19:24.333,23.7,42.194093,42.3,38.8,17.8,728429,1199999,11744642,0,-23.57,37.49 +2026-09-12 01:19:26.387,24,41.666667,42.9,39.3,16.7,692907,1200000,11744642,0,-28.94,34.05 diff --git a/docs/benchmarks/2026-09-12-iss-metal-lod-phone.json b/docs/benchmarks/2026-09-12-iss-metal-lod-phone.json new file mode 100644 index 0000000..eea1961 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-metal-lod-phone.json @@ -0,0 +1,116 @@ +{ + "date": "2026-09-12", + "status": "rejected_visual_quality", + "platform": "iPhone 17 Pro / Apple A19 Pro GPU / Metal / Release", + "os_version": "26.6.1", + "os_build": "23G83", + "source_splats": 9999999, + "hierarchy_nodes": 11744642, + "spatial_subdivisions": 10, + "gpu_refinement_rounds": 11, + "file": "iss_10M-depth10-sh1.lodsplat", + "file_bytes": 1174464264, + "sh_degree": 1, + "viewport": [ + 1206, + 2622 + ], + "render_scale": 1, + "lod_budget": 1200000, + "lod_pixel_limit": 1, + "min_pixel_radius": 1, + "tile_raster": false, + "orbit": { + "radius_m": 45, + "degrees_per_second": 4, + "start_degrees": 90, + "pivot": [ + 0, + -2, + -2 + ], + "up": [ + 1, + 0, + 0 + ], + "fov_y_degrees": 65 + }, + "measurement": { + "start": "2026-09-12 01:17:55.607", + "end": "2026-09-12 01:19:26.387", + "elapsed_seconds": 90.78, + "sample_count": 45, + "summary": { + "fps": { + "mean": 35.74666666666666, + "min": 23.7, + "max": 46.7 + }, + "frame_cadence_estimated_ms": { + "mean": 28.783827186276778, + "min": 21.41327623126338, + "max": 42.19409282700422 + }, + "gpu_render_command_ms": { + "mean": 27.719999999999995, + "min": 20.4, + "max": 42.9 + }, + "visibility_radix_command_ms": { + "mean": 28.84666666666666, + "min": 22, + "max": 40 + }, + "lod_command_ms": { + "mean": 17.322222222222226, + "min": 10.3, + "max": 25.9 + }, + "drawn": { + "mean": 584513.6222222223, + "min": 466383, + "max": 728429 + }, + "selected": { + "mean": 1199999.177777778, + "min": 1199997, + "max": 1200000 + } + }, + "timezone": "America/Argentina/Buenos_Aires", + "full_orbit_window": true, + "method": "Arithmetic means of 45 periodic console samples, about 2 seconds apart. FPS represents a roughly 0.5 s window; command durations are latest completed observations, not per-frame means. Loading and initial image capture are excluded.", + "timestamp_warning": "GPU render, visibility/radix and LOD command intervals can overlap and may refer to different in-flight frames. Do not add them or infer isolated radix cost.", + "frame_cadence_warning": "Estimated as 1000 / each rounded FPS log sample, then averaged.", + "thermal_state": null, + "memory_footprint_bytes": null + }, + "load": { + "decode_ms": 2385, + "tree_build_ms": 0, + "upload_ms": 1315 + }, + "validation": { + "launch_succeeded": true, + "capture_received": true, + "gpu_errors_in_captured_log": 0, + "user_visual_acceptance": false, + "visual_notes": "User confirmed compromised quality on device. Capture shows softened fine detail. No new identical-pose reference comparison performed on phone.", + "target_60_fps_met": false, + "memory_stability_validated": false + }, + "provenance": { + "git_head": "704dd1750969dcdccc1e81a650d3f0c0ec456405", + "dirty_worktree": true, + "local_app_executable_sha256": "77fd59d04e596d2960128a3ba29c57384daecbd5bd9431863a427ff948161e09", + "metal_lod_mm_sha256": "ef1055c8b4db4fb24208be891e7084e651bb9b1702a6116a1842048f760f15a1", + "splat_lod_metal_sha256": "ae7551e7d7fb7c7304f3b4bcc861b641a747f44d79866e7f355d7b613f0eec12" + }, + "artifacts": { + "console_log": "/tmp/splatkit-lod-phone.YD2Ad7/orbit.log", + "capture": "/tmp/splatkit-lod-phone.YD2Ad7/capture.png", + "samples_csv": "2026-09-12-iss-metal-lod-phone.csv" + }, + "launch_command": "xcrun devicectl device process launch --device D52700C0-C2DE-5D04-9FF2-EEDA9394F018 --terminate-existing --console com.splatkit.devapp --world iss_10M-depth10-sh1.lodsplat --sh 1 --budget 1200000 --scale 1 --radius 45 --speed 4 --start 90 --metal-culling 1 --min-pixel-radius 1 --tile-raster 0 --keep-awake 1 --capture 8" +} diff --git a/docs/benchmarks/2026-09-12-iss-metal-lod-sse.json b/docs/benchmarks/2026-09-12-iss-metal-lod-sse.json new file mode 100644 index 0000000..ce14d4a --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-metal-lod-sse.json @@ -0,0 +1,143 @@ +{ + "date": "2026-09-12", + "status": "experimental_quality_rejected", + "platform": "macOS / Apple M4 Pro / native Metal / Release", + "source_splats": 9999999, + "hierarchy_nodes": 11744642, + "interior_clusters": 1744643, + "file": "iss_10M-sse-depth10-sh1.lodsplat", + "file_version": 2, + "file_bytes": 1326121412, + "configuration": { + "sh_degree": 1, + "viewport": [ + 1206, + 2622 + ], + "camera_position": [ + 0, + -2, + 43 + ], + "camera_target": [ + 0, + -2, + -2 + ], + "camera_up": [ + 1, + 0, + 0 + ], + "fov_y_degrees": 65, + "capacity": 2200000, + "quality_pixels": 1, + "color_weight": 4, + "min_pixel_radius": 1, + "tile_raster": false + }, + "baseline_1200000": { + "drawn": 711835, + "warm_lod_command_ms": [ + 7.99, + 7.89 + ], + "image_mean_absolute_rgb_error_255": 6.708297545242682 + }, + "capacity_only_2200000": { + "drawn": 1160960, + "warm_lod_command_ms": [ + 11.56, + 11.62 + ], + "image_mean_absolute_rgb_error_255": 5.76451520682881 + }, + "sse_serial_group_scan": { + "selected": 2199983, + "drawn": 1996050, + "warm_lod_command_ms": [ + 7.92, + 7.86 + ], + "image_mean_absolute_rgb_error_255": 5.471003529686089 + }, + "sse_parallel_group_scan": { + "selected": 2199983, + "drawn": 1996050, + "evaluated_interiors": 971936, + "denied_refinements": 469486, + "frames": [ + { + "cold": true, + "lod_command_ms": 2.45, + "visibility_radix_command_ms": 30.43, + "render_command_ms": 32.68 + }, + { + "cold": false, + "lod_command_ms": 1.38, + "visibility_radix_command_ms": 4.18, + "render_command_ms": 15.03 + }, + { + "cold": false, + "lod_command_ms": 1.47, + "visibility_radix_command_ms": 4, + "render_command_ms": 15.36 + } + ], + "image_diff": { + "mean_absolute_rgb_error_255": 5.471056552983873, + "max_rgb_error_255": 219, + "fraction_pixels_above_2": 0.37247464685218706 + } + }, + "caveats": [ + "Two warm fixed-pose frames per configuration, not sustained FPS or A19 Pro proof.", + "Command intervals can overlap; do not sum or interpret visibility/radix as isolated radix.", + "SSE still exceeds available capacity; retained parents fail the requested quality threshold.", + "The 1/255 mean-error guard is diagnostic, not user-approved visual acceptance.", + "Thermal state and phone memory footprint were not measured." + ], + "build": { + "tool_reported_seconds": 19.24, + "wall_seconds": 19.55, + "mac_max_rss_bytes": 2501296128, + "mac_peak_footprint_bytes": 2785954264 + }, + "artifacts": { + "before_capture": "/tmp/iss-lod-refinement-before.png", + "capacity_only_capture": "/tmp/iss-lod-budget22-before.png", + "reference_capture": "/tmp/iss-reference-mac.png", + "final_capture": "/tmp/iss-lod-sse-parallel22.png", + "final_mac_log": "/tmp/splatkit-lod-sse-parallel22.log", + "offline_build_log": "/tmp/splatkit-lod-sse-offline-build.log" + }, + "provenance": { + "head": "704dd1750969dcdccc1e81a650d3f0c0ec456405", + "dirty": true, + "metal_lod_mm_sha256": "b377b0b0be41d0df23037d7ed2f5a55ed9074c7e1f47def5130ff3c4e5078f6c", + "shader_sha256": "5815c9079ff0ffe2b0a19f8b93a6a45700a2597f8b4cbffa0ff8fce62b164073", + "local_ios_executable_sha256": "f598cd6cc4f7a6ba4f015c5ae07c9768ba66596cc89d3132822ed708728c3a58" + }, + "phone": { + "status": "failed_short_run_signal_9_cause_unknown", + "device": "iPhone 17 Pro / Apple A19 Pro / iOS 26.6.1", + "log": "/tmp/splatkit-lod-sse-phone.Uf9Sfp/orbit.log", + "orbit_radius_m": 45, + "orbit_degrees_per_second": 4, + "sample_window_local": ["2026-09-12 01:52:21.774", "2026-09-12 01:52:32.370"], + "samples": [ + {"fps": 20.9, "gpu_ms": 51.0, "visibility_radix_ms": 56.4, "lod_ms": 4.6, "drawn": 1994309}, + {"fps": 9.0, "gpu_ms": 105.9, "visibility_radix_ms": 122.9, "lod_ms": 7.5, "drawn": 1993330}, + {"fps": 7.4, "gpu_ms": 87.7, "visibility_radix_ms": 124.3, "lod_ms": 6.8, "drawn": 1968254}, + {"fps": 8.5, "gpu_ms": 99.0, "visibility_radix_ms": 132.6, "lod_ms": 6.9, "drawn": 1980303} + ], + "caveats": [ + "Only four loaded-scene samples, including initial warmup; not a complete orbit or sustained benchmark.", + "Termination signal alone does not distinguish Jetsam, user closure or an external kill.", + "No matching September 12 Jetsam or SplatKit report found in device systemCrashLogs at 02:38 local.", + "Memory and thermal state were not captured in this run." + ] + } +} diff --git a/docs/benchmarks/2026-09-12-iss-metal-lod.json b/docs/benchmarks/2026-09-12-iss-metal-lod.json new file mode 100644 index 0000000..4f7296e --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-metal-lod.json @@ -0,0 +1,58 @@ +{ + "date": "2026-09-12", + "status": "experimental_quality_not_accepted", + "subsequent_phone_benchmark": "2026-09-12-iss-metal-lod-phone.json", + "platform": "macOS / Apple M4 Pro / Metal / Release", + "source_splats": 9999999, + "hierarchy_nodes": 11744642, + "spatial_subdivisions": 10, + "gpu_refinement_rounds": 11, + "file_bytes": 1174464264, + "sh_degree": 1, + "viewport": [1206, 2622], + "camera_position": [0, -2, 43], + "camera_target": [0, -2, -2], + "camera_up": [1, 0, 0], + "fov_y_degrees": 65, + "min_pixel_radius": 1, + "lod_pixel_limit": 1, + "lod_budget": 1200000, + "tile_raster": false, + "timestamp_warning": "Completed command-buffer intervals, not isolated pass costs; may overlap. Do not sum or derive FPS.", + "reference_frames": [ + {"cold": true, "drawn": 5359499, "sort_command_ms": 50.02, "render_command_ms": 61.61}, + {"cold": false, "drawn": 5359499, "sort_command_ms": 7.29, "render_command_ms": 31.00}, + {"cold": false, "drawn": 5359499, "sort_command_ms": 7.32, "render_command_ms": 32.60} + ], + "lod_frames": [ + {"cold": true, "selected": 1200000, "drawn": 711835, "lod_command_ms": 20.18, "sort_command_ms": 21.43, "render_command_ms": 16.43}, + {"cold": false, "selected": 1200000, "drawn": 711835, "lod_command_ms": 8.04, "sort_command_ms": 9.31, "render_command_ms": 9.00}, + {"cold": false, "selected": 1200000, "drawn": 711835, "lod_command_ms": 8.31, "sort_command_ms": 9.48, "render_command_ms": 9.05} + ], + "image_diff": { + "mean_absolute_rgb_error_255": 6.7082468410553382, + "max_rgb_error_255": 219, + "pixels_above_2": 1341969, + "fraction_pixels_above_2": 0.42438740697731786, + "interpretation": "Visible loss of fine panel and label detail; threshold is diagnostic, not agreed perceptual acceptance." + }, + "artifacts": { + "reference_log": "/tmp/splatkit-lod-reference-mac.log", + "lod_log": "/tmp/splatkit-lod-priority-radius1-mac.log", + "reference_image": "/tmp/iss-reference-mac.png", + "lod_image": "/tmp/iss-lod-priority-radius1-mac.png", + "depth6_build_log": "/tmp/splatkit-lod-iss-build.log", + "depth10_build_log": "/tmp/splatkit-lod-iss-depth10.log", + "phone_launch_log": "/tmp/splatkit-lod-orbit-phone.log" + }, + "phone": { + "device": "iPhone 17 Pro / A19 Pro", + "app_installed": true, + "asset_copied": true, + "launch_succeeded": false, + "blocker": "Device locked", + "fps": null, + "memory_footprint": null, + "sustained_orbit_completed": false + } +} diff --git a/docs/benchmarks/2026-09-12-iss-prepared-start.json b/docs/benchmarks/2026-09-12-iss-prepared-start.json new file mode 100644 index 0000000..2f13664 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-prepared-start.json @@ -0,0 +1,68 @@ +{ + "date": "2026-09-12", + "status": "startup_sequence_verified_quality_unresolved", + "device": "iPhone 17 Pro / Apple A19 Pro / iOS 26.6.1", + "world": "iss_10M-sse-depth10-sh1.lodsplat", + "configuration": { + "loaded": 9999999, + "resident_nodes": 11744642, + "selection_capacity": 2200000, + "sh": 1, + "viewport": [1206, 2622], + "scale": 1, + "radius_m": 45, + "speed_degrees_per_second": 4, + "start_degrees": 90, + "min_pixel_radius": 1, + "tile_raster": false, + "run_seconds_after_ready": 20, + "capture_delay_from_start_seconds": 8 + }, + "timings": { + "decode_ms": 884, + "upload_ms": 1227, + "upload_ready_since_session_preparation_seconds": 2.143, + "first_frame_ready_since_session_preparation_seconds": 2.216, + "upload_ready_local": "2026-09-12 03:00:53.682", + "first_frame_ready_local": "2026-09-12 03:00:53.755", + "orbit_start_local": "2026-09-12 03:00:53.755", + "duration_stop_local": "2026-09-12 03:01:13.833" + }, + "verification": { + "ordered_upload_frame_orbit_stop_events": "passed", + "first_camera_angle_degrees": 3.7971354486932296, + "expected_degrees_since_ready": 4.043999671936035, + "camera_time_tolerance_degrees": 1, + "native_readiness_gpu_test": "passed, including replacement reset and background exclusion", + "metal_debug_shader_validation": "readiness GPU test passed", + "full_mac_ctest": {"total": 195, "passed": 191, "skipped": 4, "failed": 0}, + "release_ios_and_app_build": "passed", + "gpu_errors_in_phone_log": 0, + "stop_reason": "duration-complete", + "sampled_peak_process_bytes": 2801370968, + "thermal_state": "nominal in sampled run", + "render_capture": "ISS visible; simplified LOD details still visible; no quality acceptance" + }, + "caveats": [ + "Elapsed preparation begins with SplatSession preparation, not process launch.", + "Repeated loading may benefit from warm filesystem/shader caches; this is not evidence of reduced decoding cost.", + "One short smoke run with a PNG capture, not the full-orbit performance reference.", + "The native GPU capture excludes SwiftUI, so the loading-cover appearance was not independently screenshot-validated.", + "Readiness waits for successful GPU work, not screen scanout or exact LOD quality.", + "LOD selection, parent data, SH degree and tile mode were not changed by this startup fix." + ], + "provenance": { + "head": "704dd1750969dcdccc1e81a650d3f0c0ec456405", + "dirty": true, + "ios_executable_sha256": "1abf49741b423b421bcf11f3f19f7cd96a275f84b3c9af6efaf022422031efa7", + "metal_renderer_sha256": "ad9f06c91038e210103f7bcdf9d22bc9ddea0fbfa8de21e147aea217c6e0cf2e", + "objc_engine_sha256": "dde324b2ee6d0bfe73225dece9dbccef4e86c15d3d87eb843924a54917d0def3", + "session_swift_sha256": "ddf8e7e690f40e5c392873de01f0f380a3c9a1fb42c3f5dd775b9e94ce3c444d" + }, + "artifacts": { + "phone_log": "/tmp/splatkit-stability.k0lBJw/iss-prepared.log", + "native_capture": "/tmp/splatkit-stability.k0lBJw/prepared-capture.png", + "ctest_log": "/tmp/splatkit-stability.k0lBJw/final-ctest.log", + "app_build_log": "/tmp/splatkit-stability.k0lBJw/preparation-app-build.log" + } +} diff --git a/docs/benchmarks/2026-09-12-iss-radix16-resources.csv b/docs/benchmarks/2026-09-12-iss-radix16-resources.csv new file mode 100644 index 0000000..2e9eecb --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-radix16-resources.csv @@ -0,0 +1,49 @@ +elapsed,footprint,peak,available,metal,thermal,loaded,drawn,paused +-1,1408731256,1408731256,2131261320,48578560,0,0,0,0 +-1,1381435776,1408731256,2158556800,48578560,0,0,0,0 +-1,1533414072,1533414072,2006578504,48578560,0,0,0,0 +-1,1545161448,1545161448,1994831128,48578560,0,0,0,0 +-1,1544652608,1545161448,1995339968,48578560,0,0,0,0 +-1,2337376064,2337376064,1202616512,1024950272,0,0,0,0 +-1,2337408832,2337408832,1202583744,1024950272,0,0,0,0 +0.364,2827028288,2827028288,712964288,1063256064,0,9999999,0,0 +0.864,2827044672,2827044672,712947904,1063256064,0,9999999,1995325,0 +1.364,2715502328,2827044672,824490248,1063256064,0,9999999,1992869,0 +1.864,2205320640,2827044672,1334671936,1063256064,0,9999999,1992321,0 +2.364,2205320640,2827044672,1334671936,1063256064,0,9999999,1996553,0 +2.864,1347289936,2827044672,2192702640,1063256064,0,9999999,1992374,0 +3.364,1347306320,2827044672,2192686256,1063256064,0,9999999,1994938,0 +3.864,1347306320,2827044672,2192686256,1063256064,0,9999999,1996326,0 +4.364,1347306320,2827044672,2192686256,1063256064,0,9999999,1993136,0 +4.864,1362707304,2827044672,2177285272,1063256064,0,9999999,1997322,0 +5.364,1362707304,2827044672,2177285272,1063256064,0,9999999,1994495,0 +5.864,1362150248,2827044672,2177842328,1063256064,0,9999999,1993349,0 +6.364,1362150248,2827044672,2177842328,1063256064,0,9999999,1992863,0 +6.863,1361560424,2827044672,2178432152,1063256064,0,9999999,1995544,0 +7.364,1361560424,2827044672,2178432152,1063256064,0,9999999,1995544,0 +7.864,1361396584,2827044672,2178595992,1063256064,0,9999999,1995121,0 +8.364,1361560424,2827044672,2178432152,1063256064,0,9999999,1993551,0 +8.864,1361560424,2827044672,2178432152,1063256064,0,9999999,1995359,0 +9.364,1361609576,2827044672,2178383000,1063256064,0,9999999,1982299,0 +9.864,1361609576,2827044672,2178383000,1063256064,0,9999999,1978097,0 +10.364,1361609576,2827044672,2178383000,1063256064,0,9999999,1969803,0 +10.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1965827,0 +11.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1981260,0 +11.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1983751,0 +12.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1980991,0 +12.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1979538,0 +13.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1975078,0 +13.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1970493,0 +14.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1971236,0 +14.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1970911,0 +15.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1968530,0 +15.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1968895,0 +16.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1972167,0 +16.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1976806,0 +17.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1973784,0 +17.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1974230,0 +18.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1975202,0 +18.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1977872,0 +19.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1977673,0 +19.864,1361544040,2827044672,2178448536,1063256064,0,9999999,1977049,0 +20.364,1361544040,2827044672,2178448536,1063256064,0,9999999,1975531,0 diff --git a/docs/benchmarks/2026-09-12-iss-radix16.csv b/docs/benchmarks/2026-09-12-iss-radix16.csv new file mode 100644 index 0000000..909c229 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-radix16.csv @@ -0,0 +1,11 @@ +time_local,fps,estimated_frame_ms,gpu_ms,sort_ms,lod_ms,drawn,selected +2026-09-12 03:33:22.695,25.8,38.75968992248062,47.4,17.4,12.3,1992869,2199996 +2026-09-12 03:33:24.858,25.3,39.52569169960474,47.4,18.4,11.2,1994938,2199973 +2026-09-12 03:33:26.971,24.4,40.98360655737705,41.1,43.2,4.2,1994495,2199982 +2026-09-12 03:33:29.100,22.2,45.04504504504504,53.1,23.6,13.3,1995121,2199945 +2026-09-12 03:33:31.133,21.5,46.51162790697674,54.8,23.1,13.9,1978097,2199982 +2026-09-12 03:33:33.160,21.8,45.87155963302752,53.9,24.1,13,1983751,2199999 +2026-09-12 03:33:35.247,22.2,45.04504504504504,51.9,23.1,13.9,1970493,2199998 +2026-09-12 03:33:37.317,22.8,43.859649122807014,52.5,21.1,13.7,1968895,2199964 +2026-09-12 03:33:39.421,23.6,42.3728813559322,49.7,21.1,13.3,1974230,2199996 +2026-09-12 03:33:41.511,24,41.666666666666664,48.6,21.2,13.4,1977049,2199994 diff --git a/docs/benchmarks/2026-09-12-iss-radix16.json b/docs/benchmarks/2026-09-12-iss-radix16.json new file mode 100644 index 0000000..0df3ef7 --- /dev/null +++ b/docs/benchmarks/2026-09-12-iss-radix16.json @@ -0,0 +1,293 @@ +{ + "date": "2026-09-12", + "status": "experimental_faster_short_phone_run_below_30fps_quality_unapproved", + "configuration": { + "world": "iss_10M-sse-depth10-sh1.lodsplat", + "loaded_splats": 9999999, + "resident_nodes": 11744642, + "selected_capacity": 2200000, + "sh": 1, + "viewport": [ + 1206, + 2622 + ], + "render_scale": 1, + "min_pixel_radius": 1, + "tile_raster": true, + "orbit_axis": "X", + "orbit_horizontal": false, + "radius_m": 45, + "speed_degrees_s": 4, + "start_degrees": 90, + "near_m": 0.05, + "far_m": 200, + "duration_s": 20 + }, + "implementation": { + "key_type": "ushort linear camera depth, stored in uint32", + "radix_passes": 2, + "radix_digit_bits": 8, + "default_depth_key_bits": 32, + "changed_lod": false, + "changed_culling": false + }, + "phone": { + "executable_sha256": "c7c4fd1df32d87bd0a460aa559cb234600a3a1b5580eb8ec4e89aeee14ed3911", + "log": "/tmp/splatkit-stability.k0lBJw/iss-radix16-phone.log", + "image": "/tmp/splatkit-stability.k0lBJw/radix16-phone.png", + "run_stopped": "duration-complete", + "samples": [ + { + "time_local": "2026-09-12 03:33:22.695", + "fps": 25.8, + "estimated_frame_ms": 38.75968992248062, + "gpu_ms": 47.4, + "sort_ms": 17.4, + "lod_ms": 12.3, + "drawn": 1992869, + "selected": 2199996 + }, + { + "time_local": "2026-09-12 03:33:24.858", + "fps": 25.3, + "estimated_frame_ms": 39.52569169960474, + "gpu_ms": 47.4, + "sort_ms": 18.4, + "lod_ms": 11.2, + "drawn": 1994938, + "selected": 2199973 + }, + { + "time_local": "2026-09-12 03:33:26.971", + "fps": 24.4, + "estimated_frame_ms": 40.98360655737705, + "gpu_ms": 41.1, + "sort_ms": 43.2, + "lod_ms": 4.2, + "drawn": 1994495, + "selected": 2199982 + }, + { + "time_local": "2026-09-12 03:33:29.100", + "fps": 22.2, + "estimated_frame_ms": 45.04504504504504, + "gpu_ms": 53.1, + "sort_ms": 23.6, + "lod_ms": 13.3, + "drawn": 1995121, + "selected": 2199945 + }, + { + "time_local": "2026-09-12 03:33:31.133", + "fps": 21.5, + "estimated_frame_ms": 46.51162790697674, + "gpu_ms": 54.8, + "sort_ms": 23.1, + "lod_ms": 13.9, + "drawn": 1978097, + "selected": 2199982 + }, + { + "time_local": "2026-09-12 03:33:33.160", + "fps": 21.8, + "estimated_frame_ms": 45.87155963302752, + "gpu_ms": 53.9, + "sort_ms": 24.1, + "lod_ms": 13, + "drawn": 1983751, + "selected": 2199999 + }, + { + "time_local": "2026-09-12 03:33:35.247", + "fps": 22.2, + "estimated_frame_ms": 45.04504504504504, + "gpu_ms": 51.9, + "sort_ms": 23.1, + "lod_ms": 13.9, + "drawn": 1970493, + "selected": 2199998 + }, + { + "time_local": "2026-09-12 03:33:37.317", + "fps": 22.8, + "estimated_frame_ms": 43.859649122807014, + "gpu_ms": 52.5, + "sort_ms": 21.1, + "lod_ms": 13.7, + "drawn": 1968895, + "selected": 2199964 + }, + { + "time_local": "2026-09-12 03:33:39.421", + "fps": 23.6, + "estimated_frame_ms": 42.3728813559322, + "gpu_ms": 49.7, + "sort_ms": 21.1, + "lod_ms": 13.3, + "drawn": 1974230, + "selected": 2199996 + }, + { + "time_local": "2026-09-12 03:33:41.511", + "fps": 24, + "estimated_frame_ms": 41.666666666666664, + "gpu_ms": 48.6, + "sort_ms": 21.2, + "lod_ms": 13.4, + "drawn": 1977049, + "selected": 2199994 + } + ], + "mean": { + "fps": { + "mean": 23.36, + "min": 21.5, + "max": 25.8 + }, + "estimated_frame_ms": { + "mean": 42.96414629549626, + "min": 38.75968992248062, + "max": 46.51162790697674 + }, + "gpu_ms": { + "mean": 50.04, + "min": 41.1, + "max": 54.8 + }, + "sort_ms": { + "mean": 23.629999999999995, + "min": 17.4, + "max": 43.2 + }, + "lod_ms": { + "mean": 12.220000000000002, + "min": 4.2, + "max": 13.9 + }, + "drawn": { + "mean": 1982993.8, + "min": 1968895, + "max": 1995121 + }, + "selected": { + "mean": 2199982.9, + "min": 2199945, + "max": 2199999 + } + }, + "tiles": [ + { + "compute": 5257, + "nonempty": 589, + "hardware": 7207, + "invalid": 0 + }, + { + "compute": 5257, + "nonempty": 589, + "hardware": 7207, + "invalid": 0 + }, + { + "compute": 5279, + "nonempty": 597, + "hardware": 7185, + "invalid": 0 + }, + { + "compute": 4841, + "nonempty": 311, + "hardware": 7623, + "invalid": 0 + }, + { + "compute": 2999, + "nonempty": 212, + "hardware": 9465, + "invalid": 0 + }, + { + "compute": 3929, + "nonempty": 650, + "hardware": 8535, + "invalid": 0 + } + ], + "sampled_peak_bytes": 2827044672, + "steady_footprint_bytes": 1361544040, + "metal_allocated_bytes": 1063256064, + "thermal_states": [ + 0 + ] + }, + "baseline": { + "source": "2026-09-12-iss-lod-hybrid.json", + "depth_key_bits": 32, + "sample_count": 9, + "mean": { + "fps": 18.92222222222222, + "estimated_frame_ms": 53.01134231344636, + "gpu_ms": 47.56666666666666, + "sort_ms": 57.522222222222226, + "lod_ms": 3.7555555555555555, + "drawn": 1983640.6666666667 + } + }, + "mac": { + "selected": 2199983, + "drawn": 1996050, + "cull_sort_warm_ms": { + "bits32": [ + 3.94, + 4.01 + ], + "bits16": [ + 3.62, + 3.35 + ] + }, + "raster_warm_ms": { + "bits32": [ + 53.74, + 23.45 + ], + "bits16": [ + 107.4, + 21.38 + ] + }, + "image_comparison": { + "width": 1206, + "height": 2622, + "mean_absolute_rgb_error_255": 0.1007451091014965, + "max_rgb_error_255": 101, + "pixels_above_2": 28013, + "fraction_pixels_above_2": 0.00885889646605518 + }, + "artifacts": [ + "/tmp/splatkit-stability.k0lBJw/radix16-hybrid-mac.log", + "/tmp/splatkit-stability.k0lBJw/radix32-hybrid-mac.log", + "/tmp/splatkit-stability.k0lBJw/radix16-hybrid-mac.png", + "/tmp/splatkit-stability.k0lBJw/radix32-hybrid-mac.png" + ] + }, + "validation": { + "full_mac": { + "total": 198, + "passed": 194, + "skipped": 4, + "failed": 0 + }, + "metal_api_and_shader_validation": "two-pass stable sort and quantized visibility GPU tests passed" + }, + "caveats": [ + "Sequential short orbit samples, not randomized or sustained results.", + "Arithmetic means of periodic snapshots; frame cadence inferred from rounded FPS.", + "GPU, visibility/radix and LOD command intervals overlap and include scheduling contention; do not sum or interpret as isolated pass costs.", + "Mac test has only two warm frames and severe raster timing outliers; not a throughput benchmark.", + "Quantized ties can reorder transparency and vary with parallel compaction; quality unapproved.", + "Phone captures are timed from session creation and do not have identical camera poses.", + "Memory is sampled at 2 Hz; guard cannot cancel work in flight.", + "Subsequent horizontal camera framing is a separate change and not included in these metrics." + ] +} diff --git a/packages/splat-core/CMakeLists.txt b/packages/splat-core/CMakeLists.txt index e470281..0d49490 100644 --- a/packages/splat-core/CMakeLists.txt +++ b/packages/splat-core/CMakeLists.txt @@ -7,7 +7,7 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_POSITION_INDEPENDENT_CODE ON) option(SPLAT_CORE_BUILD_TESTS "Build splat_core unit tests" ${PROJECT_IS_TOP_LEVEL}) -option(SPLAT_CORE_BUILD_TOOLS "Build the ply2spz converter" ${PROJECT_IS_TOP_LEVEL}) +option(SPLAT_CORE_BUILD_TOOLS "Build ply2spz, splat-tile and splat_lod_build" ${PROJECT_IS_TOP_LEVEL}) # "thread" or "address" (address implies undefined). Off by default; CI runs both. set(SPLAT_CORE_SANITIZE "" CACHE STRING "Sanitizer for splat_core and its tests") @@ -35,10 +35,20 @@ add_library(splat_core STATIC src/sorting/WorkerPool.cpp src/math/SymmetricEigen.cpp src/lod/LodTree.cpp + src/lod/LodSelection.cpp + src/lod/LodFile.cpp src/io/MappedFile.cpp src/loading/SplatWorldLoader.cpp src/sorting/VisibilityPlanner.cpp src/diagnostics/TimingSummary.cpp + src/tiles/Tileset.cpp + src/tiles/TileBuilder.cpp + src/tiles/SlabAllocator.cpp + src/tiles/TileScheduler.cpp + src/tiles/TileLoader.cpp + src/tiles/TiledWorld.cpp + src/tiles/TileStreamer.cpp + src/sorting/SlabSorter.cpp ) target_include_directories(splat_core PUBLIC include) find_package(ZLIB REQUIRED) # the SPZ gzip container is inflated here, with a ceiling diff --git a/packages/splat-core/README.md b/packages/splat-core/README.md index c286bf6..a728223 100644 --- a/packages/splat-core/README.md +++ b/packages/splat-core/README.md @@ -32,4 +32,7 @@ build/tools/ply2spz bicycle.ply bicycle.spz --sh 1 --keep 2 ``` `--sh N` keeps harmonics up to degree N (degree 3 costs 92 bytes per splat on the GPU) and `--keep N` keeps every Nth splat, for scenes too big for a phone. +`--prune-alpha T` drops the splats whose stored opacity is below T: at `1/255` (0.0039) only what draws nothing goes, so the picture is unchanged, and any higher value trades the faintest layers for speed, a choice to make with captures side by side. +Neither tool prunes unless asked; the default output is the whole scene. +`tools/splat-tile` takes the same `--sh` and `--prune-alpha` before partitioning a scene into streamed tiles. The coordinates are written as they are; the reference 3DGS frame is what the decoder assumes for a file without a frame tag, so the scene stands upright. diff --git a/packages/splat-core/include/splat/formats/SplatDecoder.h b/packages/splat-core/include/splat/formats/SplatDecoder.h index 3b0621f..cceea76 100644 --- a/packages/splat-core/include/splat/formats/SplatDecoder.h +++ b/packages/splat-core/include/splat/formats/SplatDecoder.h @@ -22,8 +22,10 @@ struct SplatDecodeOptions { // Frame the file was written in when the format does not tag it. Formats that do // carry a frame ignore this. CoordinateFrame sourceFrame = kWorldLabsFrame; + // Highest spherical-harmonics degree to materialize in memory. This does not modify the file. + int maxShDegree = 3; // Largest decompressed payload accepted; see SpzDecodeOptions. - std::size_t maxDecodedBytes = 256u * 1024u * 1024u; + std::size_t maxDecodedBytes = 768u * 1024u * 1024u; }; // The one entry point renderers call: detects the container and hands the bytes to its diff --git a/packages/splat-core/include/splat/formats/SpzDecoder.h b/packages/splat-core/include/splat/formats/SpzDecoder.h index 9c6dd5d..15fab1d 100644 --- a/packages/splat-core/include/splat/formats/SpzDecoder.h +++ b/packages/splat-core/include/splat/formats/SpzDecoder.h @@ -12,11 +12,15 @@ namespace splat { struct SpzDecodeOptions { // Frame the file was written in. World Labs does not tag it, so the caller declares it. CoordinateFrame sourceFrame = kWorldLabsFrame; + // Highest spherical-harmonics degree to materialize in the decoded cloud. The SPZ file is + // kept intact; this only drops higher bands from the runtime representation. Mobile callers + // can use 0 or 1 to avoid allocating the degree-2/3 coefficients. + int maxShDegree = 3; // Largest decompressed payload accepted: gzip containers stop inflating at it, NGSP // containers are refused from their header. Guards against decompression bombs when - // files come from the network. The default fits 2M splats with SH degree 3 (about - // 128 MB) with room to spare. - std::size_t maxDecodedBytes = 256u * 1024u * 1024u; + // files come from the network. The default fits a 10M-splat SH degree 3 world + // (about 640 MB of packed data) with room to spare. + std::size_t maxDecodedBytes = 768u * 1024u * 1024u; }; // Decodes an SPZ container (v1 to v4; gzip or zstd) into a SplatCloud in the internal frame. diff --git a/packages/splat-core/include/splat/loading/SplatWorldLoader.h b/packages/splat-core/include/splat/loading/SplatWorldLoader.h index 952cc34..02715eb 100644 --- a/packages/splat-core/include/splat/loading/SplatWorldLoader.h +++ b/packages/splat-core/include/splat/loading/SplatWorldLoader.h @@ -11,6 +11,7 @@ #include "splat/formats/SplatCloud.h" #include "splat/lod/LodTree.h" #include "splat/navigation/Collider.h" +#include "splat/tiles/TiledWorld.h" namespace splat { @@ -26,6 +27,7 @@ class SplatWorldLoader { struct World { std::unique_ptr cloud; std::shared_ptr tree; + std::unique_ptr tiles; // a tiled world: neither cloud nor tree (ADR 0015) int budget = 0; std::size_t sourceCount = 0; // splats in the file, what hosts count @@ -37,6 +39,7 @@ class SplatWorldLoader { int shDegree = 0; Bounds bounds; std::size_t nodeCount = 0; // 0 without a tree + std::size_t tileCount = 0; // 0 unless tiled double decodeMillis = 0; double reorderMillis = 0; double treeMillis = 0; @@ -52,9 +55,16 @@ class SplatWorldLoader { void setBudget(int budget); int budget() const { return budget_.load(); } + // Highest SH degree materialized for worlds loaded from now on. The source SPZ remains full. + void setMaxShDegree(int degree); + int maxShDegree() const { return maxShDegree_.load(); } + // Errors leave whatever was waiting untouched. Result loadWorld(const std::uint8_t* data, std::size_t size); Result loadWorldFile(const std::string& path); + // A tiled world from its index (a tileset.json next to its tiles). Only the index is + // read here; tiles stream in as the camera needs them. + Result loadTiledWorldFile(const std::string& path); Result loadCollider(const std::uint8_t* data, std::size_t size); Result loadColliderFile(const std::string& path); @@ -64,6 +74,7 @@ class SplatWorldLoader { private: std::atomic budget_{0}; + std::atomic maxShDegree_{3}; std::mutex mutex_; std::unique_ptr pendingWorld_; std::unique_ptr pendingCollider_; diff --git a/packages/splat-core/include/splat/lod/LodFile.h b/packages/splat-core/include/splat/lod/LodFile.h new file mode 100644 index 0000000..a34bdd9 --- /dev/null +++ b/packages/splat-core/include/splat/lod/LodFile.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +#include "splat/core/Result.h" +#include "splat/lod/LodTree.h" + +namespace splat { + +// Version 1: little-endian IEEE float32, internal RUB coordinates, lossless attributes. +// 64-byte header, followed by fixed records: LodNode, covariance[6], RGB[3], alpha, +// then higher-order RGB SH coefficients. No platform/GPU record layout on disk. +bool isLodSplat(const uint8_t* data, size_t size); +Result decodeLodSplat(const uint8_t* data, size_t size, int maxShDegree = 3); +// Refuses to overwrite an existing file. Validates the tree before opening the output. +Result writeLodSplat(const LodTree& tree, const std::string& path); +// Checks topology, attribute dimensions, finite values and covariance; returns depth +// (number of refinement rounds). Children are contiguous, BFS ordered, reachable once. +Result validateLodTree(const LodTree& tree); + +} // namespace splat diff --git a/packages/splat-core/include/splat/lod/LodTree.h b/packages/splat-core/include/splat/lod/LodTree.h index 1b70937..1321e5e 100644 --- a/packages/splat-core/include/splat/lod/LodTree.h +++ b/packages/splat-core/include/splat/lod/LodTree.h @@ -28,11 +28,29 @@ struct LodNode { }; static_assert(sizeof(LodNode) == 24, "LodNode is packed for the selection walk"); +// Interior-only traversal record. Leaves are emitted in packets, not evaluated as nodes. +// Bounds enclose descendant supports. Error is a conservative merge-disagreement heuristic +// in world units, not a certified image-space error bound. +struct LodCluster { + float center[3]{}, radius = 0; + float extent[3]{}, error = 0; + float colorVariance = 0, opacity = 0; + uint32_t node = 0, childStart = 0, childCount = 0, leafStart = 0; + uint32_t leafCount = 0, subtreeLeaves = 0; +}; +static_assert(sizeof(LodCluster) == 64); + +struct LodSelectionData { + std::vector clusters; + std::vector leaves; +}; + struct LodTree { // Every node, root first, then level by level; the leaves keep their attributes. SplatCloud nodes; std::vector layout; std::size_t leafCount = 0; + LodSelectionData selection; std::size_t nodeCount() const { return layout.size(); } }; @@ -41,11 +59,17 @@ struct LodBuildOptions { // Ratio between the cell sizes of consecutive levels. 1.5 merges gently: most // interior nodes have 2 to 4 children and the tree is about 1.5 times the leaves. float base = 1.5f; + // Offline octree: 1..10 spatial subdivisions (clamped); 0 keeps the legacy grid. + // Original splats sit below the finest occupied cells. Singleton chains collapse. + uint32_t octreeDepth = 0; }; // Builds the tree. The cloud is consumed: its splats become the leaves. LodTree buildLodTree(SplatCloud cloud, const LodBuildOptions& options = {}); +// Offline metadata construction; requires a validated hierarchy. Does not alter splats. +LodSelectionData buildLodSelectionData(const LodTree& tree); + // Where the budget should go. Nodes within `fullCosine` of the forward direction count // at their screen size; from there to 90 degrees the weight falls linearly to // `behindWeight`, which holds behind the camera. Detail concentrates where the camera diff --git a/packages/splat-core/include/splat/math/Frustum.h b/packages/splat-core/include/splat/math/Frustum.h index e6e3566..106bc2a 100644 --- a/packages/splat-core/include/splat/math/Frustum.h +++ b/packages/splat-core/include/splat/math/Frustum.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include "splat/math/Vec3.h" @@ -36,6 +37,23 @@ struct Frustum { return half >= 1.5533f ? kOpen : std::tan(half); // 89 degrees } + // True when an axis aligned box may overlap the view volume: it is not wholly past any + // of the five bounding planes. Conservative near the corners, which suits a cull. + bool intersects(const std::array& min, const std::array& max) const { + const Vec3 normals[5] = {-forward, right - forward * tanHalfX, -right - forward * tanHalfX, + up - forward * tanHalfY, -up - forward * tanHalfY}; + for (const Vec3& n : normals) { + bool allOutside = true; + for (int corner = 0; corner < 8 && allOutside; ++corner) { + const Vec3 p{(corner & 1) ? max[0] : min[0], (corner & 2) ? max[1] : min[1], + (corner & 4) ? max[2] : min[2]}; + if (dot(p - origin, n) <= 0.0f) allOutside = false; + } + if (allOutside) return false; + } + return true; + } + // True when the point is in front of the camera and inside the widened field of view. bool contains(Vec3 p) const { const Vec3 d = p - origin; diff --git a/packages/splat-core/include/splat/sorting/DistanceSorter.h b/packages/splat-core/include/splat/sorting/DistanceSorter.h index b70aa71..3ab838f 100644 --- a/packages/splat-core/include/splat/sorting/DistanceSorter.h +++ b/packages/splat-core/include/splat/sorting/DistanceSorter.h @@ -22,6 +22,10 @@ class DistanceSorter { std::size_t count() const { return positions_.size() / 3; } + // Overwrites the positions of splats [first, first + n) with `xyz`, for a slab that + // tiles land in. Not concurrent with a sort or a cull. + void place(std::size_t first, const float* xyz, std::size_t n); + // Fills `order` with every splat index, farthest first. void sort(Vec3 from, std::vector& order); diff --git a/packages/splat-core/include/splat/sorting/SlabSorter.h b/packages/splat-core/include/splat/sorting/SlabSorter.h new file mode 100644 index 0000000..5a90a4c --- /dev/null +++ b/packages/splat-core/include/splat/sorting/SlabSorter.h @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "splat/math/Frustum.h" +#include "splat/sorting/DistanceSorter.h" + +namespace splat { + +// The sorter of a tiled world: a DistanceSorter over the slab every resident tile lives +// in, run on its own thread. Tiles place their positions as they land; each request names +// the ranges to draw and the frustum, and the thread sorts those ranges back to front +// when the camera moved or the ranges changed, and otherwise only culls the order it has +// (ADR 0009). Indices in a result are slab indices, what the draw order buffer holds. +class SlabSorter { + public: + struct Range { + std::uint32_t offset; + std::uint32_t count; + bool operator==(const Range& o) const { return offset == o.offset && count == o.count; } + }; + struct Result { + std::vector order; // the visible splats of the ranges, back to front + double sortMillis = 0; // the most recent sort, which this order may reuse + double cullMillis = 0; + std::size_t sorted = 0; // splats in the ranges sorted + std::uint64_t request = 0; // the requestVisible this order answers + }; + + explicit SlabSorter(std::uint32_t capacity); + ~SlabSorter(); + + SlabSorter(const SlabSorter&) = delete; + SlabSorter& operator=(const SlabSorter&) = delete; + + // Positions (xyz per splat) of a tile that landed at `offset`. Applied before the next sort. + void place(std::uint32_t offset, std::vector positions); + // Schedules the visible order of these ranges from this camera. Returns the id of the + // request, which the result carries; a newer request replaces one not started yet. + std::uint64_t requestVisible(const Frustum& frustum, std::vector ranges); + // The newest finished order not yet taken, if any. + std::optional take(); + + private: + struct Placement { + std::uint32_t offset; + std::vector positions; + }; + struct Request { + Frustum frustum; + std::vector ranges; + std::uint64_t id = 0; + }; + void run(); + + DistanceSorter sorter_; + std::thread thread_; + std::mutex mutex_; + std::condition_variable wake_; + bool stop_ = false; + std::vector placements_; + std::optional pending_; + std::uint64_t requests_ = 0; + std::optional finished_; + // Worker thread only. + std::vector sorted_; + std::optional sortedFrom_; + std::vector sortedRanges_; + double lastSortMillis_ = 0; +}; + +} // namespace splat diff --git a/packages/splat-core/include/splat/tiles/SlabAllocator.h b/packages/splat-core/include/splat/tiles/SlabAllocator.h new file mode 100644 index 0000000..c1d5aab --- /dev/null +++ b/packages/splat-core/include/splat/tiles/SlabAllocator.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include + +namespace splat { + +// Hands out ranges of one fixed size buffer, the slab every resident tile lives in on the +// GPU. Best fit over a free list that merges neighbours, so a tile dropped next to a +// free range grows it. Not thread safe. +class SlabAllocator { + public: + explicit SlabAllocator(std::uint32_t capacity); + + std::uint32_t capacity() const { return capacity_; } + std::uint32_t used() const { return used_; } + + // The offset of a free range of `count`, or nothing when none fits. + std::optional allocate(std::uint32_t count); + // Returns a range obtained from `allocate`. + void release(std::uint32_t offset, std::uint32_t count); + + private: + std::uint32_t capacity_; + std::uint32_t used_ = 0; + std::map free_; // offset to count, disjoint, never touching +}; + +} // namespace splat diff --git a/packages/splat-core/include/splat/tiles/TileBuilder.h b/packages/splat-core/include/splat/tiles/TileBuilder.h new file mode 100644 index 0000000..7bdd3bb --- /dev/null +++ b/packages/splat-core/include/splat/tiles/TileBuilder.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include "splat/core/Result.h" +#include "splat/tiles/Tileset.h" + +namespace spz { +struct GaussianCloud; +} + +namespace splat { + +// How a level above the leaves is made from the tiles below it, per grid cell. +enum class Coarsening : std::uint8_t { + // One new splat per cell that covers its members: their weighted centre and colour, + // a covariance spanning them (Kerbl et al. 2024). Complete, but blurs into blobs. + merge, + // The member that contributes most to the image stands for the cell, as it is but + // grown to cover the members' area. Keeps edges and colours sharp; drops the rest. + select, +}; + +struct TileBuildOptions { + // The most splats a tile holds, at any level. Leaves split until they fit; each level + // above coarsens the tiles below it back down to this many. + std::uint32_t tileSplats = 262144; + Coarsening coarsening = Coarsening::merge; +}; + +// Partitions a cloud into an octree of tiles and builds every level above the leaves by +// merging, offline (ADR 0015). Writes one spz file per tile and `tileset.json` into +// `directory`, which must exist. The cloud is consumed. Its coordinates are written as +// they are, so a tiled world stands in the frame of the file it came from. +Result buildTiles(const spz::GaussianCloud& cloud, const std::string& directory, + const TileBuildOptions& options = {}); + +} // namespace splat diff --git a/packages/splat-core/include/splat/tiles/TileLoader.h b/packages/splat-core/include/splat/tiles/TileLoader.h new file mode 100644 index 0000000..9d6beee --- /dev/null +++ b/packages/splat-core/include/splat/tiles/TileLoader.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "splat/core/Result.h" +#include "splat/formats/SplatCloud.h" +#include "splat/formats/SplatDecoder.h" + +namespace splat { + +// Reads and decodes tile files on its own threads, most urgent first. The queue is +// replaced whole every time the scheduler plans, so a tile the camera left behind is +// never read; what was already started finishes and is reported like any other. +class TileLoader { + public: + struct Request { + std::uint32_t tile; + std::string path; + float priority; // bigger first + }; + struct Loaded { + std::uint32_t tile; + Result cloud; + double millis; + }; + + explicit TileLoader(SplatDecodeOptions options = {}, std::size_t threads = 1); + ~TileLoader(); + + TileLoader(const TileLoader&) = delete; + TileLoader& operator=(const TileLoader&) = delete; + + // Replaces the queue. Tiles already started or finished are not started again. + // Returns the tiles that were queued, not started, and are not in the new queue. + std::vector setQueue(std::vector queue); + // Every tile decoded since the last take, in the order they finished. + std::vector take(); + // Queued or being read. + std::size_t pending() const; + + private: + void run(); + + SplatDecodeOptions options_; + mutable std::mutex mutex_; + std::condition_variable wake_; + std::vector threads_; + bool stop_ = false; + std::vector queue_; + std::vector started_; // being read right now + std::vector finished_; +}; + +} // namespace splat diff --git a/packages/splat-core/include/splat/tiles/TileScheduler.h b/packages/splat-core/include/splat/tiles/TileScheduler.h new file mode 100644 index 0000000..23f8b05 --- /dev/null +++ b/packages/splat-core/include/splat/tiles/TileScheduler.h @@ -0,0 +1,111 @@ +#pragma once + +#include +#include +#include +#include + +#include "splat/math/Frustum.h" +#include "splat/tiles/SlabAllocator.h" +#include "splat/tiles/Tileset.h" + +namespace splat { + +// What the camera sees this frame, for the streaming decision. +struct TileView { + Frustum frustum; // already widened by the cull margin + // World units per unit depth that one pixel covers. A tile whose error divided by its + // distance is above it hides splats bigger than a pixel, so the tiles below are wanted. + float pixelScaleLimit = 0.001f; +}; + +enum class TileState : std::uint8_t { + absent, // not in memory; may be asked for + loading, // asked for, with a slab range reserved + resident, // uploaded and drawable + failed, // could not be read or uploaded; never asked for again +}; + +// Decides, per frame, which resident tiles to draw, which tiles to load next and which +// to drop (CONTEXT.md "Streaming", ADR 0015). First it picks the cover: the visible tiles +// to show, refined biggest on screen first for as long as the children fit the slab, so +// a scene too dense for the residency budget is shown at the finest level that fits +// rather than left with holes. Then it walks the tileset down to that cover: a cover +// tile is drawn when resident and loaded otherwise; while a tile's finer cover is on +// its way the tile is drawn under the pieces that landed, so nothing is a hole and +// nothing already fine turns coarse. Tiles out of view are neither drawn nor loaded. +// Loads are placed in the slab up front, so what is asked for always has a home; the +// least recently drawn tiles make room. The root is always wanted, so a turn towards +// something not loaded still finds the coarsest cover. +// A scene that fits the budget whole is fetched whole: once the cover is asked for, every +// other tile follows at the lowest priority, so a turn finds its fine tiles resident +// instead of a coarse stand-in for a moment. A scene bigger than the budget is not, since +// tiles fetched for a turn would be evicted for the cover and back again. +// A tile stays put while any draw order names its range (see `plan`); without that a +// tile landing in a range the order on the GPU still reads would draw in its place. +// Not thread safe: the render thread owns it. +class TileScheduler { + public: + // `residency` is the slab capacity in splats. + TileScheduler(std::shared_ptr tileset, std::uint32_t residency); + + struct Load { + std::uint32_t tile; + std::uint32_t offset; // where in the slab it goes + float priority; // bigger is more urgent + }; + struct Drop { + std::uint32_t tile; + std::uint32_t offset; + std::uint32_t count; + }; + struct Plan { + std::vector draw; // resident tiles to draw, each whole + std::vector load; // every tile wanted and not resident, most urgent first + std::vector drop; // evicted this frame; their ranges are free again + }; + // `pinned` are tiles a draw order still refers to: they count as used this frame, so + // they are never evicted from under it. They are drawn only if the walk chooses them, + // and they do not shrink the cover: a load that finds no room waits for them to go. + Plan plan(const TileView& view, const std::vector& pinned = {}); + + // The upload landed: the tile draws from the next plan on. + void markResident(std::uint32_t tile); + // A load that was abandoned or an upload that failed for now: its range is released + // and a later plan may ask for it again. + void markAbsent(std::uint32_t tile); + // A tile that cannot be read: released and never asked for again. + void markFailed(std::uint32_t tile); + + TileState state(std::uint32_t tile) const { return states_[tile]; } + std::uint32_t offset(std::uint32_t tile) const { return offsets_[tile]; } + // Splats resident or loading. + std::uint32_t held() const { return slab_.used(); } + std::uint32_t residency() const { return slab_.capacity(); } + const Tileset& tileset() const { return *tileset_; } + + private: + struct Wanted { + std::uint32_t tile; + float priority; + }; + std::vector cover(const TileView& view); + bool visit(std::uint32_t index, const TileView& view, Plan& plan, std::vector& wanted, + const std::vector& inCover); + bool visible(std::uint32_t index, const TileView& view) const; + float screenError(std::uint32_t index, Vec3 origin) const; + bool fineEnough(std::uint32_t index, const TileView& view) const; + void want(std::uint32_t index, float priority, std::vector& wanted); + bool place(std::uint32_t index, Plan& plan, std::vector& evictable); + void release(std::uint32_t tile, TileState next); + + std::shared_ptr tileset_; + SlabAllocator slab_; + std::vector states_; + std::vector offsets_; + bool fetchAll_; // every tile fits the slab at once + std::vector lastUsed_; // the plan that last drew or wanted the tile + std::uint64_t frame_ = 0; +}; + +} // namespace splat diff --git a/packages/splat-core/include/splat/tiles/TileStreamer.h b/packages/splat-core/include/splat/tiles/TileStreamer.h new file mode 100644 index 0000000..bbcae0e --- /dev/null +++ b/packages/splat-core/include/splat/tiles/TileStreamer.h @@ -0,0 +1,94 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "splat/sorting/SlabSorter.h" +#include "splat/tiles/TileLoader.h" +#include "splat/tiles/TileScheduler.h" +#include "splat/tiles/TiledWorld.h" + +namespace splat { + +struct StreamOptions { + std::uint32_t residency = 2000000; // slab capacity in splats + std::size_t loaderThreads = 1; + // False when the renderer culls and sorts on the GPU from `ranges()`: positions are + // then not kept for the CPU sorter, and `drawnNow` replaces requestVisible and take. + bool cpuSort = true; +}; + +// Streaming of one tiled world, everything but the GPU: the scheduler decides, the +// loader reads, the sorter orders what is drawn. Once per frame the render thread calls +// `update`, uploads what arrived into the slab ranges named, and commits each upload; +// then asks for the visible order like it does for a single file world. +class TileStreamer { + public: + explicit TileStreamer(TiledWorld world, const StreamOptions& options = {}); + + struct Arrival { + std::uint32_t tile; + std::uint32_t offset; // slab range the tile was given + const SplatCloud* cloud; // valid until commit or fail + }; + struct Step { + std::vector arrived; + std::vector failed; // could not be read; drawn by their parents from now on + bool drawChanged = false; // the set of tiles to draw is not the last one + std::size_t loading = 0; // tiles queued or being read + }; + // Plans for this view, keeps the loader on the plan and collects the tiles it decoded. + Step update(const TileView& view); + // The upload of an arrived tile landed: it draws from the next update on. + void commit(std::uint32_t tile); + // The upload did not: the tile is dropped and may be asked for again. + void fail(std::uint32_t tile); + + // The visible order of the tiles the last update chose to draw. Same contract as + // AsyncSorter: ask whenever the view changed enough, take when it is done. + void requestVisible(const Frustum& frustum); + std::optional take(); + + // For a renderer that orders the ranges itself: the slab ranges of the tiles to draw, + // and the notice that a frame draws them now, so they stay resident until the frames + // in flight are done. + const std::vector& ranges() const { return ranges_; } + void drawnNow(); + + const TiledWorld& world() const { return world_; } + const std::vector& drawn() const { return drawn_; } + TileState state(std::uint32_t tile) const { return scheduler_.state(tile); } + std::size_t drawnSplats() const { return drawnSplats_; } + std::uint32_t held() const { return scheduler_.held(); } + std::uint32_t residency() const { return scheduler_.residency(); } + + private: + TiledWorld world_; + TileScheduler scheduler_; + TileLoader loader_; + SlabSorter sorter_; + std::map> arrived_; // waiting for a commit + std::vector drawn_; + std::vector ranges_; + std::size_t drawnSplats_ = 0; + bool cpuSort_ = true; + + // The tiles each order refers to, from the request until the frames that drew it are + // done, so their ranges are not reused under a frame still reading them. + struct Order { + std::uint64_t request; + std::vector tiles; + }; + std::deque requested_; // asked for, not taken yet + std::vector shown_; // the order taken last, on the GPU now + std::deque retired_; // replaced; `request` holds the update they retire at + std::uint64_t updates_ = 0; + std::vector pinned() const; +}; + +} // namespace splat diff --git a/packages/splat-core/include/splat/tiles/TiledWorld.h b/packages/splat-core/include/splat/tiles/TiledWorld.h new file mode 100644 index 0000000..a5088a8 --- /dev/null +++ b/packages/splat-core/include/splat/tiles/TiledWorld.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +#include "splat/core/CoordinateFrame.h" +#include "splat/core/Result.h" +#include "splat/formats/SplatCloud.h" +#include "splat/tiles/Tileset.h" + +namespace splat { + +// A tiled world as the engine holds it: where the tile files are and the index, with +// every bounds in the internal frame. +struct TiledWorld { + std::string directory; + std::shared_ptr tileset; + CoordinateFrame sourceFrame = kWorldLabsFrame; // what the tile files decode from + + std::string tilePath(std::uint32_t tile) const; +}; + +// Bounds moved from one frame to the other: axes flip, so min and max swap on them. +Bounds convertBounds(const Bounds& bounds, CoordinateFrame from, CoordinateFrame to); + +// Reads the index at `path` (a tileset.json); the tiles are its siblings. Tile files +// were written in `sourceFrame`, as their PLY or spz was, and so were the bounds. +Result openTiledWorld(const std::string& path, + CoordinateFrame sourceFrame = kWorldLabsFrame); + +} // namespace splat diff --git a/packages/splat-core/include/splat/tiles/Tileset.h b/packages/splat-core/include/splat/tiles/Tileset.h new file mode 100644 index 0000000..8e2c0b4 --- /dev/null +++ b/packages/splat-core/include/splat/tiles/Tileset.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "splat/core/Result.h" +#include "splat/formats/SplatCloud.h" + +namespace splat { + +// One tile of a tiled world: a cube of the world at one level, stored as its own spz file. +struct Tile { + std::string file; // relative to the tileset + int level = 0; // 0 holds the file's splats; each level up stands in for the tiles below + Bounds bounds; // tight bounds of the splats in the file + std::uint32_t count = 0; + // World size of the smallest splat this tile stands in for: the cell the level below was + // merged at, 0 at level 0. Divided by the distance to the camera it is the world units + // per unit depth the tile hides, which streaming compares with the size of a pixel to + // decide whether the tiles below are needed. + float error = 0.0f; + std::vector children; // indices into Tileset::tiles +}; + +// The index of a tiled world (CONTEXT.md, "Tileset"; ADR 0015). +struct Tileset { + int shDegree = 0; + std::size_t splatCount = 0; // splats at level 0, the file's + std::uint32_t root = 0; + std::vector tiles; +}; + +// The JSON form written next to the tiles as `tileset.json`. +std::string writeTileset(const Tileset& tileset); +// Returns `corrupt` when the text is not a tileset this version reads. +Result readTileset(const std::string& json); + +} // namespace splat diff --git a/packages/splat-core/src/formats/SplatDecoder.cpp b/packages/splat-core/src/formats/SplatDecoder.cpp index a383875..1b6f13e 100644 --- a/packages/splat-core/src/formats/SplatDecoder.cpp +++ b/packages/splat-core/src/formats/SplatDecoder.cpp @@ -18,6 +18,7 @@ Result decodeSplatFile(const std::uint8_t* data, std::size_t size, case SplatFormat::spz: { SpzDecodeOptions spz; spz.sourceFrame = options.sourceFrame; + spz.maxShDegree = options.maxShDegree; spz.maxDecodedBytes = options.maxDecodedBytes; return decodeSpz(data, size, spz); } diff --git a/packages/splat-core/src/formats/SpzDecoder.cpp b/packages/splat-core/src/formats/SpzDecoder.cpp index e200f84..0e02675 100644 --- a/packages/splat-core/src/formats/SpzDecoder.cpp +++ b/packages/splat-core/src/formats/SpzDecoder.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -31,6 +32,29 @@ namespace { constexpr float kShC0 = 0.282095f; +// Packed SPZ stores each splat's SH values contiguously, with RGB as the fastest axis. +// Drop whole high-degree bands in place before the reference decoder allocates its float cloud. +// The source file remains unchanged; this is only a runtime memory-quality setting. +void truncatePackedSh(spz::PackedGaussians& packed, int requestedDegree) { + const int target = std::clamp(requestedDegree, 0, packed.shDegree); + if (target >= packed.shDegree) return; + + const auto pointCount = static_cast(packed.numPoints); + const std::size_t oldStride = + static_cast((packed.shDegree + 1) * (packed.shDegree + 1) - 1) * 3; + const std::size_t newStride = static_cast((target + 1) * (target + 1) - 1) * 3; + if (newStride == 0) { + std::vector().swap(packed.sh); + } else { + for (std::size_t i = 0; i < pointCount; ++i) { + std::memmove(packed.sh.data() + i * newStride, packed.sh.data() + i * oldStride, newStride); + } + packed.sh.resize(pointCount * newStride); + packed.sh.shrink_to_fit(); + } + packed.shDegree = target; +} + bool looksLikeGzip(const std::uint8_t* data, std::size_t size) { return size >= 2 && data[0] == 0x1f && data[1] == 0x8b; } @@ -177,9 +201,19 @@ Result decodeSpz(const std::uint8_t* data, std::size_t size, } MemoryBuffer buffer(packed->data(), packed->size()); std::istream in(&buffer); - cloud = spz::unpackGaussians(spz::deserializePackedGaussians(in), unpack); + auto packedGaussians = spz::deserializePackedGaussians(in); + if (packedGaussians.numPoints <= 0) { + return Error{ErrorCode::corrupt, "SPZ packed payload could not be decoded"}; + } + truncatePackedSh(packedGaussians, options.maxShDegree); + cloud = spz::unpackGaussians(packedGaussians, unpack); } else { - cloud = spz::loadSpz(data, size, unpack); + auto packedGaussians = spz::loadSpzPacked(data, size); + if (packedGaussians.numPoints <= 0) { + return Error{ErrorCode::corrupt, "SPZ packed payload could not be decoded"}; + } + truncatePackedSh(packedGaussians, options.maxShDegree); + cloud = spz::unpackGaussians(packedGaussians, unpack); } if (cloud.numPoints <= 0) { return Error{ErrorCode::corrupt, "SPZ container could not be decoded"}; diff --git a/packages/splat-core/src/loading/SplatWorldLoader.cpp b/packages/splat-core/src/loading/SplatWorldLoader.cpp index 8f8e3b3..e0b5072 100644 --- a/packages/splat-core/src/loading/SplatWorldLoader.cpp +++ b/packages/splat-core/src/loading/SplatWorldLoader.cpp @@ -7,6 +7,7 @@ #include "splat/formats/GlbDecoder.h" #include "splat/formats/SplatDecoder.h" #include "splat/io/MappedFile.h" +#include "splat/lod/LodFile.h" #include "splat/sorting/SpatialOrder.h" namespace splat { @@ -24,11 +25,33 @@ void SplatWorldLoader::setBudget(int budget) { budget_.store(std::max(budget, 0)); } +void SplatWorldLoader::setMaxShDegree(int degree) { + maxShDegree_.store(std::clamp(degree, 0, 3)); +} + Result SplatWorldLoader::loadWorld(const std::uint8_t* data, std::size_t size) { WorldReport report; auto start = Clock::now(); - auto decoded = decodeSplatFile(data, size); + if (isLodSplat(data, size)) { + auto decoded = decodeLodSplat(data, size, maxShDegree_.load()); + if (!decoded) return decoded.error(); + auto world = std::make_unique(); + world->tree = std::make_shared(std::move(decoded.value())); + world->budget = budget() > 0 ? budget() : 1200000; + world->sourceCount = world->tree->leafCount; + report.splatCount = world->sourceCount; + report.nodeCount = world->tree->nodeCount(); + report.shDegree = world->tree->nodes.shDegree; + report.bounds = world->tree->nodes.bounds; + report.decodeMillis = millisSince(start); + const std::lock_guard lock(mutex_); + pendingWorld_ = std::move(world); + return report; + } + SplatDecodeOptions options; + options.maxShDegree = maxShDegree_.load(); + auto decoded = decodeSplatFile(data, size, options); if (!decoded) return decoded.error(); report.decodeMillis = millisSince(start); auto cloud = std::make_unique(std::move(decoded.value())); @@ -63,6 +86,28 @@ Result SplatWorldLoader::loadWorldFile(const std: return loadWorld(file.value().data(), file.value().size()); } +Result SplatWorldLoader::loadTiledWorldFile( + const std::string& path) { + const auto start = Clock::now(); + auto opened = openTiledWorld(path); + if (!opened) return opened.error(); + auto world = std::make_unique(); + world->tiles = std::make_unique(std::move(opened.value())); + const Tileset& set = *world->tiles->tileset; + world->sourceCount = set.splatCount; + + WorldReport report; + report.splatCount = set.splatCount; + report.shDegree = set.shDegree; + report.bounds = set.tiles[set.root].bounds; + report.tileCount = set.tiles.size(); + report.decodeMillis = millisSince(start); + + const std::lock_guard lock(mutex_); + pendingWorld_ = std::move(world); + return report; +} + Result SplatWorldLoader::loadCollider(const std::uint8_t* data, std::size_t size) { const auto start = Clock::now(); diff --git a/packages/splat-core/src/lod/LodFile.cpp b/packages/splat-core/src/lod/LodFile.cpp new file mode 100644 index 0000000..bbdee1d --- /dev/null +++ b/packages/splat-core/src/lod/LodFile.cpp @@ -0,0 +1,291 @@ +#include "splat/lod/LodFile.h" + +#include +#include +#include +#include +#include +#include + +#include "splat/math/SymmetricEigen.h" + +namespace splat { +namespace { +constexpr std::array kMagic{'L', 'O', 'D', 'S', 'P', 'L', 'A', 'T'}; +constexpr size_t kHeader = 64; +constexpr size_t kMaxNodes = 20000000; +constexpr uint32_t kMaxDepth = 32; +Error corrupt(const char* message) { + return {ErrorCode::corrupt, message}; +} +uint32_t u32(const uint8_t* p) { + return uint32_t{p[0]} | (uint32_t{p[1]} << 8) | (uint32_t{p[2]} << 16) | (uint32_t{p[3]} << 24); +} +float f32(const uint8_t* p) { + const uint32_t bits = u32(p); + float f = 0; + std::memcpy(&f, &bits, 4); + return f; +} +void putU32(uint8_t* p, uint32_t v) { + for (int j = 0; j < 4; ++j) p[j] = static_cast(v >> (8 * j)); +} +void putF32(uint8_t* p, float f) { + uint32_t bits = 0; + std::memcpy(&bits, &f, 4); + putU32(p, bits); +} +size_t shStride(int degree) { + return static_cast((degree + 1) * (degree + 1) - 1) * 3; +} +} // namespace + +bool isLodSplat(const uint8_t* data, size_t size) { + return data && size >= kMagic.size() && std::equal(kMagic.begin(), kMagic.end(), data); +} + +Result validateLodTree(const LodTree& tree) { + const size_t n = tree.nodeCount(); + const auto& c = tree.nodes; + if (n == 0 || n > kMaxNodes || c.shDegree < 0 || c.shDegree > 3 || tree.leafCount > n || + c.positions.size() != n * 3 || c.covariances.size() != n * 6 || c.colors.size() != n * 3 || + c.alphas.size() != n || c.sh.size() != n * shStride(c.shDegree)) + return corrupt("LOD attribute dimensions or node count invalid"); + for (int j = 0; j < 3; ++j) + if (!std::isfinite(c.bounds.min[j]) || !std::isfinite(c.bounds.max[j]) || + c.bounds.min[j] > c.bounds.max[j]) + return corrupt("LOD bounds invalid"); + std::vector depths(n, 0); + uint32_t deepest = 0; + size_t next = 1; + size_t leaves = 0; + for (size_t i = 0; i < n; ++i) { + if (i >= next) return corrupt("LOD has unreachable nodes"); + const LodNode& node = tree.layout[i]; + if (!std::isfinite(node.size) || node.size < 0 || !std::isfinite(c.alphas[i]) || + c.alphas[i] < 0 || c.alphas[i] > 1000) + return corrupt("LOD size or opacity invalid"); + for (int j = 0; j < 3; ++j) + if (!std::isfinite(node.position[j]) || node.position[j] != c.positions[i * 3 + j] || + !std::isfinite(c.colors[i * 3 + j]) || c.colors[i * 3 + j] < 0 || c.colors[i * 3 + j] > 1) + return corrupt("LOD position or colour invalid"); + std::array covariance{}; + for (size_t j = 0; j < 6; ++j) { + covariance[j] = c.covariances[i * 6 + j]; + if (!std::isfinite(covariance[j])) return corrupt("LOD covariance is non-finite"); + } + const auto eigen = symmetricEigenvalues(covariance); + if (eigen[2] < -1e-5f * std::max(eigen[0], 1e-20f)) + return corrupt("LOD covariance is not positive semidefinite"); + if (node.childCount == 0) { + ++leaves; + } else { + if (node.childStart != next || node.childCount > n - next || depths[i] >= kMaxDepth) + return corrupt("LOD child ranges are not a bounded BFS tree"); + for (size_t k = next; k < next + node.childCount; ++k) depths[k] = depths[i] + 1; + deepest = std::max(deepest, uint32_t{depths[i]} + 1); + next += node.childCount; + } + } + if (next != n || leaves != tree.leafCount) return corrupt("LOD topology or leaf count invalid"); + for (const float value : c.sh) + if (!std::isfinite(value)) return corrupt("LOD SH is non-finite"); + const auto& selection = tree.selection; + if (!selection.clusters.empty()) { + if (selection.clusters.size() != std::max(size_t{1}, n - leaves) || + selection.leaves.size() != leaves || selection.clusters[0].node != 0) + return corrupt("LOD selection dimensions invalid"); + size_t nextCluster = 1; + size_t nextLeaf = 0; + for (size_t k = 0; k < selection.clusters.size(); ++k) { + const auto& cluster = selection.clusters[k]; + if (k >= nextCluster || cluster.node >= n || cluster.childStart != nextCluster || + cluster.childCount > selection.clusters.size() - nextCluster || + cluster.leafStart != nextLeaf || cluster.leafCount > leaves - nextLeaf || + !std::isfinite(cluster.error) || cluster.error < 0 || + !std::isfinite(cluster.colorVariance) || cluster.colorVariance < 0 || + !std::isfinite(cluster.opacity) || cluster.opacity < 0 || cluster.opacity > 1 || + !std::isfinite(cluster.radius) || cluster.radius < 0) + return corrupt("LOD selection ranges or error metadata invalid"); + float radius2 = 0; + for (int j = 0; j < 3; ++j) { + if (!std::isfinite(cluster.center[j]) || !std::isfinite(cluster.extent[j]) || + cluster.extent[j] < 0) + return corrupt("LOD selection bounds invalid"); + radius2 += cluster.extent[j] * cluster.extent[j]; + } + if (cluster.radius + 1e-5f * std::max(cluster.radius, 1.0f) < std::sqrt(radius2)) + return corrupt("LOD sphere does not enclose its bounds"); + const auto& original = tree.layout[cluster.node]; + uint32_t ci = 0; + uint32_t li = 0; + uint32_t subtree = 0; + for (uint32_t j = 0; j < std::max(original.childCount, 1u); ++j) { + const uint32_t index = original.childCount ? original.childStart + j : cluster.node; + const bool interior = tree.layout[index].childCount > 0; + const LodCluster* child = nullptr; + if (interior) { + if (ci >= cluster.childCount) return corrupt("LOD missing interior child"); + child = &selection.clusters[cluster.childStart + ci++]; + if (child->node != index) return corrupt("LOD selection interior mapping invalid"); + subtree += child->subtreeLeaves; + } else { + if (li >= cluster.leafCount || selection.leaves[cluster.leafStart + li] != index) + return corrupt("LOD selection leaf mapping invalid"); + ++li; + ++subtree; + } + const float reach = std::sqrt(2 * std::log(std::max(255.0f * c.alphas[index], 1.0f))); + for (int axis = 0; axis < 3; ++axis) { + const int diagonal = axis == 0 ? 0 : axis == 1 ? 3 : 5; + const float center = child ? child->center[axis] : c.positions[index * 3 + axis]; + const float extent = + child ? child->extent[axis] + : reach * std::sqrt(std::max(c.covariances[index * 6 + diagonal], 0.0f)); + const float tolerance = 1e-4f * std::max({1.0f, std::abs(center), extent}); + if (std::abs(center - cluster.center[axis]) + extent > cluster.extent[axis] + tolerance) + return corrupt("LOD bounds do not enclose descendant support"); + } + } + if (ci != cluster.childCount || li != cluster.leafCount || subtree != cluster.subtreeLeaves) + return corrupt("LOD selection subtree mismatch"); + nextCluster += ci; + nextLeaf += li; + } + if (nextCluster != selection.clusters.size() || nextLeaf != leaves) + return corrupt("LOD selection has unreachable records"); + } else if (!selection.leaves.empty()) { + return corrupt("LOD selection leaves without clusters"); + } + return deepest; +} + +Result decodeLodSplat(const uint8_t* data, size_t size, int maxShDegree) { + if (!isLodSplat(data, size)) return Error{ErrorCode::unsupportedFormat, "not a LODSPLAT file"}; + if (size < kHeader) return corrupt("truncated LODSPLAT header"); + const uint32_t version = u32(data + 8); + if (version != 1 && version != 2) + return Error{ErrorCode::unsupportedFormat, "unsupported LODSPLAT version"}; + const size_t n = u32(data + 12); + const uint32_t degree = u32(data + 20); + const uint32_t depth = u32(data + 24); + const size_t clusters = u32(data + 28); + const size_t leafRefs = u32(data + 56); + if (n == 0 || n > kMaxNodes || degree > 3 || depth > kMaxDepth || clusters > n || leafRefs > n || + (version == 1 && (clusters || leafRefs || u32(data + 60))) || + (version == 2 && (!clusters || leafRefs != u32(data + 16) || u32(data + 60) != 64))) + return corrupt("invalid LODSPLAT header"); + const size_t stride = 64 + shStride(static_cast(degree)) * 4; + if (size != kHeader + n * stride + clusters * 64 + leafRefs * 4) + return corrupt("LODSPLAT length does not match node records"); + LodTree tree; + tree.leafCount = u32(data + 16); + tree.layout.resize(n); + auto& c = tree.nodes; + c.shDegree = std::min(static_cast(degree), std::clamp(maxShDegree, 0, 3)); + const size_t sh = shStride(c.shDegree); + c.positions.resize(n * 3); + c.covariances.resize(n * 6); + c.colors.resize(n * 3); + c.alphas.resize(n); + c.sh.resize(n * sh); + for (int j = 0; j < 3; ++j) { + c.bounds.min[j] = f32(data + 32 + j * 4); + c.bounds.max[j] = f32(data + 44 + j * 4); + } + for (size_t i = 0; i < n; ++i) { + const uint8_t* p = data + kHeader + i * stride; + auto& node = tree.layout[i]; + for (int j = 0; j < 3; ++j) node.position[j] = c.positions[i * 3 + j] = f32(p + j * 4); + node.size = f32(p + 12); + node.childStart = u32(p + 16); + node.childCount = u32(p + 20); + for (size_t j = 0; j < 6; ++j) c.covariances[i * 6 + j] = f32(p + 24 + j * 4); + for (size_t j = 0; j < 3; ++j) c.colors[i * 3 + j] = f32(p + 48 + j * 4); + c.alphas[i] = f32(p + 60); + for (size_t j = 0; j < sh; ++j) c.sh[i * sh + j] = f32(p + 64 + j * 4); + } + if (version == 2) { + tree.selection.clusters.resize(clusters); + tree.selection.leaves.resize(leafRefs); + const uint8_t* p = data + kHeader + n * stride; + for (size_t k = 0; k < clusters; ++k) + for (size_t j = 0; j < 16; ++j) { + const uint32_t word = u32(p + k * 64 + j * 4); + std::memcpy(reinterpret_cast(&tree.selection.clusters[k]) + j * 4, &word, 4); + } + p += clusters * 64; + for (size_t k = 0; k < leafRefs; ++k) tree.selection.leaves[k] = u32(p + k * 4); + } + auto valid = validateLodTree(tree); + if (!valid) return valid.error(); + if (valid.value() != depth) return corrupt("LODSPLAT declared depth differs from topology"); + return tree; +} + +Result writeLodSplat(const LodTree& tree, const std::string& path) { + const auto valid = validateLodTree(tree); + if (!valid) return valid.error(); + // Exclusive creation: an interrupted output cannot replace a user's existing asset. + const auto closeFile = [](FILE* handle) { std::fclose(handle); }; + std::unique_ptr file(std::fopen(path.c_str(), "wbx"), closeFile); + if (!file) + return Error{ErrorCode::unreadable, "cannot create LODSPLAT output (exists or unwritable)"}; + const auto& c = tree.nodes; + std::array header{}; + std::copy(kMagic.begin(), kMagic.end(), header.begin()); + const auto& selection = tree.selection; + putU32(header.data() + 8, selection.clusters.empty() ? 1 : 2); + putU32(header.data() + 12, static_cast(tree.nodeCount())); + putU32(header.data() + 16, static_cast(tree.leafCount)); + putU32(header.data() + 20, static_cast(c.shDegree)); + putU32(header.data() + 24, valid.value()); + putU32(header.data() + 28, static_cast(selection.clusters.size())); + putU32(header.data() + 56, static_cast(selection.leaves.size())); + putU32(header.data() + 60, selection.clusters.empty() ? 0 : 64); + for (int j = 0; j < 3; ++j) { + putF32(header.data() + 32 + j * 4, c.bounds.min[j]); + putF32(header.data() + 44 + j * 4, c.bounds.max[j]); + } + bool ok = std::fwrite(header.data(), 1, header.size(), file.get()) == header.size(); + const size_t sh = shStride(c.shDegree); + const size_t stride = 64 + sh * 4; + std::vector block(stride * 4096); + for (size_t start = 0; start < tree.nodeCount() && ok; start += 4096) { + const size_t count = std::min(size_t{4096}, tree.nodeCount() - start); + for (size_t k = 0; k < count; ++k) { + const size_t i = start + k; + uint8_t* p = block.data() + k * stride; + const auto& node = tree.layout[i]; + for (size_t j = 0; j < 3; ++j) putF32(p + j * 4, node.position[j]); + putF32(p + 12, node.size); + putU32(p + 16, node.childStart); + putU32(p + 20, node.childCount); + for (size_t j = 0; j < 6; ++j) putF32(p + 24 + j * 4, c.covariances[i * 6 + j]); + for (size_t j = 0; j < 3; ++j) putF32(p + 48 + j * 4, c.colors[i * 3 + j]); + putF32(p + 60, c.alphas[i]); + for (size_t j = 0; j < sh; ++j) putF32(p + 64 + j * 4, c.sh[i * sh + j]); + } + ok = std::fwrite(block.data(), stride, count, file.get()) == count; + } + for (size_t start = 0; start < selection.clusters.size() && ok; start += 4096) { + const size_t count = std::min(size_t{4096}, selection.clusters.size() - start); + for (size_t k = 0; k < count; ++k) + for (size_t j = 0; j < 16; ++j) { + uint32_t word = 0; + std::memcpy(&word, reinterpret_cast(&selection.clusters[start + k]) + j * 4, + 4); + putU32(block.data() + k * 64 + j * 4, word); + } + ok = std::fwrite(block.data(), 64, count, file.get()) == count; + } + for (size_t start = 0; start < selection.leaves.size() && ok; start += 4096) { + const size_t count = std::min(size_t{4096}, selection.leaves.size() - start); + for (size_t k = 0; k < count; ++k) putU32(block.data() + k * 4, selection.leaves[start + k]); + ok = std::fwrite(block.data(), 4, count, file.get()) == count; + } + ok = std::fclose(file.release()) == 0 && ok; + if (!ok) return Error{ErrorCode::unreadable, "LODSPLAT write failed; output is incomplete"}; + return Ok{}; +} +} // namespace splat diff --git a/packages/splat-core/src/lod/LodSelection.cpp b/packages/splat-core/src/lod/LodSelection.cpp new file mode 100644 index 0000000..33d5b57 --- /dev/null +++ b/packages/splat-core/src/lod/LodSelection.cpp @@ -0,0 +1,134 @@ +#include "splat/lod/LodTree.h" + +#include +#include +#include +#include + +namespace splat { +namespace { +float length3(const float* a, const float* b) { + float s = 0; + for (int j = 0; j < 3; ++j) s += (a[j] - b[j]) * (a[j] - b[j]); + return std::sqrt(s); +} +// Frobenius covariance discrepancy is rotation-sensitive. Its square root has +// world-length units; unlike determinant it does not hide a thin, long primitive. +float covarianceError(const float* a, const float* b) { + float s = 0; + for (int j = 0; j < 6; ++j) { + const float d = a[j] - b[j]; + s += d * d * ((j == 1 || j == 2 || j == 4) ? 2 : 1); + } + return std::sqrt(std::sqrt(s)); +} +float appearanceDifference(const SplatCloud& cloud, uint32_t a, uint32_t b) { + float difference = 0; + const size_t stride = cloud.count() ? cloud.sh.size() / cloud.count() : 0; + for (int c = 0; c < 3; ++c) { + float d = std::abs(cloud.colors[a * 3 + c] - cloud.colors[b * 3 + c]); + // Conservative directional SH amplitude via addition theorem, per band. + for (int band = 1, start = 0; band <= cloud.shDegree; ++band) { + float norm = 0; + for (int k = 0; k < 2 * band + 1; ++k) { + const float v = + cloud.sh[a * stride + (start + k) * 3 + c] - cloud.sh[b * stride + (start + k) * 3 + c]; + norm += v * v; + } + d += std::sqrt(norm * (2 * band + 1) / (4.0f * 3.14159265f)); + start += 2 * band + 1; + } + difference = std::max(difference, d); + } + return difference; +} +} // namespace + +LodSelectionData buildLodSelectionData(const LodTree& tree) { + LodSelectionData out; + if (tree.nodeCount() == 0) return out; + const auto& cloud = tree.nodes; + // This queue contains interiors only. Keep original-node mapping transient on CPU. + std::vector order{0}; + out.clusters.resize(1); + out.leaves.reserve(tree.leafCount); + for (size_t head = 0; head < order.size(); ++head) { + const uint32_t index = order[head]; + const auto& node = tree.layout[index]; + LodCluster cluster; + cluster.node = index; + cluster.childStart = static_cast(order.size()); + cluster.leafStart = static_cast(out.leaves.size()); + if (node.childCount == 0) out.leaves.push_back(index); // single-splat root + for (uint32_t j = 0; j < node.childCount; ++j) { + const uint32_t child = node.childStart + j; + if (tree.layout[child].childCount) + order.push_back(child); + else + out.leaves.push_back(child); + } + cluster.childCount = static_cast(order.size()) - cluster.childStart; + cluster.leafCount = static_cast(out.leaves.size()) - cluster.leafStart; + out.clusters.resize(order.size()); + out.clusters[head] = cluster; + } + for (size_t k = out.clusters.size(); k-- > 0;) { + auto& cluster = out.clusters[k]; + const float* parent = &cloud.positions[cluster.node * 3]; + const float* parentCov = &cloud.covariances[cluster.node * 6]; + std::array lo; + std::array hi; + lo.fill(std::numeric_limits::max()); + hi.fill(std::numeric_limits::lowest()); + float weightedVariance = 0; + float weightSum = 0; + float coincidentTransmittance = 1; + cluster.opacity = std::clamp(cloud.alphas[cluster.node], 0.0f, 1.0f); + auto include = [&](uint32_t index, const float* center, const float* extent, float error, + float variance, uint32_t leaves) { + for (int j = 0; j < 3; ++j) { + lo[j] = std::min(lo[j], center[j] - extent[j]); + hi[j] = std::max(hi[j], center[j] + extent[j]); + } + const float* position = &cloud.positions[index * 3]; + const float displacement = length3(position, parent); + const float shape = covarianceError(&cloud.covariances[index * 6], parentCov); + cluster.error = std::max(cluster.error, error + displacement + shape); + const float difference = appearanceDifference(cloud, cluster.node, index); + const float weight = std::max(cloud.alphas[index], 0.0f) * leaves; + weightedVariance += weight * (variance + difference * difference); + weightSum += weight; + cluster.subtreeLeaves += leaves; + coincidentTransmittance *= 1.0f - std::clamp(cloud.alphas[index], 0.0f, 1.0f); + cluster.opacity = std::max(cluster.opacity, std::min(cloud.alphas[index], 1.0f)); + }; + for (uint32_t j = 0; j < cluster.childCount; ++j) { + const auto& child = out.clusters[cluster.childStart + j]; + include(child.node, child.center, child.extent, child.error, child.colorVariance, + child.subtreeLeaves); + } + for (uint32_t j = 0; j < cluster.leafCount; ++j) { + const uint32_t index = out.leaves[cluster.leafStart + j]; + const float* cov = &cloud.covariances[index * 6]; + // Trace bounds lambda_max; includes the renderer's alpha-dependent tail cutoff. + const float reach = std::sqrt(2 * std::log(std::max(255.0f * cloud.alphas[index], 1.0f))); + const float extent[3] = {reach * std::sqrt(std::max(cov[0], 0.0f)), + reach * std::sqrt(std::max(cov[3], 0.0f)), + reach * std::sqrt(std::max(cov[5], 0.0f))}; + include(index, &cloud.positions[index * 3], extent, 0, 0, 1); + } + for (int j = 0; j < 3; ++j) { + cluster.center[j] = (lo[j] + hi[j]) * 0.5f; + cluster.extent[j] = (hi[j] - lo[j]) * 0.5f; + } + cluster.radius = length3(hi.data(), cluster.center); + // This is only an overlap-disagreement signal. 1-product(1-alpha) is exact + // for co-located peak samples, not a replacement for spatial opacity fields. + const float peakDifference = + std::abs(std::min(cloud.alphas[cluster.node], 1.0f) - (1.0f - coincidentTransmittance)); + cluster.error = std::max(cluster.error, cluster.radius * peakDifference); + cluster.colorVariance = weightSum > 0 ? weightedVariance / weightSum : 0; + } + return out; +} +} // namespace splat diff --git a/packages/splat-core/src/lod/LodTree.cpp b/packages/splat-core/src/lod/LodTree.cpp index 20ccec6..ff3940b 100644 --- a/packages/splat-core/src/lod/LodTree.cpp +++ b/packages/splat-core/src/lod/LodTree.cpp @@ -54,8 +54,7 @@ struct Nodes { weights[k] = ellipsoidArea(semiAxes(&covariances[i * 6])) * alphas[i]; total += weights[k]; } - total = std::max(total, 1e-30f); - for (float& w : weights) w /= total; + for (float& w : weights) w = total > 1e-30f ? w / total : 1.0f / members.size(); float center[3] = {0, 0, 0}; float rgb[3] = {0, 0, 0}; @@ -68,6 +67,8 @@ struct Nodes { } for (std::size_t c = 0; c < shStride; ++c) shSum[c] += weights[k] * sh[i * shStride + c]; } + // Summing normalized float weights can put an all-white parent just above one. + for (float& channel : rgb) channel = std::clamp(channel, 0.0f, 1.0f); const float filter2 = filter * filter; float cov[6] = {0, 0, 0, 0, 0, 0}; @@ -86,11 +87,11 @@ struct Nodes { cov[5] += w * (dz * dz + c[5] + filter2); } - // Opacity that keeps the merged contribution equal to the sum of the members'. It - // exceeds one where many opaque splats overlap; the renderer draws such a node with - // min(1, alpha * falloff), an opaque core that fades at the edge (Kerbl et al. 2024). + // Area-weighted falloff approximates the sum of isolated projected contributions. + // It is NOT exact energy/opacity conservation under perspective and alpha blending. + // It may exceed one; clamp only after Gaussian evaluation (Kerbl et al. 2024). const auto axes = semiAxes(cov); - const float alpha = std::clamp(total / std::max(ellipsoidArea(axes), 1e-30f), 1e-6f, 1000.0f); + const float alpha = std::clamp(total / std::max(ellipsoidArea(axes), 1e-30f), 0.0f, 1000.0f); const auto index = static_cast(count()); positions.insert(positions.end(), center, center + 3); @@ -129,76 +130,124 @@ LodTree buildLodTree(SplatCloud cloud, const LodBuildOptions& options) { tree.leafCount = leaves; if (leaves == 0) return tree; - // Levels: at level L the cell is base^L wide. A splat joins the hierarchy at the first - // level whose cell is at least its size, so small splats merge early and big ones late. - // The finest level is bounded below so that cell coordinates fit 21 bits each. - float extent = 0.0f; - for (int c = 0; c < 3; ++c) extent = std::max(extent, cloud.bounds.max[c] - cloud.bounds.min[c]); - float minSize = nodes.size[0]; - for (const float s : nodes.size) minSize = std::min(minSize, s); - const float logBase = std::log(options.base); - const float finest = std::max(std::max(minSize, 1e-6f), extent / static_cast(1 << 20)); - int level = static_cast(std::ceil(std::log(finest) / logBase)); - - std::vector bySize(leaves); - std::iota(bySize.begin(), bySize.end(), 0u); - std::sort(bySize.begin(), bySize.end(), - [&](uint32_t a, uint32_t b) { return nodes.size[a] < nodes.size[b]; }); - - std::size_t frontier = 0; - std::vector active; - std::vector cells; - bool makeRoot = false; - const float* origin = cloud.bounds.min.data(); - for (;;) { - const float step = std::pow(options.base, static_cast(level)); - while (frontier < leaves && nodes.size[bySize[frontier]] <= step) - active.push_back(bySize[frontier++]); - - cells.clear(); - cells.reserve(active.size()); - uint64_t low[3] = {~0ull, ~0ull, ~0ull}; - uint64_t high[3] = {0, 0, 0}; - for (const uint32_t node : active) { + uint32_t root = 0; + if (options.octreeDepth > 0) { + // Morton prefixes describe nested cubes. Unlike the legacy size-adaptive grid, + // the number of spatial subdivisions is fixed offline, never built on the phone. + const uint32_t depth = std::clamp(options.octreeDepth, 1u, 10u); + const uint32_t resolution = 1u << depth; + float extent = 1e-6f; + for (int c = 0; c < 3; ++c) + extent = std::max(extent, cloud.bounds.max[c] - cloud.bounds.min[c]); + std::vector active; + active.reserve(leaves); + for (uint32_t i = 0; i < leaves; ++i) { uint64_t key = 0; - for (int c = 0; c < 3; ++c) { - const auto g = static_cast( - std::max(0.0f, std::floor((nodes.positions[node * 3 + c] - origin[c]) / step))); - low[c] = std::min(low[c], g); - high[c] = std::max(high[c], g); - key = (key << 21) | (g & 0x1FFFFF); + for (uint32_t c = 0; c < 3; ++c) { + const float unit = + std::clamp((nodes.positions[i * 3 + c] - cloud.bounds.min[c]) / extent, 0.0f, 1.0f); + const uint32_t grid = std::min(static_cast(unit * resolution), resolution - 1); + for (uint32_t bit = 0; bit < depth; ++bit) + key |= uint64_t{(grid >> bit) & 1u} << (3 * bit + c); } - cells.push_back({makeRoot ? 0 : key, node}); + active.push_back({key, i}); } - std::sort(cells.begin(), cells.end(), - [](const Cell& a, const Cell& b) { return a.key < b.key; }); - - std::vector next; + std::sort(active.begin(), active.end(), [](const Cell& a, const Cell& b) { + return a.key == b.key ? a.node < b.node : a.key < b.key; + }); std::vector members; - std::size_t cellCount = 0; - for (std::size_t start = 0; start < cells.size();) { - std::size_t end = start + 1; - while (end < cells.size() && cells[end].key == cells[start].key) ++end; - ++cellCount; - if (end - start > 1) { - members.clear(); - for (std::size_t k = start; k < end; ++k) members.push_back(cells[k].node); - next.push_back(nodes.merge(members, 0.5f * step)); - } else { - next.push_back(cells[start].node); + for (uint32_t level = 0; level <= depth; ++level) { + std::vector next; + for (size_t start = 0; start < active.size();) { + size_t end = start + 1; + while (end < active.size() && active[end].key == active[start].key) ++end; + uint32_t node = active[start].node; + if (end - start > 1) { + members.clear(); + for (size_t j = start; j < end; ++j) members.push_back(active[j].node); + // Moment matching includes within-child covariance and between-child means. + // No cell-size blur is added in the offline path. + node = nodes.merge(members, 0.0f); + } + next.push_back({active[start].key >> 3, node}); + start = end; } - start = end; + active = std::move(next); } - active.swap(next); - ++level; + root = active.front().node; + } else { + // Levels: at level L the cell is base^L wide. A splat joins the hierarchy at the first + // level whose cell is at least its size, so small splats merge early and big ones late. + // The finest level is bounded below so that cell coordinates fit 21 bits each. + float extent = 0.0f; + for (int c = 0; c < 3; ++c) + extent = std::max(extent, cloud.bounds.max[c] - cloud.bounds.min[c]); + float minSize = nodes.size[0]; + for (const float s : nodes.size) minSize = std::min(minSize, s); + const float logBase = std::log(options.base); + const float finest = std::max(std::max(minSize, 1e-6f), extent / static_cast(1 << 20)); + int level = static_cast(std::ceil(std::log(finest) / logBase)); + + std::vector bySize(leaves); + std::iota(bySize.begin(), bySize.end(), 0u); + std::sort(bySize.begin(), bySize.end(), + [&](uint32_t a, uint32_t b) { return nodes.size[a] < nodes.size[b]; }); - if (frontier < leaves) continue; - if (cellCount == 1) break; - uint64_t range = 0; - for (int c = 0; c < 3; ++c) range = std::max(range, high[c] - low[c]); - if (range <= 1) makeRoot = true; // everything left shares a cell: one more merge is the root + std::size_t frontier = 0; + std::vector active; + std::vector cells; + bool makeRoot = false; + const float* origin = cloud.bounds.min.data(); + for (;;) { + const float step = std::pow(options.base, static_cast(level)); + while (frontier < leaves && nodes.size[bySize[frontier]] <= step) + active.push_back(bySize[frontier++]); + + cells.clear(); + cells.reserve(active.size()); + uint64_t low[3] = {~0ull, ~0ull, ~0ull}; + uint64_t high[3] = {0, 0, 0}; + for (const uint32_t node : active) { + uint64_t key = 0; + for (int c = 0; c < 3; ++c) { + const auto g = static_cast( + std::max(0.0f, std::floor((nodes.positions[node * 3 + c] - origin[c]) / step))); + low[c] = std::min(low[c], g); + high[c] = std::max(high[c], g); + key = (key << 21) | (g & 0x1FFFFF); + } + cells.push_back({makeRoot ? 0 : key, node}); + } + std::sort(cells.begin(), cells.end(), + [](const Cell& a, const Cell& b) { return a.key < b.key; }); + + std::vector next; + std::vector members; + std::size_t cellCount = 0; + for (std::size_t start = 0; start < cells.size();) { + std::size_t end = start + 1; + while (end < cells.size() && cells[end].key == cells[start].key) ++end; + ++cellCount; + if (end - start > 1) { + members.clear(); + for (std::size_t k = start; k < end; ++k) members.push_back(cells[k].node); + next.push_back(nodes.merge(members, 0.5f * step)); + } else { + next.push_back(cells[start].node); + } + start = end; + } + active.swap(next); + ++level; + + if (frontier < leaves) continue; + if (cellCount == 1) break; + uint64_t range = 0; + for (int c = 0; c < 3; ++c) range = std::max(range, high[c] - low[c]); + if (range <= 1) makeRoot = true; // everything left shares a cell: one more merge is the root + } + root = active[0]; } - const uint32_t root = active[0]; // Lay the tree out root first, level by level, children of a node contiguous. The // selection walks it from the root and never touches a node before its parent. diff --git a/packages/splat-core/src/sorting/AsyncSorter.cpp b/packages/splat-core/src/sorting/AsyncSorter.cpp index 940bdd2..8ad7dd2 100644 --- a/packages/splat-core/src/sorting/AsyncSorter.cpp +++ b/packages/splat-core/src/sorting/AsyncSorter.cpp @@ -24,10 +24,16 @@ AsyncSorter::~AsyncSorter() { } void AsyncSorter::request(Vec3 from) { - { - const std::lock_guard lock(mutex_); - pending_ = Request{from, std::nullopt, LodSettings{}}; + const std::lock_guard lock(mutex_); + + if (sortedFrom_) { + const float dx = from.x - sortedFrom_->x; + const float dy = from.y - sortedFrom_->y; + const float dz = from.z - sortedFrom_->z; + if ((dx * dx + dy * dy + dz * dz) < 0.000001f) return; } + + pending_ = Request{from, std::nullopt, LodSettings{}}; wake_.notify_one(); } @@ -47,7 +53,9 @@ std::optional AsyncSorter::take() { } void AsyncSorter::run() { - std::vector order; + std::vector candidateIndices; + std::vector visibleIndices; + for (;;) { Request request; { @@ -57,51 +65,52 @@ void AsyncSorter::run() { request = *pending_; pending_.reset(); } + using Clock = std::chrono::steady_clock; auto millisBetween = [](Clock::time_point a, Clock::time_point b) { return std::chrono::duration(b - a).count(); }; + const auto start = Clock::now(); const bool useLod = tree_ && request.lod.budget > 0; - const bool lodChanged = request.lod.budget != sortedLod_.budget || - request.lod.pixelScaleLimit != sortedLod_.pixelScaleLimit; - const bool moved = !sortedFrom_ || sortedFrom_->x != request.from.x || - sortedFrom_->y != request.from.y || sortedFrom_->z != request.from.z; - const bool turned = useLod && dot(normalize(request.lod.view.forward), sortedForward_) < - request.lod.reselectCosine; - if (moved || lodChanged || turned) { - if (useLod) { - selectLodNodes(*tree_, request.from, request.lod.view, request.lod.budget, - request.lod.pixelScaleLimit, fullOrder_); - sortedForward_ = normalize(request.lod.view.forward); - lastSelectMillis_ = millisBetween(start, Clock::now()); - lastSelected_ = fullOrder_.size(); - const auto sortStart = Clock::now(); - sorter_.sortSubset(request.from, fullOrder_); - lastSortMillis_ = millisBetween(sortStart, Clock::now()); - } else { - sorter_.sort(request.from, fullOrder_); - lastSortMillis_ = millisBetween(start, Clock::now()); - lastSelectMillis_ = 0; - lastSelected_ = fullOrder_.size(); + + if (useLod) { + selectLodNodes(*tree_, request.from, request.lod.view, request.lod.budget, + request.lod.pixelScaleLimit, candidateIndices); + } else { + const std::size_t totalCount = sorter_.count(); + if (candidateIndices.size() != totalCount) { + candidateIndices.resize(totalCount); + for (std::size_t i = 0; i < totalCount; ++i) { + candidateIndices[i] = static_cast(i); + } } - sortedFrom_ = request.from; - sortedLod_ = request.lod; } - const auto sorted = Clock::now(); + const auto selectEnd = Clock::now(); + lastSelectMillis_ = millisBetween(start, selectEnd); + lastSelected_ = candidateIndices.size(); + + const auto cullStart = Clock::now(); if (request.frustum) { - sorter_.cull(fullOrder_, *request.frustum, order); + sorter_.cull(candidateIndices, *request.frustum, visibleIndices); } else { - order = fullOrder_; + visibleIndices = candidateIndices; } - const auto culled = Clock::now(); + const auto cullEnd = Clock::now(); + const double cullMillis = millisBetween(cullStart, cullEnd); + + const auto sortStart = Clock::now(); + sorter_.sortSubset(request.from, visibleIndices); + const auto sortEnd = Clock::now(); + lastSortMillis_ = millisBetween(sortStart, sortEnd); + const std::lock_guard lock(mutex_); - // An untaken result is stale now; its buffer becomes the next result's scratch. std::vector recycled = finished_ ? std::move(finished_->order) : std::vector(); - finished_ = Result{std::move(order), lastSortMillis_, millisBetween(sorted, culled), - lastSelectMillis_, lastSelected_}; - order = std::move(recycled); + finished_ = Result{std::move(visibleIndices), lastSortMillis_, cullMillis, lastSelectMillis_, + lastSelected_}; + + visibleIndices = std::move(recycled); } } diff --git a/packages/splat-core/src/sorting/DistanceSorter.cpp b/packages/splat-core/src/sorting/DistanceSorter.cpp index a9fda3b..16b581c 100644 --- a/packages/splat-core/src/sorting/DistanceSorter.cpp +++ b/packages/splat-core/src/sorting/DistanceSorter.cpp @@ -23,6 +23,12 @@ DistanceSorter::DistanceSorter(std::vector positions) orderScratch_.resize(n); } +void DistanceSorter::place(std::size_t first, const float* xyz, std::size_t n) { + if (first >= count()) return; + n = std::min(n, count() - first); + std::memcpy(&positions_[first * 3], xyz, n * 3 * sizeof(float)); +} + namespace { // Key: bit pattern of the squared distance. Non negative floats compare like their bits, diff --git a/packages/splat-core/src/sorting/SlabSorter.cpp b/packages/splat-core/src/sorting/SlabSorter.cpp new file mode 100644 index 0000000..a7745a6 --- /dev/null +++ b/packages/splat-core/src/sorting/SlabSorter.cpp @@ -0,0 +1,91 @@ +#include "splat/sorting/SlabSorter.h" + +#include + +namespace splat { + +SlabSorter::SlabSorter(std::uint32_t capacity) + : sorter_(std::vector(static_cast(capacity) * 3, 0.0f)) { + thread_ = std::thread([this] { run(); }); +} + +SlabSorter::~SlabSorter() { + { + const std::lock_guard lock(mutex_); + stop_ = true; + } + wake_.notify_all(); + thread_.join(); +} + +void SlabSorter::place(std::uint32_t offset, std::vector positions) { + const std::lock_guard lock(mutex_); + placements_.push_back({offset, std::move(positions)}); +} + +std::uint64_t SlabSorter::requestVisible(const Frustum& frustum, std::vector ranges) { + std::uint64_t id = 0; + { + const std::lock_guard lock(mutex_); + id = ++requests_; + pending_ = Request{frustum, std::move(ranges), id}; + } + wake_.notify_one(); + return id; +} + +std::optional SlabSorter::take() { + const std::lock_guard lock(mutex_); + std::optional out = std::move(finished_); + finished_.reset(); + return out; +} + +void SlabSorter::run() { + using Clock = std::chrono::steady_clock; + const auto millisBetween = [](Clock::time_point a, Clock::time_point b) { + return std::chrono::duration(b - a).count(); + }; + std::vector order; + for (;;) { + Request request; + std::vector placements; + { + std::unique_lock lock(mutex_); + wake_.wait(lock, [this] { return stop_ || pending_.has_value(); }); + if (stop_) return; + request = std::move(*pending_); + pending_.reset(); + placements.swap(placements_); + } + for (const Placement& p : placements) { + sorter_.place(p.offset, p.positions.data(), p.positions.size() / 3); + } + const Vec3 from = request.frustum.origin; + const bool moved = !sortedFrom_ || sortedFrom_->x != from.x || sortedFrom_->y != from.y || + sortedFrom_->z != from.z; + const bool changed = request.ranges != sortedRanges_ || !placements.empty(); + const auto start = Clock::now(); + if (moved || changed) { + sorted_.clear(); + for (const Range& r : request.ranges) { + for (std::uint32_t i = 0; i < r.count; ++i) sorted_.push_back(r.offset + i); + } + sorter_.sortSubset(from, sorted_); + sortedFrom_ = from; + sortedRanges_ = request.ranges; + lastSortMillis_ = millisBetween(start, Clock::now()); + } + const auto sorted = Clock::now(); + sorter_.cull(sorted_, request.frustum, order); + const auto culled = Clock::now(); + const std::lock_guard lock(mutex_); + std::vector recycled = + finished_ ? std::move(finished_->order) : std::vector(); + finished_ = Result{std::move(order), lastSortMillis_, millisBetween(sorted, culled), + sorted_.size(), request.id}; + order = std::move(recycled); + } +} + +} // namespace splat diff --git a/packages/splat-core/src/tiles/SlabAllocator.cpp b/packages/splat-core/src/tiles/SlabAllocator.cpp new file mode 100644 index 0000000..b039224 --- /dev/null +++ b/packages/splat-core/src/tiles/SlabAllocator.cpp @@ -0,0 +1,47 @@ +#include "splat/tiles/SlabAllocator.h" + +namespace splat { + +SlabAllocator::SlabAllocator(std::uint32_t capacity) : capacity_(capacity) { + if (capacity > 0) free_[0] = capacity; +} + +// Best fit: the smallest hole that takes the range, so big holes stay whole for big tiles. +std::optional SlabAllocator::allocate(std::uint32_t count) { + if (count == 0) return std::nullopt; + auto best = free_.end(); + for (auto it = free_.begin(); it != free_.end(); ++it) { + if (it->second < count) continue; + if (best == free_.end() || it->second < best->second) best = it; + } + if (best == free_.end()) return std::nullopt; + const std::uint32_t offset = best->first; + const std::uint32_t left = best->second - count; + free_.erase(best); + if (left > 0) free_[offset + count] = left; + used_ += count; + return offset; +} + +void SlabAllocator::release(std::uint32_t offset, std::uint32_t count) { + if (count == 0) return; + used_ -= count; + auto next = free_.lower_bound(offset); + // Merge with the free range that ends where this one starts. + if (next != free_.begin()) { + auto prev = std::prev(next); + if (prev->first + prev->second == offset) { + offset = prev->first; + count += prev->second; + free_.erase(prev); + } + } + // And with the one that starts where this one ends. + if (next != free_.end() && next->first == offset + count) { + count += next->second; + free_.erase(next); + } + free_[offset] = count; +} + +} // namespace splat diff --git a/packages/splat-core/src/tiles/TileBuilder.cpp b/packages/splat-core/src/tiles/TileBuilder.cpp new file mode 100644 index 0000000..8df36b8 --- /dev/null +++ b/packages/splat-core/src/tiles/TileBuilder.cpp @@ -0,0 +1,508 @@ +#include "splat/tiles/TileBuilder.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "load-spz.h" +#include "splat/sorting/SpatialOrder.h" + +namespace splat { +namespace { + +using Cloud = spz::GaussianCloud; + +// A splat as the merge sees it: the spz fields decoded to what they mean. +struct Gaussian { + std::array position; + std::array covariance; // xx, xy, xz, yy, yz, zz + float alpha; // opacity in [0, 1] +}; + +std::size_t shStride(const Cloud& c) { + return c.numPoints > 0 ? c.sh.size() / static_cast(c.numPoints) : 0; +} + +float sigmoid(float x) { + return 1.0f / (1.0f + std::exp(-x)); +} + +float logit(float p) { + p = std::clamp(p, 1e-4f, 1.0f - 1e-4f); + return std::log(p / (1.0f - p)); +} + +// Covariance R * S * S^T * R^T of a splat stored as log scales and an xyzw quaternion. +Gaussian decodeGaussian(const Cloud& c, std::size_t i) { + Gaussian g; + for (int k = 0; k < 3; ++k) g.position[k] = c.positions[i * 3 + k]; + const float sx = std::exp(c.scales[i * 3]); + const float sy = std::exp(c.scales[i * 3 + 1]); + const float sz = std::exp(c.scales[i * 3 + 2]); + float x = c.rotations[i * 4]; + float y = c.rotations[i * 4 + 1]; + float z = c.rotations[i * 4 + 2]; + float w = c.rotations[i * 4 + 3]; + const float n = std::sqrt(x * x + y * y + z * z + w * w); + if (n > 0) { + x /= n; + y /= n; + z /= n; + w /= n; + } else { + w = 1; + } + // Rotation matrix columns scaled by the axes: M = R * S. + const float m[3][3] = { + {(1 - 2 * (y * y + z * z)) * sx, 2 * (x * y - w * z) * sy, 2 * (x * z + w * y) * sz}, + {2 * (x * y + w * z) * sx, (1 - 2 * (x * x + z * z)) * sy, 2 * (y * z - w * x) * sz}, + {2 * (x * z - w * y) * sx, 2 * (y * z + w * x) * sy, (1 - 2 * (x * x + y * y)) * sz}, + }; + auto dotRow = [&](int a, int b) { + return m[a][0] * m[b][0] + m[a][1] * m[b][1] + m[a][2] * m[b][2]; + }; + g.covariance = {dotRow(0, 0), dotRow(0, 1), dotRow(0, 2), + dotRow(1, 1), dotRow(1, 2), dotRow(2, 2)}; + g.alpha = sigmoid(c.alphas[i]); + return g; +} + +// Eigenvalues and eigenvectors (columns) of a symmetric 3x3 matrix by cyclic Jacobi +// rotations. Offline the cost does not matter; what matters is that the vectors are +// orthonormal, since they become the rotation of the merged splat. +void jacobiEigen(const std::array& upper, std::array& values, + float vectors[3][3]) { + double a[3][3] = {{upper[0], upper[1], upper[2]}, + {upper[1], upper[3], upper[4]}, + {upper[2], upper[4], upper[5]}}; + double v[3][3] = {{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}; + for (int sweep = 0; sweep < 50; ++sweep) { + const double off = a[0][1] * a[0][1] + a[0][2] * a[0][2] + a[1][2] * a[1][2]; + if (off < 1e-30) break; + for (int p = 0; p < 2; ++p) { + for (int q = p + 1; q < 3; ++q) { + if (std::abs(a[p][q]) < 1e-30) continue; + const double theta = (a[q][q] - a[p][p]) / (2 * a[p][q]); + const double t = + (theta >= 0 ? 1.0 : -1.0) / (std::abs(theta) + std::sqrt(theta * theta + 1)); + const double c = 1 / std::sqrt(t * t + 1); + const double s = t * c; + for (auto& k : a) { + const double akp = k[p]; + const double akq = k[q]; + k[p] = c * akp - s * akq; + k[q] = s * akp + c * akq; + } + for (int k = 0; k < 3; ++k) { + const double apk = a[p][k]; + const double aqk = a[q][k]; + a[p][k] = c * apk - s * aqk; + a[q][k] = s * apk + c * aqk; + } + for (auto& k : v) { + const double vkp = k[p]; + const double vkq = k[q]; + k[p] = c * vkp - s * vkq; + k[q] = s * vkp + c * vkq; + } + } + } + } + for (int k = 0; k < 3; ++k) { + values[k] = static_cast(a[k][k]); + for (int r = 0; r < 3; ++r) vectors[r][k] = static_cast(v[r][k]); + } +} + +// Surface of an ellipsoid with these semi axes (Knud Thomsen's approximation): what a +// splat contributes to the image is its area times its opacity, its weight when merging. +float ellipsoidArea(float a, float b, float c) { + constexpr float kP = 1.6075f; + const float sum = std::pow(a * b, kP) + std::pow(a * c, kP) + std::pow(b * c, kP); + return 4.0f * static_cast(M_PI) * std::pow(sum / 3.0f, 1.0f / kP); +} + +float area(const std::array& cov) { + std::array e; + float vectors[3][3]; + jacobiEigen(cov, e, vectors); + return ellipsoidArea(std::sqrt(std::max(e[0], 0.0f)), std::sqrt(std::max(e[1], 0.0f)), + std::sqrt(std::max(e[2], 0.0f))); +} + +// Writes a merged covariance back as log scales and an xyzw quaternion. +void encodeShape(const std::array& cov, float* scales, float* rotation) { + std::array e; + float r[3][3]; + jacobiEigen(cov, e, r); + // A proper rotation: flip the last axis if the eigenvectors form a mirror. + const float det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1]) - + r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0]) + + r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]); + if (det < 0) { + for (auto& k : r) k[2] = -k[2]; + } + for (int k = 0; k < 3; ++k) scales[k] = std::log(std::sqrt(std::max(e[k], 1e-12f))); + // Rotation matrix to quaternion (Shepperd's method). + const float trace = r[0][0] + r[1][1] + r[2][2]; + float x = NAN; + float y = NAN; + float z = NAN; + float w = NAN; + if (trace > 0) { + const float s = std::sqrt(trace + 1.0f) * 2; + w = 0.25f * s; + x = (r[2][1] - r[1][2]) / s; + y = (r[0][2] - r[2][0]) / s; + z = (r[1][0] - r[0][1]) / s; + } else if (r[0][0] > r[1][1] && r[0][0] > r[2][2]) { + const float s = std::sqrt(1.0f + r[0][0] - r[1][1] - r[2][2]) * 2; + w = (r[2][1] - r[1][2]) / s; + x = 0.25f * s; + y = (r[0][1] + r[1][0]) / s; + z = (r[0][2] + r[2][0]) / s; + } else if (r[1][1] > r[2][2]) { + const float s = std::sqrt(1.0f + r[1][1] - r[0][0] - r[2][2]) * 2; + w = (r[0][2] - r[2][0]) / s; + x = (r[0][1] + r[1][0]) / s; + y = 0.25f * s; + z = (r[1][2] + r[2][1]) / s; + } else { + const float s = std::sqrt(1.0f + r[2][2] - r[0][0] - r[1][1]) * 2; + w = (r[1][0] - r[0][1]) / s; + x = (r[0][2] + r[2][0]) / s; + y = (r[1][2] + r[2][1]) / s; + z = 0.25f * s; + } + rotation[0] = x; + rotation[1] = y; + rotation[2] = z; + rotation[3] = w; +} + +Cloud emptyLike(const Cloud& c) { + Cloud out; + out.shDegree = c.shDegree; + out.antialiased = c.antialiased; + return out; +} + +void append(Cloud& to, const Cloud& from, std::size_t i) { + const std::size_t sh = shStride(from); + to.positions.insert(to.positions.end(), &from.positions[i * 3], &from.positions[i * 3] + 3); + to.scales.insert(to.scales.end(), &from.scales[i * 3], &from.scales[i * 3] + 3); + to.rotations.insert(to.rotations.end(), &from.rotations[i * 4], &from.rotations[i * 4] + 4); + to.colors.insert(to.colors.end(), &from.colors[i * 3], &from.colors[i * 3] + 3); + to.alphas.push_back(from.alphas[i]); + if (sh > 0) to.sh.insert(to.sh.end(), &from.sh[i * sh], &from.sh[i * sh] + sh); + ++to.numPoints; +} + +Cloud gather(const Cloud& c, const std::uint32_t* indices, std::size_t n) { + Cloud out = emptyLike(c); + out.positions.reserve(n * 3); + out.scales.reserve(n * 3); + out.rotations.reserve(n * 4); + out.colors.reserve(n * 3); + out.alphas.reserve(n); + out.sh.reserve(n * shStride(c)); + for (std::size_t k = 0; k < n; ++k) append(out, c, indices[k]); + return out; +} + +Bounds boundsOf(const Cloud& c) { + Bounds b; + if (c.numPoints == 0) return b; + b.min = b.max = {c.positions[0], c.positions[1], c.positions[2]}; + for (std::size_t i = 1; i < static_cast(c.numPoints); ++i) { + for (int k = 0; k < 3; ++k) { + b.min[k] = std::min(b.min[k], c.positions[i * 3 + k]); + b.max[k] = std::max(b.max[k], c.positions[i * 3 + k]); + } + } + return b; +} + +// Splats close in space end up close in the file, what the sort and the GPU fetch want. +void orderSpatially(Cloud& c) { + const auto n = static_cast(c.numPoints); + if (n < 2) return; + const Bounds b = boundsOf(c); + std::vector> keyed(n); + for (std::size_t i = 0; i < n; ++i) { + keyed[i] = {mortonCode(&c.positions[i * 3], b), static_cast(i)}; + } + std::stable_sort(keyed.begin(), keyed.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + std::vector order(n); + for (std::size_t i = 0; i < n; ++i) order[i] = keyed[i].second; + c = gather(c, order.data(), n); +} + +// One splat that stands in for `members`, weighted by area times opacity: its covariance +// is the members' plus their spread about the centre, so it covers what they covered +// (Kerbl et al. 2024). One member merges into itself. Opacity keeps the members' total +// contribution, clamped to what the file can hold: a tile is an spz file, and spz stores +// an opacity in [0, 1]. +void merge(const Cloud& from, const std::vector& decoded, + const std::vector& members, Cloud& to) { + const std::size_t sh = shStride(from); + std::vector weights(members.size()); + float total = 0.0f; + for (std::size_t k = 0; k < members.size(); ++k) { + const Gaussian& g = decoded[members[k]]; + weights[k] = area(g.covariance) * g.alpha; + total += weights[k]; + } + total = std::max(total, 1e-30f); + for (float& w : weights) w /= total; + + std::array center{0, 0, 0}; + std::array rgb{0, 0, 0}; + std::vector shSum(sh, 0.0f); + for (std::size_t k = 0; k < members.size(); ++k) { + const std::uint32_t i = members[k]; + for (int c = 0; c < 3; ++c) { + center[c] += weights[k] * decoded[i].position[c]; + rgb[c] += weights[k] * from.colors[i * 3 + c]; + } + for (std::size_t c = 0; c < sh; ++c) shSum[c] += weights[k] * from.sh[i * sh + c]; + } + std::array cov{0, 0, 0, 0, 0, 0}; + for (std::size_t k = 0; k < members.size(); ++k) { + const Gaussian& g = decoded[members[k]]; + const float dx = g.position[0] - center[0]; + const float dy = g.position[1] - center[1]; + const float dz = g.position[2] - center[2]; + const float w = weights[k]; + cov[0] += w * (dx * dx + g.covariance[0]); + cov[1] += w * (dx * dy + g.covariance[1]); + cov[2] += w * (dx * dz + g.covariance[2]); + cov[3] += w * (dy * dy + g.covariance[3]); + cov[4] += w * (dy * dz + g.covariance[4]); + cov[5] += w * (dz * dz + g.covariance[5]); + } + const float alpha = std::min(1.0f, total / std::max(area(cov), 1e-30f)); + + float scales[3]; + float rotation[4]; + encodeShape(cov, scales, rotation); + to.positions.insert(to.positions.end(), center.begin(), center.end()); + to.scales.insert(to.scales.end(), scales, scales + 3); + to.rotations.insert(to.rotations.end(), rotation, rotation + 4); + to.colors.insert(to.colors.end(), rgb.begin(), rgb.end()); + to.alphas.push_back(logit(alpha)); + to.sh.insert(to.sh.end(), shSum.begin(), shSum.end()); + ++to.numPoints; +} + +// The member contributing most stands for `members`, as it is except for its size: its +// axes grow so that its area is the members' total, and its opacity keeps their total +// contribution like a merge does. Position, orientation, colour and harmonics are one +// real splat's, so the level keeps the edges and colours the leaves have. +void select(const Cloud& from, const std::vector& decoded, + const std::vector& members, Cloud& to) { + std::uint32_t best = members[0]; + float bestWeight = -1.0f; + float totalArea = 0.0f; + float totalWeight = 0.0f; + for (const std::uint32_t i : members) { + const Gaussian& g = decoded[i]; + const float a = area(g.covariance); + const float w = a * g.alpha; + totalArea += a; + totalWeight += w; + if (w > bestWeight) { + bestWeight = w; + best = i; + } + } + const float ownArea = std::max(area(decoded[best].covariance), 1e-30f); + const float grow = std::sqrt(std::max(totalArea / ownArea, 1.0f)); // area scales squared + const float alpha = std::min(1.0f, totalWeight / std::max(totalArea, 1e-30f)); + const std::size_t sh = shStride(from); + to.positions.insert(to.positions.end(), &from.positions[best * 3], &from.positions[best * 3] + 3); + for (int k = 0; k < 3; ++k) to.scales.push_back(from.scales[best * 3 + k] + std::log(grow)); + to.rotations.insert(to.rotations.end(), &from.rotations[best * 4], &from.rotations[best * 4] + 4); + to.colors.insert(to.colors.end(), &from.colors[best * 3], &from.colors[best * 3] + 3); + to.alphas.push_back(logit(alpha)); + if (sh > 0) to.sh.insert(to.sh.end(), &from.sh[best * sh], &from.sh[best * sh] + sh); + ++to.numPoints; +} + +std::uint64_t cellKey(const float* p, const Bounds& cube, float cell) { + std::uint64_t key = 0; + for (int k = 0; k < 3; ++k) { + const float t = std::max(0.0f, (p[k] - cube.min[k]) / cell); + key = key * 2097152u + static_cast(std::min(t, 2097151.0f)); + } + return key; +} + +// Coarsens `from` down to at most `budget` splats on the finest grid over `cube` that +// gets there. Returns the cloud and the cell size used, the error of the tile it becomes. +std::pair coarsen(const Cloud& from, const Bounds& cube, std::uint32_t budget, + Coarsening how) { + const auto n = static_cast(from.numPoints); + const float edge = cube.max[0] - cube.min[0]; + float cell = edge / std::max(4.0f, 4.0f * std::cbrt(static_cast(budget))); + std::unordered_set occupied; + for (;; cell *= 1.25f) { + occupied.clear(); + for (std::size_t i = 0; i < n && occupied.size() <= budget; ++i) { + occupied.insert(cellKey(&from.positions[i * 3], cube, cell)); + } + if (occupied.size() <= budget || cell >= edge) break; + } + + std::vector decoded(n); + for (std::size_t i = 0; i < n; ++i) decoded[i] = decodeGaussian(from, i); + std::vector> keyed(n); + for (std::size_t i = 0; i < n; ++i) { + keyed[i] = {cellKey(&from.positions[i * 3], cube, cell), static_cast(i)}; + } + std::sort(keyed.begin(), keyed.end()); + Cloud out = emptyLike(from); + std::vector members; + for (std::size_t i = 0; i < n;) { + members.clear(); + const std::uint64_t key = keyed[i].first; + for (; i < n && keyed[i].first == key; ++i) members.push_back(keyed[i].second); + if (how == Coarsening::select) { + select(from, decoded, members, out); + } else { + merge(from, decoded, members, out); + } + } + return {std::move(out), cell}; +} + +struct Builder { + const Cloud& cloud; + const std::string& directory; + const TileBuildOptions& options; + Tileset set; + Error* failure = nullptr; + Error stored; + + bool fail(const std::string& message) { + if (failure == nullptr) { + stored = Error{ErrorCode::unreadable, message}; + failure = &stored; + } + return false; + } + + // Writes `tile` and appends its entry; returns its index. + std::uint32_t emit(Cloud& tile, int level, float error, std::vector children) { + orderSpatially(tile); + Tile entry; + entry.file = "tile_" + std::to_string(set.tiles.size()) + ".spz"; + entry.level = level; + entry.bounds = boundsOf(tile); + entry.count = static_cast(tile.numPoints); + entry.error = error; + entry.children = std::move(children); + spz::PackOptions pack; + pack.version = 2; // gzip container, the one every reader supports + if (!spz::saveSpz(tile, pack, directory + "/" + entry.file)) + fail("could not write " + entry.file); + set.tiles.push_back(std::move(entry)); + return static_cast(set.tiles.size() - 1); + } + + // Builds the tile over `cube` holding `indices` and returns its index and its splats, + // which the parent coarsens. A leaf is written as it is; an interior tile splits into + // octants and is written as the coarsening of what came back from them. + std::pair build(std::uint32_t* indices, std::size_t n, const Bounds& cube, + int depth) { + if (n <= options.tileSplats || depth >= 24) { + Cloud tile = gather(cloud, indices, n); + const std::uint32_t index = emit(tile, 0, 0.0f, {}); + return {index, std::move(tile)}; + } + std::array mid; + for (int k = 0; k < 3; ++k) mid[k] = 0.5f * (cube.min[k] + cube.max[k]); + // Partition in place by octant: x, then y within each half, then z. + std::array edges; + edges[0] = indices; + edges[8] = indices + n; + auto splitAt = [&](int axis, std::uint32_t* first, std::uint32_t* last) { + return std::partition(first, last, [&](std::uint32_t i) { + return cloud.positions[static_cast(i) * 3 + axis] < mid[axis]; + }); + }; + edges[4] = splitAt(0, edges[0], edges[8]); + edges[2] = splitAt(1, edges[0], edges[4]); + edges[6] = splitAt(1, edges[4], edges[8]); + for (int o = 0; o < 8; o += 2) edges[o + 1] = splitAt(2, edges[o], edges[o + 2]); + + Cloud merged = emptyLike(cloud); + std::vector children; + int level = 0; + for (int o = 0; o < 8; ++o) { + const auto count = static_cast(edges[o + 1] - edges[o]); + if (count == 0) continue; + Bounds child; + for (int k = 0; k < 3; ++k) { + const bool high = (o >> (2 - k)) & 1; + child.min[k] = high ? mid[k] : cube.min[k]; + child.max[k] = high ? cube.max[k] : mid[k]; + } + auto [index, tile] = build(edges[o], count, child, depth + 1); + children.push_back(index); + level = std::max(level, set.tiles[index].level + 1); + for (std::size_t i = 0; i < static_cast(tile.numPoints); ++i) + append(merged, tile, i); + } + auto [tile, cell] = coarsen(merged, cube, options.tileSplats, options.coarsening); + merged = Cloud{}; + const std::uint32_t index = emit(tile, level, cell, std::move(children)); + return {index, std::move(tile)}; + } +}; + +} // namespace + +Result buildTiles(const Cloud& cloud, const std::string& directory, + const TileBuildOptions& options) { + if (cloud.numPoints <= 0) return Error{ErrorCode::corrupt, "buildTiles: empty cloud"}; + if (options.tileSplats == 0) return Error{ErrorCode::corrupt, "buildTiles: tileSplats is 0"}; + const auto n = static_cast(cloud.numPoints); + // The root cube: the bounds grown to a cube so every octant is a cube too. + Bounds tight = boundsOf(cloud); + float edge = 0.0f; + for (int k = 0; k < 3; ++k) edge = std::max(edge, tight.max[k] - tight.min[k]); + edge = std::max(edge, 1e-6f) * 1.0001f; + Bounds cube; + for (int k = 0; k < 3; ++k) { + const float mid = 0.5f * (tight.min[k] + tight.max[k]); + cube.min[k] = mid - 0.5f * edge; + cube.max[k] = mid + 0.5f * edge; + } + std::vector indices(n); + std::iota(indices.begin(), indices.end(), 0u); + + Builder builder{cloud, directory, options, {}, nullptr, {}}; + builder.set.shDegree = cloud.shDegree; + builder.set.splatCount = n; + builder.set.root = builder.build(indices.data(), n, cube, 0).first; + if (builder.failure != nullptr) return *builder.failure; + + const std::string json = writeTileset(builder.set); + const std::string path = directory + "/tileset.json"; + FILE* f = std::fopen(path.c_str(), "wb"); + if (f == nullptr || std::fwrite(json.data(), 1, json.size(), f) != json.size()) { + if (f != nullptr) std::fclose(f); + return Error{ErrorCode::unreadable, "could not write " + path}; + } + std::fclose(f); + return builder.set; +} + +} // namespace splat diff --git a/packages/splat-core/src/tiles/TileLoader.cpp b/packages/splat-core/src/tiles/TileLoader.cpp new file mode 100644 index 0000000..4e4a569 --- /dev/null +++ b/packages/splat-core/src/tiles/TileLoader.cpp @@ -0,0 +1,84 @@ +#include "splat/tiles/TileLoader.h" + +#include +#include +#include + +#include "splat/io/MappedFile.h" + +namespace splat { + +TileLoader::TileLoader(SplatDecodeOptions options, std::size_t threads) : options_(options) { + for (std::size_t i = 0; i < std::max(threads, 1); ++i) { + threads_.emplace_back([this] { run(); }); + } +} + +TileLoader::~TileLoader() { + { + const std::lock_guard lock(mutex_); + stop_ = true; + } + wake_.notify_all(); + for (auto& t : threads_) t.join(); +} + +std::vector TileLoader::setQueue(std::vector queue) { + const std::lock_guard lock(mutex_); + std::vector dropped; + for (const Request& old : queue_) { + const bool kept = std::any_of(queue.begin(), queue.end(), + [&](const Request& r) { return r.tile == old.tile; }); + if (!kept) dropped.push_back(old.tile); + } + queue_.clear(); + for (Request& r : queue) { + const bool started = std::find(started_.begin(), started_.end(), r.tile) != started_.end(); + const bool finished = std::any_of(finished_.begin(), finished_.end(), + [&](const Loaded& l) { return l.tile == r.tile; }); + if (!started && !finished) queue_.push_back(std::move(r)); + } + wake_.notify_all(); + return dropped; +} + +std::vector TileLoader::take() { + const std::lock_guard lock(mutex_); + return std::exchange(finished_, {}); +} + +std::size_t TileLoader::pending() const { + const std::lock_guard lock(mutex_); + return queue_.size() + started_.size(); +} + +void TileLoader::run() { + using Clock = std::chrono::steady_clock; + for (;;) { + Request request; + { + std::unique_lock lock(mutex_); + wake_.wait(lock, [this] { return stop_ || !queue_.empty(); }); + if (stop_) return; + auto best = std::max_element( + queue_.begin(), queue_.end(), + [](const Request& a, const Request& b) { return a.priority < b.priority; }); + request = std::move(*best); + queue_.erase(best); + started_.push_back(request.tile); + } + const auto start = Clock::now(); + Result cloud = Error{ErrorCode::unreadable, request.path}; + if (auto file = MappedFile::open(request.path)) { + cloud = decodeSplatFile(file.value().data(), file.value().size(), options_); + } else { + cloud = file.error(); + } + const double millis = std::chrono::duration(Clock::now() - start).count(); + const std::lock_guard lock(mutex_); + started_.erase(std::find(started_.begin(), started_.end(), request.tile)); + finished_.push_back({request.tile, std::move(cloud), millis}); + } +} + +} // namespace splat diff --git a/packages/splat-core/src/tiles/TileScheduler.cpp b/packages/splat-core/src/tiles/TileScheduler.cpp new file mode 100644 index 0000000..0547622 --- /dev/null +++ b/packages/splat-core/src/tiles/TileScheduler.cpp @@ -0,0 +1,228 @@ +#include "splat/tiles/TileScheduler.h" + +#include +#include +#include +#include +#include + +namespace splat { + +TileScheduler::TileScheduler(std::shared_ptr tileset, std::uint32_t residency) + : tileset_(std::move(tileset)), + slab_(residency), + states_(tileset_->tiles.size(), TileState::absent), + offsets_(tileset_->tiles.size(), 0), + lastUsed_(tileset_->tiles.size(), 0) { + std::uint64_t total = 0; + for (const Tile& tile : tileset_->tiles) total += tile.count; + fetchAll_ = total <= residency; +} + +bool TileScheduler::visible(std::uint32_t index, const TileView& view) const { + const Tile& tile = tileset_->tiles[index]; + return view.frustum.intersects(tile.bounds.min, tile.bounds.max); +} + +// The world units per unit depth the tile hides: its error over the distance from the +// camera to its box, unbounded with the camera inside. +float TileScheduler::screenError(std::uint32_t index, Vec3 origin) const { + const Tile& tile = tileset_->tiles[index]; + float d2 = 0.0f; + for (int k = 0; k < 3; ++k) { + const float gap = + std::max({tile.bounds.min[k] - origin[k], origin[k] - tile.bounds.max[k], 0.0f}); + d2 += gap * gap; + } + if (d2 <= 0.0f) return std::numeric_limits::infinity(); + return tile.error / std::sqrt(d2); +} + +bool TileScheduler::fineEnough(std::uint32_t index, const TileView& view) const { + const Tile& tile = tileset_->tiles[index]; + if (tile.level == 0 || tile.children.empty()) return true; + return screenError(index, view.frustum.origin) <= view.pixelScaleLimit; +} + +void TileScheduler::want(std::uint32_t index, float priority, std::vector& wanted) { + if (states_[index] == TileState::failed) return; + lastUsed_[index] = frame_; + wanted.push_back({index, priority}); +} + +// The cover: the set of visible tiles to show this frame, chosen so that it fits the +// slab. Refinement goes biggest on screen first: a tile that is not fine enough is +// swapped for its visible children when they fit, and stays as it is when they do not, +// so the budget buys detail where it shows most. A child that cannot be read pins its +// parent. Pinned tiles are not charged: they are the last cover or the one before, on +// their way out or in this one, and charging them would shrink the cover it replaces +// them with until nothing fits and nothing ever draws. +std::vector TileScheduler::cover(const TileView& view) { + std::uint64_t reserved = 0; // splats of the cover, stand-ins included + const auto costOf = [&](std::uint32_t tile) -> std::uint64_t { + return tileset_->tiles[tile].count; + }; + + std::vector out; + if (!visible(tileset_->root, view)) return out; + struct Open { + float error; + std::uint32_t tile; + bool operator<(const Open& o) const { return error < o.error; } + }; + std::priority_queue open; + const Vec3 origin = view.frustum.origin; + reserved += costOf(tileset_->root); + open.push({screenError(tileset_->root, origin), tileset_->root}); + while (!open.empty()) { + const std::uint32_t index = open.top().tile; + open.pop(); + const Tile& tile = tileset_->tiles[index]; + bool refine = !fineEnough(index, view); + std::uint64_t cost = 0; + std::vector shown; + bool landed = true; + if (refine) { + for (const std::uint32_t child : tile.children) { + if (!visible(child, view)) continue; + if (states_[child] == TileState::failed) refine = false; + if (states_[child] != TileState::resident) landed = false; + shown.push_back(child); + cost += costOf(child); + } + if (shown.empty()) refine = false; + } + // A resident tile stands in while its children load, so its room stays taken. + const std::uint64_t freed = + (landed || states_[index] != TileState::resident) ? costOf(index) : 0; + if (refine && reserved - freed + cost > slab_.capacity()) refine = false; + if (!refine) { + out.push_back(index); + continue; + } + reserved += cost - freed; + for (const std::uint32_t child : shown) open.push({screenError(child, origin), child}); + } + return out; +} + +// Walks down to the cover: a cover tile is drawn when resident and wanted otherwise. +// While part of a tile's cover is still on its way, the nearest resident tile above +// the missing part is drawn under what has landed: coarse where the fine is missing, +// doubled for a moment where it is not, and never a hole nor a whole subtree swapped +// for its parent up to the root. Returns whether everything under `index` is covered. +bool TileScheduler::visit(std::uint32_t index, const TileView& view, Plan& plan, + std::vector& wanted, const std::vector& inCover) { + const Tile& tile = tileset_->tiles[index]; + if (inCover[index]) { + if (states_[index] == TileState::resident) { + lastUsed_[index] = frame_; + plan.draw.push_back(index); + return true; + } + want(index, screenError(index, view.frustum.origin), wanted); + return false; + } + bool covered = true; + for (const std::uint32_t child : tile.children) { + if (!visible(child, view)) continue; + if (!visit(child, view, plan, wanted, inCover)) covered = false; + } + if (covered || states_[index] != TileState::resident) return covered; + plan.draw.push_back(index); + lastUsed_[index] = frame_; + return true; +} + +// Reserves a slab range for the tile, evicting the least recently used tiles not touched +// by this plan until it fits. False when it cannot fit even then. +bool TileScheduler::place(std::uint32_t index, Plan& plan, std::vector& evictable) { + const std::uint32_t count = tileset_->tiles[index].count; + if (count > slab_.capacity()) { + states_[index] = TileState::failed; + return false; + } + for (;;) { + if (auto offset = slab_.allocate(count)) { + offsets_[index] = *offset; + states_[index] = TileState::loading; + return true; + } + if (evictable.empty()) return false; + const std::uint32_t victim = evictable.back(); + evictable.pop_back(); + plan.drop.push_back({victim, offsets_[victim], tileset_->tiles[victim].count}); + release(victim, TileState::absent); + } +} + +void TileScheduler::release(std::uint32_t tile, TileState next) { + if (states_[tile] == TileState::loading || states_[tile] == TileState::resident) { + slab_.release(offsets_[tile], tileset_->tiles[tile].count); + } + states_[tile] = next; +} + +TileScheduler::Plan TileScheduler::plan(const TileView& view, + const std::vector& pinned) { + ++frame_; + for (const std::uint32_t tile : pinned) lastUsed_[tile] = frame_; + Plan plan; + std::vector wanted; + std::vector inCover(states_.size(), false); + for (const std::uint32_t tile : cover(view)) inCover[tile] = true; + if (visible(tileset_->root, view)) visit(tileset_->root, view, plan, wanted, inCover); + // The root is wanted whatever the cover: the coarsest fallback of a turn. + if (states_[tileset_->root] != TileState::resident && !inCover[tileset_->root]) { + want(tileset_->root, std::numeric_limits::infinity(), wanted); + } + + std::stable_sort(wanted.begin(), wanted.end(), + [](const Wanted& a, const Wanted& b) { return a.priority > b.priority; }); + + // Eviction candidates, most recently used last so the back is the least recent. + std::vector evictable; + for (std::uint32_t i = 0; i < states_.size(); ++i) { + if (states_[i] == TileState::resident && lastUsed_[i] != frame_) evictable.push_back(i); + } + std::sort(evictable.begin(), evictable.end(), + [&](std::uint32_t a, std::uint32_t b) { return lastUsed_[a] > lastUsed_[b]; }); + // Loads placed for tiles no longer wanted are abandoned so the room goes to the cover. + for (std::uint32_t i = 0; i < states_.size(); ++i) { + if (states_[i] == TileState::loading && lastUsed_[i] != frame_ && !fetchAll_) { + plan.drop.push_back({i, offsets_[i], tileset_->tiles[i].count}); + release(i, TileState::absent); + } + } + + for (const Wanted& w : wanted) { + if (states_[w.tile] == TileState::absent && !place(w.tile, plan, evictable)) continue; + if (states_[w.tile] != TileState::loading) continue; + plan.load.push_back({w.tile, offsets_[w.tile], w.priority}); + } + + // The rest of a scene that fits whole, last and at the lowest priority. Nothing is ever + // evicted in this case, so a tile fetched for a turn stays for it. + if (fetchAll_) { + for (std::uint32_t tile = 0; tile < states_.size(); ++tile) { + if (lastUsed_[tile] == frame_ || states_[tile] == TileState::failed) continue; + if (states_[tile] == TileState::absent && !place(tile, plan, evictable)) continue; + if (states_[tile] == TileState::loading) plan.load.push_back({tile, offsets_[tile], 0.0f}); + } + } + return plan; +} + +void TileScheduler::markResident(std::uint32_t tile) { + if (states_[tile] == TileState::loading) states_[tile] = TileState::resident; +} + +void TileScheduler::markAbsent(std::uint32_t tile) { + release(tile, TileState::absent); +} + +void TileScheduler::markFailed(std::uint32_t tile) { + release(tile, TileState::failed); +} + +} // namespace splat diff --git a/packages/splat-core/src/tiles/TileStreamer.cpp b/packages/splat-core/src/tiles/TileStreamer.cpp new file mode 100644 index 0000000..0524e42 --- /dev/null +++ b/packages/splat-core/src/tiles/TileStreamer.cpp @@ -0,0 +1,121 @@ +#include "splat/tiles/TileStreamer.h" + +#include +#include + +namespace splat { +namespace { + +// Frames the GPU may still be drawing an order after a newer one was taken. +constexpr std::uint64_t kFramesInFlight = 2; + +} // namespace +namespace { + +SplatDecodeOptions decodeOptions(const TiledWorld& world) { + SplatDecodeOptions options; + options.sourceFrame = world.sourceFrame; + return options; +} + +} // namespace + +TileStreamer::TileStreamer(TiledWorld world, const StreamOptions& options) + : world_(std::move(world)), + scheduler_(world_.tileset, options.residency), + loader_(decodeOptions(world_), options.loaderThreads), + sorter_(options.residency), + cpuSort_(options.cpuSort) {} + +std::vector TileStreamer::pinned() const { + std::vector out(shown_); + for (const Order& o : requested_) out.insert(out.end(), o.tiles.begin(), o.tiles.end()); + for (const Order& o : retired_) out.insert(out.end(), o.tiles.begin(), o.tiles.end()); + std::sort(out.begin(), out.end()); + out.erase(std::unique(out.begin(), out.end()), out.end()); + return out; +} + +TileStreamer::Step TileStreamer::update(const TileView& view) { + Step step; + ++updates_; + while (!retired_.empty() && retired_.front().request + kFramesInFlight <= updates_) { + retired_.pop_front(); + } + for (TileLoader::Loaded& loaded : loader_.take()) { + if (scheduler_.state(loaded.tile) != TileState::loading) continue; // dropped meanwhile + if (!loaded.cloud) { + scheduler_.markFailed(loaded.tile); + step.failed.push_back(loaded.tile); + continue; + } + arrived_[loaded.tile] = std::make_unique(std::move(loaded.cloud.value())); + } + + TileScheduler::Plan plan = scheduler_.plan(view, pinned()); + std::vector queue; + for (const TileScheduler::Load& load : plan.load) { + if (arrived_.count(load.tile)) continue; // read already, waiting for its upload + queue.push_back({load.tile, world_.tilePath(load.tile), load.priority}); + } + for (const std::uint32_t tile : loader_.setQueue(std::move(queue))) scheduler_.markAbsent(tile); + + for (const auto& [tile, cloud] : arrived_) { + step.arrived.push_back({tile, scheduler_.offset(tile), cloud.get()}); + } + step.loading = loader_.pending(); + + if (plan.draw != drawn_) { + drawn_ = std::move(plan.draw); + ranges_.clear(); + drawnSplats_ = 0; + for (const std::uint32_t tile : drawn_) { + const std::uint32_t count = world_.tileset->tiles[tile].count; + ranges_.push_back({scheduler_.offset(tile), count}); + drawnSplats_ += count; + } + step.drawChanged = true; + } + return step; +} + +void TileStreamer::commit(std::uint32_t tile) { + auto it = arrived_.find(tile); + if (it == arrived_.end()) return; + if (cpuSort_) sorter_.place(scheduler_.offset(tile), std::move(it->second->positions)); + arrived_.erase(it); + scheduler_.markResident(tile); +} + +void TileStreamer::fail(std::uint32_t tile) { + arrived_.erase(tile); + scheduler_.markAbsent(tile); +} + +void TileStreamer::requestVisible(const Frustum& frustum) { + const std::uint64_t id = sorter_.requestVisible(frustum, ranges_); + requested_.push_back({id, drawn_}); +} + +std::optional TileStreamer::take() { + std::optional result = sorter_.take(); + if (!result) return result; + // Every request up to this one is answered or superseded; the order on the GPU is + // replaced, and the one it replaces may still be in flight for a couple of frames. + while (!requested_.empty() && requested_.front().request <= result->request) { + if (requested_.front().request == result->request) { + retired_.push_back({updates_, std::move(shown_)}); + shown_ = std::move(requested_.front().tiles); + } + requested_.pop_front(); + } + return result; +} + +void TileStreamer::drawnNow() { + if (drawn_ == shown_) return; + retired_.push_back({updates_, std::move(shown_)}); + shown_ = drawn_; +} + +} // namespace splat diff --git a/packages/splat-core/src/tiles/TiledWorld.cpp b/packages/splat-core/src/tiles/TiledWorld.cpp new file mode 100644 index 0000000..d04dde4 --- /dev/null +++ b/packages/splat-core/src/tiles/TiledWorld.cpp @@ -0,0 +1,43 @@ +#include "splat/tiles/TiledWorld.h" + +#include +#include + +#include "splat/io/MappedFile.h" + +namespace splat { + +std::string TiledWorld::tilePath(std::uint32_t tile) const { + return directory + "/" + tileset->tiles[tile].file; +} + +Bounds convertBounds(const Bounds& bounds, CoordinateFrame from, CoordinateFrame to) { + if (from == to) return bounds; + // rdf <-> rub: x stays, y and z flip. + Bounds out = bounds; + for (int k = 1; k < 3; ++k) { + out.min[k] = -bounds.max[k]; + out.max[k] = -bounds.min[k]; + } + return out; +} + +Result openTiledWorld(const std::string& path, CoordinateFrame sourceFrame) { + auto file = MappedFile::open(path); + if (!file) return file.error(); + auto parsed = readTileset( + std::string(reinterpret_cast(file.value().data()), file.value().size())); + if (!parsed) return parsed.error(); + Tileset set = std::move(parsed.value()); + for (Tile& tile : set.tiles) + tile.bounds = convertBounds(tile.bounds, sourceFrame, kInternalFrame); + + TiledWorld world; + const auto slash = path.find_last_of('/'); + world.directory = slash == std::string::npos ? "." : path.substr(0, slash); + world.tileset = std::make_shared(std::move(set)); + world.sourceFrame = sourceFrame; + return world; +} + +} // namespace splat diff --git a/packages/splat-core/src/tiles/Tileset.cpp b/packages/splat-core/src/tiles/Tileset.cpp new file mode 100644 index 0000000..729a4e3 --- /dev/null +++ b/packages/splat-core/src/tiles/Tileset.cpp @@ -0,0 +1,71 @@ +#include "splat/tiles/Tileset.h" + +#include + +namespace splat { +namespace { + +constexpr int kVersion = 1; + +Error corrupt(const std::string& what) { + return Error{ErrorCode::corrupt, "tileset: " + what}; +} + +} // namespace + +std::string writeTileset(const Tileset& tileset) { + nlohmann::json out; + out["version"] = kVersion; + out["shDegree"] = tileset.shDegree; + out["splatCount"] = tileset.splatCount; + out["root"] = tileset.root; + nlohmann::json tiles = nlohmann::json::array(); + for (const Tile& t : tileset.tiles) { + nlohmann::json j; + j["file"] = t.file; + j["level"] = t.level; + j["min"] = t.bounds.min; + j["max"] = t.bounds.max; + j["count"] = t.count; + j["error"] = t.error; + j["children"] = t.children; + tiles.push_back(std::move(j)); + } + out["tiles"] = std::move(tiles); + return out.dump(1); +} + +Result readTileset(const std::string& json) { + nlohmann::json in = nlohmann::json::parse(json, nullptr, false); + if (in.is_discarded() || !in.is_object()) return corrupt("not JSON"); + if (in.value("version", 0) != kVersion) return corrupt("unknown version"); + Tileset set; + try { + set.shDegree = in.at("shDegree").get(); + set.splatCount = in.at("splatCount").get(); + set.root = in.at("root").get(); + for (const auto& j : in.at("tiles")) { + Tile t; + t.file = j.at("file").get(); + t.level = j.at("level").get(); + t.bounds.min = j.at("min").get>(); + t.bounds.max = j.at("max").get>(); + t.count = j.at("count").get(); + t.error = j.at("error").get(); + t.children = j.at("children").get>(); + set.tiles.push_back(std::move(t)); + } + } catch (const nlohmann::json::exception& e) { + return corrupt(e.what()); + } + if (set.root >= set.tiles.size()) return corrupt("root out of range"); + for (const Tile& t : set.tiles) { + for (const std::uint32_t c : t.children) { + if (c >= set.tiles.size()) return corrupt("child out of range"); + if (set.tiles[c].level >= t.level) return corrupt("a child is not below its parent"); + } + } + return set; +} + +} // namespace splat diff --git a/packages/splat-core/tests/CMakeLists.txt b/packages/splat-core/tests/CMakeLists.txt index 57eb6e1..1b6f78d 100644 --- a/packages/splat-core/tests/CMakeLists.txt +++ b/packages/splat-core/tests/CMakeLists.txt @@ -10,13 +10,23 @@ add_executable(splat_core_tests sorting/SpatialOrderTest.cpp sorting/FrustumSortTest.cpp lod/LodTreeTest.cpp + lod/LodFileTest.cpp lod/LodSorterTest.cpp io/MappedFileTest.cpp loading/SplatWorldLoaderTest.cpp sorting/VisibilityPlannerTest.cpp diagnostics/TimingSummaryTest.cpp + tiles/TilesetTest.cpp + tiles/TileBuilderTest.cpp + tiles/SlabAllocatorTest.cpp + tiles/TileSchedulerTest.cpp + tiles/TileStreamerTest.cpp + sorting/SlabSorterTest.cpp + math/FrustumTest.cpp + tools/CloudEditTest.cpp ) target_link_libraries(splat_core_tests PRIVATE splat_core spz ZLIB::ZLIB GTest::gtest_main) +target_include_directories(splat_core_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../tools) target_compile_definitions(splat_core_tests PRIVATE SPLAT_FIXTURES_DIR="${SPLAT_FIXTURES_DIR}" ) diff --git a/packages/splat-core/tests/formats/SpzDecoderTest.cpp b/packages/splat-core/tests/formats/SpzDecoderTest.cpp index be8c75c..474a82f 100644 --- a/packages/splat-core/tests/formats/SpzDecoderTest.cpp +++ b/packages/splat-core/tests/formats/SpzDecoderTest.cpp @@ -254,6 +254,29 @@ TEST(SpzDecoder, PassesHigherOrderShThrough) { EXPECT_NEAR(result.value().sh[8], -0.3f, 0.05f); } +TEST(SpzDecoder, TruncatesHigherOrderShBeforeMaterializingTheCloud) { + spz::GaussianCloud cloud; + cloud.numPoints = 1; + cloud.shDegree = 1; + cloud.positions = {0, 0, 0}; + cloud.scales = {0, 0, 0}; + cloud.rotations = {0, 0, 0, 1}; + cloud.alphas = {0}; + cloud.colors = {0, 0, 0}; + cloud.sh = {0.5f, -0.5f, 0.25f, 0.1f, 0.2f, 0.3f, -0.1f, -0.2f, -0.3f}; + spz::PackOptions pack; + pack.version = 2; + std::vector bytes; + ASSERT_TRUE(spz::saveSpz(cloud, pack, &bytes)); + + SpzDecodeOptions options; + options.maxShDegree = 0; + auto result = decodeSpz(bytes.data(), bytes.size(), options); + ASSERT_TRUE(result.ok()) << result.error().message; + EXPECT_EQ(result.value().shDegree, 0); + EXPECT_TRUE(result.value().sh.empty()); +} + // Opt-in integration test against a real World Labs export. // Run with SPLAT_FIXTURES_DIR pointing at a folder containing kitchen_500k.spz. TEST(SpzDecoder, DecodesWorldLabsKitchen) { diff --git a/packages/splat-core/tests/lod/LodFileTest.cpp b/packages/splat-core/tests/lod/LodFileTest.cpp new file mode 100644 index 0000000..6c4215c --- /dev/null +++ b/packages/splat-core/tests/lod/LodFileTest.cpp @@ -0,0 +1,158 @@ +#include "splat/lod/LodFile.h" + +#include +#include +#include +#include "splat/io/MappedFile.h" +#include "splat/loading/SplatWorldLoader.h" + +namespace splat { +namespace { +LodTree fixture() { + SplatCloud c; + c.shDegree = 1; + c.bounds.min = {-1, 0, -2}; + c.bounds.max = {1, 0, -2}; + c.positions = {-1, 0, -2, 1, 0, -2}; + c.covariances = {1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 0, 1}; + c.colors = {1, 0, 0, 0, 0, 1}; + c.alphas = {0.5f, 0.5f}; + c.sh.assign(18, 0.25f); + LodBuildOptions options; + options.octreeDepth = 6; + return buildLodTree(std::move(c), options); +} +class LodFileTest : public testing::Test { + protected: + std::string path_ = testing::TempDir() + "/lod-file-" + + std::to_string(reinterpret_cast(this)) + ".lodsplat"; + void TearDown() override { std::remove(path_.c_str()); } + std::vector bytes() { + auto file = MappedFile::open(path_); + if (!file) return {}; + return {file.value().data(), file.value().data() + file.value().size()}; + } +}; + +TEST_F(LodFileTest, OctreeMomentMatchRetainsLeavesAndBetweenMeanCovariance) { + const auto t = fixture(); + ASSERT_EQ(t.nodeCount(), 3u); + ASSERT_TRUE(validateLodTree(t)); + EXPECT_EQ(t.leafCount, 2u); + EXPECT_FLOAT_EQ(t.nodes.positions[0], 0); + EXPECT_FLOAT_EQ(t.nodes.covariances[0], 2); // E[cov] + variance of means + EXPECT_FLOAT_EQ(t.nodes.covariances[3], 1); + EXPECT_FLOAT_EQ(t.nodes.colors[0], 0.5f); + EXPECT_FLOAT_EQ(t.nodes.sh[0], 0.25f); + EXPECT_FLOAT_EQ(t.nodes.positions[3], -1); + EXPECT_FLOAT_EQ(t.nodes.positions[6], 1); + EXPECT_FLOAT_EQ(t.nodes.alphas[1], 0.5f); +} + +TEST_F(LodFileTest, BinaryRoundTripPreservesAllAttributesAndCanCapSH) { + const auto t = fixture(); + ASSERT_TRUE(writeLodSplat(t, path_)); + auto data = bytes(); + ASSERT_EQ(data.size(), 64u + t.nodeCount() * 100); + auto loaded = decodeLodSplat(data.data(), data.size()); + ASSERT_TRUE(loaded) << loaded.error().message; + EXPECT_EQ(loaded.value().nodes.positions, t.nodes.positions); + EXPECT_EQ(loaded.value().nodes.covariances, t.nodes.covariances); + EXPECT_EQ(loaded.value().nodes.colors, t.nodes.colors); + EXPECT_EQ(loaded.value().nodes.alphas, t.nodes.alphas); + EXPECT_EQ(loaded.value().nodes.sh, t.nodes.sh); + auto dc = decodeLodSplat(data.data(), data.size(), 0); + ASSERT_TRUE(dc); + EXPECT_EQ(dc.value().nodes.shDegree, 0); + EXPECT_TRUE(dc.value().nodes.sh.empty()); + EXPECT_FALSE(writeLodSplat(t, path_)); + EXPECT_EQ(bytes(), data); // an existing asset is never overwritten +} + +TEST_F(LodFileTest, LoaderRecognizesHierarchyWithoutBuildingOrReorderingIt) { + ASSERT_TRUE(writeLodSplat(fixture(), path_)); + SplatWorldLoader loader; + loader.setBudget(123); + auto report = loader.loadWorldFile(path_); + ASSERT_TRUE(report); + EXPECT_EQ(report.value().splatCount, 2u); + EXPECT_EQ(report.value().nodeCount, 3u); + EXPECT_EQ(report.value().treeMillis, 0); + EXPECT_EQ(report.value().reorderMillis, 0); + auto world = loader.takeWorld(); + ASSERT_TRUE(world && world->tree); + EXPECT_EQ(world->sourceCount, 2u); + EXPECT_EQ(world->budget, 123); + EXPECT_EQ(world->tree->nodes.positions, fixture().nodes.positions); +} + +TEST_F(LodFileTest, MomentMatchingPreservesRotatedCovarianceAndOffsets) { + SplatCloud c; + c.bounds.min = {-1, -1, -2}; + c.bounds.max = {1, 1, -2}; + c.positions = {-1, -1, -2, 1, 1, -2}; + c.covariances = {2, 0.75f, 0, 1, 0, 0.1f, 2, 0.75f, 0, 1, 0, 0.1f}; + c.colors = {1, 0, 0, 0, 0, 1}; + c.alphas = {0.5f, 0.5f}; + LodBuildOptions options; + options.octreeDepth = 6; + const auto tree = buildLodTree(std::move(c), options); + EXPECT_FLOAT_EQ(tree.nodes.covariances[0], 3); + EXPECT_FLOAT_EQ(tree.nodes.covariances[1], 1.75f); + EXPECT_FLOAT_EQ(tree.nodes.covariances[3], 2); + EXPECT_FLOAT_EQ(tree.nodes.covariances[5], 0.1f); +} + +TEST_F(LodFileTest, VersionTwoStoresInteriorBoundsErrorsAndLeafPackets) { + auto tree = fixture(); + tree.selection = buildLodSelectionData(tree); + ASSERT_EQ(tree.selection.clusters.size(), 1u); + ASSERT_EQ(tree.selection.leaves.size(), 2u); + EXPECT_GT(tree.selection.clusters[0].error, 0); + EXPECT_GT(tree.selection.clusters[0].colorVariance, 0); + EXPECT_EQ(tree.selection.clusters[0].subtreeLeaves, 2u); + ASSERT_TRUE(validateLodTree(tree)); + ASSERT_TRUE(writeLodSplat(tree, path_)); + auto data = bytes(); + EXPECT_EQ(data[8], 2); + EXPECT_EQ(data.size(), 64u + tree.nodeCount() * 100 + 64 + 8); + auto loaded = decodeLodSplat(data.data(), data.size(), 0); + ASSERT_TRUE(loaded) << loaded.error().message; + EXPECT_EQ(loaded.value().selection.leaves, tree.selection.leaves); + EXPECT_FLOAT_EQ(loaded.value().selection.clusters[0].error, tree.selection.clusters[0].error); + auto invalid = tree; + invalid.selection.clusters[0].extent[0] = 0; + EXPECT_FALSE(validateLodTree(invalid)); + invalid = tree; + invalid.selection.leaves[0] = 0; + EXPECT_FALSE(validateLodTree(invalid)); + invalid = tree; + invalid.selection.clusters[0].colorVariance = std::numeric_limits::quiet_NaN(); + EXPECT_FALSE(validateLodTree(invalid)); + data.pop_back(); + EXPECT_FALSE(decodeLodSplat(data.data(), data.size())); +} + +TEST_F(LodFileTest, RejectsTruncationCyclesVersionAndInvalidCovarianceBeforeUse) { + ASSERT_TRUE(writeLodSplat(fixture(), path_)); + const auto original = bytes(); + for (const size_t length : {size_t{0}, size_t{8}, size_t{63}, original.size() - 1}) + EXPECT_FALSE(decodeLodSplat(original.data(), length)); + for (const size_t offset : {size_t{8}, size_t{12}, size_t{16}, size_t{24}, size_t{28}, + size_t{64 + 16}, size_t{64 + 20}}) { + auto data = original; + data[offset] = 255; + EXPECT_FALSE(decodeLodSplat(data.data(), data.size())) << offset; + } + auto t = fixture(); + t.layout[0].childStart = 0; + EXPECT_FALSE(validateLodTree(t)); + t = fixture(); + t.nodes.covariances[0] = -1; + EXPECT_FALSE(validateLodTree(t)); + t = fixture(); + t.nodes.sh[0] = std::numeric_limits::quiet_NaN(); + EXPECT_FALSE(validateLodTree(t)); +} +} // namespace +} // namespace splat diff --git a/packages/splat-core/tests/math/FrustumTest.cpp b/packages/splat-core/tests/math/FrustumTest.cpp new file mode 100644 index 0000000..cfd4f82 --- /dev/null +++ b/packages/splat-core/tests/math/FrustumTest.cpp @@ -0,0 +1,39 @@ +#include "splat/math/Frustum.h" + +#include + +namespace splat { +namespace { + +// Looking down -z from the origin, about 53 degrees across. +Frustum forward() { + return Frustum::make({0, 0, 0}, {0, 0, -1}, {0, 1, 0}, 0.5f, 0.5f, 0.0f); +} + +TEST(Frustum, ABoxInFrontIntersectsAndOneBehindDoesNot) { + const Frustum f = forward(); + EXPECT_TRUE(f.intersects({-1, -1, -11}, {1, 1, -9})); + EXPECT_FALSE(f.intersects({-1, -1, 9}, {1, 1, 11})); +} + +TEST(Frustum, ABoxOffToTheSideIsOut) { + const Frustum f = forward(); + // At depth 10 the view is 5 wide each way; a box starting at 6 is past it. + EXPECT_FALSE(f.intersects({6, -1, -11}, {8, 1, -9})); + EXPECT_TRUE(f.intersects({4, -1, -11}, {8, 1, -9})); + EXPECT_FALSE(f.intersects({-1, -8, -11}, {1, -6, -9})); +} + +TEST(Frustum, ABoxAroundTheCameraIntersects) { + const Frustum f = forward(); + EXPECT_TRUE(f.intersects({-5, -5, -5}, {5, 5, 5})); +} + +TEST(Frustum, ABoxJustOutsideTheCornerStillCounts) { + // Conservative: every corner is past some plane, but never all past the same one. + const Frustum f = forward(); + EXPECT_TRUE(f.intersects({4, 4, -11}, {6, 6, -9})); +} + +} // namespace +} // namespace splat diff --git a/packages/splat-core/tests/sorting/SlabSorterTest.cpp b/packages/splat-core/tests/sorting/SlabSorterTest.cpp new file mode 100644 index 0000000..abb9225 --- /dev/null +++ b/packages/splat-core/tests/sorting/SlabSorterTest.cpp @@ -0,0 +1,56 @@ +#include "splat/sorting/SlabSorter.h" + +#include +#include + +#include + +namespace splat { +namespace { + +std::optional waitFor(SlabSorter& sorter) { + for (int i = 0; i < 500; ++i) { + if (auto r = sorter.take()) return r; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return std::nullopt; +} + +TEST(SlabSorter, OrdersTheRangesItIsGivenBackToFrontInSlabIndices) { + SlabSorter sorter(10); + sorter.place(0, {0, 0, -1, 0, 0, -2, 0, 0, -3}); + sorter.place(5, {0, 0, -10, 0, 0, -0.5f}); + const Frustum f = Frustum::make({0, 0, 0}, {0, 0, -1}, {0, 1, 0}, 1.0f, 1.0f, 0.0f); + sorter.requestVisible(f, {{0, 3}, {5, 2}}); + auto result = waitFor(sorter); + ASSERT_TRUE(result); + EXPECT_EQ(result->order, (std::vector{5, 2, 1, 0, 6})); + EXPECT_EQ(result->sorted, 5u); +} + +TEST(SlabSorter, LeavesOutRangesNotAskedForAndSplatsOutOfView) { + SlabSorter sorter(10); + sorter.place(0, {0, 0, -1, 0, 0, -2, 0, 0, -3}); + sorter.place(5, {0, 0, -10, 0, 0, 5}); // the second is behind the camera + const Frustum f = Frustum::make({0, 0, 0}, {0, 0, -1}, {0, 1, 0}, 1.0f, 1.0f, 0.0f); + sorter.requestVisible(f, {{5, 2}}); + auto result = waitFor(sorter); + ASSERT_TRUE(result); + EXPECT_EQ(result->order, std::vector{5}); +} + +TEST(SlabSorter, ATileThatLandsLaterJoinsTheNextOrder) { + SlabSorter sorter(10); + sorter.place(0, {0, 0, -1}); + const Frustum f = Frustum::make({0, 0, 0}, {0, 0, -1}, {0, 1, 0}, 1.0f, 1.0f, 0.0f); + sorter.requestVisible(f, {{0, 1}}); + ASSERT_TRUE(waitFor(sorter)); + sorter.place(1, {0, 0, -4}); + sorter.requestVisible(f, {{0, 2}}); + auto result = waitFor(sorter); + ASSERT_TRUE(result); + EXPECT_EQ(result->order, (std::vector{1, 0})); +} + +} // namespace +} // namespace splat diff --git a/packages/splat-core/tests/tiles/SlabAllocatorTest.cpp b/packages/splat-core/tests/tiles/SlabAllocatorTest.cpp new file mode 100644 index 0000000..78899a8 --- /dev/null +++ b/packages/splat-core/tests/tiles/SlabAllocatorTest.cpp @@ -0,0 +1,52 @@ +#include "splat/tiles/SlabAllocator.h" + +#include + +namespace splat { +namespace { + +TEST(SlabAllocator, HandsOutDisjointRangesUntilFull) { + SlabAllocator slab(100); + const auto a = slab.allocate(40); + const auto b = slab.allocate(40); + ASSERT_TRUE(a && b); + EXPECT_NE(*a, *b); + EXPECT_EQ(slab.used(), 80u); + EXPECT_FALSE(slab.allocate(30)); + EXPECT_TRUE(slab.allocate(20)); + EXPECT_EQ(slab.used(), 100u); +} + +TEST(SlabAllocator, ReleasedNeighboursMergeBackIntoOneRange) { + SlabAllocator slab(100); + const auto a = slab.allocate(30); + const auto b = slab.allocate(30); + const auto c = slab.allocate(40); + ASSERT_TRUE(a && b && c); + slab.release(*a, 30); + slab.release(*c, 40); + EXPECT_FALSE(slab.allocate(50)); // two holes of 30 and 40 + slab.release(*b, 30); + EXPECT_TRUE(slab.allocate(100)); // one hole again + EXPECT_EQ(slab.used(), 100u); +} + +TEST(SlabAllocator, PrefersTheSmallestHoleThatFits) { + SlabAllocator slab(100); + const auto a = slab.allocate(20); + const auto b = slab.allocate(30); + ASSERT_TRUE(a && b); + slab.release(*a, 20); // a hole of 20 at 0, and 50 free at the end + EXPECT_EQ(slab.allocate(10), 0u); // goes in the small hole, not the big one + EXPECT_TRUE(slab.allocate(50)); // the big hole is still whole +} + +TEST(SlabAllocator, RefusesNothingAndTooMuch) { + SlabAllocator slab(10); + EXPECT_FALSE(slab.allocate(0)); + EXPECT_FALSE(slab.allocate(11)); + EXPECT_TRUE(slab.allocate(10)); +} + +} // namespace +} // namespace splat diff --git a/packages/splat-core/tests/tiles/TileBuilderTest.cpp b/packages/splat-core/tests/tiles/TileBuilderTest.cpp new file mode 100644 index 0000000..e0a88b2 --- /dev/null +++ b/packages/splat-core/tests/tiles/TileBuilderTest.cpp @@ -0,0 +1,196 @@ +#include "splat/tiles/TileBuilder.h" + +#include +#include +#include +#include +#include + +#include + +#include "load-spz.h" + +using splat::buildTiles; +using splat::readTileset; +using splat::Tile; +using splat::TileBuildOptions; +using splat::Tileset; + +namespace { + +namespace fs = std::filesystem; + +// Round splats of radius `r`, in two clusters `gap` apart, with SH degree 1. +spz::GaussianCloud clusters(int perCluster, float r, float gap, unsigned seed = 1) { + std::mt19937 rng(seed); + std::normal_distribution spread(0.0f, 1.0f); + spz::GaussianCloud c; + c.shDegree = 1; + for (int cluster = 0; cluster < 2; ++cluster) { + for (int i = 0; i < perCluster; ++i) { + c.positions.insert(c.positions.end(), + {spread(rng) + cluster * gap, spread(rng), spread(rng)}); + const float s = std::log(r); + c.scales.insert(c.scales.end(), {s, s, s}); + c.rotations.insert(c.rotations.end(), {0, 0, 0, 1}); + c.alphas.push_back(4.0f); // sigmoid: 0.98 + c.colors.insert(c.colors.end(), {cluster ? 1.0f : -1.0f, 0.0f, 0.0f}); + for (int k = 0; k < 9; ++k) c.sh.push_back(0.1f * k); + ++c.numPoints; + } + } + return c; +} + +struct TempDir { + fs::path path; + TempDir() { + path = fs::temp_directory_path() / ("splat-tiles-" + std::to_string(std::random_device{}())); + fs::create_directories(path); + } + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + ~TempDir() { fs::remove_all(path); } +}; + +bool inside(const float* p, const splat::Bounds& b, float slack = 1e-4f) { + for (int k = 0; k < 3; ++k) { + if (p[k] < b.min[k] - slack || p[k] > b.max[k] + slack) return false; + } + return true; +} + +TEST(TileBuilder, SplitsUntilTilesFitAndMergesBackUp) { + const TempDir dir; + TileBuildOptions options; + options.tileSplats = 500; + auto built = buildTiles(clusters(1200, 0.05f, 20.0f), dir.path.string(), options); + ASSERT_TRUE(built.ok()) << built.error().message; + const Tileset& set = built.value(); + EXPECT_EQ(set.shDegree, 1); + EXPECT_EQ(set.splatCount, 2400u); + + std::size_t leafSplats = 0; + int deepest = 0; + for (const Tile& t : set.tiles) { + EXPECT_LE(t.count, 500u) << t.file; + EXPECT_TRUE(fs::exists(dir.path / t.file)); + if (t.level == 0) { + leafSplats += t.count; + EXPECT_EQ(t.error, 0.0f); + EXPECT_TRUE(t.children.empty()); + } else { + EXPECT_GT(t.error, 0.0f); + EXPECT_FALSE(t.children.empty()); + for (const uint32_t c : t.children) { + EXPECT_LT(set.tiles[c].level, t.level); + EXPECT_TRUE(inside(set.tiles[c].bounds.min.data(), t.bounds, 1.0f)); + } + } + deepest = std::max(deepest, t.level); + } + EXPECT_EQ(leafSplats, 2400u); // level 0 holds the file's splats, every one once + EXPECT_EQ(set.tiles[set.root].level, deepest); + EXPECT_GE(deepest, 1); +} + +TEST(TileBuilder, TileFilesHoldWhatTheIndexSaysInsideTheirBounds) { + const TempDir dir; + TileBuildOptions options; + options.tileSplats = 300; + auto built = buildTiles(clusters(400, 0.05f, 20.0f), dir.path.string(), options); + ASSERT_TRUE(built.ok()) << built.error().message; + for (const Tile& t : built.value().tiles) { + spz::GaussianCloud c = spz::loadSpz((dir.path / t.file).string(), {}); + ASSERT_EQ(static_cast(c.numPoints), t.count) << t.file; + EXPECT_EQ(c.shDegree, 1); + for (int i = 0; i < c.numPoints; ++i) { + EXPECT_TRUE(inside(&c.positions[i * 3], t.bounds, 0.01f)) << t.file; + } + } +} + +TEST(TileBuilder, WritesAnIndexTheReaderAccepts) { + const TempDir dir; + TileBuildOptions options; + options.tileSplats = 300; + auto built = buildTiles(clusters(400, 0.05f, 20.0f), dir.path.string(), options); + ASSERT_TRUE(built.ok()) << built.error().message; + const std::ifstream in(dir.path / "tileset.json"); + std::stringstream text; + text << in.rdbuf(); + auto read = readTileset(text.str()); + ASSERT_TRUE(read.ok()) << read.error().message; + EXPECT_EQ(read.value().tiles.size(), built.value().tiles.size()); + EXPECT_EQ(read.value().root, built.value().root); +} + +TEST(TileBuilder, AMergedSplatCoversItsMembers) { + // Two clusters far apart, forced into one tile at level 1: each cluster of overlapping + // splats merges into splats that sit on the cluster, no smaller than a member, opaque + // where the members were, with unit rotations and the cluster's colour. + const TempDir dir; + TileBuildOptions options; + options.tileSplats = 64; + auto built = buildTiles(clusters(200, 0.3f, 20.0f), dir.path.string(), options); + ASSERT_TRUE(built.ok()) << built.error().message; + const Tileset& set = built.value(); + const Tile& root = set.tiles[set.root]; + ASSERT_GT(root.level, 0); + spz::GaussianCloud c = spz::loadSpz((dir.path / root.file).string(), {}); + ASSERT_GT(c.numPoints, 0); + for (int i = 0; i < c.numPoints; ++i) { + const float x = c.positions[i * 3]; + EXPECT_TRUE(std::abs(x) < 5.0f || std::abs(x - 20.0f) < 5.0f); + float largest = 0.0f; + for (int k = 0; k < 3; ++k) largest = std::max(largest, std::exp(c.scales[i * 3 + k])); + EXPECT_GE(largest, 0.29f); + EXPECT_GT(1.0f / (1.0f + std::exp(-c.alphas[i])), 0.5f); // opaque members stay opaque + float n = 0.0f; + for (int k = 0; k < 4; ++k) n += c.rotations[i * 4 + k] * c.rotations[i * 4 + k]; + EXPECT_NEAR(n, 1.0f, 1e-3f); + // Red on the left, the other cluster's sign on the right. + EXPECT_EQ(c.colors[i * 3] < 0.0f, x < 10.0f); + } +} + +TEST(TileBuilder, ASelectedSplatIsAMemberGrownToCoverTheCell) { + const TempDir dir; + TileBuildOptions options; + options.tileSplats = 64; + options.coarsening = splat::Coarsening::select; + const spz::GaussianCloud source = clusters(200, 0.3f, 20.0f); + const spz::GaussianCloud copy = source; + auto built = buildTiles(source, dir.path.string(), options); + ASSERT_TRUE(built.ok()) << built.error().message; + const Tileset& set = built.value(); + const Tile& root = set.tiles[set.root]; + ASSERT_GT(root.level, 0); + spz::GaussianCloud c = spz::loadSpz((dir.path / root.file).string(), {}); + ASSERT_GT(c.numPoints, 0); + ASSERT_LE(static_cast(c.numPoints), 64u); + for (int i = 0; i < c.numPoints; ++i) { + // Every splat of the level sits exactly where one of the source splats sits, with + // its colour, and is no smaller than it (spz quantises positions to 1/4096). + bool found = false; + for (int j = 0; j < copy.numPoints && !found; ++j) { + bool same = true; + for (int k = 0; k < 3; ++k) { + same = same && std::abs(c.positions[i * 3 + k] - copy.positions[j * 3 + k]) < 2e-3f; + } + if (!same) continue; + found = true; + EXPECT_NEAR(c.colors[i * 3], copy.colors[j * 3], 0.02f); + for (int k = 0; k < 3; ++k) EXPECT_GE(c.scales[i * 3 + k], copy.scales[j * 3 + k] - 0.05f); + } + EXPECT_TRUE(found) << "splat " << i; + EXPECT_GT(1.0f / (1.0f + std::exp(-c.alphas[i])), 0.5f); + } +} + +TEST(TileBuilder, RefusesAnEmptyCloud) { + const TempDir dir; + EXPECT_FALSE(buildTiles(spz::GaussianCloud{}, dir.path.string()).ok()); +} + +} // namespace diff --git a/packages/splat-core/tests/tiles/TileSchedulerTest.cpp b/packages/splat-core/tests/tiles/TileSchedulerTest.cpp new file mode 100644 index 0000000..261e5ab --- /dev/null +++ b/packages/splat-core/tests/tiles/TileSchedulerTest.cpp @@ -0,0 +1,279 @@ +#include "splat/tiles/TileScheduler.h" + +#include +#include + +#include + +namespace splat { +namespace { + +// A root cube of 20 with eight octant children of 100 splats each, their boxes pulled +// in to [1, 9] on every axis so that a camera inside one sees only that one. +std::shared_ptr octants() { + Tileset set; + set.shDegree = 0; + set.splatCount = 800; + Tile root; + root.file = "root.spz"; + root.level = 1; + root.bounds = {{-10, -10, -10}, {10, 10, 10}}; + root.count = 100; + root.error = 1.0f; + for (int i = 0; i < 8; ++i) { + Tile child; + child.file = "child" + std::to_string(i) + ".spz"; + child.count = 100; + for (int k = 0; k < 3; ++k) { + const bool high = (i >> k) & 1; + child.bounds.min[k] = high ? 1.0f : -9.0f; + child.bounds.max[k] = high ? 9.0f : -1.0f; + } + root.children.push_back(static_cast(set.tiles.size())); + set.tiles.push_back(child); + } + set.root = static_cast(set.tiles.size()); + set.tiles.push_back(root); + return std::make_shared(std::move(set)); +} + +TileView from(Vec3 origin, Vec3 forward, float tanHalf, float limit) { + TileView view; + view.frustum = Frustum::make(origin, forward, {0, 1, 0}, tanHalf, tanHalf, 0.0f); + view.pixelScaleLimit = limit; + return view; +} + +std::vector tilesOf(const std::vector& loads) { + std::vector out; + out.reserve(loads.size()); + for (const auto& l : loads) out.push_back(l.tile); + std::sort(out.begin(), out.end()); + return out; +} + +TEST(TileScheduler, AsksForTheRootFirstAndDrawsItUntilTheChildrenAreThere) { + auto set = octants(); + TileScheduler scheduler(set, 1000); + const TileView inside = from({0, 0, 0}, {0, 0, -1}, 1.0f, 0.001f); + + auto plan = scheduler.plan(inside); + EXPECT_TRUE(plan.draw.empty()); + // The root first, as the fallback, then the four octants in front of the camera; the + // four behind it come last, since the scene fits whole. + ASSERT_EQ(plan.load.size(), 9u); + EXPECT_EQ(plan.load[0].tile, set->root); + std::vector first(5); + for (std::size_t i = 0; i < 5; ++i) first[i] = plan.load[i].tile; + std::sort(first.begin(), first.end()); + EXPECT_EQ(first, (std::vector{0, 1, 2, 3, set->root})); + EXPECT_EQ(scheduler.state(set->root), TileState::loading); + + scheduler.markResident(set->root); + plan = scheduler.plan(inside); + EXPECT_EQ(plan.draw, std::vector{set->root}); + EXPECT_EQ(tilesOf(plan.load), (std::vector{0, 1, 2, 3, 4, 5, 6, 7})); + EXPECT_EQ(scheduler.held(), 900u); + + // Two landed: they are drawn, and the root under them where the other two go. + for (int i = 0; i < 2; ++i) scheduler.markResident(static_cast(i)); + plan = scheduler.plan(inside); + std::sort(plan.draw.begin(), plan.draw.end()); + EXPECT_EQ(plan.draw, (std::vector{0, 1, set->root})); + EXPECT_EQ(tilesOf(plan.load), (std::vector{2, 3, 4, 5, 6, 7})); + + for (int i = 2; i < 4; ++i) scheduler.markResident(static_cast(i)); + plan = scheduler.plan(inside); + std::sort(plan.draw.begin(), plan.draw.end()); + EXPECT_EQ(plan.draw, (std::vector{0, 1, 2, 3})); + EXPECT_EQ(tilesOf(plan.load), (std::vector{4, 5, 6, 7})); +} + +TEST(TileScheduler, FarAwayTheRootIsFineEnough) { + auto set = octants(); + TileScheduler scheduler(set, 1000); + scheduler.plan(from({0, 0, 1000}, {0, 0, -1}, 1.0f, 0.01f)); + scheduler.markResident(set->root); + auto plan = scheduler.plan(from({0, 0, 1000}, {0, 0, -1}, 1.0f, 0.01f)); + EXPECT_EQ(plan.draw, std::vector{set->root}); + // Nothing finer is needed; the octants come only because the scene fits whole. + EXPECT_EQ(tilesOf(plan.load), (std::vector{0, 1, 2, 3, 4, 5, 6, 7})); + for (const auto& l : plan.load) EXPECT_EQ(l.priority, 0.0f); +} + +TEST(TileScheduler, OnlyWhatTheCameraSeesIsWanted) { + auto set = octants(); + TileScheduler scheduler(set, 1000); + // Inside octant 7 (all axes high), looking +x with a narrow view. + const TileView narrow = from({5, 5, 5}, {1, 0, 0}, 0.27f, 0.001f); + scheduler.plan(narrow); + scheduler.markResident(set->root); + auto plan = scheduler.plan(narrow); + // The octant in view is wanted first; the scene fits whole, so the seven others follow + // at the lowest priority and a turn finds them there. + ASSERT_EQ(plan.load.size(), 8u); + EXPECT_EQ(plan.load[0].tile, 7u); + for (std::size_t i = 1; i < 8; ++i) EXPECT_EQ(plan.load[i].priority, 0.0f); + scheduler.markResident(7); + plan = scheduler.plan(narrow); + EXPECT_EQ(plan.draw, std::vector{7}); +} + +TEST(TileScheduler, ASceneBiggerThanTheBudgetIsFetchedOnlyWhereSeen) { + auto set = octants(); + TileScheduler scheduler(set, 850); // 50 short of the whole scene + const TileView narrow = from({5, 5, 5}, {1, 0, 0}, 0.27f, 0.001f); + scheduler.plan(narrow); + scheduler.markResident(set->root); + auto plan = scheduler.plan(narrow); + EXPECT_EQ(tilesOf(plan.load), std::vector{7}); + scheduler.markResident(7); + plan = scheduler.plan(narrow); + EXPECT_TRUE(plan.load.empty()); + EXPECT_EQ(scheduler.held(), 200u); +} + +TEST(TileScheduler, MakesRoomByDroppingWhatWasNotDrawnLately) { + auto set = octants(); + TileScheduler scheduler(set, 250); // the root and one octant + const TileView inSeven = from({5, 5, 5}, {1, 0, 0}, 0.27f, 0.001f); + const TileView inZero = from({-5, -5, -5}, {-1, 0, 0}, 0.27f, 0.001f); + scheduler.plan(inSeven); + scheduler.markResident(set->root); + scheduler.plan(inSeven); + scheduler.markResident(7); + EXPECT_EQ(scheduler.held(), 200u); + + auto plan = scheduler.plan(inZero); + ASSERT_EQ(plan.drop.size(), 1u); + EXPECT_EQ(plan.drop[0].tile, 7u); + EXPECT_EQ(tilesOf(plan.load), std::vector{0}); + EXPECT_EQ(scheduler.state(7), TileState::absent); + EXPECT_EQ(scheduler.held(), 200u); +} + +TEST(TileScheduler, ACoverThatDoesNotFitIsNotStarted) { + auto set = octants(); + TileScheduler scheduler(set, 250); + const TileView inside = from({0, 0, 0}, {0, 0, -1}, 1.0f, 0.001f); + scheduler.plan(inside); + scheduler.markResident(set->root); + auto plan = scheduler.plan(inside); + // Four octants would be finer, but they do not fit: the root is shown as it is, + // whole, rather than one octant and a hole. + EXPECT_EQ(plan.draw, std::vector{set->root}); + EXPECT_TRUE(plan.load.empty()); + EXPECT_TRUE(plan.drop.empty()); + EXPECT_EQ(scheduler.state(set->root), TileState::resident); +} + +TEST(TileScheduler, AFailedTileIsNeverAskedForAgain) { + auto set = octants(); + TileScheduler scheduler(set, 1000); + const TileView narrow = from({5, 5, 5}, {1, 0, 0}, 0.27f, 0.001f); + scheduler.plan(narrow); + scheduler.markResident(set->root); + scheduler.plan(narrow); + scheduler.markFailed(7); + auto plan = scheduler.plan(narrow); + for (const auto& l : plan.load) EXPECT_NE(l.tile, 7u); + EXPECT_EQ(plan.draw, std::vector{set->root}); + EXPECT_EQ(scheduler.held(), 800u); // the root and the seven octants that can be read +} + +TEST(TileScheduler, ATurnOntoAMissingChildKeepsTheSiblingsOnScreen) { + auto set = octants(); + TileScheduler scheduler(set, 1000); + // Inside octant 7 looking +x: only 7 wanted and drawn. + const TileView narrow = from({5, 5, 5}, {1, 0, 0}, 0.27f, 0.001f); + scheduler.plan(narrow); + scheduler.markResident(set->root); + scheduler.plan(narrow); + scheduler.markResident(7); + EXPECT_EQ(scheduler.plan(narrow).draw, std::vector{7}); + // Turn around to a wide view of the other octants: 7 stays drawn while they load. + const TileView wide = from({5, 5, 5}, {-1, 0, 0}, 1.0f, 0.001f); + auto plan = scheduler.plan(wide); + std::sort(plan.draw.begin(), plan.draw.end()); + EXPECT_EQ(plan.draw, (std::vector{7, set->root})); + EXPECT_FALSE(plan.load.empty()); + for (const auto& l : plan.load) EXPECT_NE(l.tile, 7u); +} + +TEST(TileScheduler, AnAbandonedLoadFreesItsRange) { + auto set = octants(); + TileScheduler scheduler(set, 1000); + const TileView narrow = from({5, 5, 5}, {1, 0, 0}, 0.27f, 0.001f); + scheduler.plan(narrow); + scheduler.markResident(set->root); + scheduler.plan(narrow); + EXPECT_EQ(scheduler.held(), 900u); // the root, 7 and the rest of the scene on their way + scheduler.markAbsent(7); + EXPECT_EQ(scheduler.held(), 800u); + auto plan = scheduler.plan(narrow); + ASSERT_FALSE(plan.load.empty()); + EXPECT_EQ(plan.load[0].tile, 7u); + EXPECT_EQ(scheduler.held(), 900u); +} + +TEST(TileScheduler, APinnedTileIsNotEvictedWhileNotDrawn) { + auto set = octants(); + TileScheduler scheduler(set, 250); // the root and one octant + const TileView inSeven = from({5, 5, 5}, {1, 0, 0}, 0.27f, 0.001f); + const TileView inZero = from({-5, -5, -5}, {-1, 0, 0}, 0.27f, 0.001f); + scheduler.plan(inSeven); + scheduler.markResident(set->root); + scheduler.plan(inSeven); + scheduler.markResident(7); + + auto plan = scheduler.plan(inZero, {7}); + EXPECT_TRUE(plan.drop.empty()); + EXPECT_TRUE(plan.load.empty()); // octant 0 is wanted but has no room until 7 is let go + EXPECT_EQ(plan.draw, std::vector{set->root}); + EXPECT_EQ(scheduler.state(7), TileState::resident); + EXPECT_EQ(scheduler.state(0), TileState::absent); + + plan = scheduler.plan(inZero); + ASSERT_EQ(plan.drop.size(), 1u); + EXPECT_EQ(plan.drop[0].tile, 7u); +} + +TEST(TileScheduler, AChildWaitingForItsSiblingsIsNotEvictedToMakeRoomForThem) { + auto set = octants(); + TileScheduler scheduler(set, 500); // the root and the four octants in front + const TileView inside = from({0, 0, 0}, {0, 0, -1}, 1.0f, 0.001f); + scheduler.plan(inside); + scheduler.markResident(set->root); + auto plan = scheduler.plan(inside); + ASSERT_EQ(plan.load.size(), 4u); + const std::uint32_t first = plan.load[0].tile; + scheduler.markResident(first); + scheduler.markAbsent(plan.load[1].tile); // its read was abandoned + for (int i = 0; i < 5; ++i) { + plan = scheduler.plan(inside); + EXPECT_EQ(scheduler.state(first), TileState::resident) << "plan " << i; + for (const auto& d : plan.drop) EXPECT_NE(d.tile, first); + } +} + +// The order on the GPU names the fine cover; the cover it is replaced with must be the +// same fine one, not a coarser one squeezed in next to it. +TEST(TileScheduler, PinnedTilesDoNotShrinkTheCover) { + auto set = octants(); + TileScheduler scheduler(set, 500); // the root and the four octants in front + const TileView inside = from({0, 0, 0}, {0, 0, -1}, 1.0f, 0.001f); + scheduler.plan(inside); + scheduler.markResident(set->root); + auto plan = scheduler.plan(inside); + for (const auto& l : plan.load) scheduler.markResident(l.tile); + plan = scheduler.plan(inside); + std::sort(plan.draw.begin(), plan.draw.end()); + ASSERT_EQ(plan.draw, (std::vector{0, 1, 2, 3})); + plan = scheduler.plan(inside, {0, 1, 2, 3}); + std::sort(plan.draw.begin(), plan.draw.end()); + EXPECT_EQ(plan.draw, (std::vector{0, 1, 2, 3})); + EXPECT_TRUE(plan.load.empty()); +} + +} // namespace +} // namespace splat diff --git a/packages/splat-core/tests/tiles/TileStreamerTest.cpp b/packages/splat-core/tests/tiles/TileStreamerTest.cpp new file mode 100644 index 0000000..14c0b71 --- /dev/null +++ b/packages/splat-core/tests/tiles/TileStreamerTest.cpp @@ -0,0 +1,286 @@ +#include "splat/tiles/TileStreamer.h" + +#include +#include +#include +#include +#include + +#include + +#include "load-spz.h" +#include "splat/tiles/TileBuilder.h" + +namespace splat { +namespace { + +namespace fs = std::filesystem; + +// Round splats in two clusters `gap` apart along x, SH degree 0. +spz::GaussianCloud clusters(int perCluster, float r, float gap) { + std::mt19937 rng(7); + std::normal_distribution spread(0.0f, 1.0f); + spz::GaussianCloud c; + c.shDegree = 0; + for (int cluster = 0; cluster < 2; ++cluster) { + for (int i = 0; i < perCluster; ++i) { + c.positions.insert(c.positions.end(), + {spread(rng) + cluster * gap, spread(rng), spread(rng)}); + const float s = std::log(r); + c.scales.insert(c.scales.end(), {s, s, s}); + c.rotations.insert(c.rotations.end(), {0, 0, 0, 1}); + c.alphas.push_back(4.0f); + c.colors.insert(c.colors.end(), {0.5f, 0.0f, 0.0f}); + ++c.numPoints; + } + } + return c; +} + +struct TempDir { + fs::path path; + TempDir() { + path = fs::temp_directory_path() / ("splat-stream-" + std::to_string(std::random_device{}())); + fs::create_directories(path); + } + TempDir(const TempDir&) = delete; + TempDir& operator=(const TempDir&) = delete; + ~TempDir() { fs::remove_all(path); } +}; + +TileView from(Vec3 origin, float limit) { + TileView view; + view.frustum = Frustum::make(origin, {0, 0, -1}, {0, 1, 0}, 1.0f, 1.0f, 0.5f); + view.pixelScaleLimit = limit; + return view; +} + +// Runs updates, committing every arrival, until nothing is loading and the draw set is +// still for a few rounds. Returns the rounds it took. +int settle(TileStreamer& streamer, const TileView& view) { + int quiet = 0; + for (int round = 0; round < 2000; ++round) { + auto step = streamer.update(view); + for (const auto& a : step.arrived) streamer.commit(a.tile); + if (step.loading == 0 && step.arrived.empty() && !step.drawChanged) { + if (++quiet == 5) return round; + } else { + quiet = 0; + } + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return -1; +} + +struct BuiltWorld { + TempDir dir; + TiledWorld world; + BuiltWorld() { + TileBuildOptions options; + options.tileSplats = 64; + auto built = buildTiles(clusters(200, 0.3f, 20.0f), dir.path.string(), options); + EXPECT_TRUE(built.ok()); + // The builder wrote the cloud's own coordinates; read them back unchanged. + auto opened = openTiledWorld((dir.path / "tileset.json").string(), kInternalFrame); + EXPECT_TRUE(opened.ok()) << (opened.ok() ? "" : opened.error().message); + world = opened.value(); + } +}; + +TEST(TileStreamer, StreamsDownToTheFinestTilesNearTheCamera) { + const BuiltWorld built; + StreamOptions options; + options.residency = 1000; + TileStreamer streamer(built.world, options); + const TileView near = from({0, 0, 3}, 0.0001f); + ASSERT_GE(settle(streamer, near), 0); + + const Tileset& set = *built.world.tileset; + std::size_t drawnSplats = 0; + for (const std::uint32_t tile : streamer.drawn()) { + EXPECT_EQ(set.tiles[tile].level, 0) << set.tiles[tile].file; + drawnSplats += set.tiles[tile].count; + } + // Every leaf the camera can see is drawn, and only those: the far cluster is off to + // the side of this view. + std::size_t visibleLeafSplats = 0; + for (const Tile& t : set.tiles) { + if (t.level == 0 && near.frustum.intersects(t.bounds.min, t.bounds.max)) { + visibleLeafSplats += t.count; + } + } + EXPECT_EQ(drawnSplats, visibleLeafSplats); + EXPECT_LT(drawnSplats, 400u); + EXPECT_EQ(streamer.drawnSplats(), drawnSplats); + EXPECT_LE(streamer.held(), 1000u); + + streamer.requestVisible(near.frustum); + std::optional order; + for (int i = 0; i < 500 && !order; ++i) { + order = streamer.take(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ASSERT_TRUE(order); + EXPECT_LE(order->order.size(), drawnSplats); + EXPECT_GT(order->order.size(), 100u); + EXPECT_LT(*std::max_element(order->order.begin(), order->order.end()), 1000u); +} + +// A tile the order on the GPU still draws keeps its range until a newer order replaced it +// and the frames that used it are done, however far the camera went. +TEST(TileStreamer, TilesOfTheOrderOnTheGpuStayUntilANewerOrderIsTaken) { + const BuiltWorld built; + const Tileset& set = *built.world.tileset; + StreamOptions options; + options.residency = 500; // one cluster's leaves and their parents, not both clusters' + TileStreamer streamer(built.world, options); + const TileView near = from({0, 0, 3}, 0.0001f); + ASSERT_GE(settle(streamer, near), 0); + const std::vector shown = streamer.drawn(); + ASSERT_GT(shown.size(), 1u); + for (const std::uint32_t tile : shown) ASSERT_EQ(set.tiles[tile].level, 0); + streamer.requestVisible(near.frustum); + std::optional order; + for (int i = 0; i < 500 && !order; ++i) { + order = streamer.take(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ASSERT_TRUE(order); + + // Walk to the far cluster, which needs the room, without taking any order: what the + // GPU draws stays, and the far cluster makes do with what fits. + const TileView far = from({20, 0, 3}, 0.0001f); + for (int round = 0; round < 300; ++round) { + auto step = streamer.update(far); + for (const auto& a : step.arrived) streamer.commit(a.tile); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + for (const std::uint32_t tile : shown) { + EXPECT_EQ(streamer.state(tile), TileState::resident) << set.tiles[tile].file; + } + // normal_distribution produces different tile populations across standard libraries. + // The invariant is the configured residency budget, not one platform's population. + EXPECT_LE(streamer.held(), options.residency); + + // Once the order for the far view is taken and the frames that drew the old one are + // done, the near tiles may go and the far cluster refines all the way. + streamer.requestVisible(far.frustum); + order.reset(); + for (int i = 0; i < 500 && !order; ++i) { + order = streamer.take(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ASSERT_TRUE(order); + // Orders keep flowing as the draw set refines, like the engine asks for them. + for (int pass = 0; pass < 3; ++pass) { + ASSERT_GE(settle(streamer, far), 0); + streamer.requestVisible(far.frustum); + order.reset(); + for (int i = 0; i < 500 && !order; ++i) { + order = streamer.take(); + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + ASSERT_TRUE(order); + } + for (const std::uint32_t tile : streamer.drawn()) { + EXPECT_EQ(set.tiles[tile].level, 0) << set.tiles[tile].file; + } + EXPECT_LE(streamer.held(), 500u); +} + +// A renderer that orders the ranges itself has no order to take: `drawnNow` pins the +// drawn tiles the same way, until the frames that may draw them are done. +TEST(TileStreamer, TilesDrawnNowStayUntilTheFramesInFlightAreDone) { + const BuiltWorld built; + const Tileset& set = *built.world.tileset; + StreamOptions options; + options.residency = 500; + options.cpuSort = false; + TileStreamer streamer(built.world, options); + const TileView near = from({0, 0, 3}, 0.0001f); + ASSERT_GE(settle(streamer, near), 0); + const std::vector shown = streamer.drawn(); + ASSERT_GT(shown.size(), 1u); + ASSERT_EQ(streamer.ranges().size(), shown.size()); + streamer.drawnNow(); + + // Walking away without drawing again: what the GPU draws stays resident. + const TileView far = from({20, 0, 3}, 0.0001f); + for (int round = 0; round < 300; ++round) { + auto step = streamer.update(far); + for (const auto& a : step.arrived) streamer.commit(a.tile); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + for (const std::uint32_t tile : shown) { + EXPECT_EQ(streamer.state(tile), TileState::resident) << set.tiles[tile].file; + } + EXPECT_LE(streamer.held(), options.residency); + + // Drawing the far set frees the near tiles a couple of updates later, and the far + // cluster refines all the way. + for (int pass = 0; pass < 3; ++pass) { + ASSERT_GE(settle(streamer, far), 0); + streamer.drawnNow(); + } + for (const std::uint32_t tile : streamer.drawn()) { + EXPECT_EQ(set.tiles[tile].level, 0) << set.tiles[tile].file; + } + EXPECT_LE(streamer.held(), 500u); +} + +TEST(TileStreamer, FarAwayOnlyTheRootIsDrawn) { + const BuiltWorld built; + StreamOptions options; + options.residency = 1000; + TileStreamer streamer(built.world, options); + ASSERT_GE(settle(streamer, from({0, 0, 3}, 0.0001f)), 0); + ASSERT_GE(settle(streamer, from({10, 0, 2000}, 0.01f)), 0); + EXPECT_EQ(streamer.drawn(), std::vector{built.world.tileset->root}); +} + +TEST(TileStreamer, AMissingTileFileIsReportedAndItsParentStays) { + const BuiltWorld built; + const Tileset& set = *built.world.tileset; + const TileView near = from({0, 0, 3}, 0.0001f); + // Remove one leaf file the camera can see. + std::uint32_t leaf = 0; + for (std::uint32_t i = 0; i < set.tiles.size(); ++i) { + const Tile& t = set.tiles[i]; + if (t.level == 0 && near.frustum.intersects(t.bounds.min, t.bounds.max)) leaf = i; + } + fs::remove(built.dir.path / set.tiles[leaf].file); + + StreamOptions options; + options.residency = 1000; + TileStreamer streamer(built.world, options); + bool failed = false; + for (int round = 0; round < 2000 && !failed; ++round) { + auto step = streamer.update(near); + for (const auto& a : step.arrived) streamer.commit(a.tile); + for (const std::uint32_t f : step.failed) failed = failed || f == leaf; + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + EXPECT_TRUE(failed); + ASSERT_GE(settle(streamer, near), 0); + bool leafDrawn = false; + bool parentDrawn = false; + for (const std::uint32_t tile : streamer.drawn()) { + leafDrawn = leafDrawn || tile == leaf; + for (const std::uint32_t child : set.tiles[tile].children) + parentDrawn = parentDrawn || child == leaf; + } + EXPECT_FALSE(leafDrawn); + EXPECT_TRUE(parentDrawn); +} + +TEST(TiledWorld, ConvertsBoundsBetweenFrames) { + const Bounds b{{1, 2, 3}, {4, 5, 6}}; + const Bounds r = convertBounds(b, CoordinateFrame::rdf, CoordinateFrame::rub); + EXPECT_EQ(r.min, (std::array{1, -5, -6})); + EXPECT_EQ(r.max, (std::array{4, -2, -3})); + const Bounds same = convertBounds(b, CoordinateFrame::rub, CoordinateFrame::rub); + EXPECT_EQ(same.min, b.min); +} + +} // namespace +} // namespace splat diff --git a/packages/splat-core/tests/tiles/TilesetTest.cpp b/packages/splat-core/tests/tiles/TilesetTest.cpp new file mode 100644 index 0000000..7b3486e --- /dev/null +++ b/packages/splat-core/tests/tiles/TilesetTest.cpp @@ -0,0 +1,61 @@ +#include "splat/tiles/Tileset.h" + +#include + +using splat::ErrorCode; +using splat::readTileset; +using splat::Tile; +using splat::Tileset; +using splat::writeTileset; + +namespace { + +Tileset twoLevels() { + Tileset set; + set.shDegree = 1; + set.splatCount = 10; + Tile leaf; + leaf.file = "tile_0.spz"; + leaf.bounds.min = {0, 0, 0}; + leaf.bounds.max = {1, 2, 3}; + leaf.count = 10; + Tile root; + root.file = "tile_1.spz"; + root.level = 1; + root.bounds = leaf.bounds; + root.count = 4; + root.error = 0.5f; + root.children = {0}; + set.tiles = {leaf, root}; + set.root = 1; + return set; +} + +TEST(Tileset, RoundTripsThroughJson) { + const Tileset set = twoLevels(); + auto back = readTileset(writeTileset(set)); + ASSERT_TRUE(back.ok()) << back.error().message; + const Tileset& r = back.value(); + EXPECT_EQ(r.shDegree, 1); + EXPECT_EQ(r.splatCount, 10u); + EXPECT_EQ(r.root, 1u); + ASSERT_EQ(r.tiles.size(), 2u); + EXPECT_EQ(r.tiles[1].file, "tile_1.spz"); + EXPECT_EQ(r.tiles[1].level, 1); + EXPECT_FLOAT_EQ(r.tiles[1].error, 0.5f); + EXPECT_EQ(r.tiles[1].children, std::vector{0}); + EXPECT_EQ(r.tiles[0].bounds.max[2], 3.0f); +} + +TEST(Tileset, RefusesWhatItCannotRead) { + EXPECT_EQ(readTileset("not json").error().code, ErrorCode::corrupt); + EXPECT_EQ(readTileset("{\"version\":99}").error().code, ErrorCode::corrupt); + Tileset set = twoLevels(); + set.root = 7; + EXPECT_EQ(readTileset(writeTileset(set)).error().code, ErrorCode::corrupt); + set = twoLevels(); + set.tiles[1].children = {1}; // a tile below itself + EXPECT_EQ(readTileset(writeTileset(set)).error().code, ErrorCode::corrupt); +} + +} // namespace diff --git a/packages/splat-core/tests/tools/CloudEditTest.cpp b/packages/splat-core/tests/tools/CloudEditTest.cpp new file mode 100644 index 0000000..5fa8178 --- /dev/null +++ b/packages/splat-core/tests/tools/CloudEditTest.cpp @@ -0,0 +1,76 @@ +#include "CloudEdit.h" + +#include + +#include + +using splat::tools::packedAlpha; +using splat::tools::pruneAlpha; +using splat::tools::truncateSh; + +namespace { + +float logit(float opacity) { + return std::log(opacity / (1.0f - opacity)); +} + +// Four splats with opacities that pack to 0, 1, 128 and 255, SH degree 1, each field +// carrying the splat's index so a survivor can be told apart from its neighbours. +spz::GaussianCloud four() { + spz::GaussianCloud c; + c.numPoints = 4; + c.shDegree = 1; + const float opacities[4] = {0.001f, 0.004f, 0.5f, 0.999f}; + for (int i = 0; i < 4; ++i) { + for (int k = 0; k < 3; ++k) { + c.positions.push_back(static_cast(i)); + c.scales.push_back(static_cast(i)); + c.colors.push_back(static_cast(i)); + } + for (int k = 0; k < 4; ++k) c.rotations.push_back(static_cast(i)); + c.alphas.push_back(logit(opacities[i])); + for (int k = 0; k < 9; ++k) c.sh.push_back(static_cast(i)); + } + return c; +} + +TEST(CloudEdit, PacksAlphaTheWaySpzDoes) { + EXPECT_EQ(packedAlpha(logit(0.001f)), 0); + EXPECT_EQ(packedAlpha(logit(0.004f)), 1); + EXPECT_EQ(packedAlpha(logit(0.5f)), 128); + EXPECT_EQ(packedAlpha(logit(0.999f)), 255); +} + +TEST(CloudEdit, PruningAtOneOver255DropsOnlyWhatDrawsNothing) { + spz::GaussianCloud c = four(); + EXPECT_EQ(pruneAlpha(c, 1.0f / 255.0f), 1); + ASSERT_EQ(c.numPoints, 3); + EXPECT_EQ(c.positions[0], 1.0f); + EXPECT_EQ(c.rotations[0], 1.0f); + EXPECT_EQ(c.sh[0], 1.0f); + EXPECT_EQ(c.sh.size(), 27u); + EXPECT_EQ(c.positions[6], 3.0f); +} + +TEST(CloudEdit, PruningHigherDropsTheFaintLayers) { + spz::GaussianCloud c = four(); + EXPECT_EQ(pruneAlpha(c, 0.5f), 2); + ASSERT_EQ(c.numPoints, 2); + EXPECT_EQ(c.positions[0], 2.0f); + EXPECT_EQ(c.alphas.size(), 2u); +} + +TEST(CloudEdit, PruningAtZeroKeepsEverything) { + spz::GaussianCloud c = four(); + EXPECT_EQ(pruneAlpha(c, 0.0f), 0); + EXPECT_EQ(c.numPoints, 4); +} + +TEST(CloudEdit, TruncatesHarmonicsPerPoint) { + spz::GaussianCloud c = four(); + truncateSh(c, 0); + EXPECT_EQ(c.shDegree, 0); + EXPECT_TRUE(c.sh.empty()); +} + +} // namespace diff --git a/packages/splat-core/tools/CMakeLists.txt b/packages/splat-core/tools/CMakeLists.txt index b81f322..a6123b8 100644 --- a/packages/splat-core/tools/CMakeLists.txt +++ b/packages/splat-core/tools/CMakeLists.txt @@ -1,5 +1,16 @@ add_executable(ply2spz ply2spz.cpp) +add_executable(splat_lod_build splat_lod_build.cpp) +target_link_libraries(splat_lod_build PRIVATE splat_core) +target_compile_options(splat_lod_build PRIVATE + $<$:-Wall -Wextra -Wpedantic -Werror> +) target_link_libraries(ply2spz PRIVATE spz) target_compile_options(ply2spz PRIVATE $<$:-Wall -Wextra -Wpedantic -Werror> ) + +add_executable(splat-tile splat-tile.cpp) +target_link_libraries(splat-tile PRIVATE splat_core spz) +target_compile_options(splat-tile PRIVATE + $<$:-Wall -Wextra -Wpedantic -Werror> +) diff --git a/packages/splat-core/tools/CloudEdit.h b/packages/splat-core/tools/CloudEdit.h new file mode 100644 index 0000000..130ea04 --- /dev/null +++ b/packages/splat-core/tools/CloudEdit.h @@ -0,0 +1,74 @@ +// Edits the tools apply to a scene before packing it: dropping splats, truncating harmonics. +// Header only, shared by ply2spz and splat-tile and covered by tests/tools/CloudEditTest.cpp. +#pragma once + +#include +#include +#include +#include + +#include "load-spz.h" + +namespace splat::tools { + +// Keeps the splats `keep(i)` accepts, in place and in order. +template +void filter(spz::GaussianCloud& cloud, Keep keep) { + const int n = cloud.numPoints; + const int shPerPoint = n > 0 ? static_cast(cloud.sh.size()) / n : 0; + int kept = 0; + for (int i = 0; i < n; ++i) { + if (!keep(i)) continue; + std::memmove(&cloud.positions[kept * 3], &cloud.positions[i * 3], 3 * sizeof(float)); + std::memmove(&cloud.scales[kept * 3], &cloud.scales[i * 3], 3 * sizeof(float)); + std::memmove(&cloud.rotations[kept * 4], &cloud.rotations[i * 4], 4 * sizeof(float)); + std::memmove(&cloud.colors[kept * 3], &cloud.colors[i * 3], 3 * sizeof(float)); + cloud.alphas[kept] = cloud.alphas[i]; + if (shPerPoint > 0) { + std::memmove(&cloud.sh[kept * shPerPoint], &cloud.sh[i * shPerPoint], + shPerPoint * sizeof(float)); + } + ++kept; + } + cloud.numPoints = kept; + cloud.positions.resize(kept * 3); + cloud.scales.resize(kept * 3); + cloud.rotations.resize(kept * 4); + cloud.colors.resize(kept * 3); + cloud.alphas.resize(kept); + cloud.sh.resize(static_cast(kept) * shPerPoint); +} + +// The opacity byte SPZ stores for a splat: the cloud keeps alpha as a logit, the file as +// round(sigmoid(alpha) * 255). It is what the renderer will see, so pruning decides on it. +inline int packedAlpha(float logit) { + const float opacity = 1.0f / (1.0f + std::exp(-logit)); + return static_cast(std::lround(opacity * 255.0f)); +} + +// Drops the splats whose stored opacity is below `opacity` (0 to 1). At 1/255 a splat goes +// only when its byte would be zero, which draws nothing, so the picture is unchanged; above +// that the picture loses the faintest layers and the caller judges the trade. Returns how +// many went. +inline int pruneAlpha(spz::GaussianCloud& cloud, float opacity) { + const int least = static_cast(std::lround(opacity * 255.0f)); + const int before = cloud.numPoints; + filter(cloud, [&](int i) { return packedAlpha(cloud.alphas[i]) >= least; }); + return before - cloud.numPoints; +} + +// Truncates the harmonics to `degree`; spz stores them per point, coefficient major. +inline void truncateSh(spz::GaussianCloud& cloud, int degree) { + if (degree >= cloud.shDegree) return; + const int from = (cloud.shDegree + 1) * (cloud.shDegree + 1) - 1; + const int to = (degree + 1) * (degree + 1) - 1; + std::vector sh(static_cast(cloud.numPoints) * to * 3); + for (int i = 0; i < cloud.numPoints; ++i) { + std::memcpy(&sh[static_cast(i) * to * 3], &cloud.sh[static_cast(i) * from * 3], + static_cast(to) * 3 * sizeof(float)); + } + cloud.sh = std::move(sh); + cloud.shDegree = degree; +} + +} // namespace splat::tools diff --git a/packages/splat-core/tools/ply2spz.cpp b/packages/splat-core/tools/ply2spz.cpp index 1e43a0a..a0635e4 100644 --- a/packages/splat-core/tools/ply2spz.cpp +++ b/packages/splat-core/tools/ply2spz.cpp @@ -1,13 +1,15 @@ // ply2spz: converts a Gaussian splat PLY (the 3DGS reference layout, what SuperSplat, // Polycam and the Mip-NeRF 360 scenes export) into an SPZ container the engine loads. // -// ply2spz in.ply out.spz [--sh N] [--keep N] [--drop-over M] +// ply2spz in.ply out.spz [--sh N] [--keep N] [--drop-over M] [--prune-alpha T] // // --sh N keeps spherical harmonics up to degree N (0 to 3); the file's degree by default. // Degree 3 costs 92 bytes per splat on the GPU, so drop it for scenes above 2M. // --keep N keeps every Nth splat, for scenes too big for a phone. // --drop-over M drops splats whose largest axis exceeds M meters. Scenes carry a few huge // background splats that each cost a full screen of fragments on a phone. +// --prune-alpha T drops splats whose stored opacity is below T (0 to 1). 1/255 drops only +// what draws nothing; anything higher trades the faintest layers for speed. // // The PLY's coordinates are written as they are, and the reference 3DGS frame is what the // engine assumes for a file without a frame tag, so a scene converted here stands upright. @@ -19,57 +21,18 @@ #include #include +#include "CloudEdit.h" #include "load-spz.h" namespace { int usage() { - std::fprintf(stderr, "usage: ply2spz in.ply out.spz [--sh N] [--keep N] [--drop-over M]\n"); + std::fprintf( + stderr, + "usage: ply2spz in.ply out.spz [--sh N] [--keep N] [--drop-over M] [--prune-alpha T]\n"); return 2; } -// Keeps the splats `keep(i)` accepts, in place. -template -void filter(spz::GaussianCloud& cloud, Keep keep) { - const int n = cloud.numPoints; - const int shPerPoint = cloud.numPoints > 0 ? static_cast(cloud.sh.size()) / n : 0; - int kept = 0; - for (int i = 0; i < n; ++i) { - if (!keep(i)) continue; - std::memmove(&cloud.positions[kept * 3], &cloud.positions[i * 3], 3 * sizeof(float)); - std::memmove(&cloud.scales[kept * 3], &cloud.scales[i * 3], 3 * sizeof(float)); - std::memmove(&cloud.rotations[kept * 4], &cloud.rotations[i * 4], 4 * sizeof(float)); - std::memmove(&cloud.colors[kept * 3], &cloud.colors[i * 3], 3 * sizeof(float)); - cloud.alphas[kept] = cloud.alphas[i]; - if (shPerPoint > 0) { - std::memmove(&cloud.sh[kept * shPerPoint], &cloud.sh[i * shPerPoint], - shPerPoint * sizeof(float)); - } - ++kept; - } - cloud.numPoints = kept; - cloud.positions.resize(kept * 3); - cloud.scales.resize(kept * 3); - cloud.rotations.resize(kept * 4); - cloud.colors.resize(kept * 3); - cloud.alphas.resize(kept); - cloud.sh.resize(kept * shPerPoint); -} - -// Truncates the harmonics to `degree`; spz stores them per point, coefficient major. -void truncateSh(spz::GaussianCloud& cloud, int degree) { - if (degree >= cloud.shDegree) return; - const int from = (cloud.shDegree + 1) * (cloud.shDegree + 1) - 1; - const int to = (degree + 1) * (degree + 1) - 1; - std::vector sh(static_cast(cloud.numPoints) * to * 3); - for (int i = 0; i < cloud.numPoints; ++i) { - std::memcpy(&sh[static_cast(i) * to * 3], &cloud.sh[static_cast(i) * from * 3], - static_cast(to) * 3 * sizeof(float)); - } - cloud.sh = std::move(sh); - cloud.shDegree = degree; -} - } // namespace // The conversion; main only turns an exception (a bad allocation on a huge file) into an exit code. @@ -80,6 +43,7 @@ int run(int argc, char** argv) { int sh = -1; int keep = 1; float dropOver = 0.0f; + float pruneAlpha = -1.0f; for (int i = 3; i < argc; ++i) { if (std::strcmp(argv[i], "--sh") == 0 && i + 1 < argc) sh = std::atoi(argv[++i]); @@ -87,10 +51,12 @@ int run(int argc, char** argv) { keep = std::atoi(argv[++i]); else if (std::strcmp(argv[i], "--drop-over") == 0 && i + 1 < argc) dropOver = std::strtof(argv[++i], nullptr); + else if (std::strcmp(argv[i], "--prune-alpha") == 0 && i + 1 < argc) + pruneAlpha = std::strtof(argv[++i], nullptr); else return usage(); } - if (sh > 3 || keep < 1) return usage(); + if (sh > 3 || keep < 1 || pruneAlpha > 1.0f) return usage(); spz::GaussianCloud cloud = spz::loadSplatFromPly(in, {}); if (cloud.numPoints <= 0) { @@ -98,17 +64,21 @@ int run(int argc, char** argv) { return 1; } std::printf("%d splats, sh degree %d\n", cloud.numPoints, cloud.shDegree); - if (keep > 1) filter(cloud, [keep](int i) { return i % keep == 0; }); + if (keep > 1) splat::tools::filter(cloud, [keep](int i) { return i % keep == 0; }); if (dropOver > 0.0f) { const float limit = std::log(dropOver); // scales are stored as logs const int before = cloud.numPoints; - filter(cloud, [&](int i) { + splat::tools::filter(cloud, [&](int i) { const float* s = &cloud.scales[i * 3]; return s[0] <= limit && s[1] <= limit && s[2] <= limit; }); std::printf("dropped %d splats over %.1f m\n", before - cloud.numPoints, dropOver); } - if (sh >= 0) truncateSh(cloud, sh); + if (pruneAlpha >= 0.0f) { + const int dropped = splat::tools::pruneAlpha(cloud, pruneAlpha); + std::printf("pruned %d splats below opacity %.4f\n", dropped, pruneAlpha); + } + if (sh >= 0) splat::tools::truncateSh(cloud, sh); std::vector bytes; spz::PackOptions pack; diff --git a/packages/splat-core/tools/splat-tile.cpp b/packages/splat-core/tools/splat-tile.cpp new file mode 100644 index 0000000..25f9b85 --- /dev/null +++ b/packages/splat-core/tools/splat-tile.cpp @@ -0,0 +1,125 @@ +// splat-tile: partitions a Gaussian splat scene into tiles with offline levels of detail, +// the form the engine streams (ADR 0015). +// +// splat-tile in.ply|in.spz out_dir [--tile N] [--sh N] [--coarsen merge|select] +// [--prune-alpha T] +// +// --tile N the most splats per tile, 262144 by default. Leaves split until they fit and +// every level above coarsens back down to it. +// --sh N keeps spherical harmonics up to degree N before tiling. +// --coarsen how a level is made from the tiles below it: `merge` (default) blends each +// grid cell into one covering splat; `select` keeps the cell's strongest splat. +// --prune-alpha T drops splats whose stored opacity is below T (0 to 1) before tiling. +// 1/255 drops only what draws nothing; higher trades faint layers for speed. +// +// Writes out_dir/tileset.json and one spz per tile. Coordinates are written as they are. +#include +#include +#include +#include +#include +#include +#include +#include + +#include "CloudEdit.h" +#include "load-spz.h" +#include "splat/tiles/TileBuilder.h" + +namespace { + +int usage() { + std::fprintf( + stderr, + "usage: splat-tile in.ply|in.spz out_dir [--tile N] [--sh N] [--coarsen merge|select]\n" + " [--prune-alpha T]\n"); + return 2; +} + +bool endsWith(const std::string& s, const char* suffix) { + const size_t n = std::strlen(suffix); + return s.size() >= n && s.compare(s.size() - n, n, suffix) == 0; +} + +int run(int argc, char** argv) { + if (argc < 3) return usage(); + const std::string in = argv[1]; + const std::string out = argv[2]; + splat::TileBuildOptions options; + int sh = -1; + float pruneAlpha = -1.0f; + for (int i = 3; i < argc; ++i) { + if (std::strcmp(argv[i], "--tile") == 0 && i + 1 < argc) + options.tileSplats = static_cast(std::atoi(argv[++i])); + else if (std::strcmp(argv[i], "--sh") == 0 && i + 1 < argc) + sh = std::atoi(argv[++i]); + else if (std::strcmp(argv[i], "--coarsen") == 0 && i + 1 < argc) { + const char* how = argv[++i]; + if (std::strcmp(how, "merge") == 0) + options.coarsening = splat::Coarsening::merge; + else if (std::strcmp(how, "select") == 0) + options.coarsening = splat::Coarsening::select; + else + return usage(); + } else if (std::strcmp(argv[i], "--prune-alpha") == 0 && i + 1 < argc) + pruneAlpha = std::strtof(argv[++i], nullptr); + else + return usage(); + } + if (sh > 3 || options.tileSplats == 0 || pruneAlpha > 1.0f) return usage(); + + const auto start = std::chrono::steady_clock::now(); + spz::GaussianCloud cloud = + endsWith(in, ".spz") ? spz::loadSpz(in, {}) : spz::loadSplatFromPly(in, {}); + if (cloud.numPoints <= 0) { + std::fprintf(stderr, "could not read %s as a Gaussian splat scene\n", in.c_str()); + return 1; + } + std::printf("%d splats, sh degree %d\n", cloud.numPoints, cloud.shDegree); + if (pruneAlpha >= 0.0f) { + const int dropped = splat::tools::pruneAlpha(cloud, pruneAlpha); + std::printf("pruned %d splats below opacity %.4f\n", dropped, pruneAlpha); + } + if (sh >= 0) splat::tools::truncateSh(cloud, sh); + std::filesystem::create_directories(out); + + auto built = splat::buildTiles(cloud, out, options); + if (!built.ok()) { + std::fprintf(stderr, "%s\n", built.error().message.c_str()); + return 1; + } + const splat::Tileset& set = built.value(); + std::vector tilesPerLevel; + std::vector splatsPerLevel; + std::vector bytesPerLevel; + for (const splat::Tile& t : set.tiles) { + const auto level = static_cast(t.level); + if (level >= tilesPerLevel.size()) { + tilesPerLevel.resize(level + 1); + splatsPerLevel.resize(level + 1); + bytesPerLevel.resize(level + 1); + } + ++tilesPerLevel[level]; + splatsPerLevel[level] += t.count; + bytesPerLevel[level] += std::filesystem::file_size(std::filesystem::path(out) / t.file); + } + const double seconds = + std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + std::printf("wrote %zu tiles in %.1f s\n", set.tiles.size(), seconds); + for (std::size_t level = 0; level < tilesPerLevel.size(); ++level) { + std::printf("level %zu: %zu tiles, %zu splats, %.1f MB\n", level, tilesPerLevel[level], + splatsPerLevel[level], bytesPerLevel[level] / 1048576.0); + } + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + try { + return run(argc, argv); + } catch (const std::exception& e) { + std::fprintf(stderr, "splat-tile failed: %s\n", e.what()); + return 1; + } +} diff --git a/packages/splat-core/tools/splat_lod_build.cpp b/packages/splat-core/tools/splat_lod_build.cpp new file mode 100644 index 0000000..d98136c --- /dev/null +++ b/packages/splat-core/tools/splat_lod_build.cpp @@ -0,0 +1,70 @@ +#include +#include +#include +#include +#include + +#include "splat/formats/SplatDecoder.h" +#include "splat/io/MappedFile.h" +#include "splat/lod/LodFile.h" + +namespace { +int run(int argc, char** argv) { + if (argc < 3 || (argc - 3) % 2 != 0) { + std::fprintf(stderr, + "usage: splat_lod_build input.spz output.lodsplat [--depth 6] [--sh 0..3]\n"); + return 2; + } + splat::LodBuildOptions build; + build.octreeDepth = 6; + splat::SplatDecodeOptions decode; + for (int i = 3; i < argc; i += 2) { + const std::string key(argv[i]); + const std::string text(argv[i + 1]); + int value = 0; + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value); + if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size()) return 2; + if (key == "--depth" && value >= 1 && value <= 10) + build.octreeDepth = static_cast(value); + else if (key == "--sh" && value >= 0 && value <= 3) + decode.maxShDegree = value; + else + return 2; + } + const auto start = std::chrono::steady_clock::now(); + // Mapping is released before building the hierarchy, limiting peak resident memory. + auto cloud = [&]() -> splat::Result { + auto mapped = splat::MappedFile::open(argv[1]); + if (!mapped) return mapped.error(); + return splat::decodeSplatFile(mapped.value().data(), mapped.value().size(), decode); + }(); + if (!cloud) { + std::fprintf(stderr, "%s\n", cloud.error().message.c_str()); + return 1; + } + std::printf("decoded %zu splats, SH%d; building depth %u octree\n", cloud.value().count(), + cloud.value().shDegree, build.octreeDepth); + std::fflush(stdout); + auto tree = splat::buildLodTree(std::move(cloud.value()), build); + tree.selection = splat::buildLodSelectionData(tree); + auto written = splat::writeLodSplat(tree, argv[2]); + if (!written) { + std::fprintf(stderr, "%s\n", written.error().message.c_str()); + return 1; + } + const double seconds = + std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + std::printf("wrote %zu nodes (%zu original leaves), %.2f seconds: %s\n", tree.nodeCount(), + tree.leafCount, seconds, argv[2]); + return 0; +} +} // namespace + +int main(int argc, char** argv) { + try { + return run(argc, argv); + } catch (const std::exception& e) { + std::fprintf(stderr, "splat_lod_build failed: %s\n", e.what()); + return 1; + } +} diff --git a/packages/splatkit-android/README.md b/packages/splatkit-android/README.md index 94ff4d9..62cc27f 100644 --- a/packages/splatkit-android/README.md +++ b/packages/splatkit-android/README.md @@ -1,135 +1,99 @@ # splatkit-android -Android engine for Gaussian splat worlds: Vulkan renderer, walk and fly camera, touch and gyroscope input, and a `SurfaceView` to put in a layout. -Consumes `splat-core` for formats, sorting and navigation. -`apps/android-dev` is a plain Android host that uses everything below. +Native `SplatSurfaceView`, Vulkan renderer, walk/fly camera, touch and gyroscope input. +Requires Android 10/API 29, Vulkan 1.1 and arm64-v8a. +GPU ordering additionally requires supported compute subgroups and buffer limits. +An arm64 emulator can test functionality; it is not a phone performance measurement. -Requirements: Android 10 (API 29) and a Vulkan 1.1 device. -Only `arm64-v8a` is built. +## Install -## Use it +New GPU integration: `0.1.0-alpha05`. Check [releases](https://github.com/Xget7/splatkit-android/releases) for publication status. +Maven `0.1.0-alpha04` is the older CPU-ordering artifact. ```kotlin dependencies { - implementation("io.github.xget7:splatkit-android:0.1.0-alpha04") + implementation("io.github.xget7:splatkit-android:0.1.0-alpha05") } ``` -The AAR ships `arm64-v8a` only. -A build that also targets `x86_64` still compiles, but the library cannot load on an x86_64 emulator; develop on a physical arm64 device. +For current source, use `implementation(project(":splatkit"))` in the included +`apps/android-dev` host, or include `packages/splatkit-android` as a Gradle module. -To work against a checkout instead, include the module in `settings.gradle.kts`: +## Host ```kotlin -include(":splatkit-android") -project(":splatkit-android").projectDir = file("../splatkit/packages/splatkit-android") -``` - -and depend on it with `implementation(project(":splatkit-android"))`. +import android.app.Activity +import android.os.Bundle +import com.splatkit.SplatSurfaceView +import java.io.File -Put a `SplatSurfaceView` in the layout, forward the lifecycle, and hand it the bytes of an SPZ file: - -```kotlin class WorldActivity : Activity() { - private lateinit var splatView: SplatSurfaceView - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - splatView = SplatSurfaceView(this) - setContentView(splatView) - splatView.listener = object : SplatSurfaceView.Listener { - override fun onWorldReady(splatCount: Int) { /* hide the spinner */ } - override fun onWorldFailed(message: String) { /* show it */ } + private lateinit var splats: SplatSurfaceView + + override fun onCreate(state: Bundle?) { + super.onCreate(state) + splats = SplatSurfaceView(this) + setContentView(splats) + splats.listener = object : SplatSurfaceView.Listener { + override fun onWorldReady(splatCount: Int) { /* hide loading UI */ } + override fun onWorldFailed(message: String) { /* show error */ } } - Thread { - splatView.loadWorld(assets.open("world.spz").use { it.readBytes() }) - splatView.loadCollider(assets.open("collider.glb").use { it.readBytes() }) - }.start() - splatView.setMotionEnabled(true) + // Put your world in app storage first. File loading avoids a Java-heap copy. + splats.loadWorld(File(filesDir, "world.spz")) } - override fun onResume() { super.onResume(); splatView.resume() } - override fun onPause() { splatView.pause(); super.onPause() } - override fun onDestroy() { splatView.release(); super.onDestroy() } + override fun onResume() { super.onResume(); splats.resume() } + override fun onPause() { splats.pause(); super.onPause() } + override fun onDestroy() { splats.release(); super.onDestroy() } } ``` -`loadWorld` returns immediately and decodes on the engine's loader thread; reading the bytes is what needs a background thread, as above. -The upload to the GPU happens on the next frame and `onWorldReady` follows on the main thread. -Without a collider the camera flies; with one it walks on the mesh and `onColliderReady` fires. -World Labs exports both files for every world. +Loads decode asynchronously; ready/failure callbacks run on the main thread. +Use `loadCollider(File)` for optional GLB walk collision; otherwise the camera flies. +SPZ v2–v4 and offline `.lodsplat` worlds are supported. PLY needs offline conversion. +Compose can host the view through `AndroidView`; forward the same lifecycle. +React Native GPU controls are not implemented yet. -## API +## Controls -| Member | What it does | +| API | Contract | |---|---| -| `loadWorld(bytes)`, `loadWorld(file)` | Decodes a splat file (SPZ versions 2 to 4 today; the format is detected from the bytes) and replaces the current world when ready. The `File` overload maps the file instead of copying it through the Java heap: use it for anything big. | -| `loadCollider(bytes)`, `loadCollider(file)` | Decodes a GLB mesh and switches to walk mode. | -| `cameraPose` | Position in meters plus yaw and pitch in radians, as of the last frame. Set it to teleport or to restore a saved viewpoint; when walking the camera settles on the floor under the new point. | -| `listener` | `Listener` with `onWorldReady`, `onWorldFailed`, `onColliderReady`, `onColliderFailed`, on the main thread. | -| `isAvailable` | False when Vulkan could not start; the view stays blank and every call is a no-op. | -| `applyQuality(RenderQuality)` | Sets the five values below from a preset: `RenderQuality.LOW`, `MEDIUM`, `HIGH` (the default) or `ULTRA`, or a `copy` of one. Each preset's reason is on the class and its numbers are in `docs/BENCHMARKS.md`. | -| `renderScale` | Fraction of the surface resolution the splats are drawn at, 0.1 to 2, then rescaled. 0.7 is hard to tell from 1.0 and much cheaper; above 1 supersamples, for the square of the scale in frame time. | -| `cullMarginDegrees` | Angular margin around the view kept drawn so a turn never meets an empty edge; 10 by default, widened further by the engine during a fast turn. | -| `linearBlending` | Blend in linear light instead of the encoded space the training used. Richer contrast the training never saw, 40% of the frame on Adreno 640. Off by default. | -| `splatBudget` | Most splats drawn per frame through a level of detail tree, 0 (default) draws them all. For scenes far bigger than the view or for low quality modes; at full resolution on a 2M scene it saves nothing and softens the image. Applies to worlds loaded after it is set. | -| `shDegree` | Spherical harmonics degree drawn, 0 to 3, capped by what the world carries; takes effect on the next frame. The harmonics are the view dependent colour: the glint on water and leaves. Free to draw on Adreno 640. | -| `maxShDegree` | Highest degree kept in GPU memory from the file, for worlds loaded after it is set. A memory cap, 92 bytes per splat at degree 3, not a quality setting; `shDegree` cannot exceed it for that world. World Labs worlds carry none. | -| `setMotionEnabled(bool)` | The gyroscope drives the look direction. | -| `setWalkVelocity(forward, right)` | Continuous walking in meters per second, for an on screen joystick. | -| `lookSensitivity`, `walkSensitivity` | Gesture tuning: one finger looks, two fingers walk, double tap toggles the gyroscope. | -| `readStats()` | fps, frame and GPU milliseconds, sort time, splat count. Cheap, any thread. | -| `gpuDescription` | GPU name and Vulkan version from the driver. | -| `startBenchmark(seconds)` | A reproducible turn with the frame time distribution in logcat, tag `SplatKit`. | - -Quality presets, measured on the Mi 9 with the 2M splat World Labs house at 1080x2261: - -| Preset | Render scale | Harmonics | Budget | Cull margin | Why | House GPU ms p50 | -|---|---|---|---|---|---|---| -| `LOW` | 0.5 | 0 | 500k | 10 | Phones that cannot hold 30 fps at medium, or battery: half the pixels, base colour only, and a level of detail budget so a scene of any size costs about the same. | 12.4 | -| `MEDIUM` | 0.7 | 1 | all | 10 | 60 fps on the Mi 9: 0.7 is hard to tell from 1.0 at arm's length, degree 1 keeps the broad view dependent tint for a fifth of the harmonics work. | 13.4 | -| `HIGH` | 1.0 | 3 | all | 10 | The default: every pixel, every splat, every harmonic, what the reference rasterizer draws. | 19.3 | -| `ULTRA` | 1.5 | 3 | all | 20 | Flagship GPUs and stills: supersampling settles the thin splats that shimmer at a pixel each, and a flick never shows an empty edge. | 39.1 | - -### Hosting it elsewhere - -`SplatSurfaceView` is a plain `SurfaceView`, so any host that can show an Android view can show it. -Jetpack Compose: - -```kotlin -AndroidView( - factory = { context -> SplatSurfaceView(context).also { view = it } }, - modifier = Modifier.fillMaxSize(), -) +| `loadWorld(bytes/file)`, `loadCollider(bytes/file)` | Load asynchronously; prefer files for large inputs. | +| `cameraPose` | Read/set position in meters and yaw/pitch in radians. | +| `applyQuality(RenderQuality)` | Apply preset; individual properties can override it. | +| `renderScale` | Render-target scale, 0.1–2; changing it changes image quality. | +| `shDegree`, `maxShDegree` | Draw/load SH cap, 0–3. Set the load cap before loading. | +| `splatBudget` | LOD capacity for subsequent loads; zero disables automatic tree construction. Prebuilt LOD files still select a hierarchy. GPU maximum: 2.2M. | +| `residencyBudget` | Resident splats for subsequent streamed worlds, separate from LOD selection. | +| `cullMarginDegrees` | CPU fallback's angular margin; GPU visibility uses current-camera projected bounds. | +| `linearBlending` | Optional linear-light blend; not the default trained-space compositing. | +| `setMotionEnabled`, `setWalkVelocity` | Gyroscope and continuous forward/right velocity. | +| `lookSensitivity`, `walkSensitivity` | Gesture tuning. | +| `isAvailable`, `gpuDescription` | Renderer availability and driver description. | +| `readStats()` | FPS, frame/GPU/sort ms, loaded/drawn and screen-tile counts. Completed snapshots can lag. | +| `startBenchmark(seconds)` | Turn-in-place benchmark logged under `SplatKit`. | + +Unavailable GPU timings/tile counters report zero, not zero-cost execution. +Loaded source splats differ from drawn nodes and GPU-resident hierarchy records. +Stats float transport exactly represents integers through 16,777,216. +Optional `com.splatkit.ui.SplatHudView` and `JoystickView` are host conveniences. + +## GPU contract + +GPU LOD → visibility/compaction → stable radix → indirect hardware draw. +Full32 keys are default; internal `SPLATKIT_VULKAN_SORT_BITS=16` selects approximate two-pass sorting. +Above 3M visibility survivors the draw fails closed with diagnostics. +Source capacity depends on `maxStorageBufferRange` and memory; full LOD hierarchy residency is required. +Parents and subpixel rejection are approximate. There is no universal 10M or 30/60 FPS guarantee. +Hybrid compute screen tiles remain Metal-only. + +[Architecture, limits and evidence](docs/VULKAN.md) · +[Agent harness](../../docs/AGENT_HARNESS.md) · +[iOS SDK](https://github.com/Xget7/splatkit-ios) + +```sh +cd apps/android-dev +./gradlew :splatkit:assembleRelease :splatkit:testDebugUnitTest ``` -and forward `resume`, `pause` and `release` from a `DisposableEffect` on the lifecycle. -A React Native or Flutter view manager wraps it the same way: create the view, map props to the properties above, map commands to `loadWorld`, `cameraPose` and `setWalkVelocity`, and turn `Listener` calls into events. -Everything on the view is safe to call from the main thread; loads run on the library's own loader thread and settings are posted to the render thread. - -`com.splatkit.ui` has `SplatHudView` (the stats overlay) and `JoystickView`, both optional. - -The engine draws only when the camera, the world or the surface changed, so a still scene costs no GPU time. - -Declare `android:appCategory="game"` in the host manifest: Android's power HAL keys its game performance mode on it, and Xiaomi's Game Turbo lists such apps. -Thread priority and big core affinity for the engine threads were measured on the Mi 9 and changed nothing (see the roadmap), so the library does not set them. - -## Logs - -Everything logs under the tag `SplatKit`. -MIUI hides application logs until `adb shell setprop persist.log.tag.SplatKit V`. - -## Layout - -| Domain | Where | Responsibility | -|---|---|---| -| Rendering | C++ `src/main/cpp/rendering` | Vulkan context, swapchain, frame loop, offscreen target, splat pipeline | -| Engine | C++ `src/main/cpp/engine/SplatEngine.*` | Owns the renderer, the world, the camera and the sorter; runs the frame | -| Diagnostics | C++ `src/main/cpp/diagnostics` | `Benchmark`, `StatsPublisher` | -| Camera | C++ `src/main/cpp/camera` | Walk and fly camera over the `splat-core` character controller | -| JNI | C++ `src/main/cpp/jni` | The boundary; events cross it through `SplatEngine.onNativeEvent` | -| View | Kotlin `com.splatkit.SplatSurfaceView` | The public API: surface lifecycle, settings, loaders, listener | -| Engine boundary | Kotlin `com.splatkit.engine`: `SplatEngine`, `RenderThread` | The JNI wrapper and the Choreographer driven render thread | -| Input | Kotlin `com.splatkit.input`: `TouchInput`, `MotionInput` | Touch, joystick and gyroscope | - -Shaders in `src/main/cpp/shaders` compile to SPIR-V headers at build time. +Tests and emulator evidence do not replace physical Adreno/Mali validation or reference-image acceptance. diff --git a/packages/splatkit-android/build.gradle.kts b/packages/splatkit-android/build.gradle.kts index cfa1ebc..0856707 100644 --- a/packages/splatkit-android/build.gradle.kts +++ b/packages/splatkit-android/build.gradle.kts @@ -35,16 +35,20 @@ android { kotlinOptions { jvmTarget = "17" } } +dependencies { + testImplementation("junit:junit:4.13.2") +} + // Maven Central. Credentials and the signing key come from the environment on the // publishing machine (ORG_GRADLE_PROJECT_mavenCentralUsername, mavenCentralPassword, // signingInMemoryKey, signingInMemoryKeyPassword); local builds need none of it. mavenPublishing { - coordinates("io.github.xget7", "splatkit-android", "0.1.0-alpha04") + coordinates("io.github.xget7", "splatkit-android", "0.1.0-alpha05") publishToMavenCentral(automaticRelease = true) if (project.findProperty("signingInMemoryKey") != null) signAllPublications() pom { name.set("SplatKit Android") - description.set("Real-time Gaussian splatting engine for Android on Vulkan: SPZ scenes, CPU sort and cull, spherical harmonics, level of detail, and walk navigation with colliders.") + description.set("Native Android Gaussian splatting SDK: Vulkan GPU visibility, radix sorting, hierarchical LOD, spherical harmonics, and walk navigation with colliders.") url.set("https://github.com/Xget7/splatkit-android") licenses { license { diff --git a/packages/splatkit-android/docs/VULKAN.md b/packages/splatkit-android/docs/VULKAN.md new file mode 100644 index 0000000..d2d7c14 --- /dev/null +++ b/packages/splatkit-android/docs/VULKAN.md @@ -0,0 +1,35 @@ +# Vulkan + +Public API: `SplatSurfaceView`; JNI and Vulkan classes are internal. +Current source enables `VulkanFrameCompute`: GPU LOD → visibility → stable radix → indirect draw. +Alpha05 integrates this path; Maven alpha04 predates it. Hybrid tiles and RN GPU options remain pending. + +Contracts live beside code: [VisibilityPass.h](../src/main/cpp/rendering/vulkan/VisibilityPass.h), [shader ABI](../src/main/cpp/rendering/vulkan/VulkanShaderTypes.h). +The context outlives allocations; reuse slots after fences, resize/destroy after all consumers finish. +The caller owns input sizes, alignment, upload dependencies and readback invalidation. +Unsorted output requires sorting before drawing. +Subgroup width is device-dependent; subpixel rejection is approximate. +Visibility scratch: `16*capacity+64` bytes across two slots; radix adds ~99MB at 3M. +Source capacity obeys `maxStorageBufferRange/32`; 10M is not guaranteed. +Survivor overflow above 3M zeros the draw with diagnostics; it never silently truncates. +Full32 sorting is default; internal `SPLATKIT_VULKAN_SORT_BITS=16` uses two passes on +quantized keys stored in `uint32`, without requiring native 16-bit arithmetic/storage. + +Evidence (2026-09-12): Android arm64 emulator / M4 Pro via MoltenVK passed +128 radix cases through 3M, visibility, LOD-to-indirect integration and shader-free upload pressure; +native suites had layers off. Kitchen 500k loaded/drew with layers on and no captured validation errors. +SwiftShader passes smaller suites but crashes on large mapped uploads, also reproduced without SDK code. +No physical Android performance or reference-image quality acceptance follows. + +## Metal mapping + +| Metal | Vulkan / GLSL | +|---|---| +| `threadgroup` memory | `shared` / SPIR-V Workgroup; not framebuffer tile memory (GMEM) | +| `simd_prefix_exclusive_sum`, `simd_sum` | `subgroupExclusiveAdd`, `subgroupAdd`; query subgroup capabilities/width | +| `threadgroup_position_in_grid` | `gl_WorkGroupID`; `local_size` specifies group dimensions instead | +| `device T*` buffer | Storage-buffer descriptor + `VK_BUFFER_USAGE_STORAGE_BUFFER_BIT` | + +References: [subgroups](https://docs.vulkan.org/guide/latest/subgroups.html), +[built-ins](https://docs.vulkan.org/glsl/latest/chapters/builtins.html), +[16-bit arithmetic/storage](https://docs.vulkan.org/samples/latest/samples/performance/16bit_arithmetic/README.html). diff --git a/packages/splatkit-android/src/main/cpp/CMakeLists.txt b/packages/splatkit-android/src/main/cpp/CMakeLists.txt index 6ef2467..3079a6b 100644 --- a/packages/splatkit-android/src/main/cpp/CMakeLists.txt +++ b/packages/splatkit-android/src/main/cpp/CMakeLists.txt @@ -25,8 +25,8 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(vk-bootstrap VulkanMemoryAllocator) -set(SPLAT_CORE_BUILD_TESTS OFF CACHE BOOL "" FORCE) -add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../../../splat-core splat-core) +set(SPLATKIT_ENGINE_BUILD_TESTS OFF CACHE BOOL "" FORCE) +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../../../splatkit-engine splatkit-engine) include(cmake/shaders.cmake) splatkit_compile_shaders(splatkit_shaders @@ -34,6 +34,13 @@ splatkit_compile_shaders(splatkit_shaders shaders/triangle.frag shaders/splat.vert shaders/splat.frag + shaders/visibility.comp + shaders/prepare_indirect.comp + shaders/lod_selection.comp + shaders/radix_prepare.comp + shaders/radix_histogram.comp + shaders/radix_scan.comp + shaders/radix_scatter.comp ) add_library(splatkit SHARED @@ -44,12 +51,13 @@ add_library(splatkit SHARED rendering/vulkan/DebugTrianglePipeline.cpp rendering/vulkan/GpuBuffer.cpp rendering/vulkan/SplatPipeline.cpp + rendering/vulkan/VisibilityPass.cpp + rendering/vulkan/LodSelection.cpp + rendering/vulkan/RadixSort.cpp + rendering/vulkan/VulkanFrameCompute.cpp rendering/vulkan/VulkanSplatRenderer.cpp rendering/vulkan/Vma.cpp - camera/WalkCamera.cpp - diagnostics/Benchmark.cpp - diagnostics/StatsPublisher.cpp - engine/SplatEngine.cpp + engine/AndroidEngine.cpp jni/SplatKitJni.cpp ) target_include_directories(splatkit PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) @@ -68,7 +76,7 @@ target_link_options(splatkit PRIVATE "-Wl,--exclude-libs,ALL" ) target_link_libraries(splatkit PRIVATE - splat_core + splatkit_engine splatkit_shaders vk-bootstrap::vk-bootstrap GPUOpen::VulkanMemoryAllocator @@ -76,3 +84,40 @@ target_link_libraries(splatkit PRIVATE android log ) + +option(SPLATKIT_ANDROID_BUILD_TESTS "Build no-surface Vulkan tests (run through adb)" OFF) +if(SPLATKIT_ANDROID_BUILD_TESTS) + add_library(splatkit_vulkan_test_support STATIC + rendering/vulkan/VulkanContext.cpp + rendering/vulkan/GpuBuffer.cpp + rendering/vulkan/Vma.cpp + ) + target_include_directories(splatkit_vulkan_test_support PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + target_include_directories(splatkit_vulkan_test_support SYSTEM PUBLIC ${vma_include} ${vkb_include}) + target_compile_definitions(splatkit_vulkan_test_support PUBLIC VK_USE_PLATFORM_ANDROID_KHR) + target_compile_options(splatkit_vulkan_test_support PRIVATE + -Wall -Wextra -Werror -Wno-unused-parameter -Wno-missing-field-initializers) + target_link_libraries(splatkit_vulkan_test_support PUBLIC + splatkit_engine vk-bootstrap::vk-bootstrap GPUOpen::VulkanMemoryAllocator vulkan android log) + add_executable(splatkit_gpu_buffer_test tests/GpuBufferTest.cpp) + target_link_libraries(splatkit_gpu_buffer_test PRIVATE splatkit_vulkan_test_support) + add_executable(splatkit_gpu_upload_pressure_test tests/GpuUploadPressureTest.cpp) + target_link_libraries(splatkit_gpu_upload_pressure_test PRIVATE splatkit_vulkan_test_support) + add_executable(splatkit_lod_selection_test tests/LodSelectionTest.cpp + rendering/vulkan/LodSelection.cpp) + target_link_libraries(splatkit_lod_selection_test PRIVATE + splatkit_vulkan_test_support splatkit_shaders) + add_executable(splatkit_visibility_test tests/VisibilityPassTest.cpp + rendering/vulkan/VisibilityPass.cpp) + target_link_libraries(splatkit_visibility_test PRIVATE + splatkit_vulkan_test_support splatkit_shaders) + add_executable(splatkit_vulkan_frame_test tests/VulkanFrameComputeTest.cpp + rendering/vulkan/VulkanFrameCompute.cpp rendering/vulkan/LodSelection.cpp + rendering/vulkan/VisibilityPass.cpp rendering/vulkan/RadixSort.cpp) + target_link_libraries(splatkit_vulkan_frame_test PRIVATE + splatkit_vulkan_test_support splatkit_shaders) + add_executable(splatkit_radix_sort_test tests/RadixSortTest.cpp + rendering/vulkan/RadixSort.cpp) + target_link_libraries(splatkit_radix_sort_test PRIVATE + splatkit_vulkan_test_support splatkit_shaders) +endif() diff --git a/packages/splatkit-android/src/main/cpp/Log.h b/packages/splatkit-android/src/main/cpp/Log.h deleted file mode 100644 index d9ac1e6..0000000 --- a/packages/splatkit-android/src/main/cpp/Log.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once - -#include - -#define SPLATKIT_LOG_TAG "SplatKit" -#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, SPLATKIT_LOG_TAG, __VA_ARGS__) -#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, SPLATKIT_LOG_TAG, __VA_ARGS__) -#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, SPLATKIT_LOG_TAG, __VA_ARGS__) diff --git a/packages/splatkit-android/src/main/cpp/engine/AndroidEngine.cpp b/packages/splatkit-android/src/main/cpp/engine/AndroidEngine.cpp new file mode 100644 index 0000000..f521d96 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/engine/AndroidEngine.cpp @@ -0,0 +1,41 @@ +#include "engine/AndroidEngine.h" + +#include + +#include "splatkit/Log.h" + +namespace splatkit { +namespace { + +void logcatSink(LogLevel level, const char* message) { + const int priority = level == LogLevel::error ? ANDROID_LOG_ERROR + : level == LogLevel::warn ? ANDROID_LOG_WARN + : ANDROID_LOG_INFO; + __android_log_write(priority, "SplatKit", message); +} + +} // namespace + +splat::Result> AndroidEngine::create() { + setLogSink(&logcatSink); + std::unique_ptr host(new AndroidEngine()); + auto ctx = VulkanContext::create(); + if (!ctx) return ctx.error(); + host->ctx_ = std::move(ctx.value()); + host->frameLoop_ = std::make_unique(*host->ctx_); + if (!host->frameLoop_->valid()) { + return splat::Error{splat::ErrorCode::gpuUnavailable, "frame loop"}; + } + auto renderer = std::make_unique(*host->ctx_, *host->frameLoop_); + host->renderer_ = renderer.get(); + host->engine_ = std::make_unique(std::move(renderer)); + return host; +} + +AndroidEngine::~AndroidEngine() { + engine_.reset(); + frameLoop_.reset(); + if (ctx_) ctx_->waitIdle(); +} + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/engine/AndroidEngine.h b/packages/splatkit-android/src/main/cpp/engine/AndroidEngine.h new file mode 100644 index 0000000..d4c2036 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/engine/AndroidEngine.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include + +#include "rendering/vulkan/FrameLoop.h" +#include "rendering/vulkan/VulkanContext.h" +#include "rendering/vulkan/VulkanSplatRenderer.h" +#include "splat/core/Result.h" +#include "splatkit/engine/SplatEngine.h" + +namespace splatkit { + +// The engine on Android: the Vulkan context and frame loop the renderer needs, the +// renderer, and the shared engine over it. What the JNI holds. +class AndroidEngine { + public: + static splat::Result> create(); + ~AndroidEngine(); + + AndroidEngine(const AndroidEngine&) = delete; + AndroidEngine& operator=(const AndroidEngine&) = delete; + + SplatEngine& engine() { return *engine_; } + // A new window (takes a reference) or nullptr when the surface is going away. + void setWindow(ANativeWindow* window) { renderer_->setWindow(window); } + // The window changed size while staying attached. Rebuilds the swapchain if needed. + void onSurfaceResized(uint32_t width, uint32_t height) { + renderer_->onSurfaceResized(width, height); + } + + private: + AndroidEngine() = default; + + std::unique_ptr ctx_; + std::unique_ptr frameLoop_; + VulkanSplatRenderer* renderer_ = nullptr; // owned by the engine + std::unique_ptr engine_; // last: dies first, with the renderer +}; + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/jni/SplatKitJni.cpp b/packages/splatkit-android/src/main/cpp/jni/SplatKitJni.cpp index 25581f3..016585e 100644 --- a/packages/splatkit-android/src/main/cpp/jni/SplatKitJni.cpp +++ b/packages/splatkit-android/src/main/cpp/jni/SplatKitJni.cpp @@ -11,8 +11,8 @@ #include #include -#include "Log.h" -#include "engine/SplatEngine.h" +#include "engine/AndroidEngine.h" +#include "splatkit/Log.h" // `SPLATKIT_JNI(void, nativeLook)(JNIEnv*, jobject, ...)` declares the exported symbol // the JVM binds to `SplatEngine.nativeLook`. The package is part of the name. @@ -25,9 +25,14 @@ constexpr jsize kPoseFloats = 5; constexpr jsize kStatsFloats = 7; constexpr jsize kAttitudeFloats = 9; -// The handle Kotlin holds is the engine's address; JNI has no other way to carry it. +// The handle Kotlin holds is the host's address; JNI has no other way to carry it. +splatkit::AndroidEngine* toHost(jlong handle) { + return reinterpret_cast(handle); // NOLINT(performance-no-int-to-ptr) +} + splatkit::SplatEngine* toEngine(jlong handle) { - return reinterpret_cast(handle); // NOLINT(performance-no-int-to-ptr) + auto* host = toHost(handle); + return host != nullptr ? &host->engine() : nullptr; } JavaVM* gVm = nullptr; @@ -109,22 +114,21 @@ extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { // Lifetime. SPLATKIT_JNI(jlong, nativeCreate)(JNIEnv* env, jobject thiz) { - auto result = splatkit::SplatEngine::create(); + auto result = splatkit::AndroidEngine::create(); if (!result) { LOGE("engine creation failed: %s", result.error().message.c_str()); return 0; } - splatkit::SplatEngine* engine = result.value().release(); + splatkit::AndroidEngine* host = result.value().release(); // The bridge lives in the sink and dies with the engine. - engine->setEventSink([bridge = std::make_shared(env, thiz)]( - splatkit::SplatEngine::Event e, const std::string& m, uint32_t c) { - (*bridge)(e, m, c); - }); - return reinterpret_cast(engine); + host->engine().setEventSink([bridge = std::make_shared(env, thiz)]( + splatkit::SplatEngine::Event e, const std::string& m, + uint32_t c) { (*bridge)(e, m, c); }); + return reinterpret_cast(host); } SPLATKIT_JNI(void, nativeDestroy)(JNIEnv*, jobject, jlong handle) { - delete toEngine(handle); + delete toHost(handle); } SPLATKIT_JNI(jstring, nativeGpuDescription)(JNIEnv* env, jobject, jlong handle) { @@ -135,22 +139,22 @@ SPLATKIT_JNI(jstring, nativeGpuDescription)(JNIEnv* env, jobject, jlong handle) // Surface and frames. SPLATKIT_JNI(void, nativeSetSurface)(JNIEnv* env, jobject, jlong handle, jobject surface) { - auto* engine = toEngine(handle); - if (engine == nullptr) return; + auto* host = toHost(handle); + if (host == nullptr) return; if (surface == nullptr) { - engine->setWindow(nullptr); + host->setWindow(nullptr); return; } ANativeWindow* window = ANativeWindow_fromSurface(env, surface); if (window == nullptr) LOGE("the Surface has no native window; the view stays blank"); - engine->setWindow(window); + host->setWindow(window); // The engine holds its own reference; drop the one fromSurface gave us. if (window != nullptr) ANativeWindow_release(window); } SPLATKIT_JNI(void, nativeSurfaceResized)(JNIEnv*, jobject, jlong handle, jint width, jint height) { - if (auto* engine = toEngine(handle)) { - engine->onSurfaceResized(static_cast(width), static_cast(height)); + if (auto* host = toHost(handle)) { + host->onSurfaceResized(static_cast(width), static_cast(height)); } } @@ -182,6 +186,12 @@ SPLATKIT_JNI(void, nativeLoadWorldFile)(JNIEnv* env, jobject, jlong handle, jstr withUtf8(env, path, [engine](const std::string& p) { engine->loadWorldFile(p); }); } +SPLATKIT_JNI(void, nativeLoadTiledWorldFile)(JNIEnv* env, jobject, jlong handle, jstring path) { + auto* engine = toEngine(handle); + if (engine == nullptr) return; + withUtf8(env, path, [engine](const std::string& p) { engine->loadTiledWorldFile(p); }); +} + SPLATKIT_JNI(void, nativeLoadColliderFile)(JNIEnv* env, jobject, jlong handle, jstring path) { auto* engine = toEngine(handle); if (engine == nullptr) return; @@ -250,6 +260,10 @@ SPLATKIT_JNI(void, nativeSetSplatBudget)(JNIEnv*, jobject, jlong handle, jint bu if (auto* engine = toEngine(handle)) engine->setSplatBudget(budget); } +SPLATKIT_JNI(void, nativeSetResidencyBudget)(JNIEnv*, jobject, jlong handle, jint splats) { + if (auto* engine = toEngine(handle)) engine->setResidencyBudget(splats); +} + SPLATKIT_JNI(void, nativeSetMaxShDegree)(JNIEnv*, jobject, jlong handle, jint degree) { if (auto* engine = toEngine(handle)) engine->setMaxShDegree(degree); } @@ -264,17 +278,25 @@ SPLATKIT_JNI(void, nativeStartBenchmark)(JNIEnv*, jobject, jlong handle, jfloat if (auto* engine = toEngine(handle)) engine->startBenchmark(seconds); } -// Fills out[0..6]: fps, frame ms, gpu ms, sort ms, splat count, walking (0/1), motion (0/1). +// Legacy out[0..6] is unchanged; [7..10] adds drawn/compute/nonempty/hardware counts. SPLATKIT_JNI(void, nativeStats)(JNIEnv* env, jobject, jlong handle, jfloatArray out) { + constexpr jsize kExtendedStatsFloats = 11; auto* engine = toEngine(handle); if (engine == nullptr || out == nullptr || env->GetArrayLength(out) < kStatsFloats) return; const splatkit::Stats s = engine->stats(); - const float values[kStatsFloats] = {s.fps, - s.frameMillis, - s.gpuMillis, - s.sortMillis, - static_cast(s.splatCount), - s.walking ? 1.0f : 0.0f, - s.motion ? 1.0f : 0.0f}; - env->SetFloatArrayRegion(out, 0, kStatsFloats, values); + // Float transport represents every integer through 2^24 exactly; larger counts can round. + const float values[kExtendedStatsFloats] = {s.fps, + s.frameMillis, + s.gpuMillis, + s.sortMillis, + static_cast(s.splatCount), + s.walking ? 1.0f : 0.0f, + s.motion ? 1.0f : 0.0f, + static_cast(s.drawnSplatCount), + static_cast(s.computeTileCount), + static_cast(s.nonemptyComputeTileCount), + static_cast(s.hardwareTileCount)}; + const jsize count = + env->GetArrayLength(out) >= kExtendedStatsFloats ? kExtendedStatsFloats : kStatsFloats; + env->SetFloatArrayRegion(out, 0, count, values); } diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/FrameLoop.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/FrameLoop.cpp index 339147b..09e4c3a 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/FrameLoop.cpp +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/FrameLoop.cpp @@ -1,6 +1,6 @@ #include "rendering/vulkan/FrameLoop.h" -#include "Log.h" +#include "splatkit/Log.h" namespace splatkit { diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.cpp index 0c7ea9c..5b5251c 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.cpp +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.cpp @@ -1,8 +1,9 @@ #include "rendering/vulkan/GpuBuffer.h" +#include #include -#include "Log.h" +#include "splatkit/Log.h" namespace splatkit { namespace { @@ -10,6 +11,7 @@ namespace { bool create(const VulkanContext& ctx, VkDeviceSize size, VkBufferUsageFlags usage, VmaAllocationCreateFlags flags, bool map, VkBuffer& buffer, VmaAllocation& allocation, void*& mapped) { + if (size == 0) return false; VkBufferCreateInfo info{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; info.size = size; info.usage = usage; @@ -26,6 +28,12 @@ bool create(const VulkanContext& ctx, VkDeviceSize size, VkBufferUsageFlags usag return false; } mapped = map ? result.pMappedData : nullptr; + if (map && !mapped) { + vmaDestroyBuffer(ctx.allocator(), buffer, allocation); + buffer = VK_NULL_HANDLE; + allocation = VK_NULL_HANDLE; + return false; + } return true; } @@ -57,12 +65,19 @@ void GpuBuffer::flush(VkDeviceSize offset, VkDeviceSize size) const { vmaFlushAllocation(ctx_.allocator(), allocation_, offset, size); } -bool GpuBuffer::upload(const void* data, VkDeviceSize size) { - if (size > size_) return false; - auto staging = hostVisible(ctx_, size, VK_BUFFER_USAGE_TRANSFER_SRC_BIT); +void GpuBuffer::invalidate(VkDeviceSize offset, VkDeviceSize size) const { + vmaInvalidateAllocation(ctx_.allocator(), allocation_, offset, size); +} + +bool GpuBuffer::upload(VkDeviceSize offset, const void* data, VkDeviceSize size) { + if (offset > size_ || size > size_ - offset) return false; + if (size == 0) return true; + if (!data || size > SIZE_MAX) return false; + // Bound transient mapped memory independently of the world size. Reuse the + // staging bytes only after each copy's fence, including the final partial window. + constexpr VkDeviceSize kStagingBytes = 2 * 1024 * 1024; + auto staging = hostVisible(ctx_, std::min(size, kStagingBytes), VK_BUFFER_USAGE_TRANSFER_SRC_BIT); if (!staging) return false; - std::memcpy(staging->mapped(), data, static_cast(size)); - staging->flush(0, size); VkDevice device = ctx_.device(); VkCommandPoolCreateInfo poolInfo{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; @@ -80,18 +95,43 @@ bool GpuBuffer::upload(const void* data, VkDeviceSize size) { const VkFenceCreateInfo fenceInfo{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; - const VkBufferCopy region{0, 0, size}; VkSubmitInfo submit{VK_STRUCTURE_TYPE_SUBMIT_INFO}; submit.commandBufferCount = 1; submit.pCommandBuffers = &cmd; // Each step can fail under memory pressure, which is when a 2M splat upload runs. - const bool ok = vkAllocateCommandBuffers(device, &cmdInfo, &cmd) == VK_SUCCESS && - vkBeginCommandBuffer(cmd, &begin) == VK_SUCCESS && - (vkCmdCopyBuffer(cmd, staging->handle(), buffer_, 1, ®ion), true) && - vkEndCommandBuffer(cmd) == VK_SUCCESS && - vkCreateFence(device, &fenceInfo, nullptr, &fence) == VK_SUCCESS && - vkQueueSubmit(ctx_.queue(), 1, &submit, fence) == VK_SUCCESS && - vkWaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX) == VK_SUCCESS; + bool ok = vkAllocateCommandBuffers(device, &cmdInfo, &cmd) == VK_SUCCESS && + vkCreateFence(device, &fenceInfo, nullptr, &fence) == VK_SUCCESS; + for (VkDeviceSize copied = 0; ok && copied < size;) { + const VkDeviceSize bytes = std::min(staging->size(), size - copied); + std::memcpy(staging->mapped(), static_cast(data) + copied, + static_cast(bytes)); + ok = vmaFlushAllocation(ctx_.allocator(), staging->allocation_, 0, bytes) == VK_SUCCESS && + vkResetCommandPool(device, pool, 0) == VK_SUCCESS && + vkResetFences(device, 1, &fence) == VK_SUCCESS && + vkBeginCommandBuffer(cmd, &begin) == VK_SUCCESS; + if (!ok) break; + const VkBufferCopy region{0, offset + copied, bytes}; + VkBufferMemoryBarrier overwrite{VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER}; + overwrite.srcAccessMask = VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT; + overwrite.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + overwrite.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + overwrite.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + overwrite.buffer = buffer_; + overwrite.offset = region.dstOffset; + overwrite.size = bytes; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 0, nullptr, 1, &overwrite, 0, nullptr); + vkCmdCopyBuffer(cmd, staging->handle(), buffer_, 1, ®ion); + VkMemoryBarrier ready{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + ready.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + ready.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, + 1, &ready, 0, nullptr, 0, nullptr); + ok = vkEndCommandBuffer(cmd) == VK_SUCCESS && + vkQueueSubmit(ctx_.queue(), 1, &submit, fence) == VK_SUCCESS && + vkWaitForFences(device, 1, &fence, VK_TRUE, UINT64_MAX) == VK_SUCCESS; + copied += bytes; + } if (fence != VK_NULL_HANDLE) vkDestroyFence(device, fence, nullptr); vkDestroyCommandPool(device, pool, nullptr); diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.h index c309d0f..d0c5f19 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.h +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.h @@ -32,10 +32,17 @@ class GpuBuffer { // Required after every CPU write to a hostVisible buffer: VMA only prefers coherent // memory, it does not guarantee it. A no-op when the memory is coherent. void flush(VkDeviceSize offset, VkDeviceSize size) const; - - // Blocking upload through a staging buffer and a one-shot command buffer. - // Fine for a world load; not for per-frame data. - bool upload(const void* data, VkDeviceSize size); + // After a GPU-write fence, before reading a hostVisible buffer on the CPU. + void invalidate(VkDeviceSize offset, VkDeviceSize size) const; + + // Blocking upload through <=2 MiB of staging memory, fenced before each reuse. + // Fine for a world load or a tile; not for per-frame data. + bool upload(const void* data, VkDeviceSize size) { return upload(0, data, size); } + // The same into [offset, offset + size) of the buffer, for a tile landing in a slab. + // Nonempty copies require nonnull data. + // Empty copies accept null data at any offset through size(). Invalid ranges fail. + // A failed nonempty upload may have copied a prefix; the caller must discard it. + bool upload(VkDeviceSize offset, const void* data, VkDeviceSize size); private: explicit GpuBuffer(const VulkanContext& ctx) : ctx_(ctx) {} diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/LodSelection.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/LodSelection.cpp new file mode 100644 index 0000000..184ac0b --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/LodSelection.cpp @@ -0,0 +1,245 @@ +#include "rendering/vulkan/LodSelection.h" + +#include +#include +#include +#include + +#include "rendering/vulkan/VulkanShaderTypes.h" +#include "shaders/lod_selection_comp.h" +#include "splat/lod/LodFile.h" + +namespace splatkit { +namespace { +constexpr uint32_t kThreads = 128; +constexpr uint32_t kMaxCapacity = 2200000; +void dependency(VkCommandBuffer cmd, VkPipelineStageFlags source, VkAccessFlags sourceAccess, + VkPipelineStageFlags destination, VkAccessFlags destinationAccess) { + VkMemoryBarrier barrier{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + barrier.srcAccessMask = sourceAccess; + barrier.dstAccessMask = destinationAccess; + vkCmdPipelineBarrier(cmd, source, destination, 0, 1, &barrier, 0, nullptr, 0, nullptr); +} +} // namespace + +splat::Result> LodSelection::create(const VulkanContext& ctx) { + std::unique_ptr pass(new LodSelection(ctx)); + if (!pass->initialize()) + return splat::Error{splat::ErrorCode::gpuUnavailable, + "LOD compute limits or pipeline unavailable"}; + return pass; +} + +bool LodSelection::initialize() { + static_assert(sizeof(Config) == 52); + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(ctx_.physicalDevice(), &properties); + limits_ = properties.limits; + uint32_t count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(ctx_.physicalDevice(), &count, nullptr); + std::vector families(count); + vkGetPhysicalDeviceQueueFamilyProperties(ctx_.physicalDevice(), &count, families.data()); + if (ctx_.queueFamily() >= count || + !(families[ctx_.queueFamily()].queueFlags & VK_QUEUE_COMPUTE_BIT) || + limits_.maxComputeWorkGroupInvocations < kThreads || + limits_.maxComputeWorkGroupSize[0] < kThreads || + limits_.maxComputeSharedMemorySize < kThreads * 16 || + limits_.maxPerStageDescriptorStorageBuffers < 4 || + limits_.maxDescriptorSetStorageBuffers < 4 || + limits_.maxPerStageDescriptorUniformBuffers < 1 || + limits_.maxDescriptorSetUniformBuffers < 1 || limits_.maxPerStageResources < 5 || + limits_.maxUniformBufferRange < sizeof(CameraUniform) || + limits_.maxPushConstantsSize < sizeof(Config)) + return false; + + VkDescriptorSetLayoutBinding bindings[5]{}; + bindings[0] = {0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr}; + for (uint32_t i = 1; i < 5; ++i) + bindings[i] = {i, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1, VK_SHADER_STAGE_COMPUTE_BIT, nullptr}; + VkDescriptorSetLayoutCreateInfo set{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + set.bindingCount = 5; + set.pBindings = bindings; + if (vkCreateDescriptorSetLayout(ctx_.device(), &set, nullptr, &setLayout_) != VK_SUCCESS) + return false; + VkDescriptorPoolSize sizes[] = {{VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, kSlots}, + {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 4 * kSlots}}; + VkDescriptorPoolCreateInfo pool{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + pool.maxSets = kSlots; + pool.poolSizeCount = 2; + pool.pPoolSizes = sizes; + if (vkCreateDescriptorPool(ctx_.device(), &pool, nullptr, &pool_) != VK_SUCCESS) return false; + std::array layouts{}; + layouts.fill(setLayout_); + VkDescriptorSetAllocateInfo allocate{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + allocate.descriptorPool = pool_; + allocate.descriptorSetCount = kSlots; + allocate.pSetLayouts = layouts.data(); + if (vkAllocateDescriptorSets(ctx_.device(), &allocate, sets_.data()) != VK_SUCCESS) return false; + const VkPushConstantRange push{VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(Config)}; + VkPipelineLayoutCreateInfo layout{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + layout.setLayoutCount = 1; + layout.pSetLayouts = &setLayout_; + layout.pushConstantRangeCount = 1; + layout.pPushConstantRanges = &push; + if (vkCreatePipelineLayout(ctx_.device(), &layout, nullptr, &layout_) != VK_SUCCESS) return false; + VkShaderModuleCreateInfo moduleInfo{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + moduleInfo.codeSize = shaders::lod_selection_comp_size; + moduleInfo.pCode = shaders::lod_selection_comp; + VkShaderModule module = VK_NULL_HANDLE; + if (vkCreateShaderModule(ctx_.device(), &moduleInfo, nullptr, &module) != VK_SUCCESS) + return false; + VkComputePipelineCreateInfo pipeline{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO}; + pipeline.layout = layout_; + pipeline.stage = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + pipeline.stage.stage = VK_SHADER_STAGE_COMPUTE_BIT; + pipeline.stage.module = module; + pipeline.stage.pName = "main"; + const auto result = + vkCreateComputePipelines(ctx_.device(), VK_NULL_HANDLE, 1, &pipeline, nullptr, &pipeline_); + vkDestroyShaderModule(ctx_.device(), module, nullptr); + return result == VK_SUCCESS; +} + +LodSelection::~LodSelection() { + if (pipeline_) vkDestroyPipeline(ctx_.device(), pipeline_, nullptr); + if (layout_) vkDestroyPipelineLayout(ctx_.device(), layout_, nullptr); + if (pool_) vkDestroyDescriptorPool(ctx_.device(), pool_, nullptr); + if (setLayout_) vkDestroyDescriptorSetLayout(ctx_.device(), setLayout_, nullptr); +} + +bool LodSelection::upload(const splat::LodTree& tree, uint32_t budget, Quality quality) { + if (budget == 0 || budget > kMaxCapacity || !std::isfinite(quality.pixelLimit) || + quality.pixelLimit < 0 || !std::isfinite(quality.colorWeight) || quality.colorWeight < 0) + return false; + const auto valid = splat::validateLodTree(tree); + if (!valid || tree.nodeCount() > std::numeric_limits::max()) return false; + splat::LodSelectionData compatibility; + const auto* data = &tree.selection; + if (data->clusters.empty()) { + compatibility = splat::buildLodSelectionData(tree); + data = &compatibility; + } + if (data->clusters.empty() || data->leaves.empty()) return false; + Config config; + config.capacity = std::min(budget, static_cast(tree.leafCount)); + config.pixelLimit = quality.pixelLimit; + config.colorWeight = quality.colorWeight; + config.cull = quality.frustumCull; + const size_t frontier = std::min(size_t{config.capacity}, data->clusters.size()); + const size_t groups = (frontier + kThreads - 1) / kThreads; + const size_t blocks = (groups + kThreads - 1) / kThreads; + const size_t packets = std::min(data->clusters.size(), size_t{config.capacity} / (kThreads + 1)); + if (groups > limits_.maxComputeWorkGroupCount[0] || packets > limits_.maxComputeWorkGroupCount[0]) + return false; + size_t words = 32; + auto region = [&](size_t length) { + const auto start = static_cast(words); + words += length; + return start; + }; + config.costs = region(frontier * 2); + config.offsets = region(frontier * 4); + config.costGroups = region(groups * 4); + config.groups = region(groups * 4); + config.blocks = region((blocks + 1) * 4); + config.frontier0 = region(frontier); + config.frontier1 = region(frontier); + config.packets = region(std::max(packets, size_t{1}) * 4); + const VkDeviceSize clusterBytes = data->clusters.size() * sizeof(splat::LodCluster); + const VkDeviceSize leafBytes = data->leaves.size() * sizeof(uint32_t); + const VkDeviceSize scratchBytes = words * sizeof(uint32_t); + const VkDeviceSize indexBytes = VkDeviceSize{config.capacity} * sizeof(uint32_t); + if (words > std::numeric_limits::max() || + std::max({clusterBytes, leafBytes, scratchBytes, indexBytes}) > limits_.maxStorageBufferRange) + return false; + constexpr auto kStorage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + auto clusters = GpuBuffer::deviceLocal(ctx_, clusterBytes, kStorage); + auto leaves = GpuBuffer::deviceLocal(ctx_, leafBytes, kStorage); + auto scratch = GpuBuffer::deviceLocal( + ctx_, scratchBytes, + kStorage | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + auto indices = + GpuBuffer::deviceLocal(ctx_, indexBytes, kStorage | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + if (!clusters || !leaves || !scratch || !indices || + !clusters->upload(data->clusters.data(), clusterBytes) || + !leaves->upload(data->leaves.data(), leafBytes)) + return false; + clusters_ = std::move(clusters); + leaves_ = std::move(leaves); + scratch_ = std::move(scratch); + indices_ = std::move(indices); + config_ = config; + rounds_ = valid.value() + 1; + return true; +} + +LodSelection::Output LodSelection::output() const { + if (!scratch_) return {}; + return {indices_->handle(), scratch_->handle(), config_.capacity}; +} + +bool LodSelection::encode(VkCommandBuffer cmd, uint32_t slot, const Input& input) const { + if (!cmd || slot >= kSlots || !scratch_ || !input.camera || + input.cameraOffset % limits_.minUniformBufferOffsetAlignment != 0 || + input.cameraOffset > std::numeric_limits::max() - sizeof(CameraUniform)) + return false; + const VkDescriptorBufferInfo buffers[] = { + {input.camera, input.cameraOffset, sizeof(CameraUniform)}, + {clusters_->handle(), 0, clusters_->size()}, + {leaves_->handle(), 0, leaves_->size()}, + {scratch_->handle(), 0, scratch_->size()}, + {indices_->handle(), 0, indices_->size()}}; + VkWriteDescriptorSet writes[5]{}; + for (uint32_t i = 0; i < 5; ++i) { + writes[i] = {VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + writes[i].dstSet = sets_[slot]; + writes[i].dstBinding = i; + writes[i].descriptorCount = 1; + writes[i].descriptorType = + i == 0 ? VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER : VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + writes[i].pBufferInfo = &buffers[i]; + } + vkUpdateDescriptorSets(ctx_.device(), 5, writes, 0, nullptr); + // Inter-frame WAR/WAW plus upload/host writes, including consumers on previous submissions. + dependency(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT | VK_PIPELINE_STAGE_HOST_BIT, + VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_HOST_WRITE_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT | VK_ACCESS_UNIFORM_READ_BIT); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, layout_, 0, 1, &sets_[slot], 0, + nullptr); + auto dispatch = [&](uint32_t phase, VkDeviceSize indirectOffset = VK_WHOLE_SIZE) { + Config config = config_; + config.phase = phase; + vkCmdPushConstants(cmd, layout_, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(config), &config); + if (indirectOffset == VK_WHOLE_SIZE) + vkCmdDispatch(cmd, 1, 1, 1); + else + vkCmdDispatchIndirect(cmd, scratch_->handle(), indirectOffset); + dependency(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT | + VK_ACCESS_INDIRECT_COMMAND_READ_BIT); + }; + dispatch(0); + for (uint32_t round = 0; round < rounds_; ++round) { + dispatch(1, 32); + dispatch(2, 64); + dispatch(3); + dispatch(4); + dispatch(5, 32); + dispatch(6, 64); + dispatch(3); + dispatch(7); + dispatch(8, 32); + dispatch(9); + } + dispatch(10, 44); + dependency(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | + VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_INDIRECT_COMMAND_READ_BIT | + VK_ACCESS_TRANSFER_READ_BIT); + return true; +} +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/LodSelection.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/LodSelection.h new file mode 100644 index 0000000..bb1690d --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/LodSelection.h @@ -0,0 +1,80 @@ +#pragma once + +#include +#include +#include + +#include "rendering/vulkan/GpuBuffer.h" +#include "splat/lod/LodTree.h" + +namespace splatkit { + +// Full hierarchy residency; interior-only GPU traversal and cooperative leaf packets. +// SSE matches MetalLOD's covariance/appearance metadata heuristic, not an image-error bound. +// Render thread only. Context outlives this pass. Upload/destroy require all consumers idle. +// One scratch/output domain: encode AND consumers must be ordered on the same context queue. +// The next encode overwrites output; copy diagnostics before it. No implicit submit/wait. +class LodSelection { + public: + struct Quality { + float pixelLimit = 1.0f; // zero requests exact leaves if capacity permits + float colorWeight = 4.0f; + bool frustumCull = true; + }; + struct Input { + VkBuffer camera = VK_NULL_HANDLE; // CameraUniform, 176 bytes, caller-owned/flush before encode + VkDeviceSize cameraOffset = 0; // minUniformBufferOffsetAlignment; buffer must cover range + }; + struct Output { + VkBuffer indices = + VK_NULL_HANDLE; // original LodTree node indices, uint32, count valid entries + VkBuffer state = VK_NULL_HANDLE; // count/diagnostics at constants below, TRANSFER_SRC capable + uint32_t capacity = 0; + }; + static constexpr uint32_t kSlots = 2; + static constexpr VkDeviceSize kCountOffset = 0; + static constexpr VkDeviceSize kLimitedOffset = 16; + static constexpr VkDeviceSize kEvaluatedOffset = 20; + static constexpr VkDeviceSize kDiagnosticBytes = 24; + + static splat::Result> create(const VulkanContext& ctx); + ~LodSelection(); + LodSelection(const LodSelection&) = delete; + LodSelection& operator=(const LodSelection&) = delete; + + // Transactional, bounded allocation/upload; failure preserves old hierarchy and descriptors. + // Budget must be 1..2,200,000; effective capacity is min(budget, original leaf count). + // Source metadata, scratch and output must each fit maxStorageBufferRange. + // Metadata may be built once at upload for v1 trees, never on the CPU per frame. + bool upload(const splat::LodTree& tree, uint32_t budget, Quality quality); + bool upload(const splat::LodTree& tree, uint32_t budget) { + return upload(tree, budget, Quality{}); + } + // Fence before reusing this descriptor slot; camera remains valid through completion. + // Publishes indices/count to compute/indirect/transfer consumers with Vulkan 1.1 barriers. + bool encode(VkCommandBuffer cmd, uint32_t slot, const Input& input) const; + Output output() const; + uint32_t capacity() const { return config_.capacity; } + + private: + explicit LodSelection(const VulkanContext& ctx) : ctx_(ctx) {} + bool initialize(); + struct Config { + uint32_t phase = 0, capacity = 0, costs = 0, offsets = 0; + uint32_t costGroups = 0, groups = 0, blocks = 0, frontier0 = 0; + uint32_t frontier1 = 0, packets = 0; + float pixelLimit = 1, colorWeight = 4; + uint32_t cull = 1; + } config_; + const VulkanContext& ctx_; + VkPhysicalDeviceLimits limits_{}; + uint32_t rounds_ = 0; + VkDescriptorSetLayout setLayout_ = VK_NULL_HANDLE; + VkDescriptorPool pool_ = VK_NULL_HANDLE; + std::array sets_{}; + VkPipelineLayout layout_ = VK_NULL_HANDLE; + VkPipeline pipeline_ = VK_NULL_HANDLE; + std::unique_ptr clusters_, leaves_, scratch_, indices_; +}; + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/RadixSort.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/RadixSort.cpp new file mode 100644 index 0000000..c488f1e --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/RadixSort.cpp @@ -0,0 +1,280 @@ +#include "rendering/vulkan/RadixSort.h" + +#include +#include + +#include "shaders/radix_histogram_comp.h" +#include "shaders/radix_prepare_comp.h" +#include "shaders/radix_scan_comp.h" +#include "shaders/radix_scatter_comp.h" + +namespace splatkit { +namespace { +constexpr auto kCompute = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; +constexpr auto kShader = VK_SHADER_STAGE_COMPUTE_BIT; +constexpr uint32_t kBindings = 10; +constexpr VkBufferUsageFlags kStorage = + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT; +void dependency(VkCommandBuffer cmd, VkPipelineStageFlags srcStage, VkPipelineStageFlags dstStage, + VkAccessFlags src, VkAccessFlags dst) { + VkMemoryBarrier barrier{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + barrier.srcAccessMask = src; + barrier.dstAccessMask = dst; + vkCmdPipelineBarrier(cmd, srcStage, dstStage, 0, 1, &barrier, 0, nullptr, 0, nullptr); +} +} // namespace + +RadixSort::Capabilities RadixSort::queryCapabilities(const VulkanContext& ctx) { + Capabilities result; + VkPhysicalDeviceSubgroupProperties subgroup{ + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SUBGROUP_PROPERTIES}; + VkPhysicalDeviceProperties2 properties{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2}; + properties.pNext = &subgroup; + vkGetPhysicalDeviceProperties2(ctx.physicalDevice(), &properties); + const auto& limits = properties.properties.limits; + result.subgroupSize = subgroup.subgroupSize; + uint32_t count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(ctx.physicalDevice(), &count, nullptr); + std::vector queues(count); + vkGetPhysicalDeviceQueueFamilyProperties(ctx.physicalDevice(), &count, queues.data()); + constexpr VkSubgroupFeatureFlags kRequired = VK_SUBGROUP_FEATURE_BASIC_BIT | + VK_SUBGROUP_FEATURE_ARITHMETIC_BIT | + VK_SUBGROUP_FEATURE_BALLOT_BIT; + if (ctx.queueFamily() >= count || + !(queues[ctx.queueFamily()].queueFlags & VK_QUEUE_COMPUTE_BIT)) { + result.reason = "selected queue lacks compute"; + } else if (!(subgroup.supportedStages & kShader) || + (subgroup.supportedOperations & kRequired) != kRequired || !subgroup.subgroupSize) { + result.reason = "compute subgroup basic/arithmetic/ballot unavailable"; + } else if (limits.maxComputeWorkGroupInvocations < 128 || + limits.maxComputeWorkGroupSize[0] < 128 || limits.maxComputeSharedMemorySize < 7168 || + !limits.maxComputeWorkGroupCount[0] || !limits.maxComputeWorkGroupCount[1] || + !limits.maxComputeWorkGroupCount[2]) { + result.reason = "compute workgroup/shared-memory limits too small"; + } else if (limits.maxPerStageDescriptorStorageBuffers < kBindings || + limits.maxDescriptorSetStorageBuffers < kBindings || + limits.maxPerStageResources < kBindings || limits.maxPushConstantsSize < 12 || + limits.maxStorageBufferRange < 1024 || + uint64_t{limits.maxComputeWorkGroupCount[0]} * limits.maxComputeWorkGroupCount[1] < + 256) { + result.reason = "descriptor/range/dispatch limits too small"; + } else { + result.supported = true; + result.reason = "stable subgroup histogram and workgroup bit-mask scatter available"; + } + return result; +} + +splat::Result> RadixSort::create(const VulkanContext& ctx) { + auto capabilities = queryCapabilities(ctx); + if (!capabilities.supported) + return splat::Error{splat::ErrorCode::gpuUnavailable, "radix: " + capabilities.reason}; + std::unique_ptr sort(new RadixSort(ctx)); + if (!sort->initialize()) + return splat::Error{splat::ErrorCode::gpuUnavailable, "radix pipeline allocation"}; + return sort; +} + +RadixSort::~RadixSort() { + for (auto* pipeline : pipelines_) + if (pipeline) vkDestroyPipeline(ctx_.device(), pipeline, nullptr); + if (layout_) vkDestroyPipelineLayout(ctx_.device(), layout_, nullptr); + if (pool_) vkDestroyDescriptorPool(ctx_.device(), pool_, nullptr); + if (setLayout_) vkDestroyDescriptorSetLayout(ctx_.device(), setLayout_, nullptr); +} + +bool RadixSort::initialize() { + auto* const device = ctx_.device(); + std::array bindings{}; + for (uint32_t i = 0; i < kBindings; ++i) + bindings[i] = {i, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1, kShader, nullptr}; + VkDescriptorSetLayoutCreateInfo setInfo{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + setInfo.bindingCount = kBindings; + setInfo.pBindings = bindings.data(); + if (vkCreateDescriptorSetLayout(device, &setInfo, nullptr, &setLayout_) != VK_SUCCESS) + return false; + const VkDescriptorPoolSize size{VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, kBindings * kSlots * 3}; + VkDescriptorPoolCreateInfo poolInfo{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + poolInfo.maxSets = kSlots * 3; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &size; + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &pool_) != VK_SUCCESS) return false; + std::array layouts{setLayout_, setLayout_, setLayout_}; + for (auto& sets : sets_) { + VkDescriptorSetAllocateInfo info{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + info.descriptorPool = pool_; + info.descriptorSetCount = 3; + info.pSetLayouts = layouts.data(); + if (vkAllocateDescriptorSets(device, &info, sets.data()) != VK_SUCCESS) return false; + } + const VkPushConstantRange push{kShader, 0, 12}; + VkPipelineLayoutCreateInfo layout{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + layout.setLayoutCount = 1; + layout.pSetLayouts = &setLayout_; + layout.pushConstantRangeCount = 1; + layout.pPushConstantRanges = &push; + if (vkCreatePipelineLayout(device, &layout, nullptr, &layout_) != VK_SUCCESS) return false; + const uint32_t* code[] = {shaders::radix_prepare_comp, shaders::radix_histogram_comp, + shaders::radix_scan_comp, shaders::radix_scatter_comp}; + const size_t sizes[] = {shaders::radix_prepare_comp_size, shaders::radix_histogram_comp_size, + shaders::radix_scan_comp_size, shaders::radix_scatter_comp_size}; + for (uint32_t i = 0; i < 4; ++i) { + VkShaderModuleCreateInfo moduleInfo{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + moduleInfo.codeSize = sizes[i]; + moduleInfo.pCode = code[i]; + VkShaderModule module = VK_NULL_HANDLE; + if (vkCreateShaderModule(device, &moduleInfo, nullptr, &module) != VK_SUCCESS) return false; + VkComputePipelineCreateInfo pipeline{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO}; + pipeline.stage = {VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + pipeline.stage.stage = kShader; + pipeline.stage.module = module; + pipeline.stage.pName = "main"; + pipeline.layout = layout_; + const auto result = + vkCreateComputePipelines(device, VK_NULL_HANDLE, 1, &pipeline, nullptr, &pipelines_[i]); + vkDestroyShaderModule(device, module, nullptr); + if (result != VK_SUCCESS) return false; + } + return true; +} + +bool RadixSort::reserve(uint32_t capacity) { + capacity = std::max(capacity, 1u); + if (capacity > kMaxCapacity) return false; + if (capacity <= capacity_) return true; + const auto& limits = ctx_.vkbDevice().physical_device.properties.limits; + const VkDeviceSize pairBytes = VkDeviceSize{capacity} * 4; + const uint32_t blocks = (capacity + kBlock - 1) / kBlock; + const VkDeviceSize histogramBytes = VkDeviceSize{blocks} * 256 * 4; + if (pairBytes > limits.maxStorageBufferRange || histogramBytes > limits.maxStorageBufferRange || + uint64_t{blocks} > + uint64_t{limits.maxComputeWorkGroupCount[0]} * limits.maxComputeWorkGroupCount[1]) + return false; + std::array next; + for (auto& slot : next) { + for (auto& keys : slot.keys) keys = GpuBuffer::deviceLocal(ctx_, pairBytes, kStorage); + for (auto& values : slot.values) values = GpuBuffer::deviceLocal(ctx_, pairBytes, kStorage); + slot.histogram = GpuBuffer::deviceLocal(ctx_, histogramBytes, kStorage); + slot.totals = GpuBuffer::deviceLocal(ctx_, 1024, kStorage); + slot.state = GpuBuffer::deviceLocal(ctx_, 16, kStorage | VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT); + slot.count = GpuBuffer::deviceLocal(ctx_, 4, kStorage); + slot.status = GpuBuffer::deviceLocal(ctx_, 4, kStorage); + if (!slot.keys[0] || !slot.keys[1] || !slot.values[0] || !slot.values[1] || !slot.histogram || + !slot.totals || !slot.state || !slot.count || !slot.status) + return false; + } + slots_ = std::move(next); + capacity_ = capacity; + return true; +} + +RadixSort::Output RadixSort::output(uint32_t slot) const { + if (slot >= kSlots || !capacity_) return {}; + const auto& s = slots_[slot]; + return {s.keys[0]->handle(), s.values[0]->handle(), s.count->handle(), s.status->handle()}; +} + +bool RadixSort::encode(VkCommandBuffer cmd, uint32_t slot, const Input& input) const { + if (!cmd || slot >= kSlots || !capacity_ || + (input.keyBits != KeyBits::full32 && input.keyBits != KeyBits::low16)) + return false; + const auto& limits = ctx_.vkbDevice().physical_device.properties.limits; + const VkDeviceSize bytes = VkDeviceSize{capacity_} * 4; + const auto valid = [&](VkBuffer buffer, VkDeviceSize offset, VkDeviceSize total, + VkDeviceSize range) { + if (!buffer || offset > total || range > total - offset || offset % 4 || + (limits.minStorageBufferOffsetAlignment && offset % limits.minStorageBufferOffsetAlignment)) + return false; + for (const auto& s : slots_) { + for (uint32_t i = 0; i < 2; ++i) + if (buffer == s.keys[i]->handle() || buffer == s.values[i]->handle()) return false; + if (buffer == s.count->handle() || buffer == s.status->handle() || + buffer == s.state->handle() || buffer == s.histogram->handle() || + buffer == s.totals->handle()) + return false; + } + return true; + }; + if (!valid(input.keys, input.keysOffset, input.keysBytes, bytes) || + !valid(input.values, input.valuesOffset, input.valuesBytes, bytes) || + !valid(input.count, input.countOffset, input.countBytes, 4)) + return false; + const auto& s = slots_[slot]; + auto info = [](const std::unique_ptr& buffer) { + return VkDescriptorBufferInfo{buffer->handle(), 0, buffer->size()}; + }; + for (uint32_t set = 0; set < 3; ++set) { + const uint32_t in = set == 1 ? 1 : 0; + const uint32_t out = in ^ 1; + const VkDescriptorBufferInfo infos[kBindings] = { + set == 0 ? VkDescriptorBufferInfo{input.keys, input.keysOffset, bytes} : info(s.keys[in]), + set == 0 ? VkDescriptorBufferInfo{input.values, input.valuesOffset, bytes} + : info(s.values[in]), + info(s.keys[out]), + info(s.values[out]), + {input.count, input.countOffset, 4}, + info(s.histogram), + info(s.totals), + info(s.state), + info(s.count), + info(s.status)}; + VkWriteDescriptorSet writes[kBindings]{}; + for (uint32_t i = 0; i < kBindings; ++i) { + writes[i] = {VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + writes[i].dstSet = sets_[slot][set]; + writes[i].dstBinding = i; + writes[i].descriptorCount = 1; + writes[i].descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + writes[i].pBufferInfo = &infos[i]; + } + vkUpdateDescriptorSets(ctx_.device(), kBindings, writes, 0, nullptr); + } + dependency(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT | VK_PIPELINE_STAGE_HOST_BIT, kCompute, + VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_HOST_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT); + struct Push { + uint32_t capacity, shift, maxGroupsX; + } push{capacity_, 0, limits.maxComputeWorkGroupCount[0]}; + const auto bindSet = [&](uint32_t set) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, layout_, 0, 1, &sets_[slot][set], + 0, nullptr); + }; + // Bind after each pipeline transition: MoltenVK/gfxstream otherwise retained stale + // encoded state when a different compute layout (visibility) preceded this pass. + // VulkanFrameComputeTest covers the complete producer-to-sort chain. + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipelines_[0]); + bindSet(0); + vkCmdPushConstants(cmd, layout_, kShader, 0, sizeof(push), &push); + vkCmdDispatch(cmd, 1, 1, 1); + dependency( + cmd, kCompute, kCompute | VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT | VK_ACCESS_INDIRECT_COMMAND_READ_BIT); + const uint32_t passes = input.keyBits == KeyBits::low16 ? 2 : 4; + for (uint32_t pass = 0; pass < passes; ++pass) { + push.shift = pass * 8; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipelines_[1]); + bindSet(pass == 0 ? 0 : (pass & 1 ? 1 : 2)); + vkCmdPushConstants(cmd, layout_, kShader, 0, sizeof(push), &push); + vkCmdDispatchIndirect(cmd, s.state->handle(), 0); + dependency(cmd, kCompute, kCompute, VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipelines_[2]); + bindSet(pass == 0 ? 0 : (pass & 1 ? 1 : 2)); + vkCmdPushConstants(cmd, layout_, kShader, 0, sizeof(push), &push); + const uint32_t scanX = std::min(256u, push.maxGroupsX); + vkCmdDispatch(cmd, scanX, (256 + scanX - 1) / scanX, 1); + dependency(cmd, kCompute, kCompute, VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipelines_[3]); + bindSet(pass == 0 ? 0 : (pass & 1 ? 1 : 2)); + vkCmdPushConstants(cmd, layout_, kShader, 0, sizeof(push), &push); + vkCmdDispatchIndirect(cmd, s.state->handle(), 0); + dependency(cmd, kCompute, kCompute, VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT); + } + dependency(cmd, kCompute, + kCompute | VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_SHADER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_TRANSFER_READ_BIT); + return true; +} +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/RadixSort.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/RadixSort.h new file mode 100644 index 0000000..1351bd5 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/RadixSort.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +#include "rendering/vulkan/GpuBuffer.h" +#include "splat/core/Result.h" + +namespace splatkit { + +// Stable ascending LSD radix sort. Render-thread only; never submits or reads a GPU count. +// Context and input allocations outlive execution. Fence a slot before encode/reuse, and +// fence ALL slots before reserve/destruction. One encode per slot per outstanding submission. +// Inputs are read-only, separate uint32 key/value arrays; low16 ignores upper key bits. +// Output never aliases input. Passing any owned output/scratch as input is rejected; distinct +// VkBuffer handles must not alias the same memory. External offsets/sizes must be truthful. +class RadixSort { + public: + enum class KeyBits : uint32_t { full32, low16 }; + struct Capabilities { + bool supported = false; + uint32_t subgroupSize = 0; + std::string reason; + }; + struct Input { + VkBuffer keys = VK_NULL_HANDLE; + VkBuffer values = VK_NULL_HANDLE; + VkBuffer count = VK_NULL_HANDLE; + VkDeviceSize keysBytes = 0, valuesBytes = 0, countBytes = 4; + KeyBits keyBits = KeyBits::full32; + VkDeviceSize keysOffset = 0, valuesOffset = 0, countOffset = 0; + }; + struct Output { + VkBuffer keys = VK_NULL_HANDLE, values = VK_NULL_HANDLE; + VkBuffer count = VK_NULL_HANDLE, status = VK_NULL_HANDLE; + }; + static constexpr uint32_t kSlots = 2, kBlock = 2048, kMaxCapacity = 3000000; + static constexpr uint32_t kInvalidCount = 1; + static Capabilities queryCapabilities(const VulkanContext& ctx); + static splat::Result> create(const VulkanContext& ctx); + ~RadixSort(); + RadixSort(const RadixSort&) = delete; + RadixSort& operator=(const RadixSort&) = delete; + // Transactional growth; zero reserves one element. Failure preserves all old resources. + bool reserve(uint32_t capacity); + uint32_t capacity() const { return capacity_; } + // Buffers require STORAGE_BUFFER usage. Input count > capacity fails closed on GPU: + // output count=0/status=kInvalidCount. Caller must consume output count, not input count. + // Input dependencies (same queue) and compute/vertex/transfer output visibility included. + // Queue ownership and cross-queue semaphores, host flush/invalidate remain caller-owned. + bool encode(VkCommandBuffer cmd, uint32_t slot, const Input& input) const; + // Borrowed handles valid until growth/destruction. count/status are four-byte ranges. + Output output(uint32_t slot) const; + + private: + explicit RadixSort(const VulkanContext& ctx) : ctx_(ctx) {} + bool initialize(); + struct Slot { + std::array, 2> keys, values; + std::unique_ptr histogram, totals, state, count, status; + }; + const VulkanContext& ctx_; + uint32_t capacity_ = 0; + std::array slots_; + VkDescriptorSetLayout setLayout_ = VK_NULL_HANDLE; + VkDescriptorPool pool_ = VK_NULL_HANDLE; + VkPipelineLayout layout_ = VK_NULL_HANDLE; + std::array pipelines_{}; + // First pass external->B; second B->A; subsequent A->B and B->A. + std::array, kSlots> sets_{}; +}; +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/RenderTarget.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/RenderTarget.cpp index bd1286e..c05197a 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/RenderTarget.cpp +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/RenderTarget.cpp @@ -1,6 +1,6 @@ #include "rendering/vulkan/RenderTarget.h" -#include "Log.h" +#include "splatkit/Log.h" namespace splatkit { diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.cpp index 66f2d1d..0ac1d60 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.cpp +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.cpp @@ -3,12 +3,12 @@ #include #include #include +#include #include -#include "Log.h" #include "shaders/splat_frag.h" #include "shaders/splat_vert.h" -#include "splat/math/Half.h" +#include "splatkit/Log.h" namespace splatkit { namespace { @@ -22,17 +22,6 @@ VkShaderModule makeModule(VkDevice device, const uint32_t* code, size_t size) { return module; } -uint32_t packRgba8(float r, float g, float b, float a) { - auto q = [](float v) { - return static_cast(std::lround(std::clamp(v, 0.0f, 1.0f) * 255.0f)); - }; - return q(r) | (q(g) << 8) | (q(b) << 16) | (q(a) << 24); -} - -uint32_t packHalf2(float a, float b) { - return static_cast(splat::toHalf(a)) | (static_cast(splat::toHalf(b)) << 16); -} - } // namespace splat::Result> SplatPipeline::create(const VulkanContext& ctx, @@ -201,78 +190,109 @@ bool SplatPipeline::createPipelines(VkRenderPass renderPass) { return ok; } -namespace { - -// Bands 1 to `degree` of every splat as halves, channel fastest, two per uint. The source -// keeps its own degree's coefficients per splat; a lower target degree keeps the leading -// ones, which is exactly the lower degree expansion. -std::vector packSh(const splat::SplatCloud& cloud, int degree) { +std::unique_ptr SplatPipeline::uploadWorld(const splat::SplatCloud& cloud, + int maxShDegree, bool cpuOrder) const { const size_t n = cloud.count(); - const size_t sourceCoefficients = n == 0 ? 0 : cloud.sh.size() / (n * 3); - const auto coefficients = static_cast((degree + 1) * (degree + 1) - 1); - const size_t halves = coefficients * 3; - const size_t stride = (halves + 1) / 2; - std::vector packed(n * stride, 0); - for (size_t i = 0; i < n; ++i) { - const float* src = &cloud.sh[i * sourceCoefficients * 3]; - for (size_t h = 0; h < halves; ++h) { - const uint32_t half = splat::toHalf(src[h]); - packed[i * stride + h / 2] |= half << ((h & 1) * 16); + if (n > std::numeric_limits::max() || cloud.positions.size() != n * 3 || + cloud.covariances.size() != n * 6 || cloud.colors.size() != n * 3 || cloud.alphas.size() != n) + return nullptr; + const int requestedDegree = std::clamp(std::min(cloud.shDegree, maxShDegree), 0, kMaxShDegree); + const int shDegree = carriesSh(cloud, requestedDegree) ? requestedDegree : 0; + const size_t stride = shDegree ? shStride(shDegree) : 0; + const VkDeviceSize sourceBytes = std::max(size_t{1}, n) * sizeof(GpuSplat); + const VkDeviceSize orderBytes = (cpuOrder ? std::max(size_t{1}, n) : 1) * sizeof(uint32_t); + const VkDeviceSize shBytes = std::max(size_t{1}, n * stride) * sizeof(uint32_t); + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(ctx_.physicalDevice(), &properties); + if (std::max({sourceBytes, orderBytes, shBytes}) > properties.limits.maxStorageBufferRange) + return nullptr; + + auto world = std::make_unique(); + world->count = static_cast(n); + world->shDegree = shDegree; + world->splats = GpuBuffer::deviceLocal(ctx_, sourceBytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + world->order = GpuBuffer::deviceLocal(ctx_, orderBytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + world->sh = GpuBuffer::deviceLocal(ctx_, shBytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + if (!world->splats || !world->order || !world->sh) return nullptr; + for (auto& staging : world->orderStaging) { + if (!cpuOrder) break; + staging = GpuBuffer::hostVisible(ctx_, orderBytes, VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + if (!staging) return nullptr; + } + // Bounded packing/upload windows: do not retain full packed + mapped copies of a 10M cloud. + const size_t chunk = (2 * 1024 * 1024) / std::max(sizeof(GpuSplat), stride * sizeof(uint32_t)); + std::vector packed(std::min(n, chunk)); + std::vector sh(std::min(n, chunk) * stride); + std::vector order(cpuOrder ? std::min(n, chunk) : 0); + for (size_t offset = 0; offset < n; offset += chunk) { + const size_t count = std::min(chunk, n - offset); + packSplatRange(cloud, offset, count, packed.data()); + if (!world->splats->upload(offset * sizeof(GpuSplat), packed.data(), count * sizeof(GpuSplat))) + return nullptr; + if (stride) { + packShRange(cloud, shDegree, offset, count, sh.data()); + if (!world->sh->upload(offset * stride * 4, sh.data(), count * stride * 4)) return nullptr; + } + if (cpuOrder) { + for (size_t i = 0; i < count; ++i) order[i] = static_cast(offset + i); + if (!world->order->upload(offset * 4, order.data(), count * 4)) return nullptr; } } - return packed; + const uint32_t zero = 0; + if ((!cpuOrder || !n) && !world->order->upload(&zero, sizeof(zero))) return nullptr; + if ((!stride || !n) && !world->sh->upload(&zero, sizeof(zero))) return nullptr; + return world; } -} // namespace - -std::unique_ptr SplatPipeline::uploadWorld(const splat::SplatCloud& cloud, - int maxShDegree) const { - const size_t n = cloud.count(); - const int shDegree = std::clamp(std::min(cloud.shDegree, maxShDegree), 0, kMaxShDegree); - const bool shComplete = - cloud.sh.size() >= - n * 3 * static_cast((cloud.shDegree + 1) * (cloud.shDegree + 1) - 1); - std::vector sh = - (shDegree > 0 && shComplete) ? packSh(cloud, shDegree) : std::vector{0}; - std::vector packed(n); - for (size_t i = 0; i < n; ++i) { - GpuSplat& g = packed[i]; - std::memcpy(g.position, &cloud.positions[i * 3], sizeof(g.position)); - const float alpha = cloud.alphas[i]; - g.rgba8 = - packRgba8(cloud.colors[i * 3], cloud.colors[i * 3 + 1], cloud.colors[i * 3 + 2], alpha); - if (alpha > 1.0f) std::memcpy(&g.lodAlpha, &alpha, sizeof(g.lodAlpha)); - const float* c = &cloud.covariances[i * 6]; // xx, xy, xz, yy, yz, zz - g.cov[0] = packHalf2(c[0], c[1]); - g.cov[1] = packHalf2(c[2], c[3]); - g.cov[2] = packHalf2(c[4], c[5]); - if (alpha <= 1.0f) g.lodAlpha = 0; - } - // Identity order until the sorter runs. - std::vector order(n); - for (uint32_t i = 0; i < n; ++i) order[i] = i; - +std::unique_ptr SplatPipeline::createSlab(uint32_t capacity, int shDegree, + bool cpuOrder) const { + if (capacity == 0) return nullptr; auto world = std::make_unique(); - world->count = static_cast(n); - world->shDegree = sh.size() > 1 ? shDegree : 0; - world->splats = GpuBuffer::deviceLocal(ctx_, packed.size() * sizeof(GpuSplat), + world->count = capacity; + world->shDegree = std::clamp(shDegree, 0, kMaxShDegree); + const VkDeviceSize shBytes = + world->shDegree > 0 ? VkDeviceSize{capacity} * shStride(world->shDegree) * sizeof(uint32_t) + : sizeof(uint32_t); + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(ctx_.physicalDevice(), &properties); + if (std::max(shBytes, VkDeviceSize{capacity} * sizeof(GpuSplat)) > + properties.limits.maxStorageBufferRange) + return nullptr; + world->splats = GpuBuffer::deviceLocal(ctx_, VkDeviceSize{capacity} * sizeof(GpuSplat), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); - world->order = GpuBuffer::deviceLocal(ctx_, order.size() * sizeof(uint32_t), - VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); - world->sh = GpuBuffer::deviceLocal(ctx_, sh.size() * sizeof(uint32_t), - VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + world->order = + GpuBuffer::deviceLocal(ctx_, VkDeviceSize{cpuOrder ? capacity : 1u} * sizeof(uint32_t), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + world->sh = GpuBuffer::deviceLocal(ctx_, shBytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); if (!world->splats || !world->order || !world->sh) return nullptr; for (auto& staging : world->orderStaging) { - staging = GpuBuffer::hostVisible(ctx_, order.size() * sizeof(uint32_t), + if (!cpuOrder) break; + staging = GpuBuffer::hostVisible(ctx_, VkDeviceSize{capacity} * sizeof(uint32_t), VK_BUFFER_USAGE_TRANSFER_SRC_BIT); if (!staging) return nullptr; } - if (!world->splats->upload(packed.data(), packed.size() * sizeof(GpuSplat))) return nullptr; - if (!world->order->upload(order.data(), order.size() * sizeof(uint32_t))) return nullptr; - if (!world->sh->upload(sh.data(), sh.size() * sizeof(uint32_t))) return nullptr; return world; } +bool SplatPipeline::uploadTile(const GpuWorld& slab, uint32_t offset, + const splat::SplatCloud& cloud) { + const size_t n = cloud.count(); + if (n == 0) return true; + if (offset > slab.count || n > slab.count - offset) return false; + const std::vector packed = packSplats(cloud); + if (!slab.splats->upload(VkDeviceSize{offset} * sizeof(GpuSplat), packed.data(), + packed.size() * sizeof(GpuSplat))) { + return false; + } + if (slab.shDegree == 0) return true; + const size_t stride = shStride(slab.shDegree); + const std::vector sh = carriesSh(cloud, slab.shDegree) + ? packSh(cloud, slab.shDegree) + : std::vector(n * stride, 0); + return slab.sh->upload(VkDeviceSize{offset} * stride * sizeof(uint32_t), sh.data(), + sh.size() * sizeof(uint32_t)); +} + void SplatPipeline::bindWorld(const GpuWorld& world) { for (uint32_t i = 0; i < FrameLoop::kFramesInFlight; ++i) { const VkDescriptorBufferInfo splats{world.splats->handle(), 0, VK_WHOLE_SIZE}; @@ -298,7 +318,7 @@ void SplatPipeline::bindWorld(const GpuWorld& world) { void SplatPipeline::updateOrder(VkCommandBuffer cmd, uint32_t frameSlot, const GpuWorld& world, const uint32_t* order, uint32_t count) { const VkDeviceSize bytes = std::min(count, world.count) * sizeof(uint32_t); - if (bytes == 0) return; + if (bytes == 0 || !world.orderStaging[frameSlot]) return; const GpuBuffer& staging = *world.orderStaging[frameSlot]; std::memcpy(staging.mapped(), order, static_cast(bytes)); staging.flush(0, bytes); @@ -328,11 +348,9 @@ void SplatPipeline::updateOrder(VkCommandBuffer cmd, uint32_t frameSlot, const G 0, nullptr, 1, &afterCopy, 0, nullptr); } -void SplatPipeline::draw(VkCommandBuffer cmd, uint32_t frameSlot, const GpuWorld& world, - uint32_t count, int shDegree, const splat::Mat4& view, - const splat::Mat4& proj, const splat::Vec3& cameraPosition, - VkExtent2D extent) { - if (count == 0) return; +VkBuffer SplatPipeline::updateCamera(uint32_t frameSlot, const splat::Mat4& view, + const splat::Mat4& proj, const splat::Vec3& cameraPosition, + VkExtent2D extent) { CameraUniform u{}; u.cameraPosition[0] = cameraPosition.x; u.cameraPosition[1] = cameraPosition.y; @@ -348,6 +366,36 @@ void SplatPipeline::draw(VkCommandBuffer cmd, uint32_t frameSlot, const GpuWorld u.outputLinear = outputLinear_ ? 1u : 0u; std::memcpy(uniforms_[frameSlot]->mapped(), &u, sizeof(u)); uniforms_[frameSlot]->flush(0, sizeof(u)); + return uniforms_[frameSlot]->handle(); +} + +void SplatPipeline::bindOrder(uint32_t frameSlot, VkBuffer order, uint32_t capacity) { + const VkDescriptorBufferInfo info{order, 0, + VkDeviceSize{std::max(1u, capacity)} * sizeof(uint32_t)}; + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = sets_[frameSlot]; + write.dstBinding = 2; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &info; + vkUpdateDescriptorSets(ctx_.device(), 1, &write, 0, nullptr); +} + +void SplatPipeline::drawIndirect(VkCommandBuffer cmd, uint32_t frameSlot, const GpuWorld& world, + int shDegree, VkBuffer arguments) { + const int degree = std::clamp(std::min(shDegree, world.shDegree), 0, kMaxShDegree); + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelines_[static_cast(degree)]); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, layout_, 0, 1, &sets_[frameSlot], 0, + nullptr); + vkCmdDrawIndirect(cmd, arguments, 0, 1, sizeof(VkDrawIndirectCommand)); +} + +void SplatPipeline::draw(VkCommandBuffer cmd, uint32_t frameSlot, const GpuWorld& world, + uint32_t count, int shDegree, const splat::Mat4& view, + const splat::Mat4& proj, const splat::Vec3& cameraPosition, + VkExtent2D extent) { + if (count == 0) return; + updateCamera(frameSlot, view, proj, cameraPosition, extent); const int degree = std::clamp(std::min(shDegree, world.shDegree), 0, kMaxShDegree); vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelines_[static_cast(degree)]); diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.h index 96e4f82..77eec00 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.h +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.h @@ -9,38 +9,15 @@ #include "rendering/vulkan/FrameLoop.h" #include "rendering/vulkan/GpuBuffer.h" #include "rendering/vulkan/VulkanContext.h" +#include "rendering/vulkan/VulkanShaderTypes.h" #include "splat/core/Result.h" #include "splat/formats/SplatCloud.h" #include "splat/math/Mat4.h" #include "splat/math/Vec3.h" +#include "splatkit/rendering/GpuLayout.h" namespace splatkit { -// Exactly the layout the vertex shader reads (std430, 32 bytes). -// Vertex fetch is the floor of the frame on Adreno 640 (8 ms for 500k splats at 48 -// bytes), so the record is as small as the source data allows: SPZ stores colour and -// alpha as 8 bits, and the covariance keeps 11 bits of mantissa as half floats. -struct GpuSplat { - float position[3]; - uint32_t rgba8; // colour and alpha, a real uint: never routed through a float, whose - // NaN patterns some mobile compilers canonicalise - uint32_t cov[3]; // six halves: (xx, xy), (xz, yy), (yz, zz) - uint32_t lodAlpha; // float bits of an opacity above 1 (level of detail nodes), else 0 -}; -static_assert(sizeof(GpuSplat) == 32, "GpuSplat must match the shader struct"); - -// std140 layout of the Camera uniform block. -struct CameraUniform { - splat::Mat4 view; - splat::Mat4 proj; - float focal[2]; - float tanHalfFov[2]; - float screenSize[2]; - uint32_t outputLinear; - uint32_t pad; - float cameraPosition[4]; -}; - // The world on the GPU: splats plus the draw order the sorter writes. struct GpuWorld { std::unique_ptr splats; @@ -71,11 +48,26 @@ class SplatPipeline { // Converts a decoded cloud to the GPU layout and uploads it (blocking). Spherical // harmonics above `maxShDegree` are dropped: degree 3 costs 92 bytes per splat. - std::unique_ptr uploadWorld(const splat::SplatCloud& cloud, int maxShDegree) const; + std::unique_ptr uploadWorld(const splat::SplatCloud& cloud, int maxShDegree, + bool cpuOrder = true) const; + // An empty world of `capacity` records at `shDegree`, the slab the tiles of a tiled + // world land in; nothing is drawn until an order names records that were uploaded. + std::unique_ptr createSlab(uint32_t capacity, int shDegree, bool cpuOrder = true) const; + // Converts and uploads a tile into records [offset, offset + count) of a slab + // (blocking). Harmonics above the slab's degree are dropped, missing ones are zero. + static bool uploadTile(const GpuWorld& slab, uint32_t offset, const splat::SplatCloud& cloud); // Points the descriptor set of every frame slot at this world's buffers. void bindWorld(const GpuWorld& world); + // After the frame-slot fence, before compute/draw. Borrowed uniform stays valid until + // this pipeline is destroyed; order must cover capacity uint32s and remain alive. + VkBuffer updateCamera(uint32_t frameSlot, const splat::Mat4& view, const splat::Mat4& proj, + const splat::Vec3& cameraPosition, VkExtent2D extent); + void bindOrder(uint32_t frameSlot, VkBuffer order, uint32_t capacity); + void drawIndirect(VkCommandBuffer cmd, uint32_t frameSlot, const GpuWorld& world, int shDegree, + VkBuffer arguments); + // Records the copy of a new draw order into the world. Must be called outside a render // pass, before `draw` in the same command buffer. `order` has `count` entries, at most // `world.count`: the sorter leaves out what the frustum cannot see. diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/Swapchain.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/Swapchain.cpp index 2d7d9c4..bff9a83 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/Swapchain.cpp +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/Swapchain.cpp @@ -1,6 +1,6 @@ #include "rendering/vulkan/Swapchain.h" -#include "Log.h" +#include "splatkit/Log.h" namespace splatkit { diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VisibilityPass.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VisibilityPass.cpp new file mode 100644 index 0000000..8cf49e6 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VisibilityPass.cpp @@ -0,0 +1,500 @@ +#include "rendering/vulkan/VisibilityPass.h" + +#include +#include +#include +#include +#include + +#include "rendering/vulkan/VulkanShaderTypes.h" +#include "shaders/prepare_indirect_comp.h" +#include "shaders/visibility_comp.h" +#include "splatkit/Log.h" +#include "splatkit/rendering/GpuLayout.h" + +namespace splatkit { +namespace { + +constexpr VkShaderStageFlags kComputeShader = VK_SHADER_STAGE_COMPUTE_BIT; +constexpr VkPipelineStageFlags kComputeStage = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; +constexpr VkSubgroupFeatureFlags kRequiredSubgroupOperations = VK_SUBGROUP_FEATURE_BASIC_BIT | + VK_SUBGROUP_FEATURE_ARITHMETIC_BIT | + VK_SUBGROUP_FEATURE_BALLOT_BIT; + +static_assert(sizeof(CameraUniform) == 176, "visibility camera layout must match splat.vert"); +static_assert(sizeof(VkDrawIndirectCommand) == 16, + "visibility indirect output must match native Vulkan draw arguments"); + +VkShaderModule makeModule(VkDevice device, const uint32_t* code, size_t size) { + VkShaderModuleCreateInfo info{VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO}; + info.codeSize = size; + info.pCode = code; + VkShaderModule module = VK_NULL_HANDLE; + if (vkCreateShaderModule(device, &info, nullptr, &module) != VK_SUCCESS) return VK_NULL_HANDLE; + return module; +} + +bool validBuffer(VkBuffer buffer) { + return buffer != VK_NULL_HANDLE; +} + +} // namespace + +VisibilityCapabilities VisibilityPass::queryCapabilities(const VulkanContext& ctx) { + VisibilityCapabilities result; + uint32_t queueFamilyCount = 0; + vkGetPhysicalDeviceQueueFamilyProperties(ctx.physicalDevice(), &queueFamilyCount, nullptr); + std::vector queueFamilies(queueFamilyCount); + vkGetPhysicalDeviceQueueFamilyProperties(ctx.physicalDevice(), &queueFamilyCount, + queueFamilies.data()); + VkPhysicalDeviceSubgroupProperties subgroup{ + VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SUBGROUP_PROPERTIES}; + VkPhysicalDeviceProperties2 properties{VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2}; + properties.pNext = &subgroup; + vkGetPhysicalDeviceProperties2(ctx.physicalDevice(), &properties); + + result.subgroupSize = subgroup.subgroupSize; + result.supportedStages = subgroup.supportedStages; + result.supportedOperations = subgroup.supportedOperations; + result.maxComputeWorkGroupInvocations = + properties.properties.limits.maxComputeWorkGroupInvocations; + result.maxComputeWorkGroupSizeX = properties.properties.limits.maxComputeWorkGroupSize[0]; + result.maxComputeWorkGroupCountX = properties.properties.limits.maxComputeWorkGroupCount[0]; + result.maxComputeWorkGroupCountY = properties.properties.limits.maxComputeWorkGroupCount[1]; + result.maxStorageBufferRange = properties.properties.limits.maxStorageBufferRange; + + if (ctx.queueFamily() >= queueFamilies.size() || + (queueFamilies[ctx.queueFamily()].queueFlags & VK_QUEUE_COMPUTE_BIT) == 0) { + result.reason = "selected queue family has no compute capability"; + } else if ((subgroup.supportedStages & kComputeShader) == 0) { + result.reason = "subgroup operations are not supported in compute shaders"; + } else if ((subgroup.supportedOperations & kRequiredSubgroupOperations) != + kRequiredSubgroupOperations) { + result.reason = "subgroup basic, arithmetic, or ballot operation is unavailable"; + } else if (subgroup.subgroupSize == 0) { + result.reason = "device reported an invalid subgroup size"; + } else if (result.maxComputeWorkGroupInvocations < kWorkgroupSize || + result.maxComputeWorkGroupSizeX < kWorkgroupSize || + result.maxComputeWorkGroupCountX == 0 || result.maxComputeWorkGroupCountY == 0) { + result.reason = "compute workgroup limits are below the visibility workgroup size"; + } else if (properties.properties.limits.maxPerStageDescriptorStorageBuffers < 7 || + properties.properties.limits.maxDescriptorSetStorageBuffers < 7 || + properties.properties.limits.maxPerStageResources < 8 || + properties.properties.limits.maxUniformBufferRange < sizeof(CameraUniform) || + result.maxStorageBufferRange < sizeof(GpuSplat)) { + result.reason = "descriptor or buffer-range limits are below visibility requirements"; + } else { + result.supported = true; + result.reason = "subgroup arithmetic visibility is available"; + } + return result; +} + +splat::Result> VisibilityPass::create(const VulkanContext& ctx, + float minPixelRadius) { + if (!std::isfinite(minPixelRadius) || minPixelRadius < 0.0f) { + return splat::Error{splat::ErrorCode::gpuUnavailable, + "visibility: invalid minimum pixel radius"}; + } + VisibilityCapabilities capabilities = queryCapabilities(ctx); + if (!capabilities.supported) { + LOGI("GPU visibility unavailable: %s", capabilities.reason.c_str()); + return splat::Error{splat::ErrorCode::gpuUnavailable, "visibility: " + capabilities.reason}; + } + + std::unique_ptr pass( + new VisibilityPass(ctx, std::move(capabilities), minPixelRadius)); + if (!pass->createDescriptors() || !pass->createPipelines()) { + return splat::Error{splat::ErrorCode::gpuUnavailable, "visibility pipeline"}; + } + return pass; +} + +VisibilityPass::~VisibilityPass() { + VkDevice device = ctx_.device(); + if (visibilityPipeline_) vkDestroyPipeline(device, visibilityPipeline_, nullptr); + if (preparePipeline_) vkDestroyPipeline(device, preparePipeline_, nullptr); + if (visibilityLayout_) vkDestroyPipelineLayout(device, visibilityLayout_, nullptr); + if (prepareLayout_) vkDestroyPipelineLayout(device, prepareLayout_, nullptr); + if (descriptorPool_) vkDestroyDescriptorPool(device, descriptorPool_, nullptr); + if (visibilitySetLayout_) vkDestroyDescriptorSetLayout(device, visibilitySetLayout_, nullptr); + if (prepareSetLayout_) vkDestroyDescriptorSetLayout(device, prepareSetLayout_, nullptr); +} + +bool VisibilityPass::createDescriptors() { + VkDevice device = ctx_.device(); + VkDescriptorSetLayoutBinding visibilityBindings[8]{}; + visibilityBindings[0] = {0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1, kComputeShader, nullptr}; + for (uint32_t binding = 1; binding < 8; ++binding) { + visibilityBindings[binding] = {binding, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1, kComputeShader, + nullptr}; + } + VkDescriptorSetLayoutCreateInfo visibilityInfo{ + VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + visibilityInfo.bindingCount = 8; + visibilityInfo.pBindings = visibilityBindings; + if (vkCreateDescriptorSetLayout(device, &visibilityInfo, nullptr, &visibilitySetLayout_) != + VK_SUCCESS) + return false; + + VkDescriptorSetLayoutBinding prepareBindings[3]{}; + for (uint32_t binding = 0; binding < 3; ++binding) { + prepareBindings[binding] = {binding, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 1, kComputeShader, + nullptr}; + } + VkDescriptorSetLayoutCreateInfo prepareInfo{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO}; + prepareInfo.bindingCount = 3; + prepareInfo.pBindings = prepareBindings; + if (vkCreateDescriptorSetLayout(device, &prepareInfo, nullptr, &prepareSetLayout_) != VK_SUCCESS) + return false; + + VkDescriptorPoolSize sizes[2]{}; + sizes[0] = {VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, kSlots}; + sizes[1] = {VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, 10 * kSlots}; + VkDescriptorPoolCreateInfo poolInfo{VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO}; + poolInfo.maxSets = 2 * kSlots; + poolInfo.poolSizeCount = 2; + poolInfo.pPoolSizes = sizes; + if (vkCreateDescriptorPool(device, &poolInfo, nullptr, &descriptorPool_) != VK_SUCCESS) + return false; + + std::array visibilityLayouts{}; + std::array prepareLayouts{}; + visibilityLayouts.fill(visibilitySetLayout_); + prepareLayouts.fill(prepareSetLayout_); + VkDescriptorSetAllocateInfo visibilityAlloc{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + visibilityAlloc.descriptorPool = descriptorPool_; + visibilityAlloc.descriptorSetCount = kSlots; + visibilityAlloc.pSetLayouts = visibilityLayouts.data(); + if (vkAllocateDescriptorSets(device, &visibilityAlloc, visibilitySets_.data()) != VK_SUCCESS) + return false; + VkDescriptorSetAllocateInfo prepareAlloc{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + prepareAlloc.descriptorPool = descriptorPool_; + prepareAlloc.descriptorSetCount = kSlots; + prepareAlloc.pSetLayouts = prepareLayouts.data(); + return vkAllocateDescriptorSets(device, &prepareAlloc, prepareSets_.data()) == VK_SUCCESS; +} + +bool VisibilityPass::createPipelines() { + VkDevice device = ctx_.device(); + const VkPushConstantRange push{VK_SHADER_STAGE_COMPUTE_BIT, 0, 32}; + VkPipelineLayoutCreateInfo visibilityLayoutInfo{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + visibilityLayoutInfo.setLayoutCount = 1; + visibilityLayoutInfo.pSetLayouts = &visibilitySetLayout_; + visibilityLayoutInfo.pushConstantRangeCount = 1; + visibilityLayoutInfo.pPushConstantRanges = &push; + if (vkCreatePipelineLayout(device, &visibilityLayoutInfo, nullptr, &visibilityLayout_) != + VK_SUCCESS) + return false; + VkPipelineLayoutCreateInfo prepareLayoutInfo{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + prepareLayoutInfo.setLayoutCount = 1; + prepareLayoutInfo.pSetLayouts = &prepareSetLayout_; + if (vkCreatePipelineLayout(device, &prepareLayoutInfo, nullptr, &prepareLayout_) != VK_SUCCESS) + return false; + + VkShaderModule visibilityModule = + makeModule(device, shaders::visibility_comp, shaders::visibility_comp_size); + VkShaderModule prepareModule = + makeModule(device, shaders::prepare_indirect_comp, shaders::prepare_indirect_comp_size); + if (!visibilityModule || !prepareModule) { + if (visibilityModule) vkDestroyShaderModule(device, visibilityModule, nullptr); + if (prepareModule) vkDestroyShaderModule(device, prepareModule, nullptr); + return false; + } + VkPipelineShaderStageCreateInfo visibilityStage{ + VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + visibilityStage.stage = VK_SHADER_STAGE_COMPUTE_BIT; + visibilityStage.module = visibilityModule; + visibilityStage.pName = "main"; + VkComputePipelineCreateInfo visibilityInfo{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO}; + visibilityInfo.stage = visibilityStage; + visibilityInfo.layout = visibilityLayout_; + const VkResult visibilityResult = vkCreateComputePipelines( + device, VK_NULL_HANDLE, 1, &visibilityInfo, nullptr, &visibilityPipeline_); + + VkPipelineShaderStageCreateInfo prepareStage{VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO}; + prepareStage.stage = VK_SHADER_STAGE_COMPUTE_BIT; + prepareStage.module = prepareModule; + prepareStage.pName = "main"; + VkComputePipelineCreateInfo prepareInfo{VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO}; + prepareInfo.stage = prepareStage; + prepareInfo.layout = prepareLayout_; + const VkResult prepareResult = + vkCreateComputePipelines(device, VK_NULL_HANDLE, 1, &prepareInfo, nullptr, &preparePipeline_); + vkDestroyShaderModule(device, visibilityModule, nullptr); + vkDestroyShaderModule(device, prepareModule, nullptr); + return visibilityResult == VK_SUCCESS && prepareResult == VK_SUCCESS; +} + +bool VisibilityPass::reserve(uint32_t capacity) { + if (capacity == 0 || capacity > kMaxCapacity) return false; + const VkDeviceSize maxRange = capabilities_.maxStorageBufferRange; + // Output storage is independent of the resident source descriptor range. + if (capacity > maxRange / sizeof(uint32_t)) { + LOGE("visibility capacity %u exceeds storage buffer limits", capacity); + return false; + } + const VkDeviceSize indicesBytes = VkDeviceSize{capacity} * sizeof(uint32_t); + if (capacity == capacity_) return true; + std::unique_ptr dummy; + if (!dummy_) { + const uint32_t zeros[4]{}; + dummy = GpuBuffer::deviceLocal(ctx_, sizeof(zeros), VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + if (!dummy || !dummy->upload(zeros, sizeof(zeros))) return false; + } + + std::array, kSlots> indices; + std::array, kSlots> depthKeys; + std::array, kSlots> counts; + std::array, kSlots> indirect; + std::array, kSlots> status; + for (uint32_t slot = 0; slot < kSlots; ++slot) { + indices[slot] = GpuBuffer::deviceLocal( + ctx_, indicesBytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + depthKeys[slot] = GpuBuffer::deviceLocal( + ctx_, indicesBytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + counts[slot] = GpuBuffer::deviceLocal( + ctx_, sizeof(uint32_t), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + indirect[slot] = GpuBuffer::deviceLocal(ctx_, sizeof(VkDrawIndirectCommand), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | + VK_BUFFER_USAGE_INDIRECT_BUFFER_BIT | + VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + status[slot] = GpuBuffer::deviceLocal( + ctx_, sizeof(uint32_t), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + if (!indices[slot] || !depthKeys[slot] || !counts[slot] || !indirect[slot] || !status[slot]) { + LOGE("visibility buffers for %u splats failed", capacity); + return false; + } + } + if (dummy) dummy_ = std::move(dummy); + indices_ = std::move(indices); + depthKeys_ = std::move(depthKeys); + counts_ = std::move(counts); + indirect_ = std::move(indirect); + status_ = std::move(status); + capacity_ = capacity; + return true; +} + +VisibilityPass::Output VisibilityPass::output(uint32_t slot) const { + if (slot >= kSlots || capacity_ == 0) return {}; + return {indices_[slot]->handle(), depthKeys_[slot]->handle(), counts_[slot]->handle(), + indirect_[slot]->handle(), status_[slot]->handle()}; +} + +bool VisibilityPass::updateDescriptors(uint32_t slot, const Input& input) const { + const Output out = output(slot); + if (!validBuffer(input.camera) || !validBuffer(input.splats) || !out.indices || !out.depthKeys || + !out.count || !out.indirect || !out.status) + return false; + const VkPhysicalDeviceProperties& properties = ctx_.vkbDevice().physical_device.properties; + const VkDeviceSize uniformAlignment = properties.limits.minUniformBufferOffsetAlignment; + const VkDeviceSize storageAlignment = properties.limits.minStorageBufferOffsetAlignment; + if ((uniformAlignment != 0 && input.cameraOffset % uniformAlignment != 0) || + (storageAlignment != 0 && input.splatsOffset % storageAlignment != 0) || + sizeof(CameraUniform) > properties.limits.maxUniformBufferRange || + input.cameraOffset > std::numeric_limits::max() - sizeof(CameraUniform)) + return false; + const VkDeviceSize sourceBytes = VkDeviceSize{std::max(input.sourceCount, 1u)} * sizeof(GpuSplat); + if (input.splatsOffset > std::numeric_limits::max() - sourceBytes || + sourceBytes > capabilities_.maxStorageBufferRange) + return false; + auto validRange = [](VkDeviceSize offset, VkDeviceSize bytes, VkDeviceSize total) { + return offset <= total && bytes <= total - offset; + }; + if (!validRange(input.cameraOffset, sizeof(CameraUniform), input.cameraBytes) || + !validRange(input.splatsOffset, sourceBytes, input.splatsBytes)) + return false; + const uint32_t bound = input.mode == CandidateMode::prefix && input.candidateCapacity == 0 + ? input.sourceCount + : input.candidateCapacity; + VkDescriptorBufferInfo candidateInfo{dummy_->handle(), 0, dummy_->size()}; + VkDescriptorBufferInfo candidateCountInfo{dummy_->handle(), 0, sizeof(uint32_t)}; + if (input.mode != CandidateMode::prefix) { + const VkDeviceSize bytes = input.mode == CandidateMode::indices + ? VkDeviceSize{std::max(bound, 1u)} * 4 + : VkDeviceSize{std::max(input.rangeCount, 1u)} * sizeof(Range); + if (!input.candidates || bytes > capabilities_.maxStorageBufferRange || + (storageAlignment && input.candidatesOffset % storageAlignment != 0) || + !validRange(input.candidatesOffset, bytes, input.candidatesBytes)) + return false; + candidateInfo = {input.candidates, input.candidatesOffset, bytes}; + } + if (input.mode == CandidateMode::indices) { + if (!input.candidateCount || + (storageAlignment && input.candidateCountOffset % storageAlignment != 0) || + !validRange(input.candidateCountOffset, sizeof(uint32_t), input.candidateCountBytes)) + return false; + candidateCountInfo = {input.candidateCount, input.candidateCountOffset, sizeof(uint32_t)}; + } + const VkDescriptorBufferInfo camera{input.camera, input.cameraOffset, sizeof(CameraUniform)}; + const VkDescriptorBufferInfo splats{input.splats, input.splatsOffset, sourceBytes}; + const VkDescriptorBufferInfo indices{out.indices, 0, VK_WHOLE_SIZE}; + const VkDescriptorBufferInfo depthKeys{out.depthKeys, 0, VK_WHOLE_SIZE}; + const VkDescriptorBufferInfo count{out.count, 0, sizeof(uint32_t)}; + const VkDescriptorBufferInfo indirect{out.indirect, 0, sizeof(VkDrawIndirectCommand)}; + const VkDescriptorBufferInfo status{out.status, 0, sizeof(uint32_t)}; + const VkDescriptorBufferInfo visibilityInfos[8] = { + camera, splats, indices, depthKeys, count, status, candidateInfo, candidateCountInfo}; + VkWriteDescriptorSet writes[8]{}; + for (uint32_t binding = 0; binding < 8; ++binding) { + writes[binding] = { + VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, + nullptr, + visibilitySets_[slot], + binding, + 0, + 1, + binding == 0 ? VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER : VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, + nullptr, + &visibilityInfos[binding], + nullptr}; + } + vkUpdateDescriptorSets(ctx_.device(), 8, writes, 0, nullptr); + + const VkDescriptorBufferInfo prepareInfos[3] = {count, indirect, status}; + VkWriteDescriptorSet prepareWrites[3]{}; + for (uint32_t binding = 0; binding < 3; ++binding) { + prepareWrites[binding] = { + VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET, nullptr, prepareSets_[slot], binding, 0, 1, + VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, nullptr, &prepareInfos[binding], nullptr}; + } + vkUpdateDescriptorSets(ctx_.device(), 3, prepareWrites, 0, nullptr); + return true; +} + +void VisibilityPass::barrier(VkCommandBuffer cmd, VkPipelineStageFlags srcStage, + VkPipelineStageFlags dstStage, VkAccessFlags srcAccess, + VkAccessFlags dstAccess, const Output& out) { + VkBufferMemoryBarrier barriers[5]{}; + VkBuffer buffers[5] = {out.indices, out.depthKeys, out.count, out.indirect, out.status}; + const VkDeviceSize sizes[5] = {VK_WHOLE_SIZE, VK_WHOLE_SIZE, sizeof(uint32_t), + sizeof(VkDrawIndirectCommand), sizeof(uint32_t)}; + for (uint32_t i = 0; i < 5; ++i) { + barriers[i] = {VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + nullptr, + srcAccess, + dstAccess, + VK_QUEUE_FAMILY_IGNORED, + VK_QUEUE_FAMILY_IGNORED, + buffers[i], + 0, + sizes[i]}; + } + vkCmdPipelineBarrier(cmd, srcStage, dstStage, 0, 0, nullptr, 5, barriers, 0, nullptr); +} + +bool VisibilityPass::encode(VkCommandBuffer cmd, uint32_t slot, const Input& input) const { + const uint32_t bound = input.mode == CandidateMode::prefix && input.candidateCapacity == 0 + ? input.sourceCount + : input.candidateCapacity; + if (cmd == VK_NULL_HANDLE || slot >= kSlots || capacity_ == 0 || + static_cast(input.mode) > static_cast(CandidateMode::ranges) || + static_cast(input.keyBits) > static_cast(KeyBits::low16) || + static_cast(input.keyOrder) > static_cast(KeyOrder::descending) || + (input.mode == CandidateMode::prefix && bound > input.sourceCount) || + (input.mode == CandidateMode::ranges && + ((bound == 0) != (input.rangeCount == 0) || input.rangeCount > bound || + bound > input.sourceCount))) + return false; + const uint64_t workgroups = (static_cast(bound) + kWorkgroupSize - 1) / kWorkgroupSize; + const uint64_t maxWorkgroups = static_cast(capabilities_.maxComputeWorkGroupCountX) * + capabilities_.maxComputeWorkGroupCountY; + if (workgroups > maxWorkgroups || capabilities_.maxComputeWorkGroupCountX == 0) return false; + const uint32_t groupsX = static_cast(std::max( + 1, std::min(workgroups, capabilities_.maxComputeWorkGroupCountX))); + const uint32_t groupsY = + workgroups == 0 ? 1u : static_cast((workgroups + groupsX - 1) / groupsX); + if (!updateDescriptors(slot, input)) return false; + const Output out = output(slot); + // Slot reuse requires completion; this also orders already-recorded consumers and uploads. + VkMemoryBarrier inputs{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + inputs.srcAccessMask = + VK_ACCESS_MEMORY_READ_BIT | VK_ACCESS_MEMORY_WRITE_BIT | VK_ACCESS_HOST_WRITE_BIT; + inputs.dstAccessMask = + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_UNIFORM_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT | VK_PIPELINE_STAGE_HOST_BIT, + kComputeStage | VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &inputs, 0, nullptr, 0, + nullptr); + vkCmdFillBuffer(cmd, out.count, 0, sizeof(uint32_t), 0); + vkCmdFillBuffer(cmd, out.status, 0, sizeof(uint32_t), 0); + vkCmdFillBuffer(cmd, out.indirect, 0, sizeof(VkDrawIndirectCommand), 0); + barrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, kComputeStage, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, out); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, visibilityPipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, visibilityLayout_, 0, 1, + &visibilitySets_[slot], 0, nullptr); + struct PushConstants { + uint32_t sourceCount; + uint32_t capacity; + float minPixelRadius; + uint32_t dispatchGroupsX; + uint32_t candidateCapacity; + uint32_t mode; + uint32_t rangeCount; + uint32_t keyMode; + } push{input.sourceCount, + capacity_, + minPixelRadius_, + groupsX, + bound, + static_cast(input.mode), + input.rangeCount, + (input.keyBits == KeyBits::low16 ? 1u : 0u) | + (input.keyOrder == KeyOrder::descending ? 2u : 0u)}; + vkCmdPushConstants(cmd, visibilityLayout_, VK_SHADER_STAGE_COMPUTE_BIT, 0, sizeof(push), &push); + vkCmdDispatch(cmd, groupsX, groupsY, 1); + barrier(cmd, kComputeStage, kComputeStage, VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT, out); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, preparePipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, prepareLayout_, 0, 1, + &prepareSets_[slot], 0, nullptr); + vkCmdDispatch(cmd, 1, 1, 1); + + VkBufferMemoryBarrier consumer[5]{}; + consumer[0] = {VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + nullptr, + VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_TRANSFER_READ_BIT, + VK_QUEUE_FAMILY_IGNORED, + VK_QUEUE_FAMILY_IGNORED, + out.indices, + 0, + VK_WHOLE_SIZE}; + consumer[1] = consumer[0]; + consumer[1].buffer = out.depthKeys; + consumer[4] = {VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + nullptr, + VK_ACCESS_SHADER_WRITE_BIT, + VK_ACCESS_INDIRECT_COMMAND_READ_BIT | VK_ACCESS_TRANSFER_READ_BIT, + VK_QUEUE_FAMILY_IGNORED, + VK_QUEUE_FAMILY_IGNORED, + out.indirect, + 0, + sizeof(VkDrawIndirectCommand)}; + // Publish compacted index/key/count streams to the subsequent GPU radix pass. + consumer[2] = { + VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER, + nullptr, + VK_ACCESS_SHADER_WRITE_BIT | VK_ACCESS_TRANSFER_WRITE_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT | VK_ACCESS_TRANSFER_READ_BIT, + VK_QUEUE_FAMILY_IGNORED, + VK_QUEUE_FAMILY_IGNORED, + out.count, + 0, + sizeof(uint32_t)}; + consumer[3] = consumer[2]; + consumer[3].buffer = out.status; + // Empty input leaves count/status written by transfer rather than compute. + vkCmdPipelineBarrier(cmd, kComputeStage | VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | kComputeStage | + VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, nullptr, 5, consumer, 0, nullptr); + return true; +} + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VisibilityPass.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VisibilityPass.h new file mode 100644 index 0000000..7651d20 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VisibilityPass.h @@ -0,0 +1,146 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "rendering/vulkan/GpuBuffer.h" +#include "rendering/vulkan/VulkanContext.h" +#include "splat/core/Result.h" + +namespace splatkit { + +// The capability gate for the first Vulkan visibility seam. The implementation deliberately +// requires subgroup arithmetic in the compute stage: a device without it keeps the CPU path. +struct VisibilityCapabilities { + bool supported = false; + uint32_t subgroupSize = 0; + VkShaderStageFlags supportedStages = 0; + VkSubgroupFeatureFlags supportedOperations = 0; + uint32_t maxComputeWorkGroupInvocations = 0; + uint32_t maxComputeWorkGroupSizeX = 0; + uint32_t maxComputeWorkGroupCountX = 0; + uint32_t maxComputeWorkGroupCountY = 0; + VkDeviceSize maxStorageBufferRange = 0; + std::string reason; +}; + +// GPU visibility/compaction; sorting must consume its output before rasterization. +// Render-thread only. Context and inputs outlive submitted work. Reserve/destroy while all +// consumers are idle; fence before reusing a descriptor slot. No implicit submit/count readback. +class VisibilityPass { + public: + static VisibilityCapabilities queryCapabilities(const VulkanContext& ctx); + static splat::Result> create(const VulkanContext& ctx, + float minPixelRadius = 0.5f); + ~VisibilityPass(); + + VisibilityPass(const VisibilityPass&) = delete; + VisibilityPass& operator=(const VisibilityPass&) = delete; + + // Transactional output allocation, 1..kMaxCapacity. Independent of resident source count. + bool reserve(uint32_t capacity); + uint32_t capacity() const { return capacity_; } + float minPixelRadius() const { return minPixelRadius_; } + const VisibilityCapabilities& capabilities() const { return capabilities_; } + + enum class CandidateMode : uint32_t { prefix, indices, ranges }; + enum class KeyBits : uint32_t { full32, low16 }; + enum class KeyOrder : uint32_t { ascending, descending }; + struct Range { + uint32_t offset, count, prefixEnd, pad; + }; + static_assert(sizeof(Range) == 16); + struct Input { + VkBuffer camera = VK_NULL_HANDLE; // CameraUniform, 176-byte std140 + VkDeviceSize cameraOffset = 0; + VkBuffer splats = VK_NULL_HANDLE; // resident GpuSplat records, 32 bytes each + VkDeviceSize splatsOffset = 0; + uint32_t sourceCount = 0; + // Actual total buffer sizes, including offsets. Caller owns truthful sizes and uploads. + VkDeviceSize cameraBytes = 176; + VkDeviceSize splatsBytes = 0; + CandidateMode mode = CandidateMode::prefix; + VkBuffer candidates = VK_NULL_HANDLE; // uint indices, or 16-byte Range records + VkDeviceSize candidatesOffset = 0; + VkDeviceSize candidatesBytes = 0; + VkBuffer candidateCount = VK_NULL_HANDLE; // indices mode only: GPU uint count (LOD offset0) + VkDeviceSize candidateCountOffset = 0; + VkDeviceSize candidateCountBytes = 0; + // Dispatch upper bound, independent of output capacity. Prefix zero means sourceCount; + // otherwise selects first N records. Indices zero means empty bound, not resident count. + uint32_t candidateCapacity = 0; + // Host validates sorted nonoverlap, positive counts and cumulative prefixEnd == bound. + uint32_t rangeCount = 0; + KeyBits keyBits = KeyBits::full32; + KeyOrder keyOrder = KeyOrder::ascending; + }; + + struct Output { + // Borrowed handles. Valid until successful reserve or destruction, never CPU-mapped. + VkBuffer indices = VK_NULL_HANDLE; // compacted original source indices + VkBuffer depthKeys = VK_NULL_HANDLE; // camera-depth key, configured precision/direction + VkBuffer count = VK_NULL_HANDLE; // GPU survivor count + VkBuffer indirect = VK_NULL_HANDLE; // one VkDrawIndirectCommand (4 vertices/instance) + VkBuffer status = VK_NULL_HANDLE; // bit 0 means output-capacity overflow + }; + + // Rejects malformed/undersized/misaligned descriptors before recording. Inactive bindings + // use valid owned dummy ranges. Indexed/range source lookup is bounded on the GPU. + // Any GPU failure zeros BOTH count and draw instanceCount; status preserves diagnostic bits. + // Upload/host flush dependencies are caller-owned; outputs publish to compute/vertex/transfer + // and indirect consumers. Readback requires completion and noncoherent invalidation. + // Low16 near/far quantization is approximate. Equal keys have nondeterministic compaction + // order; stable radix alone cannot make their input order deterministic across frames. + bool encode(VkCommandBuffer cmd, uint32_t slot, const Input& input) const; + Output output(uint32_t slot) const; + + static constexpr uint32_t kSlots = 2; + static constexpr uint32_t kWorkgroupSize = 128; + static constexpr uint32_t kMaxCapacity = 3000000; + static constexpr uint32_t kOverflow = 1u; + static constexpr uint32_t kInvalidIndex = 2u; + static constexpr uint32_t kInvalidCount = 4u; + static constexpr uint32_t kInvalidRange = 8u; + static constexpr uint32_t kInvalidProjection = 16u; + + private: + explicit VisibilityPass(const VulkanContext& ctx, VisibilityCapabilities capabilities, + float minPixelRadius) + : ctx_(ctx), capabilities_(std::move(capabilities)), minPixelRadius_(minPixelRadius) {} + + bool createDescriptors(); + bool createPipelines(); + bool updateDescriptors(uint32_t slot, const Input& input) const; + static void barrier(VkCommandBuffer cmd, VkPipelineStageFlags srcStage, + VkPipelineStageFlags dstStage, VkAccessFlags srcAccess, + VkAccessFlags dstAccess, const Output& output); + + const VulkanContext& ctx_; + VisibilityCapabilities capabilities_; + float minPixelRadius_ = 0.5f; + uint32_t capacity_ = 0; + + VkDescriptorSetLayout visibilitySetLayout_ = VK_NULL_HANDLE; + VkDescriptorSetLayout prepareSetLayout_ = VK_NULL_HANDLE; + VkDescriptorPool descriptorPool_ = VK_NULL_HANDLE; + std::array visibilitySets_{}; + std::array prepareSets_{}; + VkPipelineLayout visibilityLayout_ = VK_NULL_HANDLE; + VkPipelineLayout prepareLayout_ = VK_NULL_HANDLE; + VkPipeline visibilityPipeline_ = VK_NULL_HANDLE; + VkPipeline preparePipeline_ = VK_NULL_HANDLE; + + std::unique_ptr dummy_; // immutable zero words for inactive input descriptors + std::array, kSlots> indices_{}; + std::array, kSlots> depthKeys_{}; + std::array, kSlots> counts_{}; + std::array, kSlots> indirect_{}; + std::array, kSlots> status_{}; +}; + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.cpp index 544cf64..8986563 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.cpp +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.cpp @@ -1,6 +1,6 @@ #include "rendering/vulkan/VulkanContext.h" -#include "Log.h" +#include "splatkit/Log.h" namespace splatkit { @@ -9,7 +9,11 @@ namespace { VKAPI_ATTR VkBool32 VKAPI_CALL onValidationMessage(VkDebugUtilsMessageSeverityFlagBitsEXT severity, VkDebugUtilsMessageTypeFlagsEXT, const VkDebugUtilsMessengerCallbackDataEXT* data, - void*) { + void* userData) { + if (severity & (VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT)) { + static_cast*>(userData)->fetch_add(1, std::memory_order_relaxed); + } if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT) { LOGE("validation: %s", data->pMessage); } else if (severity & VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT) { @@ -35,6 +39,7 @@ splat::Result> VulkanContext::create() { std::unique_ptr ctx(new VulkanContext()); const bool validation = wantValidation(); + ctx->validationEnabled_ = validation; LOGI("validation layers: %s", validation ? "on" : "off"); vkb::InstanceBuilder builder; builder.set_app_name("SplatKit").set_engine_name("SplatKit").require_api_version(1, 1, 0); @@ -42,7 +47,9 @@ splat::Result> VulkanContext::create() { // Adreno exposes VK_EXT_debug_utils but fails to create a messenger without the layer, // which is why a release build died here while a debug build did not. if (validation) { - builder.request_validation_layers(true).set_debug_callback(onValidationMessage); + builder.request_validation_layers(true) + .set_debug_callback(onValidationMessage) + .set_debug_callback_user_data_pointer(&ctx->validationMessageCount_); } auto instanceResult = builder.build(); if (!instanceResult) { diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.h index e64b3ff..88dc8ee 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.h +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -31,6 +32,8 @@ class VulkanContext { const vkb::Device& vkbDevice() const { return device_; } // "Adreno (TM) 640, Vulkan 1.1.128": what a HUD or a bug report wants to show. const std::string& deviceDescription() const { return deviceDescription_; } + bool validationEnabled() const { return validationEnabled_; } + uint32_t validationMessageCount() const { return validationMessageCount_.load(); } // True when the queue can present to this surface. Checked every time a surface arrives. bool supportsPresent(VkSurfaceKHR surface) const; @@ -45,6 +48,8 @@ class VulkanContext { uint32_t queueFamily_ = 0; VmaAllocator allocator_ = VK_NULL_HANDLE; std::string deviceDescription_; + bool validationEnabled_ = false; + std::atomic validationMessageCount_{0}; }; } // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanFrameCompute.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanFrameCompute.cpp new file mode 100644 index 0000000..5102a7d --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanFrameCompute.cpp @@ -0,0 +1,234 @@ +#include "rendering/vulkan/VulkanFrameCompute.h" + +#include +#include +#include +#include + +#include "rendering/vulkan/RadixSort.h" +#include "splatkit/Log.h" +#include "splatkit/rendering/GpuLayout.h" + +namespace splatkit { +namespace { +void dependency(VkCommandBuffer cmd, VkPipelineStageFlags source, VkAccessFlags sourceAccess, + VkPipelineStageFlags target, VkAccessFlags targetAccess) { + VkMemoryBarrier barrier{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + barrier.srcAccessMask = sourceAccess; + barrier.dstAccessMask = targetAccess; + vkCmdPipelineBarrier(cmd, source, target, 0, 1, &barrier, 0, nullptr, 0, nullptr); +} +constexpr uint32_t kMaxVisible = 3000000; +constexpr VkDeviceSize kReadbackBytes = 32; +} // namespace + +VulkanFrameCompute::VulkanFrameCompute(const VulkanContext& ctx) : ctx_(ctx) {} + +splat::Result> VulkanFrameCompute::create( + const VulkanContext& ctx, uint32_t sourceCount, const splat::LodTree* tree, uint32_t budget) { + std::unique_ptr pass(new VulkanFrameCompute(ctx)); + if (!pass->initialize(sourceCount, tree, budget)) + return splat::Error{splat::ErrorCode::gpuUnavailable, "GPU frame allocation/capability limits"}; + return pass; +} + +bool VulkanFrameCompute::initialize(uint32_t sourceCount, const splat::LodTree* tree, + uint32_t budget) { + sourceCount_ = sourceCount; + auto visibility = VisibilityPass::create(ctx_); + if (!visibility) return false; + visibility_ = std::move(visibility.value()); + if (VkDeviceSize{std::max(1u, sourceCount)} * sizeof(GpuSplat) > + visibility_->capabilities().maxStorageBufferRange) + return false; + capacity_ = std::max(1u, std::min(sourceCount, kMaxVisible)); + if (tree) { + if (tree->nodeCount() != sourceCount) return false; + auto lod = LodSelection::create(ctx_); + if (!lod || !lod.value()->upload(*tree, budget)) return false; + lod_ = std::move(lod.value()); + capacity_ = lod_->capacity(); + } + auto radix = RadixSort::create(ctx_); + if (!radix || !radix.value()->reserve(capacity_) || !visibility_->reserve(capacity_)) + return false; + radix_ = std::move(radix.value()); + if (const char* bits = std::getenv("SPLATKIT_VULKAN_SORT_BITS")) + if (std::strcmp(bits, "16") == 0) keyBits_ = 16; + + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(ctx_.physicalDevice(), &properties); + uint32_t familyCount = 0; + vkGetPhysicalDeviceQueueFamilyProperties(ctx_.physicalDevice(), &familyCount, nullptr); + std::vector families(familyCount); + vkGetPhysicalDeviceQueueFamilyProperties(ctx_.physicalDevice(), &familyCount, families.data()); + timestampBits_ = families[ctx_.queueFamily()].timestampValidBits; + timestampPeriod_ = properties.limits.timestampPeriod; + if (!properties.limits.timestampComputeAndGraphics) timestampBits_ = 0; + for (uint32_t slot = 0; slot < kSlots; ++slot) { + ranges_[slot] = GpuBuffer::hostVisible(ctx_, kMaxRanges * sizeof(RangeRecord), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + readback_[slot] = + GpuBuffer::hostVisible(ctx_, kReadbackBytes, VK_BUFFER_USAGE_TRANSFER_DST_BIT); + if (!ranges_[slot] || !readback_[slot]) return false; + if (timestampBits_) { + VkQueryPoolCreateInfo info{VK_STRUCTURE_TYPE_QUERY_POOL_CREATE_INFO}; + info.queryType = VK_QUERY_TYPE_TIMESTAMP; + info.queryCount = 4; + if (vkCreateQueryPool(ctx_.device(), &info, nullptr, &queries_[slot]) != VK_SUCCESS) + return false; + } + } + LOGI("Vulkan GPU frame: resident %u, visible capacity %u, LOD %d, radix bits %u", sourceCount_, + capacity_, lod_ ? 1 : 0, keyBits_); + return true; +} + +VulkanFrameCompute::~VulkanFrameCompute() { + for (VkQueryPool pool : queries_) + if (pool) vkDestroyQueryPool(ctx_.device(), pool, nullptr); +} + +bool VulkanFrameCompute::prepareRanges(uint32_t slot, const SplatRenderer::Frame& frame, + VisibilityPass::Input& input) { + if (frame.rangeCount > kMaxRanges || (frame.rangeCount && !frame.ranges)) return false; + rangeRecords_.clear(); + for (uint32_t i = 0; i < frame.rangeCount; ++i) { + const auto& range = frame.ranges[i]; + if (range.offset > sourceCount_ || range.count > sourceCount_ - range.offset) return false; + if (range.count) rangeRecords_.push_back({range.offset, range.count, 0, 0}); + } + std::sort(rangeRecords_.begin(), rangeRecords_.end(), + [](const RangeRecord& a, const RangeRecord& b) { return a.offset < b.offset; }); + uint32_t end = 0; + uint32_t count = 0; + for (auto& range : rangeRecords_) { + if (range.offset < end) return false; + end = range.offset + range.count; + count += range.count; // Nonoverlap and source bounds prove this cannot overflow. + range.prefixEnd = count; + } + if (!rangeRecords_.empty()) { + const size_t bytes = rangeRecords_.size() * sizeof(RangeRecord); + std::memcpy(ranges_[slot]->mapped(), rangeRecords_.data(), bytes); + ranges_[slot]->flush(0, bytes); + } + input.mode = VisibilityPass::CandidateMode::ranges; + input.candidates = ranges_[slot]->handle(); + input.candidatesBytes = ranges_[slot]->size(); + input.candidateCapacity = count; + input.rangeCount = static_cast(rangeRecords_.size()); + return true; +} + +void VulkanFrameCompute::collect(uint32_t slot) { + if (!pending_[slot]) return; + pending_[slot] = false; + readback_[slot]->invalidate(0, kReadbackBytes); + const auto* words = static_cast(readback_[slot]->mapped()); + stats_.drawn = words[0]; + stats_.selected = words[1]; + stats_.limited = words[2]; + stats_.evaluated = words[3]; + stats_.status = words[4] | (words[5] << 16); + if (stats_.status) LOGE("Vulkan GPU frame rejected: diagnostic bits 0x%x", stats_.status); + if (!queries_[slot]) return; + uint64_t ticks[4]{}; + if (vkGetQueryPoolResults(ctx_.device(), queries_[slot], 0, 4, sizeof(ticks), ticks, + sizeof(uint64_t), VK_QUERY_RESULT_64_BIT) != VK_SUCCESS) + return; + const uint64_t mask = timestampBits_ >= 64 ? std::numeric_limits::max() + : (uint64_t{1} << timestampBits_) - 1; + const double millis = static_cast(timestampPeriod_) * 1e-6; + stats_.selectMillis = lod_ ? ((ticks[1] - ticks[0]) & mask) * millis : 0; + stats_.sortMillis = ((ticks[3] - ticks[2]) & mask) * millis; +} + +void VulkanFrameCompute::copyDiagnostics(VkCommandBuffer cmd, uint32_t slot, + const VisibilityPass::Output& visible, + uint32_t candidates) { + auto* target = readback_[slot]->handle(); + uint32_t words[8]{0, candidates, 0, 0, 0, 0, 0, 0}; + vkCmdUpdateBuffer(cmd, target, 0, sizeof(words), words); + dependency(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT | VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_ACCESS_TRANSFER_WRITE_BIT | VK_ACCESS_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT); + const auto copy = [&](VkBuffer source, VkDeviceSize from, VkDeviceSize to, VkDeviceSize bytes) { + const VkBufferCopy region{from, to, bytes}; + vkCmdCopyBuffer(cmd, source, target, 1, ®ion); + }; + const auto sorted = radix_->output(slot); + copy(sorted.count, 0, 0, 4); + copy(visible.status, 0, 16, 4); + copy(sorted.status, 0, 20, 4); + if (lod_) { + copy(lod_->output().state, LodSelection::kCountOffset, 4, 4); + copy(lod_->output().state, LodSelection::kLimitedOffset, 8, 8); + } + dependency(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_HOST_BIT, VK_ACCESS_HOST_READ_BIT); + pending_[slot] = true; +} + +std::optional VulkanFrameCompute::encode( + VkCommandBuffer cmd, uint32_t slot, VkBuffer camera, const GpuBuffer& splats, + const SplatRenderer::Frame& frame) { + if (!cmd || slot >= kSlots || !camera || frame.orderSource != SplatRenderer::OrderSource::gpu) + return std::nullopt; + collect(slot); + VisibilityPass::Input input; + input.camera = camera; + input.splats = splats.handle(); + input.splatsBytes = splats.size(); + input.sourceCount = sourceCount_; + input.keyBits = keyBits_ == 16 ? VisibilityPass::KeyBits::low16 : VisibilityPass::KeyBits::full32; + input.keyOrder = VisibilityPass::KeyOrder::descending; // Hardware uses back-to-front over. + if (!lod_ && !prepareRanges(slot, frame, input)) return std::nullopt; + dependency(cmd, VK_PIPELINE_STAGE_HOST_BIT | VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_HOST_WRITE_BIT | VK_ACCESS_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_UNIFORM_READ_BIT); + if (queries_[slot]) vkCmdResetQueryPool(cmd, queries_[slot], 0, 4); + const auto timestamp = [&](uint32_t index) { + if (queries_[slot]) + vkCmdWriteTimestamp(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, queries_[slot], index); + }; + timestamp(0); + if (lod_) { + if (!lod_->encode(cmd, slot, {camera, 0})) return std::nullopt; + const auto selected = lod_->output(); + input.mode = VisibilityPass::CandidateMode::indices; + input.candidates = selected.indices; + input.candidatesBytes = VkDeviceSize{selected.capacity} * sizeof(uint32_t); + input.candidateCount = selected.state; + input.candidateCountBytes = LodSelection::kDiagnosticBytes; + input.candidateCapacity = selected.capacity; + } + timestamp(1); + if (!visibility_->encode(cmd, slot, input)) return std::nullopt; + const auto visible = visibility_->output(slot); + timestamp(2); + RadixSort::Input sort; + sort.keys = visible.depthKeys; + sort.values = visible.indices; + sort.count = visible.count; + sort.keysBytes = sort.valuesBytes = VkDeviceSize{capacity_} * sizeof(uint32_t); + sort.countBytes = sizeof(uint32_t); + sort.keyBits = keyBits_ == 16 ? RadixSort::KeyBits::low16 : RadixSort::KeyBits::full32; + if (!radix_->encode(cmd, slot, sort)) return std::nullopt; + timestamp(3); + // The sorter's checked GPU count is authoritative even if visibility succeeded. + dependency(cmd, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_ACCESS_SHADER_WRITE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT); + const VkBufferCopy countToDraw{0, offsetof(VkDrawIndirectCommand, instanceCount), + sizeof(uint32_t)}; + vkCmdCopyBuffer(cmd, radix_->output(slot).count, visible.indirect, 1, &countToDraw); + dependency(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT, VK_ACCESS_INDIRECT_COMMAND_READ_BIT); + copyDiagnostics(cmd, slot, visible, input.candidateCapacity); + return Draw{radix_->output(slot).values, visible.indirect, capacity_}; +} + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanFrameCompute.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanFrameCompute.h new file mode 100644 index 0000000..de6f911 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanFrameCompute.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +#include "rendering/vulkan/GpuBuffer.h" +#include "rendering/vulkan/LodSelection.h" +#include "rendering/vulkan/VisibilityPass.h" +#include "splatkit/rendering/SplatRenderer.h" + +namespace splatkit { + +class RadixSort; + +// Resident-world GPU ordering. Owns LOD, culling, sorting and delayed diagnostics. +// Render thread only. Context/inputs outlive submissions. Create/destroy while idle; +// encode only after the caller's slot fence, with consumers on the same queue. +// Counts never return to CPU to decide dispatch/draw. Diagnostics lag by frame slots. +class VulkanFrameCompute { + public: + static splat::Result> create( + const VulkanContext& ctx, uint32_t sourceCount, const splat::LodTree* tree = nullptr, + uint32_t budget = 2200000); + ~VulkanFrameCompute(); + VulkanFrameCompute(const VulkanFrameCompute&) = delete; + VulkanFrameCompute& operator=(const VulkanFrameCompute&) = delete; + + struct Draw { + VkBuffer order = VK_NULL_HANDLE; + VkBuffer arguments = VK_NULL_HANDLE; + uint32_t capacity = 0; + }; + struct Stats { + uint32_t drawn = 0, selected = 0, limited = 0, evaluated = 0, status = 0; + double sortMillis = 0, selectMillis = 0; + }; + // Nullopt means encoding failed; caller must still submit/end its acquired frame. + std::optional encode(VkCommandBuffer cmd, uint32_t slot, VkBuffer camera, + const GpuBuffer& splats, const SplatRenderer::Frame& frame); + const Stats& stats() const { return stats_; } + bool hasLod() const { return lod_ != nullptr; } + + private: + explicit VulkanFrameCompute(const VulkanContext& ctx); + bool initialize(uint32_t sourceCount, const splat::LodTree* tree, uint32_t budget); + bool prepareRanges(uint32_t slot, const SplatRenderer::Frame& frame, + VisibilityPass::Input& input); + void collect(uint32_t slot); + void copyDiagnostics(VkCommandBuffer cmd, uint32_t slot, const VisibilityPass::Output& visible, + uint32_t candidates); + + struct RangeRecord { + uint32_t offset, count, prefixEnd, pad; + }; + static constexpr uint32_t kSlots = 2; + static constexpr uint32_t kMaxRanges = 65536; + const VulkanContext& ctx_; + uint32_t sourceCount_ = 0, capacity_ = 0; + uint32_t keyBits_ = 32; + uint32_t timestampBits_ = 0; + float timestampPeriod_ = 0; + std::unique_ptr lod_; + std::unique_ptr visibility_; + std::unique_ptr radix_; + std::array, kSlots> ranges_, readback_; + std::array queries_{}; + std::array pending_{}; + std::vector rangeRecords_; + Stats stats_; +}; + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanShaderTypes.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanShaderTypes.h new file mode 100644 index 0000000..6860dfd --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanShaderTypes.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include "splat/math/Mat4.h" + +namespace splatkit { + +// Host ABI shared by splat.vert and visibility.comp, independent of either pass. +// Source splat records are the platform-independent GpuSplat in GpuLayout.h. +struct alignas(16) CameraUniform { + splat::Mat4 view; + splat::Mat4 proj; + float focal[2]; + float tanHalfFov[2]; + float screenSize[2]; + uint32_t outputLinear; + uint32_t pad; + float cameraPosition[4]; +}; +static_assert(sizeof(CameraUniform) == 176); +static_assert(offsetof(CameraUniform, proj) == 64); +static_assert(offsetof(CameraUniform, focal) == 128); +static_assert(offsetof(CameraUniform, tanHalfFov) == 136); +static_assert(offsetof(CameraUniform, screenSize) == 144); +static_assert(offsetof(CameraUniform, outputLinear) == 152); +static_assert(offsetof(CameraUniform, cameraPosition) == 160); + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.cpp b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.cpp index 974346d..4bd66e2 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.cpp +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.cpp @@ -1,10 +1,13 @@ #include "rendering/vulkan/VulkanSplatRenderer.h" #include +#include #include -#include "Log.h" +#include "rendering/vulkan/RadixSort.h" + +#include "splatkit/Log.h" namespace splatkit { namespace { @@ -75,21 +78,82 @@ void VulkanSplatRenderer::setVsync(bool vsync) { if (swapchain_) keepSurfaceIf(recreateSwapchain()); } -VkExtent2D VulkanSplatRenderer::drawExtent() const { - if (target_) return target_->extent(); - return swapchain_ ? swapchain_->extent() : VkExtent2D{0, 0}; +Extent VulkanSplatRenderer::drawExtent() const { + VkExtent2D extent{0, 0}; + if (target_) { + extent = target_->extent(); + } else if (swapchain_) { + extent = swapchain_->extent(); + } + return {extent.width, extent.height}; +} + +std::optional VulkanSplatRenderer::world() const { + if (!world_) return std::nullopt; + return GpuWorldInfo{world_->count, world_->shDegree}; } bool VulkanSplatRenderer::uploadWorld(const splat::SplatCloud& cloud, int maxShDegree) { + if (!splats_ || cloud.count() > std::numeric_limits::max()) return false; + ctx_.waitIdle(); + auto compute = VulkanFrameCompute::create(ctx_, static_cast(cloud.count())); + // No unsafe giant hardware fallback if GPU allocation/capabilities are insufficient. + if (!compute && cloud.count() > 3000000) { + LOGE("large world requires GPU visibility/sort or an offline LOD file: %s", + compute.error().message.c_str()); + return false; + } + auto world = splats_->uploadWorld(cloud, maxShDegree, !compute); + if (!world) return false; + ctx_.waitIdle(); // the previous world may still be in flight + world_ = std::move(world); + compute_ = compute ? std::move(compute.value()) : nullptr; + splats_->bindWorld(*world_); + return true; +} + +bool VulkanSplatRenderer::selectsLodOnGpu() const { + return VisibilityPass::queryCapabilities(ctx_).supported && + RadixSort::queryCapabilities(ctx_).supported; +} + +bool VulkanSplatRenderer::uploadLodWorld(const splat::LodTree& tree, int maxShDegree, + uint32_t budget) { + if (!splats_ || tree.nodeCount() > std::numeric_limits::max()) return false; + ctx_.waitIdle(); + auto compute = + VulkanFrameCompute::create(ctx_, static_cast(tree.nodeCount()), &tree, budget); + if (!compute) { + LOGE("GPU LOD upload rejected: %s", compute.error().message.c_str()); + return false; + } + auto world = splats_->uploadWorld(tree.nodes, maxShDegree, false); + if (!world) return false; + world_ = std::move(world); + compute_ = std::move(compute.value()); + splats_->bindWorld(*world_); + return true; +} + +bool VulkanSplatRenderer::createSlab(uint32_t capacity, int shDegree) { if (!splats_) return false; - auto world = splats_->uploadWorld(cloud, maxShDegree); + ctx_.waitIdle(); + auto compute = VulkanFrameCompute::create(ctx_, capacity); + if (!compute && capacity > 3000000) return false; + auto world = splats_->createSlab(capacity, shDegree, !compute); if (!world) return false; ctx_.waitIdle(); // the previous world may still be in flight world_ = std::move(world); + compute_ = compute ? std::move(compute.value()) : nullptr; splats_->bindWorld(*world_); return true; } +bool VulkanSplatRenderer::uploadTile(uint32_t offset, const splat::SplatCloud& cloud) { + if (!splats_ || !world_) return false; + return splats_->uploadTile(*world_, offset, cloud); +} + bool VulkanSplatRenderer::draw(const Frame& frame) { if (!ready()) return false; uint32_t imageIndex = 0; @@ -101,10 +165,20 @@ bool VulkanSplatRenderer::draw(const Frame& frame) { } if (status != FrameLoop::Status::ok) return false; - const VkExtent2D extent = drawExtent(); + const Extent size = drawExtent(); + const VkExtent2D extent{size.width, size.height}; const uint32_t slot = frameLoop_.currentSlot(); // Outside the render pass: transfers are not allowed inside one. - if (world_ && frame.order != nullptr) { + std::optional gpuDraw; + if (world_ && compute_ && frame.orderSource == OrderSource::gpu) { + const VkBuffer camera = + splats_->updateCamera(slot, frame.view, frame.proj, frame.cameraPosition, extent); + gpuDraw = compute_->encode(cmd, slot, camera, *world_->splats, frame); + if (gpuDraw) + splats_->bindOrder(slot, gpuDraw->order, gpuDraw->capacity); + else + LOGE("GPU frame encode failed; submitting clear frame to preserve fence lifecycle"); + } else if (world_ && !compute_ && frame.order != nullptr) { splats_->updateOrder(cmd, slot, *world_, frame.order, frame.orderCount); } @@ -130,9 +204,13 @@ bool VulkanSplatRenderer::draw(const Frame& frame) { vkCmdSetScissor(cmd, 0, 1, &scissor); if (world_) { - splats_->draw(cmd, slot, *world_, std::min(frame.drawCount, world_->count), - std::min(frame.shDegree, world_->shDegree), frame.view, frame.proj, - frame.cameraPosition, extent); + if (gpuDraw) { + splats_->drawIndirect(cmd, slot, *world_, frame.shDegree, gpuDraw->arguments); + } else if (!compute_ && frame.orderSource == OrderSource::cpu) { + splats_->draw(cmd, slot, *world_, std::min(frame.drawCount, world_->count), + std::min(frame.shDegree, world_->shDegree), frame.view, frame.proj, + frame.cameraPosition, extent); + } } else { triangle_->draw(cmd); } diff --git a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.h b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.h index 3c16851..7151150 100644 --- a/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.h +++ b/packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.h @@ -12,9 +12,9 @@ #include "rendering/vulkan/SplatPipeline.h" #include "rendering/vulkan/Swapchain.h" #include "rendering/vulkan/VulkanContext.h" +#include "rendering/vulkan/VulkanFrameCompute.h" #include "splat/formats/SplatCloud.h" -#include "splat/math/Mat4.h" -#include "splat/math/Vec3.h" +#include "splatkit/rendering/SplatRenderer.h" namespace splatkit { @@ -23,10 +23,10 @@ namespace splatkit { // the world bound to them. Survives losing and regaining the window, and the world stays // through it. A rebuild that fails drops the surface and logs; the view stays blank // until the host attaches a surface again. Render thread only. -class VulkanSplatRenderer { +class VulkanSplatRenderer final : public SplatRenderer { public: VulkanSplatRenderer(VulkanContext& ctx, FrameLoop& frameLoop); - ~VulkanSplatRenderer(); + ~VulkanSplatRenderer() override; VulkanSplatRenderer(const VulkanSplatRenderer&) = delete; VulkanSplatRenderer& operator=(const VulkanSplatRenderer&) = delete; @@ -36,41 +36,39 @@ class VulkanSplatRenderer { // The window changed size while staying attached. Rebuilds the swapchain if needed. void onSurfaceResized(uint32_t width, uint32_t height); - // Fraction of the surface resolution the splats are drawn at, [0.1, 2]. Away from one - // the frame is drawn offscreen and rescaled with a linear blit. - void setRenderScale(float scale); - float renderScale() const { return renderScale_; } - // Blend in linear light instead of the encoded space; flips the swapchain format. - void setLinearBlending(bool linear); - bool linearBlending() const { return linearBlending_; } - // Off, frame times stop being multiples of the vsync, which benchmarks need. - void setVsync(bool vsync); + void setRenderScale(float scale) override; + float renderScale() const override { return renderScale_; } + // Flips the swapchain format. + void setLinearBlending(bool linear) override; + bool linearBlending() const override { return linearBlending_; } + void setVsync(bool vsync) override; - // True when a surface with pipelines is up: frames can be drawn and worlds uploaded. - bool ready() const { return swapchain_ && splats_ && triangle_; } - // Where the splats are drawn: the target's size with a render scale, else the swapchain's. - VkExtent2D drawExtent() const; - // Counts the rebuilds of the swapchain or the target. A frame drawn before one is gone. - uint32_t generation() const { return generation_; } + // True when a surface with pipelines is up. + bool ready() const override { return swapchain_ && splats_ && triangle_; } + // The target's size with a render scale, else the swapchain's. + Extent drawExtent() const override; + // Counts the rebuilds of the swapchain or the target. + uint32_t generation() const override { return generation_; } - // Uploads a world and draws it from now on, once the GPU is done with the previous one. - // Needs `ready()`. Fails, keeping the previous world, when the upload does. - bool uploadWorld(const splat::SplatCloud& cloud, int maxShDegree); - const GpuWorld* world() const { return world_.get(); } + // Uploads once the GPU is done with the previous world. Needs `ready()`. + bool uploadWorld(const splat::SplatCloud& cloud, int maxShDegree) override; + bool selectsLodOnGpu() const override; + bool uploadLodWorld(const splat::LodTree& tree, int maxShDegree, uint32_t budget) override; + bool sortsOnGpu() const override { return compute_ != nullptr; } + bool createSlab(uint32_t capacity, int shDegree) override; + // A frame in flight that still names those records may draw a mix of old and new for + // one frame. + bool uploadTile(uint32_t offset, const splat::SplatCloud& cloud) override; + std::optional world() const override; - struct Frame { - // A new draw order for the world, copied in before the draw; nullptr keeps the last. - const uint32_t* order = nullptr; - uint32_t orderCount = 0; - uint32_t drawCount = 0; // entries of the order buffer to draw - int shDegree = 0; // capped by what the world carries - splat::Mat4 view = splat::Mat4::identity(); - splat::Mat4 proj = splat::Mat4::identity(); - splat::Vec3 cameraPosition; - }; - // Records and presents one frame: the world, or the debug triangle without one. - // Returns false when nothing was presented, e.g. the swapchain was rebuilt instead. - bool draw(const Frame& frame); + // The world, or the debug triangle without one. + bool draw(const Frame& frame) override; + double lastGpuMillis() const override { return frameLoop_.lastGpuMillis(); } + double lastSortMillis() const override { return compute_ ? compute_->stats().sortMillis : 0; } + double lastSelectMillis() const override { return compute_ ? compute_->stats().selectMillis : 0; } + uint32_t lastDrawCount() const override { return compute_ ? compute_->stats().drawn : 0; } + uint32_t lastSelectedCount() const override { return compute_ ? compute_->stats().selected : 0; } + const std::string& deviceDescription() const override { return ctx_.deviceDescription(); } private: bool createSurface(); @@ -93,6 +91,7 @@ class VulkanSplatRenderer { std::unique_ptr splats_; VkFormat pipelineFormat_ = VK_FORMAT_UNDEFINED; // the format the pipelines target std::unique_ptr world_; + std::unique_ptr compute_; float renderScale_ = 1.0f; bool linearBlending_ = false; bool vsync_ = true; diff --git a/packages/splatkit-android/src/main/cpp/shaders/lod_selection.comp b/packages/splatkit-android/src/main/cpp/shaders/lod_selection.comp new file mode 100644 index 0000000..acc8300 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/shaders/lod_selection.comp @@ -0,0 +1,149 @@ +#version 450 +// Portable workgroup scans, no subgroup-width or subgroup-operation requirements. +layout(local_size_x = 128) in; +layout(std140, set = 0, binding = 0) uniform Camera { + mat4 view; mat4 proj; vec2 focal; vec2 tanHalfFov; + vec2 screenSize; uint outputLinear; uint pad; vec4 cameraPosition; +} cam; +struct Cluster { + vec4 centerRadius; vec4 extentError; + float colorVariance; float opacity; + uint node; uint childStart; uint childCount; uint leafStart; uint leafCount; uint subtreeLeaves; +}; +layout(std430, set = 0, binding = 1) readonly buffer Nodes { Cluster nodes[]; }; +layout(std430, set = 0, binding = 2) readonly buffer Leaves { uint leaves[]; }; +layout(std430, set = 0, binding = 3) buffer Scratch { uint s[]; }; +layout(std430, set = 0, binding = 4) writeonly buffer Indices { uint indices[]; }; +layout(push_constant) uniform Config { + uint phase; uint capacity; uint costs; uint offsets; + uint costGroups; uint groups; uint blocks; uint frontier0; + uint frontier1; uint packets; float pixelLimit; float colorWeight; uint cull; +} p; +// State words: count, active, packets, accepted, limited, evaluated, groups, next, +// dispatch xyz, emit xyz, outputDelta, packetDelta, scan xyz, frontier parity. +const uint SPLIT = 0x80000000u, DROP = 0x40000000u, COST = 0x3fffffffu; +shared uvec4 scanValues[128]; +uvec4 get4(uint base, uint i) { uint a = base + i * 4; return uvec4(s[a],s[a+1],s[a+2],s[a+3]); } +void put4(uint base, uint i, uvec4 v) { uint a = base + i * 4; s[a]=v.x; s[a+1]=v.y; s[a+2]=v.z; s[a+3]=v.w; } +uint currentFrontier() { return s[19] == 0 ? p.frontier0 : p.frontier1; } +uint nextFrontier() { return s[19] == 0 ? p.frontier1 : p.frontier0; } +// Inclusive workgroup scan. Every invocation participates, including tail lanes. +uvec4 scan(uvec4 value) { + uint lane = gl_LocalInvocationIndex; + scanValues[lane] = value; barrier(); + for (uint step = 1; step < 128; step *= 2) { + uvec4 addend = lane >= step ? scanValues[lane-step] : uvec4(0); + barrier(); scanValues[lane] += addend; barrier(); + } + return scanValues[lane]; +} +bool outside(Cluster n) { + vec3 v = (cam.view * vec4(n.centerRadius.xyz,1)).xyz; + float z = -v.z, r = n.centerRadius.w; + float guard = 2 * max(z,0) / max(min(cam.focal.x,cam.focal.y),1); + return z+r <= 0 || any(greaterThan(abs(v.xy)-z*cam.tanHalfFov, + r*sqrt(1+cam.tanHalfFov*cam.tanHalfFov)+guard)); +} +float errorPixels(Cluster n) { + vec3 v = (cam.view * vec4(n.centerRadius.xyz,1)).xyz; + float depth = max(-v.z-n.centerRadius.w,1e-4); + float appearance = sqrt(n.colorVariance); + float error = n.extentError.w+n.centerRadius.w*min(appearance,1); + return error*max(cam.focal.x,cam.focal.y)/depth * sqrt(1+dot(v.xy,v.xy)/(depth*depth)) * + max(n.opacity,0.1)*(1+p.colorWeight*appearance); +} +void main() { + uint t = gl_GlobalInvocationID.x, lane = gl_LocalInvocationIndex, group = gl_WorkGroupID.x; + if (p.phase == 0) { + if (t == 0) { + for (uint i=0;i<20;++i) s[i]=0; + s[1]=1; s[6]=1; s[8]=1; s[9]=1; s[10]=1; + s[12]=1; s[13]=1; s[16]=1; s[17]=1; s[18]=1; + s[p.frontier0]=0; + } + } else if (p.phase == 1) { + uint extra=0, flags=0, dropped=0; + if (t1 && (p.pixelLimit==0 || errorPixels(n)>p.pixelLimit)) { + flags=SPLIT; extra=n.childCount+n.leafCount-1; + } else if (n.childCount==1 && n.leafCount==0) flags=SPLIT; + } + uvec4 value=uvec4(extra,dropped,0,0), inclusive=scan(value); + if (t0 && prefix+extra>s[3]) { flags=0; denied=1; } + if ((flags&DROP)==0) { + Cluster n=nodes[s[currentFrontier()+t]]; + kids=(flags&SPLIT)!=0 ? n.childCount : 0; + splats=(flags&SPLIT)!=0 ? n.leafCount : 1; + packets=splats>128 ? 1 : 0; + } + } + uvec4 v=uvec4(kids,splats,packets,denied), inclusive=scan(v); + if (t128) put4(p.packets,s[2]+prefix.z+local.z,uvec4(n.leafStart,count,destination,0)); + else if (!split) indices[destination]=n.node; + else for (uint k=0;k> bit) & 1u) != 0; + uvec4 vote = subgroupBallot(set); + peers &= set ? vote : ~vote; + } + return peers; +} +uint element(uint block, uint row) { + return block*2048u + row*128u + gl_LocalInvocationIndex; +} + +shared uint bins[256]; +void main() { + uint block = gl_WorkGroupID.y*groupsX + gl_WorkGroupID.x; + if (block >= blocks) return; + uint t = gl_LocalInvocationIndex; + bins[t] = 0; bins[t+128] = 0; + barrier(); + for (uint row=0; row<16; ++row) { + uint e=element(block,row); + bool valid=e> pc.shift)&255u : 0u; + uvec4 peers=matchDigit(digit,valid); + // One shared atomic per distinct digit in a subgroup, no global counter contention. + if(valid && subgroupBallotFindLSB(peers)==gl_SubgroupInvocationID) + atomicAdd(bins[digit],subgroupBallotBitCount(peers)); + } + barrier(); + histogram[t*blocks+block]=bins[t]; + histogram[(t+128)*blocks+block]=bins[t+128]; +} diff --git a/packages/splatkit-android/src/main/cpp/shaders/radix_prepare.comp b/packages/splatkit-android/src/main/cpp/shaders/radix_prepare.comp new file mode 100644 index 0000000..744cc11 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/shaders/radix_prepare.comp @@ -0,0 +1,25 @@ +#version 450 +#extension GL_KHR_shader_subgroup_basic : require +#extension GL_KHR_shader_subgroup_ballot : require +#extension GL_KHR_shader_subgroup_arithmetic : require +layout(local_size_x=128) in; +layout(set=0,binding=0,std430) readonly buffer KeysIn { uint keysIn[]; }; +layout(set=0,binding=1,std430) readonly buffer ValuesIn { uint valuesIn[]; }; +layout(set=0,binding=2,std430) writeonly buffer KeysOut { uint keysOut[]; }; +layout(set=0,binding=3,std430) writeonly buffer ValuesOut { uint valuesOut[]; }; +layout(set=0,binding=4,std430) readonly buffer CountIn { uint countIn; }; +layout(set=0,binding=5,std430) buffer Histogram { uint histogram[]; }; +layout(set=0,binding=6,std430) buffer Totals { uint totals[]; }; +layout(set=0,binding=7,std430) buffer State { uint groupsX, groupsY, groupsZ, blocks; }; +layout(set=0,binding=8,std430) buffer CountOut { uint countOut; }; +layout(set=0,binding=9,std430) buffer Status { uint status; }; +layout(push_constant) uniform Push { uint capacity, shift, maxGroupsX; } pc; +void main() { + if (gl_LocalInvocationIndex != 0) return; + status = countIn > pc.capacity ? 1u : 0u; + countOut = status == 0u ? countIn : 0u; + blocks = max((countOut + 2047u)/2048u, 1u); + groupsX = min(blocks, pc.maxGroupsX); + groupsY = (blocks + groupsX - 1u)/groupsX; + groupsZ = 1u; +} diff --git a/packages/splatkit-android/src/main/cpp/shaders/radix_scan.comp b/packages/splatkit-android/src/main/cpp/shaders/radix_scan.comp new file mode 100644 index 0000000..f9c63b6 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/shaders/radix_scan.comp @@ -0,0 +1,42 @@ +#version 450 +#extension GL_KHR_shader_subgroup_basic : require +#extension GL_KHR_shader_subgroup_ballot : require +#extension GL_KHR_shader_subgroup_arithmetic : require +layout(local_size_x=128) in; +layout(set=0,binding=0,std430) readonly buffer KeysIn { uint keysIn[]; }; +layout(set=0,binding=1,std430) readonly buffer ValuesIn { uint valuesIn[]; }; +layout(set=0,binding=2,std430) writeonly buffer KeysOut { uint keysOut[]; }; +layout(set=0,binding=3,std430) writeonly buffer ValuesOut { uint valuesOut[]; }; +layout(set=0,binding=4,std430) readonly buffer CountIn { uint countIn; }; +layout(set=0,binding=5,std430) buffer Histogram { uint histogram[]; }; +layout(set=0,binding=6,std430) buffer Totals { uint totals[]; }; +layout(set=0,binding=7,std430) buffer State { uint groupsX, groupsY, groupsZ, blocks; }; +layout(set=0,binding=8,std430) buffer CountOut { uint countOut; }; +layout(set=0,binding=9,std430) buffer Status { uint status; }; +layout(push_constant) uniform Push { uint capacity, shift, maxGroupsX; } pc; + +shared uint scan[128]; +void main() { + uint t=gl_LocalInvocationIndex; + uint digit=gl_WorkGroupID.y*pc.maxGroupsX+gl_WorkGroupID.x; + if(digit>=256) return; + uint carry=0; + // Parallel workgroup scan of each bin, tiled as in MetalRadixSort. At 3M keys + // only 12 tiles; no invocation serially scans the global histogram. + for(uint start=0;start=step ? scan[t-step] : 0u; + barrier(); + scan[t]+=add; + barrier(); + } + if(i=blocks) return; + uint t=gl_LocalInvocationIndex; + for(uint d=t;d<256;d+=128) { + digitBase[d]=totals[d]; + blockBase[d]=histogram[d*blocks+block]; + priorRows[d]=0; + } + barrier(); + for(uint step=1;step<256;step*=2) { + uint a=t>=step ? digitBase[t-step] : 0u; + uint b=t+128>=step ? digitBase[t+128-step] : 0u; + barrier(); + digitBase[t]+=a; digitBase[t+128]+=b; + barrier(); + } + for(uint row=0;row<16;++row) { + for(uint i=t;i<1024;i+=128) masks[i]=0; + barrier(); + uint e=block*2048u+row*128u+t; + bool valid=e>pc.shift)&255u; + // One shared atomic per key, at most 32 contenders per word for all-equal input. + // No global atomics; masks encode deterministic input order regardless of scheduling. + if(valid) atomicOr(masks[d*4+t/32],1u<<(t%32)); + barrier(); + if(valid) { + uint rank=priorRows[d]; + for(uint word=0;word d) ? vec2(1.0, 0.0) : vec2(0.0, 1.0)) + : normalize(vec2(b, d - lambda2)); + vec2 e2 = vec2(e1.y, -e1.x); + axis1 = e1 * sqrt(max(lambda1, 0.0)); + axis2 = e2 * sqrt(max(lambda2, 0.0)); +} + +bool visible(Splat s, out uint key) { + key = 0u; + if (!finiteVec2(cam.focal) || !finiteVec2(cam.tanHalfFov) || !finiteVec2(cam.screenSize) || + any(lessThanEqual(cam.focal, vec2(0.0))) || + any(lessThanEqual(cam.tanHalfFov, vec2(0.0))) || + any(lessThanEqual(cam.screenSize, vec2(0.0)))) + return false; + if (!finiteFloat(s.px) || !finiteFloat(s.py) || !finiteFloat(s.pz)) return false; + vec4 rgba = unpackUnorm4x8(s.rgba8); + float alpha = s.lodAlpha != 0u ? uintBitsToFloat(s.lodAlpha) : rgba.a; + if (!finiteFloat(alpha) || alpha < 1.0 / 255.0) return false; + + vec4 viewPos4 = cam.view * vec4(s.px, s.py, s.pz, 1.0); + vec3 viewPos = viewPos4.xyz; + // The current vertex path has no meaningful projection behind the eye. Near-plane + // intersections remain eligible below: only a Gaussian wholly in front of the near plane + // may be rejected by the depth interval test. + if (!finiteFloat(viewPos.x) || !finiteFloat(viewPos.y) || !finiteFloat(viewPos.z) || + viewPos.z >= 0.0) + return false; + vec4 clip = cam.proj * viewPos4; + if (!finiteFloat(clip.x) || !finiteFloat(clip.y) || !finiteFloat(clip.z) || + !finiteFloat(clip.w) || clip.w <= 0.0) + return false; + + vec2 c0 = unpackHalf2x16(s.cov0); + vec2 c1 = unpackHalf2x16(s.cov1); + vec2 c2 = unpackHalf2x16(s.cov2); + if (!finiteVec2(c0) || !finiteVec2(c1) || !finiteVec2(c2)) return false; + vec4 covarianceSource = vec4(c0, c1); + vec3 covariance = projectCovariance(viewPos, covarianceSource, c2); + if (!finiteVec3(covariance)) return false; + vec2 axis1, axis2; + ellipseAxes(covariance, axis1, axis2); + if (!finiteVec2(axis1) || !finiteVec2(axis2)) return false; + + float radius = min(s.lodAlpha != 0u ? kLodBoundsRadius : kBoundsRadius, + sqrt(2.0 * log(max(alpha * 255.0, 1.0)))); + float halfDifference = 0.5 * (covariance.x - covariance.z); + float sourceVariance = 0.5 * (covariance.x + covariance.z) + + sqrt(max(halfDifference * halfDifference + covariance.y * covariance.y, + 0.0)) - 0.3; + if (!finiteFloat(sourceVariance) || sqrt(max(sourceVariance, 0.0)) * radius < + constants.minPixelRadius) + return false; + + vec2 extentPixels = radius * (abs(axis1) + abs(axis2)); + vec2 margin = (extentPixels + 0.5) * 2.0 * clip.w / cam.screenSize; + if (!finiteVec2(margin) || any(greaterThan(abs(clip.xy), vec2(clip.w) + margin))) return false; + + // A covariance-derived view-space depth interval makes near-plane handling conservative. + // Invalid/non-perspective projection coefficients disable only this depth rejection. + float nearPlane = cam.proj[3][2] / cam.proj[2][2]; + float farPlane = cam.proj[3][2] / (cam.proj[2][2] + 1.0); + mat3 source = mat3(covarianceSource.x, covarianceSource.y, covarianceSource.z, + covarianceSource.y, covarianceSource.w, c2.x, + covarianceSource.z, c2.x, c2.y); + vec3 viewZ = vec3(cam.view[0][2], cam.view[1][2], cam.view[2][2]); + float depthSigma = sqrt(max(dot(viewZ, source * viewZ), 0.0)); + float depthExtent = radius * depthSigma; + if (finiteFloat(nearPlane) && finiteFloat(farPlane) && nearPlane > 0.0 && farPlane > nearPlane && + (viewPos.z - depthExtent > -nearPlane || viewPos.z + depthExtent < -farPlane)) + return false; + + float depth = -viewPos.z; + if (!finiteFloat(depth) || depth <= 0.0) return false; + key = floatBitsToUint(depth); + if ((constants.keyMode & 1u) != 0u) { + // Linear camera-depth quantization, not float16. Projection validity checked by main. + float normalized = clamp((depth - nearPlane) / (farPlane - nearPlane), 0.0, 1.0); + key = uint(normalized * 65535.0); + } + if ((constants.keyMode & 2u) != 0u) + key = (constants.keyMode & 1u) != 0u ? 65535u - key : ~key; + return true; +} + +// Validate each range once, including records a malformed prefix ordering might make the +// binary search skip. Range count <= dispatch bound, so every record gets an invocation. +bool validRange(uint r) { + uint offset = candidates[4u*r], length = candidates[4u*r+1u], end = candidates[4u*r+2u]; + uint start = r == 0u ? 0u : candidates[4u*(r-1u)+2u]; + if (length == 0u || end <= start || end-start != length || + offset > constants.sourceCount || length > constants.sourceCount-offset) return false; + if (r > 0u) { + uint previousOffset = candidates[4u*(r-1u)], previousCount = candidates[4u*(r-1u)+1u]; + if (previousOffset > constants.sourceCount || previousCount > constants.sourceCount-previousOffset || + offset < previousOffset+previousCount) return false; + } + return true; +} + +// Resolve logical candidates without touching source records until their index is checked. +bool resolveCandidate(uint logical, out uint source, inout uint errors) { + source = 0u; + uint total = constants.candidateCapacity; + if (constants.mode == 1u) { + total = candidateCount; + if (total > constants.candidateCapacity) { errors |= 4u; return false; } + } + if (constants.mode == 2u && constants.rangeCount > 0u && + candidates[(constants.rangeCount - 1u) * 4u + 2u] != total) { + errors |= 8u; return false; + } + if (logical >= total) return false; + if (constants.mode == 0u) source = logical; + else if (constants.mode == 1u) source = candidates[logical]; + else { + uint low = 0u, high = constants.rangeCount; + while (low < high) { + uint middle = low + (high - low) / 2u; + if (candidates[middle * 4u + 2u] <= logical) low = middle + 1u; else high = middle; + } + if (low >= constants.rangeCount) { errors |= 8u; return false; } + uint offset = candidates[low * 4u], length = candidates[low * 4u + 1u]; + uint end = candidates[low * 4u + 2u]; + uint start = low == 0u ? 0u : candidates[(low - 1u) * 4u + 2u]; + if (length == 0u || end <= start || end - start != length || logical < start || + offset > constants.sourceCount || length > constants.sourceCount - offset) { + errors |= 8u; return false; + } + if (low > 0u) { + uint previousOffset = candidates[(low - 1u) * 4u]; + uint previousCount = candidates[(low - 1u) * 4u + 1u]; + if (previousOffset > constants.sourceCount || + previousCount > constants.sourceCount - previousOffset || + offset < previousOffset + previousCount) { errors |= 8u; return false; } + } + source = offset + logical - start; + } + if (source >= constants.sourceCount) { errors |= 2u; return false; } + return true; +} + +void main() { + uint groupIndex = gl_WorkGroupID.x + gl_WorkGroupID.y * constants.dispatchGroupsX; + uint logical = groupIndex * gl_WorkGroupSize.x + gl_LocalInvocationID.x; + uint sourceIndex = 0u, errors = 0u, key = 0u; + if (constants.mode == 2u && logical < constants.rangeCount && !validRange(logical)) errors |= 8u; + bool candidate = resolveCandidate(logical, sourceIndex, errors); + if ((constants.keyMode & 1u) != 0u) { + float nearPlane = cam.proj[3][2] / cam.proj[2][2]; + float farPlane = cam.proj[3][2] / (cam.proj[2][2] + 1.0); + if (!finiteFloat(nearPlane) || !finiteFloat(farPlane) || nearPlane <= 0.0 || farPlane <= nearPlane) { + errors |= 16u; candidate = false; + } + } + bool keep = false; + if (candidate) keep = visible(splats[sourceIndex], key); + + // All tail lanes participate. Only the elected lane performs global atomics, including + // failure publication. Invalid indices/counts and overflow fail the whole frame closed. + uint rank = subgroupExclusiveAdd(keep ? 1u : 0u); + uint survivors = subgroupAdd(keep ? 1u : 0u); + uint failures = subgroupOr(errors); + uint base = 0u; + if (subgroupElect()) { + if (survivors != 0u) { + base = atomicAdd(count, survivors); + if (base > constants.capacity || survivors > constants.capacity - base) failures |= 1u; + } + if (failures != 0u) atomicOr(status, failures); + } + base = subgroupBroadcastFirst(base); + if (keep && base < constants.capacity && base + rank < constants.capacity) { + indices[base + rank] = sourceIndex; + depthKeys[base + rank] = key; + } +} diff --git a/packages/splatkit-android/src/main/cpp/tests/GpuBufferTest.cpp b/packages/splatkit-android/src/main/cpp/tests/GpuBufferTest.cpp new file mode 100644 index 0000000..32d46ce --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/tests/GpuBufferTest.cpp @@ -0,0 +1,59 @@ +#include + +#include "tests/VulkanTestContext.h" + +int main(int argc, char** argv) { + using splatkit::GpuBuffer; + namespace test = splatkit::test; + using test::require; + try { + const bool small = argc == 2 && std::strcmp(argv[1], "--small") == 0; + require(argc == 1 || small, "usage: splatkit_gpu_buffer_test [--small]"); + test::VulkanTestContext gpu; + std::printf("GPU: %s\n", gpu.context->deviceDescription().c_str()); + std::vector expected(small ? 4096 : 16000000); + uint32_t pattern = 1; + for (auto& byte : expected) { + pattern = pattern * 1664525U + 1013904223U; + byte = pattern >> 24; + } + auto buffer = GpuBuffer::deviceLocal( + *gpu.context, expected.size(), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + require(buffer != nullptr, "allocate device buffer"); + auto order0 = GpuBuffer::hostVisible(*gpu.context, 2000000, VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + require(order0 && order0->mapped(), "allocate live mapped buffer before staging"); + std::printf("upload %zu bytes\n", expected.size()); + std::fflush(stdout); + require(buffer->upload(expected.data(), expected.size()), "upload"); + require(gpu.readback(*buffer, expected.size()) == expected, "GPU round trip"); + std::puts("PASS GPU round trip"); + if (small) std::puts("SKIP 16 MB mapping regression (--small requested)"); + const std::vector patch{9, 4, 7, 1, 8}; + require(buffer->upload(13, patch.data(), patch.size()), "unaligned byte range upload"); + std::copy(patch.begin(), patch.end(), expected.begin() + 13); + require(gpu.readback(*buffer, expected.size()) == expected, + "partial upload preserves neighbors"); + require(!buffer->upload(nullptr, 1), "reject nonempty null upload"); + require(!buffer->upload(buffer->size() - 1, patch.data(), patch.size()), "reject overrun"); + require(!buffer->upload(UINT64_MAX, patch.data(), 4), "reject overflowing offset"); + require(!buffer->upload(1, patch.data(), UINT64_MAX), "reject overflowing size"); + require(buffer->upload(buffer->size(), nullptr, 0), "empty upload at end"); + require(!buffer->upload(buffer->size() + 1, nullptr, 0), "reject empty upload past end"); + require(!GpuBuffer::deviceLocal(*gpu.context, 0, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT), + "reject empty device allocation"); + require(!GpuBuffer::hostVisible(*gpu.context, 0, VK_BUFFER_USAGE_TRANSFER_SRC_BIT), + "reject empty mapped allocation"); + std::memset(order0->mapped(), 0x62, static_cast(order0->size())); + order0->flush(0, order0->size()); + require(gpu.readback(*order0, order0->size()) == std::vector(order0->size(), 0x62), + "mapped host writes reach GPU"); + gpu.requireValidationClean(); + std::printf("PASS buffer regressions; validation=%s\n", + gpu.context->validationEnabled() ? "on" : "off"); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "FAIL: %s\n", error.what()); + return 1; + } +} diff --git a/packages/splatkit-android/src/main/cpp/tests/GpuUploadPressureTest.cpp b/packages/splatkit-android/src/main/cpp/tests/GpuUploadPressureTest.cpp new file mode 100644 index 0000000..f9f9cf4 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/tests/GpuUploadPressureTest.cpp @@ -0,0 +1,79 @@ +#include +#include + +#include "tests/VulkanTestContext.h" + +// No shaders or pipelines: reproduce RadixSort::reserve's allocation pressure, +// then the first four-byte input upload. Keep the pressure live during readback. +int main(int argc, char** argv) { + using splatkit::GpuBuffer; + using splatkit::test::require; + std::setvbuf(stdout, nullptr, _IONBF, 0); + try { + const bool small = argc == 2 && std::strcmp(argv[1], "--small") == 0; + require(argc == 1 || small, "usage: splatkit_gpu_upload_pressure_test [--small]"); + splatkit::test::VulkanTestContext gpu; + const uint32_t capacity = small ? 262145 : 3000000; + std::printf("upload-pressure GPU=%s capacity=%u validation=%s\n", + gpu.context->deviceDescription().c_str(), capacity, + gpu.context->validationEnabled() ? "on" : "off"); + VkPhysicalDeviceMemoryProperties memory{}; + vkGetPhysicalDeviceMemoryProperties(gpu.context->physicalDevice(), &memory); + for (uint32_t i = 0; i < memory.memoryTypeCount; ++i) + std::printf("memory-type=%u flags=0x%x heap=%u\n", i, memory.memoryTypes[i].propertyFlags, + memory.memoryTypes[i].heapIndex); + constexpr auto kUsage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + std::vector> pressure; + const VkDeviceSize pairBytes = VkDeviceSize{capacity} * 4; + const VkDeviceSize histogramBytes = VkDeviceSize{(capacity + 2047) / 2048} * 1024; + const std::array sizes{ + pairBytes, pairBytes, pairBytes, pairBytes, histogramBytes, 1024, 16, 4, 4}; + for (unsigned slot = 0; slot < 2; ++slot) { + for (auto size : sizes) { + auto buffer = GpuBuffer::deviceLocal(*gpu.context, size, kUsage); + require(static_cast(buffer), "pressure allocation"); + pressure.push_back(std::move(buffer)); + } + } + for (unsigned i = 0; i < 2; ++i) { + auto buffer = GpuBuffer::deviceLocal(*gpu.context, pairBytes, kUsage); + require(static_cast(buffer), "input allocation"); + pressure.push_back(std::move(buffer)); + } + auto count = GpuBuffer::deviceLocal(*gpu.context, 4, kUsage); + require(static_cast(count), "count allocation"); + const std::array countBytes{0x13, 0x57, 0x9b, 0xdf}; + std::puts("upload-pressure stage=first-four-byte-upload (no shader executed)"); + require(count->upload(countBytes.data(), countBytes.size()), "first four-byte upload"); + std::puts("upload-pressure stage=count-readback"); + require(gpu.readback(*count, 4) == std::vector(countBytes.begin(), countBytes.end()), + "exact four-byte roundtrip"); + + // One full staging window and a partial tail, with 16-byte guards on both sides. + constexpr size_t kPayloadBytes = 2 * 1024 * 1024 + 28; + constexpr size_t kGuardBytes = 16; + std::vector expected(kPayloadBytes + 2 * kGuardBytes, 0xa5); + auto destination = GpuBuffer::deviceLocal(*gpu.context, expected.size(), kUsage); + require(static_cast(destination), "guarded allocation"); + std::puts("upload-pressure stage=guarded-window-upload"); + require(destination->upload(expected.data(), expected.size()), "initialize guards"); + for (uint32_t pass = 0; pass < 3; ++pass) { + uint32_t state = pass + 1; + for (size_t i = kGuardBytes; i < kGuardBytes + kPayloadBytes; ++i) { + state = state * 1664525U + 1013904223U; + expected[i] = static_cast(state >> 24); + } + require(destination->upload(kGuardBytes, expected.data() + kGuardBytes, kPayloadBytes), + "repeated window upload"); + require(gpu.readback(*destination, expected.size()) == expected, + "exact payload and untouched guards"); + } + gpu.requireValidationClean(); + if (small) std::puts("SKIP 3M-capacity pressure (--small requested)"); + std::puts("PASS upload-pressure: count, repeated payload, both guards; no shaders"); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "FAIL upload-pressure: %s\n", error.what()); + return 1; + } +} diff --git a/packages/splatkit-android/src/main/cpp/tests/LodSelectionTest.cpp b/packages/splatkit-android/src/main/cpp/tests/LodSelectionTest.cpp new file mode 100644 index 0000000..f0e666b --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/tests/LodSelectionTest.cpp @@ -0,0 +1,230 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "rendering/vulkan/LodSelection.h" +#include "rendering/vulkan/VulkanShaderTypes.h" +#include "splat/lod/LodFile.h" +#include "tests/VulkanTestContext.h" + +namespace { +using splatkit::CameraUniform; +using splatkit::GpuBuffer; +using splatkit::LodSelection; +namespace test = splatkit::test; +using test::require; + +void attributes(splat::LodTree& tree) { + for (const auto& node : tree.layout) { + tree.nodes.positions.insert(tree.nodes.positions.end(), node.position, node.position + 3); + tree.nodes.covariances.insert(tree.nodes.covariances.end(), + {0.0001f, 0, 0, 0.0001f, 0, 0.0001f}); + tree.nodes.colors.insert(tree.nodes.colors.end(), {0.5f, 0.5f, 0.5f}); + tree.nodes.alphas.push_back(0.5f); + } + tree.selection = splat::buildLodSelectionData(tree); +} +splat::LodTree binary(uint32_t leaves) { + splat::LodTree tree; + tree.leafCount = leaves; + for (uint32_t i = 0; i < 2 * leaves - 1; ++i) + tree.layout.push_back( + {{0, 0, -2}, 0.02f, i < leaves - 1 ? 2 * i + 1 : 0, i < leaves - 1 ? 2u : 0u}); + attributes(tree); + return tree; +} +splat::LodTree packet(uint32_t leaves) { + splat::LodTree tree; + tree.leafCount = leaves; + tree.layout.push_back({{0, 0, -2}, 0.02f, 1, leaves}); + for (uint32_t i = 0; i < leaves; ++i) tree.layout.push_back({{0, 0, -2}, 0.02f, 0, 0}); + attributes(tree); + return tree; +} +void expand(const splat::LodTree& tree, uint32_t node, std::vector& leaves) { + require(node < tree.nodeCount(), "selected node in bounds"); + const auto& entry = tree.layout[node]; + if (!entry.childCount) leaves.push_back(node); + for (uint32_t k = 0; k < entry.childCount; ++k) expand(tree, entry.childStart + k, leaves); +} +void covering(const splat::LodTree& tree, const std::vector& cut) { + std::vector represented; + std::vector expected; + for (auto index : cut) expand(tree, index, represented); + expand(tree, 0, expected); + std::sort(represented.begin(), represented.end()); + std::sort(expected.begin(), expected.end()); + require(represented == expected, "cut covers every original leaf exactly once"); +} +CameraUniform camera(float distance = 0) { + CameraUniform u{}; + u.view = splat::Mat4::identity(); + u.view.at(2, 3) = -distance; + u.proj = splat::Mat4::perspective(1, 1, 0.1f, 1000000); + u.focal[0] = u.focal[1] = 500; + u.tanHalfFov[0] = u.tanHalfFov[1] = 1; + u.screenSize[0] = u.screenSize[1] = 1000; + u.cameraPosition[2] = distance; + return u; +} +struct Selection { + std::vector cut; + std::array stats{}; +}; +Selection select(const test::VulkanTestContext& gpu, const LodSelection& lod, + const CameraUniform& cam, bool doubleFrame = false) { + auto uniforms = + GpuBuffer::hostVisible(*gpu.context, sizeof(cam), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT); + require(uniforms != nullptr, "camera allocation"); + std::memcpy(uniforms->mapped(), &cam, sizeof(cam)); + uniforms->flush(0, sizeof(cam)); + auto output = lod.output(); + const VkDeviceSize bytes = LodSelection::kDiagnosticBytes + output.capacity * 4ull; + auto copy = GpuBuffer::deviceLocal( + *gpu.context, bytes, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + require(copy != nullptr, "result copy allocation"); + gpu.submit([&](VkCommandBuffer cmd) { + require(lod.encode(cmd, 0, {uniforms->handle(), 0}), "encode selection"); + const VkBufferCopy state{0, 0, LodSelection::kDiagnosticBytes}; + const VkBufferCopy indices{0, LodSelection::kDiagnosticBytes, output.capacity * 4ull}; + vkCmdCopyBuffer(cmd, output.state, copy->handle(), 1, &state); + vkCmdCopyBuffer(cmd, output.indices, copy->handle(), 1, &indices); + if (doubleFrame) { + // Consume first frame, then overwrite shared scratch on the same queue. Separate slots + // avoid updating a descriptor set referenced by the earlier recorded dispatch. + require(lod.encode(cmd, 1, {uniforms->handle(), 0}), "encode repeated frame"); + VkMemoryBarrier reuse{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + reuse.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + reuse.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 1, &reuse, 0, nullptr, 0, nullptr); + vkCmdCopyBuffer(cmd, output.state, copy->handle(), 1, &state); + vkCmdCopyBuffer(cmd, output.indices, copy->handle(), 1, &indices); + } + }); + const auto raw = gpu.readback(*copy, bytes); + Selection result; + std::memcpy(result.stats.data(), raw.data(), LodSelection::kDiagnosticBytes); + require(result.stats[0] <= output.capacity, "selected count bounded"); + result.cut.resize(result.stats[0]); + if (!result.cut.empty()) + std::memcpy(result.cut.data(), raw.data() + LodSelection::kDiagnosticBytes, + result.cut.size() * 4); + return result; +} + +void tests(const test::VulkanTestContext& gpu) { + auto created = LodSelection::create(*gpu.context); + require(static_cast(created), "LOD pipeline creation"); + auto lod = std::move(created.value()); + const auto tree = binary(512); + for (const uint32_t capacity : {1u, 7u, 127u, 128u, 129u, 257u, 512u}) { + require(lod->upload(tree, capacity, {0, 4, false}), "binary hierarchy upload"); + auto result = select(gpu, *lod, camera()); + covering(tree, result.cut); + require(result.cut.size() == capacity, "binary capacity fully used for exact refinement"); + require(result.cut == select(gpu, *lod, camera(), true).cut, "repeated frames deterministic"); + require((result.stats[4] > 0) == (capacity < 512), "denied refinements reported"); + if (capacity == 512) { + require(result.stats[5] == tree.selection.clusters.size(), "only interiors evaluated"); + for (auto node : result.cut) require(tree.layout[node].childCount == 0, "exact leaves"); + } + } + std::puts("PASS covering cuts, exact leaves, pressure, repeated frames"); + + require(lod->upload(tree, 512, {0, 4, true}), "culled hierarchy upload"); + require(select(gpu, *lod, camera(-100)).cut.empty(), "empty view"); + require(!select(gpu, *lod, camera()).cut.empty(), "empty view recovers"); + auto single = binary(1); + require(lod->upload(single, 1), "single-leaf root upload"); + require(select(gpu, *lod, camera()).cut == std::vector{0}, "single-leaf root"); + std::puts("PASS empty view and singleton"); + + auto fan = packet(513); + require(lod->upload(fan, 513, {0, 4, false}), "leaf packet upload"); + auto full = select(gpu, *lod, camera()); + covering(fan, full.cut); + require(full.cut.size() == 513 && full.stats[5] == 1 && full.stats[4] == 0, + "packet emitted cooperatively"); + require(lod->upload(fan, 512, {0, 4, false}), "packet pressure upload"); + auto denied = select(gpu, *lod, camera()); + require(denied.cut == std::vector{0} && denied.stats[4] == 1, + "whole packet denied without holes"); + std::puts("PASS large leaf packet and pressure"); + + auto old = lod->output(); + require(!lod->upload(fan, 0), "reject zero capacity"); + require(!lod->upload(fan, UINT32_MAX), "reject excessive capacity"); + require(!lod->upload(fan, 513, {-1, 4, false}), "reject negative error threshold"); + require(!lod->upload(fan, 513, {1, std::numeric_limits::quiet_NaN(), false}), + "reject NaN quality"); + auto invalid = fan; + invalid.selection.clusters[0].leafCount++; + require(!lod->upload(invalid, 513), "reject invalid metadata"); + require(lod->output().indices == old.indices && lod->output().state == old.state && + lod->capacity() == old.capacity, + "failed upload is transactional"); + require(select(gpu, *lod, camera()).cut == denied.cut, "old world survives failed upload"); + require(!lod->encode(VK_NULL_HANDLE, 0, {}), "reject null command"); + gpu.submit([&](VkCommandBuffer cmd) { + require(!lod->encode(cmd, LodSelection::kSlots, {}), "reject invalid slot"); + require(!lod->encode(cmd, 0, {}), "reject null camera"); + }); + std::puts("PASS invalid inputs and transactional upload"); + + splat::LodTree appearance; + appearance.leafCount = 4; + appearance.layout = {{{0, 0, -2}, 2, 1, 2}, {{-0.7f, 0, -2}, 0.1f, 3, 2}, + {{0.7f, 0, -2}, 1.8f, 5, 2}, {{-0.8f, 0, -2}, 0.1f, 0, 0}, + {{-0.6f, 0, -2}, 0.1f, 0, 0}, {{0.6f, 0, -2}, 0.1f, 0, 0}, + {{0.8f, 0, -2}, 0.1f, 0, 0}}; + attributes(appearance); + for (float& color : appearance.nodes.colors) color = 1; + for (size_t i = 0; i < appearance.nodeCount(); ++i) + for (const uint32_t axis : {0u, 3u, 5u}) appearance.nodes.covariances[i * 6 + axis] = 0.01f; + appearance.nodes.colors[5 * 3] = 0; + appearance.nodes.colors[6 * 3 + 1] = 0; + appearance.selection = splat::buildLodSelectionData(appearance); + require(lod->upload(appearance, 4, {1, 4, false}), "appearance upload"); + auto mixed = select(gpu, *lod, camera(100)); + require(std::set(mixed.cut.begin(), mixed.cut.end()) == std::set{1, 5, 6}, + "appearance SSE matches Metal reference cut"); + require(mixed.stats[4] == 0 && mixed.stats[5] == 3, "quality target avoids filling capacity"); + require(select(gpu, *lod, camera(1000000)).cut == std::vector{0}, + "distance reduces refinement"); + std::puts("PASS appearance SSE and distance"); + + // 32768 active interior nodes -> 256 workgroup totals -> two scan blocks. + const auto large = binary(65536); + for (const uint32_t capacity : {40000u, 65536u}) { + require(lod->upload(large, capacity, {0, 4, false}), "multiblock upload"); + auto result = select(gpu, *lod, camera()); + covering(large, result.cut); + require(result.cut.size() == capacity, "multiblock exact count"); + require(result.cut == select(gpu, *lod, camera()).cut, "multiblock deterministic"); + } + std::puts("PASS multiblock prefix scans"); + auto legacy = tree; + legacy.selection = {}; + require(lod->upload(legacy, 512, {0, 4, false}), "v1 upload metadata construction"); + covering(legacy, select(gpu, *lod, camera()).cut); + std::puts("PASS v1 metadata compatibility"); +} +} // namespace + +int main() { + try { + test::VulkanTestContext gpu; + std::printf("GPU: %s\n", gpu.context->deviceDescription().c_str()); + tests(gpu); + gpu.requireValidationClean(); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "FAIL: %s\n", error.what()); + return 1; + } +} diff --git a/packages/splatkit-android/src/main/cpp/tests/RadixSortTest.cpp b/packages/splatkit-android/src/main/cpp/tests/RadixSortTest.cpp new file mode 100644 index 0000000..8daed2d --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/tests/RadixSortTest.cpp @@ -0,0 +1,169 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "rendering/vulkan/RadixSort.h" +#include "tests/VulkanTestContext.h" + +using splatkit::GpuBuffer; +using splatkit::RadixSort; +using splatkit::test::require; +using splatkit::test::VulkanTestContext; + +namespace { +std::vector read(VulkanTestContext& gpu, VkBuffer source, uint32_t count) { + if (!count) return {}; + const VkDeviceSize bytes = VkDeviceSize{count} * 4; + auto staging = GpuBuffer::deviceLocal( + *gpu.context, bytes, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + require(static_cast(staging), "readback intermediate allocation"); + gpu.submit([&](VkCommandBuffer cmd) { + VkMemoryBarrier barrier{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + barrier.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 1, &barrier, 0, nullptr, 0, nullptr); + const VkBufferCopy copy{0, 0, bytes}; + vkCmdCopyBuffer(cmd, source, staging->handle(), 1, ©); + }); + auto raw = gpu.readback(*staging, bytes); + std::vector result(count); + std::memcpy(result.data(), raw.data(), raw.size()); + return result; +} +} // namespace + +int main() { + std::setvbuf(stdout, nullptr, _IONBF, 0); + try { + VulkanTestContext gpu; + const auto capabilities = RadixSort::queryCapabilities(*gpu.context); + std::printf("Radix device: %s; subgroup=%u; supported=%d; %s\n", + gpu.context->deviceDescription().c_str(), capabilities.subgroupSize, + capabilities.supported, capabilities.reason.c_str()); + if (!capabilities.supported) { + require(!RadixSort::create(*gpu.context), "unsupported create must fail"); + std::puts("SKIP radix GPU cases: unsupported device"); + return 77; + } + std::puts("radix stage=create"); + auto result = RadixSort::create(*gpu.context); + require(static_cast(result), "create radix"); + auto sort = std::move(result.value()); + require(sort->output(0).keys == VK_NULL_HANDLE, "unreserved output"); + std::puts("radix stage=reserve-one"); + require(sort->reserve(0) && sort->capacity() == 1, "zero reserve"); + auto* const old = sort->output(0).keys; + require(!sort->reserve(RadixSort::kMaxCapacity + 1), "reject oversized capacity"); + require(sort->capacity() == 1 && sort->output(0).keys == old, "transactional reserve failure"); + const bool large = std::getenv("SPLATKIT_RADIX_LARGE") != nullptr; + const bool tiny = std::getenv("SPLATKIT_RADIX_TINY") != nullptr; + const uint32_t capacity = tiny ? 257 : large ? RadixSort::kMaxCapacity : 262145; + std::printf("radix stage=reserve capacity=%u\n", capacity); + require(sort->reserve(capacity), "reserve test capacity"); + const VkDeviceSize bytes = VkDeviceSize{capacity} * 4; + std::puts("radix stage=input-allocation"); + auto keys = GpuBuffer::deviceLocal(*gpu.context, bytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + auto values = GpuBuffer::deviceLocal(*gpu.context, bytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + auto count = GpuBuffer::deviceLocal(*gpu.context, 4, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + require(keys && values && count, "allocate input"); + RadixSort::Input input; + input.keys = keys->handle(); + input.values = values->handle(); + input.count = count->handle(); + input.keysBytes = bytes; + input.valuesBytes = bytes; + require(!sort->encode(VK_NULL_HANDLE, 0, input), "reject null command buffer"); + gpu.submit([&](VkCommandBuffer cmd) { + require(!sort->encode(cmd, RadixSort::kSlots, input), "reject invalid slot"); + auto bad = input; + bad.keysBytes = bytes - 1; + require(!sort->encode(cmd, 0, bad), "reject undersized input"); + bad = input; + bad.keysOffset = 1; + require(!sort->encode(cmd, 0, bad), "reject misaligned offset"); + bad = input; + // Fixed uint32_t underlying type permits this value; exercise enum validation. + // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) + bad.keyBits = static_cast(9); + require(!sort->encode(cmd, 0, bad), "reject key mode"); + bad = input; + bad.keys = sort->output(1).keys; + require(!sort->encode(cmd, 0, bad), "reject owned output alias"); + }); + std::vector sizes{0, 1, 31, 32, 33, 67, 127, 128, 129, 257}; + if (!tiny) sizes.insert(sizes.end(), {2047, 2048, 2049, 4097, 262145}); + if (large && !tiny) sizes.push_back(capacity); + std::mt19937 random(0x51a7u); + uint32_t cases = 0; + for (const auto bits : {RadixSort::KeyBits::full32, RadixSort::KeyBits::low16}) { + input.keyBits = bits; + const uint32_t mask = bits == RadixSort::KeyBits::low16 ? 65535u : UINT32_MAX; + for (uint32_t n : sizes) { + // Mixed full-width values, many duplicates, and all-equal input cross block boundaries. + for (uint32_t pattern = 0; pattern < 4; ++pattern) { + std::printf("radix case=%u bits=%u n=%u pattern=%u stage=upload\n", cases, + bits == RadixSort::KeyBits::low16 ? 16u : 32u, n, pattern); + std::vector inKeys(n); + std::vector inValues(n); + std::vector expected(n); + std::iota(inValues.begin(), inValues.end(), 0); + std::iota(expected.begin(), expected.end(), 0); + for (uint32_t i = 0; i < n; ++i) { + inKeys[i] = pattern == 0 ? random() : pattern == 1 ? random() % 17 : 42; + if (pattern == 3) { + const float depth = 1.0f + i * 0.02f; + std::memcpy(&inKeys[i], &depth, sizeof(depth)); + inKeys[i] = ~inKeys[i]; + } + if (bits == RadixSort::KeyBits::low16) + inKeys[i] = (inKeys[i] & 65535u) | (random() & 0xffff0000u); + } + require(keys->upload(inKeys.data(), VkDeviceSize{n} * 4), "upload keys"); + require(values->upload(inValues.data(), VkDeviceSize{n} * 4), "upload values"); + require(count->upload(&n, 4), "upload count"); + std::stable_sort(expected.begin(), expected.end(), [&](uint32_t a, uint32_t b) { + return (inKeys[a] & mask) < (inKeys[b] & mask); + }); + const uint32_t slot = cases++ % RadixSort::kSlots; + std::puts("radix stage=encode-submit-wait"); + gpu.submit([&](VkCommandBuffer cmd) { + require(sort->encode(cmd, slot, input), "encode radix"); + }); + std::puts("radix stage=readback-metadata"); + const auto output = sort->output(slot); + require(read(gpu, output.count, 1)[0] == n, "output count"); + require(read(gpu, output.status, 1)[0] == 0, "output status"); + std::puts("radix stage=readback-keys"); + const auto actualKeys = read(gpu, output.keys, n); + std::puts("radix stage=readback-values"); + const auto actualValues = read(gpu, output.values, n); + std::puts("radix stage=compare"); + require(actualValues == expected, "stable permutation differs from CPU stable_sort"); + for (uint32_t i = 0; i < n; ++i) + require(actualKeys[i] == inKeys[expected[i]], "key/value association lost"); + } + } + } + uint32_t overflow = capacity + 1; + require(count->upload(&overflow, 4), "upload invalid count"); + gpu.submit( + [&](VkCommandBuffer cmd) { require(sort->encode(cmd, 0, input), "encode invalid count"); }); + require(read(gpu, sort->output(0).count, 1)[0] == 0, "invalid count fails closed"); + require(read(gpu, sort->output(0).status, 1)[0] == RadixSort::kInvalidCount, + "invalid count status"); + gpu.requireValidationClean(); + std::printf( + "PASS radix: %u stable-sort cases, invalid inputs/count, reserve, slots; large=%d\n", cases, + large); + if (!large) std::puts("SKIP 3M case: set SPLATKIT_RADIX_LARGE=1"); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "FAIL radix: %s\n", error.what()); + return 1; + } +} diff --git a/packages/splatkit-android/src/main/cpp/tests/VisibilityPassTest.cpp b/packages/splatkit-android/src/main/cpp/tests/VisibilityPassTest.cpp new file mode 100644 index 0000000..13396fb --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/tests/VisibilityPassTest.cpp @@ -0,0 +1,297 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "rendering/vulkan/VisibilityPass.h" +#include "rendering/vulkan/VulkanShaderTypes.h" +#include "splatkit/rendering/GpuLayout.h" +#include "tests/VulkanTestContext.h" + +namespace { +using splatkit::CameraUniform; +using splatkit::GpuBuffer; +using splatkit::GpuSplat; +using splatkit::packSplats; +using splatkit::VisibilityPass; +namespace test = splatkit::test; +using test::require; +using Mode = VisibilityPass::CandidateMode; + +CameraUniform camera() { + CameraUniform cam{}; + cam.view = splat::Mat4::identity(); + cam.proj = splat::Mat4::perspective(1, 1, 0.1f, 100); + cam.focal[0] = cam.focal[1] = 500; + cam.tanHalfFov[0] = cam.tanHalfFov[1] = 1; + cam.screenSize[0] = cam.screenSize[1] = 1000; + return cam; +} +std::vector source(uint32_t count) { + splat::SplatCloud cloud; + for (uint32_t i = 0; i < count; ++i) { + cloud.positions.insert(cloud.positions.end(), {0, 0, -static_cast(1 + i % 10)}); + cloud.covariances.insert(cloud.covariances.end(), {0.01f, 0, 0, 0.01f, 0, 0.01f}); + cloud.colors.insert(cloud.colors.end(), {1, 0, 0}); + cloud.alphas.push_back(0.5f); + } + return packSplats(cloud); +} +template +std::unique_ptr upload(const test::VulkanTestContext& gpu, + const std::vector& values) { + const size_t bytes = std::max(values.size() * sizeof(T), sizeof(T)); + auto buffer = GpuBuffer::deviceLocal(*gpu.context, bytes, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + require(buffer != nullptr, "input allocation"); + const T zero{}; + require(buffer->upload(values.empty() ? &zero : values.data(), bytes), "input upload"); + return buffer; +} +struct Fixture { + explicit Fixture(const test::VulkanTestContext& gpu) + : splats(upload(gpu, source(300))), + uniforms(GpuBuffer::hostVisible(*gpu.context, sizeof(CameraUniform), + VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT)) { + require(uniforms != nullptr, "uniform allocation"); + setCamera(camera()); + } + void setCamera(const CameraUniform& cam) const { + std::memcpy(uniforms->mapped(), &cam, sizeof(cam)); + uniforms->flush(0, sizeof(cam)); + } + VisibilityPass::Input input() const { + VisibilityPass::Input in; + in.camera = uniforms->handle(); + in.cameraBytes = uniforms->size(); + in.splats = splats->handle(); + in.splatsBytes = splats->size(); + in.sourceCount = 300; + return in; + } + std::unique_ptr splats, uniforms; +}; +struct Result { + uint32_t count = 0, status = 0; + VkDrawIndirectCommand draw{}; + std::vector indices, keys; +}; +Result run(const test::VulkanTestContext& gpu, const VisibilityPass& pass, + const VisibilityPass::Input& input, uint32_t slot = 0) { + const VkDeviceSize bytes = 24 + pass.capacity() * 8ull; + auto copy = GpuBuffer::deviceLocal( + *gpu.context, bytes, VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT); + require(copy != nullptr, "readback copy allocation"); + gpu.submit([&](VkCommandBuffer cmd) { + require(pass.encode(cmd, slot, input), "visibility encode"); + auto out = pass.output(slot); + const VkBufferCopy count{0, 0, 4}; + const VkBufferCopy status{0, 4, 4}; + const VkBufferCopy draw{0, 8, 16}; + const VkBufferCopy indices{0, 24, pass.capacity() * 4ull}; + const VkBufferCopy keys{0, 24 + pass.capacity() * 4ull, pass.capacity() * 4ull}; + vkCmdCopyBuffer(cmd, out.count, copy->handle(), 1, &count); + vkCmdCopyBuffer(cmd, out.status, copy->handle(), 1, &status); + vkCmdCopyBuffer(cmd, out.indirect, copy->handle(), 1, &draw); + vkCmdCopyBuffer(cmd, out.indices, copy->handle(), 1, &indices); + vkCmdCopyBuffer(cmd, out.depthKeys, copy->handle(), 1, &keys); + }); + const auto raw = gpu.readback(*copy, bytes); + Result result; + std::memcpy(&result.count, raw.data(), 4); + std::memcpy(&result.status, raw.data() + 4, 4); + std::memcpy(&result.draw, raw.data() + 8, 16); + require(result.count <= pass.capacity(), "count within output capacity"); + require(result.draw.vertexCount == 4 && result.draw.instanceCount == result.count && + result.draw.firstVertex == 0 && result.draw.firstInstance == 0, + "indirect draw agrees with normalized count"); + result.indices.resize(result.count); + result.keys.resize(result.count); + if (result.count) { + std::memcpy(result.indices.data(), raw.data() + 24, result.count * 4); + std::memcpy(result.keys.data(), raw.data() + 24 + pass.capacity() * 4ull, result.count * 4); + } + return result; +} +void membership(const Result& result, std::vector expected) { + require(result.status == 0, "no visibility failure"); + auto indices = result.indices; + std::sort(indices.begin(), indices.end()); + std::sort(expected.begin(), expected.end()); + require(indices == expected, "candidate membership equals expected source indices"); +} +void failed(const Result& result, uint32_t bit) { + require((result.status & bit) != 0 && result.count == 0 && result.draw.instanceCount == 0, + "GPU failure zeros count and indirect draw"); +} +void indexed(VisibilityPass::Input& in, const GpuBuffer& indices, const GpuBuffer& count, + uint32_t bound) { + in.mode = Mode::indices; + in.candidates = indices.handle(); + in.candidatesBytes = indices.size(); + in.candidateCount = count.handle(); + in.candidateCountBytes = count.size(); + in.candidateCapacity = bound; +} +void ranges(VisibilityPass::Input& in, const GpuBuffer& records, uint32_t count, uint32_t bound) { + in.mode = Mode::ranges; + in.candidates = records.handle(); + in.candidatesBytes = records.size(); + in.rangeCount = count; + in.candidateCapacity = bound; +} + +void tests(const test::VulkanTestContext& gpu) { + auto created = VisibilityPass::create(*gpu.context, 0); + require(static_cast(created), "create subgroup visibility pass"); + auto pass = std::move(created.value()); + const Fixture f(gpu); + require(pass->reserve(300), "reserve output"); + for (const uint32_t n : {0u, 1u, 127u, 128u, 129u, 257u, 300u}) { + auto in = f.input(); + in.sourceCount = n; + std::vector expected(n); + std::iota(expected.begin(), expected.end(), 0); + membership(run(gpu, *pass, in), expected); + membership(run(gpu, *pass, in, 1), expected); + } + std::puts("PASS prefix, empty, subgroup tails and descriptor slots"); + + require(pass->reserve(3), "small output capacity"); + auto indexBuffer = upload(gpu, std::vector{299, 0, 128}); + auto countBuffer = upload(gpu, std::vector{3}); + auto in = f.input(); + indexed(in, *indexBuffer, *countBuffer, 3); + membership(run(gpu, *pass, in), {299, 0, 128}); + const uint32_t zero = 0; + const uint32_t excessive = 4; + require(countBuffer->upload(&zero, 4), "zero GPU count"); + membership(run(gpu, *pass, in), {}); + in.candidateCapacity = 0; + membership(run(gpu, *pass, in), {}); + require(countBuffer->upload(&excessive, 4), "bad GPU count"); + failed(run(gpu, *pass, in), VisibilityPass::kInvalidCount); + in.candidateCapacity = 3; + failed(run(gpu, *pass, in), VisibilityPass::kInvalidCount); + auto badIndex = upload(gpu, std::vector{0, 300, UINT32_MAX}); + const uint32_t three = 3; + require(countBuffer->upload(&three, 4), "restore GPU count"); + indexed(in, *badIndex, *countBuffer, 3); + failed(run(gpu, *pass, in), VisibilityPass::kInvalidIndex); + std::puts("PASS indexed LOD, high resident indices, bad index/count"); + + require(pass->reserve(5), "range output capacity"); + auto records = upload(gpu, std::vector{{2, 2, 2, 0}, {290, 3, 5, 0}}); + in = f.input(); + ranges(in, *records, 2, 5); + membership(run(gpu, *pass, in), {2, 3, 290, 291, 292}); + auto emptyRecords = upload(gpu, std::vector{}); + ranges(in, *emptyRecords, 0, 0); + membership(run(gpu, *pass, in), {}); + auto badRecords = upload(gpu, std::vector{{299, 2, 2, 0}}); + ranges(in, *badRecords, 1, 2); + failed(run(gpu, *pass, in), VisibilityPass::kInvalidRange); + auto badPrefix = upload(gpu, std::vector{{2, 2, 3, 0}}); + ranges(in, *badPrefix, 1, 2); + failed(run(gpu, *pass, in), VisibilityPass::kInvalidRange); + auto overlap = upload(gpu, std::vector{{2, 2, 2, 0}, {3, 2, 4, 0}}); + ranges(in, *overlap, 2, 4); + failed(run(gpu, *pass, in), VisibilityPass::kInvalidRange); + auto hiddenBadRange = + upload(gpu, std::vector{{0, 2, 2, 0}, {2, 1, 1, 0}, {4, 2, 3, 0}}); + ranges(in, *hiddenBadRange, 3, 3); + failed(run(gpu, *pass, in), VisibilityPass::kInvalidRange); + std::puts("PASS streamed ranges, empty ranges and malformed ranges"); + + in = f.input(); // source count exceeds output capacity: GPU overflow, never CPU truncation + failed(run(gpu, *pass, in), VisibilityPass::kOverflow); + in.candidateCapacity = 3; + membership(run(gpu, *pass, in), {0, 1, 2}); + std::puts("PASS output overflow fails closed and recovers"); + + for (auto bits : {VisibilityPass::KeyBits::full32, VisibilityPass::KeyBits::low16}) { + for (auto order : {VisibilityPass::KeyOrder::ascending, VisibilityPass::KeyOrder::descending}) { + in.keyBits = bits; + in.keyOrder = order; + auto result = run(gpu, *pass, in); + membership(result, {0, 1, 2}); + const auto cam = camera(); + const float nearPlane = cam.proj.at(2, 3) / cam.proj.at(2, 2); + const float farPlane = cam.proj.at(2, 3) / (cam.proj.at(2, 2) + 1); + for (size_t i = 0; i < result.count; ++i) { + const auto depth = static_cast(1 + result.indices[i] % 10); + uint32_t expected = 0; + std::memcpy(&expected, &depth, 4); + if (bits == VisibilityPass::KeyBits::low16) + expected = static_cast( + std::clamp((depth - nearPlane) / (farPlane - nearPlane), 0.0f, 1.0f) * 65535); + if (order == VisibilityPass::KeyOrder::descending) + expected = bits == VisibilityPass::KeyBits::low16 ? 65535 - expected : ~expected; + if (bits == VisibilityPass::KeyBits::full32) + require(result.keys[i] == expected, + "32-bit depth key preserves float bits and BTF inversion"); + else + require(result.keys[i] <= 65535 && + std::abs(static_cast(result.keys[i]) - expected) <= 1, + "16-bit quantized depth key (one-bin floating-point tolerance)"); + } + } + } + auto badCamera = camera(); + badCamera.proj = splat::Mat4::identity(); + f.setCamera(badCamera); + in.keyBits = VisibilityPass::KeyBits::low16; + failed(run(gpu, *pass, in), VisibilityPass::kInvalidProjection); + f.setCamera(camera()); + std::puts("PASS full32/low16 ascending/BTF keys and invalid projection"); + + const auto old = pass->output(0); + require(!pass->reserve(0) && !pass->reserve(VisibilityPass::kMaxCapacity + 1), + "reject capacity bounds"); + require(pass->output(0).indices == old.indices, "reserve failure preserves output"); + in = f.input(); + in.candidateCapacity = 3; + gpu.submit([&](VkCommandBuffer cmd) { + auto bad = in; + bad.splatsBytes = 32; + require(!pass->encode(cmd, 0, bad), "reject undersized resident source descriptor"); + bad = in; + bad.cameraBytes = 175; + require(!pass->encode(cmd, 0, bad), "reject undersized uniform descriptor"); + bad = in; + bad.splatsOffset = std::numeric_limits::max(); + require(!pass->encode(cmd, 0, bad), "reject offset overflow or misalignment"); + bad = in; + // Fixed uint32_t underlying type permits this value; exercise enum validation. + // NOLINTNEXTLINE(clang-analyzer-optin.core.EnumCastOutOfRange) + bad.mode = static_cast(99); + require(!pass->encode(cmd, 0, bad), "reject unknown mode"); + bad = in; + indexed(bad, *indexBuffer, *countBuffer, 3); + bad.candidatesBytes = 4; + require(!pass->encode(cmd, 0, bad), "reject undersized candidate descriptor"); + bad.candidatesBytes = indexBuffer->size(); + bad.candidateCountBytes = 3; + require(!pass->encode(cmd, 0, bad), "reject undersized count descriptor"); + require(!pass->encode(cmd, VisibilityPass::kSlots, in), "reject invalid slot"); + require(!pass->encode(VK_NULL_HANDLE, 0, in), "reject null command"); + }); + membership(run(gpu, *pass, in), {0, 1, 2}); + std::puts("PASS descriptor rejection and transactional capacity"); +} +} // namespace + +int main() { + try { + test::VulkanTestContext gpu; + std::printf("GPU: %s\n", gpu.context->deviceDescription().c_str()); + tests(gpu); + gpu.requireValidationClean(); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "FAIL: %s\n", error.what()); + return 1; + } +} diff --git a/packages/splatkit-android/src/main/cpp/tests/VulkanFrameComputeTest.cpp b/packages/splatkit-android/src/main/cpp/tests/VulkanFrameComputeTest.cpp new file mode 100644 index 0000000..50a35ff --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/tests/VulkanFrameComputeTest.cpp @@ -0,0 +1,180 @@ +#include +#include +#include +#include +#include + +#include "rendering/vulkan/VulkanFrameCompute.h" +#include "rendering/vulkan/VulkanShaderTypes.h" +#include "splatkit/rendering/GpuLayout.h" +#include "tests/VulkanTestContext.h" + +namespace { +using splatkit::CameraUniform; +using splatkit::GpuBuffer; +using splatkit::GpuSplat; +using splatkit::packSplats; +using splatkit::SplatRenderer; +using splatkit::VulkanFrameCompute; +namespace test = splatkit::test; +using test::require; + +splat::SplatCloud cloud(uint32_t count) { + splat::SplatCloud result; + for (uint32_t i = 0; i < count; ++i) { + result.positions.insert(result.positions.end(), {0, 0, -1.0f - i * 0.02f}); + result.covariances.insert(result.covariances.end(), {0.01f, 0, 0, 0.01f, 0, 0.01f}); + result.colors.insert(result.colors.end(), {1, 0, 0}); + result.alphas.push_back(0.5f); + } + return result; +} + +struct Inputs { + Inputs(const test::VulkanTestContext& gpu, const splat::SplatCloud& source) { + camera = GpuBuffer::hostVisible(*gpu.context, sizeof(CameraUniform), + VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT); + auto packed = packSplats(source); + if (packed.empty()) packed.resize(1); + splats = GpuBuffer::deviceLocal(*gpu.context, packed.size() * sizeof(GpuSplat), + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT); + require(camera && splats && splats->upload(packed.data(), packed.size() * sizeof(GpuSplat)), + "frame input allocation/upload"); + setView(0); + } + void setView(float x) const { + CameraUniform u{}; + u.view = splat::Mat4::identity(); + u.view.at(0, 3) = x; + u.proj = splat::Mat4::perspective(1, 1, 0.1f, 100); + u.screenSize[0] = u.screenSize[1] = 1000; + u.focal[0] = u.focal[1] = 500; + u.tanHalfFov[0] = u.tanHalfFov[1] = 1; + std::memcpy(camera->mapped(), &u, sizeof(u)); + camera->flush(0, sizeof(u)); + } + std::unique_ptr camera, splats; +}; + +std::vector run(const test::VulkanTestContext& gpu, VulkanFrameCompute& pass, + const Inputs& inputs, uint32_t slot, uint32_t capacity, + const std::vector& ranges) { + const VkDeviceSize bytes = sizeof(VkDrawIndirectCommand) + std::max(1u, capacity) * 4ull; + auto copy = GpuBuffer::deviceLocal(*gpu.context, bytes, VK_BUFFER_USAGE_TRANSFER_SRC_BIT); + require(copy != nullptr, "frame result allocation"); + gpu.submit([&](VkCommandBuffer cmd) { + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + frame.ranges = ranges.data(); + frame.rangeCount = static_cast(ranges.size()); + auto draw = pass.encode(cmd, slot, inputs.camera->handle(), *inputs.splats, frame); + require(draw.has_value(), "GPU frame encode"); + require(draw->capacity <= std::max(1u, capacity), "draw buffer bound"); + VkMemoryBarrier barrier{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + barrier.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 1, &barrier, 0, nullptr, 0, nullptr); + const VkBufferCopy args{0, 0, sizeof(VkDrawIndirectCommand)}; + const VkBufferCopy order{0, sizeof(VkDrawIndirectCommand), draw->capacity * 4ull}; + vkCmdCopyBuffer(cmd, draw->arguments, copy->handle(), 1, &args); + vkCmdCopyBuffer(cmd, draw->order, copy->handle(), 1, &order); + }); + auto data = gpu.readback(*copy, bytes); + VkDrawIndirectCommand args{}; + std::memcpy(&args, data.data(), sizeof(args)); + require(args.vertexCount == 4 && args.instanceCount <= capacity && args.firstVertex == 0 && + args.firstInstance == 0, + "GPU draw arguments bounded and initialized"); + std::vector indices(args.instanceCount); + if (!indices.empty()) + std::memcpy(indices.data(), data.data() + sizeof(args), indices.size() * sizeof(uint32_t)); + return indices; +} + +void tests(const test::VulkanTestContext& gpu) { + auto source = cloud(257); + Inputs inputs(gpu, source); + const std::vector ranges{{100, 40}, {0, 10}, {240, 17}}; + std::vector expected; + for (auto range : ranges) + for (uint32_t i = 0; i < range.count; ++i) expected.push_back(range.offset + i); + std::sort(expected.rbegin(), expected.rend()); + for (const char* bits : {"32", "16"}) { + require(setenv("SPLATKIT_VULKAN_SORT_BITS", bits, 1) == 0, "set test precision"); + auto created = VulkanFrameCompute::create(*gpu.context, 257); + require(static_cast(created), "create GPU frame"); + auto pass = std::move(created.value()); + const auto actual = run(gpu, *pass, inputs, 0, 257, ranges); + if (actual != expected) { + std::fprintf(stderr, "range ordering: expected %zu, got %zu\n", expected.size(), + actual.size()); + for (size_t i = 0; i < actual.size(); ++i) + std::fprintf(stderr, "%zu: expected %u, got %u\n", i, + i < expected.size() ? expected[i] : UINT32_MAX, actual[i]); + } + require(actual == expected, "back-to-front range ordering"); + require(run(gpu, *pass, inputs, 1, 257, {}).empty(), "empty ranges draw nothing"); + require(run(gpu, *pass, inputs, 0, 257, ranges) == expected, "slot reuse ordering"); + require(pass->stats().drawn == expected.size() && pass->stats().status == 0, + "delayed diagnostics match completed GPU count"); + inputs.setView(100); + require(run(gpu, *pass, inputs, 1, 257, ranges).empty(), "offscreen ranges draw nothing"); + require(pass->stats().drawn == 0, "zero drawn remains zero with a loaded source"); + inputs.setView(0); + require(run(gpu, *pass, inputs, 0, 257, ranges) == expected, "visibility recovers"); + gpu.submit([&](VkCommandBuffer cmd) { + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + const SplatRenderer::Range overlap[]{{0, 2}, {1, 2}}; + frame.ranges = overlap; + frame.rangeCount = 2; + require(!pass->encode(cmd, 1, inputs.camera->handle(), *inputs.splats, frame), + "overlapping residency ranges rejected"); + frame.rangeCount = 0; + require(!pass->encode(cmd, 2, inputs.camera->handle(), *inputs.splats, frame), + "invalid frame slot rejected"); + }); + std::printf("PASS %s-bit visibility/sort/indirect chain, ranges, empty view and diagnostics\n", + bits); + } + require(unsetenv("SPLATKIT_VULKAN_SORT_BITS") == 0, "restore test precision"); + + splat::LodTree tree; + tree.leafCount = 2; + tree.layout = {{{0, 0, -3}, 4, 1, 2}, {{0, 0, -1}, 0.2f, 0, 0}, {{0, 0, -5}, 0.2f, 0, 0}}; + tree.nodes = cloud(3); + tree.nodes.positions = {0, 0, -3, 0, 0, -1, 0, 0, -5}; + tree.nodes.covariances[0] = tree.nodes.covariances[3] = tree.nodes.covariances[5] = 1; + tree.selection = splat::buildLodSelectionData(tree); + const Inputs lodInputs(gpu, tree.nodes); + for (const uint32_t budget : {1u, 2u}) { + auto created = VulkanFrameCompute::create(*gpu.context, 3, &tree, budget); + require(static_cast(created), "create GPU LOD frame"); + auto pass = std::move(created.value()); + require(pass->hasLod(), "hierarchy is GPU selected"); + const auto cut = budget == 1 ? std::vector{0} : std::vector{2, 1}; + require(run(gpu, *pass, lodInputs, 0, budget, {}) == cut, + "LOD GPU count feeds visibility/sort"); + require(run(gpu, *pass, lodInputs, 0, budget, {}) == cut, "LOD slot reuse"); + require(pass->stats().selected == budget && pass->stats().drawn == budget && + pass->stats().status == 0, + "LOD completed diagnostics"); + } + std::puts("PASS hierarchical selection feeds sorted indirect draw with covering pressure"); + gpu.requireValidationClean(); +} +} // namespace + +int main() { + try { + test::VulkanTestContext gpu; + std::printf("GPU: %s; validation: %s\n", gpu.context->deviceDescription().c_str(), + gpu.context->validationEnabled() ? "on" : "off"); + tests(gpu); + return 0; + } catch (const std::exception& e) { + std::fprintf(stderr, "FAIL %s\n", e.what()); + return 1; + } +} diff --git a/packages/splatkit-android/src/main/cpp/tests/VulkanTestContext.h b/packages/splatkit-android/src/main/cpp/tests/VulkanTestContext.h new file mode 100644 index 0000000..a675637 --- /dev/null +++ b/packages/splatkit-android/src/main/cpp/tests/VulkanTestContext.h @@ -0,0 +1,112 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "rendering/vulkan/GpuBuffer.h" + +namespace splatkit::test { + +inline void require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +// No surface, Android activity, or renderer. Own this before all GPU resources. +// Calls are single-threaded; submit waits for completion before returning. +// Set SPLATKIT_REQUIRE_VALIDATION=1 to fail if the loader cannot discover the layer. +class VulkanTestContext { + public: + VulkanTestContext() { + auto result = VulkanContext::create(); + if (!result) throw std::runtime_error(result.error().message); + context = std::move(result.value()); + if (const char* required = std::getenv("SPLATKIT_REQUIRE_VALIDATION")) { + if (std::strcmp(required, "1") == 0) + require(context->validationEnabled(), "required Vulkan validation layer unavailable"); + } + } + + void requireValidationClean() const { + require(context->validationMessageCount() == 0, "Vulkan validation warning/error"); + } + + void submit(const std::function& record) const { + struct Commands { + explicit Commands(VkDevice device) : device(device) {} + Commands(const Commands&) = delete; + Commands& operator=(const Commands&) = delete; + VkDevice device; + VkCommandPool pool = VK_NULL_HANDLE; + VkFence fence = VK_NULL_HANDLE; + ~Commands() { + if (fence) vkDestroyFence(device, fence, nullptr); + if (pool) vkDestroyCommandPool(device, pool, nullptr); + } + } commands{context->device()}; + VkCommandPoolCreateInfo pool{VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO}; + pool.queueFamilyIndex = context->queueFamily(); + pool.flags = VK_COMMAND_POOL_CREATE_TRANSIENT_BIT; + require(vkCreateCommandPool(commands.device, &pool, nullptr, &commands.pool) == VK_SUCCESS, + "create command pool"); + VkCommandBufferAllocateInfo allocate{VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO}; + allocate.commandPool = commands.pool; + allocate.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocate.commandBufferCount = 1; + VkCommandBuffer cmd = VK_NULL_HANDLE; + require(vkAllocateCommandBuffers(commands.device, &allocate, &cmd) == VK_SUCCESS, + "allocate command buffer"); + VkCommandBufferBeginInfo begin{VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO}; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + require(vkBeginCommandBuffer(cmd, &begin) == VK_SUCCESS, "begin command buffer"); + record(cmd); + require(vkEndCommandBuffer(cmd) == VK_SUCCESS, "end command buffer"); + const VkFenceCreateInfo fence{VK_STRUCTURE_TYPE_FENCE_CREATE_INFO}; + require(vkCreateFence(commands.device, &fence, nullptr, &commands.fence) == VK_SUCCESS, + "create fence"); + VkSubmitInfo info{VK_STRUCTURE_TYPE_SUBMIT_INFO}; + info.commandBufferCount = 1; + info.pCommandBuffers = &cmd; + require(vkQueueSubmit(context->queue(), 1, &info, commands.fence) == VK_SUCCESS, "submit"); + require(vkWaitForFences(commands.device, 1, &commands.fence, VK_TRUE, UINT64_MAX) == VK_SUCCESS, + "wait for GPU"); + } + + // Source must have TRANSFER_SRC usage. Makes prior GPU writes visible, copies, + // waits, then invalidates noncoherent memory before returning the actual bytes. + std::vector readback(const GpuBuffer& source, VkDeviceSize bytes) const { + require(bytes > 0 && bytes <= source.size(), "readback range"); + auto destination = GpuBuffer::hostVisible( + *context, std::min(bytes, 2 * 1024 * 1024), VK_BUFFER_USAGE_TRANSFER_DST_BIT); + require(destination && destination->mapped(), "create readback buffer"); + std::vector result(static_cast(bytes)); + for (VkDeviceSize copied = 0; copied < bytes;) { + const VkDeviceSize chunk = std::min(destination->size(), bytes - copied); + submit([&](VkCommandBuffer cmd) { + VkMemoryBarrier before{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + before.srcAccessMask = VK_ACCESS_MEMORY_WRITE_BIT; + before.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 1, &before, 0, nullptr, 0, nullptr); + const VkBufferCopy region{copied, 0, chunk}; + vkCmdCopyBuffer(cmd, source.handle(), destination->handle(), 1, ®ion); + VkMemoryBarrier after{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + after.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + after.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + vkCmdPipelineBarrier(cmd, VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_HOST_BIT, 0, 1, + &after, 0, nullptr, 0, nullptr); + }); + destination->invalidate(0, chunk); + std::memcpy(result.data() + copied, destination->mapped(), static_cast(chunk)); + copied += chunk; + } + return result; + } + + std::unique_ptr context; +}; + +} // namespace splatkit::test diff --git a/packages/splatkit-android/src/main/java/com/splatkit/SplatStats.kt b/packages/splatkit-android/src/main/java/com/splatkit/SplatStats.kt index d6cf0dd..df24a7f 100644 --- a/packages/splatkit-android/src/main/java/com/splatkit/SplatStats.kt +++ b/packages/splatkit-android/src/main/java/com/splatkit/SplatStats.kt @@ -1,13 +1,49 @@ package com.splatkit -/** A snapshot of what the engine is doing, refreshed twice a second. */ +/** + * Published snapshot, refreshed twice a second; completed-frame diagnostics may lag the view. + * Counts cross JNI as floats: all integers through 16,777,216 are exact; larger values may round. + */ class SplatStats { var fps = 0f var frameMillis = 0f /** GPU time of the last frame from timestamp queries; zero when unsupported. */ var gpuMillis = 0f + /** Sort duration; the GPU path reports zero when timestamp queries are unavailable. */ var sortMillis = 0f + /** Loaded source splats, not the number currently drawn or GPU-resident tree records. */ var splatCount = 0 + /** Alias of [splatCount], with the same source-count meaning. */ + var loadedSplatCount: Int + get() = splatCount + set(value) { splatCount = value } var walking = false var motion = false + /** Last completed visibility/order result; zero remains zero even when a source is loaded. */ + var drawnSplatCount = 0 + /** Completed compute screen tiles, including background-only tiles; zero when unavailable. */ + var computeTileCount = 0 + /** Completed compute screen tiles containing candidates; zero when unavailable. */ + var nonemptyComputeTileCount = 0 + /** Completed hardware screen tiles; zero when unavailable. */ + var hardwareTileCount = 0 +} + +internal const val SPLAT_STATS_FLOATS = 11 + +/** Mirrors nativeStats: the legacy seven floats stay first, followed by four completed counts. */ +internal fun decodeSplatStats(values: FloatArray, into: SplatStats): SplatStats { + require(values.size >= SPLAT_STATS_FLOATS) { "Stats payload needs $SPLAT_STATS_FLOATS floats" } + into.fps = values[0] + into.frameMillis = values[1] + into.gpuMillis = values[2] + into.sortMillis = values[3] + into.splatCount = values[4].toInt() + into.walking = values[5] != 0f + into.motion = values[6] != 0f + into.drawnSplatCount = values[7].toInt() + into.computeTileCount = values[8].toInt() + into.nonemptyComputeTileCount = values[9].toInt() + into.hardwareTileCount = values[10].toInt() + return into } diff --git a/packages/splatkit-android/src/main/java/com/splatkit/SplatSurfaceView.kt b/packages/splatkit-android/src/main/java/com/splatkit/SplatSurfaceView.kt index 7c09c4b..3a71fd9 100644 --- a/packages/splatkit-android/src/main/java/com/splatkit/SplatSurfaceView.kt +++ b/packages/splatkit-android/src/main/java/com/splatkit/SplatSurfaceView.kt @@ -93,6 +93,13 @@ class SplatSurfaceView @JvmOverloads constructor( /** Decodes a collider GLB from a file; enables walk mode when ready. */ fun loadCollider(file: File) = renderThread.loadColliderFile(file.absolutePath) + /** + * Shows a tiled world from its index, a `tileset.json` with its tiles beside it (made + * offline by `splat-tile`). Only the index is read now; tiles stream in as the camera + * needs them, nearest and biggest on screen first, within [residencyBudget]. + */ + fun loadTiledWorld(tileset: File) = renderThread.loadTiledWorldFile(tileset.absolutePath) + /** * The camera's position and look direction. Reading gives the pose as of the last * frame; setting teleports, and when walking the camera settles on the floor under @@ -130,10 +137,8 @@ class SplatSurfaceView @JvmOverloads constructor( } /** - * Angular margin around the view, in degrees, kept drawn so that what turns into - * view before the next cull lands is already there; the engine widens it further - * during a fast turn. 10 by default. Wider draws more that is off screen, narrower - * risks an empty edge on a flick. + * CPU fallback's angular culling margin, in degrees. The GPU path evaluates the + * current camera each frame and uses projected splat bounds instead. */ var cullMarginDegrees: Float = 10f set(value) { @@ -154,11 +159,11 @@ class SplatSurfaceView @JvmOverloads constructor( } /** - * Most splats drawn per frame, or 0 to draw them all. With a budget, a world loaded - * afterwards gets a level of detail hierarchy (about 1.5 times the splats in GPU - * memory) and every frame draws the nodes that cover the scene at about a pixel - * each, nearest in full detail, so frame time stops depending on the scene's size. - * Applies to worlds loaded after it is set. + * LOD selection capacity for subsequently loaded worlds. Zero disables automatic + * hierarchy construction; a .lodsplat file already contains its hierarchy. + * Vulkan GPU selection supports at most 2.2M nodes. Coarse parents are approximate, + * and the full hierarchy must fit GPU memory. Non-LOD visibility above 3M survivors + * fails closed instead of truncating or issuing an unsafe draw. */ var splatBudget: Int = 0 set(value) { @@ -166,6 +171,18 @@ class SplatSurfaceView @JvmOverloads constructor( renderThread.setSplatBudget(field) } + /** + * Residency budget of a tiled world: the most splats held on the GPU at once, about + * 32 bytes each plus the harmonics. Streaming fills it with what is nearest and + * biggest on screen and evicts what the camera left. Applies to tiled worlds loaded + * after it is set. + */ + var residencyBudget: Int = 2_000_000 + set(value) { + field = value.coerceIn(100_000, 8_000_000) + renderThread.setResidencyBudget(field) + } + /** * Spherical harmonics degree drawn, 0 to 3, capped by what the loaded world carries. * Takes effect on the next frame. Spherical harmonics make colour depend on the view diff --git a/packages/splatkit-android/src/main/java/com/splatkit/engine/RenderThread.kt b/packages/splatkit-android/src/main/java/com/splatkit/engine/RenderThread.kt index 494f855..3941a95 100644 --- a/packages/splatkit-android/src/main/java/com/splatkit/engine/RenderThread.kt +++ b/packages/splatkit-android/src/main/java/com/splatkit/engine/RenderThread.kt @@ -114,6 +114,7 @@ internal class RenderThread { fun loadWorld(spzBytes: ByteArray) = decode { it.loadWorld(spzBytes) } fun loadCollider(glbBytes: ByteArray) = decode { it.loadCollider(glbBytes) } fun loadWorldFile(path: String) = decode { it.loadWorldFile(path) } + fun loadTiledWorldFile(path: String) = decode { it.loadTiledWorldFile(path) } fun loadColliderFile(path: String) = decode { it.loadColliderFile(path) } // Camera and input. @@ -135,6 +136,7 @@ internal class RenderThread { fun setCullMargin(degrees: Float) = post { engine?.setCullMargin(degrees) } fun setLinearBlending(linear: Boolean) = post { engine?.setLinearBlending(linear) } fun setSplatBudget(budget: Int) = post { engine?.setSplatBudget(budget) } + fun setResidencyBudget(splats: Int) = post { engine?.setResidencyBudget(splats) } fun setMaxShDegree(degree: Int) = post { engine?.setMaxShDegree(degree) } fun setShDegree(degree: Int) = post { engine?.setShDegree(degree) } diff --git a/packages/splatkit-android/src/main/java/com/splatkit/engine/SplatEngine.kt b/packages/splatkit-android/src/main/java/com/splatkit/engine/SplatEngine.kt index 0e2c1ec..1d025ca 100644 --- a/packages/splatkit-android/src/main/java/com/splatkit/engine/SplatEngine.kt +++ b/packages/splatkit-android/src/main/java/com/splatkit/engine/SplatEngine.kt @@ -3,6 +3,8 @@ package com.splatkit.engine import android.view.Surface import com.splatkit.CameraPose import com.splatkit.SplatStats +import com.splatkit.SPLAT_STATS_FLOATS +import com.splatkit.decodeSplatStats /** * The JNI boundary to the C++ engine: one opaque handle owned by native code, one @@ -15,7 +17,7 @@ import com.splatkit.SplatStats internal class SplatEngine { private var handle: Long = nativeCreate() private val poseScratch = FloatArray(POSE_FLOATS) - private val statsScratch = FloatArray(STATS_FLOATS) + private val statsScratch = FloatArray(SPLAT_STATS_FLOATS) /** False when Vulkan could not be brought up; every call is then a no-op. */ val isValid: Boolean get() = handle != 0L @@ -44,6 +46,7 @@ internal class SplatEngine { fun loadWorld(spzBytes: ByteArray) = nativeLoadWorld(handle, spzBytes) fun loadCollider(glbBytes: ByteArray) = nativeLoadCollider(handle, glbBytes) fun loadWorldFile(path: String) = nativeLoadWorldFile(handle, path) + fun loadTiledWorldFile(path: String) = nativeLoadTiledWorldFile(handle, path) fun loadColliderFile(path: String) = nativeLoadColliderFile(handle, path) // Camera and input. @@ -70,6 +73,7 @@ internal class SplatEngine { fun setCullMargin(degrees: Float) = nativeSetCullMargin(handle, degrees) fun setLinearBlending(linear: Boolean) = nativeSetLinearBlending(handle, linear) fun setSplatBudget(budget: Int) = nativeSetSplatBudget(handle, budget) + fun setResidencyBudget(splats: Int) = nativeSetResidencyBudget(handle, splats) fun setMaxShDegree(degree: Int) = nativeSetMaxShDegree(handle, degree) fun setShDegree(degree: Int) = nativeSetShDegree(handle, degree) @@ -86,14 +90,7 @@ internal class SplatEngine { fun readStats(into: SplatStats): SplatStats = synchronized(this) { if (handle == 0L) return into nativeStats(handle, statsScratch) - into.fps = statsScratch[0] - into.frameMillis = statsScratch[1] - into.gpuMillis = statsScratch[2] - into.sortMillis = statsScratch[3] - into.splatCount = statsScratch[4].toInt() - into.walking = statsScratch[5] != 0f - into.motion = statsScratch[6] != 0f - into + decodeSplatStats(statsScratch, into) } // The any-thread readers above take the same lock, so none of them can run on a @@ -114,6 +111,7 @@ internal class SplatEngine { private external fun nativeLoadWorld(handle: Long, spzBytes: ByteArray) private external fun nativeLoadCollider(handle: Long, glbBytes: ByteArray) private external fun nativeLoadWorldFile(handle: Long, path: String) + private external fun nativeLoadTiledWorldFile(handle: Long, path: String) private external fun nativeLoadColliderFile(handle: Long, path: String) private external fun nativeSetCameraPose(handle: Long, x: Float, y: Float, z: Float, yaw: Float, pitch: Float) /** Fills [out] (at least [POSE_FLOATS]) with x, y, z, yaw, pitch. */ @@ -127,15 +125,15 @@ internal class SplatEngine { private external fun nativeSetCullMargin(handle: Long, degrees: Float) private external fun nativeSetLinearBlending(handle: Long, linear: Boolean) private external fun nativeSetSplatBudget(handle: Long, budget: Int) + private external fun nativeSetResidencyBudget(handle: Long, splats: Int) private external fun nativeSetMaxShDegree(handle: Long, degree: Int) private external fun nativeSetShDegree(handle: Long, degree: Int) private external fun nativeStartBenchmark(handle: Long, seconds: Float) - /** Fills [out] (at least [STATS_FLOATS]) with fps, frame ms, gpu ms, sort ms, splats, walking, motion. */ + /** Fills [out] using the [decodeSplatStats] layout ([SPLAT_STATS_FLOATS] floats). */ private external fun nativeStats(handle: Long, out: FloatArray) private companion object { const val POSE_FLOATS = 5 - const val STATS_FLOATS = 7 init { System.loadLibrary("splatkit") diff --git a/packages/splatkit-android/src/main/java/com/splatkit/ui/SplatHudView.kt b/packages/splatkit-android/src/main/java/com/splatkit/ui/SplatHudView.kt index 5b6a90e..ad2156e 100644 --- a/packages/splatkit-android/src/main/java/com/splatkit/ui/SplatHudView.kt +++ b/packages/splatkit-android/src/main/java/com/splatkit/ui/SplatHudView.kt @@ -61,9 +61,9 @@ class SplatHudView @JvmOverloads constructor( } text = String.format( Locale.US, - "%s\n%s\nsort %5.1f ms\n%,d splats %s, %s", + "%s\n%s\nsort %5.1f ms\n%,d drawn / %,d loaded\n%s, %s", view.gpuDescription, frame, stats.sortMillis, - stats.splatCount, mode, input, + stats.drawnSplatCount, stats.loadedSplatCount, mode, input, ) } } diff --git a/packages/splatkit-android/src/test/java/com/splatkit/SplatStatsTest.kt b/packages/splatkit-android/src/test/java/com/splatkit/SplatStatsTest.kt new file mode 100644 index 0000000..c17da07 --- /dev/null +++ b/packages/splatkit-android/src/test/java/com/splatkit/SplatStatsTest.kt @@ -0,0 +1,73 @@ +package com.splatkit + +import org.junit.Assert.* +import org.junit.Test + +class SplatStatsTest { + @Test fun decodesLegacyPrefixAndAppendedCompletedCounts() { + val snapshot = SplatStats() + val result = decodeSplatStats( + floatArrayOf(60f, 16f, 7f, 3f, 500000f, 1f, 0f, 123f, 40f, 12f, 8f), snapshot + ) + assertSame(snapshot, result) + assertEquals(60f, result.fps, 0f) + assertEquals(16f, result.frameMillis, 0f) + assertEquals(7f, result.gpuMillis, 0f) + assertEquals(3f, result.sortMillis, 0f) + assertEquals(500000, result.splatCount) + assertEquals(500000, result.loadedSplatCount) + assertTrue(result.walking) + assertFalse(result.motion) + assertEquals(123, result.drawnSplatCount) + assertEquals(40, result.computeTileCount) + assertEquals(12, result.nonemptyComputeTileCount) + assertEquals(8, result.hardwareTileCount) + } + + @Test fun zeroCompletedCountsNeverFallBackToLoadedCount() { + val snapshot = SplatStats().apply { + drawnSplatCount = 99 + computeTileCount = 5 + nonemptyComputeTileCount = 4 + hardwareTileCount = 3 + } + decodeSplatStats(floatArrayOf(0f, 0f, 0f, 0f, 2000000f, 0f, 1f, 0f, 0f, 0f, 0f), snapshot) + assertEquals(2000000, snapshot.loadedSplatCount) + assertEquals(0, snapshot.drawnSplatCount) + assertEquals(0, snapshot.computeTileCount) + assertEquals(0, snapshot.nonemptyComputeTileCount) + assertEquals(0, snapshot.hardwareTileCount) + assertFalse(snapshot.walking) + assertTrue(snapshot.motion) + decodeSplatStats(FloatArray(SPLAT_STATS_FLOATS), snapshot) + assertEquals(0, snapshot.loadedSplatCount) + } + + @Test fun sourceAliasDoesNotDriftOrChangeDrawnCount() { + val snapshot = SplatStats().apply { drawnSplatCount = 3 } + snapshot.splatCount = 12 + assertEquals(12, snapshot.loadedSplatCount) + snapshot.loadedSplatCount = 24 + assertEquals(24, snapshot.splatCount) + assertEquals(3, snapshot.drawnSplatCount) + } + + @Test fun countsRetainFloatTransportPrecisionLimit() { + val payload = FloatArray(SPLAT_STATS_FLOATS) + payload[4] = 16777216f + payload[7] = 16777217.toFloat() + val result = decodeSplatStats(payload, SplatStats()) + assertEquals(16777216, result.loadedSplatCount) + assertEquals(16777216, result.drawnSplatCount) // rounding already happened before decoding + } + + @Test fun shortPayloadIsRejectedBeforeChangingSnapshot() { + val snapshot = SplatStats().apply { splatCount = 77 } + try { + decodeSplatStats(FloatArray(7), snapshot) + fail("Expected short-payload rejection") + } catch (_: IllegalArgumentException) { + assertEquals(77, snapshot.loadedSplatCount) + } + } +} diff --git a/packages/splatkit-engine/CMakeLists.txt b/packages/splatkit-engine/CMakeLists.txt new file mode 100644 index 0000000..29b339e --- /dev/null +++ b/packages/splatkit-engine/CMakeLists.txt @@ -0,0 +1,38 @@ +cmake_minimum_required(VERSION 3.22) +project(splatkit_engine LANGUAGES C CXX VERSION 0.1.0) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +option(SPLATKIT_ENGINE_BUILD_TESTS "Build splatkit_engine unit tests" ${PROJECT_IS_TOP_LEVEL}) + +# The engine sits on splat-core; a platform package that already added it skips this. +if(NOT TARGET splat_core) + set(SPLAT_CORE_BUILD_TESTS ${SPLATKIT_ENGINE_BUILD_TESTS} CACHE BOOL "" FORCE) + set(SPLAT_CORE_BUILD_TOOLS OFF CACHE BOOL "" FORCE) + add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../splat-core splat-core) +endif() + +# Everything of the engine that has no graphics API in it: the frame, the camera, the +# diagnostics and the GPU record layout. A platform supplies a SplatRenderer and a log +# sink and gets the whole engine. +add_library(splatkit_engine STATIC + src/Log.cpp + src/rendering/GpuLayout.cpp + src/camera/WalkCamera.cpp + src/diagnostics/Benchmark.cpp + src/diagnostics/StatsPublisher.cpp + src/engine/SplatEngine.cpp +) +target_include_directories(splatkit_engine PUBLIC include) +target_link_libraries(splatkit_engine PUBLIC splat_core) +target_compile_options(splatkit_engine PRIVATE + $<$:-Wall -Wextra -Wpedantic -Werror> +) + +if(SPLATKIT_ENGINE_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/packages/splatkit-engine/include/splatkit/Log.h b/packages/splatkit-engine/include/splatkit/Log.h new file mode 100644 index 0000000..001a4f9 --- /dev/null +++ b/packages/splatkit-engine/include/splatkit/Log.h @@ -0,0 +1,19 @@ +#pragma once + +// The engine's log. Each platform installs a sink once (logcat, os_log); until then +// lines go to stderr, which is what the desktop tests and tools want. +namespace splatkit { + +enum class LogLevel { info, warn, error }; + +using LogSink = void (*)(LogLevel level, const char* message); +void setLogSink(LogSink sink); + +// printf style, one line per call. +void logf(LogLevel level, const char* format, ...) __attribute__((format(printf, 2, 3))); + +} // namespace splatkit + +#define LOGI(...) ::splatkit::logf(::splatkit::LogLevel::info, __VA_ARGS__) +#define LOGW(...) ::splatkit::logf(::splatkit::LogLevel::warn, __VA_ARGS__) +#define LOGE(...) ::splatkit::logf(::splatkit::LogLevel::error, __VA_ARGS__) diff --git a/packages/splatkit-android/src/main/cpp/camera/WalkCamera.h b/packages/splatkit-engine/include/splatkit/camera/WalkCamera.h similarity index 77% rename from packages/splatkit-android/src/main/cpp/camera/WalkCamera.h rename to packages/splatkit-engine/include/splatkit/camera/WalkCamera.h index f4f82a6..61f9ecd 100644 --- a/packages/splatkit-android/src/main/cpp/camera/WalkCamera.h +++ b/packages/splatkit-engine/include/splatkit/camera/WalkCamera.h @@ -23,10 +23,15 @@ class WalkCamera { void setCollider(std::unique_ptr collider); bool hasCollider() const { return collider_ != nullptr; } - // Touch: radians. Pitch is clamped and ignored while motion is on. + // Touch: radians. Yaw/pitch poses clamp pitch; look-at poses turn in screen axes. + // Pitch input is ignored while motion is on. void look(float deltaYaw, float deltaPitch); // Absolute orientation in radians, for reproducible captures and benchmarks. void setOrientation(float yaw, float pitch); + // Scripted paths: from `position`, look at `target` with `up` at the top of the frame. + // Any roll goes; the view stays continuous through the poles yaw and pitch cannot pass. + // Touch retains this basis with motion off; setOrientation restores yaw/pitch mode. + void setLookAt(splat::Vec3 position, splat::Vec3 target, splat::Vec3 up); float yaw() const { return yaw_; } float pitch() const { return pitch_; } // Teleport. When walking, the next update snaps to the floor under the new point. @@ -58,6 +63,8 @@ class WalkCamera { float velocityRight_ = 0; bool motion_ = false; splat::Mat4 attitude_ = splat::Mat4::identity(); + bool scripted_ = false; // rotation() uses the full look-at basis, including touch turns + splat::Mat4 scriptedRotation_ = splat::Mat4::identity(); splat::Mat4 referenceToWorld_; }; diff --git a/packages/splatkit-android/src/main/cpp/diagnostics/Benchmark.h b/packages/splatkit-engine/include/splatkit/diagnostics/Benchmark.h similarity index 100% rename from packages/splatkit-android/src/main/cpp/diagnostics/Benchmark.h rename to packages/splatkit-engine/include/splatkit/diagnostics/Benchmark.h diff --git a/packages/splatkit-android/src/main/cpp/diagnostics/StatsPublisher.h b/packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h similarity index 82% rename from packages/splatkit-android/src/main/cpp/diagnostics/StatsPublisher.h rename to packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h index c86405f..173b252 100644 --- a/packages/splatkit-android/src/main/cpp/diagnostics/StatsPublisher.h +++ b/packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h @@ -18,6 +18,10 @@ struct Stats { uint32_t splatCount = 0; bool walking = false; bool motion = false; + uint32_t drawnSplatCount = 0; // last completed visibility/order result, not source count + uint32_t computeTileCount = 0; // includes background-only screen tiles + uint32_t nonemptyComputeTileCount = 0; + uint32_t hardwareTileCount = 0; }; // Position in the world's frame (meters) and yaw and pitch in radians, yaw about the up @@ -48,6 +52,9 @@ class StatsPublisher { uint32_t gpuSplats = 0; // records on the GPU, more than the file with a tree bool walking = false; bool motion = false; + uint32_t computeTiles = 0; + uint32_t nonemptyComputeTiles = 0; + uint32_t hardwareTiles = 0; }; // Once per vsync, drawn or not. `sample` is called when the window closes. @@ -68,6 +75,10 @@ class StatsPublisher { std::atomic gpuMillis_{0}; std::atomic sortMillis_{0}; std::atomic splats_{0}; + std::atomic drawnSplats_{0}; + std::atomic computeTiles_{0}; + std::atomic nonemptyComputeTiles_{0}; + std::atomic hardwareTiles_{0}; std::atomic walking_{false}; std::atomic motion_{false}; std::atomic pose_[5]{}; diff --git a/packages/splatkit-android/src/main/cpp/engine/SplatEngine.h b/packages/splatkit-engine/include/splatkit/engine/SplatEngine.h similarity index 69% rename from packages/splatkit-android/src/main/cpp/engine/SplatEngine.h rename to packages/splatkit-engine/include/splatkit/engine/SplatEngine.h index 94576db..ccf3bf2 100644 --- a/packages/splatkit-android/src/main/cpp/engine/SplatEngine.h +++ b/packages/splatkit-engine/include/splatkit/engine/SplatEngine.h @@ -9,47 +9,42 @@ #include #include -#include -#include - -#include "camera/WalkCamera.h" -#include "diagnostics/Benchmark.h" -#include "diagnostics/StatsPublisher.h" -#include "rendering/vulkan/FrameLoop.h" -#include "rendering/vulkan/VulkanContext.h" -#include "rendering/vulkan/VulkanSplatRenderer.h" #include "splat/core/Result.h" #include "splat/loading/SplatWorldLoader.h" #include "splat/math/Mat4.h" #include "splat/sorting/AsyncSorter.h" #include "splat/sorting/VisibilityPlanner.h" +#include "splat/tiles/TileStreamer.h" +#include "splatkit/camera/WalkCamera.h" +#include "splatkit/diagnostics/Benchmark.h" +#include "splatkit/diagnostics/StatsPublisher.h" +#include "splatkit/rendering/SplatRenderer.h" namespace splatkit { -// The native engine behind one SplatSurfaceView. It owns the loader, the camera, the -// sorter and the renderer and runs them once per vsync: a frame steps the camera, asks +// The native engine behind one view. It owns the loader, the camera, the sorter and +// the platform's renderer and runs them once per vsync: a frame steps the camera, asks // the sorter for the visible set when the view changed enough, and draws only when // something visible changed, so a still scene costs no GPU time. // // Rendering, input and settings run on the render thread. Loading may run on any // thread: it decodes there and leaves the result for the render thread to upload. -// Survives losing and regaining the surface. +// The surface belongs to the renderer: the engine survives losing and regaining it. class SplatEngine { public: using Stats = splatkit::Stats; using CameraPose = splatkit::CameraPose; - static splat::Result> create(); + explicit SplatEngine(std::unique_ptr renderer); ~SplatEngine(); SplatEngine(const SplatEngine&) = delete; SplatEngine& operator=(const SplatEngine&) = delete; - // A new window (takes a reference) or nullptr when the surface is going away. - void setWindow(ANativeWindow* window); + // The platform's renderer, for the calls only its view makes: attaching a surface. + SplatRenderer& renderer() { return *renderer_; } + void render(int64_t frameTimeNanos); - // The window changed size while staying attached. Rebuilds the swapchain if needed. - void onSurfaceResized(uint32_t width, uint32_t height); // Decodes an SPZ world. Thread safe. Errors are reported and leave the current world. void loadWorld(const std::uint8_t* data, std::size_t size); @@ -58,9 +53,15 @@ class SplatEngine { // The same from a file, mapped rather than copied through the host's heap. void loadWorldFile(const std::string& path); void loadColliderFile(const std::string& path); + // A tiled world from its index file; tiles stream in as the camera needs them. Thread + // safe. Errors are reported and leave the current world. + void loadTiledWorldFile(const std::string& path); // Set on the render thread; read from any thread, refreshed every frame. void setCameraPose(const CameraPose& pose); + // Scripted camera: from `position` looking at `target` with `up` at the top of the + // frame, whatever the roll. Teleports like setCameraPose. + void setCameraLookAt(splat::Vec3 position, splat::Vec3 target, splat::Vec3 up); CameraPose cameraPose() const { return stats_.pose(); } // What the host needs to know about loading. Ready events fire on the render thread @@ -96,9 +97,16 @@ class SplatEngine { // a pixel each, nearest in full detail. Applies to worlds loaded after it is set. void setSplatBudget(int budget) { loader_.setBudget(budget); } - // Highest spherical harmonics degree uploaded with the next world, 0 to 3. Degree 3 - // adds 92 bytes per splat; 0 keeps the base colour only. Any thread. - void setMaxShDegree(int degree) { maxShDegree_ = std::clamp(degree, 0, kMaxShDegree); } + // Residency budget of a tiled world: the most splats held on the GPU at once, which is + // what streaming fills nearest first and evicts against. Applies to tiled worlds + // loaded after it is set. Any thread. + void setResidencyBudget(int splats) { + residency_.store(static_cast(std::clamp(splats, kMinResidency, kMaxResidency))); + } + + // Highest spherical harmonics degree decoded and uploaded with the next world, 0 to 3. + // Degree 3 adds 92 bytes per splat on the GPU; 0 keeps the base colour only. Any thread. + void setMaxShDegree(int degree); // Spherical harmonics degree drawn, 0 to 3, capped by what the loaded world carries. // Takes effect on the next frame: a quality change never needs a reload. Render thread. @@ -111,16 +119,22 @@ class SplatEngine { void setMotionEnabled(bool enabled) { camera_.setMotionEnabled(enabled); } void setVelocity(float forward, float right) { camera_.setVelocity(forward, right); } + // Draws the next frame even when nothing changed, for a renderer that has something + // to do with it, such as a capture. + void requestRedraw() { redrawNeeded_ = true; } + // Readable from any thread. Refreshed twice a second by the render loop. Stats stats() const { return stats_.stats(); } // Runs a reproducible capture: gyroscope off, a fixed pose, one full yaw turn over // `seconds`, then logs the frame time distribution. Waits for a world if none is up. void startBenchmark(float seconds); - const std::string& gpuDescription() const { return ctx_->deviceDescription(); } + const std::string& gpuDescription() const { return renderer_->deviceDescription(); } private: static constexpr int kMaxShDegree = 3; + static constexpr int kMinResidency = 100000; + static constexpr int kMaxResidency = 32000000; // The camera as the frame sees it: matrices for the draw, axes for the cull. struct FrameCamera { @@ -129,7 +143,6 @@ class SplatEngine { splat::VisibilityPlanner::View axes; }; - SplatEngine() = default; void emit(Event event, const std::string& message = {}, uint32_t splatCount = 0) const { if (events_) events_(event, message, splatCount); } @@ -137,17 +150,17 @@ class SplatEngine { void reportCollider(const splat::Result& report); bool applyPendingLoads(); float frameSeconds(int64_t frameTimeNanos); - void driveBenchmark(float dt, const GpuWorld& world); - FrameCamera frameCamera(VkExtent2D extent) const; + void driveBenchmark(float dt, const GpuWorldInfo& world); + FrameCamera frameCamera(Extent extent) const; void publishPose(); - void requestVisible(const FrameCamera& camera, float dt, VkExtent2D extent); + void requestVisible(const FrameCamera& camera, float dt, Extent extent); + void streamTiles(const FrameCamera& camera, float pixelScale, + const std::optional& requested); void takeSortResult(); StatsPublisher::Sample sample() const; EventSink events_; - std::unique_ptr ctx_; - std::unique_ptr frameLoop_; - std::unique_ptr renderer_; // after ctx_ and frameLoop_: dies first + std::unique_ptr renderer_; splat::SplatWorldLoader loader_; WalkCamera camera_; splat::VisibilityPlanner planner_; @@ -155,13 +168,18 @@ class SplatEngine { StatsPublisher stats_; std::atomic maxShDegree_{kMaxShDegree}; + std::atomic residency_{2000000}; int shDegree_ = kMaxShDegree; - // Render thread from here on. + // Render thread from here on. One of the two is up with a world: the sorter for a + // single file world, the streamer for a tiled one. std::unique_ptr sorter_; + std::unique_ptr streamer_; int loadedBudget_ = 0; // the budget of the world on the GPU, 0 without a tree uint32_t sourceCount_ = 0; // splats in the loaded file, what hosts and the HUD count uint32_t drawCount_ = 0; // entries of the order buffer to draw: the visible splats - std::optional pendingOrder_; // sorted, waiting for a frame + std::optional> pendingOrder_; // sorted, waiting for a frame + bool gpuSort_ = false; // the renderer orders the ranges of the loaded world itself + std::vector ranges_; // what the GPU sort draws this frame struct SortTimings { double sortMillis = 0; double cullMillis = 0; diff --git a/packages/splatkit-engine/include/splatkit/rendering/GpuLayout.h b/packages/splatkit-engine/include/splatkit/rendering/GpuLayout.h new file mode 100644 index 0000000..03900f2 --- /dev/null +++ b/packages/splatkit-engine/include/splatkit/rendering/GpuLayout.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include + +#include "splat/formats/SplatCloud.h" + +namespace splatkit { + +// The record every renderer's vertex stage reads (32 bytes, the same on both APIs). +// Vertex fetch is the floor of the frame on Adreno 640 (8 ms for 500k splats at 48 +// bytes), so the record is as small as the source data allows: SPZ stores colour and +// alpha as 8 bits, and the covariance keeps 11 bits of mantissa as half floats. +struct GpuSplat { + float position[3]; + uint32_t rgba8; // colour and alpha, a real uint: never routed through a float, whose + // NaN patterns some mobile compilers canonicalise + uint32_t cov[3]; // six halves: (xx, xy), (xz, yy), (yz, zz) + uint32_t lodAlpha; // float bits of an opacity above 1 (level of detail nodes), else 0 +}; +static_assert(sizeof(GpuSplat) == 32, "GpuSplat must match the shader struct"); + +// Uints per splat of the harmonics buffer at a degree: the halves of bands 1 to +// `degree`, channel fastest, two per uint, each splat starting on a uint. +std::size_t shStride(int degree); + +// True when the cloud carries harmonics up to `degree` for every splat. +bool carriesSh(const splat::SplatCloud& cloud, int degree); + +// Bands 1 to `degree` of every splat, `shStride(degree)` uints each. The cloud must +// carry at least that degree. +std::vector packSh(const splat::SplatCloud& cloud, int degree); + +// Every splat of the cloud in the GPU layout. +std::vector packSplats(const splat::SplatCloud& cloud); +// Bounded staging for large resident worlds. Caller validates the range and capacity. +void packSplatRange(const splat::SplatCloud& cloud, size_t offset, size_t count, GpuSplat* out); +void packShRange(const splat::SplatCloud& cloud, int degree, size_t offset, size_t count, + uint32_t* out); + +} // namespace splatkit diff --git a/packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h b/packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h new file mode 100644 index 0000000..155e532 --- /dev/null +++ b/packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h @@ -0,0 +1,119 @@ +#pragma once + +#include +#include +#include + +#include "splat/formats/SplatCloud.h" +#include "splat/lod/LodTree.h" +#include "splat/math/Mat4.h" +#include "splat/math/Vec3.h" + +namespace splatkit { + +struct Extent { + uint32_t width = 0; + uint32_t height = 0; +}; + +// The world the renderer holds: `count` records, harmonics up to `shDegree`. A single +// file world has exactly its splats; a slab has its capacity, filled by tiles. +struct GpuWorldInfo { + uint32_t count = 0; + int shDegree = 0; +}; + +// Screen-tile ownership from the last completed hybrid frame. All zero when +// unavailable. Compute includes background tiles; nonemptyCompute excludes them. +struct ScreenTileStats { + uint32_t compute = 0; + uint32_t nonemptyCompute = 0; + uint32_t hardware = 0; +}; + +// What the engine needs from a platform's graphics API: a surface it can draw the +// world on, a world it can upload whole or by tiles into a slab, and a frame drawn from +// an order the sorter wrote. Vulkan on Android, Metal on iOS. Render thread only. +// +// Attaching and resizing the surface are platform calls made by the platform's own +// view code, so they are not part of this interface. +class SplatRenderer { + public: + virtual ~SplatRenderer() = default; + + // Fraction of the surface resolution the splats are drawn at, [0.1, 2]. Away from one + // the frame is drawn offscreen and rescaled with a linear blit. + virtual void setRenderScale(float scale) = 0; + virtual float renderScale() const = 0; + // Blend in linear light instead of the encoded space. + virtual void setLinearBlending(bool linear) = 0; + virtual bool linearBlending() const = 0; + // Off, frame times stop being multiples of the vsync, which benchmarks need. + virtual void setVsync(bool vsync) = 0; + + // True when a surface is up: frames can be drawn and worlds uploaded. + virtual bool ready() const = 0; + // Where the splats are drawn: the surface's size with the render scale applied. + virtual Extent drawExtent() const = 0; + // Counts the rebuilds of the surface's images. A frame drawn before one is gone. + virtual uint32_t generation() const = 0; + + // Uploads a world and draws it from now on. Fails, keeping the previous world, when + // the upload does. + virtual bool uploadWorld(const splat::SplatCloud& cloud, int maxShDegree) = 0; + // Optional native GPU hierarchy selection. Unsupported renderers retain CPU LOD. + virtual bool selectsLodOnGpu() const { return false; } + virtual bool uploadLodWorld(const splat::LodTree&, int, uint32_t) { return false; } + // Replaces the world with an empty slab of `capacity` records for a tiled world; + // tiles land in it through `uploadTile`. + virtual bool createSlab(uint32_t capacity, int shDegree) = 0; + // Uploads a tile into records [offset, offset + count) of the slab (blocking). + virtual bool uploadTile(uint32_t offset, const splat::SplatCloud& cloud) = 0; + virtual std::optional world() const = 0; + + // A run of the world's records: what a tile occupies in a slab. + struct Range { + uint32_t offset = 0; + uint32_t count = 0; + }; + // True when the renderer culls and sorts on the GPU: the engine then hands it the + // ranges to draw in every frame instead of an order. + virtual bool sortsOnGpu() const { return false; } + + enum class OrderSource { cpu, gpu }; + + struct Frame { + // Explicit even for an empty frame: a null range pointer is not a CPU fallback. + OrderSource orderSource = OrderSource::cpu; + // A new draw order for the world, copied in before the draw; nullptr keeps the last. + const uint32_t* order = nullptr; + uint32_t orderCount = 0; + uint32_t drawCount = 0; // entries of the order buffer to draw + // For a renderer that sorts on the GPU: the ranges to draw, every frame. The order + // fields are unused then. + const Range* ranges = nullptr; + uint32_t rangeCount = 0; + int shDegree = 0; // capped by what the world carries + splat::Mat4 view = splat::Mat4::identity(); + splat::Mat4 proj = splat::Mat4::identity(); + splat::Vec3 cameraPosition; + }; + // Records and presents one frame. Returns false when nothing was presented, e.g. the + // surface was rebuilt instead; the caller keeps the order for the next frame. + virtual bool draw(const Frame& frame) = 0; + + // GPU time of the most recently completed frame, from timestamps at both ends of it. + // Zero until the first frame completes or if unsupported. + virtual double lastGpuMillis() const = 0; + // GPU time of the last visibility pass, when the renderer sorts on the GPU. + virtual double lastSortMillis() const { return 0; } + // Splats the last frame drew, when the renderer sorts on the GPU. + virtual uint32_t lastDrawCount() const { return 0; } + virtual uint32_t lastSelectedCount() const { return 0; } + virtual double lastSelectMillis() const { return 0; } + virtual ScreenTileStats lastScreenTileStats() const { return {}; } + // GPU name and API version, for a HUD. + virtual const std::string& deviceDescription() const = 0; +}; + +} // namespace splatkit diff --git a/packages/splatkit-engine/src/Log.cpp b/packages/splatkit-engine/src/Log.cpp new file mode 100644 index 0000000..13ccb16 --- /dev/null +++ b/packages/splatkit-engine/src/Log.cpp @@ -0,0 +1,32 @@ +#include "splatkit/Log.h" + +#include +#include +#include + +namespace splatkit { +namespace { + +void stderrSink(LogLevel level, const char* message) { + const char* tag = level == LogLevel::error ? "E" : level == LogLevel::warn ? "W" : "I"; + std::fprintf(stderr, "SplatKit %s: %s\n", tag, message); +} + +std::atomic gSink{&stderrSink}; + +} // namespace + +void setLogSink(LogSink newSink) { + gSink.store(newSink != nullptr ? newSink : &stderrSink); +} + +void logf(LogLevel level, const char* format, ...) { + char line[1024]; + va_list args; + va_start(args, format); + std::vsnprintf(line, sizeof(line), format, args); + va_end(args); + gSink.load()(level, line); +} + +} // namespace splatkit diff --git a/packages/splatkit-android/src/main/cpp/camera/WalkCamera.cpp b/packages/splatkit-engine/src/camera/WalkCamera.cpp similarity index 70% rename from packages/splatkit-android/src/main/cpp/camera/WalkCamera.cpp rename to packages/splatkit-engine/src/camera/WalkCamera.cpp index 8c582f0..f32d491 100644 --- a/packages/splatkit-android/src/main/cpp/camera/WalkCamera.cpp +++ b/packages/splatkit-engine/src/camera/WalkCamera.cpp @@ -1,4 +1,4 @@ -#include "camera/WalkCamera.h" +#include "splatkit/camera/WalkCamera.h" #include #include @@ -29,6 +29,17 @@ void WalkCamera::setCollider(std::unique_ptr collider) { } void WalkCamera::look(float deltaYaw, float deltaPitch) { + if (scripted_ && !motion_) { + // A look-at pose can carry roll or pass a pole. Keep that basis when touch + // takes over, rotating about the screen's up/right instead of snapping to Y-up. + scriptedRotation_ = scriptedRotation_ * splat::Mat4::rotation(deltaYaw, {0, 1, 0}) * + splat::Mat4::rotation(deltaPitch, {1, 0, 0}); + const auto forward = splat::normalize(scriptedRotation_.transformDirection({0, 0, -1})); + yaw_ = std::atan2(-forward.x, -forward.z); + pitch_ = std::asin(std::clamp(forward.y, -1.0f, 1.0f)); + return; + } + scripted_ = false; yaw_ += deltaYaw; if (!motion_) pitch_ = std::clamp(pitch_ + deltaPitch, -kMaxPitch, kMaxPitch); } @@ -74,10 +85,31 @@ void WalkCamera::setPosition(splat::Vec3 position) { } void WalkCamera::setOrientation(float yaw, float pitch) { + scripted_ = false; yaw_ = yaw; pitch_ = std::clamp(pitch, -kMaxPitch, kMaxPitch); } +void WalkCamera::setLookAt(splat::Vec3 position, splat::Vec3 target, splat::Vec3 up) { + setPosition(position); + const splat::Vec3 forward = splat::normalize(target - position); + splat::Vec3 right = splat::cross(forward, up); + if (splat::length(right) < 1e-6f) right = splat::cross(forward, {0, 0, 1}); + right = splat::normalize(right); + const splat::Vec3 top = splat::cross(right, forward); + splat::Mat4 r = splat::Mat4::identity(); + for (int i = 0; i < 3; ++i) { + r.at(i, 0) = (&right.x)[i]; + r.at(i, 1) = (&top.x)[i]; + r.at(i, 2) = -(&forward.x)[i]; + } + scriptedRotation_ = r; + scripted_ = true; + // Yaw and pitch keep describing the view for whoever reads them. + yaw_ = std::atan2(-forward.x, -forward.z); + pitch_ = std::asin(std::clamp(forward.y, -1.0f, 1.0f)); +} + void WalkCamera::setVelocity(float forward, float right) { velocityForward_ = forward; velocityRight_ = right; @@ -107,6 +139,7 @@ splat::Vec3 WalkCamera::position() const { } splat::Mat4 WalkCamera::rotation() const { + if (scripted_) return scriptedRotation_; const splat::Mat4 yaw = splat::Mat4::rotation(yaw_, {0, 1, 0}); if (motion_) return yaw * referenceToWorld_ * attitude_; return yaw * splat::Mat4::rotation(pitch_, {1, 0, 0}); diff --git a/packages/splatkit-android/src/main/cpp/diagnostics/Benchmark.cpp b/packages/splatkit-engine/src/diagnostics/Benchmark.cpp similarity index 97% rename from packages/splatkit-android/src/main/cpp/diagnostics/Benchmark.cpp rename to packages/splatkit-engine/src/diagnostics/Benchmark.cpp index d1696c2..4a0df6a 100644 --- a/packages/splatkit-android/src/main/cpp/diagnostics/Benchmark.cpp +++ b/packages/splatkit-engine/src/diagnostics/Benchmark.cpp @@ -1,12 +1,12 @@ -#include "diagnostics/Benchmark.h" +#include "splatkit/diagnostics/Benchmark.h" #include #include #include #include -#include "Log.h" #include "splat/diagnostics/TimingSummary.h" +#include "splatkit/Log.h" namespace splatkit { namespace { diff --git a/packages/splatkit-android/src/main/cpp/diagnostics/StatsPublisher.cpp b/packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp similarity index 83% rename from packages/splatkit-android/src/main/cpp/diagnostics/StatsPublisher.cpp rename to packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp index f6f63b8..f6c6cc9 100644 --- a/packages/splatkit-android/src/main/cpp/diagnostics/StatsPublisher.cpp +++ b/packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp @@ -1,6 +1,6 @@ -#include "diagnostics/StatsPublisher.h" +#include "splatkit/diagnostics/StatsPublisher.h" -#include "Log.h" +#include "splatkit/Log.h" namespace splatkit { namespace { @@ -25,6 +25,10 @@ void StatsPublisher::onFrame(int64_t frameTimeNanos, bool rendered, gpuMillis_.store(static_cast(s.gpuMillis), kRelaxed); sortMillis_.store(static_cast(s.sortMillis), kRelaxed); splats_.store(s.sourceSplats, kRelaxed); + drawnSplats_.store(s.drawn, kRelaxed); + computeTiles_.store(s.computeTiles, kRelaxed); + nonemptyComputeTiles_.store(s.nonemptyComputeTiles, kRelaxed); + hardwareTiles_.store(s.hardwareTiles, kRelaxed); walking_.store(s.walking, kRelaxed); motion_.store(s.motion, kRelaxed); @@ -59,6 +63,10 @@ Stats StatsPublisher::stats() const { s.gpuMillis = gpuMillis_.load(kRelaxed); s.sortMillis = sortMillis_.load(kRelaxed); s.splatCount = splats_.load(kRelaxed); + s.drawnSplatCount = drawnSplats_.load(kRelaxed); + s.computeTileCount = computeTiles_.load(kRelaxed); + s.nonemptyComputeTileCount = nonemptyComputeTiles_.load(kRelaxed); + s.hardwareTileCount = hardwareTiles_.load(kRelaxed); s.walking = walking_.load(kRelaxed); s.motion = motion_.load(kRelaxed); return s; diff --git a/packages/splatkit-android/src/main/cpp/engine/SplatEngine.cpp b/packages/splatkit-engine/src/engine/SplatEngine.cpp similarity index 51% rename from packages/splatkit-android/src/main/cpp/engine/SplatEngine.cpp rename to packages/splatkit-engine/src/engine/SplatEngine.cpp index fada57f..bb40591 100644 --- a/packages/splatkit-android/src/main/cpp/engine/SplatEngine.cpp +++ b/packages/splatkit-engine/src/engine/SplatEngine.cpp @@ -1,11 +1,12 @@ -#include "engine/SplatEngine.h" +#include "splatkit/engine/SplatEngine.h" #include #include #include -#include "Log.h" #include "splat/math/Frustum.h" +#include "splat/tiles/TileStreamer.h" +#include "splatkit/Log.h" namespace splatkit { namespace { @@ -15,6 +16,8 @@ constexpr float kNearPlane = 0.05f; constexpr float kFarPlane = 200.0f; // A frame longer than this (a stall, a resume) steps the camera as if it were this long. constexpr float kMaxFrameSeconds = 0.1f; +// How many pixels the splats a tile hides may cover before the tiles below are wanted. +constexpr float kTilePixels = 1.0f; using Clock = std::chrono::steady_clock; @@ -24,31 +27,21 @@ double millisSince(Clock::time_point start) { } // namespace -splat::Result> SplatEngine::create() { - std::unique_ptr engine(new SplatEngine()); - auto ctx = VulkanContext::create(); - if (!ctx) return ctx.error(); - engine->ctx_ = std::move(ctx.value()); - engine->frameLoop_ = std::make_unique(*engine->ctx_); - if (!engine->frameLoop_->valid()) { - return splat::Error{splat::ErrorCode::gpuUnavailable, "frame loop"}; - } - engine->renderer_ = std::make_unique(*engine->ctx_, *engine->frameLoop_); - return engine; +SplatEngine::SplatEngine(std::unique_ptr renderer) + : renderer_(std::move(renderer)) {} + +void SplatEngine::setMaxShDegree(int degree) { + degree = std::clamp(degree, 0, kMaxShDegree); + maxShDegree_ = degree; + loader_.setMaxShDegree(degree); } +// The renderer goes first: it waits for the GPU, which may still read an order the +// sorter or the streamer own. SplatEngine::~SplatEngine() { renderer_.reset(); sorter_.reset(); - if (ctx_) ctx_->waitIdle(); -} - -void SplatEngine::setWindow(ANativeWindow* window) { - renderer_->setWindow(window); -} - -void SplatEngine::onSurfaceResized(uint32_t width, uint32_t height) { - renderer_->onSurfaceResized(width, height); + streamer_.reset(); } void SplatEngine::setShDegree(int degree) { @@ -68,6 +61,10 @@ void SplatEngine::loadWorldFile(const std::string& path) { reportWorld(loader_.loadWorldFile(path)); } +void SplatEngine::loadTiledWorldFile(const std::string& path) { + reportWorld(loader_.loadTiledWorldFile(path)); +} + void SplatEngine::loadCollider(const std::uint8_t* data, std::size_t size) { reportCollider(loader_.loadCollider(data, size)); } @@ -89,6 +86,7 @@ void SplatEngine::reportWorld(const splat::Result 0) LOGI("tiled world: %zu tiles", r.tileCount); } void SplatEngine::reportCollider( @@ -112,20 +110,48 @@ bool SplatEngine::applyPendingLoads() { if (!world) return false; const auto start = Clock::now(); - if (!renderer_->uploadWorld(world->splats(), maxShDegree_.load())) { - LOGE("world upload failed"); - emit(Event::worldFailed, "GPU upload failed"); - return false; + if (world->tiles) { + const splat::Tileset& set = *world->tiles->tileset; + const uint32_t residency = residency_.load(); + if (!renderer_->createSlab(residency, std::min(set.shDegree, maxShDegree_.load()))) { + LOGE("slab of %u splats failed", residency); + emit(Event::worldFailed, "GPU upload failed"); + return false; + } + splat::StreamOptions options; + options.residency = residency; + options.loaderThreads = 2; + // A renderer that sorts on the GPU takes the ranges of the tiles to draw each frame. + gpuSort_ = renderer_->sortsOnGpu(); + options.cpuSort = !gpuSort_; + sorter_.reset(); + streamer_ = std::make_unique(std::move(*world->tiles), options); + } else { + const bool gpuLod = world->tree && renderer_->selectsLodOnGpu(); + const bool uploaded = gpuLod ? renderer_->uploadLodWorld(*world->tree, maxShDegree_.load(), + static_cast(world->budget)) + : renderer_->uploadWorld(world->splats(), maxShDegree_.load()); + if (!uploaded) { + LOGE("world upload failed"); + emit(Event::worldFailed, "GPU upload failed"); + return false; + } + // The sorter keeps the positions, or the tree, whose attributes are already on the + // GPU. A renderer that sorts on the GPU takes the whole world as one range instead; + // a tree uses CPU selection only on renderers without native GPU LOD support. + streamer_.reset(); + gpuSort_ = renderer_->sortsOnGpu() && (!world->tree || gpuLod); + sorter_ = gpuSort_ ? nullptr + : world->tree + ? std::make_unique(world->tree) + : std::make_unique(std::move(world->cloud->positions)); } - // The sorter keeps the positions, or the tree, whose attributes are already on the GPU. - sorter_ = world->tree ? std::make_unique(world->tree) - : std::make_unique(std::move(world->cloud->positions)); sourceCount_ = static_cast(world->sourceCount); loadedBudget_ = world->budget; planner_.invalidate(); pendingOrder_.reset(); // an order for the old world indexes past a smaller new one drawCount_ = 0; // the first frustum sort decides what is visible - const GpuWorld& gpu = *renderer_->world(); + const GpuWorldInfo gpu = renderer_->world().value_or(GpuWorldInfo{}); LOGI("uploaded %u splats in %.0f ms, sh degree %d", gpu.count, millisSince(start), gpu.shDegree); emit(Event::worldReady, {}, sourceCount_); return true; @@ -143,11 +169,18 @@ void SplatEngine::setCameraPose(const CameraPose& pose) { publishPose(); } +void SplatEngine::setCameraLookAt(splat::Vec3 position, splat::Vec3 target, splat::Vec3 up) { + camera_.setLookAt(position, target, up); + planner_.invalidate(); + redrawNeeded_ = true; + publishPose(); +} + void SplatEngine::publishPose() { stats_.publishPose(camera_.position(), camera_.yaw(), camera_.pitch()); } -SplatEngine::FrameCamera SplatEngine::frameCamera(VkExtent2D extent) const { +SplatEngine::FrameCamera SplatEngine::frameCamera(Extent extent) const { FrameCamera c; c.view = camera_.viewMatrix(); const float aspect = static_cast(extent.width) / static_cast(extent.height); @@ -163,25 +196,76 @@ SplatEngine::FrameCamera SplatEngine::frameCamera(VkExtent2D extent) const { // Visibility: only the splats inside a widened frustum reach the GPU, which pays per // splat it processes. The planner says when the view changed enough to ask again. -void SplatEngine::requestVisible(const FrameCamera& camera, float dt, VkExtent2D extent) { +void SplatEngine::requestVisible(const FrameCamera& camera, float dt, Extent extent) { auto frustum = planner_.update(camera.axes, dt, lastSort_.cullMillis); - if (!frustum) return; + // A pixel at unit depth: what a node or a tile may cover on screen before it is refined. + const float pixelScale = 2.0f / (camera.proj.at(1, 1) * static_cast(extent.height)); + if (streamer_) { + streamTiles(camera, pixelScale, frustum); + return; + } + if (!frustum || !sorter_) return; splat::LodSettings lod; lod.budget = static_cast(loadedBudget_); - // A pixel at unit depth: what a node may cover on screen before it is refined. - lod.pixelScaleLimit = 2.0f / (camera.proj.at(1, 1) * static_cast(extent.height)); + lod.pixelScaleLimit = pixelScale; lod.view.forward = camera.axes.forward; sorter_->requestVisible(*frustum, lod); } +// Streaming runs every frame: the scheduler plans for the view, tiles that arrived are +// uploaded into their slab ranges, and a new order is asked for when the view changed +// enough or the set of tiles drawn did. +void SplatEngine::streamTiles(const FrameCamera& camera, float pixelScale, + const std::optional& requested) { + splat::TileView view; + view.frustum = requested ? *requested + : splat::Frustum::make(camera.axes.position, camera.axes.forward, + camera.axes.up, camera.axes.tanHalfX, + camera.axes.tanHalfY, planner_.marginRadians()); + view.pixelScaleLimit = pixelScale * kTilePixels; + const splat::TileStreamer::Step step = streamer_->update(view); + for (const auto& arrival : step.arrived) { + if (renderer_->uploadTile(arrival.offset, *arrival.cloud)) { + streamer_->commit(arrival.tile); + } else { + LOGE("tile %u upload failed", arrival.tile); + streamer_->fail(arrival.tile); + } + } + for (const uint32_t tile : step.failed) LOGE("tile %u could not be read", tile); + if (gpuSort_) { + if (step.drawChanged) redrawNeeded_ = true; + return; + } + if (requested || step.drawChanged) streamer_->requestVisible(view.frustum); +} + void SplatEngine::takeSortResult() { + if (gpuSort_) { + lastSort_.sortMillis = renderer_->lastSortMillis(); + lastSort_.cullMillis = 0; + lastSort_.selectMillis = renderer_->lastSelectMillis(); + lastSort_.selected = streamer_ ? streamer_->drawnSplats() : sourceCount_; + drawCount_ = renderer_->lastDrawCount(); + return; + } + if (streamer_) { + auto sorted = streamer_->take(); + if (!sorted) return; + lastSort_.sortMillis = sorted->sortMillis; + lastSort_.cullMillis = sorted->cullMillis; + lastSort_.selectMillis = 0; + lastSort_.selected = sorted->sorted; + pendingOrder_ = std::move(sorted->order); + return; + } auto sorted = sorter_->take(); if (!sorted) return; lastSort_.sortMillis = sorted->sortMillis; lastSort_.cullMillis = sorted->cullMillis; lastSort_.selectMillis = sorted->selectMillis; lastSort_.selected = sorted->selected; - pendingOrder_ = std::move(*sorted); + pendingOrder_ = std::move(sorted->order); } // Benchmark and stats. @@ -191,27 +275,32 @@ void SplatEngine::startBenchmark(float seconds) { renderer_->setVsync(false); // so frame times are not vsync multiples } -void SplatEngine::driveBenchmark(float dt, const GpuWorld& world) { +void SplatEngine::driveBenchmark(float dt, const GpuWorldInfo& world) { if (benchmark_.pending()) { camera_.setMotionEnabled(false); camera_.setOrientation(0.0f, 0.0f); benchmark_.begin(world.count); return; } - if (benchmark_.running()) camera_.look(benchmark_.step(dt, frameLoop_->lastGpuMillis()), 0.0f); + if (benchmark_.running()) camera_.look(benchmark_.step(dt, renderer_->lastGpuMillis()), 0.0f); } StatsPublisher::Sample SplatEngine::sample() const { StatsPublisher::Sample s; - s.gpuMillis = frameLoop_->lastGpuMillis(); + s.gpuMillis = renderer_->lastGpuMillis(); s.sortMillis = lastSort_.sortMillis; s.cullMillis = lastSort_.cullMillis; s.selectMillis = lastSort_.selectMillis; - s.selected = lastSort_.selected; + s.selected = gpuSort_ && renderer_->lastSelectedCount() > 0 ? renderer_->lastSelectedCount() + : lastSort_.selected; s.drawn = drawCount_; - const GpuWorld* world = renderer_->world(); + const auto tiles = renderer_->lastScreenTileStats(); + s.computeTiles = tiles.compute; + s.nonemptyComputeTiles = tiles.nonemptyCompute; + s.hardwareTiles = tiles.hardware; + const std::optional world = renderer_->world(); s.sourceSplats = world ? sourceCount_ : 0; - s.gpuSplats = world ? world->count : 0; + s.gpuSplats = world ? (streamer_ ? streamer_->held() : world->count) : 0; s.walking = camera_.hasCollider(); s.motion = camera_.motionEnabled(); return s; @@ -232,10 +321,10 @@ void SplatEngine::render(int64_t frameTimeNanos) { if (!renderer_->ready()) return; if (applyPendingLoads()) redrawNeeded_ = true; - const VkExtent2D extent = renderer_->drawExtent(); - const GpuWorld* world = renderer_->world(); + const Extent extent = renderer_->drawExtent(); + const std::optional world = renderer_->world(); std::optional camera; - if (world != nullptr) { + if (world) { const float dt = frameSeconds(frameTimeNanos); driveBenchmark(dt, *world); camera_.update(dt); @@ -255,11 +344,25 @@ void SplatEngine::render(int64_t frameTimeNanos) { return; } - VulkanSplatRenderer::Frame frame; + SplatRenderer::Frame frame; + if (camera && gpuSort_) { + frame.orderSource = SplatRenderer::OrderSource::gpu; + // The renderer culls and sorts the ranges itself; the streamer keeps them resident + // while frames in flight may draw them. + if (streamer_) { + ranges_.clear(); + for (const auto& r : streamer_->ranges()) ranges_.push_back({r.offset, r.count}); + streamer_->drawnNow(); + } else { + ranges_.assign(1, {0, renderer_->world()->count}); + } + frame.ranges = ranges_.data(); + frame.rangeCount = static_cast(ranges_.size()); + } if (camera) { if (pendingOrder_) { - drawCount_ = static_cast(pendingOrder_->order.size()); - frame.order = pendingOrder_->order.data(); + drawCount_ = static_cast(pendingOrder_->size()); + frame.order = pendingOrder_->data(); frame.orderCount = drawCount_; } frame.drawCount = drawCount_; @@ -268,7 +371,10 @@ void SplatEngine::render(int64_t frameTimeNanos) { frame.proj = camera->proj; frame.cameraPosition = camera->axes.position; } - if (!renderer_->draw(frame)) return; // the order and the redraw wait for the next frame + if (!renderer_->draw(frame)) { + stats_.onFrame(frameTimeNanos, false, sampler); + return; // The order and the redraw wait for the next frame; FPS must still age to zero. + } pendingOrder_.reset(); redrawNeeded_ = false; lastDrawnView_ = camera ? camera->view : splat::Mat4::identity(); diff --git a/packages/splatkit-engine/src/rendering/GpuLayout.cpp b/packages/splatkit-engine/src/rendering/GpuLayout.cpp new file mode 100644 index 0000000..d4abdf0 --- /dev/null +++ b/packages/splatkit-engine/src/rendering/GpuLayout.cpp @@ -0,0 +1,85 @@ +#include "splatkit/rendering/GpuLayout.h" + +#include +#include +#include + +#include "splat/math/Half.h" + +namespace splatkit { +namespace { + +uint32_t packRgba8(float r, float g, float b, float a) { + auto q = [](float v) { + return static_cast(std::lround(std::clamp(v, 0.0f, 1.0f) * 255.0f)); + }; + return q(r) | (q(g) << 8) | (q(b) << 16) | (q(a) << 24); +} + +uint32_t packHalf2(float a, float b) { + return static_cast(splat::toHalf(a)) | (static_cast(splat::toHalf(b)) << 16); +} + +} // namespace + +std::size_t shStride(int degree) { + const auto coefficients = static_cast((degree + 1) * (degree + 1) - 1); + return (coefficients * 3 + 1) / 2; +} + +bool carriesSh(const splat::SplatCloud& cloud, int degree) { + const std::size_t n = cloud.count(); + return degree > 0 && cloud.shDegree >= degree && + cloud.sh.size() >= + n * 3 * static_cast((cloud.shDegree + 1) * (cloud.shDegree + 1) - 1); +} + +std::vector packSh(const splat::SplatCloud& cloud, int degree) { + const std::size_t n = cloud.count(); + std::vector packed(n * shStride(degree), 0); + packShRange(cloud, degree, 0, n, packed.data()); + return packed; +} + +void packShRange(const splat::SplatCloud& cloud, int degree, size_t offset, size_t count, + uint32_t* out) { + const size_t n = cloud.count(); + const std::size_t sourceCoefficients = n == 0 ? 0 : cloud.sh.size() / (n * 3); + const auto coefficients = static_cast((degree + 1) * (degree + 1) - 1); + const std::size_t halves = coefficients * 3; + const std::size_t stride = shStride(degree); + for (std::size_t i = 0; i < count; ++i) { + std::fill_n(out + i * stride, stride, 0u); + const float* src = &cloud.sh[(offset + i) * sourceCoefficients * 3]; + for (std::size_t h = 0; h < halves; ++h) { + const uint32_t half = splat::toHalf(src[h]); + out[i * stride + h / 2] |= half << ((h & 1) * 16); + } + } +} + +std::vector packSplats(const splat::SplatCloud& cloud) { + const std::size_t n = cloud.count(); + std::vector packed(n); + packSplatRange(cloud, 0, n, packed.data()); + return packed; +} + +void packSplatRange(const splat::SplatCloud& cloud, size_t offset, size_t count, GpuSplat* out) { + for (std::size_t k = 0; k < count; ++k) { + const size_t i = offset + k; + GpuSplat& g = out[k]; + std::memcpy(g.position, &cloud.positions[i * 3], sizeof(g.position)); + const float alpha = cloud.alphas[i]; + g.rgba8 = + packRgba8(cloud.colors[i * 3], cloud.colors[i * 3 + 1], cloud.colors[i * 3 + 2], alpha); + if (alpha > 1.0f) std::memcpy(&g.lodAlpha, &alpha, sizeof(g.lodAlpha)); + const float* c = &cloud.covariances[i * 6]; // xx, xy, xz, yy, yz, zz + g.cov[0] = packHalf2(c[0], c[1]); + g.cov[1] = packHalf2(c[2], c[3]); + g.cov[2] = packHalf2(c[4], c[5]); + if (alpha <= 1.0f) g.lodAlpha = 0; + } +} + +} // namespace splatkit diff --git a/packages/splatkit-engine/tests/CMakeLists.txt b/packages/splatkit-engine/tests/CMakeLists.txt new file mode 100644 index 0000000..de3aa03 --- /dev/null +++ b/packages/splatkit-engine/tests/CMakeLists.txt @@ -0,0 +1,11 @@ +add_executable(splatkit_engine_tests + camera/WalkCameraTest.cpp + rendering/GpuLayoutTest.cpp + rendering/FrameOrderTest.cpp +) +target_link_libraries(splatkit_engine_tests PRIVATE splatkit_engine spz GTest::gtest_main) +target_compile_options(splatkit_engine_tests PRIVATE + $<$:-Wall -Wextra -Wpedantic -Werror> +) +include(GoogleTest) +gtest_discover_tests(splatkit_engine_tests) diff --git a/packages/splatkit-engine/tests/camera/WalkCameraTest.cpp b/packages/splatkit-engine/tests/camera/WalkCameraTest.cpp new file mode 100644 index 0000000..31eacfd --- /dev/null +++ b/packages/splatkit-engine/tests/camera/WalkCameraTest.cpp @@ -0,0 +1,100 @@ +#include "splatkit/camera/WalkCamera.h" + +#include + +#include + +namespace splatkit { +namespace { + +constexpr float kPi = 3.14159265358979f; + +TEST(WalkCamera, StartsAtTheOriginLookingDownNegativeZ) { + const WalkCamera camera; + const splat::Vec3 forward = camera.rotation().transformDirection({0, 0, -1}); + EXPECT_NEAR(forward.x, 0.0f, 1e-6f); + EXPECT_NEAR(forward.y, 0.0f, 1e-6f); + EXPECT_NEAR(forward.z, -1.0f, 1e-6f); + EXPECT_EQ(camera.position().x, 0.0f); +} + +TEST(WalkCamera, LookAtFramesTheTargetWithTheGivenUpAndPassesThePole) { + WalkCamera camera; + // Straight above the origin, with +z at the top of the frame: a pose pitch cannot hold. + camera.setLookAt({0, 10, 0}, {0, 0, 0}, {0, 0, 1}); + const splat::Vec3 forward = camera.rotation().transformDirection({0, 0, -1}); + const splat::Vec3 top = camera.rotation().transformDirection({0, 1, 0}); + EXPECT_NEAR(forward.y, -1.0f, 1e-6f); + EXPECT_NEAR(top.z, 1.0f, 1e-6f); + const splat::Vec3 origin = camera.viewMatrix().transformPoint({0, 0, 0}); + EXPECT_NEAR(origin.z, -10.0f, 1e-5f); + EXPECT_NEAR(camera.pitch(), -kPi / 2, 1e-5f); + camera.look(0.0f, 0.0f); // Touch must preserve the complete look-at orientation. + EXPECT_NEAR(camera.rotation().transformDirection({0, 0, -1}).y, -1.0f, 1e-2f); + EXPECT_NEAR(camera.rotation().transformDirection({0, 1, 0}).z, 1.0f, 1e-5f); +} + +TEST(WalkCamera, TouchAfterLookAtKeepsRollAndTurnsInScreenAxes) { + WalkCamera camera; + camera.setLookAt({0, -2, 130}, {0, -2, -2}, {1, 0, 0}); + const auto before = camera.rotation(); + camera.look(0, 0); + for (size_t i = 0; i < before.m.size(); ++i) { + EXPECT_NEAR(camera.rotation().m[i], before.m[i], 1e-6f); + } + camera.look(0.1f, 0.2f); + const auto expected = + before * splat::Mat4::rotation(0.1f, {0, 1, 0}) * splat::Mat4::rotation(0.2f, {1, 0, 0}); + for (size_t i = 0; i < expected.m.size(); ++i) { + EXPECT_NEAR(camera.rotation().m[i], expected.m[i], 1e-6f); + } + EXPECT_FLOAT_EQ(camera.position().x, 0); + EXPECT_FLOAT_EQ(camera.position().y, -2); + EXPECT_FLOAT_EQ(camera.position().z, 130); +} + +TEST(WalkCamera, YawTurnsLeftAboutUpAndWalkFollowsTheView) { + WalkCamera camera; + camera.look(kPi / 2, 0.0f); // a quarter turn to the left: forward is now -x + camera.walk(2.0f, 0.0f); + EXPECT_NEAR(camera.position().x, -2.0f, 1e-5f); + EXPECT_NEAR(camera.position().z, 0.0f, 1e-5f); +} + +TEST(WalkCamera, PitchIsClampedAndIgnoredWhileMotionDrivesTheView) { + WalkCamera camera; + camera.look(0.0f, 10.0f); + EXPECT_NEAR(camera.pitch(), 85.0f * kPi / 180.0f, 1e-5f); + camera.setMotionEnabled(true); + EXPECT_EQ(camera.pitch(), 0.0f); + camera.look(0.0f, 1.0f); + EXPECT_EQ(camera.pitch(), 0.0f); +} + +TEST(WalkCamera, VelocityMovesEveryUpdate) { + WalkCamera camera; + camera.setVelocity(1.0f, 0.0f); + camera.update(0.5f); + camera.update(0.5f); + EXPECT_NEAR(camera.position().z, -1.0f, 1e-5f); + camera.setVelocity(0.0f, 0.0f); + camera.update(1.0f); + EXPECT_NEAR(camera.position().z, -1.0f, 1e-5f); +} + +// A phone held upright facing north, in Android's East-North-Up frame: device x east, +// device y up, device z south (out of the screen towards the user). The camera must +// look north, which is -z in the engine's frame. +TEST(WalkCamera, AttitudeInTheReferenceFrameLooksNorth) { + WalkCamera camera; + const float upright[9] = {1, 0, 0, 0, 0, -1, 0, 1, 0}; + camera.setAttitude(upright); + camera.setMotionEnabled(true); + const splat::Vec3 forward = camera.rotation().transformDirection({0, 0, -1}); + EXPECT_NEAR(forward.x, 0.0f, 1e-5f); + EXPECT_NEAR(forward.y, 0.0f, 1e-5f); + EXPECT_NEAR(forward.z, -1.0f, 1e-5f); +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-engine/tests/rendering/FrameOrderTest.cpp b/packages/splatkit-engine/tests/rendering/FrameOrderTest.cpp new file mode 100644 index 0000000..91a1e73 --- /dev/null +++ b/packages/splatkit-engine/tests/rendering/FrameOrderTest.cpp @@ -0,0 +1,152 @@ +#include + +#include "load-spz.h" +#include "splatkit/engine/SplatEngine.h" + +namespace splatkit { +namespace { + +// Capture what the real engine asks of either native renderer. +class RecordingRenderer final : public SplatRenderer { + public: + explicit RecordingRenderer(bool gpu) : gpu_(gpu) {} + void setRenderScale(float) override {} + float renderScale() const override { return 1; } + void setLinearBlending(bool) override {} + bool linearBlending() const override { return false; } + void setVsync(bool) override {} + bool ready() const override { return true; } + Extent drawExtent() const override { return {1000, 1000}; } + uint32_t generation() const override { return 0; } + bool uploadWorld(const splat::SplatCloud& cloud, int) override { + world_ = GpuWorldInfo{static_cast(cloud.count()), 0}; + return true; + } + bool createSlab(uint32_t, int) override { return false; } + bool uploadTile(uint32_t, const splat::SplatCloud&) override { return false; } + std::optional world() const override { return world_; } + bool sortsOnGpu() const override { return gpu_; } + bool selectsLodOnGpu() const override { return gpuLod; } + bool uploadLodWorld(const splat::LodTree& tree, int degree, uint32_t budget) override { + uploadedBudget = budget; + return uploadWorld(tree.nodes, degree); + } + bool draw(const Frame& frame) override { + source = frame.orderSource; + ranges.clear(); + for (uint32_t i = 0; i < frame.rangeCount; ++i) ranges.push_back(frame.ranges[i]); + ++frames; + return true; + } + double lastGpuMillis() const override { return 0; } + ScreenTileStats lastScreenTileStats() const override { return tiles; } + const std::string& deviceDescription() const override { return description_; } + OrderSource source = OrderSource::cpu; + std::vector ranges; + uint32_t frames = 0; + ScreenTileStats tiles; + bool gpuLod = false; + uint32_t uploadedBudget = 0; + + private: + bool gpu_; + std::optional world_; + std::string description_ = "test"; +}; + +std::vector worldBytes() { + spz::GaussianCloud cloud; + cloud.numPoints = 1; + cloud.positions = {0, 0, 2}; + cloud.scales = {0, 0, 0}; + cloud.rotations = {0, 0, 0, 1}; + cloud.alphas = {0}; + cloud.colors = {0, 0, 0}; + spz::PackOptions options; + options.version = 2; + std::vector bytes; + EXPECT_TRUE(spz::saveSpz(cloud, options, &bytes)); + return bytes; +} + +TEST(FrameOrder, GpuRendererReceivesTheWorldRangeOnEveryRequestedFrame) { + auto renderer = std::make_unique(true); + auto* observed = renderer.get(); + SplatEngine engine(std::move(renderer)); + const auto bytes = worldBytes(); + engine.loadWorld(bytes.data(), bytes.size()); + for (int64_t i = 1; i <= 2; ++i) { + engine.requestRedraw(); + engine.render(i * 16666667); + EXPECT_EQ(observed->source, SplatRenderer::OrderSource::gpu); + ASSERT_EQ(observed->ranges.size(), 1u); + EXPECT_EQ(observed->ranges[0].offset, 0u); + EXPECT_EQ(observed->ranges[0].count, 1u); + } + EXPECT_EQ(observed->frames, 2u); +} + +TEST(FrameOrder, CompletedScreenTileCountsReachPublishedStatsAndClearWhenUnavailable) { + auto renderer = std::make_unique(true); + auto* observed = renderer.get(); + SplatEngine engine(std::move(renderer)); + const auto bytes = worldBytes(); + engine.loadWorld(bytes.data(), bytes.size()); + observed->tiles = {39, 2, 25}; + engine.render(1); + engine.requestRedraw(); + engine.render(500000001); + auto stats = engine.stats(); + EXPECT_EQ(stats.computeTileCount, 39u); + EXPECT_EQ(stats.nonemptyComputeTileCount, 2u); + EXPECT_EQ(stats.hardwareTileCount, 25u); + observed->tiles = {}; + engine.requestRedraw(); + engine.render(1000000001); + stats = engine.stats(); + EXPECT_EQ(stats.computeTileCount, 0u); + EXPECT_EQ(stats.nonemptyComputeTileCount, 0u); + EXPECT_EQ(stats.hardwareTileCount, 0u); +} + +TEST(FrameOrder, CpuRendererKeepsTheCpuOrderContract) { + auto renderer = std::make_unique(false); + auto* observed = renderer.get(); + SplatEngine engine(std::move(renderer)); + const auto bytes = worldBytes(); + engine.loadWorld(bytes.data(), bytes.size()); + engine.render(16666667); + EXPECT_EQ(observed->source, SplatRenderer::OrderSource::cpu); + EXPECT_TRUE(observed->ranges.empty()); + EXPECT_EQ(observed->frames, 1u); +} + +TEST(FrameOrder, LodWorldExplicitlyUsesCpuOrderOnAGpuCapableRenderer) { + auto renderer = std::make_unique(true); + auto* observed = renderer.get(); + SplatEngine engine(std::move(renderer)); + engine.setSplatBudget(1); + const auto bytes = worldBytes(); + engine.loadWorld(bytes.data(), bytes.size()); + engine.render(16666667); + EXPECT_EQ(observed->source, SplatRenderer::OrderSource::cpu); + EXPECT_TRUE(observed->ranges.empty()); + EXPECT_EQ(observed->frames, 1u); +} + +TEST(FrameOrder, NativeLodRendererReceivesHierarchyAndUsesGpuOrder) { + auto renderer = std::make_unique(true); + auto* observed = renderer.get(); + observed->gpuLod = true; + SplatEngine engine(std::move(renderer)); + engine.setSplatBudget(100); + const auto bytes = worldBytes(); + engine.loadWorld(bytes.data(), bytes.size()); + engine.render(16666667); + EXPECT_EQ(observed->uploadedBudget, 100u); + EXPECT_EQ(observed->source, SplatRenderer::OrderSource::gpu); + EXPECT_EQ(observed->frames, 1u); +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-engine/tests/rendering/GpuLayoutTest.cpp b/packages/splatkit-engine/tests/rendering/GpuLayoutTest.cpp new file mode 100644 index 0000000..c097028 --- /dev/null +++ b/packages/splatkit-engine/tests/rendering/GpuLayoutTest.cpp @@ -0,0 +1,57 @@ +#include "splatkit/rendering/GpuLayout.h" + +#include + +#include + +#include "splat/math/Half.h" + +namespace splatkit { +namespace { + +splat::SplatCloud twoSplats(int shDegree) { + splat::SplatCloud cloud; + cloud.positions = {0, 1, 2, 3, 4, 5}; + cloud.colors = {1.0f, 0.5f, 0.0f, 0.0f, 0.0f, 1.0f}; + cloud.alphas = {0.5f, 2.0f}; + cloud.covariances = {1, 2, 3, 4, 5, 6, 6, 5, 4, 3, 2, 1}; + cloud.shDegree = shDegree; + const auto coefficients = static_cast((shDegree + 1) * (shDegree + 1) - 1); + cloud.sh.assign(2 * coefficients * 3, 0.25f); + return cloud; +} + +TEST(GpuLayout, PacksColourAlphaAndCovarianceIntoThirtyTwoBytes) { + const auto packed = packSplats(twoSplats(0)); + ASSERT_EQ(packed.size(), 2u); + EXPECT_EQ(packed[0].position[2], 2.0f); + EXPECT_EQ(packed[0].rgba8 & 0xffu, 255u); // r + EXPECT_EQ((packed[0].rgba8 >> 8) & 0xffu, 128u); // g + EXPECT_EQ((packed[0].rgba8 >> 24) & 0xffu, 128u); // a + EXPECT_EQ(packed[0].cov[0] & 0xffffu, splat::toHalf(1.0f)); + EXPECT_EQ(packed[0].cov[2] >> 16, splat::toHalf(6.0f)); + EXPECT_EQ(packed[0].lodAlpha, 0u); +} + +TEST(GpuLayout, AnOpacityAboveOneIsKeptAsFloatBits) { + const auto packed = packSplats(twoSplats(0)); + float alpha = 0; + std::memcpy(&alpha, &packed[1].lodAlpha, sizeof(alpha)); + EXPECT_EQ(alpha, 2.0f); + EXPECT_EQ((packed[1].rgba8 >> 24) & 0xffu, 255u); // clamped in the byte +} + +TEST(GpuLayout, HarmonicsArePackedTwoHalvesPerUintPerSplat) { + EXPECT_EQ(shStride(1), 5u); // 3 coefficients * 3 channels = 9 halves + EXPECT_EQ(shStride(3), 23u); // 15 * 3 = 45 halves + const auto cloud = twoSplats(2); + EXPECT_TRUE(carriesSh(cloud, 2)); + EXPECT_FALSE(carriesSh(cloud, 3)); + const auto sh = packSh(cloud, 1); // a lower degree than the cloud carries + ASSERT_EQ(sh.size(), 2 * shStride(1)); + EXPECT_EQ(sh[0] & 0xffffu, splat::toHalf(0.25f)); + EXPECT_EQ(sh[4] >> 16, 0u); // the odd half of the last uint is padding +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-ios/CMakeLists.txt b/packages/splatkit-ios/CMakeLists.txt new file mode 100644 index 0000000..fb08e7d --- /dev/null +++ b/packages/splatkit-ios/CMakeLists.txt @@ -0,0 +1,54 @@ +cmake_minimum_required(VERSION 3.22) +project(splatkit_ios LANGUAGES C CXX OBJCXX) + +# Configure for a device with the iOS toolchain flags, e.g. +# cmake -S packages/splatkit-ios -B build/ios -G Xcode -DCMAKE_SYSTEM_NAME=iOS \ +# -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=17.0 +# scripts/build-ios.sh does exactly that and leaves the static libraries where the +# dev app and the Swift package pick them up. + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +# The Metal code runs on a Mac too, so its tests are a macOS build of this package. +if(CMAKE_SYSTEM_NAME STREQUAL "iOS") + set(SPLATKIT_IOS_BUILD_TESTS OFF) +else() + option(SPLATKIT_IOS_BUILD_TESTS "Build splatkit_ios unit tests (macOS)" ${PROJECT_IS_TOP_LEVEL}) +endif() +set(SPLATKIT_ENGINE_BUILD_TESTS ${SPLATKIT_IOS_BUILD_TESTS} CACHE BOOL "" FORCE) +add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../splatkit-engine splatkit-engine) + +# The Metal shader source travels inside the library as a string and is compiled by the +# device at start, so a host app needs no metallib of ours in its bundle. +include(cmake/embed.cmake) +splatkit_embed_text(splatkit_ios_shaders + ${CMAKE_CURRENT_SOURCE_DIR}/Sources/SplatKitCore/rendering/shaders/Splat.metal + SplatShaderSource +) + +add_library(splatkit_ios STATIC + Sources/SplatKitCore/rendering/MetalSplatRenderer.mm + Sources/SplatKitCore/rendering/MetalVisibility.mm + Sources/SplatKitCore/rendering/MetalLOD.mm + Sources/SplatKitCore/rendering/MetalRadixSort.mm + Sources/SplatKitCore/rendering/MetalTileRaster.mm + Sources/SplatKitCore/rendering/MetalWorld.mm + Sources/SplatKitCore/engine/SKSplatEngine.mm +) +target_include_directories(splatkit_ios + PUBLIC Sources/SplatKitCore/include + PRIVATE Sources/SplatKitCore ${CMAKE_CURRENT_BINARY_DIR}/embedded +) +target_compile_options(splatkit_ios PRIVATE -Wall -Wextra -Werror -fobjc-arc) +target_link_libraries(splatkit_ios PUBLIC splatkit_engine + "-framework Metal" "-framework QuartzCore" "-framework Foundation" + "-framework CoreGraphics" "-framework ImageIO" "-framework UniformTypeIdentifiers") +add_dependencies(splatkit_ios splatkit_ios_shaders) + +if(SPLATKIT_IOS_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() diff --git a/packages/splatkit-ios/README.md b/packages/splatkit-ios/README.md new file mode 100644 index 0000000..a173d6f --- /dev/null +++ b/packages/splatkit-ios/README.md @@ -0,0 +1,108 @@ +# splatkit-ios + +Gaussian splat rendering for iOS: the shared SplatKit engine drawn with Metal, wrapped in a `UIView`. +Requires iOS 17+ and Apple GPU family 7+ (A14/M1+); unsupported GPUs report unavailable. + +## Use it + +Swift Package Manager: add `https://github.com/Xget7/splatkit-ios`, product `SplatKit`, then `import SplatKit`. +Choose exact version `0.1.0-alpha.2`. +For source builds: + +Build the static libraries with `scripts/build-ios.sh` from the repository root, then add to your target: + +- the Swift sources in `Sources/SplatKit`, +- a bridging header importing `SplatKit/SKSplatEngine.h`, with `Sources/SplatKitCore/include` in the header search paths, +- `build/ios/lib` in the library search paths and `-lsplatkit_ios -lsplatkit_engine -lsplat_core -lspz -lzstd -lz -lc++` in the linker flags, +- the Metal, QuartzCore, CoreMotion, ImageIO, CoreGraphics and UniformTypeIdentifiers frameworks. + +`scripts/package-ios.sh` builds the XCFramework; `Package.swift` pins its release checksum. + +```swift +let view = SplatMetalView() +view.delegate = self +view.loadWorld(file: documents.appendingPathComponent("scene.spz")) +view.loadCollider(file: documents.appendingPathComponent("collider.glb")) +view.setMotionEnabled(true) +view.resume() +``` + +Forward `resume()`, `pause()` and `release()` from the host's lifecycle; the layer follows the view's window. + +## API + +`SplatMetalView` mirrors `SplatSurfaceView` on Android: + +| Member | What it does | +| --- | --- | +| `loadWorld(file:)` | Decodes and shows a `.spz`, `.ply` or `.lodsplat` world; the file is mapped, not copied | +| `loadTiledWorld(tileset:)` | Streams a tiled world made by `splat-tile` within `residencyBudget` | +| `loadCollider(file:)` | Decodes a GLB mesh and enables walk mode | +| `cameraPose` | Position, yaw and pitch; set it to teleport | +| `renderScale` | Fraction of the view's resolution the splats are drawn at, 0.1 to 2 | +| `cullMarginDegrees` | Angular margin kept drawn around the view | +| `linearBlending` | Blend in linear light instead of the encoded colour space | +| `splatBudget`, `residencyBudget` | Most splats drawn per frame, most splats resident on the GPU; a tiled scene that fits the residency whole is fetched whole, so turning never meets a coarse stand-in | +| `shDegree`, `maxShDegree` | Harmonics drawn, harmonics kept from the file | +| `setWalkVelocity(forward:right:)` | Continuous walking in meters per second | +| `setMotionEnabled(_:)`, `isMotionEnabled` | Gyroscope driven camera | +| `startBenchmark(seconds:)` | A reproducible turn with the frame time distribution logged | +| `captureFrame(to:completion:)` | The next frame as a PNG | +| `readStats()`, `gpuDescription` | Frame, GPU and sort times, splats drawn, device name | +| `delegate` | World and collider outcomes, on the main thread | + +Gestures: one finger looks, two fingers walk, a double tap toggles the gyroscope; `lookSensitivity` and `walkSensitivity` scale them. + +## Layout + +``` +Sources/SplatKitCore/rendering/ renderer, world, visibility, radix, LOD, tiles; shaders/ contains MSL +Sources/SplatKitCore/engine/ SKSplatEngine, the Objective-C boundary over the shared engine +Sources/SplatKitCore/include/ the public header Swift imports +Sources/SplatKit/ RenderThread, MotionInput, SplatMetalView +cmake/ embeds the shader source into the library +``` + +The shader is compiled at run time from the embedded source, so the library is a plain static archive with no metallib to ship. +The renderer keeps two frames in flight and reads GPU time from the command buffer. +GPU path: visibility → radix → indirect draw; compatibility frames may use CPU order. +`sortMillis` includes visibility/radix. +That path draws front to back and stops shading a pixel once it is opaque (ADR 0018), so the frame costs what the visible layers cost. +The sort has unit tests that run on a Mac: `cmake -S packages/splatkit-ios -B build/ios-mac && cmake --build build/ios-mac && ctest --test-dir build/ios-mac`. +Colours blend in the encoded space by default, on a `bgra8Unorm` layer; `linearBlending` switches the layer to `bgra8Unorm_srgb`. + +## Experiments + +Start motion after `splatView(_:worldFrameReady:)`, not upload-only `worldReady`. +Keep the view attached/resumed while loading; readiness means GPU completion, not visual acceptance. + +Dev-only switches, applied before renderer creation: + +| Switch | Effect | +|---|---| +| `--metal-culling 1 --min-pixel-radius 1` | Covariance bounds, opacity/subpixel rejection | +| `--depth-key-bits 16` | Two radix passes; uint32 storage unchanged; ties may shimmer | +| `--tile-raster 1` | 16×16 tiles; compute ≤512 candidates, dense/large-footprint tiles use hardware | +| `--budget 2200000` | LOD capacity, not guaranteed quality | +| `--orbit-horizontal 0` | Previous vertical framing for benchmark reproduction | +| `--run-seconds 20` | Bounded run with resource monitoring | + +Defaults: 32-bit sorting, tiles disabled. +Culling, LOD and depth quantization are approximations pending visual acceptance. +Tile termination uses transmittance ≤0.0001; hardware geometry submission remains. +Private allocations still consume unified memory. +The dev memory guard cannot cancel in-flight work or prevent allocation spikes. + +Build `splat_lod_build` from `splat-core/tools`; invoke `splat_lod_build input.spz output.lodsplat --depth 10 --sh 1`. +Output must be new. +Moment-matched parents remain approximate; original leaves survive. +Parents use [moment-matching initialization](https://arxiv.org/html/2406.12080v1#S4.SS1), without training/refinement. +v2 adds interior metadata and leaf packets; v1 remains readable. +Layout/validation: [LodFile.cpp](../splat-core/src/lod/LodFile.cpp). +Selection feeds visibility → radix → raster; denied refinements retain parents and report pressure. +The hierarchy stays resident, without Hi-Z or temporal transitions. + +Stats: `loadedSplatCount` counts source splats; `drawnSplatCount` counts completed draw candidates. +Tile counts distinguish compute, nonempty compute and hardware screen tiles. +Command timings overlap; HUD sort includes visibility/radix. +[Measurements](../../docs/BENCHMARKS.md) separate visual rejection, Mac checks and phone evidence. diff --git a/packages/splatkit-ios/Sources/SplatKit/MotionInput.swift b/packages/splatkit-ios/Sources/SplatKit/MotionInput.swift new file mode 100644 index 0000000..2946756 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKit/MotionInput.swift @@ -0,0 +1,59 @@ +import CoreMotion +import UIKit +#if canImport(SplatKitCore) +import SplatKitCore +#endif + +/// Turns the phone's orientation into a camera attitude. +/// +/// Uses the arbitrary-heading, z-vertical reference frame: accelerometer plus gyroscope, +/// no magnetometer, so it is immune to magnetic interference and its heading is +/// arbitrary, the same trade as Android's game rotation vector. The matrix handed out +/// maps device axes to the reference frame (z up), remapped for the interface +/// orientation so landscape works. +final class MotionInput { + private let manager = CMMotionManager() + private let queue = OperationQueue() + private let onAttitude: ([Float]) -> Void + var interfaceOrientation: UIInterfaceOrientation = .portrait + + var isAvailable: Bool { manager.isDeviceMotionAvailable } + + init(onAttitude: @escaping ([Float]) -> Void) { + self.onAttitude = onAttitude + queue.name = "com.splatkit.motion" + queue.maxConcurrentOperationCount = 1 + manager.deviceMotionUpdateInterval = 1.0 / 60.0 + } + + func start() { + guard isAvailable, !manager.isDeviceMotionActive else { return } + manager.startDeviceMotionUpdates(using: .xArbitraryCorrectedZVertical, to: queue) { [weak self] motion, _ in + guard let self, let m = motion?.attitude.rotationMatrix else { return } + self.onAttitude(self.remap(m)) + } + } + + func stop() { + manager.stopDeviceMotionUpdates() + } + + /// CMRotationMatrix maps reference to device (m11 m12 m13 is its first row); the + /// engine wants device to reference, its transpose. Then the device axes are turned + /// so that "x right, y up on screen" holds in the current interface orientation. + private func remap(_ m: CMRotationMatrix) -> [Float] { + // Columns of the device-to-reference matrix: where each device axis points. + let x = SIMD3(Float(m.m11), Float(m.m12), Float(m.m13)) + let y = SIMD3(Float(m.m21), Float(m.m22), Float(m.m23)) + let z = SIMD3(Float(m.m31), Float(m.m32), Float(m.m33)) + let (right, up): (SIMD3, SIMD3) = switch interfaceOrientation { + case .landscapeRight: (y, -x) + case .landscapeLeft: (-y, x) + case .portraitUpsideDown: (-x, -y) + default: (x, y) + } + return [right.x, up.x, z.x, + right.y, up.y, z.y, + right.z, up.z, z.z] + } +} diff --git a/packages/splatkit-ios/Sources/SplatKit/RenderThread.swift b/packages/splatkit-ios/Sources/SplatKit/RenderThread.swift new file mode 100644 index 0000000..6350426 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKit/RenderThread.swift @@ -0,0 +1,200 @@ +import Foundation +import QuartzCore +#if canImport(SplatKitCore) +import SplatKitCore +#endif + +/// Owns the native engine and drives it from a dedicated thread, one frame per display +/// link tick, like the Android render thread. +/// +/// The main thread never touches the engine. Everything is posted here; the calls that +/// must complete before the layer goes away block the caller until done. Decoding runs +/// on a loader queue so frames keep flowing; the engine uploads on its next frame. +final class RenderThread { + private let thread: Thread + private let ready = DispatchSemaphore(value: 0) + private var runLoop: CFRunLoop! + private var displayLink: CADisplayLink? + private let loader = DispatchQueue(label: "com.splatkit.loader", qos: .userInitiated) + // Created on the render thread; read by the any-thread getters (stats, camera pose). + private(set) var engine: SKSplatEngine? + private var rendering = false + + /// False when Metal could not be brought up; every call is then a no-op. + var isAvailable: Bool { engine != nil } + + /// Loading outcomes, delivered on the main thread. + var onEvent: ((SKSplatEvent, String, UInt32) -> Void)? + + init() { + let started = DispatchSemaphore(value: 0) + var engine: SKSplatEngine? + var loop: CFRunLoop! + thread = Thread { + loop = CFRunLoopGetCurrent() + // Everything that must live on the render thread is created there. + engine = SKSplatEngine.create() + started.signal() + // The loop stays alive on a timer that never fires; work arrives as blocks. + let keepAlive = Timer(timeInterval: .greatestFiniteMagnitude, repeats: true) { _ in } + RunLoop.current.add(keepAlive, forMode: .default) + RunLoop.current.run() + } + thread.name = "SplatKitRender" + thread.qualityOfService = .userInteractive + thread.start() + started.wait() + runLoop = loop + self.engine = engine + engine?.eventHandler = { [weak self] event, message, count in + DispatchQueue.main.async { self?.onEvent?(event, message, count) } + } + } + + // Posting. + + /// Runs `block` on the render thread, later. + func post(_ block: @escaping () -> Void) { + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.defaultMode.rawValue, block) + CFRunLoopWakeUp(runLoop) + } + + /// Runs `block` on the render thread and waits for it. + func sync(_ block: @escaping () -> Void) { + if Thread.current == thread { + block() + return + } + let done = DispatchSemaphore(value: 0) + post { + block() + done.signal() + } + done.wait() + } + + // Layer and lifecycle. + + /// Blocks: the engine draws on the layer as soon as this returns. + func layerAttached(_ layer: CAMetalLayer, size: CGSize) { + sync { [self] in + engine?.setLayer(layer) + engine?.setDrawableSize(size) + } + } + + func layerResized(_ size: CGSize) { + post { [self] in engine?.setDrawableSize(size) } + } + + /// Blocks: after this returns the layer may be released. + func layerDetached() { + sync { [self] in engine?.setLayer(nil) } + } + + func resume() { + post { [self] in + guard !rendering else { return } + rendering = true + let link = CADisplayLink(target: self, selector: #selector(frame(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 120, preferred: 120) + link.add(to: RunLoop.current, forMode: .default) + displayLink = link + } + } + + func pause() { + post { [self] in + rendering = false + displayLink?.invalidate() + displayLink = nil + } + } + + func release() { + sync { [self] in + rendering = false + displayLink?.invalidate() + displayLink = nil + engine?.setLayer(nil) + engine = nil + CFRunLoopStop(CFRunLoopGetCurrent()) + } + } + + @objc private func frame(_ link: CADisplayLink) { + guard rendering else { return } + engine?.render(Int64(link.timestamp * 1_000_000_000)) + } + + // Loading, on the loader queue. + + func loadWorldFile(_ path: String) { + loader.async { [self] in engine?.loadWorldFile(path) } + } + + func loadTiledWorldFile(_ path: String) { + loader.async { [self] in engine?.loadTiledWorldFile(path) } + } + + func loadColliderFile(_ path: String) { + loader.async { [self] in engine?.loadColliderFile(path) } + } + + // Camera and input. + + func look(_ deltaYaw: Float, _ deltaPitch: Float) { + post { [self] in engine?.look(withDeltaYaw: deltaYaw, deltaPitch: deltaPitch) } + } + + func walk(_ forward: Float, _ right: Float) { + post { [self] in engine?.walkForward(forward, right: right) } + } + + func setVelocity(_ forward: Float, _ right: Float) { + post { [self] in engine?.setVelocityForward(forward, right: right) } + } + + func setAttitude(_ rowMajor: [Float]) { + post { [self] in rowMajor.withUnsafeBufferPointer { engine?.setAttitude($0.baseAddress!) } } + } + + func setMotionEnabled(_ enabled: Bool) { + post { [self] in engine?.setMotionEnabled(enabled) } + } + + func lookAt(from position: SKVec3, target: SKVec3, up: SKVec3) { + post { [self] in engine?.lookAt(from: position, target: target, up: up) } + } + + func setCameraPose(_ pose: SKCameraPose) { + post { [self] in engine?.cameraPose = pose } + } + + func cameraPose() -> SKCameraPose? { engine?.cameraPose } + + // Settings. + + func setRenderScale(_ scale: Float) { post { [self] in engine?.setRenderScale(scale) } } + func setCullMargin(_ degrees: Float) { post { [self] in engine?.setCullMargin(degrees) } } + func setLinearBlending(_ linear: Bool) { post { [self] in engine?.setLinearBlending(linear) } } + func setSplatBudget(_ budget: Int) { post { [self] in engine?.setSplatBudget(Int32(budget)) } } + func setResidencyBudget(_ splats: Int) { post { [self] in engine?.setResidencyBudget(Int32(splats)) } } + // This setting controls decoding as well as GPU upload, so apply it before a following + // load can begin on the loader queue. + func setMaxShDegree(_ degree: Int) { sync { [self] in engine?.setMaxShDegree(Int32(degree)) } } + func setShDegree(_ degree: Int) { post { [self] in engine?.setShDegree(Int32(degree)) } } + func startBenchmark(_ seconds: Float) { post { [self] in engine?.startBenchmark(seconds) } } + + var gpuDescription: String { engine?.gpuDescription ?? "" } + func stats() -> SKSplatStats? { engine?.stats } +} + +extension RenderThread { + func captureFrame(_ path: String, completion: @escaping (Bool) -> Void) { + post { [self] in + guard let engine else { return completion(false) } + engine.captureFrame(toFile: path, completion: completion) + } + } +} diff --git a/packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift b/packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift new file mode 100644 index 0000000..8611683 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift @@ -0,0 +1,381 @@ +import QuartzCore +import UIKit +#if canImport(SplatKitCore) +import SplatKitCore +#endif + +/// Where the camera is and where it looks: position in the world's frame in meters, yaw +/// about the up axis and pitch, both in radians. Pitch is clamped to 85 degrees. Read it +/// to save a viewpoint, set it to teleport or restore one. +public struct CameraPose: Equatable { + public var x: Float + public var y: Float + public var z: Float + public var yaw: Float + public var pitch: Float + + public init(x: Float, y: Float, z: Float, yaw: Float = 0, pitch: Float = 0) { + self.x = x + self.y = y + self.z = z + self.yaw = yaw + self.pitch = pitch + } +} + +/// A snapshot of what the engine is doing, refreshed twice a second. +public struct SplatStats { + public var fps: Float = 0 + public var frameMillis: Float = 0 + /// GPU time of the last frame; zero until one completes. + public var gpuMillis: Float = 0 + public var sortMillis: Float = 0 + public var splatCount: Int = 0 + /// Splats in the loaded source world; not GPU residency for a streamed world. + public var loadedSplatCount: Int { splatCount } + /// Last completed visibility/order result, refreshed twice a second without a GPU wait. + /// This counts submitted splats, not splats contributing a visible pixel after occlusion. + public var drawnSplatCount: Int = 0 + /// Screen tiles completed by compute, including background-only tiles. + /// All tile counts are zero when hybrid diagnostics are unavailable. + public var computeTileCount: Int = 0 + /// Compute tiles whose candidate list contains at least one splat. + public var nonemptyComputeTileCount: Int = 0 + public var hardwareTileCount: Int = 0 + public var walking = false + public var motion = false + + public init() {} +} + +/// Loading outcomes, delivered on the main thread. +public protocol SplatViewDelegate: AnyObject { + /// The world is uploaded; its first GPU frame may still be pending. + func splatView(_ view: SplatMetalView, worldReady splatCount: Int) + /// First successful GPU frame of this uploaded world has completed. Suitable for + /// dismissing a loading cover or starting a tour. Requires resume(), even while + /// covered. Not a guarantee that streamed tiles are resident or LOD detail is exact. + func splatView(_ view: SplatMetalView, worldFrameReady splatCount: Int) + /// The file was not a readable world, or the GPU refused it; the previous world stays. + func splatView(_ view: SplatMetalView, worldFailed message: String) + /// Walk mode is on. + func splatViewColliderReady(_ view: SplatMetalView) + func splatView(_ view: SplatMetalView, colliderFailed message: String) +} + +public extension SplatViewDelegate { + func splatView(_ view: SplatMetalView, worldReady splatCount: Int) {} + func splatView(_ view: SplatMetalView, worldFrameReady splatCount: Int) {} + func splatView(_ view: SplatMetalView, worldFailed message: String) {} + func splatViewColliderReady(_ view: SplatMetalView) {} + func splatView(_ view: SplatMetalView, colliderFailed message: String) {} +} + +/// A view that renders with SplatKit, on a CAMetalLayer of its own. +/// +/// The host forwards resume, pause and release. Everything else follows the view's +/// lifecycle: the engine gets the layer when the view is in a window and gives it back, +/// synchronously, before the view leaves it. +/// +/// Gestures: one finger drags the view (yaw, and pitch when the gyroscope is off); +/// two fingers walk (up is forward, sideways strafes); a double tap toggles the gyroscope. +public final class SplatMetalView: UIView { + public override class var layerClass: AnyClass { CAMetalLayer.self } + + private let renderThread = RenderThread() + private lazy var motion = MotionInput { [weak self] in self?.renderThread.setAttitude($0) } + private var motionEnabled = false + private var resumed = false + private var attached = false + private var lastDrawableSize = CGSize.zero + + /// Radians per point dragged. + public var lookSensitivity: Float = 0.004 + /// Meters per point dragged with two fingers. + public var walkSensitivity: Float = 0.01 + /// Whether a one-finger drag is allowed to move the camera. Scripted tours can disable it. + public var touchLookEnabled = true + /// Whether the double-tap gesture can toggle motion input. + public var motionToggleEnabled = true + + public weak var delegate: SplatViewDelegate? + + public override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + private func setUp() { + isOpaque = true + backgroundColor = .black + renderThread.onEvent = { [weak self] event, message, count in + guard let self, let delegate = self.delegate else { return } + switch event { + case .worldReady: delegate.splatView(self, worldReady: Int(count)) + case .worldFrameReady: delegate.splatView(self, worldFrameReady: Int(count)) + case .worldFailed: delegate.splatView(self, worldFailed: message) + case .colliderReady: delegate.splatViewColliderReady(self) + case .colliderFailed: delegate.splatView(self, colliderFailed: message) + @unknown default: break + } + } + let look = UIPanGestureRecognizer(target: self, action: #selector(onLook(_:))) + look.maximumNumberOfTouches = 1 + addGestureRecognizer(look) + let walk = UIPanGestureRecognizer(target: self, action: #selector(onWalk(_:))) + walk.minimumNumberOfTouches = 2 + walk.maximumNumberOfTouches = 2 + addGestureRecognizer(walk) + let tap = UITapGestureRecognizer(target: self, action: #selector(onDoubleTap)) + tap.numberOfTapsRequired = 2 + addGestureRecognizer(tap) + } + + /// False when Metal could not be brought up on this device; the view stays blank. + public var isAvailable: Bool { renderThread.isAvailable } + + /// Decodes and shows a world from a file the app can read. The file is mapped, not + /// copied, so this is the way to load big worlds. Replaces the current one when ready. + public func loadWorld(file: URL) { renderThread.loadWorldFile(file.path) } + + /// Decodes a collider GLB from a file; enables walk mode when ready. + public func loadCollider(file: URL) { renderThread.loadColliderFile(file.path) } + + /// Shows a tiled world from its index, a `tileset.json` with its tiles beside it (made + /// offline by `splat-tile`). Only the index is read now; tiles stream in as the camera + /// needs them, nearest and biggest on screen first, within `residencyBudget`. + public func loadTiledWorld(tileset: URL) { renderThread.loadTiledWorldFile(tileset.path) } + + /// The camera's position and look direction, as of the last frame when read; setting + /// teleports, and when walking the camera settles on the floor under the new point. + public var cameraPose: CameraPose { + get { + guard let p = renderThread.cameraPose() else { return CameraPose(x: 0, y: 0, z: 0) } + return CameraPose(x: p.x, y: p.y, z: p.z, yaw: p.yaw, pitch: p.pitch) + } + set { renderThread.setCameraPose(SKCameraPose(x: newValue.x, y: newValue.y, z: newValue.z, yaw: newValue.yaw, pitch: newValue.pitch)) } + } + + /// Scripted camera: teleports to `position` looking at `target`, with `up` at the top + /// of the frame whatever the roll, so a path can pass over the poles that yaw and + /// pitch cannot. The next touch or motion update takes the view back. + public func lookAt(from position: SIMD3, target: SIMD3, up: SIMD3) { + renderThread.lookAt(from: SKVec3(x: position.x, y: position.y, z: position.z), + target: SKVec3(x: target.x, y: target.y, z: target.z), + up: SKVec3(x: up.x, y: up.y, z: up.z)) + } + + /// Fraction of the view's resolution the splats are drawn at, in [0.1, 2]. Below one + /// the frame is drawn smaller and upscaled; above one it is supersampled. + public var renderScale: Float = 1 { + didSet { + renderScale = min(max(renderScale, 0.1), 2) + renderThread.setRenderScale(renderScale) + } + } + + /// Angular margin around the view, in degrees, kept drawn so that what turns into + /// view before the next cull lands is already there. 10 by default. + public var cullMarginDegrees: Float = 10 { + didSet { + cullMarginDegrees = min(max(cullMarginDegrees, 0), 80) + renderThread.setCullMargin(cullMarginDegrees) + } + } + + /// Blend splats in linear light instead of the encoded colour space the training + /// used. Off by default. + public var linearBlending = false { + didSet { renderThread.setLinearBlending(linearBlending) } + } + + /// Most splats drawn per frame for a single file world, or 0 to draw them all. With a + /// budget, a world loaded afterwards gets a level of detail hierarchy. + public var splatBudget = 0 { + didSet { + splatBudget = max(splatBudget, 0) + renderThread.setSplatBudget(splatBudget) + } + } + + /// Residency budget of a tiled world: the most splats held on the GPU at once, about + /// 32 bytes each plus the harmonics. Applies to tiled worlds loaded after it is set. + public var residencyBudget = 2_000_000 { + didSet { + residencyBudget = min(max(residencyBudget, 100_000), 32_000_000) + renderThread.setResidencyBudget(residencyBudget) + } + } + + /// Spherical harmonics degree drawn, 0 to 3, capped by what the loaded world carries. + public var shDegree = 3 { + didSet { + shDegree = min(max(shDegree, 0), 3) + renderThread.setShDegree(shDegree) + } + } + + /// Highest spherical harmonics degree decoded and kept in GPU memory from the file, 0 to 3, + /// applied to worlds loaded after it is set. The source file remains complete. + public var maxShDegree = 3 { + didSet { + maxShDegree = min(max(maxShDegree, 0), 3) + renderThread.setMaxShDegree(maxShDegree) + } + } + + /// Walks continuously at the given speed in meters per second until called again with zeros. + public func setWalkVelocity(forward: Float, right: Float) { + renderThread.setVelocity(forward, right) + } + + /// Runs a reproducible capture: the gyroscope goes off, the camera takes a fixed pose + /// and turns once over `seconds`, then the frame time distribution is logged. + public func startBenchmark(seconds: Float = 10) { + setMotionEnabled(false) + renderThread.startBenchmark(seconds) + } + + /// GPU name and API reported by Metal. + public var gpuDescription: String { renderThread.gpuDescription } + + /// True while the gyroscope drives the camera. + public var isMotionEnabled: Bool { motionEnabled } + + /// Latest engine stats. Cheap; safe on the main thread. + public func readStats() -> SplatStats { + var stats = SplatStats() + guard let s = renderThread.stats() else { return stats } + stats.fps = s.fps + stats.frameMillis = s.frameMillis + stats.gpuMillis = s.gpuMillis + stats.sortMillis = s.sortMillis + stats.splatCount = Int(s.splatCount) + stats.drawnSplatCount = Int(s.drawnSplatCount) + stats.computeTileCount = Int(s.computeTileCount) + stats.nonemptyComputeTileCount = Int(s.nonemptyComputeTileCount) + stats.hardwareTileCount = Int(s.hardwareTileCount) + stats.walking = s.walking.boolValue + stats.motion = s.motion.boolValue + return stats + } + + /// Drives the camera with the phone's orientation. No-op when the sensor is missing. + public func setMotionEnabled(_ enabled: Bool) { + motionEnabled = enabled && motion.isAvailable + renderThread.setMotionEnabled(motionEnabled) + if resumed { + if motionEnabled { motion.start() } else { motion.stop() } + } + } + + public func resume() { + resumed = true + motion.interfaceOrientation = interfaceOrientation + renderThread.resume() + if motionEnabled { motion.start() } + } + + public func pause() { + resumed = false + motion.stop() + renderThread.pause() + } + + public func release() { + motion.stop() + detach() + renderThread.release() + } + + // Layer lifecycle. + + private var metalLayer: CAMetalLayer { layer as! CAMetalLayer } + + private var interfaceOrientation: UIInterfaceOrientation { + window?.windowScene?.interfaceOrientation ?? .portrait + } + + private var drawableSize: CGSize { + let scale = window?.screen.scale ?? UIScreen.main.scale + return CGSize(width: (bounds.width * scale).rounded(), height: (bounds.height * scale).rounded()) + } + + public override func willMove(toWindow newWindow: UIWindow?) { + if newWindow == nil { detach() } + super.willMove(toWindow: newWindow) + } + + public override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { attachIfSized() } + } + + public override func layoutSubviews() { + super.layoutSubviews() + metalLayer.contentsScale = window?.screen.scale ?? UIScreen.main.scale + let size = drawableSize + guard size.width > 0, size.height > 0 else { return } + metalLayer.drawableSize = size + motion.interfaceOrientation = interfaceOrientation + if !attached { + attachIfSized() + } else if size != lastDrawableSize { + lastDrawableSize = size + renderThread.layerResized(size) + } + } + + private func attachIfSized() { + let size = drawableSize + guard !attached, window != nil, size.width > 0, size.height > 0 else { return } + metalLayer.drawableSize = size + lastDrawableSize = size + attached = true + renderThread.layerAttached(metalLayer, size: size) + } + + private func detach() { + guard attached else { return } + attached = false + renderThread.layerDetached() + } + + // Gestures. + + @objc private func onLook(_ g: UIPanGestureRecognizer) { + guard touchLookEnabled else { + g.setTranslation(.zero, in: self) + return + } + let d = g.translation(in: self) + renderThread.look(-Float(d.x) * lookSensitivity, -Float(d.y) * lookSensitivity) + g.setTranslation(.zero, in: self) + } + + @objc private func onWalk(_ g: UIPanGestureRecognizer) { + let d = g.translation(in: self) + renderThread.walk(-Float(d.y) * walkSensitivity, Float(d.x) * walkSensitivity) + g.setTranslation(.zero, in: self) + } + + @objc private func onDoubleTap() { + guard motionToggleEnabled else { return } + setMotionEnabled(!motionEnabled) + } +} + +public extension SplatMetalView { + /// Saves the next frame as a PNG at the view's pixel resolution. `completion` runs on + /// the main thread. + func captureFrame(to file: URL, completion: @escaping (Bool) -> Void) { + renderThread.captureFrame(file.path) { ok in + DispatchQueue.main.async { completion(ok) } + } + } +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm b/packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm new file mode 100644 index 0000000..c212fb9 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm @@ -0,0 +1,216 @@ +#import "SplatKit/SKSplatEngine.h" + +#import +#import +#import + +#include +#include +#include + +#include "rendering/MetalSplatRenderer.h" +#include "splatkit/Log.h" +#include "splatkit/engine/SplatEngine.h" + +namespace { + +// NSLog reaches both the system log and the console of `devicectl --console`. +void nslogSink(splatkit::LogLevel level, const char* message) { + const char* tag = level == splatkit::LogLevel::error ? "E" + : level == splatkit::LogLevel::warn ? "W" + : "I"; + NSLog(@"SplatKit %s: %s", tag, message); +} + +// BGRA rows, top down, to a PNG file. Colours are written as they were presented. +bool writePng(NSString* path, const std::vector& bgra, uint32_t width, uint32_t height) { + if (width == 0 || height == 0 || bgra.size() != size_t{width} * height * 4) return false; + CGColorSpaceRef space = CGColorSpaceCreateDeviceRGB(); + CGDataProviderRef provider = + CGDataProviderCreateWithData(nullptr, bgra.data(), bgra.size(), nullptr); + const CGBitmapInfo info = kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipFirst; + CGImageRef image = CGImageCreate(width, height, 8, 32, size_t{width} * 4, space, info, provider, + nullptr, false, kCGRenderingIntentDefault); + bool ok = false; + if (image != nullptr) { + NSURL* url = [NSURL fileURLWithPath:path]; + CGImageDestinationRef dest = CGImageDestinationCreateWithURL( + (__bridge CFURLRef)url, (__bridge CFStringRef)UTTypePNG.identifier, 1, nullptr); + if (dest != nullptr) { + CGImageDestinationAddImage(dest, image, nullptr); + ok = CGImageDestinationFinalize(dest); + CFRelease(dest); + } + CGImageRelease(image); + } + CGDataProviderRelease(provider); + CGColorSpaceRelease(space); + return ok; +} + +} // namespace + +@implementation SKSplatEngine { + splatkit::MetalSplatRenderer* _renderer; // owned by the engine + std::unique_ptr _engine; + bool _awaitingWorldFrame; + uint32_t _worldFrameSplatCount; +} + ++ (nullable instancetype)create { + splatkit::setLogSink(&nslogSink); + auto renderer = splatkit::MetalSplatRenderer::create(); + if (!renderer) return nil; + return [[self alloc] initWithRenderer:std::move(renderer)]; +} + +- (instancetype)initWithRenderer:(std::unique_ptr)renderer { + self = [super init]; + if (self == nil) return nil; + _renderer = renderer.get(); + _engine = std::make_unique(std::move(renderer)); + __weak SKSplatEngine* weakSelf = self; + _engine->setEventSink([weakSelf](splatkit::SplatEngine::Event event, const std::string& message, + uint32_t splatCount) { + SKSplatEngine* strongSelf = weakSelf; + if (strongSelf == nil) return; + if (event == splatkit::SplatEngine::Event::worldReady) { + strongSelf->_awaitingWorldFrame = true; + strongSelf->_worldFrameSplatCount = splatCount; + } + if (strongSelf.eventHandler == nil) return; + strongSelf.eventHandler(static_cast(event), @(message.c_str()), splatCount); + }); + return self; +} + +- (void)setLayer:(nullable CAMetalLayer*)layer { + _renderer->setLayer(layer); +} + +- (void)setDrawableSize:(CGSize)size { + _renderer->setDrawableSize(static_cast(size.width), static_cast(size.height)); +} + +- (void)render:(int64_t)frameTimeNanos { + _engine->render(frameTimeNanos); + // Read an atomic completion flag, never wait for the GPU on the render thread. + if (_awaitingWorldFrame && _renderer->hasCompletedWorldFrame()) { + _awaitingWorldFrame = false; + if (self.eventHandler) + self.eventHandler(SKSplatEventWorldFrameReady, @"", _worldFrameSplatCount); + } +} + +- (void)loadWorldFile:(NSString*)path { + _engine->loadWorldFile(path.UTF8String); +} + +- (void)loadTiledWorldFile:(NSString*)path { + _engine->loadTiledWorldFile(path.UTF8String); +} + +- (void)loadColliderFile:(NSString*)path { + _engine->loadColliderFile(path.UTF8String); +} + +- (SKCameraPose)cameraPose { + const splatkit::CameraPose p = _engine->cameraPose(); + return {p.x, p.y, p.z, p.yaw, p.pitch}; +} + +- (void)setCameraPose:(SKCameraPose)pose { + _engine->setCameraPose({pose.x, pose.y, pose.z, pose.yaw, pose.pitch}); +} + +- (void)lookAtFrom:(SKVec3)position target:(SKVec3)target up:(SKVec3)up { + _engine->setCameraLookAt({position.x, position.y, position.z}, {target.x, target.y, target.z}, + {up.x, up.y, up.z}); +} + +- (SKSplatStats)stats { + const splatkit::Stats s = _engine->stats(); + return {s.fps, + s.frameMillis, + s.gpuMillis, + s.sortMillis, + s.splatCount, + s.walking, + s.motion, + s.drawnSplatCount, + s.computeTileCount, + s.nonemptyComputeTileCount, + s.hardwareTileCount}; +} + +- (NSString*)gpuDescription { + return @(_engine->gpuDescription().c_str()); +} + +- (void)lookWithDeltaYaw:(float)deltaYaw deltaPitch:(float)deltaPitch { + _engine->look(deltaYaw, deltaPitch); +} + +- (void)walkForward:(float)forward right:(float)right { + _engine->walk(forward, right); +} + +- (void)setVelocityForward:(float)forward right:(float)right { + _engine->setVelocity(forward, right); +} + +- (void)setAttitude:(const float*)rowMajor { + _engine->setAttitude(rowMajor); +} + +- (void)setMotionEnabled:(BOOL)enabled { + _engine->setMotionEnabled(enabled == YES); +} + +- (void)setRenderScale:(float)scale { + _engine->setRenderScale(scale); +} + +- (void)setCullMargin:(float)degrees { + _engine->setCullMargin(degrees); +} + +- (void)setLinearBlending:(BOOL)linear { + _engine->setLinearBlending(linear == YES); +} + +- (void)setSplatBudget:(int)budget { + _engine->setSplatBudget(budget); +} + +- (void)setResidencyBudget:(int)splats { + _engine->setResidencyBudget(splats); +} + +- (void)setMaxShDegree:(int)degree { + _engine->setMaxShDegree(degree); +} + +- (void)setShDegree:(int)degree { + _engine->setShDegree(degree); +} + +- (void)startBenchmark:(float)seconds { + _engine->startBenchmark(seconds); +} + +- (void)captureFrameToFile:(NSString*)path completion:(void (^)(BOOL ok))completion { + _renderer->captureNextFrame( + [path, completion](std::vector bgra, uint32_t width, uint32_t height) { + const bool ok = writePng(path, bgra, width, height); + if (ok) { + LOGI("captured %ux%u to %s", width, height, path.UTF8String); + } else { + LOGE("capture to %s failed", path.UTF8String); + } + completion(ok); + }); + _engine->requestRedraw(); +} + +@end diff --git a/packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h b/packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h new file mode 100644 index 0000000..e8fc46d --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h @@ -0,0 +1,103 @@ +#pragma once + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Where the camera is and where it looks: position in the world's frame in meters, yaw +/// about the up axis and pitch, both in radians. Pitch is clamped to 85 degrees. +typedef struct { + float x; + float y; + float z; + float yaw; + float pitch; +} SKCameraPose; + +typedef struct { + float x; + float y; + float z; +} SKVec3; + +/// A snapshot of what the engine is doing, refreshed twice a second. +typedef struct { + float fps; + float frameMillis; + float gpuMillis; + float sortMillis; + uint32_t splatCount; + BOOL walking; + BOOL motion; + uint32_t drawnSplatCount; + uint32_t computeTileCount; + uint32_t nonemptyComputeTileCount; + uint32_t hardwareTileCount; +} SKSplatStats; + +typedef NS_ENUM(NSInteger, SKSplatEvent) { + SKSplatEventWorldReady = 0, + SKSplatEventWorldFailed = 1, + SKSplatEventColliderReady = 2, + SKSplatEventColliderFailed = 3, + /// First successful GPU frame after this world's upload, not merely upload completion. + SKSplatEventWorldFrameReady = 4, +}; + +/// The native engine behind one view: the shared C++ engine over the Metal renderer. +/// +/// Rendering, input and settings belong to one thread, the view's render thread. The +/// load methods may be called from any thread: they decode there and the next frame +/// uploads. Stats and the camera pose are readable from any thread. +@interface SKSplatEngine : NSObject + +/// Nil when the device has no Metal. ++ (nullable instancetype)create; +- (instancetype)init NS_UNAVAILABLE; + +/// The layer to draw on, or nil when the view is going away. Render thread. +- (void)setLayer:(nullable CAMetalLayer*)layer; +/// The layer's size in pixels. Render thread. +- (void)setDrawableSize:(CGSize)size; +/// One frame, at the display link's timestamp. Render thread. +- (void)render:(int64_t)frameTimeNanos; + +/// Loading outcomes, on whichever thread found them. +@property(nonatomic, copy, nullable) void (^eventHandler) + (SKSplatEvent event, NSString* message, uint32_t splatCount); + +- (void)loadWorldFile:(NSString*)path; +- (void)loadTiledWorldFile:(NSString*)path; +- (void)loadColliderFile:(NSString*)path; + +@property(nonatomic) SKCameraPose cameraPose; +@property(nonatomic, readonly) SKSplatStats stats; +@property(nonatomic, readonly) NSString* gpuDescription; + +- (void)lookWithDeltaYaw:(float)deltaYaw deltaPitch:(float)deltaPitch; +/// Scripted camera: from `position` looking at `target` with `up` at the top of the frame. +- (void)lookAtFrom:(SKVec3)position target:(SKVec3)target up:(SKVec3)up; +- (void)walkForward:(float)forward right:(float)right; +- (void)setVelocityForward:(float)forward right:(float)right; +/// Device to reference rotation, row major 3x3, device axes x right, y up, z out of the +/// screen, reference z up. +- (void)setAttitude:(const float*)rowMajor; +- (void)setMotionEnabled:(BOOL)enabled; + +- (void)setRenderScale:(float)scale; +- (void)setCullMargin:(float)degrees; +- (void)setLinearBlending:(BOOL)linear; +- (void)setSplatBudget:(int)budget; +- (void)setResidencyBudget:(int)splats; +- (void)setMaxShDegree:(int)degree; +- (void)setShDegree:(int)degree; +- (void)startBenchmark:(float)seconds; + +/// Writes the next presented frame to `path` as a PNG, at the layer's resolution. +/// `completion` runs on an arbitrary thread with the outcome. Render thread. +- (void)captureFrameToFile:(NSString*)path completion:(void (^)(BOOL ok))completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/splatkit-ios/Sources/SplatKitCore/include/module.modulemap b/packages/splatkit-ios/Sources/SplatKitCore/include/module.modulemap new file mode 100644 index 0000000..bff98aa --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/include/module.modulemap @@ -0,0 +1,4 @@ +module SplatKitCore { + header "SplatKit/SKSplatEngine.h" + export * +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalCompute.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalCompute.h new file mode 100644 index 0000000..20bb20e --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalCompute.h @@ -0,0 +1,36 @@ +#pragma once + +#import + +#include +#include "splatkit/Log.h" + +namespace splatkit::metal { + +inline id pipeline(id device, id library, + const char* name, + MTLFunctionConstantValues* constants = nil) { + NSError* functionError = nil; + id function = constants == nil ? [library newFunctionWithName:@(name)] + : [library newFunctionWithName:@(name) + constantValues:constants + error:&functionError]; + if (function == nil) { + LOGE("kernel %s missing: %s", name, + functionError == nil ? "" : functionError.localizedDescription.UTF8String); + return nil; + } + NSError* error = nil; + id state = [device newComputePipelineStateWithFunction:function + error:&error]; + if (state == nil) LOGE("kernel %s: %s", name, error.localizedDescription.UTF8String); + return state; +} + +inline id buffer(id device, size_t bytes, + MTLResourceOptions options = MTLResourceStorageModeShared) { + if (bytes > device.maxBufferLength) return nil; + return [device newBufferWithLength:std::max(bytes, 16) options:options]; +} + +} // namespace splatkit::metal diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalLOD.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalLOD.h new file mode 100644 index 0000000..daa7257 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalLOD.h @@ -0,0 +1,41 @@ +#pragma once + +#import +#include +#include "splat/lod/LodTree.h" + +namespace splatkit { + +// Interior-only traversal -> bounded GPU cut. V1 metadata is built once at upload; +// v2 metadata is precomputed offline. No CPU traversal/count readback per frame. +// Create/upload while idle; encode and its consumers use the same command queue. +class MetalLOD { + public: + bool create(id device, id library); + bool upload(id queue, const splat::LodTree& tree, uint32_t budget, + float pixelLimit = 1.0f, float colorWeight = 4.0f, bool frustumCull = true); + void encode(id command, id uniforms); + id indices() const { return indices_; } + // State words 4/5 contain denied refinements/evaluated interior nodes. + id count() const { return state_; } + uint32_t budget() const { return config_.budget; } + + private: + struct Config { + uint32_t budget; + float pixelLimit; + float colorWeight; + uint32_t cull; + } config_{}; + id device_ = nil; + id initialize_ = nil, evaluate_ = nil, budget_ = nil; + id compact_ = nil, allocate_ = nil, scatter_ = nil; + id advance_ = nil, emit_ = nil; + id scanGroups_ = nil, scanBlocks_ = nil; + id nodes_ = nil, leaves_ = nil, indices_ = nil, packets_ = nil; + id costs_ = nil, costGroups_ = nil, offsets_ = nil, groups_ = nil, state_ = nil; + id blocks_ = nil; + std::array, 2> frontier_{}; + uint32_t depth_ = 0; +}; +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalLOD.mm b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalLOD.mm new file mode 100644 index 0000000..4a4afbf --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalLOD.mm @@ -0,0 +1,185 @@ +#include "rendering/MetalLOD.h" + +#include +#include +#include +#include "rendering/MetalCompute.h" +#include "splat/lod/LodFile.h" + +namespace splatkit { +bool MetalLOD::create(id device, id library) { + device_ = device; + initialize_ = metal::pipeline(device, library, "initializeSplatLOD"); + evaluate_ = metal::pipeline(device, library, "evaluateSplatLOD"); + budget_ = metal::pipeline(device, library, "budgetSplatLOD"); + compact_ = metal::pipeline(device, library, "compactSplatLOD"); + allocate_ = metal::pipeline(device, library, "allocateSplatLOD"); + scatter_ = metal::pipeline(device, library, "scatterSplatLOD"); + advance_ = metal::pipeline(device, library, "advanceSplatLOD"); + emit_ = metal::pipeline(device, library, "emitSplatLOD"); + scanGroups_ = metal::pipeline(device, library, "scanSplatLODGroups"); + scanBlocks_ = metal::pipeline(device, library, "scanSplatLODBlocks"); + if (!initialize_ || !evaluate_ || !budget_ || !compact_ || !allocate_ || !scatter_ || !advance_ || + !emit_ || !scanGroups_ || !scanBlocks_) + return false; + for (auto p : {evaluate_, compact_, scatter_, emit_, scanGroups_}) + if (p.threadExecutionWidth != 32 || p.maxTotalThreadsPerThreadgroup < 256) return false; + return true; +} +bool MetalLOD::upload(id queue, const splat::LodTree& tree, uint32_t capacity, + float pixelLimit, float colorWeight, bool frustumCull) { + auto valid = splat::validateLodTree(tree); + if (!valid) LOGE("invalid LOD hierarchy: %s", valid.error().message.c_str()); + if (!valid || capacity == 0 || !std::isfinite(pixelLimit) || pixelLimit < 0 || + !std::isfinite(colorWeight) || colorWeight < 0) + return false; + capacity = std::min({capacity, 2200000u, static_cast(tree.leafCount)}); + splat::LodSelectionData compatibility; + const auto* data = &tree.selection; + if (data->clusters.empty()) { + LOGI("LOD v1 compatibility: constructing selection metadata once at upload"); + compatibility = splat::buildLodSelectionData(tree); + data = &compatibility; + } + const size_t frontierCapacity = std::min(size_t{capacity}, data->clusters.size()); + const size_t groupCount = (frontierCapacity + 31) / 32; + constexpr auto storage = MTLResourceStorageModePrivate; + auto upload = [&](const void* source, size_t bytes) -> id { + auto buffer = metal::buffer(device_, std::max(bytes, size_t{4}), storage); + constexpr size_t chunkBytes = 4u << 20; + auto staging = metal::buffer(device_, std::max(size_t{4}, std::min(chunkBytes, bytes))); + if (!buffer || !staging) return nil; + for (size_t offset = 0; offset < bytes; offset += chunkBytes) { + const size_t count = std::min(chunkBytes, bytes - offset); + std::memcpy(staging.contents, static_cast(source) + offset, count); + auto command = [queue commandBuffer]; + auto blit = [command blitCommandEncoder]; + [blit copyFromBuffer:staging + sourceOffset:0 + toBuffer:buffer + destinationOffset:offset + size:count]; + [blit endEncoding]; + [command commit]; + [command waitUntilCompleted]; + if (command.status == MTLCommandBufferStatusError) return nil; + } + return buffer; + }; + nodes_ = upload(data->clusters.data(), data->clusters.size() * sizeof(splat::LodCluster)); + leaves_ = upload(data->leaves.data(), data->leaves.size() * 4); + indices_ = metal::buffer(device_, size_t{capacity} * 4, storage); + packets_ = + metal::buffer(device_, std::min(size_t{capacity}, data->clusters.size()) * 16, storage); + costs_ = metal::buffer(device_, frontierCapacity * 8, storage); + offsets_ = metal::buffer(device_, frontierCapacity * 16, storage); + costGroups_ = metal::buffer(device_, groupCount * 16, storage); + groups_ = metal::buffer(device_, groupCount * 16, storage); + blocks_ = metal::buffer(device_, ((groupCount + 255) / 256 + 1) * 16, storage); + state_ = metal::buffer(device_, 80, storage); + for (auto& buffer : frontier_) buffer = metal::buffer(device_, frontierCapacity * 4, storage); + if (!nodes_ || !leaves_ || !indices_ || !packets_ || !costs_ || !offsets_ || !costGroups_ || + !groups_ || !blocks_ || !state_ || !frontier_[0] || !frontier_[1]) + return false; + depth_ = valid.value() + 1; + config_ = {capacity, pixelLimit, colorWeight, frustumCull ? 1u : 0u}; + LOGI( + "GPU LOD SSE: %zu interior clusters, %zu leaves, %u rounds, capacity %u, %.2f px, color %.2f", + data->clusters.size(), data->leaves.size(), depth_, capacity, pixelLimit, colorWeight); + return true; +} +void MetalLOD::encode(id command, id uniforms) { + auto start = [&](id pipeline, NSString* label) { + auto e = [command computeCommandEncoder]; + e.label = label; + [e setComputePipelineState:pipeline]; + return e; + }; + auto one = [](id e) { + [e dispatchThreadgroups:MTLSizeMake(1, 1, 1) threadsPerThreadgroup:MTLSizeMake(1, 1, 1)]; + [e endEncoding]; + }; + auto active = [&](id e) { + [e dispatchThreadgroupsWithIndirectBuffer:state_ + indirectBufferOffset:32 + threadsPerThreadgroup:MTLSizeMake(256, 1, 1)]; + [e endEncoding]; + }; + auto scan = [&](id groups) { + auto e = start(scanGroups_, @"LOD parallel scan of SIMD totals"); + [e setBuffer:groups offset:0 atIndex:0]; + [e setBuffer:blocks_ offset:0 atIndex:1]; + [e setBuffer:state_ offset:0 atIndex:2]; + [e dispatchThreadgroupsWithIndirectBuffer:state_ + indirectBufferOffset:64 + threadsPerThreadgroup:MTLSizeMake(256, 1, 1)]; + [e endEncoding]; + e = start(scanBlocks_, @"LOD scan of block totals"); + [e setBuffer:blocks_ offset:0 atIndex:0]; + [e setBuffer:state_ offset:0 atIndex:1]; + one(e); + }; + auto e = start(initialize_, @"Initialize interior LOD frontier"); + [e setBuffer:frontier_[0] offset:0 atIndex:0]; + [e setBuffer:state_ offset:0 atIndex:1]; + one(e); + for (uint32_t level = 0; level < depth_; ++level) { + e = start(evaluate_, @"LOD node bounds and screen error"); + [e setBuffer:uniforms offset:0 atIndex:0]; + [e setBuffer:nodes_ offset:0 atIndex:1]; + [e setBuffer:frontier_[level & 1u] offset:0 atIndex:2]; + [e setBuffer:state_ offset:0 atIndex:3]; + [e setBytes:&config_ length:sizeof(config_) atIndex:4]; + [e setBuffer:costs_ offset:0 atIndex:5]; + [e setBuffer:costGroups_ offset:0 atIndex:6]; + active(e); + scan(costGroups_); + e = start(budget_, @"LOD deterministic capacity guard"); + [e setBuffer:costGroups_ offset:0 atIndex:0]; + [e setBuffer:costs_ offset:0 atIndex:1]; + [e setBuffer:state_ offset:0 atIndex:2]; + [e setBytes:&config_ length:sizeof(config_) atIndex:3]; + [e setBuffer:blocks_ offset:0 atIndex:4]; + one(e); + e = start(compact_, @"LOD SIMD prefix compaction"); + [e setBuffer:nodes_ offset:0 atIndex:0]; + [e setBuffer:frontier_[level & 1u] offset:0 atIndex:1]; + [e setBuffer:costs_ offset:0 atIndex:2]; + [e setBuffer:costGroups_ offset:0 atIndex:3]; + [e setBuffer:state_ offset:0 atIndex:4]; + [e setBuffer:offsets_ offset:0 atIndex:5]; + [e setBuffer:groups_ offset:0 atIndex:6]; + [e setBuffer:blocks_ offset:0 atIndex:7]; + active(e); + scan(groups_); + e = start(allocate_, @"LOD deterministic packet offsets"); + [e setBuffer:blocks_ offset:0 atIndex:0]; + [e setBuffer:state_ offset:0 atIndex:1]; + one(e); + e = start(scatter_, @"LOD child frontier and leaf packets"); + [e setBuffer:nodes_ offset:0 atIndex:0]; + [e setBuffer:frontier_[level & 1u] offset:0 atIndex:1]; + [e setBuffer:offsets_ offset:0 atIndex:2]; + [e setBuffer:groups_ offset:0 atIndex:3]; + [e setBuffer:state_ offset:0 atIndex:4]; + [e setBuffer:frontier_[(level + 1) & 1u] offset:0 atIndex:5]; + [e setBuffer:packets_ offset:0 atIndex:6]; + [e setBuffer:indices_ offset:0 atIndex:7]; + [e setBuffer:leaves_ offset:0 atIndex:8]; + [e setBuffer:blocks_ offset:0 atIndex:9]; + active(e); + e = start(advance_, @"Advance LOD indirect work"); + [e setBuffer:state_ offset:0 atIndex:0]; + one(e); + } + e = start(emit_, @"Emit LOD leaf indices cooperatively"); + [e setBuffer:packets_ offset:0 atIndex:0]; + [e setBuffer:leaves_ offset:0 atIndex:1]; + [e setBuffer:state_ offset:0 atIndex:2]; + [e setBuffer:indices_ offset:0 atIndex:3]; + [e dispatchThreadgroupsWithIndirectBuffer:state_ + indirectBufferOffset:44 + threadsPerThreadgroup:MTLSizeMake(256, 1, 1)]; + [e endEncoding]; +} +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalRadixSort.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalRadixSort.h new file mode 100644 index 0000000..f0c291c --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalRadixSort.h @@ -0,0 +1,50 @@ +#pragma once + +#import + +#include +#include + +namespace splatkit { + +// Stable ascending sort of uint32 key/value pairs. No camera or splat knowledge. +// Call reserve while idle, fill keys()/values(), then encode on the render queue. +// The GPU-written count must not exceed capacity(). Results are in the same buffers. +// Scratch is shared across frames: all encodes and their consumers use one queue. +class MetalRadixSort { + public: + // Low16 requires zero upper bits. Keys remain uint32 storage in both modes. + enum class KeyBits : uint32_t { Low16 = 16, Full32 = 32 }; + bool create(id device, id library, + MTLResourceOptions storage = MTLResourceStorageModeShared); + // Transactional allocation: failure preserves the previous buffers and capacity. + bool reserve(uint32_t capacity); + uint32_t capacity() const { return capacity_; } + void encode(id cmd, id count, KeyBits bits = KeyBits::Full32); + id keys() const { return keys_[0]; } + id values() const { return values_[0]; } + + static constexpr uint32_t kThreads = 256; + static constexpr uint32_t kBlock = kThreads * 16; + + private: + static constexpr uint32_t kDigitBits = 8; + static constexpr uint32_t kBins = 1u << kDigitBits; + static_assert(16 / kDigitBits % 2 == 0 && 32 / kDigitBits % 2 == 0, + "Both key widths must finish in the input buffers"); + + id device_ = nil; + MTLResourceOptions storage_ = MTLResourceStorageModeShared; + id prepare_ = nil; + id histogram_ = nil; + id scan_ = nil; + id scatter_ = nil; + uint32_t capacity_ = 0; + std::array, 2> keys_{}; + std::array, 2> values_{}; + id histogramBuffer_ = nil; + id totals_ = nil; + id dispatch_ = nil; +}; + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalRadixSort.mm b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalRadixSort.mm new file mode 100644 index 0000000..25f113e --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalRadixSort.mm @@ -0,0 +1,100 @@ +#include "rendering/MetalRadixSort.h" + +#include "rendering/MetalCompute.h" + +namespace splatkit { + +bool MetalRadixSort::create(id device, id library, + MTLResourceOptions storage) { + device_ = device; + storage_ = storage; + prepare_ = metal::pipeline(device, library, "prepareRadixSort"); + histogram_ = metal::pipeline(device, library, "radixHistogram"); + scan_ = metal::pipeline(device, library, "radixScan"); + scatter_ = metal::pipeline(device, library, "radixScatter"); + // The shader's blocked layout and ballot masks use 32 lanes per SIMD group. + if (histogram_.threadExecutionWidth != 32 || scan_.threadExecutionWidth != 32 || + scatter_.threadExecutionWidth != 32) { + LOGE("radix sort requires 32-lane SIMD groups"); + return false; + } + totals_ = metal::buffer(device, kBins * sizeof(uint32_t), storage_); + dispatch_ = metal::buffer(device, 4 * sizeof(uint32_t), storage_); + return prepare_ != nil && histogram_ != nil && scan_ != nil && scatter_ != nil && + totals_ != nil && dispatch_ != nil; +} + +bool MetalRadixSort::reserve(uint32_t capacity) { + capacity = std::max(capacity, 1u); + if (capacity <= capacity_) return true; + const size_t blocks = (size_t{capacity} + kBlock - 1) / kBlock; + std::array, 2> keys{}; + std::array, 2> values{}; + for (auto& key : keys) + key = metal::buffer(device_, size_t{capacity} * sizeof(uint32_t), storage_); + for (auto& value : values) + value = metal::buffer(device_, size_t{capacity} * sizeof(uint32_t), storage_); + id histogram = metal::buffer(device_, blocks * kBins * sizeof(uint32_t), storage_); + if (keys[0] == nil || keys[1] == nil || values[0] == nil || values[1] == nil || + histogram == nil) { + LOGE("sort buffers for %u pairs failed", capacity); + return false; + } + keys_ = keys; + values_ = values; + histogramBuffer_ = histogram; + capacity_ = capacity; + return true; +} + +void MetalRadixSort::encode(id cmd, id count, KeyBits bits) { + id dispatch = dispatch_; + id enc = [cmd computeCommandEncoder]; + enc.label = bits == KeyBits::Low16 ? @"Splat radix sort (16 bits / 2 passes)" + : @"Splat radix sort (32 bits / 4 passes)"; + [enc setComputePipelineState:prepare_]; + [enc setBuffer:count offset:0 atIndex:0]; + [enc setBuffer:dispatch offset:0 atIndex:1]; + [enc dispatchThreadgroups:MTLSizeMake(1, 1, 1) threadsPerThreadgroup:MTLSizeMake(1, 1, 1)]; + + const MTLSize threads = MTLSizeMake(kThreads, 1, 1); + const uint32_t passes = bits == KeyBits::Low16 ? 2u : 4u; + for (uint32_t pass = 0; pass < passes; ++pass) { + const uint32_t shift = pass * kDigitBits; + const uint32_t in = pass & 1u; + const uint32_t out = in ^ 1u; + + [enc setComputePipelineState:histogram_]; + [enc setBuffer:keys_[in] offset:0 atIndex:0]; + [enc setBuffer:count offset:0 atIndex:1]; + [enc setBuffer:dispatch offset:0 atIndex:2]; + [enc setBytes:&shift length:sizeof(shift) atIndex:3]; + [enc setBuffer:histogramBuffer_ offset:0 atIndex:4]; + [enc dispatchThreadgroupsWithIndirectBuffer:dispatch + indirectBufferOffset:0 + threadsPerThreadgroup:threads]; + + [enc setComputePipelineState:scan_]; + [enc setBuffer:dispatch offset:0 atIndex:0]; + [enc setBuffer:histogramBuffer_ offset:0 atIndex:1]; + [enc setBuffer:totals_ offset:0 atIndex:2]; + [enc dispatchThreadgroups:MTLSizeMake(kBins, 1, 1) threadsPerThreadgroup:threads]; + + [enc setComputePipelineState:scatter_]; + [enc setBuffer:keys_[in] offset:0 atIndex:0]; + [enc setBuffer:values_[in] offset:0 atIndex:1]; + [enc setBuffer:keys_[out] offset:0 atIndex:2]; + [enc setBuffer:values_[out] offset:0 atIndex:3]; + [enc setBuffer:count offset:0 atIndex:4]; + [enc setBuffer:dispatch offset:0 atIndex:5]; + [enc setBytes:&shift length:sizeof(shift) atIndex:6]; + [enc setBuffer:histogramBuffer_ offset:0 atIndex:7]; + [enc setBuffer:totals_ offset:0 atIndex:8]; + [enc dispatchThreadgroupsWithIndirectBuffer:dispatch + indirectBufferOffset:0 + threadsPerThreadgroup:threads]; + } + [enc endEncoding]; +} + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalShaderTypes.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalShaderTypes.h new file mode 100644 index 0000000..40e537a --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalShaderTypes.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include + +#include "splat/math/Mat4.h" + +namespace splatkit { + +// Host layouts for shaders/SplatTypes.metalh. Private to the Metal renderer. +// World records remain in the platform-independent GpuLayout.h. +struct alignas(16) CameraUniform { + splat::Mat4 view; + splat::Mat4 proj; + float focal[2]; + float tanHalfFov[2]; + float screenSize[2]; + uint32_t outputLinear; + uint32_t pad; + float cameraPosition[4]; +}; +static_assert(sizeof(CameraUniform) == 176); +static_assert(offsetof(CameraUniform, focal) == 128); +static_assert(offsetof(CameraUniform, outputLinear) == 152); +static_assert(offsetof(CameraUniform, cameraPosition) == 160); + +struct alignas(8) ProjectedSplat { + float center[2]; + uint32_t axis1; + uint32_t axis2; + uint32_t color0; + uint32_t color1; + float radius; + uint32_t index; +}; +static_assert(sizeof(ProjectedSplat) == 32); +static_assert(offsetof(ProjectedSplat, index) == 28); + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.h new file mode 100644 index 0000000..7bbc446 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.h @@ -0,0 +1,156 @@ +#pragma once + +#import +#import + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rendering/MetalLOD.h" +#include "rendering/MetalTileRaster.h" +#include "rendering/MetalVisibility.h" +#include "rendering/MetalWorld.h" +#include "splatkit/rendering/GpuLayout.h" +#include "splatkit/rendering/SplatRenderer.h" + +namespace splatkit { + +// Everything between a CAMetalLayer and a presented frame: the device and queue, the +// pipelines built for the layer's pixel format, the offscreen target a render scale +// needs, and the world bound to them. The layer is attached and sized by the view; the +// world stays through a detach. Render thread only, except where noted. +class MetalSplatRenderer final : public SplatRenderer { + public: + // Null when the device has no Metal. + static std::unique_ptr create(); + ~MetalSplatRenderer() override; + + MetalSplatRenderer(const MetalSplatRenderer&) = delete; + MetalSplatRenderer& operator=(const MetalSplatRenderer&) = delete; + + // The layer to present to, or nil when the view is going away. The renderer sets its + // device and pixel format; the view sets its size through `setDrawableSize`. + void setLayer(CAMetalLayer* layer); + // The layer's size in pixels. Rebuilds the offscreen target if there is one. + void setDrawableSize(uint32_t width, uint32_t height); + + void setRenderScale(float scale) override; + float renderScale() const override { return renderScale_; } + void setLinearBlending(bool linear) override; + bool linearBlending() const override { return linearBlending_; } + void setVsync(bool vsync) override; + + bool ready() const override { return layer_ != nil && width_ > 0 && height_ > 0; } + Extent drawExtent() const override; + uint32_t generation() const override { return generation_; } + + bool uploadWorld(const splat::SplatCloud& cloud, int maxShDegree) override; + bool selectsLodOnGpu() const override { return gpuSort_; } + bool uploadLodWorld(const splat::LodTree& tree, int maxShDegree, uint32_t budget) override; + bool createSlab(uint32_t capacity, int shDegree) override; + bool uploadTile(uint32_t offset, const splat::SplatCloud& cloud) override; + std::optional world() const override; + + bool draw(const Frame& frame) override; + + // Any thread. A successful GPU frame of the current world has finished; uploads + // alone and background frames do not qualify. Reset when the world is replaced. + bool hasCompletedWorldFrame() const { return !gpuFailed_.load() && completedWorldFrame_.load(); } + + // Pixels of a presented frame: BGRA, 8 bits each, rows top down, `width` by `height`. + using CaptureHandler = + std::function bgra, uint32_t width, uint32_t height)>; + // Hands the next frame's pixels to `handler`, from the GPU's completion thread. One + // capture at a time; a request while one is pending replaces it. + void captureNextFrame(CaptureHandler handler); + double lastGpuMillis() const override { return gpuFailed_.load() ? 0 : lastGpuMillis_.load(); } + // The cull and the sort run as compute passes on the GPU (MetalVisibility); the engine + // hands over the ranges to draw and never sorts on the CPU for this renderer. + bool sortsOnGpu() const override { return gpuSort_; } + double lastSortMillis() const override { return gpuFailed_.load() ? 0 : lastSortMillis_.load(); } + uint32_t lastDrawCount() const override { return gpuFailed_.load() ? 0 : lastDrawCount_.load(); } + uint32_t lastSelectedCount() const override { + return gpuFailed_.load() ? 0 : lastSelectedCount_.load(); + } + double lastSelectMillis() const override { + return gpuFailed_.load() ? 0 : lastSelectMillis_.load(); + } + uint32_t lastLodLimitedCount() const { return lod_ ? lastLodLimitedCount_.load() : 0; } + uint32_t lastLodEvaluatedCount() const { return lod_ ? lastLodEvaluatedCount_.load() : 0; } + ScreenTileStats lastScreenTileStats() const override { + if (gpuFailed_.load()) return {}; + return {lastComputeTiles_.load(), lastNonemptyComputeTiles_.load(), lastHardwareTiles_.load()}; + } + const std::string& deviceDescription() const override { return description_; } + + static constexpr int kMaxShDegree = 3; + static constexpr uint32_t kFramesInFlight = MetalVisibility::kSlots; + + private: + MetalSplatRenderer() = default; + bool createPipelines(); + bool createTarget(); + MTLPixelFormat pixelFormat() const; + // Where the splats are drawn: the drawable's format, or half floats when the GPU order + // path accumulates coverage front to back, which 8 bits would round away. + MTLPixelFormat targetFormat() const; + void waitIdle(); + + id device_ = nil; + id queue_ = nil; + id library_ = nil; + std::array, kMaxShDegree + 1> splatPipelines_{}; + id blitPipeline_ = nil; + // The GPU order path: front to back in batches, a saturation mask between them, the + // background last. + id projectedPipeline_ = nil; + id maskPipeline_ = nil; + id backgroundPipeline_ = nil; + id splatDepth_ = nil; // pass unless masked, never write + id maskDepth_ = nil; // always write + id depth_ = nil; // GPU-private, the size of the colour target + bool createDepth(NSUInteger width, NSUInteger height); + MTLPixelFormat pipelineFormat_ = MTLPixelFormatInvalid; + std::array, kFramesInFlight> uniforms_{}; + dispatch_semaphore_t inFlight_ = nullptr; + + CAMetalLayer* layer_ = nil; + uint32_t width_ = 0; + uint32_t height_ = 0; + id target_ = nil; // scaled rendering or half-float GPU compositing + std::unique_ptr world_; + float renderScale_ = 1.0f; + bool linearBlending_ = false; + uint32_t generation_ = 0; + uint64_t frame_ = 0; + std::atomic lastGpuMillis_{0}; + std::atomic completedWorldFrame_{false}; + MetalVisibility visibility_; + std::unique_ptr lod_; + std::array, kFramesInFlight> lodReadback_{}; + std::atomic lastSelectedCount_{0}; + std::atomic lastLodLimitedCount_{0}, lastLodEvaluatedCount_{0}; + std::atomic lastSelectMillis_{0}; + float minPixelRadius_ = 0.5f; + MetalRadixSort::KeyBits depthBits_ = MetalRadixSort::KeyBits::Full32; + MetalTileRaster tileRaster_; + bool computeRaster_ = false; + // A GPU error latches this renderer off. Never repeatedly resubmit failed work. + std::atomic gpuFailed_{false}; + bool gpuSort_ = false; + std::atomic lastSortMillis_{0}; + std::atomic lastDrawCount_{0}; + std::atomic lastComputeTiles_{0}; + std::atomic lastNonemptyComputeTiles_{0}; + std::atomic lastHardwareTiles_{0}; + CaptureHandler capture_; + std::string description_; +}; + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm new file mode 100644 index 0000000..8d87205 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm @@ -0,0 +1,652 @@ +#include "rendering/MetalSplatRenderer.h" + +#include +#include +#include +#include +#include + +#include "SplatShaderSource.h" +#include "rendering/MetalCompute.h" +#include "rendering/MetalShaderTypes.h" +#include "splat/math/Mat4.h" +#include "splatkit/Log.h" + +namespace splatkit { + +namespace { +constexpr MTLPixelFormat kDepthFormat = MTLPixelFormatDepth16Unorm; +constexpr MTLClearColor kBackground = {0.05, 0.05, 0.08, 1.0}; +} // namespace + +std::unique_ptr MetalSplatRenderer::create() { + std::unique_ptr r(new MetalSplatRenderer()); + r->device_ = MTLCreateSystemDefaultDevice(); + if (r->device_ == nil) { + LOGE("no Metal device"); + return nullptr; + } + // The embedded library uses SIMD reductions; reject unsupported GPUs before compiling it. + if (![r->device_ supportsFamily:MTLGPUFamilyApple7]) { + LOGE("SplatKit requires Apple GPU family 7 or newer (A14/M1+)"); + return nullptr; + } + r->queue_ = [r->device_ newCommandQueue]; + NSError* error = nil; + // SIMD prefix reductions are available on iOS starting with MSL 2.3. + MTLCompileOptions* options = [MTLCompileOptions new]; + options.languageVersion = MTLLanguageVersion2_3; + r->library_ = [r->device_ newLibraryWithSource:@(SplatShaderSource) options:options error:&error]; + if (r->library_ == nil) { + LOGE("shader compilation failed: %s", error.localizedDescription.UTF8String); + return nullptr; + } + if (r->queue_ == nil) return nullptr; + for (auto& uniform : r->uniforms_) { + uniform = metal::buffer(r->device_, sizeof(CameraUniform)); + if (uniform == nil) return nullptr; + } + // Temporary internal opt-in, set before renderer creation by the dev app. + // Not a public SDK setting until device performance and quality are accepted. + const char* experimentValue = std::getenv("SPLATKIT_METAL_CULLING_EXPERIMENT"); + const bool experiment = experimentValue != nullptr && std::strcmp(experimentValue, "1") == 0; + float minPixelRadius = 0.5f; + const char* radiusValue = std::getenv("SPLATKIT_METAL_MIN_PIXEL_RADIUS"); + if (radiusValue != nullptr) { + char* end = nullptr; + const float value = std::strtof(radiusValue, &end); + if (end != radiusValue && *end == '\0' && std::isfinite(value) && value >= 0.0f) + minPixelRadius = value; + else + LOGW("invalid experimental pixel radius; using 0.5px"); + } + const char* depthBits = std::getenv("SPLATKIT_METAL_DEPTH_KEY_BITS"); + if (depthBits != nullptr && std::strcmp(depthBits, "16") == 0) + r->depthBits_ = MetalRadixSort::KeyBits::Low16; + else if (depthBits != nullptr && std::strcmp(depthBits, "32") != 0) + LOGW("invalid experimental depth key width; using 32 bits"); + r->gpuSort_ = + r->visibility_.create(r->device_, r->library_, experiment, minPixelRadius, r->depthBits_); + LOGI("Metal depth keys: %u bits, %u radix passes (uint32 scratch)", + static_cast(r->depthBits_), static_cast(r->depthBits_) / 8); + r->minPixelRadius_ = minPixelRadius; + if (experiment) + LOGI("Metal culling experiment: opacity < 1/255, footprint bounds, %.2fpx, view depth, private " + "scratch", + minPixelRadius); + if (!r->gpuSort_) LOGW("GPU sort unavailable, sorting on the CPU"); + const char* tileValue = std::getenv("SPLATKIT_METAL_TILE_RASTER"); + if (r->gpuSort_ && tileValue != nullptr && std::strcmp(tileValue, "1") == 0) { + r->computeRaster_ = r->tileRaster_.create(r->device_, r->library_); + LOGI("Experimental tile raster: %s", + r->computeRaster_ + ? "bounded hybrid enabled (512 candidates / local large-footprint fallback)" + : "unavailable; using hardware"); + } + r->inFlight_ = dispatch_semaphore_create(kFramesInFlight); + r->description_ = std::string(r->device_.name.UTF8String) + ", Metal"; + LOGI("%s", r->description_.c_str()); + return r; +} + +MetalSplatRenderer::~MetalSplatRenderer() { + waitIdle(); +} + +// Waits for every frame in flight: the order and world buffers may be released after. +void MetalSplatRenderer::waitIdle() { + if (inFlight_ == nullptr) return; + for (uint32_t i = 0; i < kFramesInFlight; ++i) { + dispatch_semaphore_wait(inFlight_, DISPATCH_TIME_FOREVER); + } + for (uint32_t i = 0; i < kFramesInFlight; ++i) dispatch_semaphore_signal(inFlight_); +} + +void MetalSplatRenderer::setLayer(CAMetalLayer* layer) { + if (layer == layer_) return; + waitIdle(); + layer_ = layer; + if (layer_ == nil) return; + layer_.device = device_; + layer_.pixelFormat = pixelFormat(); + layer_.framebufferOnly = YES; + ++generation_; + if (pipelineFormat_ != targetFormat() && !createPipelines()) { + LOGE("rendering stopped: no pipelines"); + layer_ = nil; + } +} + +void MetalSplatRenderer::setDrawableSize(uint32_t width, uint32_t height) { + if (width == width_ && height == height_) return; + LOGI("drawable %ux%u, was %ux%u", width, height, width_, height_); + width_ = width; + height_ = height; + ++generation_; + createTarget(); +} + +void MetalSplatRenderer::setRenderScale(float scale) { + scale = std::clamp(scale, 0.1f, 2.0f); + if (scale == renderScale_) return; + renderScale_ = scale; + ++generation_; + createTarget(); +} + +void MetalSplatRenderer::setLinearBlending(bool linear) { + if (linear == linearBlending_) return; + linearBlending_ = linear; + waitIdle(); + if (layer_ != nil) layer_.pixelFormat = pixelFormat(); + ++generation_; + createTarget(); + if (!createPipelines()) { + LOGE("rendering stopped: no pipelines"); + layer_ = nil; + } +} + +void MetalSplatRenderer::setVsync(bool vsync) { + // Presentation is tied to the display link that drives the frames; a benchmark + // without vsync would need its own loop. Frame times are the GPU times either way. + (void)vsync; +} + +MTLPixelFormat MetalSplatRenderer::pixelFormat() const { + return linearBlending_ ? MTLPixelFormatBGRA8Unorm_sRGB : MTLPixelFormatBGRA8Unorm; +} + +Extent MetalSplatRenderer::drawExtent() const { + if (target_ != nil) { + return {static_cast(target_.width), static_cast(target_.height)}; + } + return {width_, height_}; +} + +// Back the transient depth buffer with GPU-private memory. Large splat passes can +// exhaust the tiler's parameter buffer; memoryless attachments prevent spilling +// that pass and fail on iPhone with OutOfMemoryForParameterBuffer. +bool MetalSplatRenderer::createDepth(NSUInteger width, NSUInteger height) { + if (depth_ != nil && depth_.width == width && depth_.height == height) return true; + MTLTextureDescriptor* desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:kDepthFormat + width:width + height:height + mipmapped:NO]; + desc.usage = MTLTextureUsageRenderTarget; + desc.storageMode = MTLStorageModePrivate; + depth_ = [device_ newTextureWithDescriptor:desc]; + if (depth_ == nil) + LOGE("depth buffer %lux%lu failed", (unsigned long)width, (unsigned long)height); + return depth_ != nil; +} + +MTLPixelFormat MetalSplatRenderer::targetFormat() const { + return gpuSort_ ? MTLPixelFormatRGBA16Float : pixelFormat(); +} + +bool MetalSplatRenderer::createTarget() { + target_ = nil; + if ((renderScale_ == 1.0f && !gpuSort_) || width_ == 0 || height_ == 0) return true; + MTLTextureDescriptor* desc = [MTLTextureDescriptor + texture2DDescriptorWithPixelFormat:targetFormat() + width:std::max(1u, static_cast(width_ * renderScale_)) + height:std::max(1u, static_cast(height_ * renderScale_)) + mipmapped:NO]; + desc.usage = MTLTextureUsageRenderTarget | MTLTextureUsageShaderRead; + if (computeRaster_) desc.usage |= MTLTextureUsageShaderWrite; + desc.storageMode = MTLStorageModePrivate; + target_ = [device_ newTextureWithDescriptor:desc]; + if (target_ == nil) { + LOGE("render target %lux%lu failed", static_cast(desc.width), + static_cast(desc.height)); + } + return target_ != nil; +} + +// One splat pipeline per spherical harmonics degree, specialised through a function +// constant so a degree 0 world pays nothing for SH, plus the render scale pass. +bool MetalSplatRenderer::createPipelines() { + pipelineFormat_ = targetFormat(); + NSError* error = nil; + id fragment = [library_ newFunctionWithName:@"splatFragment"]; + for (int degree = 0; degree <= kMaxShDegree; ++degree) { + MTLFunctionConstantValues* constants = [MTLFunctionConstantValues new]; + uint32_t value = static_cast(degree); + [constants setConstantValue:&value type:MTLDataTypeUInt atIndex:0]; + id vertex = [library_ newFunctionWithName:@"splatVertex" + constantValues:constants + error:&error]; + if (vertex == nil) { + LOGE("splat vertex function: %s", error.localizedDescription.UTF8String); + return false; + } + MTLRenderPipelineDescriptor* desc = [MTLRenderPipelineDescriptor new]; + desc.vertexFunction = vertex; + desc.fragmentFunction = fragment; + desc.colorAttachments[0].pixelFormat = pipelineFormat_; + desc.depthAttachmentPixelFormat = kDepthFormat; + // "Over" compositing, back to front: out = src.a * src + (1 - src.a) * dst. + desc.colorAttachments[0].blendingEnabled = YES; + desc.colorAttachments[0].sourceRGBBlendFactor = MTLBlendFactorSourceAlpha; + desc.colorAttachments[0].destinationRGBBlendFactor = MTLBlendFactorOneMinusSourceAlpha; + desc.colorAttachments[0].sourceAlphaBlendFactor = MTLBlendFactorOne; + desc.colorAttachments[0].destinationAlphaBlendFactor = MTLBlendFactorOneMinusSourceAlpha; + id state = [device_ newRenderPipelineStateWithDescriptor:desc + error:&error]; + if (state == nil) { + LOGE("splat pipeline degree %d: %s", degree, error.localizedDescription.UTF8String); + return false; + } + splatPipelines_[static_cast(degree)] = state; + } + + // The GPU order path draws the projections the visibility kernel wrote, front to + // back with "under" compositing of premultiplied colour onto a clear of zero: + // out = (1 - dst.a) * src + dst, for colour and coverage alike. + MTLRenderPipelineDescriptor* under = [MTLRenderPipelineDescriptor new]; + under.vertexFunction = [library_ newFunctionWithName:@"projectedVertex"]; + under.fragmentFunction = [library_ newFunctionWithName:@"splatFragmentUnder"]; + under.colorAttachments[0].pixelFormat = pipelineFormat_; + under.depthAttachmentPixelFormat = kDepthFormat; + under.colorAttachments[0].blendingEnabled = YES; + under.colorAttachments[0].sourceRGBBlendFactor = MTLBlendFactorOneMinusDestinationAlpha; + under.colorAttachments[0].destinationRGBBlendFactor = MTLBlendFactorOne; + under.colorAttachments[0].sourceAlphaBlendFactor = MTLBlendFactorOneMinusDestinationAlpha; + under.colorAttachments[0].destinationAlphaBlendFactor = MTLBlendFactorOne; + projectedPipeline_ = [device_ newRenderPipelineStateWithDescriptor:under error:&error]; + if (projectedPipeline_ == nil) { + LOGE("projected pipeline: %s", error.localizedDescription.UTF8String); + return false; + } + // The background goes under whatever coverage is left, with the same blend. + under.vertexFunction = [library_ newFunctionWithName:@"blitVertex"]; + under.fragmentFunction = [library_ newFunctionWithName:@"backgroundFragment"]; + backgroundPipeline_ = [device_ newRenderPipelineStateWithDescriptor:under error:&error]; + if (backgroundPipeline_ == nil) { + LOGE("background pipeline: %s", error.localizedDescription.UTF8String); + return false; + } + // The saturation mask touches the depth buffer only. + MTLRenderPipelineDescriptor* mask = [MTLRenderPipelineDescriptor new]; + mask.vertexFunction = [library_ newFunctionWithName:@"blitVertex"]; + mask.fragmentFunction = [library_ newFunctionWithName:@"saturationMask"]; + mask.colorAttachments[0].pixelFormat = pipelineFormat_; + mask.colorAttachments[0].writeMask = MTLColorWriteMaskNone; + mask.depthAttachmentPixelFormat = kDepthFormat; + maskPipeline_ = [device_ newRenderPipelineStateWithDescriptor:mask error:&error]; + if (maskPipeline_ == nil) { + LOGE("saturation mask pipeline: %s", error.localizedDescription.UTF8String); + return false; + } + MTLDepthStencilDescriptor* depth = [MTLDepthStencilDescriptor new]; + depth.depthCompareFunction = MTLCompareFunctionLessEqual; + depth.depthWriteEnabled = NO; + splatDepth_ = [device_ newDepthStencilStateWithDescriptor:depth]; + depth.depthCompareFunction = MTLCompareFunctionAlways; + depth.depthWriteEnabled = YES; + maskDepth_ = [device_ newDepthStencilStateWithDescriptor:depth]; + + MTLRenderPipelineDescriptor* blit = [MTLRenderPipelineDescriptor new]; + blit.vertexFunction = [library_ newFunctionWithName:@"blitVertex"]; + blit.fragmentFunction = [library_ newFunctionWithName:@"blitFragment"]; + blit.colorAttachments[0].pixelFormat = pixelFormat(); + blitPipeline_ = [device_ newRenderPipelineStateWithDescriptor:blit error:&error]; + if (blitPipeline_ == nil) { + LOGE("blit pipeline: %s", error.localizedDescription.UTF8String); + return false; + } + LOGI("pipelines for format %lu", static_cast(pipelineFormat_)); + return true; +} + +// Worlds. + +bool MetalSplatRenderer::uploadWorld(const splat::SplatCloud& cloud, int maxShDegree) { + auto world = MetalWorld::upload(device_, queue_, cloud, maxShDegree); + if (!world) return false; + waitIdle(); + if (gpuSort_ && !visibility_.reserve(world->info().count)) return false; + world_ = std::move(world); + lod_.reset(); + completedWorldFrame_.store(false); + lastSelectedCount_.store(0); + lastSelectMillis_.store(0); + return true; +} + +bool MetalSplatRenderer::uploadLodWorld(const splat::LodTree& tree, int maxShDegree, + uint32_t budget) { + if (!gpuSort_) return false; + waitIdle(); + auto lod = std::make_unique(); + float qualityPixels = 1.0f; + if (const char* text = std::getenv("SPLATKIT_METAL_LOD_QUALITY_PIXELS")) { + char* end = nullptr; + const float value = std::strtof(text, &end); + if (end == text || *end != '\0' || !std::isfinite(value) || value < 0) { + LOGE("invalid experimental LOD quality threshold"); + return false; + } + qualityPixels = value; + } + if (!lod->create(device_, library_) || !lod->upload(queue_, tree, budget, qualityPixels)) + return false; + // Compact projections and radix scratch scale with the cut, not all resident nodes. + MetalVisibility visibility; + if (!visibility.create(device_, library_, true, minPixelRadius_, depthBits_) || + !visibility.reserve(static_cast(tree.nodeCount()), lod->budget())) + return false; + std::array, kFramesInFlight> readback{}; + for (auto& buffer : readback) { + buffer = metal::buffer(device_, 6 * sizeof(uint32_t)); + if (!buffer) return false; + } + auto world = MetalWorld::upload(device_, queue_, tree.nodes, maxShDegree); + if (!world) return false; + visibility_ = std::move(visibility); + lod_ = std::move(lod); + lodReadback_ = readback; + lastLodLimitedCount_.store(0); + lastLodEvaluatedCount_.store(0); + world_ = std::move(world); + completedWorldFrame_.store(false); + lastSelectedCount_.store(0); + lastSelectMillis_.store(0); + return true; +} + +bool MetalSplatRenderer::createSlab(uint32_t capacity, int shDegree) { + auto world = MetalWorld::slab(device_, capacity, shDegree); + if (!world) return false; + waitIdle(); + if (gpuSort_ && !visibility_.reserve(capacity)) return false; + world_ = std::move(world); + lod_.reset(); + completedWorldFrame_.store(false); + lastSelectedCount_.store(0); + lastSelectMillis_.store(0); + return true; +} + +bool MetalSplatRenderer::uploadTile(uint32_t offset, const splat::SplatCloud& cloud) { + return world_ && world_->uploadTile(offset, cloud); +} + +std::optional MetalSplatRenderer::world() const { + return world_ ? std::optional{world_->info()} : std::nullopt; +} + +// The frame. + +bool MetalSplatRenderer::draw(const Frame& frame) { + if (gpuFailed_.load()) return false; + if (!ready() || splatPipelines_[0] == nil) return false; + dispatch_semaphore_wait(inFlight_, DISPATCH_TIME_FOREVER); + if (gpuFailed_.load()) { + dispatch_semaphore_signal(inFlight_); + return false; + } + // A capture copies the presented pixels out before the drawable goes to the screen, + // and the layer only hands out readable drawables while `framebufferOnly` is off. + if (capture_) layer_.framebufferOnly = NO; + id drawable = [layer_ nextDrawable]; + if (drawable == nil) { + dispatch_semaphore_signal(inFlight_); + return false; + } + const uint32_t slot = static_cast(frame_ % kFramesInFlight); + const Extent extent = drawExtent(); + + // A new order goes into the buffer the frame in flight is not reading. + uint32_t drawCount = 0; + if (world_) { + if (frame.order != nullptr) { + if (!world_->writeOrder(frame.order, frame.orderCount)) { + dispatch_semaphore_signal(inFlight_); + return false; + } + } + drawCount = std::min(frame.drawCount, world_->info().count); + } + + CameraUniform u{}; + u.view = frame.view; + u.proj = frame.proj; + u.screenSize[0] = static_cast(extent.width); + u.screenSize[1] = static_cast(extent.height); + u.focal[0] = u.screenSize[0] * frame.proj.at(0, 0) / 2; + u.focal[1] = u.screenSize[1] * frame.proj.at(1, 1) / 2; + u.tanHalfFov[0] = 1 / frame.proj.at(0, 0); + u.tanHalfFov[1] = 1 / frame.proj.at(1, 1); + u.outputLinear = linearBlending_ ? 1u : 0u; + u.cameraPosition[0] = frame.cameraPosition.x; + u.cameraPosition[1] = frame.cameraPosition.y; + u.cameraPosition[2] = frame.cameraPosition.z; + std::memcpy(uniforms_[slot].contents, &u, sizeof(u)); + + // The GPU order: its own command buffer, so its time is known apart from the draw's. + const bool gpuOrder = world_ && frame.orderSource == OrderSource::gpu && gpuSort_; + if (gpuOrder) { + id sort = [queue_ commandBuffer]; + const int degree = + std::clamp(std::min(frame.shDegree, world_->info().shDegree), 0, kMaxShDegree); + if (lod_) { + auto selection = [queue_ commandBuffer]; + selection.label = @"GPU LOD selection"; + lod_->encode(selection, uniforms_[slot]); + id selectedCount = lodReadback_[slot]; + auto readback = [selection blitCommandEncoder]; + [readback copyFromBuffer:lod_->count() + sourceOffset:0 + toBuffer:selectedCount + destinationOffset:0 + size:24]; + [readback endEncoding]; + std::atomic* selected = &lastSelectedCount_; + std::atomic* milliseconds = &lastSelectMillis_; + std::atomic* limited = &lastLodLimitedCount_; + std::atomic* evaluated = &lastLodEvaluatedCount_; + const bool logLod = frame_ % 120 == 0; + std::atomic* failed = &gpuFailed_; + [selection addCompletedHandler:^(id done) { + if (done.status == MTLCommandBufferStatusError) { + failed->store(true); + LOGE("LOD command failed: %s", done.error.localizedDescription.UTF8String); + return; + } + selected->store(*static_cast(selectedCount.contents)); + const auto* counters = static_cast(selectedCount.contents); + limited->store(counters[4]); + evaluated->store(counters[5]); + if (logLod) + LOGI("LOD SSE: %u selected, %u evaluated interiors, %u quality-limited refinements", + counters[0], counters[5], counters[4]); + milliseconds->store((done.GPUEndTime - done.GPUStartTime) * 1000.0); + }]; + [selection commit]; + } + if (!visibility_.encode(sort, slot, uniforms_[slot], world_->splats(), world_->harmonics(), + degree, lod_ ? nullptr : frame.ranges, lod_ ? 0 : frame.rangeCount, + lod_ ? lod_->indices() : nil, lod_ ? lod_->count() : nil)) { + LOGE("invalid visibility ranges"); + dispatch_semaphore_signal(inFlight_); + return false; + } + std::atomic* sortMillis = &lastSortMillis_; + std::atomic* drawn = &lastDrawCount_; + id countBuffer = visibility_.countBuffer(slot); + std::atomic* failed = &gpuFailed_; + [sort addCompletedHandler:^(id done) { + if (done.status == MTLCommandBufferStatusError) { + failed->store(true); + LOGE("visibility command failed: %s", done.error.localizedDescription.UTF8String); + return; + } + sortMillis->store((done.GPUEndTime - done.GPUStartTime) * 1000.0); + drawn->store(*static_cast(countBuffer.contents)); + }]; + [sort commit]; + } + + id cmd = [queue_ commandBuffer]; + MTLRenderPassDescriptor* pass = [MTLRenderPassDescriptor renderPassDescriptor]; + id colour = target_ != nil ? target_ : drawable.texture; + const bool tileRendered = + gpuOrder && computeRaster_ && target_ != nil && + tileRaster_.encode(cmd, uniforms_[slot], visibility_.projected(), visibility_.order(), + visibility_.countBuffer(slot), visibility_.capacity(), target_, slot); + { + pass.colorAttachments[0].texture = colour; + pass.colorAttachments[0].loadAction = tileRendered ? MTLLoadActionLoad : MTLLoadActionClear; + pass.colorAttachments[0].storeAction = MTLStoreActionStore; + // Front to back accumulates onto nothing and puts the background under at the end. + pass.colorAttachments[0].clearColor = gpuOrder ? MTLClearColorMake(0, 0, 0, 0) : kBackground; + if (createDepth(colour.width, colour.height)) { + pass.depthAttachment.texture = depth_; + pass.depthAttachment.loadAction = MTLLoadActionClear; + pass.depthAttachment.storeAction = MTLStoreActionDontCare; + pass.depthAttachment.clearDepth = 1.0; + } + id encoder = [cmd renderCommandEncoderWithDescriptor:pass]; + if (world_ && (drawCount > 0 || gpuOrder)) { + [encoder setVertexBuffer:uniforms_[slot] offset:0 atIndex:0]; + if (gpuOrder) { + [encoder setVertexBuffer:visibility_.projected() offset:0 atIndex:1]; + [encoder setVertexBuffer:visibility_.order() offset:0 atIndex:2]; + id arguments = visibility_.drawArguments(slot); + for (uint32_t batch = 0; batch < MetalVisibility::kDrawBatches; ++batch) { + // Completed compute tiles have alpha one and must be masked before even + // the first batch. Transparent overflow tiles receive all hardware splats. + if (batch > 0 || tileRendered) { + [encoder setRenderPipelineState:maskPipeline_]; + [encoder setDepthStencilState:maskDepth_]; + [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; + } + [encoder setRenderPipelineState:projectedPipeline_]; + [encoder setDepthStencilState:splatDepth_]; + [encoder drawPrimitives:MTLPrimitiveTypeTriangleStrip + indirectBuffer:arguments + indirectBufferOffset:batch * MetalVisibility::kDrawArgumentBytes]; + } + } else { + const int degree = + std::clamp(std::min(frame.shDegree, world_->info().shDegree), 0, kMaxShDegree); + [encoder setRenderPipelineState:splatPipelines_[static_cast(degree)]]; + [encoder setDepthStencilState:splatDepth_]; + [encoder setVertexBuffer:world_->splats() offset:0 atIndex:1]; + [encoder setVertexBuffer:world_->harmonics() offset:0 atIndex:3]; + [encoder setVertexBuffer:world_->order() offset:0 atIndex:2]; + [encoder drawPrimitives:MTLPrimitiveTypeTriangleStrip + vertexStart:0 + vertexCount:4 + instanceCount:drawCount]; + } + } + if (gpuOrder) { + const float background[4] = {static_cast(kBackground.red), + static_cast(kBackground.green), + static_cast(kBackground.blue), 1.0f}; + [encoder setRenderPipelineState:backgroundPipeline_]; + [encoder setDepthStencilState:splatDepth_]; + [encoder setFragmentBytes:background length:sizeof(background) atIndex:0]; + [encoder drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; + } + [encoder endEncoding]; + + } // Hardware completes overflow tiles, or the entire frame when compute is off. + + if (target_ != nil) { + MTLRenderPassDescriptor* blit = [MTLRenderPassDescriptor renderPassDescriptor]; + blit.colorAttachments[0].texture = drawable.texture; + blit.colorAttachments[0].loadAction = MTLLoadActionDontCare; + blit.colorAttachments[0].storeAction = MTLStoreActionStore; + id scale = [cmd renderCommandEncoderWithDescriptor:blit]; + [scale setRenderPipelineState:blitPipeline_]; + [scale setFragmentTexture:target_ atIndex:0]; + [scale drawPrimitives:MTLPrimitiveTypeTriangle vertexStart:0 vertexCount:3]; + [scale endEncoding]; + } + + id captured = nil; + CaptureHandler onCapture; + if (capture_) { + if (drawable.texture.framebufferOnly) { + LOGW("capture skipped: the drawable is not readable yet"); + } else { + const NSUInteger bytesPerRow = NSUInteger{width_} * 4; + captured = [device_ newBufferWithLength:bytesPerRow * height_ + options:MTLResourceStorageModeShared]; + id copy = [cmd blitCommandEncoder]; + [copy copyFromTexture:drawable.texture + sourceSlice:0 + sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(width_, height_, 1) + toBuffer:captured + destinationOffset:0 + destinationBytesPerRow:bytesPerRow + destinationBytesPerImage:bytesPerRow * height_]; + [copy endEncoding]; + onCapture = std::move(capture_); + capture_ = nullptr; + layer_.framebufferOnly = YES; + } + } + + [cmd presentDrawable:drawable]; + dispatch_semaphore_t inFlight = inFlight_; + std::atomic* gpuMillis = &lastGpuMillis_; + std::atomic* failed = &gpuFailed_; + const uint32_t width = width_; + const uint32_t height = height_; + auto* worldFrame = &completedWorldFrame_; + const bool drewWorld = world_ && (gpuOrder || drawCount > 0); + id tileStats = tileRendered ? tileRaster_.diagnostics(slot) : nil; + auto* computeTiles = &lastComputeTiles_; + auto* nonemptyTiles = &lastNonemptyComputeTiles_; + auto* hardwareTiles = &lastHardwareTiles_; + const bool logTileStats = frame_ < 4 || frame_ % 120 == 0; + [cmd addCompletedHandler:^(id done) { + if (done.status == MTLCommandBufferStatusError) { + failed->store(true); + LOGE("render command failed: %s", done.error.localizedDescription.UTF8String); + // Failed timestamps/capture bytes do not describe a presented frame. + gpuMillis->store(0.0); + if (onCapture) onCapture({}, 0, 0); + dispatch_semaphore_signal(inFlight); + return; + } + gpuMillis->store((done.GPUEndTime - done.GPUStartTime) * 1000.0); + if (drewWorld && !failed->load()) worldFrame->store(true); + // Consume only completed GPU diagnostics, never wait for an in-flight frame. + const auto* tileValues = + tileStats == nil ? nullptr : static_cast(tileStats.contents); + computeTiles->store(tileValues ? tileValues[0] : 0); + nonemptyTiles->store(tileValues ? tileValues[3] : 0); + hardwareTiles->store(tileValues ? tileValues[1] : 0); + if (tileStats != nil && logTileStats) { + const auto* values = static_cast(tileStats.contents); + LOGI("hybrid tiles: %u compute (%u nonempty), %u hardware, invalid input %u", values[0], + values[3], values[1], values[2]); + } + if (onCapture) { + const auto* bytes = static_cast(captured.contents); + onCapture(std::vector(bytes, bytes + size_t{width} * height * 4), width, height); + } + dispatch_semaphore_signal(inFlight); + }]; + [cmd commit]; + ++frame_; + return true; +} + +void MetalSplatRenderer::captureNextFrame(CaptureHandler handler) { + if (gpuFailed_.load()) { + if (handler) handler({}, 0, 0); + return; + } + capture_ = std::move(handler); +} + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalTileRaster.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalTileRaster.h new file mode 100644 index 0000000..388b560 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalTileRaster.h @@ -0,0 +1,38 @@ +#pragma once + +#import +#include +#include + +namespace splatkit { + +// Bounded hybrid compositor: exact lists of at most 512 candidates per screen tile. +// Dense/unsafe tiles are transparent sentinels, completed by hardware rasterization. +// The caller MUST load the result and mask completed tiles before the hardware draw. +class MetalTileRaster { + public: + bool create(id device, id library); + // Returns false before encoding if scratch cannot fit (128 MiB ceiling). + // The caller can then draw this frame with the existing hardware rasterizer. + bool encode(id cmd, id uniforms, id projected, + id order, id count, uint32_t capacity, id target, + uint32_t slot = 0); + // [compute tiles, hardware tiles, invalid input, nonempty compute tiles]. + // Read after slot completion. Compute tiles include background-only tiles. + id diagnostics(uint32_t slot) const { return diagnostics_[slot]; } + + private: + id device_ = nil; + id bin_ = nil; + id scanRows_ = nil; + id prepareFallback_ = nil; + id raster_ = nil; + id summarize_ = nil; + std::array, 2> diagnostics_{}; + id bins_ = nil; + id counts_ = nil; + id fallback_ = nil; + id rectangles_ = nil; +}; + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalTileRaster.mm b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalTileRaster.mm new file mode 100644 index 0000000..40eaca5 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalTileRaster.mm @@ -0,0 +1,127 @@ +#include "rendering/MetalTileRaster.h" +#include "rendering/MetalCompute.h" +#include "rendering/MetalShaderTypes.h" + +namespace splatkit { + +bool MetalTileRaster::create(id device, id library) { + device_ = device; + bin_ = metal::pipeline(device, library, "binSplatTiles"); + scanRows_ = metal::pipeline(device, library, "scanLargeSplatRows"); + prepareFallback_ = metal::pipeline(device, library, "prepareSplatTileFallback"); + raster_ = metal::pipeline(device, library, "rasterSplatTiles"); + summarize_ = metal::pipeline(device, library, "summarizeSplatTiles"); + for (auto& buffer : diagnostics_) buffer = metal::buffer(device, 4 * sizeof(uint32_t)); + return bin_ != nil && scanRows_ != nil && prepareFallback_ != nil && raster_ != nil && + summarize_ != nil && diagnostics_[0] != nil && diagnostics_[1] != nil && + bin_.threadExecutionWidth == 32 && bin_.maxTotalThreadsPerThreadgroup >= 256 && + raster_.threadExecutionWidth == 32 && scanRows_.maxTotalThreadsPerThreadgroup >= 32 && + prepareFallback_.maxTotalThreadsPerThreadgroup >= 32 && + raster_.maxTotalThreadsPerThreadgroup >= 256 && + raster_.staticThreadgroupMemoryLength <= device.maxThreadgroupMemoryLength; +} + +bool MetalTileRaster::encode(id cmd, id uniforms, + id projected, id order, id count, + uint32_t capacity, id target, uint32_t slot) { + // Signed rectangle prefix sums must be able to represent every input splat. + if (capacity > 0x7fffffffu || slot >= diagnostics_.size() || bin_ == nil || scanRows_ == nil || + prepareFallback_ == nil || raster_ == nil || target == nil || + target.pixelFormat != MTLPixelFormatRGBA16Float || uniforms.length < sizeof(CameraUniform) || + projected.length < size_t{capacity} * sizeof(ProjectedSplat) || + order.length < size_t{capacity} * sizeof(uint32_t) || count.length < sizeof(uint32_t)) + return false; + struct Config { + uint32_t tilesX, tilesY, capacity, candidates; + }; + static_assert(sizeof(Config) == 16); + const Config config{static_cast((target.width + 15) / 16), + static_cast((target.height + 15) / 16), capacity, 512}; + const size_t tiles = size_t{config.tilesX} * config.tilesY; + const size_t bytes = tiles * config.candidates * sizeof(uint32_t); + const size_t rectangleBytes = size_t{config.tilesX + 1} * (config.tilesY + 1) * sizeof(int32_t); + if (bytes + tiles * sizeof(uint32_t) + rectangleBytes + 16 > 128u * 1024u * 1024u) return false; + if (bins_ == nil || bins_.length < std::max(bytes, 16) || + rectangles_.length < rectangleBytes) { + id bins = metal::buffer(device_, bytes, MTLResourceStorageModePrivate); + id counts = + metal::buffer(device_, tiles * sizeof(uint32_t), MTLResourceStorageModePrivate); + id fallback = + metal::buffer(device_, sizeof(uint32_t), MTLResourceStorageModePrivate); + id rectangles = + metal::buffer(device_, rectangleBytes, MTLResourceStorageModePrivate); + if (bins == nil || counts == nil || fallback == nil || rectangles == nil) return false; + bins_ = bins; + counts_ = counts; + fallback_ = fallback; + rectangles_ = rectangles; + bins_.label = @"Bounded exact tile candidates"; + } + id reset = [cmd blitCommandEncoder]; + [reset fillBuffer:counts_ range:NSMakeRange(0, counts_.length) value:0]; + [reset fillBuffer:fallback_ range:NSMakeRange(0, fallback_.length) value:0]; + [reset fillBuffer:rectangles_ range:NSMakeRange(0, rectangles_.length) value:0]; + [reset endEncoding]; + id bin = [cmd computeCommandEncoder]; + bin.label = @"Bounded exact tile binning"; + [bin setComputePipelineState:bin_]; + [bin setBuffer:uniforms offset:0 atIndex:0]; + [bin setBuffer:projected offset:0 atIndex:1]; + [bin setBuffer:order offset:0 atIndex:2]; + [bin setBuffer:count offset:0 atIndex:3]; + [bin setBuffer:counts_ offset:0 atIndex:4]; + [bin setBuffer:bins_ offset:0 atIndex:5]; + [bin setBuffer:fallback_ offset:0 atIndex:6]; + [bin setBytes:&config length:sizeof(config) atIndex:7]; + [bin setBuffer:rectangles_ offset:0 atIndex:8]; + [bin dispatchThreadgroups:MTLSizeMake((size_t{std::max(capacity, 1u)} + 255) / 256, 1, 1) + threadsPerThreadgroup:MTLSizeMake(256, 1, 1)]; + [bin endEncoding]; + // Encoder boundaries order the rectangle prefix passes before the raster + // reads counts, without CPU readback or per-large-splat screen-sized loops. + id rows = [cmd computeCommandEncoder]; + rows.label = @"Large splat rectangle row prefixes"; + [rows setComputePipelineState:scanRows_]; + [rows setBuffer:rectangles_ offset:0 atIndex:0]; + [rows setBytes:&config length:sizeof(config) atIndex:1]; + [rows dispatchThreadgroups:MTLSizeMake((config.tilesY + 1 + 31) / 32, 1, 1) + threadsPerThreadgroup:MTLSizeMake(32, 1, 1)]; + [rows endEncoding]; + id prepare = [cmd computeCommandEncoder]; + prepare.label = @"Per-tile hardware ownership"; + [prepare setComputePipelineState:prepareFallback_]; + [prepare setBuffer:rectangles_ offset:0 atIndex:0]; + [prepare setBuffer:counts_ offset:0 atIndex:1]; + [prepare setBuffer:fallback_ offset:0 atIndex:2]; + [prepare setBytes:&config length:sizeof(config) atIndex:3]; + [prepare dispatchThreadgroups:MTLSizeMake((config.tilesX + 31) / 32, 1, 1) + threadsPerThreadgroup:MTLSizeMake(32, 1, 1)]; + [prepare endEncoding]; + id raster = [cmd computeCommandEncoder]; + raster.label = @"Experimental 16x16 tile compositing"; + [raster setComputePipelineState:raster_]; + [raster setBuffer:uniforms offset:0 atIndex:0]; + [raster setBuffer:projected offset:0 atIndex:1]; + [raster setBuffer:order offset:0 atIndex:2]; + [raster setBuffer:count offset:0 atIndex:3]; + [raster setBuffer:counts_ offset:0 atIndex:4]; + [raster setBuffer:bins_ offset:0 atIndex:5]; + [raster setBuffer:fallback_ offset:0 atIndex:6]; + [raster setBytes:&config length:sizeof(config) atIndex:7]; + [raster setTexture:target atIndex:0]; + [raster dispatchThreadgroups:MTLSizeMake(config.tilesX, config.tilesY, 1) + threadsPerThreadgroup:MTLSizeMake(16, 16, 1)]; + [raster endEncoding]; + id stats = [cmd computeCommandEncoder]; + stats.label = @"Hybrid tile diagnostics"; + [stats setComputePipelineState:summarize_]; + [stats setBuffer:counts_ offset:0 atIndex:0]; + [stats setBuffer:fallback_ offset:0 atIndex:1]; + [stats setBytes:&config length:sizeof(config) atIndex:2]; + [stats setBuffer:diagnostics_[slot] offset:0 atIndex:3]; + [stats dispatchThreadgroups:MTLSizeMake(1, 1, 1) threadsPerThreadgroup:MTLSizeMake(1, 1, 1)]; + [stats endEncoding]; + return true; +} + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.h new file mode 100644 index 0000000..ecee7ac --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.h @@ -0,0 +1,80 @@ +#pragma once + +#import + +#include +#include + +#include "rendering/MetalRadixSort.h" +#include "rendering/MetalShaderTypes.h" +#include "splatkit/rendering/SplatRenderer.h" + +namespace splatkit { + +// Projects and compacts requested ranges, sorts survivors, prepares indirect draws. +// Owns per-frame CPU inputs and GPU scratch; never commits or waits on a command buffer. +// Call reserve only while idle. All encodes and raster consumers use one command queue. +class MetalVisibility { + public: + // Internal experiment only; the default preserves the existing renderer. + // Low16 uses linear view depth and finite forward-Z Mat4::perspective planes, + // independently of the culling option. Shader key width and radix passes agree. + bool create(id device, id library, bool experiment = false, + float minPixelRadius = 0.5f, + MetalRadixSort::KeyBits depthBits = MetalRadixSort::KeyBits::Full32); + // Space for every source splat, or an explicitly bounded GPU index list when + // activeCapacity is nonzero. The latter must only be used with indexed encode. + // Failure preserves the previous allocation. + bool reserve(uint32_t capacity, uint32_t activeCapacity = 0); + uint32_t capacity() const { return capacity_; } + + // Invalid ranges fail before encoding any work. Empty ranges produce an empty draw. + // Caller owns uniforms/source buffers and must keep slot inputs unchanged until + // this frame completes. Ranges must be disjoint and refer to the reserved source. + bool encode(id cmd, uint32_t slot, id uniforms, id splats, + id sh, int shDegree, const SplatRenderer::Range* ranges, + uint32_t rangeCount, id indices = nil, id activeCount = nil); + + id order() const { return sort_.values(); } + // GPU-owned uint32 slots; Low16 only uses the lower 16 bits. + id depthKeys() const { return sort_.keys(); } + id projected() const { return projected_; } + id drawArguments(uint32_t slot) const { return drawArguments_[slot]; } + // A four-byte statistics readback, not the GPU counter in experimental mode. + id countBuffer(uint32_t slot) const { return countReadback_[slot]; } + // Only read after the GPU has completed this slot. + uint32_t count(uint32_t slot) const { + return *static_cast(countReadback_[slot].contents); + } + + static constexpr uint32_t kSlots = 2; + static constexpr uint32_t kDrawBatches = 7; + static constexpr uint32_t kDrawArgumentBytes = sizeof(MTLDrawPrimitivesIndirectArguments); + // The final record describes the whole visible set (baseInstance = 0). + // Raster currently consumes the seven partitions to retain saturation masking. + static constexpr uint32_t kFullDrawOffset = kDrawBatches * kDrawArgumentBytes; + + private: + static constexpr uint32_t kThreads = 256; + static constexpr uint32_t kMaxRanges = 65536; + static constexpr int kShDegrees = 4; + + id device_ = nil; + MTLResourceOptions storage_ = MTLResourceStorageModeShared; + std::array, kShDegrees> visibility_{}; + std::array, kShDegrees> indexedVisibility_{}; + id prepareDraw_ = nil; + MetalRadixSort sort_; + MetalRadixSort::KeyBits depthBits_ = MetalRadixSort::KeyBits::Full32; + uint32_t capacity_ = 0; + uint32_t sourceCapacity_ = 0; + bool indexedOnly_ = false; + id projected_ = nil; + std::array, kSlots> count_{}; + std::array, kSlots> countReadback_{}; + std::array, kSlots> drawArguments_{}; + std::array, kSlots> ranges_{}; + std::array, kSlots> rangeStarts_{}; +}; + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm new file mode 100644 index 0000000..4997d55 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm @@ -0,0 +1,150 @@ +#include "rendering/MetalVisibility.h" + +#include +#include +#include + +#include "rendering/MetalCompute.h" + +namespace splatkit { + +bool MetalVisibility::create(id device, id library, bool experiment, + float minPixelRadius, MetalRadixSort::KeyBits depthBits) { + if (!std::isfinite(minPixelRadius) || minPixelRadius < 0.0f) return false; + device_ = device; + depthBits_ = depthBits; + const bool quantized = depthBits_ == MetalRadixSort::KeyBits::Low16; + storage_ = experiment ? MTLResourceStorageModePrivate : MTLResourceStorageModeShared; + bool ok = true; + for (int degree = 0; degree < kShDegrees; ++degree) { + MTLFunctionConstantValues* constants = [MTLFunctionConstantValues new]; + uint32_t value = static_cast(degree); + [constants setConstantValue:&value type:MTLDataTypeUInt atIndex:0]; + [constants setConstantValue:&experiment type:MTLDataTypeBool atIndex:1]; + [constants setConstantValue:&minPixelRadius type:MTLDataTypeFloat atIndex:2]; + [constants setConstantValue:&quantized type:MTLDataTypeBool atIndex:4]; + bool indexed = false; + [constants setConstantValue:&indexed type:MTLDataTypeBool atIndex:3]; + visibility_[static_cast(degree)] = + metal::pipeline(device, library, "visibility", constants); + indexed = true; + [constants setConstantValue:&indexed type:MTLDataTypeBool atIndex:3]; + indexedVisibility_[static_cast(degree)] = + metal::pipeline(device, library, "visibility", constants); + ok = ok && indexedVisibility_[static_cast(degree)] != nil; + ok = ok && visibility_[static_cast(degree)] != nil; + const auto pipeline = visibility_[static_cast(degree)]; + ok = ok && pipeline.threadExecutionWidth == 32 && + pipeline.maxTotalThreadsPerThreadgroup >= kThreads; + } + prepareDraw_ = metal::pipeline(device, library, "prepareDrawArguments"); + for (uint32_t slot = 0; slot < kSlots; ++slot) { + count_[slot] = metal::buffer(device, sizeof(uint32_t), storage_); + countReadback_[slot] = experiment ? metal::buffer(device, sizeof(uint32_t)) : count_[slot]; + drawArguments_[slot] = metal::buffer(device, (kDrawBatches + 1) * kDrawArgumentBytes, storage_); + ranges_[slot] = metal::buffer(device, size_t{kMaxRanges} * sizeof(SplatRenderer::Range)); + rangeStarts_[slot] = metal::buffer(device, size_t{kMaxRanges + 1} * sizeof(uint32_t)); + ok = ok && count_[slot] != nil && countReadback_[slot] != nil && drawArguments_[slot] != nil && + ranges_[slot] != nil && rangeStarts_[slot] != nil; + } + return ok && prepareDraw_ != nil && sort_.create(device, library, storage_); +} + +bool MetalVisibility::reserve(uint32_t capacity, uint32_t activeCapacity) { + const uint32_t sourceCapacity = capacity; + if (activeCapacity > 0) capacity = std::min(capacity, activeCapacity); + capacity = std::max(capacity, 1u); + if (capacity <= capacity_) { + sourceCapacity_ = sourceCapacity; + indexedOnly_ = activeCapacity > 0 && activeCapacity < sourceCapacity; + return true; + } + id projected = + metal::buffer(device_, size_t{capacity} * sizeof(ProjectedSplat), storage_); + if (projected == nil || !sort_.reserve(capacity)) { + LOGE("visibility buffers for %u splats failed", capacity); + return false; + } + projected_ = projected; + capacity_ = capacity; + sourceCapacity_ = sourceCapacity; + indexedOnly_ = activeCapacity > 0 && activeCapacity < sourceCapacity; + return true; +} + +bool MetalVisibility::encode(id cmd, uint32_t slot, id uniforms, + id splats, id sh, int shDegree, + const SplatRenderer::Range* ranges, uint32_t rangeCount, + id indices, id activeCount) { + const bool indexed = indices != nil; + if (indexedOnly_ && !indexed) return false; + if (indexed != (activeCount != nil)) return false; + if (slot >= kSlots || rangeCount > kMaxRanges || capacity_ == 0 || + (rangeCount > 0 && ranges == nullptr)) + return false; + // Validate before writing slot inputs, so a rejected request cannot corrupt them. + uint32_t total = 0; + for (uint32_t i = 0; i < rangeCount; ++i) { + const auto& range = ranges[i]; + if (range.offset > sourceCapacity_ || range.count > sourceCapacity_ - range.offset || + range.count > capacity_ - total) + return false; + total += range.count; + } + auto* starts = static_cast(rangeStarts_[slot].contents); + starts[0] = 0; + for (uint32_t i = 0; i < rangeCount; ++i) starts[i + 1] = starts[i] + ranges[i].count; + if (rangeCount > 0) { + std::memcpy(ranges_[slot].contents, ranges, size_t{rangeCount} * sizeof(*ranges)); + } + // Queue-ordered reset also works for GPU-private counters, without a CPU fence. + id reset = [cmd blitCommandEncoder]; + [reset fillBuffer:count_[slot] range:NSMakeRange(0, sizeof(uint32_t)) value:0]; + [reset endEncoding]; + + id cull = [cmd computeCommandEncoder]; + cull.label = @"Splat visibility and projection"; + const int degree = std::clamp(shDegree, 0, kShDegrees - 1); + [cull setComputePipelineState:(indexed ? indexedVisibility_ + : visibility_)[static_cast(degree)]]; + [cull setBuffer:uniforms offset:0 atIndex:0]; + [cull setBuffer:splats offset:0 atIndex:1]; + [cull setBuffer:ranges_[slot] offset:0 atIndex:2]; + [cull setBuffer:rangeStarts_[slot] offset:0 atIndex:3]; + [cull setBytes:&rangeCount length:sizeof(rangeCount) atIndex:4]; + [cull setBuffer:sort_.keys() offset:0 atIndex:5]; + [cull setBuffer:sort_.values() offset:0 atIndex:6]; + [cull setBuffer:count_[slot] offset:0 atIndex:7]; + [cull setBuffer:sh offset:0 atIndex:8]; + [cull setBuffer:projected_ offset:0 atIndex:9]; + if (indexed) { + [cull setBuffer:indices offset:0 atIndex:10]; + [cull setBuffer:activeCount offset:0 atIndex:11]; + total = capacity_; + } + const NSUInteger groups = (size_t{std::max(total, 1u)} + kThreads - 1) / kThreads; + [cull dispatchThreadgroups:MTLSizeMake(groups, 1, 1) + threadsPerThreadgroup:MTLSizeMake(kThreads, 1, 1)]; + [cull endEncoding]; + + sort_.encode(cmd, count_[slot], depthBits_); + id draw = [cmd computeCommandEncoder]; + draw.label = @"Splat indirect draw arguments"; + [draw setComputePipelineState:prepareDraw_]; + [draw setBuffer:count_[slot] offset:0 atIndex:0]; + [draw setBuffer:drawArguments_[slot] offset:0 atIndex:1]; + [draw dispatchThreadgroups:MTLSizeMake(1, 1, 1) threadsPerThreadgroup:MTLSizeMake(1, 1, 1)]; + [draw endEncoding]; + if (countReadback_[slot] != count_[slot]) { + id readback = [cmd blitCommandEncoder]; + [readback copyFromBuffer:count_[slot] + sourceOffset:0 + toBuffer:countReadback_[slot] + destinationOffset:0 + size:sizeof(uint32_t)]; + [readback endEncoding]; + } + return true; +} + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalWorld.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalWorld.h new file mode 100644 index 0000000..2f91648 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalWorld.h @@ -0,0 +1,41 @@ +#pragma once + +#import + +#include +#include + +#include "splatkit/rendering/SplatRenderer.h" + +namespace splatkit { + +// GPU residency of one world, independent of its camera and rendering pipelines. +// Static worlds upload once into private memory; slabs accept incremental tile writes. +// The renderer guarantees no frame reads a slab range or order slot while it is written. +class MetalWorld { + public: + static std::unique_ptr upload(id device, id queue, + const splat::SplatCloud& cloud, int maxShDegree); + static std::unique_ptr slab(id device, uint32_t capacity, int shDegree); + bool uploadTile(uint32_t offset, const splat::SplatCloud& cloud); + // CPU-order compatibility, allocated lazily even when the device supports GPU sort. + bool writeOrder(const uint32_t* order, uint32_t count); + + GpuWorldInfo info() const { return {count_, shDegree_}; } + id splats() const { return splats_; } + id harmonics() const { return sh_; } + id order() const { return orders_[currentOrder_]; } + + private: + explicit MetalWorld(id device) : device_(device) {} + id device_ = nil; + id splats_ = nil; + id sh_ = nil; + std::array, 2> orders_{}; + uint32_t currentOrder_ = 0; + uint32_t count_ = 0; + int shDegree_ = 0; + bool slab_ = false; +}; + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalWorld.mm b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalWorld.mm new file mode 100644 index 0000000..0a7aaa8 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalWorld.mm @@ -0,0 +1,136 @@ +#include "rendering/MetalWorld.h" + +#include +#include +#include + +#include "rendering/MetalCompute.h" +#include "splatkit/rendering/GpuLayout.h" + +namespace splatkit { +namespace { + +// Host-supplied clouds must be complete before packing reads their attributes. +bool validCloud(const splat::SplatCloud& cloud) { + const size_t n = cloud.count(); + return n <= std::numeric_limits::max() && cloud.positions.size() == n * 3 && + cloud.covariances.size() == n * 6 && cloud.colors.size() == n * 3 && + cloud.alphas.size() == n; +} + +id privateBuffer(id device, size_t bytes) { + if (bytes > device.maxBufferLength) return nil; + return [device newBufferWithLength:std::max(bytes, 4) + options:MTLResourceStorageModePrivate]; +} + +} // namespace + +std::unique_ptr MetalWorld::upload(id device, id queue, + const splat::SplatCloud& cloud, int maxShDegree) { + if (!validCloud(cloud)) return nullptr; + auto world = std::unique_ptr(new MetalWorld(device)); + world->count_ = static_cast(cloud.count()); + const int degree = std::clamp(std::min(cloud.shDegree, maxShDegree), 0, 3); + world->shDegree_ = carriesSh(cloud, degree) ? degree : 0; + const size_t splatBytes = cloud.count() * sizeof(GpuSplat); + const size_t stride = world->shDegree_ > 0 ? shStride(world->shDegree_) : 0; + const size_t shBytes = std::max(size_t{4}, cloud.count() * stride * sizeof(uint32_t)); + world->splats_ = privateBuffer(device, splatBytes); + world->sh_ = privateBuffer(device, shBytes); + constexpr size_t chunk = 65536; + id stagingSplats = + metal::buffer(device, std::min(size_t{world->count_}, chunk) * sizeof(GpuSplat)); + id stagingSh = + metal::buffer(device, std::min(size_t{world->count_}, chunk) * stride * 4); + if (world->splats_ == nil || world->sh_ == nil || stagingSplats == nil || stagingSh == nil) { + return nullptr; + } + // Pack directly into a bounded staging window, not full-size packed + staging copies. + for (size_t offset = 0; offset < std::max(size_t{1}, cloud.count()); offset += chunk) { + const size_t count = std::min(chunk, cloud.count() - offset); + packSplatRange(cloud, offset, count, static_cast(stagingSplats.contents)); + if (stride) + packShRange(cloud, world->shDegree_, offset, count, + static_cast(stagingSh.contents)); + id upload = [queue commandBuffer]; + id blit = [upload blitCommandEncoder]; + if (upload == nil || blit == nil) return nullptr; + upload.label = @"Splat world upload"; + if (count > 0) { + [blit copyFromBuffer:stagingSplats + sourceOffset:0 + toBuffer:world->splats_ + destinationOffset:offset * sizeof(GpuSplat) + size:count * sizeof(GpuSplat)]; + } + if (stride > 0 && count > 0) { + [blit copyFromBuffer:stagingSh + sourceOffset:0 + toBuffer:world->sh_ + destinationOffset:offset * stride * 4 + size:count * stride * 4]; + } else if (offset == 0) { + [blit fillBuffer:world->sh_ range:NSMakeRange(0, 4) value:0]; + } + [blit endEncoding]; + [upload commit]; + [upload waitUntilCompleted]; + if (upload.status != MTLCommandBufferStatusCompleted) { + LOGE("world upload failed: %s", upload.error.localizedDescription.UTF8String); + return nullptr; + } + } + return world; +} + +std::unique_ptr MetalWorld::slab(id device, uint32_t capacity, + int shDegree) { + if (capacity == 0) return nullptr; + auto world = std::unique_ptr(new MetalWorld(device)); + world->count_ = capacity; + world->shDegree_ = std::clamp(shDegree, 0, 3); + world->slab_ = true; + const size_t shBytes = world->shDegree_ > 0 + ? size_t{capacity} * shStride(world->shDegree_) * sizeof(uint32_t) + : sizeof(uint32_t); + world->splats_ = metal::buffer(device, size_t{capacity} * sizeof(GpuSplat)); + world->sh_ = metal::buffer(device, shBytes); + if (world->splats_ == nil || world->sh_ == nil) return nullptr; + return world; +} + +bool MetalWorld::uploadTile(uint32_t offset, const splat::SplatCloud& cloud) { + if (!slab_ || !validCloud(cloud)) return false; + const size_t n = cloud.count(); + if (offset > count_ || n > count_ - offset) return false; + if (n == 0) return true; + const auto packed = packSplats(cloud); + std::memcpy(static_cast(splats_.contents) + offset, packed.data(), + packed.size() * sizeof(GpuSplat)); + if (shDegree_ == 0) return true; + const size_t stride = shStride(shDegree_); + const auto sh = + carriesSh(cloud, shDegree_) ? packSh(cloud, shDegree_) : std::vector(n * stride, 0); + std::memcpy(static_cast(sh_.contents) + size_t{offset} * stride, sh.data(), + sh.size() * sizeof(uint32_t)); + return true; +} + +bool MetalWorld::writeOrder(const uint32_t* order, uint32_t count) { + if (count > count_ || (count > 0 && order == nullptr)) return false; + if (orders_[0] == nil) { + std::array, 2> orders{}; + for (auto& buffer : orders) buffer = metal::buffer(device_, size_t{count_} * sizeof(uint32_t)); + if (orders[0] == nil || orders[1] == nil) return false; + orders_ = orders; + } + const uint32_t next = currentOrder_ ^ 1u; + if (count > 0) { + std::memcpy(orders_[next].contents, order, size_t{count} * sizeof(uint32_t)); + } + currentOrder_ = next; + return true; +} + +} // namespace splatkit diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/PrepareIndirect.metalh b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/PrepareIndirect.metalh new file mode 100644 index 0000000..77953cf --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/PrepareIndirect.metalh @@ -0,0 +1,19 @@ +#pragma once +#include "SplatTypes.metalh" + +constant uint kDrawBatches = 7; +constant uint kBatchEndSixtyFourths[kDrawBatches] = {1, 2, 4, 8, 16, 32, 64}; + +// Exactly one thread, after compaction. DrawArguments matches Metal's native +// MTLDrawPrimitivesIndirectArguments layout (four uint32 values). +kernel void prepareDrawArguments(const device uint* count [[buffer(0)]], + device DrawArguments* draw [[buffer(1)]]) { + uint n = count[0]; + draw[kDrawBatches] = DrawArguments{4, n, 0, 0}; + uint start = 0; + for (uint b = 0; b < kDrawBatches; ++b) { + uint end = uint((ulong(n) * kBatchEndSixtyFourths[b]) / 64); + draw[b] = DrawArguments{4, end - start, 0, start}; + start = end; + } +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/Splat.metal b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/Splat.metal new file mode 100644 index 0000000..493c878 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/Splat.metal @@ -0,0 +1,7 @@ +// One library, with each stage maintained in its own source file. +#include "SplatRaster.metal" +#include "SplatVisibility.metal" +#include "PrepareIndirect.metalh" +#include "SplatRadixSort.metal" +#include "SplatTileRaster.metal" +#include "SplatLOD.metal" diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatLOD.metal b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatLOD.metal new file mode 100644 index 0000000..6055205 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatLOD.metal @@ -0,0 +1,212 @@ +#include "SplatTypes.metalh" + +// Matches splat::LodCluster. Only interior nodes enter the frontier. +struct LodCluster { + packed_float3 center; float radius; + packed_float3 extent; float error; + float colorVariance, opacity; + uint node, childStart, childCount, leafStart, leafCount, subtreeLeaves; +}; +struct LodState { + uint count, active, packets, accepted; + uint limited, evaluated, groups, next; + uint dispatchX, dispatchY, dispatchZ; + uint emitX, emitY, emitZ; + uint outputDelta, packetDelta; + uint scanX, scanY, scanZ; +}; +struct LodConfig { uint capacity; float pixelLimit, colorWeight; uint cull; }; +constant uint kLodSplit = 0x80000000u; +constant uint kLodDrop = 0x40000000u; +constant uint kLodCost = 0x3fffffffu; + +inline bool lodOutside(constant Camera& cam, LodCluster node) { + float3 v = (cam.view * float4(float3(node.center), 1)).xyz; + float z = -v.z, r = node.radius; + float2 tangent = cam.tanHalfFov.xy; + float guard = 2.0f * max(z, 0.0f) / max(min(cam.focal.x, cam.focal.y), 1.0f); + return z + r <= 0.0f || any(abs(v.xy) - z * tangent > r * sqrt(1 + tangent * tangent) + guard); +} +inline float lodErrorPixels(constant Camera& cam, LodCluster node, float colorWeight) { + float3 v = (cam.view * float4(float3(node.center), 1)).xyz; + float depth = max(-v.z - node.radius, 1e-4f); + // Jacobian norm estimate: world-length discrepancy -> pixels. Not a certified + // compositing-error bound. The covariance and SH discrepancy are computed offline. + float perspective = sqrt(1 + dot(v.xy, v.xy) / (depth * depth)); + float appearance = min(sqrt(node.colorVariance), 1.0f); + float error = node.error + node.radius * appearance; + return error * max(cam.focal.x, cam.focal.y) / depth * perspective * + max(node.opacity, 0.1f) * (1 + colorWeight * sqrt(node.colorVariance)); +} +kernel void initializeSplatLOD(device uint* frontier [[buffer(0)]], + device LodState& state [[buffer(1)]]) { + frontier[0] = 0; + state = {0, 1, 0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1, 1, 0, 0, 1, 1, 1}; +} +kernel void evaluateSplatLOD(uint t [[thread_position_in_grid]], + uint lane [[thread_index_in_simdgroup]], + constant Camera& cam [[buffer(0)]], + const device LodCluster* nodes [[buffer(1)]], + const device uint* frontier [[buffer(2)]], + const device LodState& state [[buffer(3)]], + constant LodConfig& config [[buffer(4)]], + device uint2* costs [[buffer(5)]], + device uint4* groups [[buffer(6)]]) { + uint extra = 0, flags = 0, dropped = 0; + if (t < state.active) { + LodCluster node = nodes[frontier[t]]; + if (config.cull && lodOutside(cam, node)) { + flags = kLodDrop; + dropped = 1; + } else if (node.childCount + node.leafCount > 1 && + (config.pixelLimit == 0 || lodErrorPixels(cam, node, config.colorWeight) > config.pixelLimit)) { + flags = kLodSplit; + extra = node.childCount + node.leafCount - 1; + } else if (node.childCount == 1 && node.leafCount == 0) { + flags = kLodSplit; + } + } + uint prefix = simd_prefix_exclusive_sum(extra); + uint sum = simd_sum(extra), drops = simd_sum(dropped); + if (t < state.active) costs[t] = uint2(extra | flags, prefix); + if (lane == 0 && t < state.active) groups[t / 32] = uint4(sum, drops, 0, 0); +} + +// Two-level parallel scan: 256 SIMD-group totals per block, then at most 269 +// block totals at the 2.2M safety limit. No global atomic contention. +kernel void scanSplatLODGroups(uint t [[thread_position_in_grid]], + uint tid [[thread_index_in_threadgroup]], + uint lane [[thread_index_in_simdgroup]], + uint block [[threadgroup_position_in_grid]], + device uint4* groups [[buffer(0)]], + device uint4* blocks [[buffer(1)]], + const device LodState& state [[buffer(2)]]) { + threadgroup uint4 sums[8]; + uint4 v = t < state.groups ? groups[t] : uint4(0); + uint4 prefix = uint4(simd_prefix_exclusive_sum(v.x), simd_prefix_exclusive_sum(v.y), + simd_prefix_exclusive_sum(v.z), simd_prefix_exclusive_sum(v.w)); + if (lane == 31) sums[tid / 32] = prefix + v; + threadgroup_barrier(mem_flags::mem_threadgroup); + if (tid == 0) { + uint4 sum = 0; + for (uint i = 0; i < 8; ++i) { uint4 next = sums[i]; sums[i] = sum; sum += next; } + blocks[block] = sum; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + if (t < state.groups) groups[t] = prefix + sums[tid / 32]; +} +kernel void scanSplatLODBlocks(device uint4* blocks [[buffer(0)]], + const device LodState& state [[buffer(1)]]) { + uint4 sum = 0; + uint n = (state.groups + 255) / 256; + for (uint i = 0; i < n; ++i) { uint4 next = blocks[i]; blocks[i] = sum; sum += next; } + blocks[n] = sum; +} +// Capacity is a safety bound, not the quality target. Retain whole subtrees and +// report denied refinements, never truncate an output suffix. +kernel void budgetSplatLOD(const device uint4* groups [[buffer(0)]], + const device uint2* costs [[buffer(1)]], + device LodState& state [[buffer(2)]], + constant LodConfig& config [[buffer(3)]], + const device uint4* blocks [[buffer(4)]]) { + uint4 total = blocks[(state.groups + 255) / 256]; + uint available = config.capacity - state.count - state.active + total.y; + if (total.x <= available) { state.accepted = total.x; return; } + uint low = 0, high = state.active; + while (low < high) { + uint t = low + (high - low) / 2, g = t / 32; + uint end = blocks[g / 256].x + groups[g].x + costs[t].y + (costs[t].x & kLodCost); + if (end <= available) low = t + 1; else high = t; + } + state.accepted = low == 0 ? 0 : blocks[((low - 1) / 32) / 256].x + + groups[(low - 1) / 32].x + costs[low - 1].y + (costs[low - 1].x & kLodCost); +} +kernel void compactSplatLOD(uint t [[thread_position_in_grid]], + uint lane [[thread_index_in_simdgroup]], + const device LodCluster* nodes [[buffer(0)]], + const device uint* frontier [[buffer(1)]], + const device uint2* costs [[buffer(2)]], + const device uint4* costsByGroup [[buffer(3)]], + const device LodState& state [[buffer(4)]], + device uint4* offsets [[buffer(5)]], + device uint4* groups [[buffer(6)]], + const device uint4* blocks [[buffer(7)]]) { + uint kids = 0, splats = 0, packets = 0, flags = kLodDrop, denied = 0; + if (t < state.active) { + flags = costs[t].x & ~kLodCost; + uint extra = costs[t].x & kLodCost; + uint prefix = blocks[(t / 32) / 256].x + costsByGroup[t / 32].x + costs[t].y; + if ((flags & kLodSplit) && extra > 0 && prefix + extra > state.accepted) { + flags = 0; + denied = 1; + } + if (!(flags & kLodDrop)) { + LodCluster node = nodes[frontier[t]]; + kids = flags & kLodSplit ? node.childCount : 0; + splats = flags & kLodSplit ? node.leafCount : 1; + packets = splats > 32 ? 1 : 0; + } + } + uint a = simd_prefix_exclusive_sum(kids), b = simd_prefix_exclusive_sum(splats); + uint c = simd_prefix_exclusive_sum(packets); + uint4 sum = uint4(simd_sum(kids), simd_sum(splats), simd_sum(packets), simd_sum(denied)); + if (t < state.active) offsets[t] = uint4(a, b, c, flags); + if (lane == 0 && t < state.active) groups[t / 32] = sum; +} +kernel void allocateSplatLOD(const device uint4* blocks [[buffer(0)]], + device LodState& state [[buffer(1)]]) { + uint4 prefix = blocks[(state.groups + 255) / 256]; + state.next = prefix.x; + state.outputDelta = prefix.y; + state.packetDelta = prefix.z; + state.limited += prefix.w; + state.evaluated += state.active; +} +kernel void scatterSplatLOD(uint t [[thread_position_in_grid]], + const device LodCluster* nodes [[buffer(0)]], + const device uint* frontier [[buffer(1)]], + const device uint4* offsets [[buffer(2)]], + const device uint4* groups [[buffer(3)]], + const device LodState& state [[buffer(4)]], + device uint* next [[buffer(5)]], + device uint4* packets [[buffer(6)]], + device uint* indices [[buffer(7)]], + const device uint* leaves [[buffer(8)]], + const device uint4* blocks [[buffer(9)]]) { + if (t >= state.active) return; + uint4 local = offsets[t], group = groups[t / 32] + blocks[(t / 32) / 256]; + if (local.w & kLodDrop) return; + LodCluster node = nodes[frontier[t]]; + bool split = (local.w & kLodSplit) != 0; + if (split) + for (uint k = 0; k < node.childCount; ++k) next[group.x + local.x + k] = node.childStart + k; + uint count = split ? node.leafCount : 1; + uint destination = state.count + group.y + local.y; + if (count > 32) + packets[state.packets + group.z + local.z] = + uint4(node.leafStart, count, destination, 1); + else if (!split) indices[destination] = node.node; + else + for (uint k = 0; k < count; ++k) indices[destination + k] = leaves[node.leafStart + k]; +} +kernel void advanceSplatLOD(device LodState& state [[buffer(0)]]) { + state.count += state.outputDelta; + state.packets += state.packetDelta; + state.active = state.next; + state.groups = (state.active + 31) / 32; + state.dispatchX = (state.active + 255) / 256; + state.emitX = state.packets; + state.scanX = (state.groups + 255) / 256; +} +// One cooperative group per packet. This is an index copy, not per-leaf SSE. +kernel void emitSplatLOD(uint group [[threadgroup_position_in_grid]], + uint lane [[thread_index_in_threadgroup]], + const device uint4* packets [[buffer(0)]], + const device uint* leaves [[buffer(1)]], + const device LodState& state [[buffer(2)]], + device uint* indices [[buffer(3)]]) { + if (group >= state.packets) return; + uint4 packet = packets[group]; + for (uint k = lane; k < packet.y; k += 256) + indices[packet.z + k] = packet.w ? leaves[packet.x + k] : packet.x; +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatProjection.metalh b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatProjection.metalh new file mode 100644 index 0000000..0f2637a --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatProjection.metalh @@ -0,0 +1,163 @@ +#pragma once +#include "SplatTypes.metalh" + +// Shared by the visibility compute pass and the CPU-order vertex path. +// Projects the 3D covariance to screen space: Sigma' = J W Sigma W^T J^T. +inline float3 projectCovariance(constant Camera& cam, float3 viewPos, float4 covA, float2 covB) { + float invZ = 1.0 / viewPos.z; + float invZ2 = invZ * invZ; + + float2 lim = 1.3 * cam.tanHalfFov; + viewPos.x = clamp(viewPos.x * invZ, -lim.x, lim.x) * viewPos.z; + viewPos.y = clamp(viewPos.y * invZ, -lim.y, lim.y) * viewPos.z; + + float3x3 J = float3x3(float3(cam.focal.x * invZ, 0.0, 0.0), + float3(0.0, cam.focal.y * invZ, 0.0), + float3(-cam.focal.x * viewPos.x * invZ2, -cam.focal.y * viewPos.y * invZ2, 0.0)); + float3x3 W = float3x3(cam.view[0].xyz, cam.view[1].xyz, cam.view[2].xyz); + float3x3 T = J * W; + float3x3 Vrk = float3x3(float3(covA.x, covA.y, covA.z), + float3(covA.y, covA.w, covB.x), + float3(covA.z, covB.x, covB.y)); + float3x3 cov = T * Vrk * transpose(T); + return float3(cov[0][0] + 0.3, cov[0][1], cov[1][1] + 0.3); +} + +inline void ellipseAxes(float3 cov2D, thread float2& axis1, thread float2& axis2) { + float a = cov2D.x, b = cov2D.y, d = cov2D.z; + float det = a * d - b * b; + float mean = 0.5 * (a + d); + float dist = max(0.1, sqrt(max(mean * mean - det, 0.0))); + float lambda1 = mean + dist; + float lambda2 = mean - dist; + float2 e1 = (b == 0.0) ? ((a > d) ? float2(1, 0) : float2(0, 1)) : normalize(float2(b, d - lambda2)); + float2 e2 = float2(e1.y, -e1.x); + axis1 = e1 * sqrt(lambda1); + axis2 = e2 * sqrt(max(lambda2, 0.0)); +} + +inline float shHalf(const device uint* shData, uint base, uint h) { + float2 pair = unpackHalf2(shData[base + h / 2u]); + return (h & 1u) == 0u ? pair.x : pair.y; +} + +inline float3 shCoefficient(const device uint* shData, uint base, uint k) { + return float3(shHalf(shData, base, k * 3u), + shHalf(shData, base, k * 3u + 1u), + shHalf(shData, base, k * 3u + 2u)); +} + +inline float3 shColor(const device uint* shData, uint index, float3 d) { + const float C1 = 0.4886025119; + const float C2[5] = {1.0925484306, -1.0925484306, 0.3153915653, -1.0925484306, 0.5462742153}; + const float C3[7] = {-0.5900435899, 2.8906114426, -0.4570457995, 0.3731763326, + -0.4570457995, 1.4453057213, -0.5900435899}; + uint base = index * SH_STRIDE; + float x = d.x, y = d.y, z = d.z; + float3 c = -C1 * y * shCoefficient(shData, base, 0u) + C1 * z * shCoefficient(shData, base, 1u) - + C1 * x * shCoefficient(shData, base, 2u); + if (SH_DEGREE >= 2u) { + float xx = x * x, yy = y * y, zz = z * z, xy = x * y, yz = y * z, xz = x * z; + c += C2[0] * xy * shCoefficient(shData, base, 3u) + C2[1] * yz * shCoefficient(shData, base, 4u) + + C2[2] * (2.0 * zz - xx - yy) * shCoefficient(shData, base, 5u) + + C2[3] * xz * shCoefficient(shData, base, 6u) + + C2[4] * (xx - yy) * shCoefficient(shData, base, 7u); + if (SH_DEGREE >= 3u) { + c += C3[0] * y * (3.0 * xx - yy) * shCoefficient(shData, base, 8u) + + C3[1] * xy * z * shCoefficient(shData, base, 9u) + + C3[2] * y * (4.0 * zz - xx - yy) * shCoefficient(shData, base, 10u) + + C3[3] * z * (2.0 * zz - 3.0 * xx - 3.0 * yy) * shCoefficient(shData, base, 11u) + + C3[4] * x * (4.0 * zz - xx - yy) * shCoefficient(shData, base, 12u) + + C3[5] * z * (xx - yy) * shCoefficient(shData, base, 13u) + + C3[6] * x * (xx - 3.0 * yy) * shCoefficient(shData, base, 14u); + } + } + return c; +} + +inline bool projectSplat(constant Camera& cam, Splat s, uint index, const device uint* shData, + thread Projected& out, bool tightCulling = false, + float minPixelRadius = 0.5) { + float4 rgba = unpackUnorm4x8(s.rgba8); + // This is the peak opacity actually sent to rasterization, including LoD's + // above-one override. There is no separate opacity multiplier (scale = 1). + // exp(-r²/2) <= 1: if the peak fails the fragment cutoff, every pixel fails. + // Keep equality: the fragment uses <, not <=. Do not cull dark but opaque splats. + float alpha = s.lodAlpha != 0u ? as_type(s.lodAlpha) : rgba.a; + if (tightCulling && alpha < 1.0 / 255.0) return false; + + float4 viewPos4 = cam.view * float4(s.px, s.py, s.pz, 1.0); + float3 viewPos = viewPos4.xyz; + if (viewPos.z >= 0.0) return false; + float4 clip = cam.proj * viewPos4; + float bounds = 1.2 * clip.w; + // Metal's perspective clip depth is [0,w], not OpenGL's [-w,w]. + // For this camera, clip.w = -viewPos.z and near = proj[3][2] / proj[2][2]. + // Keep the existing centre near/far convention; side planes use footprint bounds below. + if (clip.z < 0.0 || clip.z > clip.w || + (!tightCulling && (clip.x < -bounds || clip.x > bounds || clip.y < -bounds || clip.y > bounds))) { + return false; + } + if (tightCulling && clip.w <= cam.proj[3][2] / cam.proj[2][2]) return false; + + float2 c0 = unpackHalf2(s.cov0); + float2 c1 = unpackHalf2(s.cov1); + float2 c2 = unpackHalf2(s.cov2); + float3 cov2D = projectCovariance(cam, viewPos, float4(c0, c1), c2); + float2 axis1, axis2; + ellipseAxes(cov2D, axis1, axis2); + + float radius = min(s.lodAlpha != 0u ? kLodBoundsRadius : kBoundsRadius, sqrt(2.0 * log(max(alpha * 255.0, 1.0)))); + + if (tightCulling) { + // Use the unfiltered covariance eigenvalue for the configurable source-footprint + // approximation. ellipseAxes' stabilizing floor is for drawing, not culling. + float halfDifference = 0.5 * (cov2D.x - cov2D.z); + float sourceVariance = 0.5 * (cov2D.x + cov2D.z) + + sqrt(halfDifference * halfDifference + cov2D.y * cov2D.y) - 0.3; + if (sqrt(max(sourceVariance, 0.0)) * radius < minPixelRadius) return false; + + // Bound the actual half-packed raster quad, not just its centre or a fixed + // 20% margin. Large/anisotropic splats crossing an edge must survive. + float2 packedAxis1 = float2(half2(axis1)); + float2 packedAxis2 = float2(half2(axis2)); + float2 extentPixels = radius * (abs(packedAxis1) + abs(packedAxis2)); + float2 margin = (extentPixels + 0.5) * 2.0 * clip.w / cam.screenSize; + if (any(abs(clip.xy) > clip.w + margin)) return false; + } else { + float maxVariance = max(dot(axis1, axis1), dot(axis2, axis2)); + float maxSourceSigma = sqrt(max(maxVariance - 0.3, 0.0)); + if (maxSourceSigma * radius < kMinPixelRadius) return false; + } + + float3 rgb = rgba.rgb; + if (SH_DEGREE >= 1u) { + float3 dir = normalize(float3(s.px, s.py, s.pz) - cam.cameraPosition.xyz); + rgb = max(rgb + shColor(shData, index, dir), float3(0.0)); + } + if (cam.outputLinear == 1u) rgb = pow(rgb, float3(2.2)); + + out.center = clip.xy / clip.w; + out.axis1 = as_type(half2(axis1)); + out.axis2 = as_type(half2(axis2)); + out.color0 = as_type(half2(rgb.rg)); + out.color1 = as_type(half2(rgb.b, alpha)); + out.radius = radius; + out.index = index; + return true; +} + +inline SplatVertex expandQuad(constant Camera& cam, Projected p, uint vertexId) { + float2 corner = kCorners[vertexId]; + float2 delta = (corner.x * unpackHalf2(p.axis1) + corner.y * unpackHalf2(p.axis2)) * 2.0 * p.radius / cam.screenSize; + SplatVertex out; + out.position = float4(p.center + delta, kSplatDepth, 1.0); + out.relativePosition = p.radius * corner; + out.color = float4(unpackHalf2(p.color0), unpackHalf2(p.color1)); + return out; +} + +inline float splatAlpha(SplatVertex in) { + float r2 = dot(in.relativePosition, in.relativePosition); + return min(exp(-0.5 * r2) * in.color.a, 1.0); +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatRadixSort.metal b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatRadixSort.metal new file mode 100644 index 0000000..1288193 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatRadixSort.metal @@ -0,0 +1,165 @@ +#include "SplatTypes.metalh" + +constant uint kSortThreads = 256; +constant uint kSortPerThread = 16; +constant uint kSortBlock = kSortThreads * kSortPerThread; +constant uint kSortDigitBits = 8; +constant uint kSortBins = 1u << kSortDigitBits; +constant uint kSortPerSimdgroup = 32 * kSortPerThread; +constant uint kSortSimdgroups = kSortThreads / 32; + +kernel void prepareRadixSort(const device uint* count [[buffer(0)]], + device SortDispatch* dispatch [[buffer(1)]]) { + uint blocks = max((count[0] + kSortBlock - 1) / kSortBlock, 1u); + dispatch->threadgroupsX = blocks; + dispatch->threadgroupsY = 1; + dispatch->threadgroupsZ = 1; + dispatch->blocks = blocks; +} + +static uint matchDigit(uint digit, bool valid) { + uint peers = uint(simd_vote::vote_t(simd_ballot(valid))); + for (uint b = 0; b < kSortDigitBits; ++b) { + bool bit = (digit >> b) & 1u; + uint vote = uint(simd_vote::vote_t(simd_ballot(bit))); + peers &= bit ? vote : ~vote; + } + return peers; +} + +static uint simdgroupElement(uint block, uint sg, uint row, uint lane) { + return block * kSortBlock + sg * kSortPerSimdgroup + row * 32 + lane; +} + +kernel void radixHistogram(uint tid [[thread_index_in_threadgroup]], + uint block [[threadgroup_position_in_grid]], + uint lane [[thread_index_in_simdgroup]], + uint sg [[simdgroup_index_in_threadgroup]], + const device uint* keys [[buffer(0)]], + const device uint* count [[buffer(1)]], + const device SortDispatch* dispatch [[buffer(2)]], + constant uint& shift [[buffer(3)]], + device uint* histogram [[buffer(4)]]) { + threadgroup atomic_uint bins[kSortBins]; + atomic_store_explicit(&bins[tid], 0u, memory_order_relaxed); + threadgroup_barrier(mem_flags::mem_threadgroup); + uint n = count[0]; + for (uint row = 0; row < kSortPerThread; ++row) { + uint e = simdgroupElement(block, sg, row, lane); + bool valid = e < n; + uint d = valid ? (keys[e] >> shift) & (kSortBins - 1) : 0u; + uint peers = matchDigit(d, valid); + if (valid && ctz(peers) == lane) { + atomic_fetch_add_explicit(&bins[d], popcount(peers), memory_order_relaxed); + } + } + threadgroup_barrier(mem_flags::mem_threadgroup); + histogram[tid * dispatch->blocks + block] = atomic_load_explicit(&bins[tid], memory_order_relaxed); +} + +kernel void radixScan(uint tid [[thread_index_in_threadgroup]], + uint digit [[threadgroup_position_in_grid]], + uint lane [[thread_index_in_simdgroup]], + uint sg [[simdgroup_index_in_threadgroup]], + const device SortDispatch* dispatch [[buffer(0)]], + device uint* histogram [[buffer(1)]], + device uint* totals [[buffer(2)]]) { + threadgroup uint sgTotals[kSortSimdgroups]; + threadgroup uint sgOffsets[kSortSimdgroups]; + uint blocks = dispatch->blocks; + device uint* row = histogram + digit * blocks; + uint carry = 0; + for (uint start = 0; start < blocks; start += kSortThreads) { + uint i = start + tid; + uint v = i < blocks ? row[i] : 0u; + uint p = simd_prefix_exclusive_sum(v); + uint s = simd_sum(v); + if (lane == 0) sgTotals[sg] = s; + threadgroup_barrier(mem_flags::mem_threadgroup); + if (tid == 0) { + uint run = 0; + for (uint g = 0; g < kSortSimdgroups; ++g) { + sgOffsets[g] = run; + run += sgTotals[g]; + } + sgTotals[0] = run; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + if (i < blocks) row[i] = carry + sgOffsets[sg] + p; + carry += sgTotals[0]; + threadgroup_barrier(mem_flags::mem_threadgroup); + } + if (tid == 0) totals[digit] = carry; +} + +kernel void radixScatter(uint tid [[thread_index_in_threadgroup]], + uint block [[threadgroup_position_in_grid]], + uint lane [[thread_index_in_simdgroup]], + uint sg [[simdgroup_index_in_threadgroup]], + const device uint* keysIn [[buffer(0)]], + const device uint* valuesIn [[buffer(1)]], + device uint* keysOut [[buffer(2)]], + device uint* valuesOut [[buffer(3)]], + const device uint* count [[buffer(4)]], + const device SortDispatch* dispatch [[buffer(5)]], + constant uint& shift [[buffer(6)]], + const device uint* histogram [[buffer(7)]], + const device uint* totals [[buffer(8)]]) { + threadgroup uint sgCounts[kSortSimdgroups][kSortBins]; + threadgroup uint sgTotals[kSortSimdgroups]; + threadgroup uint digitBase[kSortBins]; + threadgroup uint blockBase[kSortBins]; + uint n = count[0]; + uint blocks = dispatch->blocks; + + for (uint g = 0; g < kSortSimdgroups; ++g) sgCounts[g][tid] = 0; + blockBase[tid] = histogram[tid * blocks + block]; + { + uint v = totals[tid]; + uint p = simd_prefix_exclusive_sum(v); + uint s = simd_sum(v); + if (lane == 0) sgTotals[sg] = s; + threadgroup_barrier(mem_flags::mem_threadgroup); + uint run = 0; + for (uint g = 0; g < sg; ++g) run += sgTotals[g]; + digitBase[tid] = run + p; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + + uint keys[kSortPerThread]; + uint ranks[kSortPerThread]; + for (uint row = 0; row < kSortPerThread; ++row) { + uint e = simdgroupElement(block, sg, row, lane); + bool valid = e < n; + keys[row] = valid ? keysIn[e] : 0u; + uint d = (keys[row] >> shift) & (kSortBins - 1); + uint peers = matchDigit(d, valid); + uint leader = ctz(peers); + uint base = 0; + if (valid && leader == lane) { + base = sgCounts[sg][d]; + sgCounts[sg][d] = base + popcount(peers); + } + base = simd_shuffle(base, valid ? leader : lane); + ranks[row] = base + popcount(peers & ((1u << lane) - 1u)); + } + threadgroup_barrier(mem_flags::mem_threadgroup); + { + uint run = 0; + for (uint g = 0; g < kSortSimdgroups; ++g) { + uint v = sgCounts[g][tid]; + sgCounts[g][tid] = run; + run += v; + } + } + threadgroup_barrier(mem_flags::mem_threadgroup); + + for (uint row = 0; row < kSortPerThread; ++row) { + uint e = simdgroupElement(block, sg, row, lane); + if (e >= n) continue; + uint d = (keys[row] >> shift) & (kSortBins - 1); + uint dst = digitBase[d] + blockBase[d] + sgCounts[sg][d] + ranks[row]; + keysOut[dst] = keys[row]; + valuesOut[dst] = valuesIn[e]; + } +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatRaster.metal b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatRaster.metal new file mode 100644 index 0000000..4b5a346 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatRaster.metal @@ -0,0 +1,69 @@ +#include "SplatTypes.metalh" +#include "SplatProjection.metalh" + +// Rasterization and compositing, including the CPU order compatibility path. +vertex SplatVertex splatVertex(uint vertexId [[vertex_id]], uint instanceId [[instance_id]], + constant Camera& cam [[buffer(0)]], + const device Splat* splats [[buffer(1)]], + const device uint* order [[buffer(2)]], + const device uint* shData [[buffer(3)]]) { + uint index = order[instanceId]; + Projected p; + if (!projectSplat(cam, splats[index], index, shData, p)) { + SplatVertex out; + out.position = float4(0.0, 0.0, 2.0, 1.0); + out.relativePosition = float2(0.0); + out.color = float4(0.0); + return out; + } + return expandQuad(cam, p, vertexId); +} + +// GPU order path vertex shader +vertex SplatVertex projectedVertex(uint vertexId [[vertex_id]], uint instanceId [[instance_id]], + constant Camera& cam [[buffer(0)]], + const device Projected* projected [[buffer(1)]], + const device uint* order [[buffer(2)]]) { + return expandQuad(cam, projected[order[instanceId]], vertexId); +} + +// Fragment Shaders +fragment float4 splatFragment(SplatVertex in [[stage_in]]) { + float alpha = splatAlpha(in); + if (alpha < 1.0 / 255.0) discard_fragment(); + return float4(in.color.rgb, alpha); +} + +fragment float4 splatFragmentUnder(SplatVertex in [[stage_in]]) { + half2 relative = half2(in.relativePosition); + half r2 = dot(relative, relative); + half alpha = min(exp(half(-0.5) * r2) * half(in.color.a), half(1.0)); + if (alpha < half(1.0 / 255.0)) discard_fragment(); + half3 rgb = half3(in.color.rgb) * alpha; + return float4(float3(rgb), float(alpha)); +} + +// Fullscreen Blit / Render Scale +vertex BlitVertex blitVertex(uint vertexId [[vertex_id]]) { + const float2 corners[3] = {float2(-1, -1), float2(3, -1), float2(-1, 3)}; + BlitVertex out; + out.position = float4(corners[vertexId], 0, 1); + out.uv = float2(corners[vertexId].x * 0.5 + 0.5, 0.5 - corners[vertexId].y * 0.5); + return out; +} + +fragment float4 blitFragment(BlitVertex in [[stage_in]], texture2d source [[texture(0)]]) { + constexpr sampler linearSampler(filter::linear, address::clamp_to_edge); + return source.sample(linearSampler, in.uv); +} + +fragment MaskOut saturationMask(BlitVertex in [[stage_in]], float4 dst [[color(0)]]) { + if (dst.a < kSaturated) discard_fragment(); + MaskOut out; + out.depth = kMaskDepth; + return out; +} + +fragment float4 backgroundFragment(BlitVertex in [[stage_in]], constant float4& color [[buffer(0)]]) { + return color; +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatTileRaster.metal b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatTileRaster.metal new file mode 100644 index 0000000..a8d864d --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatTileRaster.metal @@ -0,0 +1,244 @@ +#include "SplatTypes.metalh" + +// Bounded hybrid backend. Dense tiles are rendered by the hardware path in full, +// never truncated. No raster loop or threadgroup barrier depends on source count. +constant uint kTileCandidates = 512; +constant uint kTileThreads = 256; +constant uint kMaxFootprintTiles = 16; + +struct TileConfig { uint tilesX, tilesY, capacity, candidates; }; +// Rasterization does not use the source index. Packed centre alignment keeps +// each cached sample at 28 bytes: 14 KiB samples + 2 KiB ranks, including 512. +// This also fits when Metal shader validation doubles threadgroup allocations. +struct TileSample { + packed_float2 center; + uint axis1, axis2; + float radius; + uint color0, color1; +}; + +kernel void summarizeSplatTiles(const device uint* tileCounts [[buffer(0)]], + const device uint* fallback [[buffer(1)]], + constant TileConfig& config [[buffer(2)]], + device uint* result [[buffer(3)]]) { + uint total = config.tilesX * config.tilesY; + uint hardware = 0, nonempty = 0; + for (uint tile = 0; tile < total; ++tile) { + uint n = tileCounts[tile]; + hardware += n > kTileCandidates; + nonempty += n > 0 && n <= kTileCandidates; + } + result[0] = total - hardware; + result[1] = hardware; + result[2] = fallback[0]; + result[3] = nonempty; +} + +// Difference-grid rectangle update. Aggregate equal corner destinations within +// each SIMDGroup; four corners replace a loop over every covered screen tile. +inline void addLargeCorner(uint lane, bool pending, uint corner, int sign, + device atomic_int* rectangles) { + while (simd_any(pending)) { + uint destination = simd_min(pending ? corner : 0xffffffffu); + bool match = pending && corner == destination; + int delta = simd_sum(match ? sign : 0); + if (lane == 0) atomic_fetch_add_explicit(&rectangles[destination], delta, memory_order_relaxed); + pending = pending && !match; + } +} + +kernel void scanLargeSplatRows(uint y [[thread_position_in_grid]], + device int* rectangles [[buffer(0)]], + constant TileConfig& config [[buffer(1)]]) { + if (y > config.tilesY) return; + uint row = y * (config.tilesX + 1); + int sum = 0; + for (uint x = 0; x <= config.tilesX; ++x) { + sum += rectangles[row + x]; + rectangles[row + x] = sum; + } +} + +kernel void prepareSplatTileFallback(uint x [[thread_position_in_grid]], + const device int* rectangles [[buffer(0)]], + device uint* tileCounts [[buffer(1)]], + const device uint* invalidInput [[buffer(2)]], + constant TileConfig& config [[buffer(3)]]) { + if (x >= config.tilesX) return; + int sum = 0; + for (uint y = 0; y < config.tilesY; ++y) { + sum += rectangles[y * (config.tilesX + 1) + x]; + // Hardware must include the omitted large splat AND every small splat here. + // Invalid input remains a separate fail-closed whole-frame safety condition. + if (sum > 0 || invalidInput[0] != 0) tileCounts[y * config.tilesX + x] = kTileCandidates + 1; + } +} + +inline float4 tilePixelBounds(constant Camera& cam, Projected p) { + float2 center = (p.center * float2(0.5, -0.5) + 0.5) * cam.screenSize; + float2 extent = p.radius * (abs(unpackHalf2(p.axis1)) + abs(unpackHalf2(p.axis2))) + 0.5; + return float4(center - extent, center + extent); +} + +kernel void binSplatTiles(uint rank [[thread_position_in_grid]], + uint lane [[thread_index_in_simdgroup]], + constant Camera& cam [[buffer(0)]], + const device Projected* projected [[buffer(1)]], + const device uint* order [[buffer(2)]], + const device uint* count [[buffer(3)]], + device atomic_uint* tileCounts [[buffer(4)]], + device uint* candidates [[buffer(5)]], + device atomic_uint* fallback [[buffer(6)]], + constant TileConfig& config [[buffer(7)]], + device atomic_int* rectangles [[buffer(8)]]) { + uint n = count[0]; + // Buffer safety only; source count alone no longer forces hardware rendering. + if (n > config.capacity || config.candidates != kTileCandidates) { + if (rank == 0) atomic_store_explicit(fallback, 1u, memory_order_relaxed); + return; + } + uint stopped = 0; + if (lane == 0) stopped = atomic_load_explicit(fallback, memory_order_relaxed); + if (simd_broadcast(stopped, 0) != 0) return; + uint2 first = 0, span = 1; + uint area = 0; + bool unsafe = false; + // Inactive lanes still reach every collective, including a lane zero with no + // geometry. Read neither the order nor projected data for padded grid lanes. + if (rank < n) { + uint index = order[rank]; + if (index >= config.capacity) { + unsafe = true; + } else { + float4 bounds = tilePixelBounds(cam, projected[index]); + if (!all(isfinite(bounds))) { + unsafe = true; + } else if (!any(bounds.zw < 0.0) && !any(bounds.xy >= cam.screenSize)) { + first = uint2(clamp(floor(bounds.xy / 16.0), float2(0), float2(config.tilesX - 1, config.tilesY - 1))); + uint2 last = uint2(clamp(floor(bounds.zw / 16.0), float2(0), float2(config.tilesX - 1, config.tilesY - 1))); + span = last - first + 1; + area = span.x * span.y; + } + } + } + if (simd_any(unsafe)) { + if (lane == 0) atomic_store_explicit(fallback, 1u, memory_order_relaxed); + return; + } + bool large = area > kMaxFootprintTiles; + uint stride = config.tilesX + 1; + uint2 end = first + span; + addLargeCorner(lane, large, first.y * stride + first.x, 1, rectangles); + addLargeCorner(lane, large, first.y * stride + end.x, -1, rectangles); + addLargeCorner(lane, large, end.y * stride + first.x, -1, rectangles); + addLargeCorner(lane, large, end.y * stride + end.x, 1, rectangles); + // Large splats are absent from candidate lists only after their entire clipped + // footprint has been recorded for hardware completion. No coverage is dropped. + if (large) area = 0; + uint steps = simd_max(area); + for (uint step = 0; step < steps; ++step) { + bool pending = step < area; + uint tile = (first.y + step / span.x) * config.tilesX + first.x + step % span.x; + // Different lanes can target different tiles. Group equal destinations, + // reserving once per destination, not once per splat. At most 32 iterations. + while (simd_any(pending)) { + uint destination = simd_min(pending ? tile : 0xffffffffu); + uint matches = pending && tile == destination ? 1u : 0u; + uint offset = simd_prefix_exclusive_sum(matches); + uint total = simd_sum(matches); + uint base = kTileCandidates; + if (lane == 0) { + if (atomic_load_explicit(&tileCounts[destination], memory_order_relaxed) <= kTileCandidates) + base = atomic_fetch_add_explicit(&tileCounts[destination], total, memory_order_relaxed); + } + base = simd_broadcast(base, 0); + if (matches && base < kTileCandidates && offset < kTileCandidates - base) + candidates[destination * kTileCandidates + base + offset] = rank; + pending = pending && !matches; + } + } +} + +kernel void rasterSplatTiles(uint2 tile [[threadgroup_position_in_grid]], + uint2 local [[thread_position_in_threadgroup]], + constant Camera& cam [[buffer(0)]], + const device Projected* projected [[buffer(1)]], + const device uint* order [[buffer(2)]], + const device uint* count [[buffer(3)]], + const device uint* tileCounts [[buffer(4)]], + const device uint* candidates [[buffer(5)]], + const device uint* fallback [[buffer(6)]], + constant TileConfig& config [[buffer(7)]], + texture2d target [[texture(0)]]) { + uint tid = local.y * 16 + local.x; + uint2 pixel = tile * 16 + local; + bool inBounds = pixel.x < target.get_width() && pixel.y < target.get_height(); + uint tileIndex = tile.y * config.tilesX + tile.x; + uint n = tileCounts[tileIndex]; + // Uniform across this entire threadgroup, before any barrier. + if (n > kTileCandidates) { + if (inBounds) target.write(float4(0.0), pixel); + return; + } + if (n == 0) { + if (inBounds) target.write(float4(0.05, 0.05, 0.08, 1.0), pixel); + return; + } + threadgroup uint ranks[kTileCandidates]; + threadgroup TileSample cache[kTileCandidates]; + uint activeSize = n > 1 ? 1u << (32u - clz(n - 1u)) : 1u; + // 256 pixel threads cooperatively handle up to 512 entries. Padding is part + // of the sorting network, not unused memory: never skip comparisons with it. + for (uint i = tid; i < activeSize; i += kTileThreads) + ranks[i] = i < n ? candidates[tileIndex * kTileCandidates + i] : 0xffffffffu; + threadgroup_barrier(mem_flags::mem_threadgroup); + // Sort exact candidate ranks: global rank already defines the depth order. + // Tile-uniform loop bounds keep every barrier converged. Sparse tiles need + // fewer stages; dense tiles still have a fixed upper bound of 45 stages. + for (uint size = 2; size <= activeSize; size *= 2) { + for (uint stride = size / 2; stride > 0; stride /= 2) { + for (uint i = tid; i < activeSize; i += kTileThreads) { + uint other = i ^ stride; + if (other > i) { + uint a = ranks[i], b = ranks[other]; + bool ascending = (i & size) == 0; + ranks[i] = ascending ? min(a, b) : max(a, b); + ranks[other] = ascending ? max(a, b) : min(a, b); + } + } + threadgroup_barrier(mem_flags::mem_threadgroup); + } + } + for (uint i = tid; i < n; i += kTileThreads) { + Projected p = projected[order[ranks[i]]]; + cache[i] = {packed_float2(p.center), p.axis1, p.axis2, p.radius, p.color0, p.color1}; + } + threadgroup_barrier(mem_flags::mem_threadgroup); + // No threadgroup barriers follow. Keep edge and finished lanes participating + // in the SIMD vote; each SIMDGroup can leave independently of the other seven. + float transmittance = 1.0; + float3 color = 0.0; + float2 sample = float2(pixel) + 0.5; + for (uint i = 0; i < n; ++i) { + bool done = !inBounds || transmittance <= 0.0001f; + if (simd_all(done)) break; + if (done) continue; + TileSample p = cache[i]; + float2 axis1 = unpackHalf2(p.axis1), axis2 = unpackHalf2(p.axis2); + float det = axis1.x * axis2.y - axis1.y * axis2.x; + if (abs(det) < 1e-12) continue; + float2 center = (float2(p.center) * float2(0.5, -0.5) + 0.5) * cam.screenSize; + float2 delta = (sample - center) * float2(1, -1); + float2 relative = float2(axis2.y * delta.x - axis2.x * delta.y, + axis1.x * delta.y - axis1.y * delta.x) / det; + if (any(abs(relative) > p.radius)) continue; + half2 q = half2(relative), color1 = as_type(p.color1); + half alpha = min(exp(half(-0.5) * dot(q, q)) * color1.y, half(1.0)); + if (alpha < half(1.0 / 255.0)) continue; + color += transmittance * float(alpha) * float3(unpackHalf2(p.color0), float(color1.x)); + transmittance *= 1.0 - float(alpha); + } + // Alpha one marks a complete tile for the hardware depth mask. Overflow tiles + // above remain transparent and receive the full hardware splat draw instead. + if (inBounds) target.write(float4(color + transmittance * float3(0.05, 0.05, 0.08), 1.0), pixel); +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatTypes.metalh b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatTypes.metalh new file mode 100644 index 0000000..ec303dc --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatTypes.metalh @@ -0,0 +1,90 @@ +#pragma once +#include +using namespace metal; + +// Metal buffer layouts. The host counterparts live in MetalShaderTypes.h. +struct Camera { + float4x4 view; + float4x4 proj; + float2 focal; // pixels: screenSize * proj[0][0] / 2, screenSize * proj[1][1] / 2 + float2 tanHalfFov; // 1 / proj[0][0], 1 / proj[1][1] + float2 screenSize; // pixels + uint outputLinear; // 1 when the target is sRGB and expects linear values + uint pad; + float4 cameraPosition; // world space +}; + +// Spherical harmonics constants +constant uint SH_DEGREE [[function_constant(0)]]; +constant uint SH_COEFFICIENTS = (SH_DEGREE + 1) * (SH_DEGREE + 1) - 1; +constant uint SH_STRIDE = (SH_COEFFICIENTS * 3 + 1) / 2; + +// Core data layout for Splats +struct Splat { + float px, py, pz; // world position + uint rgba8; // colour and alpha, 8 bits each + uint cov0; // halves: xx, xy + uint cov1; // halves: xz, yy + uint cov2; // halves: yz, zz + uint lodAlpha; // float bits of an opacity above 1, else 0 +}; + +// Intermediate GPU-projected splat layout (32 bytes) +struct Projected { + float2 center; + uint axis1; + uint axis2; + uint color0; + uint color1; + float radius; + uint index; +}; + +// Render Pipeline Vertex/Fragment Interfaces +struct SplatVertex { + float4 position [[position]]; + float2 relativePosition; + float4 color; +}; + +struct BlitVertex { + float4 position [[position]]; + float2 uv; +}; + +struct MaskOut { + float depth [[depth(any)]]; +}; + +struct Range { + uint offset; + uint count; +}; + +struct SortDispatch { + uint threadgroupsX, threadgroupsY, threadgroupsZ; + uint blocks; +}; + +struct DrawArguments { + uint vertexCount, instanceCount, vertexStart, baseInstance; +}; + +// Render Constants +constant float kSplatDepth = 0.5; +constant float kMaskDepth = 0.25; +constant float kSaturated = 254.0 / 255.0; + +constant float kBoundsRadius = 3.0; +constant float kLodBoundsRadius = 5.0; +constant float kMinPixelRadius = 1.0; +constant float2 kCorners[4] = {float2(-1, -1), float2(-1, 1), float2(1, -1), float2(1, 1)}; + +// Helper unpacking functions +inline float2 unpackHalf2(uint packed) { + return float2(as_type(packed)); +} + +inline float4 unpackUnorm4x8(uint packed) { + return float4(packed & 0xffu, (packed >> 8) & 0xffu, (packed >> 16) & 0xffu, packed >> 24) / 255.0; +} diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatVisibility.metal b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatVisibility.metal new file mode 100644 index 0000000..72f7296 --- /dev/null +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatVisibility.metal @@ -0,0 +1,83 @@ +#include "SplatTypes.metalh" +#include "SplatProjection.metalh" + +constant bool kTightCulling [[function_constant(1)]]; +constant float kExperimentalMinPixelRadius [[function_constant(2)]]; +constant bool kIndexedLOD [[function_constant(3)]]; +constant bool kQuantizedDepth [[function_constant(4)]]; + +// Finite, forward-Z Metal perspective, as produced by Mat4::perspective. +// Recover the camera's actual clip planes without changing the shared Camera ABI. +// This is linear camera depth, not nonlinear post-projection depth or float16. +static uint quantizedDepthKey(constant Camera& cam, float depth) { + float zNear = cam.proj[3][2] / cam.proj[2][2]; + float zFar = cam.proj[3][2] / (cam.proj[2][2] + 1.0f); + float normalized = clamp((depth - zNear) / (zFar - zNear), 0.0f, 1.0f); + // Reject nonfinite source depths before emission; keep their conversion defined. + ushort key = ushort(isfinite(normalized) ? normalized * 65535.0f : 0.0f); + return uint(key); // upper bits zero; existing uint scratch, only two radix passes +} + +static uint findRange(const device uint* starts, uint rangeCount, uint t) { + uint lo = 0, hi = rangeCount; + while (hi - lo > 1) { + uint mid = (lo + hi) / 2; + if (starts[mid] <= t) lo = mid; else hi = mid; + } + return lo; +} + +kernel void visibility(uint t [[thread_position_in_grid]], + uint lane [[thread_index_in_simdgroup]], + constant Camera& cam [[buffer(0)]], + const device Splat* splats [[buffer(1)]], + const device Range* ranges [[buffer(2)]], + const device uint* rangeStarts [[buffer(3)]], + constant uint& rangeCount [[buffer(4)]], + device uint* keys [[buffer(5)]], + device uint* values [[buffer(6)]], + device atomic_uint* count [[buffer(7)]], + const device uint* shData [[buffer(8)]], + device Projected* projected [[buffer(9)]], + const device uint* activeIndices [[buffer(10), function_constant(kIndexedLOD)]], + const device uint* activeCount [[buffer(11), function_constant(kIndexedLOD)]]) { + const uint total = kIndexedLOD ? activeCount[0] : rangeStarts[rangeCount]; + bool visible = false; + uint key = 0; + uint index = 0; + Projected p; + + if (t < total) { + if (kIndexedLOD) { + index = activeIndices[t]; + } else { + uint r = findRange(rangeStarts, rangeCount, t); + index = ranges[r].offset + (t - rangeStarts[r]); + } + Splat s = splats[index]; + visible = projectSplat(cam, s, index, shData, p, kTightCulling, kExperimentalMinPixelRadius); + float3 d = float3(s.px, s.py, s.pz) - cam.cameraPosition.xyz; + // Positive IEEE float bits sort in ascending numeric order. This experiment + // uses camera depth and recomputes it every frame, including rotations. + float depth = -(cam.view * float4(s.px, s.py, s.pz, 1.0)).z; + float sortDepth = kTightCulling || kQuantizedDepth ? depth : dot(d, d); + key = kQuantizedDepth ? quantizedDepthKey(cam, depth) : as_type(sortDepth); + visible = visible && isfinite(sortDepth); + } + // No lane returns before these collectives, including padded tail lanes. + uint rank = simd_prefix_exclusive_sum(visible ? 1u : 0u); + uint survivors = simd_sum(visible ? 1u : 0u); + uint base = 0; + if (lane == 0 && survivors > 0) { + base = atomic_fetch_add_explicit(count, survivors, memory_order_relaxed); + } + base = simd_broadcast(base, 0); + if (visible) { + keys[base + rank] = key; + // Experimental values are original slab indices, as requested. Keeping the + // projection at that same index lets the existing vertex path consume them. + uint projectionIndex = kTightCulling && !kIndexedLOD ? index : base + rank; + values[base + rank] = projectionIndex; + projected[projectionIndex] = p; + } +} diff --git a/packages/splatkit-ios/cmake/embed-text.cmake b/packages/splatkit-ios/cmake/embed-text.cmake new file mode 100644 index 0000000..82efad9 --- /dev/null +++ b/packages/splatkit-ios/cmake/embed-text.cmake @@ -0,0 +1,29 @@ +# Expand local shader includes at build time: a consuming app needs no source files. +# Each local file is included once; system includes stay for the Metal compiler. +function(expand_shader path result) + get_filename_component(path "${path}" REALPATH) + get_property(seen GLOBAL PROPERTY shader_includes) + if("${path}" IN_LIST seen) + set(${result} "" PARENT_SCOPE) + return() + endif() + set_property(GLOBAL APPEND PROPERTY shader_includes "${path}") + file(READ "${path}" source) + get_filename_component(dir "${path}" DIRECTORY) + get_filename_component(name "${path}" NAME) + string(REGEX MATCHALL "#include[ \t]+\"[^\"]+\"" includes "${source}") + foreach(include IN LISTS includes) + string(REGEX REPLACE "#include[ \t]+\"([^\"]+)\"" "\\1" local "${include}") + expand_shader("${dir}/${local}" expanded) + string(REPLACE "${include}" "${expanded}" source "${source}") + endforeach() + string(REPLACE "#pragma once" "" source "${source}") + set(${result} "\n// ${name}\n${source}\n" PARENT_SCOPE) +endfunction() + +cmake_policy(SET CMP0057 NEW) +expand_shader("${INPUT}" text) +get_filename_component(output_dir "${OUTPUT}" DIRECTORY) +file(MAKE_DIRECTORY "${output_dir}") +file(WRITE ${OUTPUT} + "#pragma once\n// Generated from ${INPUT}; do not edit.\ninline constexpr const char ${SYMBOL}[] = R\"SPLATKIT(${text})SPLATKIT\";\n") diff --git a/packages/splatkit-ios/cmake/embed.cmake b/packages/splatkit-ios/cmake/embed.cmake new file mode 100644 index 0000000..d4c2070 --- /dev/null +++ b/packages/splatkit-ios/cmake/embed.cmake @@ -0,0 +1,17 @@ +# splatkit_embed_text( ): generates embedded/.h declaring +# `inline constexpr const char []` with the file's text, as a target the library +# depends on, so a shader edit rebuilds it. +function(splatkit_embed_text target file symbol) + set(out ${CMAKE_CURRENT_BINARY_DIR}/embedded/${symbol}.h) + get_filename_component(shader_dir ${file} DIRECTORY) + file(GLOB_RECURSE shader_sources CONFIGURE_DEPENDS + ${shader_dir}/*.metal ${shader_dir}/*.metalh) + add_custom_command( + OUTPUT ${out} + COMMAND ${CMAKE_COMMAND} -DINPUT=${file} -DOUTPUT=${out} -DSYMBOL=${symbol} + -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed-text.cmake + DEPENDS ${file} ${shader_sources} ${CMAKE_CURRENT_SOURCE_DIR}/cmake/embed-text.cmake + COMMENT "Embedding ${file}" + ) + add_custom_target(${target} DEPENDS ${out}) +endfunction() diff --git a/packages/splatkit-ios/distribution/AGENTS.md b/packages/splatkit-ios/distribution/AGENTS.md new file mode 100644 index 0000000..f517643 --- /dev/null +++ b/packages/splatkit-ios/distribution/AGENTS.md @@ -0,0 +1,6 @@ +# SplatKit iOS agents + +Integration: [README](README.md), [API](packages/splatkit-ios/README.md). +Verification: `python3 scripts/sdk_harness.py plan metal`, then `check metal`; `check engine` covers shared C++. +Read [CONTRIBUTING](CONTRIBUTING.md) before editing. +Keep Metal internals private, lifecycle explicit, docs terse and skipped/approximate/device-specific results honest. diff --git a/packages/splatkit-ios/distribution/CONTRIBUTING.md b/packages/splatkit-ios/distribution/CONTRIBUTING.md new file mode 100644 index 0000000..6af7841 --- /dev/null +++ b/packages/splatkit-ios/distribution/CONTRIBUTING.md @@ -0,0 +1,9 @@ +# Contributing + +Keep platform-free algorithms in `splat-core`, orchestration in `splatkit-engine`, Metal in `splatkit-ios`. +Use the pinned dependency revisions and format C++ with the repository `.clang-format`. +Run `python3 scripts/sdk_harness.py check engine`, then `check metal` for rendering changes. +`bash scripts/package-ios.sh` builds device/simulator binaries and includes dependency licenses. +Report skipped tests, quality limitations and device/driver provenance. +Device tests require explicit approval; synthetic tests need no scene files. +Keep docs short; contracts belong beside code. diff --git a/packages/splatkit-ios/distribution/README.md b/packages/splatkit-ios/distribution/README.md new file mode 100644 index 0000000..89e56dd --- /dev/null +++ b/packages/splatkit-ios/distribution/README.md @@ -0,0 +1,36 @@ +# SplatKit iOS + +Native Metal Gaussian splatting for iOS 17+, A14/M1+ GPUs: asynchronous loading, GPU visibility/radix sorting, SH0–3, walk/fly input and diagnostics. +MIT-licensed sources include the shared C++ engine/core. + +## Install + +Add `https://github.com/Xget7/splatkit-ios` in Xcode Package Dependencies and select product `SplatKit`. +Choose exact version `0.1.0-alpha.2`. +The package downloads the release XCFramework for arm64 devices and arm64/x86_64 simulators. + +```swift +import SplatKit + +let view = SplatMetalView() +view.loadWorld(file: worldURL) +view.resume() +``` + +Attach the view to your hierarchy; forward `resume()`, `pause()` and `release()` from lifecycle events. +Start motion after `splatView(_:worldFrameReady:)`. +[API and experiments](packages/splatkit-ios/README.md). + +## Verify + +```sh +python3 scripts/sdk_harness.py check engine +python3 scripts/sdk_harness.py check metal +bash scripts/package-ios.sh +``` + +Requires Xcode, CMake and Python 3.9+; builds fetch pinned dependencies. +No signing credentials or scene downloads are needed for synthetic native tests. +LOD, hybrid tiles and 16-bit sorting remain experimental; quality acceptance and sustained 30/60 FPS are not guaranteed. +Simulator/Mac checks are not phone benchmarks. +RN GPU controls remain pending. diff --git a/packages/splatkit-ios/tests/CMakeLists.txt b/packages/splatkit-ios/tests/CMakeLists.txt new file mode 100644 index 0000000..a234b64 --- /dev/null +++ b/packages/splatkit-ios/tests/CMakeLists.txt @@ -0,0 +1,16 @@ +add_executable(splatkit_ios_tests + MetalVisibilityTest.mm + MetalLODTest.mm + MetalRadixSortTest.mm + MetalWorldTest.mm + MetalRasterTest.mm + MetalTileRasterTest.mm +) +target_include_directories(splatkit_ios_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/../Sources/SplatKitCore ${CMAKE_CURRENT_BINARY_DIR}/../embedded +) +target_link_libraries(splatkit_ios_tests PRIVATE splatkit_ios GTest::gtest_main) +target_compile_options(splatkit_ios_tests PRIVATE -Wall -Wextra -Werror -fobjc-arc) +add_dependencies(splatkit_ios_tests splatkit_ios_shaders) +include(GoogleTest) +gtest_discover_tests(splatkit_ios_tests) diff --git a/packages/splatkit-ios/tests/MetalLODTest.mm b/packages/splatkit-ios/tests/MetalLODTest.mm new file mode 100644 index 0000000..c93bf45 --- /dev/null +++ b/packages/splatkit-ios/tests/MetalLODTest.mm @@ -0,0 +1,341 @@ +#import +#include +#include +#include +#include +#include +#include "MetalTestContext.h" +#include "rendering/MetalCompute.h" +#include "rendering/MetalLOD.h" +#include "rendering/MetalSplatRenderer.h" +#include "rendering/MetalVisibility.h" +#include "rendering/MetalWorld.h" +#include "splat/formats/SpzDecoder.h" +#include "splat/io/MappedFile.h" +#include "splat/lod/LodFile.h" +#include "splatkit/camera/WalkCamera.h" + +namespace splatkit { +namespace { +splat::LodTree hierarchy() { + splat::SplatCloud c; + c.bounds.min = {-1, -1, -3}; + c.bounds.max = {1, 1, -2}; + for (int i = 0; i < 515; ++i) { + const float x = (i % 17) / 8.0f - 1; + const float y = ((i / 17) % 17) / 8.0f - 1; + const float z = -2.0f - (i / 289) * 0.25f; + c.positions.insert(c.positions.end(), {x, y, z}); + c.covariances.insert(c.covariances.end(), {0.001f, 0, 0, 0.001f, 0, 0.001f}); + c.colors.insert(c.colors.end(), {1, 0, 0}); + c.alphas.push_back(0.5f); + } + splat::LodBuildOptions options; + options.octreeDepth = 6; + return splat::buildLodTree(std::move(c), options); +} +id camera(float distance = 0) { + CameraUniform u{}; + u.view = splat::Mat4::identity(); + u.view.at(2, 3) = -distance; + u.cameraPosition[2] = distance; + u.proj = splat::Mat4::perspective(1.0f, 1.0f, 0.1f, 1000000.0f); + u.focal[0] = u.focal[1] = 500; + u.tanHalfFov[0] = u.tanHalfFov[1] = 1; + u.screenSize[0] = u.screenSize[1] = 1000; + return [test::Gpu::get().device newBufferWithBytes:&u + length:sizeof(u) + options:MTLResourceStorageModeShared]; +} +void leaves(const splat::LodTree& tree, uint32_t node, std::vector& out) { + const auto& n = tree.layout[node]; + if (n.childCount == 0) out.push_back(node); + for (uint32_t k = 0; k < n.childCount; ++k) leaves(tree, n.childStart + k, out); +} +std::vector select(MetalLOD& lod, id uniforms, + std::array* stats = nullptr) { + auto& gpu = test::Gpu::get(); + auto output = metal::buffer(gpu.device, 24 + size_t{lod.budget()} * 4); + auto command = [gpu.queue commandBuffer]; + lod.encode(command, uniforms); + auto blit = [command blitCommandEncoder]; + [blit copyFromBuffer:lod.count() sourceOffset:0 toBuffer:output destinationOffset:0 size:24]; + [blit copyFromBuffer:lod.indices() + sourceOffset:0 + toBuffer:output + destinationOffset:24 + size:size_t{lod.budget()} * 4]; + [blit endEncoding]; + [command commit]; + [command waitUntilCompleted]; + EXPECT_EQ(command.status, MTLCommandBufferStatusCompleted); + const auto* values = static_cast(output.contents); + EXPECT_LE(values[0], lod.budget()); + if (stats) std::copy_n(values, 6, stats->begin()); + return {values + 6, values + 6 + std::min(values[0], lod.budget())}; +} + +TEST(MetalLODTest, BudgetCutsCoverEveryLeafExactlyOnceAndAreDeterministic) { + auto& gpu = test::Gpu::get(); + ASSERT_NE(gpu.library, nil); + const auto tree = hierarchy(); + for (uint32_t budget : {1u, 7u, 31u, 32u, 33u, 64u, 257u, 400u, 515u}) { + MetalLOD lod; + ASSERT_TRUE(lod.create(gpu.device, gpu.library)); + ASSERT_TRUE(lod.upload(gpu.queue, tree, budget, 0)); + EXPECT_EQ(lod.indices().storageMode, MTLStorageModePrivate); + auto cut = select(lod, camera()); + EXPECT_EQ(cut, select(lod, camera())); + ASSERT_FALSE(cut.empty()); + ASSERT_LE(cut.size(), budget); + std::vector represented; + for (uint32_t node : cut) { + ASSERT_LT(node, tree.nodeCount()); + leaves(tree, node, represented); + } + EXPECT_EQ(represented.size(), tree.leafCount); + EXPECT_EQ(std::set(represented.begin(), represented.end()).size(), tree.leafCount); + if (budget == 515) EXPECT_EQ(cut.size(), tree.leafCount); + } +} + +TEST(MetalLODTest, DistanceChangesCutAndIndexedVisibilitySortsOnlyThatCut) { + auto& gpu = test::Gpu::get(); + const auto tree = hierarchy(); + MetalLOD lod; + ASSERT_TRUE(lod.create(gpu.device, gpu.library)); + ASSERT_TRUE(lod.upload(gpu.queue, tree, 400, 1)); + EXPECT_GT(select(lod, camera()).size(), 1u); + EXPECT_EQ(select(lod, camera(1000000)).size(), 1u); + auto world = MetalWorld::upload(gpu.device, gpu.queue, tree.nodes, 0); + ASSERT_TRUE(world); + MetalVisibility visibility; + ASSERT_TRUE(visibility.create(gpu.device, gpu.library, true, 0)); + ASSERT_TRUE(visibility.reserve(static_cast(tree.nodeCount()), lod.budget())); + EXPECT_EQ(visibility.capacity(), 400u); + { + auto rejected = [gpu.queue commandBuffer]; + const SplatRenderer::Range highIndex{static_cast(tree.nodeCount()) - 1, 1}; + EXPECT_FALSE(visibility.encode(rejected, 0, camera(), world->splats(), world->harmonics(), 0, + &highIndex, 1)); + } + auto uniforms = camera(); + auto command = [gpu.queue commandBuffer]; + lod.encode(command, uniforms); + ASSERT_TRUE(visibility.encode(command, 0, uniforms, world->splats(), world->harmonics(), 0, + nullptr, 0, lod.indices(), lod.count())); + [command commit]; + [command waitUntilCompleted]; + ASSERT_EQ(command.status, MTLCommandBufferStatusCompleted); + EXPECT_GT(visibility.count(0), 0u); + EXPECT_LE(visibility.count(0), 400u); +} + +TEST(MetalLODTest, OfflineFixtureRendersWithBoundedDrawCount) { + const char* path = std::getenv("SPLAT_LOD_PATH"); + if (!path) GTEST_SKIP() << "Set SPLAT_LOD_PATH for the offline ISS hierarchy integration run"; + const char* reference = std::getenv("SPLAT_LOD_REFERENCE_SPZ"); + const char* requestedBudget = std::getenv("SPLAT_LOD_BUDGET"); + const uint32_t budget = requestedBudget ? std::strtoul(requestedBudget, nullptr, 10) : 1200000u; + auto tree = [&]() -> splat::Result { + auto file = splat::MappedFile::open(reference ? reference : path); + if (!file) return file.error(); + if (reference) { + splat::SpzDecodeOptions options; + options.maxShDegree = 1; + auto cloud = splat::decodeSpz(file.value().data(), file.value().size(), options); + if (!cloud) return cloud.error(); + splat::LodTree result; + result.nodes = std::move(cloud.value()); + result.leafCount = result.nodes.count(); + return result; + } + return splat::decodeLodSplat(file.value().data(), file.value().size(), 1); + }(); + ASSERT_TRUE(tree) << tree.error().message; + auto renderer = MetalSplatRenderer::create(); + ASSERT_TRUE(renderer); + auto layer = [CAMetalLayer layer]; + layer.drawableSize = CGSizeMake(1206, 2622); + renderer->setLayer(layer); + renderer->setDrawableSize(1206, 2622); + ASSERT_TRUE(reference ? renderer->uploadWorld(tree.value().nodes, 1) + : renderer->uploadLodWorld(tree.value(), 1, budget)); + WalkCamera camera; + camera.setLookAt({0, -2, 43}, {0, -2, -2}, {1, 0, 0}); + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + frame.view = camera.viewMatrix(); + frame.cameraPosition = camera.position(); + frame.proj = splat::Mat4::perspective(65.0f * 3.14159265f / 180, 1206.0f / 2622, 0.05f, 200); + frame.shDegree = 1; + const SplatRenderer::Range range{0, renderer->world()->count}; + frame.ranges = ⦥ + frame.rangeCount = 1; + for (int iteration = 0; iteration < 3; ++iteration) { + auto completed = dispatch_semaphore_create(0); + std::vector pixels; + renderer->captureNextFrame([&](std::vector image, uint32_t, uint32_t) { + pixels = std::move(image); + dispatch_semaphore_signal(completed); + }); + ASSERT_TRUE(renderer->draw(frame)); + ASSERT_EQ( + dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC)), 0); + ASSERT_EQ(pixels.size(), 1206u * 2622u * 4u); + if (!reference) { + ASSERT_LE(renderer->lastSelectedCount(), budget); + ASSERT_LE(renderer->lastDrawCount(), renderer->lastSelectedCount()); + } + EXPECT_GT(renderer->lastDrawCount(), 0u); + printf("[ LOD ISS ] %u selected, %u drawn, %.2f ms LOD, %.2f ms cull+sort, %.2f ms raster\n", + renderer->lastSelectedCount(), renderer->lastDrawCount(), renderer->lastSelectMillis(), + renderer->lastSortMillis(), renderer->lastGpuMillis()); + printf("[ LOD QUALITY ] %u evaluated interiors, %u denied refinements\n", + renderer->lastLodEvaluatedCount(), renderer->lastLodLimitedCount()); + if (iteration == 2) { + if (const char* capture = std::getenv("SPLAT_LOD_CAPTURE")) { + auto space = CGColorSpaceCreateDeviceRGB(); + auto provider = + CGDataProviderCreateWithData(nullptr, pixels.data(), pixels.size(), nullptr); + auto image = CGImageCreate(1206, 2622, 8, 32, 1206 * 4, space, + kCGBitmapByteOrder32Little | kCGImageAlphaNoneSkipFirst, + provider, nullptr, false, kCGRenderingIntentDefault); + auto url = [NSURL fileURLWithPath:@(capture)]; + auto destination = CGImageDestinationCreateWithURL((__bridge CFURLRef)url, + CFSTR("public.png"), 1, nullptr); + ASSERT_NE(destination, nullptr); + CGImageDestinationAddImage(destination, image, nullptr); + EXPECT_TRUE(CGImageDestinationFinalize(destination)); + CFRelease(destination); + CGImageRelease(image); + CGDataProviderRelease(provider); + CGColorSpaceRelease(space); + } + } + } +} + +TEST(MetalLODTest, QualityRefinesColorVariationWithoutFillingCapacity) { + auto& gpu = test::Gpu::get(); + splat::LodTree tree; + tree.leafCount = 4; + tree.layout = {{{0, 0, -2}, 2, 1, 2}, {{-0.7f, 0, -2}, 0.1f, 3, 2}, + {{0.7f, 0, -2}, 1.8f, 5, 2}, {{-0.8f, 0, -2}, 0.1f, 0, 0}, + {{-0.6f, 0, -2}, 0.1f, 0, 0}, {{0.6f, 0, -2}, 0.1f, 0, 0}, + {{0.8f, 0, -2}, 0.1f, 0, 0}}; + for (const auto& node : tree.layout) { + tree.nodes.positions.insert(tree.nodes.positions.end(), node.position, node.position + 3); + tree.nodes.covariances.insert(tree.nodes.covariances.end(), {0.01f, 0, 0, 0.01f, 0, 0.01f}); + tree.nodes.colors.insert(tree.nodes.colors.end(), {1, 1, 1}); + tree.nodes.alphas.push_back(0.5f); + } + tree.nodes.colors[5 * 3] = 0; + tree.nodes.colors[6 * 3 + 1] = 0; + tree.selection = splat::buildLodSelectionData(tree); + MetalLOD lod; + ASSERT_TRUE(lod.create(gpu.device, gpu.library)); + // Uniform pair may collapse at this distance; the varying pair must stay fine. + ASSERT_TRUE(lod.upload(gpu.queue, tree, 4, 1, 4, false)); + std::array stats{}; + const auto cut = select(lod, camera(100), &stats); + EXPECT_EQ(std::set(cut.begin(), cut.end()), (std::set{1, 5, 6})); + EXPECT_EQ(stats[4], 0u); + EXPECT_EQ(stats[5], 3u); +} + +TEST(MetalLODTest, PacketExpansionDoesNotEvaluateLeavesAndReportsCapacityPressure) { + auto& gpu = test::Gpu::get(); + auto tree = hierarchy(); + tree.selection = splat::buildLodSelectionData(tree); + MetalLOD lod; + ASSERT_TRUE(lod.create(gpu.device, gpu.library)); + ASSERT_TRUE(lod.upload(gpu.queue, tree, 515, 0, 4, false)); + std::array stats{}; + EXPECT_EQ(select(lod, camera(), &stats).size(), 515u); + EXPECT_EQ(stats[4], 0u); + EXPECT_EQ(stats[5], tree.selection.clusters.size()); + ASSERT_TRUE(lod.upload(gpu.queue, tree, 33, 0, 4, false)); + EXPECT_LE(select(lod, camera(), &stats).size(), 33u); + EXPECT_GT(stats[4], 0u); +} + +TEST(MetalLODTest, EmptyViewEmitsNothingAndSingleLeafRootEmitsOne) { + auto& gpu = test::Gpu::get(); + MetalLOD lod; + ASSERT_TRUE(lod.create(gpu.device, gpu.library)); + ASSERT_TRUE(lod.upload(gpu.queue, hierarchy(), 515, 0)); + EXPECT_TRUE(select(lod, camera(-100)).empty()); + splat::SplatCloud cloud; + cloud.positions = {0, 0, -2}; + cloud.colors = {1, 1, 1}; + cloud.alphas = {0.5f}; + cloud.covariances = {0.001f, 0, 0, 0.001f, 0, 0.001f}; + auto tree = splat::buildLodTree(std::move(cloud)); + ASSERT_TRUE(lod.upload(gpu.queue, tree, 1)); + EXPECT_EQ(select(lod, camera()), (std::vector{0})); +} + +TEST(MetalLODTest, ParallelScanCrossesBlocksWithoutHolesOrDuplicates) { + constexpr uint32_t count = 32768, nodes = 2 * count - 1; + splat::LodTree tree; + tree.leafCount = count; + for (uint32_t i = 0; i < nodes; ++i) { + tree.layout.push_back( + {{0, 0, -2}, 0.02f, i < count - 1 ? 2 * i + 1 : 0, i < count - 1 ? 2u : 0u}); + tree.nodes.positions.insert(tree.nodes.positions.end(), {0, 0, -2}); + tree.nodes.covariances.insert(tree.nodes.covariances.end(), + {0.0001f, 0, 0, 0.0001f, 0, 0.0001f}); + tree.nodes.colors.insert(tree.nodes.colors.end(), {0.5f, 0.5f, 0.5f}); + tree.nodes.alphas.push_back(0.1f); + } + tree.selection = splat::buildLodSelectionData(tree); + auto& gpu = test::Gpu::get(); + MetalLOD lod; + ASSERT_TRUE(lod.create(gpu.device, gpu.library)); + for (uint32_t capacity : {20000u, count}) { + ASSERT_TRUE(lod.upload(gpu.queue, tree, capacity, 0, 4, false)); + std::array stats{}; + auto cut = select(lod, camera(), &stats); + EXPECT_EQ(cut, select(lod, camera())); + ASSERT_EQ(cut.size(), capacity); + std::vector represented; + for (uint32_t node : cut) leaves(tree, node, represented); + EXPECT_EQ(represented.size(), count); + EXPECT_EQ(std::set(represented.begin(), represented.end()).size(), count); + if (capacity == count) { + EXPECT_EQ(stats[4], 0u); + EXPECT_EQ(stats[5], count - 1); + } else + EXPECT_GT(stats[4], 0u); + } +} + +TEST(MetalLODTest, LargeLeafPacketUsesCooperativeEmissionWithoutOverflow) { + splat::LodTree tree; + tree.leafCount = 513; + for (uint32_t i = 0; i < 514; ++i) { + tree.layout.push_back({{0, 0, -2}, 0.02f, i == 0 ? 1u : 0u, i == 0 ? 513u : 0u}); + tree.nodes.positions.insert(tree.nodes.positions.end(), {0, 0, -2}); + tree.nodes.covariances.insert(tree.nodes.covariances.end(), + {0.0001f, 0, 0, 0.0001f, 0, 0.0001f}); + tree.nodes.colors.insert(tree.nodes.colors.end(), {0.5f, 0.5f, 0.5f}); + tree.nodes.alphas.push_back(0.1f); + } + tree.selection = splat::buildLodSelectionData(tree); + auto& gpu = test::Gpu::get(); + MetalLOD lod; + ASSERT_TRUE(lod.create(gpu.device, gpu.library)); + ASSERT_TRUE(lod.upload(gpu.queue, tree, 513, 0)); + std::array stats{}; + auto cut = select(lod, camera(), &stats); + ASSERT_EQ(cut.size(), 513u); + for (uint32_t i = 0; i < 513; ++i) EXPECT_EQ(cut[i], i + 1); + EXPECT_EQ(stats[5], 1u); + EXPECT_EQ(stats[4], 0u); + ASSERT_TRUE(lod.upload(gpu.queue, tree, 512, 0)); + EXPECT_EQ(select(lod, camera(), &stats), (std::vector{0})); + EXPECT_EQ(stats[4], 1u); +} +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-ios/tests/MetalRadixSortTest.mm b/packages/splatkit-ios/tests/MetalRadixSortTest.mm new file mode 100644 index 0000000..bf9bfdd --- /dev/null +++ b/packages/splatkit-ios/tests/MetalRadixSortTest.mm @@ -0,0 +1,147 @@ +#import + +#include +#include +#include +#include +#include "MetalTestContext.h" +#include "rendering/MetalRadixSort.h" + +namespace splatkit { +namespace { +using test::Gpu; + +struct Pair { + uint32_t key; + uint32_t value; + bool operator==(const Pair& o) const { return key == o.key && value == o.value; } +}; + +// Fills the key and value buffers with `pairs`, sorts on the GPU, returns what came out. +std::vector sortOnGpu(MetalRadixSort& v, const std::vector& pairs, + MetalRadixSort::KeyBits bits = MetalRadixSort::KeyBits::Full32) { + Gpu& gpu = Gpu::get(); + auto* keys = static_cast(v.keys().contents); + auto* values = static_cast(v.values().contents); + for (size_t i = 0; i < pairs.size(); ++i) { + keys[i] = pairs[i].key; + values[i] = pairs[i].value; + } + uint32_t count = static_cast(pairs.size()); + id countBuffer = [gpu.device newBufferWithBytes:&count + length:sizeof(count) + options:MTLResourceStorageModeShared]; + id cmd = [gpu.queue commandBuffer]; + v.encode(cmd, countBuffer, bits); + [cmd commit]; + [cmd waitUntilCompleted]; + EXPECT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + std::vector out(pairs.size()); + for (size_t i = 0; i < pairs.size(); ++i) { + out[i] = {static_cast(v.keys().contents)[i], + static_cast(v.values().contents)[i]}; + } + return out; +} + +std::vector sortOnCpu(std::vector pairs) { + std::stable_sort(pairs.begin(), pairs.end(), + [](const Pair& a, const Pair& b) { return a.key < b.key; }); + return pairs; +} + +std::vector randomPairs(size_t n, uint32_t keyMask, unsigned seed) { + std::mt19937 rng(seed); + std::vector pairs(n); + for (size_t i = 0; i < n; ++i) pairs[i] = {rng() & keyMask, static_cast(i)}; + return pairs; +} + +class MetalRadixSortTest : public ::testing::Test { + protected: + void SetUp() override { + ASSERT_NE(Gpu::get().device, nil); + ASSERT_NE(Gpu::get().library, nil); + ASSERT_TRUE(sort.create(Gpu::get().device, Gpu::get().library)); + } + MetalRadixSort sort; +}; + +TEST_F(MetalRadixSortTest, SortsRandomKeysLikeAStableCpuSort) { + const size_t n = 1000003; // many blocks, a partial last one + ASSERT_TRUE(sort.reserve(n)); + const auto pairs = randomPairs(n, 0xffffffffu, 1); + EXPECT_EQ(sortOnGpu(sort, pairs), sortOnCpu(pairs)); +} + +TEST_F(MetalRadixSortTest, KeepsTheOrderOfEqualKeys) { + const size_t n = 70000; // few distinct keys: long runs of ties across blocks + ASSERT_TRUE(sort.reserve(n)); + const auto pairs = randomPairs(n, 0x7u, 2); + EXPECT_EQ(sortOnGpu(sort, pairs), sortOnCpu(pairs)); +} + +TEST_F(MetalRadixSortTest, SortsSmallAndEmptyInputs) { + ASSERT_TRUE(sort.reserve(64)); + EXPECT_EQ(sortOnGpu(sort, {}), std::vector{}); + const std::vector one{{5, 9}}; + EXPECT_EQ(sortOnGpu(sort, one), one); + const auto pairs = randomPairs(17, 0xffffffffu, 3); + EXPECT_EQ(sortOnGpu(sort, pairs), sortOnCpu(pairs)); +} + +TEST_F(MetalRadixSortTest, SortsExactlyOneBlock) { + const size_t n = MetalRadixSort::kBlock; + ASSERT_TRUE(sort.reserve(n)); + const auto pairs = randomPairs(n, 0xffffffffu, 4); + EXPECT_EQ(sortOnGpu(sort, pairs), sortOnCpu(pairs)); +} + +TEST_F(MetalRadixSortTest, TwoPassKeysMatchFourPassAndStableCpuIncludingTiesAndTails) { + ASSERT_TRUE(sort.reserve(1000003)); + for (uint32_t n : {0u, 1u, 31u, 32u, 33u, 4095u, 4096u, 4097u, 1000003u}) { + SCOPED_TRACE(n); + auto pairs = randomPairs(n, 0xffffu, 16); + if (n > 1) { + pairs.front().key = 65535; + pairs.back().key = 0; + } + const auto expected = sortOnCpu(pairs); + EXPECT_EQ(sortOnGpu(sort, pairs, MetalRadixSort::KeyBits::Low16), expected); + EXPECT_EQ(sortOnGpu(sort, pairs), expected); + } + const auto ties = randomPairs(70000, 0x7u, 17); + EXPECT_EQ(sortOnGpu(sort, ties, MetalRadixSort::KeyBits::Low16), sortOnCpu(ties)); +} + +// Not a check, a number: the GPU time of a sort at the scale a phone draws. +TEST_F(MetalRadixSortTest, ReportsTheSortTimeOfFiveMillionKeys) { + const size_t n = 5000000; + ASSERT_TRUE(sort.reserve(n)); + const auto pairs = randomPairs(n, 0xffffffffu, 5); + auto* keys = static_cast(sort.keys().contents); + auto* values = static_cast(sort.values().contents); + for (size_t i = 0; i < n; ++i) { + keys[i] = pairs[i].key; + values[i] = pairs[i].value; + } + uint32_t count = static_cast(n); + id countBuffer = [Gpu::get().device newBufferWithBytes:&count + length:sizeof(count) + options:MTLResourceStorageModeShared]; + double best = 1e9; + for (int i = 0; i < 10; ++i) { + id cmd = [Gpu::get().queue commandBuffer]; + sort.encode(cmd, countBuffer); + [cmd commit]; + [cmd waitUntilCompleted]; + best = std::min(best, (cmd.GPUEndTime - cmd.GPUStartTime) * 1000.0); + // The result is in place after an even number of passes: sort it again as is. + } + const auto* sorted = static_cast(sort.keys().contents); + EXPECT_TRUE(std::is_sorted(sorted, sorted + n)); + printf("[ sort ] %zu keys: %.2f ms on %s\n", n, best, Gpu::get().device.name.UTF8String); +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-ios/tests/MetalRasterTest.mm b/packages/splatkit-ios/tests/MetalRasterTest.mm new file mode 100644 index 0000000..c87fa9f --- /dev/null +++ b/packages/splatkit-ios/tests/MetalRasterTest.mm @@ -0,0 +1,319 @@ +#include +#include +#include +#include +#include + +#include "rendering/MetalSplatRenderer.h" +#include "splat/formats/SpzDecoder.h" +#include "splat/io/MappedFile.h" +#include "splat/sorting/SpatialOrder.h" +#include "splatkit/camera/WalkCamera.h" + +namespace splatkit { +namespace { + +class TileMode { + public: + explicit TileMode(bool enabled) { + if (const char* value = std::getenv("SPLATKIT_METAL_TILE_RASTER")) previous_ = value; + setenv("SPLATKIT_METAL_TILE_RASTER", enabled ? "1" : "0", 1); + } + ~TileMode() { + if (previous_) + setenv("SPLATKIT_METAL_TILE_RASTER", previous_->c_str(), 1); + else + unsetenv("SPLATKIT_METAL_TILE_RASTER"); + } + + private: + std::optional previous_; +}; + +class MetalRasterTest : public testing::Test { + protected: + void SetUp() override { + id device = MTLCreateSystemDefaultDevice(); + if (device == nil || ![device supportsFamily:MTLGPUFamilyApple7]) + GTEST_SKIP() << "Apple GPU family 7 unavailable; renderer validation requires A14/M1+"; + } +}; + +TEST_F(MetalRasterTest, WorldReadinessRequiresGpuCompletionAndResetsOnReplacement) { + TileMode option(false); + auto renderer = MetalSplatRenderer::create(); + ASSERT_NE(renderer, nullptr); + auto layer = [CAMetalLayer layer]; + layer.drawableSize = CGSizeMake(64, 64); + renderer->setLayer(layer); + renderer->setDrawableSize(64, 64); + auto completed = dispatch_semaphore_create(0); + auto drawAndWait = [&](const SplatRenderer::Frame& frame) { + renderer->captureNextFrame( + [&](std::vector, uint32_t, uint32_t) { dispatch_semaphore_signal(completed); }); + EXPECT_TRUE(renderer->draw(frame)); + EXPECT_EQ( + dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), 0); + }; + drawAndWait({}); + EXPECT_FALSE(renderer->hasCompletedWorldFrame()); + splat::SplatCloud cloud; + cloud.positions = {0, 0, -2}; + cloud.colors = {1, 0, 0}; + cloud.alphas = {1}; + cloud.covariances = {0.04f, 0, 0, 0.04f, 0, 0.04f}; + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + const SplatRenderer::Range range{0, 1}; + frame.ranges = ⦥ + frame.rangeCount = 1; + frame.proj = splat::Mat4::perspective(1, 1, 0.1f, 100); + for (int replacement = 0; replacement < 2; ++replacement) { + ASSERT_TRUE(renderer->uploadWorld(cloud, 0)); + EXPECT_FALSE(renderer->hasCompletedWorldFrame()); + drawAndWait({}); // Uploaded, but no CPU order or GPU visibility has run yet. + EXPECT_FALSE(renderer->hasCompletedWorldFrame()); + drawAndWait(frame); + EXPECT_TRUE(renderer->hasCompletedWorldFrame()); + } + ASSERT_TRUE(renderer->createSlab(10, 0)); + EXPECT_FALSE(renderer->hasCompletedWorldFrame()); +} + +TEST_F(MetalRasterTest, HybridCompletesOverflowTilesWithTheFullHardwareImage) { + std::vector images[2]; + for (int mode = 0; mode < 2; ++mode) { + TileMode option(mode != 0); + auto renderer = MetalSplatRenderer::create(); + ASSERT_NE(renderer, nullptr); + auto layer = [CAMetalLayer layer]; + layer.drawableSize = CGSizeMake(64, 64); + renderer->setLayer(layer); + renderer->setDrawableSize(64, 64); + splat::SplatCloud cloud; + for (uint32_t i = 0; i < 514; ++i) { + cloud.positions.insert(cloud.positions.end(), {i == 513 ? 0.85f : 0.0f, 0, -2}); + cloud.colors.insert(cloud.colors.end(), {i == 513 ? 0.0f : 1.0f, i == 513 ? 1.0f : 0.0f, 0}); + cloud.alphas.push_back(0.6f); + cloud.covariances.insert(cloud.covariances.end(), {0.01f, 0, 0, 0.01f, 0, 0.01f}); + } + ASSERT_TRUE(renderer->uploadWorld(cloud, 0)); + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + const SplatRenderer::Range range{0, 514}; + frame.ranges = ⦥ + frame.rangeCount = 1; + frame.proj = splat::Mat4::perspective(1, 1, 0.1f, 100); + auto captured = dispatch_semaphore_create(0); + renderer->captureNextFrame([&](std::vector pixels, uint32_t, uint32_t) { + images[mode] = std::move(pixels); + dispatch_semaphore_signal(captured); + }); + ASSERT_TRUE(renderer->draw(frame)); + ASSERT_EQ(dispatch_semaphore_wait(captured, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), + 0); + ASSERT_EQ(images[mode].size(), 64u * 64u * 4u); + } + int maxDifference = 0; + for (size_t i = 0; i < images[0].size(); ++i) + maxDifference = std::max(maxDifference, std::abs(int(images[0][i]) - int(images[1][i]))); + EXPECT_LE(maxDifference, 2); + EXPECT_GT(images[1][(32 * 64 + 32) * 4 + 2], 200); + EXPECT_GT(images[1][(32 * 64 + 56) * 4 + 1], 80); +} + +TEST_F(MetalRasterTest, LargeFootprintAndCrossTileBoundaryMatchHardwareImage) { + std::vector images[2]; + for (int mode = 0; mode < 2; ++mode) { + TileMode option(mode != 0); + auto renderer = MetalSplatRenderer::create(); + ASSERT_NE(renderer, nullptr); + auto layer = [CAMetalLayer layer]; + layer.drawableSize = CGSizeMake(128, 128); + renderer->setLayer(layer); + renderer->setDrawableSize(128, 128); + splat::SplatCloud cloud; + // Large blue footprint covers >16 tiles; red straddles x=96, the border + // between one of its hardware-owned tiles and a compute-owned neighbour. + cloud.positions = {-0.13f, 0.13f, -2.1f, 0.54f, 0.13f, -2.0f}; + cloud.colors = {0, 0, 1, 1, 0, 0}; + cloud.alphas = {0.6f, 0.6f}; + cloud.covariances = {0.02f, 0, 0, 0.02f, 0, 0.02f, 0.001f, 0, 0, 0.001f, 0, 0.001f}; + ASSERT_TRUE(renderer->uploadWorld(cloud, 0)); + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + const SplatRenderer::Range range{0, 2}; + frame.ranges = ⦥ + frame.rangeCount = 1; + frame.proj = splat::Mat4::perspective(1, 1, 0.1f, 100); + auto captured = dispatch_semaphore_create(0); + renderer->captureNextFrame([&](std::vector pixels, uint32_t, uint32_t) { + images[mode] = std::move(pixels); + dispatch_semaphore_signal(captured); + }); + ASSERT_TRUE(renderer->draw(frame)); + ASSERT_EQ(dispatch_semaphore_wait(captured, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), + 0); + ASSERT_EQ(images[mode].size(), 128u * 128u * 4u); + const auto tiles = renderer->lastScreenTileStats(); + if (mode == 1) { + EXPECT_GT(tiles.compute, 0u); + EXPECT_GT(tiles.nonemptyCompute, 0u); + EXPECT_GT(tiles.hardware, 0u); + EXPECT_EQ(tiles.compute + tiles.hardware, 64u); + } else { + EXPECT_EQ(tiles.compute + tiles.nonemptyCompute + tiles.hardware, 0u); + } + } + int maxDifference = 0; + for (size_t i = 0; i < images[0].size(); ++i) + maxDifference = std::max(maxDifference, std::abs(int(images[0][i]) - int(images[1][i]))); + EXPECT_LE(maxDifference, 2); + EXPECT_GT(images[1][(56 * 128 + 56) * 4], 60); // large blue splat was not omitted + EXPECT_GT(images[1][(56 * 128 + 95) * 4 + 2], 60); + EXPECT_GT(images[1][(56 * 128 + 96) * 4 + 2], 60); +} + +TEST_F(MetalRasterTest, LodLeafCutFeedsHybridWithoutLosingOverflowTiles) { + splat::LodTree tree; + tree.leafCount = 514; + tree.nodes.positions = {0, 0, -2}; + tree.nodes.colors = {1, 0, 0}; + tree.nodes.alphas = {1}; + tree.nodes.covariances = {0.25f, 0, 0, 0.25f, 0, 0.25f}; + tree.layout.push_back({{0, 0, -2}, 2, 1, 514}); + for (uint32_t i = 0; i < 514; ++i) { + const float x = i == 513 ? 0.85f : 0.0f; + tree.nodes.positions.insert(tree.nodes.positions.end(), {x, 0, -2}); + tree.nodes.colors.insert(tree.nodes.colors.end(), + {i == 513 ? 0.0f : 1.0f, i == 513 ? 1.0f : 0.0f, 0}); + tree.nodes.alphas.push_back(0.6f); + tree.nodes.covariances.insert(tree.nodes.covariances.end(), {0.01f, 0, 0, 0.01f, 0, 0.01f}); + tree.layout.push_back({{x, 0, -2}, 0, 0, 0}); + } + std::vector images[2]; + for (int mode = 0; mode < 2; ++mode) { + TileMode option(mode != 0); + auto renderer = MetalSplatRenderer::create(); + ASSERT_NE(renderer, nullptr); + auto layer = [CAMetalLayer layer]; + layer.drawableSize = CGSizeMake(64, 64); + renderer->setLayer(layer); + renderer->setDrawableSize(64, 64); + ASSERT_TRUE(renderer->uploadLodWorld(tree, 0, 514)); + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + frame.proj = splat::Mat4::perspective(1, 1, 0.1f, 100); + auto completed = dispatch_semaphore_create(0); + renderer->captureNextFrame([&](std::vector pixels, uint32_t, uint32_t) { + images[mode] = std::move(pixels); + dispatch_semaphore_signal(completed); + }); + ASSERT_TRUE(renderer->draw(frame)); + ASSERT_EQ( + dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), 0); + ASSERT_EQ(renderer->lastSelectedCount(), 514u); + ASSERT_EQ(renderer->lastDrawCount(), 514u); + ASSERT_EQ(images[mode].size(), 64u * 64u * 4u); + if (mode == 1) { + const auto tiles = renderer->lastScreenTileStats(); + EXPECT_GT(tiles.nonemptyCompute, 0u); + EXPECT_GT(tiles.hardware, 0u); + EXPECT_EQ(tiles.compute + tiles.hardware, 16u); + } + } + int maxDifference = 0; + for (size_t i = 0; i < images[0].size(); ++i) + maxDifference = std::max(maxDifference, std::abs(int(images[0][i]) - int(images[1][i]))); + EXPECT_LE(maxDifference, 2); + EXPECT_GT(images[1][(32 * 64 + 32) * 4 + 2], 200); + EXPECT_GT(images[1][(32 * 64 + 56) * 4 + 1], 80); +} + +TEST_F(MetalRasterTest, GpuOrderedSplatContributesToThePresentedPixels) { + auto renderer = MetalSplatRenderer::create(); + ASSERT_NE(renderer, nullptr); + CAMetalLayer* layer = [CAMetalLayer layer]; + layer.drawableSize = CGSizeMake(64, 64); + renderer->setLayer(layer); + renderer->setDrawableSize(64, 64); + splat::SplatCloud cloud; + cloud.positions = {0, 0, -2}; + cloud.colors = {1, 0, 0}; + cloud.alphas = {1}; + cloud.covariances = {0.04f, 0, 0, 0.04f, 0, 0.04f}; + ASSERT_TRUE(renderer->uploadWorld(cloud, 0)); + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + const SplatRenderer::Range range{0, 1}; + frame.ranges = ⦥ + frame.rangeCount = 1; + frame.proj = splat::Mat4::perspective(1, 1, 0.1f, 100); + dispatch_semaphore_t captured = dispatch_semaphore_create(0); + std::vector pixels; + renderer->captureNextFrame([&](std::vector image, uint32_t, uint32_t) { + pixels = std::move(image); + dispatch_semaphore_signal(captured); + }); + ASSERT_TRUE(renderer->draw(frame)); + ASSERT_EQ(dispatch_semaphore_wait(captured, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), + 0); + ASSERT_EQ(pixels.size(), 64u * 64u * 4u); + const size_t center = (32 * 64 + 32) * 4; + EXPECT_GT(pixels[center + 2], 200); // red, not the background + EXPECT_LT(pixels[center + 1], 20); +} + +TEST_F(MetalRasterTest, IssFixtureProducesVisiblePixels) { + const char* path = std::getenv("SPLAT_ISS_PATH"); + if (!path) GTEST_SKIP() << "Set SPLAT_ISS_PATH to the ISS SPZ fixture"; + auto file = splat::MappedFile::open(path); + ASSERT_TRUE(file); + splat::SpzDecodeOptions options; + options.maxShDegree = 1; + auto decoded = splat::decodeSpz(file.value().data(), file.value().size(), options); + ASSERT_TRUE(decoded); + auto& cloud = decoded.value(); + splat::reorderSpatially(cloud); + auto renderer = MetalSplatRenderer::create(); + ASSERT_NE(renderer, nullptr); + CAMetalLayer* layer = [CAMetalLayer layer]; + layer.drawableSize = CGSizeMake(1206, 2622); + renderer->setLayer(layer); + renderer->setDrawableSize(1206, 2622); + ASSERT_TRUE(renderer->draw({})); // The app presents a background while loading. + ASSERT_TRUE(renderer->uploadWorld(cloud, 1)); + WalkCamera camera; + camera.setLookAt({0, 128, -2}, {0, -2, -2}, {1, 0, 0}); + SplatRenderer::Frame frame; + frame.orderSource = SplatRenderer::OrderSource::gpu; + frame.view = camera.viewMatrix(); + frame.cameraPosition = camera.position(); + frame.proj = + splat::Mat4::perspective(65.0f * 3.14159265f / 180.0f, 1206.0f / 2622.0f, 0.05f, 200); + frame.shDegree = 1; + const SplatRenderer::Range range{0, static_cast(cloud.count())}; + frame.ranges = ⦥ + frame.rangeCount = 1; + dispatch_semaphore_t captured = dispatch_semaphore_create(0); + std::vector pixels; + for (int iteration = 0; iteration < 3; ++iteration) { + renderer->captureNextFrame([&](std::vector image, uint32_t, uint32_t) { + pixels = std::move(image); + dispatch_semaphore_signal(captured); + }); + ASSERT_TRUE(renderer->draw(frame)); + ASSERT_EQ( + dispatch_semaphore_wait(captured, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC)), 0); + uint32_t visiblePixels = 0; + for (size_t i = 0; i < pixels.size(); i += 4) { + if (pixels[i] > 40 || pixels[i + 1] > 40 || pixels[i + 2] > 40) ++visiblePixels; + } + printf("[ ISS ] %u visible pixels, %u splats, %.2f ms compute, %.2f ms raster\n", visiblePixels, + renderer->lastDrawCount(), renderer->lastSortMillis(), renderer->lastGpuMillis()); + EXPECT_GT(visiblePixels, 10000u); + } +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-ios/tests/MetalTestContext.h b/packages/splatkit-ios/tests/MetalTestContext.h new file mode 100644 index 0000000..c1977a7 --- /dev/null +++ b/packages/splatkit-ios/tests/MetalTestContext.h @@ -0,0 +1,24 @@ +#pragma once + +#import +#include "SplatShaderSource.h" + +namespace splatkit::test { + +// Compile exactly the source shipped by the SDK, once per test executable. +struct Gpu { + id device = MTLCreateSystemDefaultDevice(); + id queue = [device newCommandQueue]; + id library = nil; + Gpu() { + NSError* error = nil; + library = [device newLibraryWithSource:@(SplatShaderSource) options:nil error:&error]; + if (library == nil) NSLog(@"Splat test shader compilation: %@", error); + } + static Gpu& get() { + static Gpu gpu; + return gpu; + } +}; + +} // namespace splatkit::test diff --git a/packages/splatkit-ios/tests/MetalTileRasterTest.mm b/packages/splatkit-ios/tests/MetalTileRasterTest.mm new file mode 100644 index 0000000..6424245 --- /dev/null +++ b/packages/splatkit-ios/tests/MetalTileRasterTest.mm @@ -0,0 +1,431 @@ +#include +#include +#include +#include "MetalTestContext.h" +#include "rendering/MetalCompute.h" +#include "rendering/MetalShaderTypes.h" +#include "rendering/MetalTileRaster.h" +#include "splat/math/Half.h" + +namespace splatkit { +namespace { + +TEST(MetalTileRasterTest, LargeFootprintFallsBackOnlyOnIntersectedTilesAndResetsEachFrame) { + auto& gpu = test::Gpu::get(); + MetalTileRaster raster; + ASSERT_TRUE(raster.create(gpu.device, gpu.library)); + CameraUniform camera{}; + camera.screenSize[0] = camera.screenSize[1] = 128; + ProjectedSplat splats[3]{}; + const uint32_t order[] = {0, 1, 2}; + const float centres[][2] = {{8.5f, 8.5f}, {95.5f, 56.5f}, {56.5f, 56.5f}}; + for (uint32_t i = 0; i < 3; ++i) { + splats[i].center[0] = centres[i][0] / 64.0f - 1.0f; + splats[i].center[1] = 1.0f - centres[i][1] / 64.0f; + splats[i].axis1 = splat::toHalf(i == 2 ? 10.0f : 1.0f); + splats[i].axis2 = uint32_t{splats[i].axis1} << 16; + splats[i].radius = 3; + splats[i].color0 = splat::toHalf(1.0f); + splats[i].color1 = uint32_t{splat::toHalf(1.0f)} << 16; + } + auto buffer = [&](const void* data, size_t bytes) { + return [gpu.device newBufferWithBytes:data length:bytes options:MTLResourceStorageModeShared]; + }; + auto uniforms = buffer(&camera, sizeof(camera)); + auto projected = buffer(splats, sizeof(splats)); + auto indices = buffer(order, sizeof(order)); + auto desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA16Float + width:128 + height:128 + mipmapped:NO]; + desc.usage = MTLTextureUsageShaderWrite; + desc.storageMode = MTLStorageModePrivate; + auto target = [gpu.device newTextureWithDescriptor:desc]; + auto pixels = [gpu.device newBufferWithLength:1024 * 128 options:MTLResourceStorageModeShared]; + for (const uint32_t n : {3u, 2u, 3u}) { + auto count = buffer(&n, sizeof(n)); + auto cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(raster.encode(cmd, uniforms, projected, indices, count, 3, target)); + auto copy = [cmd blitCommandEncoder]; + [copy copyFromTexture:target + sourceSlice:0 + sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(128, 128, 1) + toBuffer:pixels + destinationOffset:0 + destinationBytesPerRow:1024 + destinationBytesPerImage:1024 * 128]; + [copy endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + const auto* stats = static_cast(raster.diagnostics(0).contents); + EXPECT_EQ(stats[0], n == 3 ? 39u : 64u); + EXPECT_EQ(stats[1], n == 3 ? 25u : 0u); + EXPECT_EQ(stats[2], 0u); + const auto* image = static_cast(pixels.contents); + EXPECT_FLOAT_EQ(splat::fromHalf(image[8 * 512 + 8 * 4]), 1.0f); + // Compare the entire per-tile completion mask, including all four edges of + // the large rectangle; touching tile (6,3) must remain compute-owned. + for (uint32_t y = 0; y < 8; ++y) { + for (uint32_t x = 0; x < 8; ++x) { + const bool hardware = n == 3 && x >= 1 && x <= 5 && y >= 1 && y <= 5; + const size_t alpha = (y * 16 + 8) * 512 + (x * 16 + 8) * 4 + 3; + EXPECT_FLOAT_EQ(splat::fromHalf(image[alpha]), hardware ? 0.0f : 1.0f); + } + } + } +} + +TEST(MetalTileRasterTest, ReverseCandidatesSortThroughPaddingAndSecondCooperativeLoad) { + auto& gpu = test::Gpu::get(); + auto pipeline = metal::pipeline(gpu.device, gpu.library, "rasterSplatTiles"); + ASSERT_NE(pipeline, nil); + CameraUniform camera{}; + camera.screenSize[0] = camera.screenSize[1] = 1; + auto buffer = [&](const void* data, size_t bytes) { + return [gpu.device newBufferWithBytes:data length:bytes options:MTLResourceStorageModeShared]; + }; + auto uniforms = buffer(&camera, sizeof(camera)); + const uint32_t config[] = {1, 1, 512, 512}, zero = 0; + auto fallback = buffer(&zero, sizeof(zero)); + auto desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA16Float + width:1 + height:1 + mipmapped:NO]; + desc.usage = MTLTextureUsageShaderWrite; + desc.storageMode = MTLStorageModePrivate; + auto target = [gpu.device newTextureWithDescriptor:desc]; + auto pixel = [gpu.device newBufferWithLength:256 options:MTLResourceStorageModeShared]; + for (const uint32_t n : {1u, 2u, 3u, 5u, 31u, 255u, 256u, 257u, 511u, 512u}) { + SCOPED_TRACE(n); + std::vector splats(512); + std::vector order(512), candidates(512); + for (uint32_t i = 0; i < n; ++i) { + order[i] = i; + candidates[i] = n - i - 1; + auto& p = splats[i]; + p.center[0] = i + 3 < n ? 1000.0f : 0.0f; + p.axis1 = splat::toHalf(1.0f); + p.axis2 = uint32_t{splat::toHalf(1.0f)} << 16; + p.radius = 3; + if (i == n - 1) { + p.color1 = (uint32_t{splat::toHalf(1.0f)} << 16) | splat::toHalf(1.0f); + } else if (i == n - 2) { + p.color0 = uint32_t{splat::toHalf(1.0f)} << 16; + p.color1 = uint32_t{splat::toHalf(0.5f)} << 16; + } else { + p.color0 = splat::toHalf(1.0f); + p.color1 = uint32_t{splat::toHalf(0.5f)} << 16; + } + } + auto projected = buffer(splats.data(), splats.size() * sizeof(ProjectedSplat)); + auto indices = buffer(order.data(), order.size() * sizeof(uint32_t)); + auto bins = buffer(candidates.data(), candidates.size() * sizeof(uint32_t)); + auto count = buffer(&n, sizeof(n)); + auto cmd = [gpu.queue commandBuffer]; + auto compute = [cmd computeCommandEncoder]; + [compute setComputePipelineState:pipeline]; + [compute setBuffer:uniforms offset:0 atIndex:0]; + [compute setBuffer:projected offset:0 atIndex:1]; + [compute setBuffer:indices offset:0 atIndex:2]; + [compute setBuffer:count offset:0 atIndex:3]; + [compute setBuffer:count offset:0 atIndex:4]; + [compute setBuffer:bins offset:0 atIndex:5]; + [compute setBuffer:fallback offset:0 atIndex:6]; + [compute setBytes:config length:sizeof(config) atIndex:7]; + [compute setTexture:target atIndex:0]; + [compute dispatchThreadgroups:MTLSizeMake(1, 1, 1) + threadsPerThreadgroup:MTLSizeMake(16, 16, 1)]; + [compute endEncoding]; + auto copy = [cmd blitCommandEncoder]; + [copy copyFromTexture:target + sourceSlice:0 + sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(1, 1, 1) + toBuffer:pixel + destinationOffset:0 + destinationBytesPerRow:256 + destinationBytesPerImage:256]; + [copy endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + const auto* rgba = static_cast(pixel.contents); + EXPECT_NEAR(splat::fromHalf(rgba[0]), n >= 3 ? 0.5f : 0.0f, 0.001f); + EXPECT_NEAR(splat::fromHalf(rgba[1]), n >= 3 ? 0.25f : n == 2 ? 0.5f : 0.0f, 0.001f); + EXPECT_NEAR(splat::fromHalf(rgba[2]), n >= 3 ? 0.25f : n == 2 ? 0.5f : 1.0f, 0.001f); + EXPECT_FLOAT_EQ(splat::fromHalf(rgba[3]), 1.0f); + } +} + +TEST(MetalTileRasterTest, MixedTileReservationsWithInactiveLaneZeroAndPaddedGroups) { + auto& gpu = test::Gpu::get(); + MetalTileRaster raster; + ASSERT_TRUE(raster.create(gpu.device, gpu.library)); + CameraUniform camera{}; + camera.screenSize[0] = camera.screenSize[1] = 64; + auto buffer = [&](const void* data, size_t bytes) { + return [gpu.device newBufferWithBytes:data length:bytes options:MTLResourceStorageModeShared]; + }; + auto uniforms = buffer(&camera, sizeof(camera)); + auto desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA16Float + width:64 + height:64 + mipmapped:NO]; + desc.usage = MTLTextureUsageShaderWrite; + desc.storageMode = MTLStorageModePrivate; + auto target = [gpu.device newTextureWithDescriptor:desc]; + auto pixels = [gpu.device newBufferWithLength:512 * 64 options:MTLResourceStorageModeShared]; + for (const uint32_t n : {31u, 35u, 258u}) { + std::vector splats(n); + std::vector order(n); + uint32_t perTile[16]{}; + for (uint32_t i = 0; i < n; ++i) { + const uint32_t tile = (i * 7) % 16; + const bool active = i % 32 != 0; + order[i] = i; + splats[i].center[0] = active ? (float(tile % 4 * 16) + 8.5f) / 32.0f - 1.0f : 1000.0f; + splats[i].center[1] = 1.0f - (float(tile / 4 * 16) + 8.5f) / 32.0f; + splats[i].axis1 = splat::toHalf(0.5f); + splats[i].axis2 = uint32_t{splat::toHalf(0.5f)} << 16; + splats[i].radius = 3; + const bool front = active && perTile[tile]++ == 0; + splats[i].color0 = front ? splat::toHalf(1.0f) : 0; + splats[i].color1 = + (uint32_t{splat::toHalf(0.125f)} << 16) | (front ? 0 : splat::toHalf(1.0f)); + } + auto projected = buffer(splats.data(), splats.size() * sizeof(ProjectedSplat)); + auto indices = buffer(order.data(), order.size() * sizeof(uint32_t)); + auto counter = buffer(&n, sizeof(n)); + auto cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(raster.encode(cmd, uniforms, projected, indices, counter, n, target)); + auto copy = [cmd blitCommandEncoder]; + [copy copyFromTexture:target + sourceSlice:0 + sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(64, 64, 1) + toBuffer:pixels + destinationOffset:0 + destinationBytesPerRow:512 + destinationBytesPerImage:512 * 64]; + [copy endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + const auto* image = static_cast(pixels.contents); + for (uint32_t tile = 0; tile < 16; ++tile) { + SCOPED_TRACE(::testing::Message() << "count=" << n << " tile=" << tile); + const size_t pixel = (tile / 4 * 16 + 8) * 256 + (tile % 4 * 16 + 8) * 4; + const float remaining = std::pow(0.875f, float(perTile[tile])); + const float red = perTile[tile] ? 0.125f : 0.0f; + EXPECT_NEAR(splat::fromHalf(image[pixel]), red + 0.05f * remaining, 0.001f); + EXPECT_NEAR(splat::fromHalf(image[pixel + 2]), 1.0f - red - remaining + 0.08f * remaining, + 0.001f); + EXPECT_FLOAT_EQ(splat::fromHalf(image[pixel + 3]), 1.0f); + } + } +} + +TEST(MetalTileRasterTest, RetainsContributionsUntilStrictTransmittanceCutoff) { + auto& gpu = test::Gpu::get(); + MetalTileRaster raster; + ASSERT_TRUE(raster.create(gpu.device, gpu.library)); + CameraUniform camera{}; + camera.screenSize[0] = 17; + camera.screenSize[1] = 19; + auto buffer = [&](const void* data, size_t bytes) { + return [gpu.device newBufferWithBytes:data length:bytes options:MTLResourceStorageModeShared]; + }; + auto uniforms = buffer(&camera, sizeof(camera)); + auto desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA16Float + width:17 + height:19 + mipmapped:NO]; + desc.usage = MTLTextureUsageShaderWrite; + desc.storageMode = MTLStorageModePrivate; + auto target = [gpu.device newTextureWithDescriptor:desc]; + auto pixels = [gpu.device newBufferWithLength:256 * 19 options:MTLResourceStorageModeShared]; + // At the centre, 9 half-opaque red layers leave T=1/512: blue must still contribute. + // Fourteen leave T=1/16384: stop before blue, retaining that much background. + // Adjacent lanes see different coverage, including pixels outside a partial tile. + for (uint32_t layers : {9u, 14u}) { + const uint32_t n = layers + 1; + std::vector splats(n); + std::vector order(n); + for (uint32_t i = 0; i < n; ++i) { + order[i] = i; + splats[i].axis1 = splat::toHalf(0.5f); + splats[i].axis2 = uint32_t{splat::toHalf(0.5f)} << 16; + splats[i].radius = 3; + splats[i].color0 = i < layers ? splat::toHalf(1.0f) : 0; + splats[i].color1 = i < layers ? uint32_t{splat::toHalf(0.5f)} << 16 + : (uint32_t{splat::toHalf(1.0f)} << 16) | splat::toHalf(1.0f); + } + auto projected = buffer(splats.data(), splats.size() * sizeof(ProjectedSplat)); + auto indices = buffer(order.data(), order.size() * sizeof(uint32_t)); + auto counter = buffer(&n, sizeof(n)); + auto cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(raster.encode(cmd, uniforms, projected, indices, counter, n, target)); + auto copy = [cmd blitCommandEncoder]; + [copy copyFromTexture:target + sourceSlice:0 + sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(17, 19, 1) + toBuffer:pixels + destinationOffset:0 + destinationBytesPerRow:256 + destinationBytesPerImage:256 * 19]; + [copy endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + const auto* image = static_cast(pixels.contents); + const size_t centre = 9 * 128 + 8 * 4; + const float remaining = 1.0f / float(1u << layers); + EXPECT_NEAR(splat::fromHalf(image[centre + 2]), layers == 9 ? remaining : remaining * 0.08f, + 0.000001f); + EXPECT_FLOAT_EQ(splat::fromHalf(image[centre + 3]), 1.0f); + const size_t corner = 18 * 128 + 16 * 4; + EXPECT_NEAR(splat::fromHalf(image[corner + 2]), 0.08f, 0.0001f); + } +} + +TEST(MetalTileRasterTest, SparseDepthInterleavingCompletesWithoutDroppingBackground) { + auto& gpu = test::Gpu::get(); + MetalTileRaster raster; + ASSERT_TRUE(raster.create(gpu.device, gpu.library)); + constexpr uint32_t n = 32768; + CameraUniform camera{}; + camera.screenSize[0] = camera.screenSize[1] = 64; + std::vector splats(n); + std::vector order(n); + for (uint32_t i = 0; i < n; ++i) { + order[i] = i; + const uint32_t tile = i % 16; + splats[i].center[0] = (float(tile % 4 * 16) + 8.5f) / 32.0f - 1.0f; + splats[i].center[1] = 1.0f - (float(tile / 4 * 16) + 8.5f) / 32.0f; + splats[i].axis1 = splat::toHalf(0.25f); + splats[i].axis2 = uint32_t{splat::toHalf(0.25f)} << 16; + splats[i].radius = 3; + splats[i].color0 = splat::toHalf(1.0f); + splats[i].color1 = uint32_t{splat::toHalf(1.0f)} << 16; + } + auto buffer = [&](const void* data, size_t bytes) { + return [gpu.device newBufferWithBytes:data length:bytes options:MTLResourceStorageModeShared]; + }; + auto uniforms = buffer(&camera, sizeof(camera)); + auto projected = buffer(splats.data(), splats.size() * sizeof(ProjectedSplat)); + auto indices = buffer(order.data(), order.size() * sizeof(uint32_t)); + for (const uint32_t count : {8192u, 8208u, n}) { + auto counter = buffer(&count, sizeof(count)); + auto desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA16Float + width:64 + height:64 + mipmapped:NO]; + desc.usage = MTLTextureUsageShaderWrite; + desc.storageMode = MTLStorageModePrivate; + auto target = [gpu.device newTextureWithDescriptor:desc]; + auto cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(raster.encode(cmd, uniforms, projected, indices, counter, n, target)); + auto pixel = [gpu.device newBufferWithLength:256 options:MTLResourceStorageModeShared]; + auto copy = [cmd blitCommandEncoder]; + [copy copyFromTexture:target + sourceSlice:0 + sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(1, 1, 1) + toBuffer:pixel + destinationOffset:0 + destinationBytesPerRow:256 + destinationBytesPerImage:256]; + [copy endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + printf("[ sparse tiles ] %u splats, 64x64, %.3f ms GPU\n", count, + (cmd.GPUEndTime - cmd.GPUStartTime) * 1000.0); + // Dense tiles must request complete hardware rendering (transparent sentinel), + // not enter a source-count-sized raster loop or silently truncate their list. + // Exactly 512 per tile fits; 513 must overflow even when the reservation was + // made by a whole SIMDGroup crossing the capacity boundary. + EXPECT_FLOAT_EQ(splat::fromHalf(static_cast(pixel.contents)[3]), + count == 8192 ? 1.0f : 0.0f); + } +} + +TEST(MetalTileRasterTest, AboveOneMillionCandidatesEmptyFrameAndPartialEdgeTiles) { + auto& gpu = test::Gpu::get(); + MetalTileRaster raster; + ASSERT_TRUE(raster.create(gpu.device, gpu.library)); + // Keep the raster work sparse while exercising a count above the former guard. + constexpr uint32_t n = 1000001; + CameraUniform camera{}; + camera.screenSize[0] = 17; + camera.screenSize[1] = 19; + std::vector splats(n); + std::vector order(n); + const uint32_t half = splat::toHalf(0.5f); + const uint32_t one = splat::toHalf(1.0f); + for (uint32_t i = 0; i < n; ++i) { + order[i] = i; + splats[i].center[0] = 1000; // no intersection with any tile + splats[i].axis1 = half; + splats[i].axis2 = half << 16; + splats[i].radius = 3; + splats[i].color1 = one << 16; + } + splats[0].center[0] = 0; + splats[0].color0 = one; // opaque red in the first block + splats[256].center[0] = 0; + splats[256].color1 |= one; // opaque blue in the next block, hidden at centre + auto buffer = [&](const void* data, size_t bytes) { + return [gpu.device newBufferWithBytes:data length:bytes options:MTLResourceStorageModeShared]; + }; + auto uniforms = buffer(&camera, sizeof(camera)); + auto projected = buffer(splats.data(), splats.size() * sizeof(ProjectedSplat)); + auto indices = buffer(order.data(), order.size() * sizeof(uint32_t)); + uint32_t count = n; + auto counter = buffer(&count, sizeof(count)); + auto desc = [MTLTextureDescriptor texture2DDescriptorWithPixelFormat:MTLPixelFormatRGBA16Float + width:17 + height:19 + mipmapped:NO]; + desc.usage = MTLTextureUsageShaderWrite; + desc.storageMode = MTLStorageModePrivate; + auto target = [gpu.device newTextureWithDescriptor:desc]; + auto pixels = [gpu.device newBufferWithLength:256 * 19 options:MTLResourceStorageModeShared]; + for (uint32_t activeCount : {n, 0u}) { + *static_cast(counter.contents) = activeCount; + auto cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(raster.encode(cmd, uniforms, projected, indices, counter, n, target)); + auto blit = [cmd blitCommandEncoder]; + [blit copyFromTexture:target + sourceSlice:0 + sourceLevel:0 + sourceOrigin:MTLOriginMake(0, 0, 0) + sourceSize:MTLSizeMake(17, 19, 1) + toBuffer:pixels + destinationOffset:0 + destinationBytesPerRow:256 + destinationBytesPerImage:256 * 19]; + [blit endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + const auto* image = static_cast(pixels.contents); + const size_t centre = 9 * 128 + 8 * 4; + EXPECT_NEAR(splat::fromHalf(image[centre]), activeCount ? 1.0f : 0.05f, 0.001f); + EXPECT_NEAR(splat::fromHalf(image[centre + 2]), activeCount ? 0.0f : 0.08f, 0.001f); + const size_t corner = 18 * 128 + 16 * 4; + EXPECT_NEAR(splat::fromHalf(image[corner]), 0.05f, 0.001f); + EXPECT_FLOAT_EQ(splat::fromHalf(image[corner + 3]), 1.0f); + } +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-ios/tests/MetalVisibilityTest.mm b/packages/splatkit-ios/tests/MetalVisibilityTest.mm new file mode 100644 index 0000000..55b888d --- /dev/null +++ b/packages/splatkit-ios/tests/MetalVisibilityTest.mm @@ -0,0 +1,422 @@ +#import + +#include + +#include +#include +#include +#include + +#include "MetalTestContext.h" +#include "rendering/MetalVisibility.h" +#include "splat/math/Half.h" +#include "splat/math/Mat4.h" +#include "splatkit/rendering/GpuLayout.h" + +namespace splatkit { +namespace { + +using test::Gpu; + +class MetalVisibilityTest : public ::testing::Test { + protected: + void SetUp() override { + ASSERT_NE(Gpu::get().device, nil); + ASSERT_NE(Gpu::get().library, nil); + ASSERT_TRUE(visibility.create(Gpu::get().device, Gpu::get().library)); + } + MetalVisibility visibility; +}; + +struct VisibilityInput { + id splats; + id uniforms; + explicit VisibilityInput(uint32_t count) { + auto& gpu = Gpu::get(); + GpuSplat splat{}; + splat.position[2] = -2; + splat.rgba8 = 0xffffffffu; + const uint32_t one = splat::toHalf(1.0f); + splat.cov[0] = one; + splat.cov[1] = one << 16; + splat.cov[2] = one << 16; + std::vector source(count, splat); + splats = [gpu.device newBufferWithBytes:source.data() + length:source.size() * sizeof(GpuSplat) + options:MTLResourceStorageModeShared]; + CameraUniform u{}; + u.view = splat::Mat4::identity(); + u.proj = splat::Mat4::perspective(1.0f, 1.0f, 0.1f, 100.0f); + u.focal[0] = u.focal[1] = 500; + u.tanHalfFov[0] = u.tanHalfFov[1] = 1; + u.screenSize[0] = u.screenSize[1] = 1000; + uniforms = [gpu.device newBufferWithBytes:&u + length:sizeof(u) + options:MTLResourceStorageModeShared]; + } +}; + +TEST_F(MetalVisibilityTest, FullSetAboveTwoMillionIsNotSampled) { + const uint32_t n = 2000003; + VisibilityInput input(n); + ASSERT_TRUE(visibility.reserve(n)); + const SplatRenderer::Range range{0, n}; + id cmd = [Gpu::get().queue commandBuffer]; + ASSERT_TRUE(visibility.encode(cmd, 0, input.uniforms, input.splats, nil, 0, &range, 1)); + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + ASSERT_EQ(visibility.count(0), n); + const auto* projected = static_cast(visibility.projected().contents); + std::vector seen(n, false); + for (uint32_t i = 0; i < n; ++i) { + ASSERT_LT(projected[i].index, n); + ASSERT_FALSE(seen[projected[i].index]); + seen[projected[i].index] = true; + } +} + +TEST_F(MetalVisibilityTest, EmptyFrameClearsPreviousIndirectDraws) { + VisibilityInput input(8); + ASSERT_TRUE(visibility.reserve(8)); + const SplatRenderer::Range range{0, 8}; + for (uint32_t step = 0; step < 2; ++step) { + id cmd = [Gpu::get().queue commandBuffer]; + ASSERT_TRUE(visibility.encode(cmd, 0, input.uniforms, input.splats, nil, 0, + step == 0 ? &range : nullptr, step == 0 ? 1 : 0)); + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + EXPECT_EQ(visibility.count(0), step == 0 ? 8u : 0u); + } + const auto* draws = + static_cast(visibility.drawArguments(0).contents); + for (uint32_t i = 0; i < MetalVisibility::kDrawBatches; ++i) { + EXPECT_EQ(draws[i].instanceCount, 0u); + } +} + +TEST_F(MetalVisibilityTest, RejectsRangesOutsideANewerSmallerWorld) { + VisibilityInput input(4); + ASSERT_TRUE(visibility.reserve(64)); + ASSERT_TRUE(visibility.reserve(4)); + const SplatRenderer::Range range{3, 2}; + id cmd = [Gpu::get().queue commandBuffer]; + EXPECT_FALSE(visibility.encode(cmd, 0, input.uniforms, input.splats, nil, 0, &range, 1)); + EXPECT_FALSE(visibility.encode(cmd, 0, input.uniforms, input.splats, nil, 0, nullptr, 1)); + EXPECT_FALSE(visibility.encode(cmd, MetalVisibility::kSlots, input.uniforms, input.splats, nil, 0, + nullptr, 0)); +} + +TEST_F(MetalVisibilityTest, CullsAndOrdersTheRangesFrontToBack) { + Gpu& gpu = Gpu::get(); + // Six splats: two ranges of three. The camera at the origin looks down -z. + // 0: 10 m ahead, 1: behind, 2: 2 m ahead, 3: far to the side (outside the view), + // 4: 5 m ahead, 5: never in a range. + const float positions[6][3] = {{0, 0, -10}, {0, 0, 5}, {0, 0, -2}, + {50, 0, -1}, {0, 0, -5}, {0, 0, -3}}; + std::vector splats(6); + const uint32_t one = splat::toHalf(1.0f); + for (int i = 0; i < 6; ++i) { + splats[i].position[0] = positions[i][0]; + splats[i].position[1] = positions[i][1]; + splats[i].position[2] = positions[i][2]; + splats[i].rgba8 = 0xffffffffu; + // A unit isotropic covariance gives these test splats a real rendered footprint; + // zero-filled records would now (correctly) be rejected as sub-pixel. + splats[i].cov[0] = one; + splats[i].cov[1] = one << 16; + splats[i].cov[2] = one << 16; + } + id splatBuffer = [gpu.device newBufferWithBytes:splats.data() + length:splats.size() * sizeof(GpuSplat) + options:MTLResourceStorageModeShared]; + CameraUniform u{}; + u.view = splat::Mat4::identity(); + u.proj = splat::Mat4::perspective(1.0f, 1.0f, 0.1f, 100.0f); + u.focal[0] = u.focal[1] = 500.0f; + u.tanHalfFov[0] = u.tanHalfFov[1] = 1.0f; + u.screenSize[0] = u.screenSize[1] = 1000.0f; + id uniforms = [gpu.device newBufferWithBytes:&u + length:sizeof(u) + options:MTLResourceStorageModeShared]; + ASSERT_TRUE(visibility.reserve(6)); + const SplatRenderer::Range ranges[2] = {{0, 3}, {3, 2}}; + + id cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(visibility.encode(cmd, 1, uniforms, splatBuffer, nil, 0, ranges, 2)); + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + + ASSERT_EQ(visibility.count(1), 3u); + // The order names projections; each projection remembers its slab index. + const auto* order = static_cast(visibility.order().contents); + const auto* projected = static_cast(visibility.projected().contents); + const uint32_t stride = sizeof(ProjectedSplat) / sizeof(uint32_t); + std::vector drawn; + for (int i = 0; i < 3; ++i) drawn.push_back(projected[order[i] * stride + stride - 1]); + EXPECT_EQ(drawn, (std::vector{2, 4, 0})); + // The batches partition the instances, in order. + const auto* draw = static_cast(visibility.drawArguments(1).contents); + uint32_t next = 0; + for (uint32_t b = 0; b < MetalVisibility::kDrawBatches; ++b) { + EXPECT_EQ(draw[b * 4], 4u); // vertices per instance + EXPECT_EQ(draw[b * 4 + 3], next); // base instance + next += draw[b * 4 + 1]; // instances + } + EXPECT_EQ(next, 3u); +} + +TEST_F(MetalVisibilityTest, DropsAProjectedSubpixelGaussianBeforeSorting) { + Gpu& gpu = Gpu::get(); + const uint32_t one = splat::toHalf(1.0f); + const uint32_t tiny = splat::toHalf(1.0e-8f); + GpuSplat splats[2]{}; + // The first splat is comfortably visible; the second is far below one pixel at + // this distance and must never reserve a visibility/radix-sort slot. + splats[0].position[2] = -2.0f; + splats[1].position[2] = -2.0f; + for (GpuSplat& s : splats) s.rgba8 = 0xffffffffu; + splats[0].cov[0] = one; + splats[0].cov[1] = one << 16; + splats[0].cov[2] = one << 16; + splats[1].cov[0] = tiny; + splats[1].cov[1] = tiny << 16; + splats[1].cov[2] = tiny << 16; + + id splatBuffer = [gpu.device newBufferWithBytes:splats + length:sizeof(splats) + options:MTLResourceStorageModeShared]; + CameraUniform u{}; + u.view = splat::Mat4::identity(); + u.proj = splat::Mat4::perspective(1.0f, 1.0f, 0.1f, 100.0f); + u.focal[0] = u.focal[1] = 500.0f; + u.tanHalfFov[0] = u.tanHalfFov[1] = 1.0f; + u.screenSize[0] = u.screenSize[1] = 1000.0f; + id uniforms = [gpu.device newBufferWithBytes:&u + length:sizeof(u) + options:MTLResourceStorageModeShared]; + ASSERT_TRUE(visibility.reserve(2)); + const SplatRenderer::Range range{0, 2}; + id cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(visibility.encode(cmd, 0, uniforms, splatBuffer, nil, 0, &range, 1)); + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + + EXPECT_EQ(visibility.count(0), 1u); +} + +// The experiment keeps all intermediate buffers private. Read them only through +// explicit blits, just as an offline diagnostic would, never through .contents. +TEST(MetalVisibilityExperimentTest, QuantizesCameraDepthAndSortsBothSourceAndLodIndices) { + Gpu& gpu = Gpu::get(); + constexpr uint32_t n = 8; + VisibilityInput input(n); + auto* camera = static_cast(input.uniforms.contents); + // Exactly representable planes: near=1, far=257. Also test camera translation. + camera->proj = splat::Mat4::perspective(1, 1, 1, 257); + camera->view.at(2, 3) = -10; + camera->cameraPosition[2] = 10; + const float depths[n] = {200, 2.002f, 2.001f, 128, 1.001f, 257, 0.5f, -1}; + auto* source = static_cast(input.splats.contents); + for (uint32_t i = 0; i < n; ++i) source[i].position[2] = 10 - depths[i]; + const uint32_t ids[n] = {0, 1, 2, 3, 4, 5, 6, 7}; + auto indices = [gpu.device newBufferWithBytes:ids + length:sizeof(ids) + options:MTLResourceStorageModeShared]; + auto count = [gpu.device newBufferWithBytes:&n + length:sizeof(n) + options:MTLResourceStorageModeShared]; + auto readback = [gpu.device newBufferWithLength:n * (8 + sizeof(ProjectedSplat)) + options:MTLResourceStorageModeShared]; + for (bool indexed : {false, true}) { + SCOPED_TRACE(indexed); + MetalVisibility visibility; + ASSERT_TRUE( + visibility.create(gpu.device, gpu.library, true, 0, MetalRadixSort::KeyBits::Low16)); + ASSERT_TRUE(visibility.reserve(n)); + const SplatRenderer::Range range{0, n}; + auto cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(visibility.encode(cmd, 0, input.uniforms, input.splats, nil, 0, + indexed ? nullptr : &range, indexed ? 0 : 1, + indexed ? indices : nil, indexed ? count : nil)); + auto blit = [cmd blitCommandEncoder]; + [blit copyFromBuffer:visibility.depthKeys() + sourceOffset:0 + toBuffer:readback + destinationOffset:0 + size:n * 4]; + [blit copyFromBuffer:visibility.order() + sourceOffset:0 + toBuffer:readback + destinationOffset:n * 4 + size:n * 4]; + [blit copyFromBuffer:visibility.projected() + sourceOffset:0 + toBuffer:readback + destinationOffset:n * 8 + size:n * sizeof(ProjectedSplat)]; + [blit endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + ASSERT_EQ(visibility.count(0), 6u); + const auto* keys = static_cast(readback.contents); + const auto* order = keys + n; + const auto* projected = reinterpret_cast(order + n); + // Same quantization bin keeps 1 before 2 within this single SIMD group, even + // though 2 is slightly nearer. Float32 ordering would reverse them. + const uint32_t expected[n - 2] = {4, 1, 2, 3, 0, 5}; + for (uint32_t i = 0; i < n - 2; ++i) { + ASSERT_LT(order[i], n); + EXPECT_EQ(projected[order[i]].index, expected[i]); + const auto key = static_cast((depths[expected[i]] - 1) / 256 * 65535); + EXPECT_EQ(keys[i], key); + EXPECT_LE(keys[i], 65535u); + } + EXPECT_EQ(keys[0], 0u); + EXPECT_EQ(keys[5], 65535u); + } +} + +TEST(MetalVisibilityExperimentTest, CompactsSimdTailsAndClearsReusedSlots) { + Gpu& gpu = Gpu::get(); + MetalVisibility visibility; + ASSERT_TRUE(visibility.create(gpu.device, gpu.library, true)); + constexpr uint32_t capacity = 513; + VisibilityInput input(capacity); + ASSERT_TRUE(visibility.reserve(capacity)); + EXPECT_EQ(visibility.order().storageMode, MTLStorageModePrivate); + EXPECT_EQ(visibility.projected().storageMode, MTLStorageModePrivate); + EXPECT_EQ(visibility.drawArguments(0).storageMode, MTLStorageModePrivate); + EXPECT_EQ(visibility.countBuffer(0).storageMode, MTLStorageModeShared); + auto* source = static_cast(input.splats.contents); + for (uint32_t i = 0; i < capacity; ++i) { + if (i % 3 == 0) source[i].position[2] = 2; // mixed live/dead lanes + } + id orderReadback = [gpu.device newBufferWithLength:capacity * sizeof(uint32_t) + options:MTLResourceStorageModeShared]; + id drawReadback = [gpu.device newBufferWithLength:visibility.drawArguments(0).length + options:MTLResourceStorageModeShared]; + for (uint32_t n : {1u, 31u, 32u, 33u, 255u, 256u, 257u, 513u, 0u}) { + const uint32_t slot = n % MetalVisibility::kSlots; + const SplatRenderer::Range range{0, n}; + id cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(visibility.encode(cmd, slot, input.uniforms, input.splats, nil, 0, + n == 0 ? nullptr : &range, n == 0 ? 0 : 1)); + id blit = [cmd blitCommandEncoder]; + [blit copyFromBuffer:visibility.order() + sourceOffset:0 + toBuffer:orderReadback + destinationOffset:0 + size:capacity * sizeof(uint32_t)]; + [blit copyFromBuffer:visibility.drawArguments(slot) + sourceOffset:0 + toBuffer:drawReadback + destinationOffset:0 + size:drawReadback.length]; + [blit endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + const uint32_t expected = n - (n + 2) / 3; + ASSERT_EQ(visibility.count(slot), expected); + const auto* order = static_cast(orderReadback.contents); + std::vector seen(n, false); + for (uint32_t i = 0; i < expected; ++i) { + ASSERT_LT(order[i], n); + EXPECT_NE(order[i] % 3, 0u); + EXPECT_FALSE(seen[order[i]]); + seen[order[i]] = true; + } + const auto* draws = + static_cast(drawReadback.contents); + uint32_t next = 0; + for (uint32_t b = 0; b < MetalVisibility::kDrawBatches; ++b) { + EXPECT_EQ(draws[b].vertexCount, 4u); + EXPECT_EQ(draws[b].vertexStart, 0u); + EXPECT_EQ(draws[b].baseInstance, next); + next += draws[b].instanceCount; + } + EXPECT_EQ(next, expected); + const auto& whole = draws[MetalVisibility::kDrawBatches]; + EXPECT_EQ(whole.vertexCount, 4u); + EXPECT_EQ(whole.instanceCount, expected); + EXPECT_EQ(whole.vertexStart, 0u); + EXPECT_EQ(whole.baseInstance, 0u); + } +} + +TEST(MetalVisibilityExperimentTest, KeepsEdgeFootprintsAndSortsByCameraDepth) { + Gpu& gpu = Gpu::get(); + MetalVisibility visibility; + ASSERT_TRUE(visibility.create(gpu.device, gpu.library, true)); + constexpr uint32_t n = 9; + VisibilityInput input(n); + auto* source = static_cast(input.splats.contents); + const float positions[n][3] = {{0, 0, -4}, {100, 0, -4}, {0, 0, 4}, {0, 0, -0.05f}, {0, 0, -120}, + {0, 0, -4}, {3, 0, -4}, {2, 0, -3}, {0, 0, -3.5f}}; + for (uint32_t i = 0; i < n; ++i) { + std::copy(std::begin(positions[i]), std::end(positions[i]), source[i].position); + } + // Source 5 is sub-pixel even though the raster's low-pass filter has a footprint. + source[5].cov[0] = source[5].cov[1] = source[5].cov[2] = 0; + // Source 6's centre is beyond the old 20% margin, but its large quad crosses the view. + ASSERT_TRUE(visibility.reserve(n)); + // Nonzero range offset verifies that output values refer to original source slots. + const SplatRenderer::Range range{1, n - 1}; + id readback = [gpu.device newBufferWithLength:n * sizeof(uint32_t) + options:MTLResourceStorageModeShared]; + id cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(visibility.encode(cmd, 0, input.uniforms, input.splats, nil, 0, &range, 1)); + id blit = [cmd blitCommandEncoder]; + [blit copyFromBuffer:visibility.order() + sourceOffset:0 + toBuffer:readback + destinationOffset:0 + size:n * sizeof(uint32_t)]; + [blit endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + ASSERT_EQ(visibility.count(0), 3u); + const auto* order = static_cast(readback.contents); + // Distance would put source 8 before source 7; camera depth must do the reverse. + EXPECT_EQ((std::vector(order, order + 3)), (std::vector{7, 8, 6})); +} + +TEST(MetalVisibilityExperimentTest, ConfigurableCutoffKeepsLargerFootprintsAndLodOpacity) { + auto& gpu = Gpu::get(); + VisibilityInput input(5); + auto* source = static_cast(input.splats.contents); + const float radii[] = {0.75f, 1.1f, 1.5f, 1.5f, 1.5f}; + for (uint32_t i = 0; i < 5; ++i) { + // focal=500, depth=2, support radius=3: sigmaWorld = radiusPx / 750. + const float sigma = radii[i] / 750.0f; + const uint32_t variance = splat::toHalf(sigma * sigma); + source[i].cov[0] = variance; + source[i].cov[1] = variance << 16; + source[i].cov[2] = variance << 16; + } + source[3].rgba8 = 0x00ffffff; // zero opacity must not survive + source[4].rgba8 = 0; // dark opaque LoD splats must not be mistaken for transparent + const float lodOpacity = 2; + std::memcpy(&source[4].lodAlpha, &lodOpacity, sizeof(lodOpacity)); + for (float cutoff : {0.5f, 1.0f, 1.2f}) { + MetalVisibility visibility; + ASSERT_TRUE(visibility.create(gpu.device, gpu.library, true, cutoff)); + ASSERT_TRUE(visibility.reserve(5)); + const SplatRenderer::Range range{0, 5}; + auto cmd = [gpu.queue commandBuffer]; + ASSERT_TRUE(visibility.encode(cmd, 0, input.uniforms, input.splats, nil, 0, &range, 1)); + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + EXPECT_EQ(visibility.count(0), cutoff < 1 ? 4u : (cutoff < 1.2f ? 3u : 2u)); + } +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-ios/tests/MetalWorldTest.mm b/packages/splatkit-ios/tests/MetalWorldTest.mm new file mode 100644 index 0000000..e837ae9 --- /dev/null +++ b/packages/splatkit-ios/tests/MetalWorldTest.mm @@ -0,0 +1,133 @@ +#include + +#include + +#include "MetalTestContext.h" +#include "rendering/MetalWorld.h" +#include "splatkit/rendering/GpuLayout.h" + +namespace splatkit { +namespace { + +splat::SplatCloud cloud() { + splat::SplatCloud c; + c.positions = {1, 2, -3, 4, 5, -6}; + c.covariances = {1, 0, 0, 1, 0, 1, 2, 0, 0, 2, 0, 2}; + c.colors = {1, 0, 0, 0, 1, 0}; + c.alphas = {1, 0.5f}; + return c; +} + +TEST(MetalWorldTest, PrivateUploadMatchesSharedCorePackingAndAcceptsCpuOrder) { + auto& gpu = test::Gpu::get(); + const auto source = cloud(); + auto world = MetalWorld::upload(gpu.device, gpu.queue, source, 3); + ASSERT_NE(world, nullptr); + EXPECT_EQ(world->info().count, 2u); + EXPECT_EQ(world->info().shDegree, 0); + EXPECT_EQ(world->splats().storageMode, MTLStorageModePrivate); + EXPECT_EQ(world->order(), nil); + const auto packed = packSplats(source); + const size_t bytes = packed.size() * sizeof(GpuSplat); + id readback = [gpu.device newBufferWithLength:bytes + options:MTLResourceStorageModeShared]; + id cmd = [gpu.queue commandBuffer]; + id copy = [cmd blitCommandEncoder]; + [copy copyFromBuffer:world->splats() + sourceOffset:0 + toBuffer:readback + destinationOffset:0 + size:bytes]; + [copy endEncoding]; + [cmd commit]; + [cmd waitUntilCompleted]; + ASSERT_EQ(cmd.status, MTLCommandBufferStatusCompleted); + EXPECT_EQ(std::memcmp(readback.contents, packed.data(), bytes), 0); + const uint32_t order[2] = {1, 0}; + ASSERT_TRUE(world->writeOrder(order, 2)); + id previous = world->order(); + EXPECT_EQ(std::memcmp(previous.contents, order, sizeof(order)), 0); + const uint32_t next[2] = {0, 1}; + ASSERT_TRUE(world->writeOrder(next, 2)); + EXPECT_NE(world->order(), previous); + EXPECT_EQ(std::memcmp(previous.contents, order, sizeof(order)), 0); + EXPECT_FALSE(world->uploadTile(0, source)); +} + +TEST(MetalWorldTest, TileUpdatesStayInTheirSlabRangeAndClearMissingHarmonics) { + auto& gpu = test::Gpu::get(); + auto world = MetalWorld::slab(gpu.device, 4, 1); + ASSERT_NE(world, nullptr); + const auto source = cloud(); + std::memset(world->splats().contents, 0, world->splats().length); + std::memset(world->harmonics().contents, 0xff, world->harmonics().length); + ASSERT_TRUE(world->uploadTile(1, source)); + const auto* records = static_cast(world->splats().contents); + EXPECT_FLOAT_EQ(records[0].position[0], 0); + EXPECT_FLOAT_EQ(records[1].position[0], 1); + EXPECT_FLOAT_EQ(records[2].position[0], 4); + EXPECT_FLOAT_EQ(records[3].position[0], 0); + const auto* sh = static_cast(world->harmonics().contents); + const size_t stride = shStride(1); + EXPECT_EQ(sh[0], 0xffffffffu); + for (size_t i = stride; i < stride * 3; ++i) EXPECT_EQ(sh[i], 0u); + EXPECT_EQ(sh[stride * 3], 0xffffffffu); + EXPECT_FALSE(world->uploadTile(3, source)); + EXPECT_FLOAT_EQ(records[3].position[0], 0); +} + +TEST(MetalWorldTest, EmptyUploadsAndInvalidRequestsHaveDefinedOutcomes) { + auto& gpu = test::Gpu::get(); + auto world = MetalWorld::upload(gpu.device, gpu.queue, {}, 0); + ASSERT_NE(world, nullptr); + EXPECT_EQ(world->info().count, 0u); + EXPECT_TRUE(world->writeOrder(nullptr, 0)); + EXPECT_FALSE(world->writeOrder(nullptr, 1)); + EXPECT_EQ(MetalWorld::slab(gpu.device, 0, 0), nullptr); + auto malformed = cloud(); + malformed.alphas.clear(); + EXPECT_EQ(MetalWorld::upload(gpu.device, gpu.queue, malformed, 0), nullptr); +} + +TEST(MetalWorldTest, ChunkedUploadPreservesSHAndRecordsAcrossStagingBoundary) { + auto& gpu = test::Gpu::get(); + splat::SplatCloud source; + source.shDegree = 1; + for (uint32_t i = 0; i < 65539; ++i) { + source.positions.insert(source.positions.end(), {static_cast(i), 0, -2}); + source.covariances.insert(source.covariances.end(), {0.01f, 0, 0, 0.02f, 0, 0.03f}); + source.colors.insert(source.colors.end(), {1, 0, 0}); + source.alphas.push_back(i == 65536 ? 2.0f : 0.5f); + for (int j = 0; j < 9; ++j) source.sh.push_back(static_cast((i + j) % 17) / 32); + } + auto world = MetalWorld::upload(gpu.device, gpu.queue, source, 1); + ASSERT_TRUE(world); + const auto packed = packSplats(source); + const auto harmonics = packSh(source, 1); + const size_t bytes = packed.size() * sizeof(GpuSplat); + const size_t shBytes = harmonics.size() * 4; + auto readback = [gpu.device newBufferWithLength:bytes + shBytes + options:MTLResourceStorageModeShared]; + auto command = [gpu.queue commandBuffer]; + auto blit = [command blitCommandEncoder]; + [blit copyFromBuffer:world->splats() + sourceOffset:0 + toBuffer:readback + destinationOffset:0 + size:bytes]; + [blit copyFromBuffer:world->harmonics() + sourceOffset:0 + toBuffer:readback + destinationOffset:bytes + size:shBytes]; + [blit endEncoding]; + [command commit]; + [command waitUntilCompleted]; + ASSERT_EQ(command.status, MTLCommandBufferStatusCompleted); + EXPECT_EQ(std::memcmp(readback.contents, packed.data(), bytes), 0); + EXPECT_EQ( + std::memcmp(static_cast(readback.contents) + bytes, harmonics.data(), shBytes), 0); +} + +} // namespace +} // namespace splatkit diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100755 index 0000000..e345dd3 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Builds the iOS static libraries (splat-core, splatkit-engine, splatkit-ios and their +# fetched dependencies) for a device, into build/ios. The dev app and the Swift package +# link them from there. +# +# scripts/build-ios.sh Release, arm64 device +# scripts/build-ios.sh Debug +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +config="${1:-Release}" +build="$root/build/ios" + +cmake -S "$root/packages/splatkit-ios" -B "$build" -G "Unix Makefiles" \ + -DCMAKE_BUILD_TYPE="$config" \ + -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_SYSROOT=iphoneos \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=17.0 \ + -DCMAKE_C_COMPILER_WORKS=ON -DCMAKE_CXX_COMPILER_WORKS=ON \ + > /dev/null +cmake --build "$build" --parallel + +# One folder of libraries for the linker, whatever subdirectory CMake put them in. +mkdir -p "$build/lib" +find "$build" -name '*.a' -not -path "$build/lib/*" -exec cp {} "$build/lib/" \; +ls "$build/lib" diff --git a/scripts/export-ios-source.py b/scripts/export-ios-source.py new file mode 100644 index 0000000..f4fc6a7 --- /dev/null +++ b/scripts/export-ios-source.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Export reviewed SDK paths into a new directory without touching the Git index.""" +import hashlib +import argparse +import json +from pathlib import Path +import re +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[1] +PACKAGES = ("packages/splat-core", "packages/splatkit-engine", "packages/splatkit-ios") +FILES = ( + "Package.swift", "LICENSE", "CONTEXT.md", ".clang-format", ".gitignore", + "scripts/package-ios.sh", "scripts/build-ios.sh", "scripts/sdk_harness.py", + "scripts/export-ios-source.py", "scripts/tests/test_sdk_harness.py", + ".github/workflows/ios.yml", ".github/workflows/engine.yml", ".github/workflows/core.yml", + "docs/BENCHMARKS.md", "docs/benchmarks", +) +FORBIDDEN = {".ply", ".spz", ".glb", ".lodsplat", ".a", ".so", ".dylib", ".pem", ".p12", ".key", ".keystore", ".mobileprovision"} +SECRET = re.compile(rb"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----|gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,}|AKIA[A-Z0-9]{16}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", choices=("ios", "android"), default="ios") + platform = parser.parse_args().platform + packages, files = PACKAGES, FILES + if platform == "android": + packages += ("packages/splatkit-android",) + files += ("README.md", "CONTRIBUTING.md", "AGENTS.md", ".clang-tidy", + "apps/android-dev", "scripts/lint-cpp.sh", "scripts/fetch-validation-layers.sh", + ".github/workflows/android.yml", ".github/workflows/lint.yml", + "docs/AGENT_HARNESS.md", "docs/adr/0019-offline-lod-files-and-native-gpu-selection.md", + "docs/adr/0020-interior-lod-traversal-and-explicit-quality-pressure.md", + "docs/adr/0021-mobile-backend-parity.md") + names = subprocess.check_output( + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *packages, *files], + cwd=ROOT).decode().split("\0") + selected = {} + for name in sorted(set(names) - {""}): + source = ROOT / name + if not source.exists(): # Deleted source shader, superseded by shaders/Splat.metal. + continue + if source.is_symlink() or not source.is_file() or source.suffix in FORBIDDEN: + raise ValueError(f"unexpected export file: {name}") + if source.stat().st_size > 2_000_000: + raise ValueError(f"oversized export file: {name}") + if SECRET.search(source.read_bytes()): + raise ValueError(f"secret-pattern match, review required: {name}") + selected[name] = source + if platform == "ios": + for name in ("README.md", "CONTRIBUTING.md", "AGENTS.md"): + selected[name] = ROOT / "packages/splatkit-ios/distribution" / name + destination = Path(tempfile.mkdtemp(prefix=f"splatkit-{platform}-public-")) + manifest = {} + for name, source in selected.items(): + target = destination / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + manifest[name] = hashlib.sha256(target.read_bytes()).hexdigest() + (destination / "source-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + print(json.dumps({"directory": str(destination), "files": len(manifest), + "bytes": sum(path.stat().st_size for path in selected.values())})) + + +if __name__ == "__main__": + main() diff --git a/scripts/lint-cpp.sh b/scripts/lint-cpp.sh index e37f10e..021ca80 100755 --- a/scripts/lint-cpp.sh +++ b/scripts/lint-cpp.sh @@ -33,12 +33,18 @@ clang_format="$bin/clang-format" clang_tidy="$bin/clang-tidy" core="$root/packages/splat-core" +engine="$root/packages/splatkit-engine" android="$root/packages/splatkit-android/src/main/cpp" +ios="$root/packages/splatkit-ios/Sources/SplatKitCore" build="$root/build/lint" +# The iOS sources are Objective-C++: clang-format handles them, the NDK's clang-tidy +# cannot parse them against an Apple SDK, so they are formatted here and built, with +# warnings as errors, by scripts/build-ios.sh. sources() { - find "$core/include" "$core/src" "$core/tests" "$core/tools" "$android" \ - -type f \( -name '*.cpp' -o -name '*.h' \) | sort + find "$core/include" "$core/src" "$core/tests" "$core/tools" \ + "$engine/include" "$engine/src" "$engine/tests" "$android" "$ios" \ + -type f \( -name '*.cpp' -o -name '*.h' -o -name '*.mm' \) | sort } echo "clang-format" @@ -59,8 +65,13 @@ echo "configure splat-core" cmake -S "$core" -B "$build/core" "${toolchain[@]}" \ -DSPLAT_CORE_BUILD_TESTS=ON -DSPLAT_CORE_BUILD_TOOLS=ON > /dev/null +echo "configure splatkit-engine" +cmake -S "$engine" -B "$build/engine" "${toolchain[@]}" \ + -DSPLATKIT_ENGINE_BUILD_TESTS=ON > /dev/null + echo "configure splatkit-android" -cmake -S "$android" -B "$build/android" "${toolchain[@]}" > /dev/null +cmake -S "$android" -B "$build/android" "${toolchain[@]}" \ + -DSPLATKIT_ANDROID_BUILD_TESTS=ON > /dev/null # The splat pipeline includes the generated shader headers. cmake --build "$build/android" --target splatkit_shaders_generate > /dev/null @@ -75,6 +86,8 @@ tidy() { # echo "clang-tidy splat-core" tidy "$build/core" $(find "$core/src" "$core/tests" "$core/tools" -name '*.cpp' | sort) +echo "clang-tidy splatkit-engine" +tidy "$build/engine" $(find "$engine/src" "$engine/tests" -name '*.cpp' | sort) echo "clang-tidy splatkit-android" tidy "$build/android" $(find "$android" -name '*.cpp' | sort) # clang-tidy's fixes do not keep the formatting. diff --git a/scripts/package-ios.sh b/scripts/package-ios.sh new file mode 100644 index 0000000..3f62e3b --- /dev/null +++ b/scripts/package-ios.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Build a self-contained Objective-C++ XCFramework; Swift sources remain open. +set -euo pipefail +splat_root="$(cd "$(dirname "$0")/.." && pwd)" +splat_jobs="${SPLATKIT_BUILD_JOBS:-2}" +mkdir -p "$splat_root/build/ios-distribution" +splat_output="$(mktemp -d "$splat_root/build/ios-distribution/package.XXXXXX")" + +for splat_sdk in iphoneos iphonesimulator; do + splat_archs=arm64 + if [[ "$splat_sdk" == iphonesimulator ]]; then splat_archs='arm64;x86_64'; fi + splat_build="$splat_root/build/ios-distribution/$splat_sdk" + cmake -S "$splat_root/packages/splatkit-ios" -B "$splat_build" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_SYSTEM_NAME=iOS \ + -DCMAKE_OSX_SYSROOT="$splat_sdk" -DCMAKE_OSX_ARCHITECTURES="$splat_archs" \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=17.0 \ + -DCMAKE_C_COMPILER_WORKS=ON -DCMAKE_CXX_COMPILER_WORKS=ON + cmake --build "$splat_build" --parallel "$splat_jobs" + mkdir -p "$splat_output/$splat_sdk" + xcrun libtool -static -o "$splat_output/$splat_sdk/libSplatKitCore.a" \ + "$splat_build/libsplatkit_ios.a" \ + "$splat_build/splatkit-engine/libsplatkit_engine.a" \ + "$splat_build/splatkit-engine/splat-core/libsplat_core.a" \ + "$splat_build/_deps/spz-build/libspz.a" \ + "$splat_build/_deps/zstd-build/lib/libzstd.a" +done + +splat_headers="$splat_root/packages/splatkit-ios/Sources/SplatKitCore/include" +xcodebuild -create-xcframework \ + -library "$splat_output/iphoneos/libSplatKitCore.a" -headers "$splat_headers" \ + -library "$splat_output/iphonesimulator/libSplatKitCore.a" -headers "$splat_headers" \ + -output "$splat_output/SplatKitCore.xcframework" + +# Redistributed dependency licenses accompany the archive, not just the source repo. +mkdir -p "$splat_output/SplatKitCore.xcframework/Notices" +cp "$splat_root/LICENSE" "$splat_output/SplatKitCore.xcframework/Notices/SplatKit.txt" +cp "$splat_build/_deps/spz-src/LICENSE" "$splat_output/SplatKitCore.xcframework/Notices/SPZ.txt" +cp "$splat_build/_deps/nlohmann_json-src/LICENSE.MIT" "$splat_output/SplatKitCore.xcframework/Notices/JSON.txt" +cp "$splat_build/_deps/zstd-src/LICENSE" "$splat_output/SplatKitCore.xcframework/Notices/Zstandard.txt" +ditto -c -k --keepParent "$splat_output/SplatKitCore.xcframework" "$splat_output/SplatKitCore.xcframework.zip" +swift package compute-checksum "$splat_output/SplatKitCore.xcframework.zip" +printf 'Artifact: %s\n' "$splat_output/SplatKitCore.xcframework.zip" diff --git a/scripts/sdk_harness.py b/scripts/sdk_harness.py new file mode 100644 index 0000000..70a4383 --- /dev/null +++ b/scripts/sdk_harness.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Local SDK checks; JSON stdout, logs on disk, no device control.""" + +import argparse +import json +import os +from pathlib import Path +import platform +import re +import signal +import subprocess +import sys +import tempfile +import xml.etree.ElementTree as ET + +ROOT = Path(__file__).resolve().parents[1] +BUILD = ROOT / "build/sdk-harness" + + +def positive(value): + number = int(value) + if number <= 0: + raise argparse.ArgumentTypeError("must be positive") + return number + + +def plan(target, jobs): + if target == "android": + return [{"cwd": str(ROOT / "apps/android-dev"), "argv": [ + "./gradlew", "--console=plain", ":splatkit:assembleDebug", ":app:assembleDebug"]}] + package = "splatkit-ios" if target == "metal" else "splatkit-engine" + build = str(BUILD / target) + commands = [ + ["cmake", "-S", str(ROOT / "packages" / package), "-B", build, + "-DCMAKE_BUILD_TYPE=Release"], + ["cmake", "--build", build, "--parallel", str(jobs)], + ["ctest", "--test-dir", build, "--output-on-failure", "--no-tests=error"], + ] + return [{"cwd": str(ROOT), "argv": command} for command in commands] + + +def run_step(step, logfile, timeout): + result = {**step, "log": str(logfile)} + try: + with logfile.open("w") as stream: + process = subprocess.Popen(step["argv"], cwd=step["cwd"], stdout=stream, + stderr=subprocess.STDOUT, start_new_session=True) + try: + result["exit_code"] = process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait() + result.update(exit_code=124, error="timeout") + except OSError as error: + result.update(exit_code=127, error=str(error)) + result["status"] = "passed" if result["exit_code"] == 0 else "failed" + return result + + +def test_counts(path): + cases = list(ET.parse(path).getroot().iter("testcase")) + skipped = sum(case.find("skipped") is not None or case.get("status") == "notrun" + for case in cases) + failed = sum(case.find("failure") is not None or case.find("error") is not None + for case in cases) + return {"total": len(cases), "passed": len(cases) - skipped - failed, + "failed": failed, "skipped": skipped} + + +def check(target, jobs, timeout): + report = {"schema_version": 1, "target": target, "status": "failed", "steps": [], + "host": platform.system(), "phone_performance_validated": False} + if target == "metal" and platform.system() != "Darwin": + return {**report, "status": "blocked", "error": "Metal checks require macOS"} + BUILD.mkdir(parents=True, exist_ok=True) + run = Path(tempfile.mkdtemp(prefix=target + "-", dir=BUILD)) + commands = plan(target, jobs) + junit = run / "tests.xml" + if target != "android": + commands[-1]["argv"] += ["--output-junit", str(junit)] + for index, step in enumerate(commands): + result = run_step(step, run / f"{index + 1}.log", timeout) + report["steps"].append(result) + if result["status"] != "passed": + break + else: + report["status"] = "passed" + if target == "android": + report["scope"] = "AAR/APK packaging only; no rendering or compute execution" + report["artifacts"] = [str(ROOT / relative) for relative in ( + "packages/splatkit-android/build/outputs/aar/splatkit-debug.aar", + "apps/android-dev/app/build/outputs/apk/debug/app-debug.apk")] + if report["status"] == "passed" and not all(Path(p).is_file() for p in report["artifacts"]): + report.update(status="failed", error="build returned success without artifacts") + else: + report["scope"] = "macOS Metal + shared tests" if target == "metal" else "shared C++ tests" + try: + report["tests"] = test_counts(junit) + if report["tests"]["passed"] == 0 or report["tests"]["failed"]: + report["status"] = "failed" + except (OSError, ET.ParseError) as error: + report.update(status="failed", error=f"test evidence unavailable: {error}") + report["report"] = str(run / "report.json") + Path(report["report"]).write_text(json.dumps(report, indent=2) + "\n") + return report + + +def android_log(contents, pid, expected, environment): + # Standard `adb logcat -v threadtime`: ignore other apps and previous process IDs. + line_pattern = re.compile(r"^\d\d-\d\d\s+[\d:.]+\s+(\d+)\s+\d+\s+[A-Z]\s+.*?:\s?(.*)$") + messages = [match[2] for line in contents.splitlines() + if (match := line_pattern.match(line)) and int(match[1]) == pid] + text = "\n".join(messages) + errors = [line for line in messages if re.search( + r"Fatal signal|FATAL EXCEPTION|VUID-|Validation Error|World failed:|VK_ERROR_", line)] + device = re.search(r"Vulkan device: (.+)", text) + draws = re.findall(r"(\d+) drawn of (\d+) selected of (\d+)", text) + valid_draw = any(0 < int(drawn) <= int(selected) <= int(loaded) == expected + for drawn, selected, loaded in draws) + checks = {"vulkan_initialized": device is not None, + "world_ready": f"world ready: {expected} splats" in messages, + "nonempty_draw": valid_draw, "no_logged_errors": not errors} + return {"schema_version": 1, "target": "android-log", "pid": pid, + "environment": environment, "vulkan": device[1] if device else None, + "status": "passed" if all(checks.values()) else "failed", "checks": checks, + "errors": errors, "phone_performance_validated": False, + "scope": "captured dev-app load/draw only; no visual or inactive-compute validation"} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + for name in ("plan", "check"): + sub = commands.add_parser(name) + sub.add_argument("target", choices=("engine", "metal", "android")) + sub.add_argument("--jobs", type=positive, default=2) + if name == "check": + sub.add_argument("--timeout", type=positive, default=900, help="seconds per step") + logs = commands.add_parser("android-log", help="validate an already captured threadtime log") + logs.add_argument("log", type=Path) + logs.add_argument("--pid", type=positive, required=True) + logs.add_argument("--expected-splats", type=positive, required=True) + logs.add_argument("--environment", choices=("emulator", "physical"), required=True) + args = parser.parse_args(argv) + try: + if args.command == "plan": + report = {"schema_version": 1, "status": "not_run", "steps": plan(args.target, args.jobs)} + elif args.command == "check": + report = check(args.target, args.jobs, args.timeout) + else: + report = android_log(args.log.read_text(), args.pid, args.expected_splats, args.environment) + report["log"] = str(args.log.resolve()) + except OSError as error: + report = {"schema_version": 1, "status": "blocked", "error": str(error)} + print(json.dumps(report, indent=2)) + return 0 if report["status"] in ("passed", "not_run") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_sdk_harness.py b/scripts/tests/test_sdk_harness.py new file mode 100644 index 0000000..d86aa5a --- /dev/null +++ b/scripts/tests/test_sdk_harness.py @@ -0,0 +1,95 @@ +import importlib.util +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +SCRIPT = Path(__file__).resolve().parents[1] / "sdk_harness.py" +spec = importlib.util.spec_from_file_location("sdk_harness", SCRIPT) +harness = importlib.util.module_from_spec(spec) +spec.loader.exec_module(harness) + + +def log(message, pid=42): + return f"09-12 06:53:50.703 {pid} 100 I SplatKit: {message}\n" + + +class HarnessTest(unittest.TestCase): + def ready(self): + return (log("Vulkan device: SwiftShader Device (LLVM 10.0.0), API 1.3.0") + + log("world ready: 500000 splats") + + log("123 drawn of 500000 selected of 500000")) + + def verdict(self, text): + return harness.android_log(text, 42, 500000, "emulator") + + def test_complete_log_is_smoke_not_benchmark(self): + result = self.verdict(self.ready()) + self.assertEqual(result["status"], "passed") + self.assertFalse(result["phone_performance_validated"]) + self.assertEqual(result["environment"], "emulator") + + def test_crash_fails_even_after_ready(self): + result = self.verdict(self.ready() + log("Fatal signal 11 (SIGSEGV)")) + self.assertEqual(result["status"], "failed") + self.assertFalse(result["checks"]["no_logged_errors"]) + + def test_other_process_cannot_supply_success(self): + self.assertEqual(self.verdict(self.ready().replace(" 42 ", " 99 "))["status"], "failed") + + def test_other_process_crash_is_ignored(self): + self.assertEqual(self.verdict(self.ready() + log("Fatal signal 11", 99))["status"], "passed") + + def test_empty_wrong_world_and_incomplete_logs_fail(self): + for text in ("", log("world ready: 500000 splats"), + self.ready().replace("500000", "400000"), + self.ready().replace("123 drawn", "0 drawn")): + with self.subTest(text=text): + self.assertEqual(self.verdict(text)["status"], "failed") + + def test_validation_error_fails(self): + self.assertEqual(self.verdict(self.ready() + log("VUID-vkCmdDraw-None-02700"))["status"], "failed") + + def test_plan_never_installs_or_launches(self): + steps = harness.plan("android", 2) + self.assertEqual(steps[0]["argv"], ["./gradlew", "--console=plain", + ":splatkit:assembleDebug", ":app:assembleDebug"]) + self.assertIn("--no-tests=error", harness.plan("metal", 2)[-1]["argv"]) + + def test_exit_code_and_timeout_are_failures(self): + with tempfile.TemporaryDirectory() as folder: + for code, timeout, expected in (("raise SystemExit(3)", 5, 3), + ("import time; time.sleep(10)", 1, 124)): + step = {"cwd": folder, "argv": [sys.executable, "-c", code]} + result = harness.run_step(step, Path(folder) / "step.log", timeout) + self.assertEqual(result["status"], "failed") + self.assertEqual(result["exit_code"], expected) + + def test_missing_tool_fails(self): + with tempfile.TemporaryDirectory() as folder: + step = {"cwd": folder, "argv": [str(Path(folder) / "missing-tool")]} + self.assertEqual(harness.run_step(step, Path(folder) / "step.log", 1)["exit_code"], 127) + + def test_skips_are_reported_separately(self): + with tempfile.TemporaryDirectory() as folder: + junit = Path(folder) / "tests.xml" + junit.write_text('' + '') + self.assertEqual(harness.test_counts(junit), {"total": 3, "passed": 1, "failed": 1, "skipped": 1}) + + def test_cli_plan_is_json_and_missing_log_is_nonzero(self): + import json + result = subprocess.run([sys.executable, str(SCRIPT), "plan", "engine"], capture_output=True, text=True) + self.assertEqual(json.loads(result.stdout)["status"], "not_run") + self.assertEqual(result.returncode, 0) + with tempfile.TemporaryDirectory() as folder: + result = subprocess.run([sys.executable, str(SCRIPT), "android-log", folder + "/absent", + "--pid", "42", "--expected-splats", "500000", + "--environment", "emulator"], capture_output=True, text=True) + self.assertEqual(json.loads(result.stdout)["status"], "blocked") + self.assertEqual(result.returncode, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/source-manifest.json b/source-manifest.json new file mode 100644 index 0000000..d699aed --- /dev/null +++ b/source-manifest.json @@ -0,0 +1,275 @@ +{ + ".clang-format": "fc7883e10e83d18c0d4a8111e1e23a0d07c0479eb32eedaf2de3761f4ae35636", + ".clang-tidy": "b93f3362c159b0489d60e03943e1d96bb71e81eec633c29ee5adc610a76934a0", + ".github/workflows/android.yml": "0e7100b9e557b239e184ee507f907c320a4767f42a16598037ef7ec4d27bf185", + ".github/workflows/core.yml": "913c3a20ed8c756630ad4b44a39cd3e7ca2e3ae8539fd491fd7e4b944c744480", + ".github/workflows/engine.yml": "e067f16070bb38ddd8fafbc4ce587a108d7160197c8dd8d50b3b9c2278599902", + ".github/workflows/ios.yml": "fbecedd3a7f827033649440ce0959e6332e696b9ce92ea56ec5919216ec6fe08", + ".github/workflows/lint.yml": "9cc4f030b94c10087dbab06abbf401d51fe94720dd4ac7c417dcacaf59cf331f", + ".gitignore": "02cc620153bd9225b9e60e8aacddf7f0af65393c9c5874d09c5c6fc09d3d905f", + "AGENTS.md": "58a9be431d54e18a39c68107feda19a0a76389bad7cc214d39446135f3a650ab", + "CONTEXT.md": "96bdf621bd324e0ca9bf04a1cdba772a22fbda80eadcd7069857027fb2eac4ec", + "CONTRIBUTING.md": "28065af02f1e68950c7209fc22b279d96cbf0c5f966def77c820165f037bb8d9", + "LICENSE": "0d53b878fa23d675c2e62010d4e4fb417ba6eff00b609d47dc72ea5cf14d7aa7", + "Package.swift": "fd6138340e77c491d0e7a003de3777ac0a36c69529fcbb4d06dcf2b39bd83ff3", + "README.md": "6032f9f37775dbb55043b26a84b4d4b5924a728ee1ca0041990f4f03098e1d4b", + "apps/android-dev/app/build.gradle.kts": "c7dbc6f7b1f42786a7e77e6d4e27b454e16eb120deb1103ed84f7afb92b89a09", + "apps/android-dev/app/src/main/AndroidManifest.xml": "9993edb09b62c4babb9a05ab512573f6919f00c0979b2d5f4cafb4b0c69fb61e", + "apps/android-dev/app/src/main/java/com/splatkit/devapp/MainActivity.kt": "96ee3fada48cdba4cbf21b6a4344f7965d2314a7b6d2bc81360a73c6fc0f4d1a", + "apps/android-dev/build.gradle.kts": "e202250c626e1e4ee977bc55b3a170a2a7b8592602dd462d81cb86d87d8c84a6", + "apps/android-dev/gradle.properties": "691bb11f902f995318dad9a05d1bdf16a85afca0469f700748cd5e43a461055b", + "apps/android-dev/gradle/wrapper/gradle-wrapper.jar": "81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f", + "apps/android-dev/gradle/wrapper/gradle-wrapper.properties": "cdab1ffb439219b1db125e3c99a86cf2424f8762b9b3faa404ed061e3635ecde", + "apps/android-dev/gradlew": "734b3879d3501dce471cf0522d3bcbafe76873d9fc5129345b67fb43bd15e933", + "apps/android-dev/gradlew.bat": "57931b17dd228e5c24dac90e815d0bf82477e831a4618dfab4136f5446b42a9f", + "apps/android-dev/settings.gradle.kts": "32ecd2c7a8bec95f946750420484a5844252e01cd1b42a6431beb79838cabe3d", + "docs/AGENT_HARNESS.md": "bc03d1669a3c6385bb314a03b386ccb2248040dca34957213a81980642c49edd", + "docs/BENCHMARKS.md": "ec1af356caad424699910ef6b202ba9d2c194a8ffe68be2844adfddb68525fb5", + "docs/adr/0019-offline-lod-files-and-native-gpu-selection.md": "af22db6dafc313227bc0d9aae0a6b79d9704e86aa5379b8e0b28e4fb448b9882", + "docs/adr/0020-interior-lod-traversal-and-explicit-quality-pressure.md": "1ed53bdb2c888f4618acf34607a7c3d54b450c3172794629c8f6c8ba07776bb9", + "docs/adr/0021-mobile-backend-parity.md": "760bb7bd58e15558d28f585c4ac2e738980a2a7f18bef023d166a0771763f772", + "docs/benchmarks/2026-09-11-iss-metal-512.csv": "10b3975ed50764bf588f3458b5e2eb9df111b8391586d62a9e1ae71c7fd993bf", + "docs/benchmarks/2026-09-11-iss-metal-512.json": "d693422e808b42c7b69e67fc6c46f5ed16e698a681ac4223e919db912e1267f9", + "docs/benchmarks/2026-09-12-iss-horizontal-smoke.json": "010f032872a12c39a09918c1dc4d24877795a914be507274a76528f069a4ae54", + "docs/benchmarks/2026-09-12-iss-lod-hybrid.csv": "afdb05dcf7ca449be79b8c6bf1b14c50300ed9d7d4670b942f426c1d7122df55", + "docs/benchmarks/2026-09-12-iss-lod-hybrid.json": "2f885d65917c1c612ad763025a96e25a6bb8bcf0e2af419b3f4853f10b1dca89", + "docs/benchmarks/2026-09-12-iss-metal-lod-guarded-resources.csv": "d89c3f71250169bde5006b61f10c14bfac97f827b37dce7b2279ced7eadbbe0e", + "docs/benchmarks/2026-09-12-iss-metal-lod-guarded.csv": "3b2866950487da6d117f497d81b2217a2c286792d5f54d594459f09eb0f710fa", + "docs/benchmarks/2026-09-12-iss-metal-lod-guarded.json": "55cf8455a5d20c3b56a2b450916805478270a3adb9a7f5f0be99d1ffbcb9bd82", + "docs/benchmarks/2026-09-12-iss-metal-lod-phone.csv": "cd57525fd344fbb2f02aa619f0d390b94a11cd1ba2956cddafcdd1e5bac8a53e", + "docs/benchmarks/2026-09-12-iss-metal-lod-phone.json": "a7625c364a065ba98bd256888669cfba953cad7803035c82237a2c6b4d15ded9", + "docs/benchmarks/2026-09-12-iss-metal-lod-sse.json": "9ea7b1adb6dbc2f3f879a6d1841d0ebe5208ef60d9c7e66c1baa0b1f5d86ccbb", + "docs/benchmarks/2026-09-12-iss-metal-lod.json": "9d2732ad3633366fc0b2f7219d69eec74b6b2d954d4700ffb0126e7c64d9b2d6", + "docs/benchmarks/2026-09-12-iss-prepared-start.json": "c6af32a03b2e45c9a43aadb0a2ba3c5b13f76a85eac5cc62f69aede26b4d2b9a", + "docs/benchmarks/2026-09-12-iss-radix16-resources.csv": "da4c2556ea6d2fab5a83c92e46e1069feeb568789359a9d5861c04621659db5b", + "docs/benchmarks/2026-09-12-iss-radix16.csv": "e98beca657243aad24b3852d51e96af76fbb999eca3de4db45abe2c83ee8d867", + "docs/benchmarks/2026-09-12-iss-radix16.json": "6cfa4583f9d6118c9ea22a336f65626de7742a67ecd3a20f57721a7f687dc726", + "packages/splat-core/CMakeLists.txt": "3f5d91bab34ca7fdf8b9b2ff86c4e1be63e200212049483d8c4651f0e742c7e3", + "packages/splat-core/README.md": "c87e6cd66d0e8a9bd309004890e33d3875079db72c211e0546df536a14e05dcc", + "packages/splat-core/cmake/dependencies.cmake": "27bad5a3b80b1e1fc96e8c3e5eac21cf0781bdeedc954f8655fb0d1ad1bf7d67", + "packages/splat-core/include/splat/core/CoordinateFrame.h": "991549225f319cf819ecd56493f6c4b9c44d90fc2bab417d2c19cc93bbe7cb6e", + "packages/splat-core/include/splat/core/Result.h": "84df14075ea63568cbd515f180588e0dff347dc6fd8943b98153d17f852a4749", + "packages/splat-core/include/splat/diagnostics/TimingSummary.h": "0444589120ec1cc1bf787dd572e18646b443d1db686daaa5f17a1b9ab3472834", + "packages/splat-core/include/splat/formats/GlbDecoder.h": "e4498f4f032e64d58c6ef3c899b30a34c28eaef7db4a9e6a31215ed3c0e5353d", + "packages/splat-core/include/splat/formats/SplatCloud.h": "8c5c2861a7ff29a33324f5108b2c45d1c589427075a426519b3d459d9ae9deb8", + "packages/splat-core/include/splat/formats/SplatDecoder.h": "bb6e2c30f02ae74cd8866c69ccde713e65b8ae2f4a0ae722ae1f2da3621bfee6", + "packages/splat-core/include/splat/formats/SpzDecoder.h": "d6e99e6d9c1b9c0dfa43be45ad83b4256e2e071d363f900b26e3e7525940804c", + "packages/splat-core/include/splat/formats/TriangleMesh.h": "01fd2be12bd0f1b830c98c2c9747dea41c8c6e05a42700fe9b6500cfd4e09379", + "packages/splat-core/include/splat/io/MappedFile.h": "9b5d5f053bd62b9f390a6c6ffd5d9344854d3dcd3464bcef890a548df19a48e1", + "packages/splat-core/include/splat/loading/SplatWorldLoader.h": "f764f5e61aa854ff880b74529c503727eedbb8232cb368bba30182f3de5855b8", + "packages/splat-core/include/splat/lod/LodFile.h": "9012d5477cf29802dd1ed3955551de5fcc679daaed85e2f67ae3703e7b9e5516", + "packages/splat-core/include/splat/lod/LodTree.h": "976118a21dc32c87d012d2f7d2d92dc9432bb1b95d7504565187502f17dbbcd4", + "packages/splat-core/include/splat/math/Frustum.h": "b03b662bf4f1368fc9148192955729612e2ad11a6d8ab36a963f3e54402854f8", + "packages/splat-core/include/splat/math/Half.h": "1f2d80b3b15fb0a542a63e2e5e06ae4f69202322f1a31fa77e6fe73371e92e34", + "packages/splat-core/include/splat/math/Mat4.h": "5045146a5c282b3fbbae8043bd018e97010429d19342497874f5f7a739ba55f1", + "packages/splat-core/include/splat/math/SymmetricEigen.h": "f46cdaaaf4426f61533c7c63217e70d2ebf8af70b1db940e68c60074dae33394", + "packages/splat-core/include/splat/math/Vec3.h": "5b43e21fa41f266af152ebdc4504d21fd43a5cb0e03e25c085e47fca9d2e7df6", + "packages/splat-core/include/splat/navigation/CharacterController.h": "5041c08c47f9f7d5c830799e164e52ab77bb94edd6c2b92fcb2979159169c74e", + "packages/splat-core/include/splat/navigation/Collider.h": "16ab19b89eeb58e1ba95c9a137133123fee456358ca01bb633b8d81d243503fc", + "packages/splat-core/include/splat/sorting/AsyncSorter.h": "d9d94f8d346d8a68f055c7bd72ec6057e5a6cceca97c8b6d500d262483e2692a", + "packages/splat-core/include/splat/sorting/DistanceSorter.h": "395cf191bf2de9c341f983c64eb22b13027188a74fce67186514e52cb8df79fa", + "packages/splat-core/include/splat/sorting/SlabSorter.h": "839947195ed0ddd421d999b6033d4528b8d0797bfb61d222dd649225471e023b", + "packages/splat-core/include/splat/sorting/SpatialOrder.h": "6149db95b849e395e06efbe8814fc88cc0afe4bbb915aa76cc386bfba7039fb3", + "packages/splat-core/include/splat/sorting/VisibilityPlanner.h": "d2dd117c67a2089ea90f808760d3389373e09771259a3450a52675257399ee4a", + "packages/splat-core/include/splat/sorting/WorkerPool.h": "f8c99506ff2fcccdf8213c4fea0f52572428a9ffc1b608fa9bc101b1bad0ce7d", + "packages/splat-core/include/splat/tiles/SlabAllocator.h": "4c02a130f8f348b7da7f18233487f37a6070e183d07ceb4b621787ca046eaf7e", + "packages/splat-core/include/splat/tiles/TileBuilder.h": "53fa9a7c9c0648fd3e8ec95eb8841cac4d9284a94cb4d020d37b7291602de118", + "packages/splat-core/include/splat/tiles/TileLoader.h": "9f51b954a6a11bd3ad24ebad0fc0a7c2e465b1d90a7c9e15fe058a4f25d5f186", + "packages/splat-core/include/splat/tiles/TileScheduler.h": "7b741a5715463df1dfbd902576a520c156b97dc5ff03948a3390b03375e2184c", + "packages/splat-core/include/splat/tiles/TileStreamer.h": "c57eb0cbce84493317149d881c189907fb7a4f991be585b1697f17458c00e5f4", + "packages/splat-core/include/splat/tiles/TiledWorld.h": "73b43bc4e3069736f017c4a4ffd9aa9c714f93166a3f6b846f07b43699fd02f8", + "packages/splat-core/include/splat/tiles/Tileset.h": "a4f2e11038152a2579e5b899c298342de2e3610806d5e89921f82828fd6d026f", + "packages/splat-core/src/diagnostics/TimingSummary.cpp": "1c79099936f449b7a6f2d9cc8a39e66e0538b5b863e60e25d76a4f6abf05405b", + "packages/splat-core/src/formats/GlbDecoder.cpp": "a6383c5c2c18309756de9578c540adf4fad93284b40a36069900c8073def0fbd", + "packages/splat-core/src/formats/SplatDecoder.cpp": "d2eae467893f43c9f99a8e88cbf71db3e64b61d2b75438d3d0f62414e7f7683a", + "packages/splat-core/src/formats/SpzDecoder.cpp": "a8e3e234ddef403eb9a4a18622951250085d5b0b0aeab09e397a0b86f4f0feac", + "packages/splat-core/src/io/MappedFile.cpp": "3f37512a2367f0273f11387cdd239d6628f57395220a978f6cd3a5a6ef3add7f", + "packages/splat-core/src/loading/SplatWorldLoader.cpp": "5fc3b4f656b6002de7c7d96909bf984675d26958115c7918fb64e4696ec37d70", + "packages/splat-core/src/lod/LodFile.cpp": "43c300a5f418835166b38aa7df02814636be1c1790263481dc0ede0d1c370171", + "packages/splat-core/src/lod/LodSelection.cpp": "0dd492df1c9d229e7de0335773e5f5d1c23fb15dd74750fb7204d8a64e542a54", + "packages/splat-core/src/lod/LodTree.cpp": "4b9bd8a6c8d2bd93484a71216c796c7cd176a4ea88f321f7212a76b209b77d9a", + "packages/splat-core/src/math/SymmetricEigen.cpp": "bc288e0539cb9c542d65b36f81fecb6975d1a090facf9578b2ba8d231259573b", + "packages/splat-core/src/navigation/CharacterController.cpp": "b2861ad8865f6e387e21ecd99acbfc6d47b67f288dfdb19cc5e1145a9fcd05d1", + "packages/splat-core/src/navigation/Collider.cpp": "d903df18f633f0fecb67830543b6464cd15312af8d0d9ab97d086a994c931fb7", + "packages/splat-core/src/sorting/AsyncSorter.cpp": "58957933c1b0bde5d59964362214bf6ffb61c413b937d11f70f5420bd119f9fa", + "packages/splat-core/src/sorting/DistanceSorter.cpp": "2aa9af8adaaff77927fb0ae321da21fe41ae1c3907ad1cd3a80ba0b63518049e", + "packages/splat-core/src/sorting/SlabSorter.cpp": "3581366f9d53dcf9e30215c1b19a2151da41b4728b162565ad14129d3d243363", + "packages/splat-core/src/sorting/SpatialOrder.cpp": "8337f65c82abb15797e3a36388aa31e93733c2967f99d206ff395b64ef52550c", + "packages/splat-core/src/sorting/VisibilityPlanner.cpp": "9053b0b2e19fe8da8bd0812ca0fe6e7268deb0b5e932765fe72ec6f7cc32c61e", + "packages/splat-core/src/sorting/WorkerPool.cpp": "b7bd5459037139d573b92f66efa419549f7de0210955a320dc8b4dd86a24d7b0", + "packages/splat-core/src/tiles/SlabAllocator.cpp": "e8d2a3dcda7c0c9f1150b97d7ef76cd8c9e422ee9d297b8bfc1bf6a771cafe50", + "packages/splat-core/src/tiles/TileBuilder.cpp": "d3767c2c5a3c69d1d4c67f99175490e47ef4bd8db0cc73829cc1cc23f84dbd4d", + "packages/splat-core/src/tiles/TileLoader.cpp": "77331f2d179ab0aae011bfdfed4ca537d14c1ee500276f212fe0d018088e772a", + "packages/splat-core/src/tiles/TileScheduler.cpp": "aabd72d1066a950b6be3601cd9822eeec31a7f75766bb1cbd8fd54caa0caea03", + "packages/splat-core/src/tiles/TileStreamer.cpp": "38afbb686aa3f44638bd48e3f6e6f744bdf132086761dd8312f95ebee339da62", + "packages/splat-core/src/tiles/TiledWorld.cpp": "6e7bca3fb204921d2e7bd67a2e83d7b361611a06d70b21bbdaa425b40aaf9e11", + "packages/splat-core/src/tiles/Tileset.cpp": "36c42017de320223fe01ef6bde49ef53c2feb8a1403cfe086f56d8a144f8a541", + "packages/splat-core/tests/CMakeLists.txt": "34d3af21c4046dfd44acfcd4dc18092aa067faa3caa79abc3c2fa35945c92c24", + "packages/splat-core/tests/diagnostics/TimingSummaryTest.cpp": "e9554667af62dc8c9c1d6538b846bc497e1c8adffaf3582c9797ff8e58fdd35e", + "packages/splat-core/tests/formats/GlbDecoderTest.cpp": "adc05d14ede3b2441c1caef6cade27a2c804d14e54e1162caf5c44ffad59b36a", + "packages/splat-core/tests/formats/SplatDecoderTest.cpp": "a2d1cdc486932459893ed831a2fd44f4282e891953bb3389ec26e539a1a2ffee", + "packages/splat-core/tests/formats/SpzDecoderTest.cpp": "0829c895a8ec0f6f9c9b88062d9c2b3d7ad52811e4a85c453cacca50d5d77b2b", + "packages/splat-core/tests/io/MappedFileTest.cpp": "52457a693c039eaa0a45208aa6b2698349ae8847d06acce64e8092602c5ee599", + "packages/splat-core/tests/loading/SplatWorldLoaderTest.cpp": "cb2207948318b660df9bed93441e9b49bd0998e68ada5b498a4d6a52a75cc58e", + "packages/splat-core/tests/lod/LodFileTest.cpp": "ff6b5f23fadcbe2a809fa095043e7af12658892af22ed2af32d52566aeb3d3a2", + "packages/splat-core/tests/lod/LodSorterTest.cpp": "c3f40ab47a28025844ac457310c5704a6101547569ddcdb3f334399a13cdd3c3", + "packages/splat-core/tests/lod/LodTreeTest.cpp": "9114498b1a625b8cffccf68e10e03ee806c48b494163152dffa79b7005bce52f", + "packages/splat-core/tests/math/FrustumTest.cpp": "738fc75de517b5e4f04645bd0bc6373d10821a6f06f38084bd161b37b934524e", + "packages/splat-core/tests/math/HalfTest.cpp": "3139c7fe7e76d2feeff8d3a6c3a92e22e1ad214af3c71ca6ea093d27d32c6799", + "packages/splat-core/tests/math/Mat4Test.cpp": "0c69a370b94991f48746eb98c4c574af29702ed78e2cb9ca87a5e16a4b29cb77", + "packages/splat-core/tests/math/SymmetricEigenTest.cpp": "9415d657cc2b8b1c810bc3bdd23a94cf26f61337c556141beaa11a413bf690e6", + "packages/splat-core/tests/navigation/ColliderTest.cpp": "16949c0213c97e946a6c9c57acd86222de9f04c612cb77b066700601081b857a", + "packages/splat-core/tests/sorting/DistanceSorterTest.cpp": "2b5148be21c53ad328ea781932e55339b32b7902d6e11c450364b8f8aa49bb16", + "packages/splat-core/tests/sorting/FrustumSortTest.cpp": "a0dec31fe4d847c1ccd188b42ac72185425d4b54e4359e9c63f55b94c8b4cdad", + "packages/splat-core/tests/sorting/SlabSorterTest.cpp": "a1f63336860fb66a5cd7b9aa4feb740791b7f4c898688bed04dadb34e5c97fee", + "packages/splat-core/tests/sorting/SpatialOrderTest.cpp": "7e5791b0f0f386a16c144f7f48d670a719e5860ea789fe8b296e678de1f4ff43", + "packages/splat-core/tests/sorting/VisibilityPlannerTest.cpp": "8b8177fa00877eed6e99d79e4f09c42acc0550f4e391973ad62e601e817bad48", + "packages/splat-core/tests/tiles/SlabAllocatorTest.cpp": "c71c5124841ac69602a8dceba30324675beeabe8380d6bf066f497c7fa8e88ce", + "packages/splat-core/tests/tiles/TileBuilderTest.cpp": "38ce8c4aecf3a5f09c552e7a6517e8c5f20d9d32a743af0e68d65d963891e797", + "packages/splat-core/tests/tiles/TileSchedulerTest.cpp": "82e97832c77e9451d3f3dc3f75085bebd805e07d9a01d7336a176f4be0765f4f", + "packages/splat-core/tests/tiles/TileStreamerTest.cpp": "3388236a1d59f9d7ad759fbccb4726ee26833e519cd643c4a33735e4e09666b6", + "packages/splat-core/tests/tiles/TilesetTest.cpp": "20b1e8411bfbbbc04e1221c85b43cd970e776c1e9c03a53ef651e1bd22ba7a58", + "packages/splat-core/tests/tools/CloudEditTest.cpp": "7b020123a504b17106877973c5c8529ea3c50dbf879533625ce48938da1f49ee", + "packages/splat-core/tools/CMakeLists.txt": "2cdf4d1a1d4b06bb8b9e98bef9c2e71ac64bed653870df8865a2b6a5741a408a", + "packages/splat-core/tools/CloudEdit.h": "1dd093c70d93566944626e41abbfd56c1906024c01a52437d50adca16934c9a6", + "packages/splat-core/tools/ply2spz.cpp": "e3be0ce7beea9f459ddcf40124a36e0329cd1c8b7b8a1119bc2bf6437c7420f1", + "packages/splat-core/tools/splat-tile.cpp": "e376144027ee9f354ee7a0ccc02fdaf13eecf5baa73a53369758a3fc0177d0ff", + "packages/splat-core/tools/splat_lod_build.cpp": "6dcf186c775b4f447d69d6d9e9c86fa14f1fca2214d531d5f335b0520648e565", + "packages/splatkit-android/README.md": "713a13eb87daa530de3822619cb76d73236359a90024fde8b49bd831f06229a7", + "packages/splatkit-android/build.gradle.kts": "819814f1c12c81f6da8571b073ec29d282955ac7116787f12a20150a5e14fd39", + "packages/splatkit-android/consumer-rules.pro": "3a6e87afefc13479ca339a6a387c3bb25b307ee52838d09830fa726cd0ae820f", + "packages/splatkit-android/docs/VULKAN.md": "4543937646520c4d07b093802b36791e28f34f43dacddf1bfa3b4d2e810bc648", + "packages/splatkit-android/src/main/AndroidManifest.xml": "c8fe08b06e479efc7e3d00476fffe693a3e943e2b874b3c6a1ba5156fc180751", + "packages/splatkit-android/src/main/cpp/CMakeLists.txt": "8cb9dd371a666fe13b027a33115cfd35865e81633beb6c752cd20b79d1a0d442", + "packages/splatkit-android/src/main/cpp/cmake/embed.cmake": "f303363ac1532fa1cb40668290105b13d85124b3827cf8df9ed1f810527fb741", + "packages/splatkit-android/src/main/cpp/cmake/shaders.cmake": "ebbd6f248799a28c1db1d1b74ee7335dc22ab25ca7633b5948ce156a12d83821", + "packages/splatkit-android/src/main/cpp/engine/AndroidEngine.cpp": "526960439663f4de82784a98beeadb27c414af461ed61405fa3d4b01bfc25d1d", + "packages/splatkit-android/src/main/cpp/engine/AndroidEngine.h": "3cd5c89dcdba1f6c47b647e14fb0a2951213b72f0cd667b4b369150e310a830f", + "packages/splatkit-android/src/main/cpp/jni/SplatKitJni.cpp": "513ef38f7e6c516ad7abc1d480de21ae3d12e37376b3d1704aaf107a1cd922d3", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/DebugTrianglePipeline.cpp": "d6c8d79168bc33485f79caa59ca551c17de991d6762f3f957f8b89583526b4fa", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/DebugTrianglePipeline.h": "f590fcaa87bc8192b4aed2d7863c9b8a4a364755ed799984b687c62aa462c964", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/FrameLoop.cpp": "2c1a2dff69ff5f16787b87143650c8d5d99b9cfc6b4f41e5970513d909741862", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/FrameLoop.h": "a0cbd053451f9d8d8d084ea68ea8c092da71e941f6721f632060b3d9bdcd92a1", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.cpp": "dafbe6e0483ad751acde6271e6eb1cf3e26927ec7a4715df1e46b86b2160e85b", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/GpuBuffer.h": "56fa2eacecd028b5f81b4210dcf8f9f24e5aed0ae5b02183327ad791b4c7820c", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/LodSelection.cpp": "d2802831e2a9ff1d6bc2aea68a3cc9b1de5e7e12a943e83f9928aae97f6df5e9", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/LodSelection.h": "8a7ef58427c7cf0bfde985336ebf696f8005e7bc8698dff736565e7cfca19eaf", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/RadixSort.cpp": "99ef5deda009e123c7e0822f82e9923aacb40d3a0b560f273d4a7c589d3e5b26", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/RadixSort.h": "08163ee79ce3d5c848ce7644248941a4ab26d25feb5d132dd9c28c3c21959fe5", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/RenderTarget.cpp": "7fc810c385bdc1f74d49f3baf7f64ffa31cd9e27638b0aaf2c252e425bea3793", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/RenderTarget.h": "d80b316b74815e9558a0217851eed81cb274802c80baedee085b023ed69099fe", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.cpp": "673a013c750908dec0c5f6ba2a5b8b3fea5351eff52b2b25b5b43b088f1da83f", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/SplatPipeline.h": "ab4f985a4ad6afbafbe5e84add914a8502b4895edc87af9f1371a16154545d6f", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/Swapchain.cpp": "9d52ee073d407a860d6d795ed96b32a4e35109ec59617cf88a36f58d06195c95", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/Swapchain.h": "b427179eaae7e63ee59658eb14b9035f305149f64017b864947dc81165d0c2bc", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VisibilityPass.cpp": "0845a00f8aeb69c0e96c669338d18cf9a51cdfc011afe20c5dd8167c2cc5960c", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VisibilityPass.h": "21c5e9637c889f5b99090474c6f0a8fc5a39e08b2f7400cb7da45be35a398057", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/Vma.cpp": "daca25b1f5e1e9520988b0b0eecc2b39139b98140b57bd2a8b19fb62aedb2dd1", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.cpp": "0785f6e01ce8b75f3b16dc7cde1510be8d3649b54e0c68ac1de29f96430d346c", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanContext.h": "3dd077dbccd17e07530be81d897e0d8438fbf6b0d352282dbe6d30484a0a8a43", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanFrameCompute.cpp": "03c306d7f203f82d52c21427452d8aa595fa30f40e5bb575f5f13a8e7cb7b328", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanFrameCompute.h": "95c725da4978caf5991873f0e6e781feccc685f53735f3d6983103a14384d587", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanShaderTypes.h": "7d7e6e44b701270844445a30344fda7e35cdd6bd649f209942de700581e0fe9a", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.cpp": "8bc9edd3bc8874f0091fd52f9af276e16e87de58c946b7ab010e118d047e4416", + "packages/splatkit-android/src/main/cpp/rendering/vulkan/VulkanSplatRenderer.h": "34e977fefe0576c17a07b2fa94d5cd0dcda83656316f7006e6aa3e9dfbb771ac", + "packages/splatkit-android/src/main/cpp/shaders/lod_selection.comp": "7af79cf69721a8035936a879cf0d23bb7fc2ef7898d9f1d794b76655722f303f", + "packages/splatkit-android/src/main/cpp/shaders/prepare_indirect.comp": "f13ea2afab4410dd38f5981b71cd573376f9fd5658747ee7ded98bca39b3db31", + "packages/splatkit-android/src/main/cpp/shaders/radix_histogram.comp": "c96f75e6f11351a1d510ef8306223eda44af0390d3a508ce71e0674c088572e2", + "packages/splatkit-android/src/main/cpp/shaders/radix_prepare.comp": "2d93a73fea08ae97e451a853ad18f6e51e3ce78a997aaddeb0dbf9b1982776e7", + "packages/splatkit-android/src/main/cpp/shaders/radix_scan.comp": "da465255b3e7f7d4bcf20ca59f61be1646e7dcd963673e7c47afab237241cc2b", + "packages/splatkit-android/src/main/cpp/shaders/radix_scatter.comp": "05d78a2ae23b3e8378eed1102af728082f0a54ae280442127d42355ae3aff24e", + "packages/splatkit-android/src/main/cpp/shaders/splat.frag": "48f4e398f0a8a1c25903fd54c9568cc1a0637f9e2e3317852e6497e5f05ce433", + "packages/splatkit-android/src/main/cpp/shaders/splat.vert": "d1b85f6d180b481998470e17dc9b45975507faa82d7b7c8edb4f821a617f40e9", + "packages/splatkit-android/src/main/cpp/shaders/triangle.frag": "d7d49c01b45994c52991563d870a33c0bf45c826e8a144645379c2f5014e3152", + "packages/splatkit-android/src/main/cpp/shaders/triangle.vert": "97c931ddebc5f06b26397f3dce0d7be6c205801c5be9ea3de5a60998d75802da", + "packages/splatkit-android/src/main/cpp/shaders/visibility.comp": "33f0bc0eb0b6219b71f1a544f34a07f15713e2dedf7beba8f3aa629eba272a96", + "packages/splatkit-android/src/main/cpp/splatkit.map": "cd2c6ec19dbe9274dec4f15c1bf4c2d96c29101accc4fa88cbaba2c0956f04c3", + "packages/splatkit-android/src/main/cpp/tests/GpuBufferTest.cpp": "aa5513e311cfee171e0c6f61c161020d0a24adee28d810fb1cf350376c361898", + "packages/splatkit-android/src/main/cpp/tests/GpuUploadPressureTest.cpp": "44010b5abbee620cbaa276fbfc0d81446ee90ceec2ca27d189e436d980167e4f", + "packages/splatkit-android/src/main/cpp/tests/LodSelectionTest.cpp": "c048b457db8a33f2c5958dab62db6ab05a733887c5bd822f8b506c1a43fe0b42", + "packages/splatkit-android/src/main/cpp/tests/RadixSortTest.cpp": "33d24edcb16b000c55b110ffae3e63e756eb375e636a26199e39c8b10f73c42f", + "packages/splatkit-android/src/main/cpp/tests/VisibilityPassTest.cpp": "774fe323da0443d853766d7827864abcf26a3834e690d94dbc77be517689c91d", + "packages/splatkit-android/src/main/cpp/tests/VulkanFrameComputeTest.cpp": "6cd87d615891889970ba1a10f005d274e717e551e63dbe3091edcab5f4118f58", + "packages/splatkit-android/src/main/cpp/tests/VulkanTestContext.h": "fec10fe52ab9cb95c8a139654485f69f536089c4b84ba8799c93fc945089adf2", + "packages/splatkit-android/src/main/java/com/splatkit/CameraPose.kt": "f6af772972f0f06f5dc767e13ca8ba9744a29751648f881a4a756d9f92420559", + "packages/splatkit-android/src/main/java/com/splatkit/RenderQuality.kt": "e3ae7221b10787f3298734d1f1049ff0061dbb2e6282616acf73227c713fc008", + "packages/splatkit-android/src/main/java/com/splatkit/SplatStats.kt": "94d635e10b8a6d14e13d9d28af66f47dc61a8f724275b8813f411a62e44b7f41", + "packages/splatkit-android/src/main/java/com/splatkit/SplatSurfaceView.kt": "449cdde537a7eca3ccc7e4e4b2eb30ae0b15ceb8de16361a33b707447d5b1752", + "packages/splatkit-android/src/main/java/com/splatkit/engine/RenderThread.kt": "fb88a29460f9ffa28e8ce0f6abe37ae698c868c5fbae528586fe0bb50516dda9", + "packages/splatkit-android/src/main/java/com/splatkit/engine/SplatEngine.kt": "bca0a1505e39474fc42ab6c7208ce2362acb9d11c26ec5204b6a575713333791", + "packages/splatkit-android/src/main/java/com/splatkit/input/MotionInput.kt": "84cc01e4f6f52dde19bd950ce8b4a34b17ad56b107b8ab4c4772f53efd0fb7bf", + "packages/splatkit-android/src/main/java/com/splatkit/input/TouchInput.kt": "8d41f62433be6bf859efe8e1b7bc3ef1870bdd5fc78e3d7128776e6ea307e48d", + "packages/splatkit-android/src/main/java/com/splatkit/ui/JoystickView.kt": "629640506d38d7ef41d95ebe3e6428cd0079028cde4e0175229597c435c575d9", + "packages/splatkit-android/src/main/java/com/splatkit/ui/SplatHudView.kt": "11615c9e02b8a0689c473eb5fef7e648c4473828d9f9807dce8e49668434ccf4", + "packages/splatkit-android/src/main/resources/META-INF/LICENSE-splatkit-android.txt": "bd29ea6e08361ced93a56b9b3cb871fd8836be8063bd8523f476dca82cc8b530", + "packages/splatkit-android/src/test/java/com/splatkit/SplatStatsTest.kt": "161d401c246ad6b147ca44e1c8e8ed6d781f1ae1e6266fcea467580e2835c38a", + "packages/splatkit-engine/CMakeLists.txt": "ec84c8b8629a36ca3dcc3154995de3cce78706035d4459b9029df3c047957b69", + "packages/splatkit-engine/include/splatkit/Log.h": "d88cad8c5164ce439c59ce3872fb784a229afde30ff76c783d0165d28e1bb10a", + "packages/splatkit-engine/include/splatkit/camera/WalkCamera.h": "43e7e578ea9d70922f95e83805ef45a8321bd9ca681fb861d7e00cdc015bb716", + "packages/splatkit-engine/include/splatkit/diagnostics/Benchmark.h": "59b3dfd6f92a7176191a4326ac7fb9a9450333bcbf1684d47b3d36731cd01d9c", + "packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h": "5aeac7ea7d6c45bf883192d88b813936f529b4553d0ba82904313b7beb138507", + "packages/splatkit-engine/include/splatkit/engine/SplatEngine.h": "7d1cc6004fbd9f227378097db673c9235b7b1bfc91715f96acb6030f37bf4d68", + "packages/splatkit-engine/include/splatkit/rendering/GpuLayout.h": "0cbc13f05e292003844cc9996bb8561fcbc6e9197153a1dabf8e944c6184d662", + "packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h": "83f2ed6b35a7177967c2cbe4a03835fe360cd4ef5908a2baafe74d2a84ef1a27", + "packages/splatkit-engine/src/Log.cpp": "9f1673bc7f4849bb8fb00e167d138c6f3bfdb82bdaaaef902c16db6fb3f359b8", + "packages/splatkit-engine/src/camera/WalkCamera.cpp": "a3468549b3294a57d8c97fc7ca60c476195839590668b8a03ce90307ef1b30e2", + "packages/splatkit-engine/src/diagnostics/Benchmark.cpp": "593ca6da2138a75443a4c8c1fc8e3681b153357fc2c95c325285517266eea97d", + "packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp": "be481cd67a957073e9b1671e0da3683365031e3c6c8416d26bc864486888f88e", + "packages/splatkit-engine/src/engine/SplatEngine.cpp": "17e609bd5ba45f49e79c5bfe7a9670fd689636643572c829e674ea4be13ec5df", + "packages/splatkit-engine/src/rendering/GpuLayout.cpp": "33b384ef738cc28f147e06608fb3004aa839f9a02bb9835abdcb74d50b642204", + "packages/splatkit-engine/tests/CMakeLists.txt": "d212c87bb5f9284f49a845bb216f4cd64617098528bf21fc626fedbd7d775583", + "packages/splatkit-engine/tests/camera/WalkCameraTest.cpp": "69be71cdf7a672a451d39569c1c5651cbf45b1329c48f39d4ccceea997cf9465", + "packages/splatkit-engine/tests/rendering/FrameOrderTest.cpp": "deeed75c2ff15efcc3ba929a3ba616d9a1b517d25daafa8f90f2f9a70b3b93fc", + "packages/splatkit-engine/tests/rendering/GpuLayoutTest.cpp": "9c613ac37b790fe8a069eab6af8570b5c394801ab252680d56824d2b43edb69d", + "packages/splatkit-ios/CMakeLists.txt": "4709d2fffbfe5733624ad4c46311280c300e8346be898bbed77e6617f2438ebb", + "packages/splatkit-ios/README.md": "4a31f90987a12aa8c7fc297c69b48fbbf0eae118b853eeaad315e5cd68a72019", + "packages/splatkit-ios/Sources/SplatKit/MotionInput.swift": "e0bcebb884451bbfd1e5e2b0a0a300c70acedbc9dda062c98a25ca605ac68a7b", + "packages/splatkit-ios/Sources/SplatKit/RenderThread.swift": "9e9877445b0c9d851994f003abe69963222352e73b3de8430fbd8f6fa23a191a", + "packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift": "d34d3cc9c60d749683dba570711192fe1c3d947d7dbf1696d458f3d1638529c0", + "packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm": "dde324b2ee6d0bfe73225dece9dbccef4e86c15d3d87eb843924a54917d0def3", + "packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h": "b31003b5f70db7032ff82382edf1bef49b9057edf83d030d70d136de72eeb389", + "packages/splatkit-ios/Sources/SplatKitCore/include/module.modulemap": "3c57851cacdc49a5003015608fb45040864c8c074bdf7c447f613451976f58f4", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalCompute.h": "36658f929e89b75de57559f44b3a5bd57f8d8c91c3e2e6ddc46911e305379a90", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalLOD.h": "0d68695b7be7ce2bb6ece4ce3667e637c53507566a45597b234d77d85bfa8af8", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalLOD.mm": "b377b0b0be41d0df23037d7ed2f5a55ed9074c7e1f47def5130ff3c4e5078f6c", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalRadixSort.h": "ab92a91a1a8f5af8b6909566c2f5ba3b7092d1ba6cb8c222cb28d82a48f0165e", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalRadixSort.mm": "7225c576a68aacb28b8b9e506a5f0ddcb22b8a633cdc233ef04b9d746b2893c3", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalShaderTypes.h": "63e11616fa0e5a85689f6caff01067026940daa6953cb1c58aa45970ae5a8e17", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.h": "1726032cdb3194dc536f7d64b624bb0340b9057ec10b25e724b8cfc230a8a43d", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm": "89af16ed829540804d103e069c9c97217b9ce4c190341dbc2a7f931c886e448f", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalTileRaster.h": "3e0036de422bfde9e0329f2cbab03bd1a5a0c0c07e5b6ad12086565cc5db2dff", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalTileRaster.mm": "970cda458cc60ccd69b5abcddf26ee189df7bfd8b8942b4eeaa7793876d7adf3", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.h": "18e1b943e08abb64aabbb78074638c7a52e8e988d30f59b730a6b5be132ff90e", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm": "21dd15ac7b0d328377215141914c418d07aa6ab8cfb7a6930832af36fd1b36e5", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalWorld.h": "c00dd89852f8d42729d628e9d375828f49c1e33453b6fb1c6c23f36945c61da1", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalWorld.mm": "761c862599d769c86f79f70a533287d1a0d2f64eb99b38d75e33c553801cccb3", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/PrepareIndirect.metalh": "33d1bb05d25b4f766860dca93eb19f885b026856fc2931bcbc740202f0bacfc7", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/Splat.metal": "a6a259e9eea7b843dd16dd8e1a872057c4e83e931e411807451e4097cc4ec8d4", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatLOD.metal": "5815c9079ff0ffe2b0a19f8b93a6a45700a2597f8b4cbffa0ff8fce62b164073", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatProjection.metalh": "12f5374c54b14888e7a0c7d83b50e5d52a8563bbe62ff3bfba24363a12a25e22", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatRadixSort.metal": "e8dd07e81aae35ad9078dcca6746868ff1c990bb1389bbea07c6517d0a8b3414", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatRaster.metal": "d07a81e3883991dbc7762019429849f5cb669e7adbde426456aa7a43f5aa4f90", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatTileRaster.metal": "684c417c9c40361aa71c314ca7a5ec834d24a8e4510f1f5111a143cfc57d01cc", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatTypes.metalh": "dabf6a5cde0f7938ed87b3c34dc82104ac93e05791ae373718ca17c5321cbec8", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatVisibility.metal": "64e647ea844eb6579e901d74d9ecd5ebf97bc9f8313fc3e4215bdd93d959a60a", + "packages/splatkit-ios/cmake/embed-text.cmake": "04f1b705aaf5f1f1cb3237c858ed46710ef82badd06ce4d6a91210b1e93c4711", + "packages/splatkit-ios/cmake/embed.cmake": "efa53039196cc08862b4901a1ae9ba97798ef7e36f75c37ab3c1e8d5345700d9", + "packages/splatkit-ios/distribution/AGENTS.md": "ae264333145a45e27170884decff8b8b136e2aaa8afbc3bebeb2cf078bcfadd6", + "packages/splatkit-ios/distribution/CONTRIBUTING.md": "57a79b10ecbf51252b6c36a8e03804441729b78a3b55691367ba40003872c223", + "packages/splatkit-ios/distribution/README.md": "d50f9574a921f6fbebd5038bedd1e34b3ae43882eff51d2c275b8783b0b4d2a9", + "packages/splatkit-ios/tests/CMakeLists.txt": "f7b4889a22cb81f0f55c5d53c526654d1651d09d839c92ea11b3ed99292a73e5", + "packages/splatkit-ios/tests/MetalLODTest.mm": "438ea0657133b0b88ca2570d12ea1f066edeccb322bacd18c753c96148cb9bc2", + "packages/splatkit-ios/tests/MetalRadixSortTest.mm": "ea57b2c771ea090e680686cd7ae1620dc81e771cf9a97bb786870d8f3efeb099", + "packages/splatkit-ios/tests/MetalRasterTest.mm": "3bd351a6aa945c28f8139867ec9128c9196d194d35ef93df79f09dbec8fada54", + "packages/splatkit-ios/tests/MetalTestContext.h": "f8e8ca3eff3818dde207d98a3697edd93b31984204051b3f9e006cfe10908132", + "packages/splatkit-ios/tests/MetalTileRasterTest.mm": "621a5968ac68b06b1021be103e5d635d2552a7af3358dfd0c6b65e114dcc6c90", + "packages/splatkit-ios/tests/MetalVisibilityTest.mm": "329d967cfe20a39d8c3ab4537562a294cfe9bf360cb30a7326589b53b2aa3cbc", + "packages/splatkit-ios/tests/MetalWorldTest.mm": "f6a61735bfba292caf4209834c194c1accc862bda6e8f420cd4849a7d9a69305", + "scripts/build-ios.sh": "c05feb1e381afb85054e4090551942fa7937e77ba43689ca1e4a70e8e9886bb9", + "scripts/export-ios-source.py": "035066089b805c23a4faf1372a0088d7ab2db9e20e1619371eed1f76b404eb3c", + "scripts/fetch-validation-layers.sh": "e4be235b73a839836f793348edb6795eea1f396cdd90ea8f13b4f0ff78381e17", + "scripts/lint-cpp.sh": "4fe15276ef436235998ba957fa8f2fb377f27a6f07f14c812adf91e7b14c7918", + "scripts/package-ios.sh": "d4cd0ea92c1742bf2b5a1f8387ccae7083615e0256cff68dc6f32647d02b6a68", + "scripts/sdk_harness.py": "54792b0c25e9abc981a6d22a38692c3dfc8b17ed05c29244b81d368dc6e76a1a", + "scripts/tests/test_sdk_harness.py": "0a7785b4dfbb2e2f9dedac54c209a3fd315b99c06b2164cd8a820de9fe476c26" +}