diff --git a/.gitignore b/.gitignore index c6f87b3..3b5007b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,11 @@ build*/ .build/ +DerivedData/ *.spz *.ply *.glb .DS_Store +.argent/ .idea/ .gradle/ local.properties diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..68f7a99 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +Notable changes to the SplatKit iOS SDK and the shared C++ engine it ships. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); alphas may break APIs. + +## Unreleased + +### Added + +- `SplatMetalView.renderPolicy` and `deviceCapabilities`, with `SKRenderPolicy`, `SKDeviceCapabilities` and `-[SKSplatEngine applyRenderPolicy:reason:warnings:]`. + Each request is re-validated on the render thread; Metal applies the sort depth with GPU sort and the sub-pixel threshold under tight culling, and every other field falls back with a warning. + An invalid request or a preparation failure keeps the previous policy. +- Shared engine: `resolveRenderPolicy`, `SplatEngine::setRenderPolicy` and a capability query on `SplatRenderer`. +- Benchmarks log 30-second windows and final p99 frame and GPU times; `TimingSummary` reports p99. + +### Changed + +- Benchmarks reject durations outside `(0, 3600]` seconds and treat a zero GPU time as unavailable, not as a free frame. + +### Removed + +- The `SPLATKIT_METAL_MIN_PIXEL_RADIUS` and `SPLATKIT_METAL_DEPTH_KEY_BITS` environment variables; set `renderPolicy` instead. + +## [0.1.0-alpha.2] - 2026-09-12 + +### Fixed + +- GCC portability of the offline LOD writer and portable residency test budgets. +- Hosts without Metal raster support report skipped tests instead of failures. + +## [0.1.0-alpha.1] - 2026-09-12 + +### Added + +- Native Metal SDK for iOS 17 and A14/M1 or newer, distributed as a SwiftPM device and simulator XCFramework. +- GPU visibility and radix sorting, experimental LOD and hybrid screen tiles, asynchronous loading and first-frame readiness. + +[0.1.0-alpha.2]: https://github.com/Xget7/splatkit-ios/releases/tag/v0.1.0-alpha.2 +[0.1.0-alpha.1]: https://github.com/Xget7/splatkit-ios/releases/tag/v0.1.0-alpha.1 diff --git a/CONTEXT.md b/CONTEXT.md index 691fbf8..9245a8c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,7 +16,8 @@ The splats of a world decoded into memory, structure of arrays, in the engine's _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. +What a host asks the engine to show: a source of splats plus, optionally, a collider. +A world is one file or a tileset streamed in tiles. _Avoid_: scene, model, asset, map **Collider**: @@ -24,44 +25,57 @@ The triangle mesh the walk camera collides with; the world's floor and walls for _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. +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. +One presented image. +The engine draws a frame only when something changed. **Sort**: -Ordering the splats back to front from the camera, on the CPU, so blending is correct. +Ordering splats by camera depth in the direction required by compositing. _Avoid_: depth sort, z-order **Cull**: -Dropping the sorted splats outside the camera's frustum, widened by a margin, before drawing. +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. +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. +A named set of starting values for the render scale, the harmonics degree and the budgets: `RenderQuality` on Android, the builder presets in React Native. +Individual settings override it. _Avoid_: mode, profile, level **Splat budget**: Selection capacity; zero disables reduction. _Avoid_: limit, cap, max splats +**Render policy**: +Per-view renderer choices, such as sort depth and the sub-pixel threshold, resolved against the device capabilities. +A supported choice applies, an unsupported one falls back with a warning, and an invalid request is rejected while the previous policy stays. +_Avoid_: settings, config, quality (that is the preset) + +**Capabilities**: +What one backend on one device can apply from a render policy, and the limits it accepts. + ### 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. +One entry of the 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. +The hierarchy of nodes, built at load time or read from an offline `.lodsplat` file. _Avoid_: LOD, octree (a tree of nodes is not an octree of tiles) **Selection**: -Covering cut; capacity≠quality. +The nodes drawn this frame: a cut that covers the world within the splat budget. +A larger budget is not a quality guarantee. ### Scale @@ -69,8 +83,12 @@ Covering cut; capacity≠quality. 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. +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**: @@ -82,5 +100,5 @@ Loading and dropping tiles by what the camera can see while walking, so memory h _Avoid_: downloading (that is fetching a source), lazy loading **Residency budget**: -The most tile bytes held in memory at once; what streaming evicts against. +The most splats held resident at once; what streaming evicts against. _Avoid_: cache size, memory limit diff --git a/README.md b/README.md index 89e56dd..c0823c9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 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`. +Choose exact version `0.1.0-alpha.2`; `main` may be ahead of that release, as the [changelog](CHANGELOG.md) lists. The package downloads the release XCFramework for arm64 devices and arm64/x86_64 simulators. ```swift @@ -33,4 +33,4 @@ 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. +[react-native-splatkit](https://github.com/Xget7/react-native-splatkit) drives `renderPolicy` from its `policy` prop; iOS device validation is pending. diff --git a/docs/BENCHMARKS.md b/docs/BENCHMARKS.md index 50b228f..bab5f9d 100644 --- a/docs/BENCHMARKS.md +++ b/docs/BENCHMARKS.md @@ -1,6 +1,9 @@ # Benchmark log -## ISS experiments, 2026-09-11/12 +Every optimisation starts with a measurement of the state before it, recorded here, and ends with the same measurement after. +Rows are dated evidence for the build they name, not claims about current performance. + +## iPhone Metal: 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. @@ -21,16 +24,40 @@ Horizontal framing changes the visible set; it is not a radix A/B. [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. +## Mac Metal: LOD capacity against the reference, 2026-09-12 + +House 2M with an offline depth-6 hierarchy on an M4 Pro, compared by `compare_captures.py` at exact camera equality. +In a distant view a 1.2M budget denied 311 refinements and blotched (PSNR 23.1 dB); 2.2M, effective 2M, matched the reference (PSNR 71.8 dB). +This is not a phone measurement, and a covering cut is not an image-quality guarantee ([JSON](benchmarks/2026-09-12-mac-lod-quality.json)). + +## Mi 9 Vulkan: GPU pipeline, 2026-09-12/14 + +GPU LOD, visibility, radix and indirect draw on the Xiaomi Mi 9 (Adreno 640, driver 0x801f6000), minified Release, validation off, SH 0, 1080x2261, 30-second turns. + +| Date | World | Settings | FPS mean | GPU ms mean | Evidence | +|---|---|---|---:|---:|---| +| 2026-09-12 | house 2M | full source | 19.2 | 51.9 | [JSON](benchmarks/2026-09-12-mi9-vulkan.json) | +| 2026-09-12 | house 2M | load-time LOD, 1.2M capacity, before the empty-group skip | 24.0 | 41.6 | same | +| 2026-09-12 | house 2M | same LOD, empty groups skipped | 41.4 | 23.9 | same | +| 2026-09-14 | kitchen 500k | no LOD | 42.6 | 22.6 | [JSON](benchmarks/2026-09-14-mi9-sdk-smoke.json) | +| 2026-09-14 | house 2M | load-time LOD, 1.2M capacity | 39.2 | 24.9 | same | + +Stage timings and drawn counts are periodic samples, not all-frame statistics. +The 2026-09-14 session lost USB before its lifecycle cycles; sustained thermal and memory runs remain pending. + +## Mi 9 Vulkan: CPU ordering, 2026-09-04 to 09 + 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. -| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | Sort ms | Cull ms | Drawn of total | Load: decode / reorder / upload ms | +| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | Sort ms | Cull ms | Drawn of total | Load ms (decode / reorder / upload) or note | |---|---|---|---|---|---|---|---|---| | 2026-09-04 | 91b8d76 | house 2M | scale 1.0 | 32.4 / 32.0 / 41.1 | 41 to 46 | 10 | 143k to 291k of 2M | 530 / 470 / 270 | | 2026-09-04 | 91b8d76 | raccoon 932k, SH 3 | scale 1.0 | 14.4 / 12.9 / 27.8 | 95 | n/a | up to 932k | 634 / 216 / 501 | | 2026-09-04 | 91b8d76 | raccoon 932k, SH 0 | scale 1.0 | 13.9 / 12.4 / 26.7 | 95 | n/a | up to 932k | | +| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | Sort ms | Cull ms | Drawn of total | Note | +|---|---|---|---|---|---|---|---|---| | 2026-09-04 | LoD tree | house 2M, budget 500k | scale 1.0, budget spread over the sphere | 25.6 / 21.7 / 45.7 | 18 | 13 | 18k to 52k of 500k | tree build 4.3 s on the Mac, blurry: rejected | | 2026-09-04 | LoD tree | house 2M, budget 500k | scale 1.0, budget weighted ahead (0.02 behind) | 32.4 / 32.5 / 42.4 | 16 | 14 | 152k of 500k | select 91 ms, still soft | | 2026-09-04 | LoD tree | house 2M, budget 1M | scale 1.0, budget weighted ahead | 31.1 / 32.1 / 41.3 | 34 | 14 | 239k of 1M | select 218 ms, looks like the full scene | @@ -40,23 +67,29 @@ Reading: at full resolution the house draws 150k to 290k splats and nearly all o A budget below what the view needs blurs the image without saving GPU time, because the merged nodes are bigger and cost the fragments the leaves would have. The tree pays off when the scene is bigger than what the view needs at a pixel each (far content, many millions of splats) or at low quality modes; it is off by default. -| 2026-09-04 | ADR 0011 | house 2M | scale 0.999, sRGB attachment (old default) | 32.8 / 32.4 / 41.8 | | | | reference | -| 2026-09-04 | ADR 0011 | house 2M | scale 0.999, RGBA8 UNORM attachment | 20.1 / 19.6 / 26.3 | | | | blending in the encoded space | -| 2026-09-04 | ADR 0011 | house 2M | scale 0.999, RGB565 attachment | 20.1 / 19.5 / 26.8 | | | | same as UNORM8: the cost was sRGB, not the bytes | -| 2026-09-04 | ADR 0011 | house 2M | scale 1.0, sustained performance mode | 32.6 / 32.3 / 41.8 | | | | no change: rejected | -| 2026-09-04 | ADR 0011 | house 2M | scale 1.0, new default | 19.8 / 19.4 / 26.3 | | | | | -| 2026-09-04 | ADR 0011 | house 2M | scale 0.7, new default | 13.7 / 13.6 / 17.8 | | | | 60 fps | -| 2026-09-04 | ADR 0011 | house 2M | scale 0.5, new default | 12.6 / 13.2 / 15.1 | | | | no cheaper than 0.7: per splat floor | -| 2026-09-04 | ADR 0011 | house 2M | scale 1.0, linearBlending | 32.6 / 32.2 / 41.6 | | | | the old path, still available | -| 2026-09-04 | ADR 0011 | kitchen 500k | scale 1.0, new default | 14.8 / 14.0 / 20.7 | | | | was 19.4 | -| 2026-09-04 | ADR 0011 | kitchen 500k | scale 0.7, new default | 12.6 / 12.8 / 15.0 | | | | | - -| 2026-09-04 | ADR 0012 | house 2M | preset HIGH: scale 1.0, SH 3, all splats, margin 10 | 19.8 / 19.3 / 26.2 | 43 to 47 | 10 | 216k to 419k of 2M | the default, matches the ADR 0011 row | -| 2026-09-04 | ADR 0012 | house 2M | preset MEDIUM: scale 0.7, SH 1, all splats, margin 10 | 13.4 / 13.4 / 17.5 | | | | 60 fps | -| 2026-09-04 | ADR 0012 | house 2M | preset LOW: scale 0.5, SH 0, budget 500k, margin 10 | 12.1 / 12.4 / 14.7 | 18 | 14 | 178k to 207k of 500k | select 89 to 101 ms, tree 2.4 s on the phone at load | -| 2026-09-04 | ADR 0012 | house 2M | LOW without the budget: scale 0.5, SH 0 | 12.6 / 13.1 / 15.0 | | | | the budget buys 0.7 ms here; its point is scenes bigger than this | -| 2026-09-04 | ADR 0012 | house 2M | preset ULTRA: scale 1.5 (1620x3391), SH 3, all splats, margin 20 | 39.4 / 39.1 / 50.0 | 43 | 10 | 419k of 2M | supersampled, 25 fps on the Mi 9, GPU 61 C at the end | +| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | Sort ms | Cull ms | Drawn of total | Note | +|---|---|---|---|---|---|---|---|---| +| 2026-09-04 | encoded blending | house 2M | scale 0.999, sRGB attachment (old default) | 32.8 / 32.4 / 41.8 | | | | reference | +| 2026-09-04 | encoded blending | house 2M | scale 0.999, RGBA8 UNORM attachment | 20.1 / 19.6 / 26.3 | | | | blending in the encoded space | +| 2026-09-04 | encoded blending | house 2M | scale 0.999, RGB565 attachment | 20.1 / 19.5 / 26.8 | | | | same as UNORM8: the cost was sRGB, not the bytes | +| 2026-09-04 | encoded blending | house 2M | scale 1.0, sustained performance mode | 32.6 / 32.3 / 41.8 | | | | no change: rejected | +| 2026-09-04 | encoded blending | house 2M | scale 1.0, new default | 19.8 / 19.4 / 26.3 | | | | | +| 2026-09-04 | encoded blending | house 2M | scale 0.7, new default | 13.7 / 13.6 / 17.8 | | | | 60 fps | +| 2026-09-04 | encoded blending | house 2M | scale 0.5, new default | 12.6 / 13.2 / 15.1 | | | | no cheaper than 0.7: per splat floor | +| 2026-09-04 | encoded blending | house 2M | scale 1.0, linearBlending | 32.6 / 32.2 / 41.6 | | | | the old path, still available | +| 2026-09-04 | encoded blending | kitchen 500k | scale 1.0, new default | 14.8 / 14.0 / 20.7 | | | | was 19.4 | +| 2026-09-04 | encoded blending | kitchen 500k | scale 0.7, new default | 12.6 / 12.8 / 15.0 | | | | | + +| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | Sort ms | Cull ms | Drawn of total | Note | +|---|---|---|---|---|---|---|---|---| +| 2026-09-04 | quality presets | house 2M | preset HIGH: scale 1.0, SH 3, all splats, margin 10 | 19.8 / 19.3 / 26.2 | 43 to 47 | 10 | 216k to 419k of 2M | the default, matches the encoded blending row | +| 2026-09-04 | quality presets | house 2M | preset MEDIUM: scale 0.7, SH 1, all splats, margin 10 | 13.4 / 13.4 / 17.5 | | | | 60 fps | +| 2026-09-04 | quality presets | house 2M | preset LOW: scale 0.5, SH 0, budget 500k, margin 10 | 12.1 / 12.4 / 14.7 | 18 | 14 | 178k to 207k of 500k | select 89 to 101 ms, tree 2.4 s on the phone at load | +| 2026-09-04 | quality presets | house 2M | LOW without the budget: scale 0.5, SH 0 | 12.6 / 13.1 / 15.0 | | | | the budget buys 0.7 ms here; its point is scenes bigger than this | +| 2026-09-04 | quality presets | house 2M | preset ULTRA: scale 1.5 (1620x3391), SH 3, all splats, margin 20 | 39.4 / 39.1 / 50.0 | 43 | 10 | 419k of 2M | supersampled, 25 fps on the Mi 9, GPU 61 C at the end | +| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | Sort ms | Cull ms | Drawn of total | Note | +|---|---|---|---|---|---|---|---|---| | 2026-09-07 | abcd950 | house 2M | preset HIGH, R8 minified dev app, phone rebooted that day, 150 MB free | 21.9 / 21.6 / 27.9 | | | | | | 2026-09-07 | abcd950 | house 2M | preset HIGH, same build unminified, same session | 21.8 / 21.5 / 28.0 | | | | R8 changes nothing on the GPU; the phone is 2 ms slower than on the 4th | @@ -64,10 +97,13 @@ Reading: day to day the same build moves by about 2 ms on this phone, so a chang Reading: the presets sit on the measured curve, medium is the 60 fps point on this phone, and ultra costs the square of its scale as predicted (1.5 squared times 19.3 is 43). -Earlier numbers, before this log existed, are in the roadmap tables. +Earlier numbers are under [Before the log](#before-the-log-formerly-the-roadmap). -Apartment Pool (superspl.at, CC BY, paul/nesterdigital): 3.57M splats converted with `ply2spz --sh 0`, no collider, benchmark from (-0.3, 2.3, 4.0) looking at the pool. The frame is all blended fragments here: it scales with the pixels and with the splat count alike. +Apartment Pool (superspl.at, CC BY, paul/nesterdigital): 3.57M splats converted with `ply2spz --sh 0`, no collider, benchmark from (-0.3, 2.3, 4.0) looking at the pool. +The frame is all blended fragments here: it scales with the pixels and with the splat count alike. +| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | Sort ms | Cull ms | Drawn of total | Note | +|---|---|---|---|---|---|---|---|---| | 2026-09-08 | 9732897 | pool 3.57M | preset HIGH | 73.5 / 65.8 / 123.3 | 72 to 97 | 16 to 20 | 464k of 3.57M | 14 fps: this scene's splats are large on screen, 3.5 times the house's cost for 1.75 times its splats | | 2026-09-08 | 9732897 | pool 3.57M | preset MEDIUM | 41.4 / 36.6 / 63.3 | | | | scale 0.7 is 0.49 of the pixels and 0.56 of the time | | 2026-09-08 | 9732897 | pool 3.57M | preset LOW (budget 500k) | 20.2 / 19.2 / 32.5 | | | | | @@ -75,28 +111,77 @@ Apartment Pool (superspl.at, CC BY, paul/nesterdigital): 3.57M splats converted | 2026-09-08 | 9732897 | pool 1.79M (`--keep 2`) | preset HIGH | 36.0 / 32.5 / 56.1 | | | | half the splats, half the time | | 2026-09-08 | 9732897 | pool 1.79M (`--keep 2`) | preset MEDIUM | 20.8 / 18.1 / 31.1 | | | | 55 fps, the demo setting for this scene | -Mip-NeRF 360 Bicycle (superspl.at, CC BY, Seeget3D): 2.6M splats with harmonics degree 3, converted with `ply2spz` untouched, no collider, benchmark from (0, 0.7, 1.6) pitch -0.2 looking at the bike. The scene is an outdoor orbit: the bike side costs a third of the frame, the tree canopy behind the camera fills it. +Mip-NeRF 360 Bicycle (superspl.at, CC BY, Seeget3D): 2.6M splats with harmonics degree 3, converted with `ply2spz` untouched, no collider, benchmark from (0, 0.7, 1.6) pitch -0.2 looking at the bike. +The scene is an outdoor orbit: the bike side costs a third of the frame, the tree canopy behind the camera fills it. +| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | Sort ms | Cull ms | Drawn of total | Load ms (decode / reorder / upload) | +|---|---|---|---|---|---|---|---|---| | 2026-09-08 | 11daaf7 | bicycle 2.6M | preset HIGH | 19.4 / 13.7 / 45.3 | 64 | 26 to 47 | 293k to 1.48M of 2.6M | 1637 / - / 1047 | Harmonics degree drawn, 2026-09-09, same bicycle file and pose, portrait 1080x2261 and landscape 2265x1080 (the landscape run sees the canopy across the whole width, hence the load). The degree is chosen per frame from the data already uploaded, so the in place rows switch it on the running world with no reload; the fresh rows load the world with that degree, and each run started with the GPU below 40 C. -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 3, fresh load | 18.9 / 13.7 / 45.2 | | | | | -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 0, fresh load | 17.2 / 13.8 / 39.0 | | | | | -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 3, fresh load again | 18.8 / 13.0 / 47.8 | | | | | -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 0 in place | 19.1 / 14.0 / 49.0 | | | | | -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 3 in place | 20.8 / 13.7 / 67.1 | | | | | -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 0 in place again | 19.0 / 14.4 / 51.6 | | | | | -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, landscape | HIGH, degree 3, fresh load | 40.9 / 16.5 / 212.9 | | | | | -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, landscape | HIGH, degree 0, fresh load | 28.5 / 14.6 / 122.0 | | | | | -| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, landscape | HIGH, degree 3, fresh load again | 41.1 / 16.5 / 212.3 | | | | | -| 2026-09-09 | 35b9400 unchanged | bicycle 2.6M, landscape | HIGH, degree 3 | 41.3 / 16.5 / 212.0 | | | | | -| 2026-09-09 | 35b9400 unchanged | bicycle 2.6M, landscape | HIGH, `--ei sh 0` | 28.8 / 14.7 / 122.6 | | | | | +| Date | Commit | Scene | Settings | GPU ms mean / p50 / p95 | +|---|---|---|---|---| +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 3, fresh load | 18.9 / 13.7 / 45.2 | +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 0, fresh load | 17.2 / 13.8 / 39.0 | +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 3, fresh load again | 18.8 / 13.0 / 47.8 | +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 0 in place | 19.1 / 14.0 / 49.0 | +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 3 in place | 20.8 / 13.7 / 67.1 | +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, portrait | HIGH, degree 0 in place again | 19.0 / 14.4 / 51.6 | +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, landscape | HIGH, degree 3, fresh load | 40.9 / 16.5 / 212.9 | +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, landscape | HIGH, degree 0, fresh load | 28.5 / 14.6 / 122.0 | +| 2026-09-09 | 35b9400 plus the per frame degree | bicycle 2.6M, landscape | HIGH, degree 3, fresh load again | 41.1 / 16.5 / 212.3 | +| 2026-09-09 | 35b9400 unchanged | bicycle 2.6M, landscape | HIGH, degree 3 | 41.3 / 16.5 / 212.0 | +| 2026-09-09 | 35b9400 unchanged | bicycle 2.6M, landscape | HIGH, `--ei sh 0` | 28.8 / 14.7 / 122.6 | Reading: degree 3 costs 9 percent of GPU time over degree 0 in portrait and 30 percent in landscape, where the frame is vertex bound on the canopy, and choosing the degree per frame costs nothing against the build that fixed it at load (41.1 against 41.3 in landscape). Switching the degree on the running world was checked by capture: degree 3 set in place matches a fresh degree 3 load within 0.1 of 255 on average, degree 0 differs from it by 1.4 and degree 1 by 1.6, with no world decode in the log. +## Before the log (formerly the roadmap) + +Measured on the same Mi 9, release build, at 1080x2261 before this log existed. +500k splats with `--ez benchmark true`: + +| Render scale | Splat record | GPU ms mean | GPU ms p50 | +|---|---|---|---| +| 1.0 | 48 bytes | 29 to 31 | 27 to 29 | +| 1.0 | 32 bytes | 25.2 | 23.0 | +| 1.0 | 32 bytes, encoded space blending (adopted) | 14.8 | 14.0 | +| 0.7 | 48 bytes | 18.7 | 16.7 | +| 0.5 | 48 bytes | 15.0 | 13.7 | + +Decode 192 ms, spatial reorder 120 ms, upload 50 ms, sort 11.5 ms on a background thread. + +The 2M-splat World Labs house (outdoor, bounds 50 m): + +| Change | Render scale | GPU ms mean | GPU ms p50 | GPU ms p95 | +|---|---|---|---|---| +| As decoded | 1.0 | 144 | 121 | 279 | +| Morton order | 1.0 | 48.4 | 44.9 | 77.1 | +| Frustum culled in the sort | 1.0 | 34.7 | 34.2 | 43.2 | +| Frustum culled in the sort | 0.7 | 18.1 | 17.5 | 24.4 | +| Frustum culled in the sort | 0.5 | 13.9 | 13.7 | 18.2 | +| Blending in the encoded space | 1.0 | 19.8 | 19.4 | 26.3 | +| Blending in the encoded space | 0.7 | 13.7 | 13.6 | 17.8 | + +The vertex fetch is a random gather through the sort order, so it is bound by memory latency, not bandwidth: a fetch-only shader took 160 ms per frame on the house as decoded. +Reordering the cloud along a Morton curve after decode (`splat::reorderSpatially`, 620 ms for 2M on the CPU) makes consecutive entries of the distance order hit the same cache lines and tripled the frame rate; the kitchen was already coherent and did not change. +`reorderSpatially` is a radix sort plus five array permutations: 470 ms for 2M splats on the phone's loader thread against 66 ms on an M4 Pro, so the permutation, not the sort, is what the phone pays. +After that the frame was bound per splat processed, not per pixel: render scale 0.5 still took 41 ms, and a compute pass that copied the records into draw order so the vertex shader reads sequentially changed nothing (46.3 against 46.9 ms p50). +Only the splats inside a widened frustum reach the GPU, so it sees 170k to 290k of the 2M splats during a turn: 32 ms at full resolution, 60 fps at render scale 0.5. +The distance order does not depend on where the camera looks, so turning never sorts: the sorter thread keeps the full order (44 ms, only when the camera moves) and turning runs a cull of it (10 ms for 2M on four threads, two streaming passes through a visibility bitmap). +The cull margin is 10 degrees plus the turn rate times the time a cull takes to reach the screen (50 ms at least, more when the frame or the cull is slow), so a flick of the phone finds its edges already drawn; a fixed margin showed empty edges on fast turns, and a fixed 50 ms showed them again on a 3.5M scene at 15 fps. +Display priority (-8) and big core affinity for the render, sort and cull threads changed nothing on the house: GPU p50 31.9 against 31.8 ms, sort 41 to 46 ms and cull 10 ms either way, and the big cores did not clock higher (1.9 to 2.6 GHz in both modes, out of 2.8). +The frame is bound by the GPU, so "performance modes" that touch CPU scheduling have nothing to give; the library does not expose one. +Spherical harmonics cost nothing measurable on the raccoon sample (932k splats, degree 3, 92 bytes per splat extra): p50 12.5 ms with them, 12.4 without. +Vertex fetch was an 8 ms floor at 48 bytes per splat; the 32-byte record (half float covariance, 8-bit colour and alpha, both lossless against SPZ) took 4 to 6 ms off every frame. +Vertex math costs nothing; blended fragments are the rest. +A compute prepass measured 4 ms slower on this GPU and was removed; do not resubmit it without a number from a device. +Also rejected (house p50, base 44.9 ms): one triangle per splat instead of a quad 48.1 ms, a triangle only for splats under 1.5 px 48.9 ms, the draw split into 256k instance chunks 181 ms, and a compute gather into draw order 46.3 ms. +With culling in place (house 34.2, kitchen 21.8), an octagon per splat gave house 38.9 and kitchen 19.4: it trades vertices for fragments and only pays where splats are large. +The GPU throttles above 60 C, so every number here was taken after cooling below 48 C. + ## Image quality against the reference renderer 2026-09-08, pool 3.57M with spherical harmonics degree 3, Mi 9 at preset HIGH (1080x2261, 65 degree vertical field of view, no render target), pose (-0.3, 2.3, 4.0) yaw -0.75 pitch -0.15. @@ -113,6 +198,7 @@ Sharpness is the variance of a 4-neighbour Laplacian over the same 1080x1200 cro | Mi 9 ULTRA (1.5x supersampled) | the phone's SPZ | 49.7 | | Mi 9 MEDIUM (0.7x) | the phone's SPZ | 31.6 | -Reading: the half float covariance and the radial sort cost nothing, the SPZ quantisation costs 4 percent, and the phone is a further 7 percent softer at the edges than the reference with the same data; that residual is open (the quad reaches 3 sigma against the reference's 2.83, and the 65 degree field of view spans 2261 rows instead of 2340). +Reading: the half float covariance and the radial sort cost nothing, the SPZ quantisation costs 4 percent, and the phone is a further 7 percent softer at the edges than the reference with the same data. +That residual is open: the quad reaches 3 sigma against the reference's 2.83, and the 65 degree field of view spans 2261 rows instead of 2340. The difference people see against the superspl.at page is elsewhere: that viewer is landscape with a wide field of view, while the phone in portrait shows 34 degrees across 1080 pixels, 1.4 times the magnification, so every splat is 1.4 times larger on screen. -Sh degree 3 against degree 0 at the home pose changes the image by 3.5 of 255 on average. +SH degree 3 against degree 0 at the home pose changes the image by 3.5 of 255 on average. diff --git a/docs/benchmarks/2026-09-12-mac-lod-quality.json b/docs/benchmarks/2026-09-12-mac-lod-quality.json new file mode 100644 index 0000000..843b779 --- /dev/null +++ b/docs/benchmarks/2026-09-12-mac-lod-quality.json @@ -0,0 +1,38 @@ +{ + "environment": "macOS Apple M4 Pro, native Metal integration test; not iPhone or Android", + "world": "modern_house_with_lush_landscaping_2m.spz", + "source_splats": 2000000, + "world_sha256": "fe32a5a66b7f5caaad793ab90a7ff7d7948f52ba360044170b4da04c3950ebad", + "hierarchy_sha256": "21755aaafce938066cd95d90a107f8f1d6b5ec71799cc11e933336babfde9236", + "hierarchy": "offline depth6, 2020889 nodes; NOT the Mi 9 default load-time hierarchy", + "viewport": [1206, 2622], + "settings": {"render_scale": 1, "source_sh": 0, "requested_sh": 1, "depth_bits": 32, "min_pixel_radius": 0.5, "quality_pixels": 1, "linear_blending": false, "compute_tiles": false}, + "method": "Third completed capture per configuration; exact view/projection equality verified by compare_captures.py; no resize, HUD or image alignment", + "captures": "build/sdk-harness/mac-quality-o3Skwu/ contains PNGs, hash manifests and native logs", + "comparisons": [ + { + "pose": [0, 0.73, 0, 0, 0], "requested_budget": 1200000, + "reference_drawn": 53288, "lod_selected": 509152, "lod_drawn": 53288, + "denied_refinements": 0, "mae_255": 0.0198048869, "psnr_db": 59.5994527, + "p99_channel_error_255": 1, "max_channel_error_255": 43, + "worst_32px_mae_255": 2.53092448 + }, + { + "pose": [0, 0.73, 15, 0, 0], "requested_budget": 1200000, + "reference_drawn": 1501545, "lod_selected": 1130753, "lod_drawn": 1008414, + "denied_refinements": 311, "mae_255": 9.13584052, "psnr_db": 23.1387970, + "p99_channel_error_255": 68, "max_channel_error_255": 189, + "worst_32px_mae_255": 93.29296875 + }, + { + "pose": [0, 0.73, 15, 0, 0], "requested_budget": 2200000, "effective_capacity": 2000000, + "reference_drawn": 1501545, "lod_selected": 1697773, "lod_drawn": 1501543, + "denied_refinements": 0, "mae_255": 0.00185423421, "psnr_db": 71.7928733, + "p99_channel_error_255": 0, "max_channel_error_255": 28, + "worst_32px_mae_255": 0.311523438 + } + ], + "finding": "Changing only capacity removes denied refinements and nearly restores the reference in this distant view. Budget-forced coarse parents visibly blotch; a covering cut is not an image-quality guarantee.", + "limits": "The distant view is outside the intended navigation region and the source itself has incomplete geometry there. No temporal acceptance, sustained timings, device memory validation or default quality changes follow. Concurrent Mac work invalidates timing comparisons.", + "release_gate": "blocked: distant 1.2M quality rejected internally; higher-capacity phone cost and human visual acceptance pending" +} diff --git a/docs/benchmarks/2026-09-12-mi9-vulkan.json b/docs/benchmarks/2026-09-12-mi9-vulkan.json new file mode 100644 index 0000000..aa3d2b3 --- /dev/null +++ b/docs/benchmarks/2026-09-12-mi9-vulkan.json @@ -0,0 +1,128 @@ +{ + "date": "2026-09-12", + "environment": "physical Xiaomi Mi 9", + "gpu": "Adreno 640", + "vulkan": "1.1.128", + "driver": "0x801f6000", + "android": "11 / MIUI 12.5.1", + "build": "minified Release, validation off", + "resolution": [ + 1080, + 2261 + ], + "render_scale": 1, + "sh_degree": 0, + "sort_bits": 32, + "source": "modern_house_with_lush_landscaping_2m.spz", + "source_splats": 2000000, + "source_sha256": "fe32a5a66b7f5caaad793ab90a7ff7d7948f52ba360044170b4da04c3950ebad", + "collider_sha256": "a9ab04ad30947f85000fad460f7bcf7488dfb4b995a449a4124342c4b53d5467", + "camera": [ + 0, + 0.73, + 0, + 0, + 0 + ], + "benchmark": "30-second turn in place after world-ready; gyro off; mailbox presentation; Android host frame scheduling remains", + "lod": "load-time default hierarchy, 2970075 resident nodes, selection capacity 1200000; unchanged quality settings across tail-skip A/B", + "stage_timings": "Means of ~2-second completed-frame log samples, not per-frame averages; null means not instrumented", + "process_pss_kib": 579266, + "process_rss_kib": 686792, + "android_thermal_status": 0, + "acceptance": "No perceptual metric/reference-image acceptance or sustained thermal certification. LOD parents/subpixel rejection remain approximate; no universal FPS guarantee.", + "runs": [ + { + "variant": "full_source", + "local_start": "09-12 18:06:16.349", + "frames": 578, + "fps_mean": 19.2, + "frame_mean_ms": 52, + "frame_p95_ms": 66.8, + "gpu_mean_ms": 51.9, + "gpu_p95_ms": 59.6, + "sort_sample_mean_ms": 3.147, + "cull_sample_mean_ms": null, + "select_sample_mean_ms": 0, + "sample_count": 15, + "drawn_sample_range": [ + 44983, + 125217 + ], + "selected_sample_range": [ + 2000000, + 2000000 + ], + "end_gpu_celsius": 58.3 + }, + { + "variant": "lod_before_tail_skip", + "local_start": "09-12 18:10:34.401", + "frames": 721, + "fps_mean": 24, + "frame_mean_ms": 41.7, + "frame_p95_ms": 50.1, + "gpu_mean_ms": 41.6, + "gpu_p95_ms": 50, + "sort_sample_mean_ms": 3.173, + "cull_sample_mean_ms": 19.147, + "select_sample_mean_ms": 3.64, + "sample_count": 15, + "drawn_sample_range": [ + 45310, + 121828 + ], + "selected_sample_range": [ + 50909, + 132148 + ], + "end_gpu_celsius": 58.7 + }, + { + "variant": "lod_after_tail_skip", + "local_start": "09-12 18:15:00.531", + "frames": 1243, + "fps_mean": 41.4, + "frame_mean_ms": 24.1, + "frame_p95_ms": 33.4, + "gpu_mean_ms": 23.9, + "gpu_p95_ms": 32.6, + "sort_sample_mean_ms": 3.18, + "cull_sample_mean_ms": 1.807, + "select_sample_mean_ms": 3.653, + "sample_count": 15, + "drawn_sample_range": [ + 44999, + 124889 + ], + "selected_sample_range": [ + 52411, + 136761 + ], + "end_gpu_celsius": 64.5 + }, + { + "variant": "lod_after_tail_skip", + "local_start": "09-12 18:16:05.115", + "frames": 1244, + "fps_mean": 41.4, + "frame_mean_ms": 24.1, + "frame_p95_ms": 33.4, + "gpu_mean_ms": 23.9, + "gpu_p95_ms": 32.5, + "sort_sample_mean_ms": 3.173, + "cull_sample_mean_ms": 1.813, + "select_sample_mean_ms": 3.64, + "sample_count": 15, + "drawn_sample_range": [ + 44920, + 124804 + ], + "selected_sample_range": [ + 50891, + 135651 + ], + "end_gpu_celsius": 63.3 + } + ] +} diff --git a/docs/benchmarks/2026-09-14-mi9-sdk-smoke.json b/docs/benchmarks/2026-09-14-mi9-sdk-smoke.json new file mode 100644 index 0000000..569b074 --- /dev/null +++ b/docs/benchmarks/2026-09-14-mi9-sdk-smoke.json @@ -0,0 +1,80 @@ +{ + "status": "partial_adb_usb_connection_lost", + "environment": "physical", + "device": "Xiaomi Mi 9", + "android": "11", + "gpu": "Adreno 640", + "vulkan_api": "1.1.128", + "driver": "0x801f6000", + "page_bytes": 4096, + "charging": true, + "render_extent": [1080, 2261], + "apk_sha256": "cc0861fface7d0b1235b63825c49d02bff5cbac96156ac51acba0818278c15a4", + "build": "minified Release, dev lifecycle and first-frame logging added", + "validation_layers": false, + "render_scale": 1, + "draw_sh": 0, + "depth_key_bits": 32, + "runs": [ + { + "world": "bundled kitchen_500k.spz", + "source_splats": 500000, + "lod_capacity_splats": 0, + "seconds": 30, + "frames": 1279, + "fps_mean": 42.6, + "frame_mean_ms": 23.5, + "frame_p99_ms": 33.5, + "gpu_mean_ms": 22.6, + "sort_periodic_sample_mean_ms": 1.5067, + "drawn_periodic_sample_range": [8966, 90991], + "world_ready_to_frame_ready_ms": 46 + }, + { + "world": "house_2m.spz", + "source_sha256": "fe32a5a66b7f5caaad793ab90a7ff7d7948f52ba360044170b4da04c3950ebad", + "source_splats": 2000000, + "resident_hierarchy_records": 2970075, + "lod_capacity_splats": 1200000, + "hierarchy": "load-time, not offline depth6", + "seconds": 30, + "frames": 1177, + "fps_mean": 39.2, + "frame_mean_ms": 25.5, + "frame_p99_ms": 50.1, + "gpu_mean_ms": 24.9, + "sort_periodic_sample_mean_ms": 3.16, + "drawn_periodic_sample_range": [44938, 125465], + "request_to_frame_ready_ms": 5439, + "world_ready_to_frame_ready_ms": 54 + } + ], + "observed": [ + "Data-preserving APK update succeeded", + "Uploaded and GPU-frame-ready callbacks appeared in order, once each per loaded world", + "Kitchen and house visibly rendered in inspected screenshots; no reference-image acceptance performed", + "Landscape and portrait swapchain recreation logged", + "Home/foreground round trip logged pause/resume without process restart", + "Screen lock logged pause; initial continuation required user unlock", + "Later USB connection confirmed unlocked Mi 9, world-frame-ready event and resumed draw statistics, PID 9350; USB screenshot was incomplete", + "USB transport failed before first automated background cycle; macOS retained the ADB USB interface, but adb devices became empty; one host server restart did not recover it" + ], + "pending": [ + "Rapid replacement and invalid-world recovery", + "Repeated destruction/recreation", + "Ten-minute sustained memory and thermal run", + "Debug build with Vulkan validation layers", + "16 KB runtime on a suitable device or emulator", + "React Native adapters and consumer app" + ], + "limitations": [ + "GPU timings are completed-query samples that may lag or repeat", + "Stage means and draw ranges are periodic samples, not all-frame statistics", + "This is not a matched thermal comparison against historical FPS", + "Argent UI inspection failed; an auxiliary-install prompt was not authorized; subsequent lifecycle commands used ADB", + "Gyro button text remains stale after intent changes; logs/settings, not that label, identify input mode" + ], + "local_evidence": "build/qa-mi9-20260914/", + "cleanup_pending": "Temporary stay-on-while-plugged-in was enabled for USB testing (previous value 0); connection loss prevented restoring it", + "publication_accepted": false +} diff --git a/packages/splat-core/README.md b/packages/splat-core/README.md index a728223..ad78831 100644 --- a/packages/splat-core/README.md +++ b/packages/splat-core/README.md @@ -7,10 +7,14 @@ No graphics, no platform APIs, no React Native. | Domain | Responsibility | Public headers | |---|---|---| -| Formats | Decode `.spz` and `.glb` into library-owned types in the internal frame | `splat/formats/*.h` | -| Sorting | Back to front order by distance, on a background thread | `splat/sorting/*.h` | -| Math | Column major matrices and vectors shared by every renderer | `splat/math/*.h` | +| Formats | Decode `.spz` worlds and `.glb` colliders into library-owned types in the internal frame | `splat/formats/*.h`, `splat/core/*.h` | +| Loading | Map files, decode and prepare worlds for a render thread | `splat/io/*.h`, `splat/loading/*.h` | +| Level of detail | Load-time trees, offline `.lodsplat` files and budgeted selection | `splat/lod/*.h` | +| Tiles | Tilesets, tile loading and residency-bounded streaming | `splat/tiles/*.h` | +| Sorting | Distance order on a background thread, spatial reorder and visibility planning | `splat/sorting/*.h` | +| Math | Column major matrices, vectors and frusta shared by every renderer | `splat/math/*.h` | | Navigation | Collider grid, raycast, character controller | `splat/navigation/*.h` | +| Diagnostics | Timing summaries with percentiles | `splat/diagnostics/*.h` | ## Build and test @@ -35,4 +39,5 @@ build/tools/ply2spz bicycle.ply bicycle.spz --sh 1 --keep 2 `--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. +`tools/splat_lod_build` writes an offline `.lodsplat` hierarchy; the [iOS README](../splatkit-ios/README.md) shows its options. 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/diagnostics/TimingSummary.h b/packages/splat-core/include/splat/diagnostics/TimingSummary.h index cb296f6..643286f 100644 --- a/packages/splat-core/include/splat/diagnostics/TimingSummary.h +++ b/packages/splat-core/include/splat/diagnostics/TimingSummary.h @@ -12,6 +12,7 @@ struct TimingSummary { double p50 = 0; double p95 = 0; double max = 0; + double p99 = 0; }; // Summarises `millis`; an empty input gives a zero summary. diff --git a/packages/splat-core/include/splat/loading/SplatWorldLoader.h b/packages/splat-core/include/splat/loading/SplatWorldLoader.h index 02715eb..ff49b26 100644 --- a/packages/splat-core/include/splat/loading/SplatWorldLoader.h +++ b/packages/splat-core/include/splat/loading/SplatWorldLoader.h @@ -17,8 +17,8 @@ namespace splat { // Prepares worlds and colliders for a renderer. Decoding runs on whatever thread calls // `load`, the result waits until the render thread takes it, and a newer load replaces -// one still waiting. A world is decoded, reordered spatially (ADR 0006) and, when a -// budget is set, turned into a level of detail tree (ADR 0010). Loads may run +// one still waiting. A world is decoded, reordered spatially and, when a +// budget is set, turned into a level of detail tree. Loads may run // concurrently; the last one to finish is the one taken. class SplatWorldLoader { public: @@ -27,7 +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) + std::unique_ptr tiles; // a tiled world: neither cloud nor tree int budget = 0; std::size_t sourceCount = 0; // splats in the file, what hosts count diff --git a/packages/splat-core/include/splat/sorting/SlabSorter.h b/packages/splat-core/include/splat/sorting/SlabSorter.h index 5a90a4c..99c3a1a 100644 --- a/packages/splat-core/include/splat/sorting/SlabSorter.h +++ b/packages/splat-core/include/splat/sorting/SlabSorter.h @@ -16,8 +16,8 @@ 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. +// when the camera moved or the ranges changed, and otherwise only culls the order it has. +// Indices in a result are slab indices, what the draw order buffer holds. class SlabSorter { public: struct Range { diff --git a/packages/splat-core/include/splat/sorting/SpatialOrder.h b/packages/splat-core/include/splat/sorting/SpatialOrder.h index 73a4b3f..4224972 100644 --- a/packages/splat-core/include/splat/sorting/SpatialOrder.h +++ b/packages/splat-core/include/splat/sorting/SpatialOrder.h @@ -10,7 +10,7 @@ namespace splat { // Reorders the splats of a cloud along a Morton (Z order) curve over its bounds, so that // splats close in space are close in memory. The distance sort then produces an order // whose consecutive entries mostly hit the same cache lines, which is what the GPU's -// per vertex fetch needs; docs/ROADMAP.md has the measured effect. +// per vertex fetch needs; docs/BENCHMARKS.md has the measured effect. // Every attribute array is permuted together; bounds and SH degree are unchanged. void reorderSpatially(SplatCloud& cloud); diff --git a/packages/splat-core/include/splat/sorting/VisibilityPlanner.h b/packages/splat-core/include/splat/sorting/VisibilityPlanner.h index f13932f..ef49a5d 100644 --- a/packages/splat-core/include/splat/sorting/VisibilityPlanner.h +++ b/packages/splat-core/include/splat/sorting/VisibilityPlanner.h @@ -8,7 +8,7 @@ namespace splat { // Decides, frame by frame, whether the renderer asks the sorter for a new visible set -// and how much wider than the view the cull keeps it (ADR 0009). The distance order does +// and how much wider than the view the cull keeps it. The distance order does // not depend on where the camera looks, so moving asks for a sort and turning only for a // cull of the order the sorter has; the frames in between draw whatever order they have. // diff --git a/packages/splat-core/include/splat/tiles/TileBuilder.h b/packages/splat-core/include/splat/tiles/TileBuilder.h index 7bdd3bb..8f38352 100644 --- a/packages/splat-core/include/splat/tiles/TileBuilder.h +++ b/packages/splat-core/include/splat/tiles/TileBuilder.h @@ -30,7 +30,7 @@ struct TileBuildOptions { }; // 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 +// merging, offline. 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, diff --git a/packages/splat-core/include/splat/tiles/TileScheduler.h b/packages/splat-core/include/splat/tiles/TileScheduler.h index 23f8b05..86fb1b2 100644 --- a/packages/splat-core/include/splat/tiles/TileScheduler.h +++ b/packages/splat-core/include/splat/tiles/TileScheduler.h @@ -27,7 +27,7 @@ enum class TileState : std::uint8_t { }; // 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 drop (CONTEXT.md "Streaming"). 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 diff --git a/packages/splat-core/include/splat/tiles/Tileset.h b/packages/splat-core/include/splat/tiles/Tileset.h index 8e2c0b4..3ad0541 100644 --- a/packages/splat-core/include/splat/tiles/Tileset.h +++ b/packages/splat-core/include/splat/tiles/Tileset.h @@ -24,7 +24,7 @@ struct Tile { std::vector children; // indices into Tileset::tiles }; -// The index of a tiled world (CONTEXT.md, "Tileset"; ADR 0015). +// The index of a tiled world (CONTEXT.md, "Tileset"). struct Tileset { int shDegree = 0; std::size_t splatCount = 0; // splats at level 0, the file's diff --git a/packages/splat-core/src/diagnostics/TimingSummary.cpp b/packages/splat-core/src/diagnostics/TimingSummary.cpp index 9b583dc..d9ada46 100644 --- a/packages/splat-core/src/diagnostics/TimingSummary.cpp +++ b/packages/splat-core/src/diagnostics/TimingSummary.cpp @@ -15,6 +15,7 @@ TimingSummary summarizeTimings(std::vector millis) { summary.mean = total / static_cast(n); summary.p50 = millis[n / 2]; summary.p95 = millis[(n * 95) / 100]; + summary.p99 = millis[(n * 99) / 100]; summary.max = millis.back(); return summary; } diff --git a/packages/splat-core/src/formats/SpzDecoder.cpp b/packages/splat-core/src/formats/SpzDecoder.cpp index 76de91b..0e02675 100644 --- a/packages/splat-core/src/formats/SpzDecoder.cpp +++ b/packages/splat-core/src/formats/SpzDecoder.cpp @@ -39,7 +39,7 @@ void truncatePackedSh(spz::PackedGaussians& packed, int requestedDegree) { const int target = std::clamp(requestedDegree, 0, packed.shDegree); if (target >= packed.shDegree) return; - const std::size_t pointCount = static_cast(packed.numPoints); + 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; @@ -47,8 +47,7 @@ void truncatePackedSh(spz::PackedGaussians& packed, int requestedDegree) { 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); + std::memmove(packed.sh.data() + i * newStride, packed.sh.data() + i * oldStride, newStride); } packed.sh.resize(pointCount * newStride); packed.sh.shrink_to_fit(); diff --git a/packages/splat-core/src/lod/LodFile.cpp b/packages/splat-core/src/lod/LodFile.cpp index 88c007f..bbdee1d 100644 --- a/packages/splat-core/src/lod/LodFile.cpp +++ b/packages/splat-core/src/lod/LodFile.cpp @@ -23,7 +23,7 @@ uint32_t u32(const uint8_t* p) { } float f32(const uint8_t* p) { const uint32_t bits = u32(p); - float f; + float f = 0; std::memcpy(&f, &bits, 4); return f; } @@ -31,7 +31,7 @@ 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; + uint32_t bits = 0; std::memcpy(&bits, &f, 4); putU32(p, bits); } @@ -57,7 +57,8 @@ Result validateLodTree(const LodTree& tree) { return corrupt("LOD bounds invalid"); std::vector depths(n, 0); uint32_t deepest = 0; - size_t next = 1, leaves = 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]; @@ -87,14 +88,15 @@ Result validateLodTree(const LodTree& tree) { } } if (next != n || leaves != tree.leafCount) return corrupt("LOD topology or leaf count invalid"); - for (float value : c.sh) + 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, nextLeaf = 0; + 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 || @@ -115,7 +117,9 @@ Result validateLodTree(const LodTree& tree) { 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, li = 0, subtree = 0; + 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; @@ -126,8 +130,9 @@ Result validateLodTree(const LodTree& tree) { 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) + 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))); @@ -162,8 +167,10 @@ Result decodeLodSplat(const uint8_t* data, size_t size, int maxShDegree 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), depth = u32(data + 24); - const size_t clusters = u32(data + 28), leafRefs = u32(data + 56); + 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))) @@ -241,7 +248,8 @@ Result writeLodSplat(const LodTree& tree, const std::string& path) { 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), stride = 64 + sh * 4; + 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); @@ -264,7 +272,7 @@ Result writeLodSplat(const LodTree& tree, const std::string& path) { 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; + uint32_t word = 0; std::memcpy(&word, reinterpret_cast(&selection.clusters[start + k]) + j * 4, 4); putU32(block.data() + k * 64 + j * 4, word); diff --git a/packages/splat-core/src/lod/LodSelection.cpp b/packages/splat-core/src/lod/LodSelection.cpp index 2645afa..33d5b57 100644 --- a/packages/splat-core/src/lod/LodSelection.cpp +++ b/packages/splat-core/src/lod/LodSelection.cpp @@ -76,10 +76,13 @@ LodSelectionData buildLodSelectionData(const LodTree& tree) { auto& cluster = out.clusters[k]; const float* parent = &cloud.positions[cluster.node * 3]; const float* parentCov = &cloud.covariances[cluster.node * 6]; - std::array lo, hi; + std::array lo; + std::array hi; lo.fill(std::numeric_limits::max()); hi.fill(std::numeric_limits::lowest()); - float weightedVariance = 0, weightSum = 0, coincidentTransmittance = 1; + 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) { diff --git a/packages/splat-core/src/sorting/AsyncSorter.cpp b/packages/splat-core/src/sorting/AsyncSorter.cpp index b0d6079..8ad7dd2 100644 --- a/packages/splat-core/src/sorting/AsyncSorter.cpp +++ b/packages/splat-core/src/sorting/AsyncSorter.cpp @@ -25,12 +25,12 @@ AsyncSorter::~AsyncSorter() { void AsyncSorter::request(Vec3 from) { 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; + if ((dx * dx + dy * dy + dz * dz) < 0.000001f) return; } pending_ = Request{from, std::nullopt, LodSettings{}}; @@ -107,9 +107,9 @@ void AsyncSorter::run() { const std::lock_guard lock(mutex_); std::vector recycled = finished_ ? std::move(finished_->order) : std::vector(); - finished_ = Result{std::move(visibleIndices), lastSortMillis_, cullMillis, - lastSelectMillis_, lastSelected_}; - + finished_ = Result{std::move(visibleIndices), lastSortMillis_, cullMillis, lastSelectMillis_, + lastSelected_}; + visibleIndices = std::move(recycled); } } diff --git a/packages/splat-core/tests/CMakeLists.txt b/packages/splat-core/tests/CMakeLists.txt index 1b6f78d..14a2dcf 100644 --- a/packages/splat-core/tests/CMakeLists.txt +++ b/packages/splat-core/tests/CMakeLists.txt @@ -25,7 +25,11 @@ add_executable(splat_core_tests math/FrustumTest.cpp tools/CloudEditTest.cpp ) -target_link_libraries(splat_core_tests PRIVATE splat_core spz ZLIB::ZLIB GTest::gtest_main) +# spz already reaches the link line through the static splat_core; only its headers are added +# here, so Apple's linker does not warn about the duplicate archive. +target_link_libraries(splat_core_tests PRIVATE splat_core ZLIB::ZLIB GTest::gtest_main) +target_include_directories(splat_core_tests PRIVATE + $) 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/diagnostics/TimingSummaryTest.cpp b/packages/splat-core/tests/diagnostics/TimingSummaryTest.cpp index 6ed197b..1a6ce87 100644 --- a/packages/splat-core/tests/diagnostics/TimingSummaryTest.cpp +++ b/packages/splat-core/tests/diagnostics/TimingSummaryTest.cpp @@ -12,6 +12,7 @@ TEST(TimingSummary, EmptyInputIsAllZero) { EXPECT_EQ(s.p50, 0.0); EXPECT_EQ(s.p95, 0.0); EXPECT_EQ(s.max, 0.0); + EXPECT_EQ(s.p99, 0.0); } TEST(TimingSummary, SortsBeforeTakingPercentiles) { @@ -30,6 +31,7 @@ TEST(TimingSummary, OneSampleIsEveryStatistic) { EXPECT_FLOAT_EQ(static_cast(s.p50), 16.7f); EXPECT_FLOAT_EQ(static_cast(s.p95), 16.7f); EXPECT_FLOAT_EQ(static_cast(s.max), 16.7f); + EXPECT_FLOAT_EQ(static_cast(s.p99), 16.7f); } TEST(TimingSummary, P95LeavesOutTheTopFivePercent) { @@ -41,5 +43,13 @@ TEST(TimingSummary, P95LeavesOutTheTopFivePercent) { EXPECT_FLOAT_EQ(static_cast(s.max), 100.0f); } +TEST(TimingSummary, P99PreservesRareStallsSeparatelyFromP95) { + std::vector millis(1000, 16.0f); + for (std::size_t i = 0; i < 20; ++i) millis[i] = 500.0f; + const auto s = summarizeTimings(millis); + EXPECT_DOUBLE_EQ(s.p95, 16.0); + EXPECT_DOUBLE_EQ(s.p99, 500.0); +} + } // namespace } // namespace splat diff --git a/packages/splat-core/tests/lod/LodFileTest.cpp b/packages/splat-core/tests/lod/LodFileTest.cpp index 5ee3e14..6c4215c 100644 --- a/packages/splat-core/tests/lod/LodFileTest.cpp +++ b/packages/splat-core/tests/lod/LodFileTest.cpp @@ -24,11 +24,11 @@ LodTree fixture() { } 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::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); + auto file = MappedFile::open(path_); if (!file) return {}; return {file.value().data(), file.value().data() + file.value().size()}; } @@ -51,7 +51,7 @@ TEST_F(LodFileTest, OctreeMomentMatchRetainsLeavesAndBetweenMeanCovariance) { TEST_F(LodFileTest, BinaryRoundTripPreservesAllAttributesAndCanCapSH) { const auto t = fixture(); - ASSERT_TRUE(writeLodSplat(t, path)); + ASSERT_TRUE(writeLodSplat(t, path_)); auto data = bytes(); ASSERT_EQ(data.size(), 64u + t.nodeCount() * 100); auto loaded = decodeLodSplat(data.data(), data.size()); @@ -65,15 +65,15 @@ TEST_F(LodFileTest, BinaryRoundTripPreservesAllAttributesAndCanCapSH) { ASSERT_TRUE(dc); EXPECT_EQ(dc.value().nodes.shDegree, 0); EXPECT_TRUE(dc.value().nodes.sh.empty()); - EXPECT_FALSE(writeLodSplat(t, path)); + EXPECT_FALSE(writeLodSplat(t, path_)); EXPECT_EQ(bytes(), data); // an existing asset is never overwritten } TEST_F(LodFileTest, LoaderRecognizesHierarchyWithoutBuildingOrReorderingIt) { - ASSERT_TRUE(writeLodSplat(fixture(), path)); + ASSERT_TRUE(writeLodSplat(fixture(), path_)); SplatWorldLoader loader; loader.setBudget(123); - auto report = loader.loadWorldFile(path); + auto report = loader.loadWorldFile(path_); ASSERT_TRUE(report); EXPECT_EQ(report.value().splatCount, 2u); EXPECT_EQ(report.value().nodeCount, 3u); @@ -112,7 +112,7 @@ TEST_F(LodFileTest, VersionTwoStoresInteriorBoundsErrorsAndLeafPackets) { 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)); + ASSERT_TRUE(writeLodSplat(tree, path_)); auto data = bytes(); EXPECT_EQ(data[8], 2); EXPECT_EQ(data.size(), 64u + tree.nodeCount() * 100 + 64 + 8); @@ -134,12 +134,12 @@ TEST_F(LodFileTest, VersionTwoStoresInteriorBoundsErrorsAndLeafPackets) { } TEST_F(LodFileTest, RejectsTruncationCyclesVersionAndInvalidCovarianceBeforeUse) { - ASSERT_TRUE(writeLodSplat(fixture(), path)); + ASSERT_TRUE(writeLodSplat(fixture(), path_)); const auto original = bytes(); - for (size_t length : {size_t{0}, size_t{8}, size_t{63}, original.size() - 1}) + for (const size_t length : {size_t{0}, size_t{8}, size_t{63}, original.size() - 1}) EXPECT_FALSE(decodeLodSplat(original.data(), length)); - for (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}}) { + 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; diff --git a/packages/splat-core/tools/splat-tile.cpp b/packages/splat-core/tools/splat-tile.cpp index 25f9b85..7dffd5c 100644 --- a/packages/splat-core/tools/splat-tile.cpp +++ b/packages/splat-core/tools/splat-tile.cpp @@ -1,5 +1,5 @@ // splat-tile: partitions a Gaussian splat scene into tiles with offline levels of detail, -// the form the engine streams (ADR 0015). +// the form the engine streams. // // splat-tile in.ply|in.spz out_dir [--tile N] [--sh N] [--coarsen merge|select] // [--prune-alpha T] diff --git a/packages/splat-core/tools/splat_lod_build.cpp b/packages/splat-core/tools/splat_lod_build.cpp index e461d2d..d98136c 100644 --- a/packages/splat-core/tools/splat_lod_build.cpp +++ b/packages/splat-core/tools/splat_lod_build.cpp @@ -19,7 +19,8 @@ int run(int argc, char** argv) { build.octreeDepth = 6; splat::SplatDecodeOptions decode; for (int i = 3; i < argc; i += 2) { - const std::string key(argv[i]), text(argv[i + 1]); + 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; diff --git a/packages/splatkit-engine/CMakeLists.txt b/packages/splatkit-engine/CMakeLists.txt index 29b339e..c0920da 100644 --- a/packages/splatkit-engine/CMakeLists.txt +++ b/packages/splatkit-engine/CMakeLists.txt @@ -21,6 +21,7 @@ endif() add_library(splatkit_engine STATIC src/Log.cpp src/rendering/GpuLayout.cpp + src/rendering/RenderPolicy.cpp src/camera/WalkCamera.cpp src/diagnostics/Benchmark.cpp src/diagnostics/StatsPublisher.cpp diff --git a/packages/splatkit-engine/include/splatkit/diagnostics/Benchmark.h b/packages/splatkit-engine/include/splatkit/diagnostics/Benchmark.h index a07eecc..a8875fe 100644 --- a/packages/splatkit-engine/include/splatkit/diagnostics/Benchmark.h +++ b/packages/splatkit-engine/include/splatkit/diagnostics/Benchmark.h @@ -7,11 +7,11 @@ namespace splatkit { // A reproducible capture: one full yaw turn over a fixed time from a fixed pose with the // gyroscope off, then the frame and GPU time distributions in the log. The GPU -// temperature is logged with them because Adreno throttles when hot, and every number -// taken above roughly 60 degrees is a number about the throttling. Render thread. +// temperature is context, not proof of throttling. Render thread. class Benchmark { public: - // Queues a capture of `seconds`. It begins once a world is up. + // Queues a capture of (0, 3600] finite seconds. Invalid requests cancel the capture. + // It begins once a world is up. Long captures also log 30-second windows. void start(float seconds); bool pending() const { return pending_; } bool running() const { return running_; } @@ -25,13 +25,17 @@ class Benchmark { private: void finish(); + void reportWindow(); bool pending_ = false; bool running_ = false; float seconds_ = 0; - float elapsed_ = 0; + double elapsed_ = 0; + double windowStart_ = 0; std::vector frameMillis_; std::vector gpuMillis_; + std::vector windowFrameMillis_; + std::vector windowGpuMillis_; }; } // namespace splatkit diff --git a/packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h b/packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h index 173b252..ceec908 100644 --- a/packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h +++ b/packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h @@ -59,6 +59,9 @@ class StatsPublisher { // Once per vsync, drawn or not. `sample` is called when the window closes. void onFrame(int64_t frameTimeNanos, bool rendered, const std::function& sample); + // Publishes everything but the frame rate now, leaving the window open: stats read after + // a host event then describe the frame that raised it. + void publish(const Sample& sample); void publishPose(splat::Vec3 position, float yaw, float pitch); Stats stats() const; diff --git a/packages/splatkit-engine/include/splatkit/engine/SplatEngine.h b/packages/splatkit-engine/include/splatkit/engine/SplatEngine.h index ccf3bf2..fd4efa0 100644 --- a/packages/splatkit-engine/include/splatkit/engine/SplatEngine.h +++ b/packages/splatkit-engine/include/splatkit/engine/SplatEngine.h @@ -44,6 +44,18 @@ class SplatEngine { // The platform's renderer, for the calls only its view makes: attaching a surface. SplatRenderer& renderer() { return *renderer_; } + // One capability query for the whole engine: limits, features and accepted policy. + DeviceCapabilities deviceCapabilities() const { return renderer_->deviceCapabilities(); } + + // The renderer policy currently applied to this instance. + RenderPolicy renderPolicy() const { return policy_; } + + // Re-validates and applies a requested render policy to this instance only. Invalid + // input or a failed preparation keeps the previous policy and returns accepted=false + // with that policy as `effective`. Unsupported choices fall back with one warning each. + // Render thread, before a decode. + RenderPolicyResolution setRenderPolicy(const RenderPolicy& requested); + void render(int64_t frameTimeNanos); // Decodes an SPZ world. Thread safe. Errors are reported and leave the current world. @@ -87,7 +99,7 @@ class SplatEngine { // Blend splats in linear light instead of the encoded space the training used. Richer // contrast at the cost of 40% of the frame on Adreno 640, and not what the reference - // rasterizer produces; off by default (ADR 0011). Render thread. + // rasterizer produces; off by default. Render thread. void setLinearBlending(bool linear) { renderer_->setLinearBlending(linear); } bool linearBlending() const { return renderer_->linearBlending(); } @@ -123,8 +135,12 @@ class SplatEngine { // to do with it, such as a capture. void requestRedraw() { redrawNeeded_ = true; } - // Readable from any thread. Refreshed twice a second by the render loop. + // Readable from any thread. Refreshed twice a second by the render loop, and by + // publishStats. Stats stats() const { return stats_.stats(); } + // Render thread: publishes the newest finished frame's counts and times now, for a host + // event that promises stats read after it describe that frame. + void publishStats(); // 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. @@ -161,6 +177,8 @@ class SplatEngine { EventSink events_; std::unique_ptr renderer_; + // The applied render policy; the backend's fallback until the host sets one. + RenderPolicy policy_; splat::SplatWorldLoader loader_; WalkCamera camera_; splat::VisibilityPlanner planner_; diff --git a/packages/splatkit-engine/include/splatkit/rendering/RenderPolicy.h b/packages/splatkit-engine/include/splatkit/rendering/RenderPolicy.h new file mode 100644 index 0000000..3e588e3 --- /dev/null +++ b/packages/splatkit-engine/include/splatkit/rendering/RenderPolicy.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include + +namespace splatkit { + +// The renderer-applicable slice of the host policy. Enum spellings and numeric ranges +// mirror the JS contract (packages/react-native-splatkit/src/performance.ts) so the JS +// authority and the native re-validation agree; keep them in step. A change here is a +// wire/contract change for every adapter. +enum class RasterStrategy : uint32_t { + hardware = 0, + computeTile = 1, + hybrid = 2, +}; + +// Sort depth-key width. 16 quantizes camera depth linearly; 32 keeps the float bits. +enum class SortKeyBits : uint32_t { + low16 = 16, + full32 = 32, +}; + +struct RenderPolicy { + RasterStrategy raster = RasterStrategy::hardware; + // Tile edge in pixels, one of 8, 16 or 32. + uint32_t tileSize = 16; + // Screen-space error a hierarchy node may cover before it is refined; > 0. + float lodErrorPixels = 1.0f; + // Opacity below which a splat contributes nothing; [0, 1]. Fixed at 1/255 today. + float alphaThreshold = 1.0f / 255.0f; + // Smallest source footprint, in pixels, kept for drawing; >= 0. + float subpixelThreshold = 0.5f; + bool enableFrustumCulling = true; + bool enableHiZOcclusion = false; + bool enableEarlyTermination = true; + SortKeyBits sortDepth = SortKeyBits::full32; +}; + +// What one backend can apply and the ranges it accepts. A field left false is not +// implemented: a request resolves to `fallback` and a warning, never a silent no-op. +// `fallback` is also the starting point for every resolution. +struct RenderPolicySupport { + bool raster = false; + bool tileSize = false; + bool lodErrorPixels = false; + bool alphaThreshold = false; + bool subpixelThreshold = false; + bool enableFrustumCulling = false; + bool enableHiZOcclusion = false; + bool enableEarlyTermination = false; + bool sortDepth = false; + // Accepted tile sizes as a bitmask over {8,16,32}: bit 0 = 8, bit 1 = 16, bit 2 = 32. + // Zero means all three. + uint32_t tileSizeMask = 0; + float minLodErrorPixels = 1e-6f; + float maxLodErrorPixels = 3.402823466e38f; // FLT_MAX + float minSubpixelThreshold = 0.0f; + float maxSubpixelThreshold = 3.402823466e38f; + RenderPolicy fallback; +}; + +// Resource ceilings and device features the host may rely on. Values must come from the +// native adapter, never a JS device-name lookup. +struct SplatLimits { + uint32_t maxLodCapacitySplats = 0; + uint32_t minResidencyCapacitySplats = 1; + uint32_t maxResidencyCapacitySplats = 1; +}; + +struct DeviceCapabilities { + SplatLimits limits; + // Experimental hybrid screen tiles. False when the backend has no tile path. + bool supportsComputeTiles = false; + // Conservative occlusion is not implemented anywhere yet. + bool supportsHiZOcclusion = false; + bool supportsSubgroups = false; + uint32_t maxTextureDimension = 0; + RenderPolicySupport policy; +}; + +// One warning per resolution that changed a value away from the request. +struct RenderPolicyWarning { + std::string field; + std::string message; +}; + +// The outcome of re-validating a requested policy against one backend. When `accepted` +// is false the previous policy stays in effect and `error` says why: the request was +// invalid, or it was valid but the backend could not prepare it (`preparationFailed`). +// Otherwise `effective` is safe to apply, with `warnings` describing every fallback. +struct RenderPolicyResolution { + RenderPolicy effective; + std::vector warnings; + bool accepted = true; + bool preparationFailed = false; + std::string error; +}; + +// Structural validation shared by every adapter. True when every field is in range. +bool validRenderPolicy(const RenderPolicy& policy, std::string* error); + +// Re-validates a requested policy against a backend. Unsupported fields keep `fallback` +// and add a warning; supported fields are range-checked. Invalid input rejects the whole +// request so the caller keeps the previous policy. One authority for every backend. +RenderPolicyResolution resolveRenderPolicy(const RenderPolicy& requested, + const RenderPolicySupport& support); + +// Stable spellings for logs, diagnostics and the wire. Never localized. +const char* toString(RasterStrategy strategy); +const char* toString(SortKeyBits bits); + +} // namespace splatkit diff --git a/packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h b/packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h index 155e532..f401916 100644 --- a/packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h +++ b/packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h @@ -8,6 +8,7 @@ #include "splat/lod/LodTree.h" #include "splat/math/Mat4.h" #include "splat/math/Vec3.h" +#include "splatkit/rendering/RenderPolicy.h" namespace splatkit { @@ -48,9 +49,23 @@ class SplatRenderer { // 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. + // Requests a presentation policy; the host can still schedule frames at vsync. virtual void setVsync(bool vsync) = 0; + // One capability query per backend: limits, feature flags and the policy it accepts. + // Must report honestly; a false here is what makes a policy request fall back. + virtual DeviceCapabilities deviceCapabilities() const { return {}; } + + // Applies an already-resolved policy to this instance only, never a process global. + // Only fields this backend's capabilities marked supported are honoured. Returns false + // and leaves the previous policy in place when preparation fails; the reason is set. + // Render thread only, and before any decode that follows. + virtual bool applyRenderPolicy(const RenderPolicy& policy, std::string* reason) { + (void)policy; + if (reason != nullptr) reason->clear(); + return true; + } + // 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. @@ -105,8 +120,9 @@ class SplatRenderer { // 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. + // GPU times of completed sort and visibility passes; zero when unavailable. virtual double lastSortMillis() const { return 0; } + virtual double lastCullMillis() 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; } diff --git a/packages/splatkit-engine/src/diagnostics/Benchmark.cpp b/packages/splatkit-engine/src/diagnostics/Benchmark.cpp index 4a0df6a..e6f768e 100644 --- a/packages/splatkit-engine/src/diagnostics/Benchmark.cpp +++ b/packages/splatkit-engine/src/diagnostics/Benchmark.cpp @@ -1,7 +1,9 @@ #include "splatkit/diagnostics/Benchmark.h" +#include #include #include +#include #include #include @@ -30,35 +32,69 @@ float gpuTemperatureCelsius() { } // namespace void Benchmark::start(float seconds) { - seconds_ = seconds; - pending_ = true; + pending_ = false; running_ = false; frameMillis_.clear(); gpuMillis_.clear(); - LOGI("benchmark queued: %.0f s, waiting for a world", seconds); + windowFrameMillis_.clear(); + windowGpuMillis_.clear(); + if (!std::isfinite(seconds) || seconds <= 0 || seconds > 3600) { + LOGW("benchmark rejected: duration must be finite and in (0, 3600] seconds"); + return; + } + seconds_ = seconds; + pending_ = true; + LOGI("benchmark queued: %.3f s, waiting for a world", seconds); } void Benchmark::begin(uint32_t splatCount) { + if (!pending_) return; pending_ = false; running_ = true; elapsed_ = 0; + windowStart_ = 0; frameMillis_.reserve(static_cast(seconds_ * 120)); gpuMillis_.reserve(static_cast(seconds_ * 120)); - LOGI("benchmark started: %u splats, one turn over %.0f s, gpu %.1f C", splatCount, seconds_, + LOGI("benchmark started: %u splats, one turn over %.3f s, gpu %.1f C", splatCount, seconds_, gpuTemperatureCelsius()); } float Benchmark::step(float dt, double gpuMillis) { - if (!running_) return 0.0f; + if (!running_ || !std::isfinite(dt) || dt <= 0 || + dt > std::numeric_limits::max() / 1000.0f) + return 0.0f; + const double turnSeconds = std::min(static_cast(dt), seconds_ - elapsed_); elapsed_ += dt; frameMillis_.push_back(dt * 1000.0f); - gpuMillis_.push_back(static_cast(gpuMillis)); + windowFrameMillis_.push_back(dt * 1000.0f); + // Zero means unavailable, not an instantaneous GPU frame. Backends expose the + // latest completed query, so these are samples, not one-to-one submission timings. + if (std::isfinite(gpuMillis) && gpuMillis > 0 && gpuMillis <= std::numeric_limits::max()) { + gpuMillis_.push_back(static_cast(gpuMillis)); + windowGpuMillis_.push_back(static_cast(gpuMillis)); + } + if (elapsed_ - windowStart_ >= 30.0) reportWindow(); if (elapsed_ >= seconds_) finish(); - return 2.0f * static_cast(M_PI) * dt / seconds_; + return static_cast(2.0 * M_PI * turnSeconds / seconds_); +} + +void Benchmark::reportWindow() { + if (windowFrameMillis_.empty()) return; + const auto frame = splat::summarizeTimings(std::move(windowFrameMillis_)); + const auto gpu = splat::summarizeTimings(std::move(windowGpuMillis_)); + windowFrameMillis_.clear(); + windowGpuMillis_.clear(); + LOGI( + "benchmark window: start %.1f end %.1f, %zu frames, frame mean %.1f p95 %.1f p99 %.1f " + "max %.1f, gpu mean %.1f p95 %.1f p99 %.1f, gpu samples %zu, gpu %.1f C", + windowStart_, elapsed_, frame.count, frame.mean, frame.p95, frame.p99, frame.max, gpu.mean, + gpu.p95, gpu.p99, gpu.count, gpuTemperatureCelsius()); + windowStart_ = elapsed_; } void Benchmark::finish() { running_ = false; + reportWindow(); const splat::TimingSummary frame = splat::summarizeTimings(std::move(frameMillis_)); const splat::TimingSummary gpu = splat::summarizeTimings(std::move(gpuMillis_)); frameMillis_.clear(); @@ -68,6 +104,8 @@ void Benchmark::finish() { frame.count, 1000.0 / frame.mean, frame.mean, frame.p50, frame.p95, frame.max); LOGI("benchmark gpu ms: mean %.1f p50 %.1f p95 %.1f max %.1f, gpu %.1f C at the end", gpu.mean, gpu.p50, gpu.p95, gpu.max, gpuTemperatureCelsius()); + LOGI("benchmark tails: frame p99 %.1f, gpu p99 %.1f, gpu samples %zu of %zu", frame.p99, gpu.p99, + gpu.count, frame.count); } } // namespace splatkit diff --git a/packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp b/packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp index f6c6cc9..9d530f3 100644 --- a/packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp +++ b/packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp @@ -22,15 +22,7 @@ void StatsPublisher::onFrame(int64_t frameTimeNanos, bool rendered, const auto fps = static_cast(windowFrames_ * 1e9 / static_cast(elapsed)); fps_.store(fps, kRelaxed); frameMillis_.store(fps > 0.0f ? 1000.0f / fps : 0.0f, kRelaxed); - 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); + publish(s); // An idle scene logs once, not every two seconds. const bool idle = windowFrames_ == 0; @@ -48,6 +40,18 @@ void StatsPublisher::onFrame(int64_t frameTimeNanos, bool rendered, s.motion ? ", gyro" : ""); } +void StatsPublisher::publish(const Sample& s) { + 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); +} + void StatsPublisher::publishPose(splat::Vec3 position, float yaw, float pitch) { pose_[0].store(position.x, kRelaxed); pose_[1].store(position.y, kRelaxed); diff --git a/packages/splatkit-engine/src/engine/SplatEngine.cpp b/packages/splatkit-engine/src/engine/SplatEngine.cpp index bb40591..6054dfc 100644 --- a/packages/splatkit-engine/src/engine/SplatEngine.cpp +++ b/packages/splatkit-engine/src/engine/SplatEngine.cpp @@ -27,8 +27,32 @@ double millisSince(Clock::time_point start) { } // namespace -SplatEngine::SplatEngine(std::unique_ptr renderer) - : renderer_(std::move(renderer)) {} +SplatEngine::SplatEngine(std::unique_ptr renderer) : renderer_(std::move(renderer)) { + // The backend's fallback is the policy in effect until the host sets one. + policy_ = renderer_->deviceCapabilities().policy.fallback; +} + +RenderPolicyResolution SplatEngine::setRenderPolicy(const RenderPolicy& requested) { + RenderPolicyResolution resolution = + resolveRenderPolicy(requested, renderer_->deviceCapabilities().policy); + if (!resolution.accepted) { + // Report the policy still in effect, not the backend fallback the resolution started from. + resolution.effective = policy_; + return resolution; + } + std::string reason; + if (!renderer_->applyRenderPolicy(resolution.effective, &reason)) { + resolution.accepted = false; + resolution.preparationFailed = true; + resolution.error = reason.empty() ? "renderer refused the policy" : reason; + resolution.warnings.clear(); + resolution.effective = policy_; + return resolution; + } + policy_ = resolution.effective; + redrawNeeded_ = true; + return resolution; +} void SplatEngine::setMaxShDegree(int degree) { degree = std::clamp(degree, 0, kMaxShDegree); @@ -243,7 +267,7 @@ void SplatEngine::streamTiles(const FrameCamera& camera, float pixelScale, void SplatEngine::takeSortResult() { if (gpuSort_) { lastSort_.sortMillis = renderer_->lastSortMillis(); - lastSort_.cullMillis = 0; + lastSort_.cullMillis = renderer_->lastCullMillis(); lastSort_.selectMillis = renderer_->lastSelectMillis(); lastSort_.selected = streamer_ ? streamer_->drawnSplats() : sourceCount_; drawCount_ = renderer_->lastDrawCount(); @@ -272,7 +296,8 @@ void SplatEngine::takeSortResult() { void SplatEngine::startBenchmark(float seconds) { benchmark_.start(seconds); - renderer_->setVsync(false); // so frame times are not vsync multiples + // Requests non-FIFO presentation; host callbacks can still be vsync-paced. + if (benchmark_.pending()) renderer_->setVsync(false); } void SplatEngine::driveBenchmark(float dt, const GpuWorldInfo& world) { @@ -285,6 +310,12 @@ void SplatEngine::driveBenchmark(float dt, const GpuWorldInfo& world) { if (benchmark_.running()) camera_.look(benchmark_.step(dt, renderer_->lastGpuMillis()), 0.0f); } +void SplatEngine::publishStats() { + // Reading GPU results consumes nothing; a CPU sort result stays for the next frame. + if (gpuSort_) takeSortResult(); + stats_.publish(sample()); +} + StatsPublisher::Sample SplatEngine::sample() const { StatsPublisher::Sample s; s.gpuMillis = renderer_->lastGpuMillis(); @@ -312,7 +343,7 @@ float SplatEngine::frameSeconds(int64_t frameTimeNanos) { const float dt = lastFrameNanos_ == 0 ? 0.0f : static_cast(frameTimeNanos - lastFrameNanos_) * 1e-9f; lastFrameNanos_ = frameTimeNanos; - return std::min(dt, kMaxFrameSeconds); + return std::max(dt, 0.0f); } // Every vsync steps the camera and the sorter, but the GPU only draws when something @@ -325,8 +356,9 @@ void SplatEngine::render(int64_t frameTimeNanos) { const std::optional world = renderer_->world(); std::optional camera; if (world) { - const float dt = frameSeconds(frameTimeNanos); - driveBenchmark(dt, *world); + const float wallSeconds = frameSeconds(frameTimeNanos); + driveBenchmark(wallSeconds, *world); + const float dt = std::min(wallSeconds, kMaxFrameSeconds); camera_.update(dt); camera = frameCamera(extent); publishPose(); diff --git a/packages/splatkit-engine/src/rendering/RenderPolicy.cpp b/packages/splatkit-engine/src/rendering/RenderPolicy.cpp new file mode 100644 index 0000000..a0dd160 --- /dev/null +++ b/packages/splatkit-engine/src/rendering/RenderPolicy.cpp @@ -0,0 +1,162 @@ +#include "splatkit/rendering/RenderPolicy.h" + +#include +#include + +namespace splatkit { +namespace { + +bool finitePositive(float value) { + return std::isfinite(value) && value > 0.0f; +} + +bool acceptedTileSize(uint32_t mask, uint32_t size) { + if (mask == 0) return size == 8 || size == 16 || size == 32; + const uint32_t bit = size == 8 ? 1u : size == 16 ? 2u : size == 32 ? 4u : 0u; + return bit != 0 && (mask & bit) != 0; +} + +std::string number(float value) { + std::string text = std::to_string(value); + // Trim the noisy trailing zeros std::to_string leaves on float values. + if (text.find('.') != std::string::npos) { + text.erase(text.find_last_not_of('0') + 1); + if (!text.empty() && text.back() == '.') text.pop_back(); + } + return text; +} + +} // namespace + +const char* toString(RasterStrategy strategy) { + switch (strategy) { + case RasterStrategy::hardware: + return "hardware"; + case RasterStrategy::computeTile: + return "computeTile"; + case RasterStrategy::hybrid: + return "hybrid"; + } + return "unknown"; +} + +const char* toString(SortKeyBits bits) { + return bits == SortKeyBits::low16 ? "16" : "32"; +} + +bool validRenderPolicy(const RenderPolicy& policy, std::string* error) { + const auto reject = [&](const std::string& message) { + if (error != nullptr) *error = message; + return false; + }; + if (static_cast(policy.raster) > static_cast(RasterStrategy::hybrid)) { + return reject("raster must be hardware, computeTile or hybrid"); + } + if (policy.tileSize != 8 && policy.tileSize != 16 && policy.tileSize != 32) { + return reject("tileSize must be 8, 16 or 32"); + } + if (!finitePositive(policy.lodErrorPixels)) { + return reject("lodErrorPixels must be finite and positive"); + } + if (!std::isfinite(policy.alphaThreshold) || policy.alphaThreshold < 0.0f || + policy.alphaThreshold > 1.0f) { + return reject("alphaThreshold must be finite and in [0, 1]"); + } + if (!std::isfinite(policy.subpixelThreshold) || policy.subpixelThreshold < 0.0f) { + return reject("subpixelThreshold must be finite and non-negative"); + } + if (policy.sortDepth != SortKeyBits::low16 && policy.sortDepth != SortKeyBits::full32) { + return reject("sortDepth must be 16 or 32"); + } + return true; +} + +RenderPolicyResolution resolveRenderPolicy(const RenderPolicy& requested, + const RenderPolicySupport& support) { + RenderPolicyResolution result; + result.effective = support.fallback; + std::string error; + if (!validRenderPolicy(requested, &error)) { + result.accepted = false; + result.error = std::move(error); + return result; + } + const auto warn = [&result](const char* field, std::string message) { + result.warnings.push_back({field, std::move(message)}); + }; + + if (support.raster) { + result.effective.raster = requested.raster; + } else if (requested.raster != result.effective.raster) { + warn("raster", std::string("raster ") + toString(requested.raster) + + " is not implemented on this backend; using " + + toString(result.effective.raster)); + } + + if (support.tileSize && acceptedTileSize(support.tileSizeMask, requested.tileSize)) { + result.effective.tileSize = requested.tileSize; + } else if (requested.tileSize != result.effective.tileSize) { + warn("tileSize", "tileSize " + std::to_string(requested.tileSize) + + " is not available on this backend; using " + + std::to_string(result.effective.tileSize)); + } + + if (support.lodErrorPixels) { + const float clamped = + std::clamp(requested.lodErrorPixels, support.minLodErrorPixels, support.maxLodErrorPixels); + if (clamped != requested.lodErrorPixels) { + warn("lodErrorPixels", + "lodErrorPixels was clamped to the backend range; using " + number(clamped)); + } + result.effective.lodErrorPixels = clamped; + } else if (requested.lodErrorPixels != result.effective.lodErrorPixels) { + warn("lodErrorPixels", "lodErrorPixels is not configurable on this backend; using " + + number(result.effective.lodErrorPixels)); + } + + if (support.alphaThreshold) { + result.effective.alphaThreshold = requested.alphaThreshold; + } else if (requested.alphaThreshold != result.effective.alphaThreshold) { + warn("alphaThreshold", "alphaThreshold is fixed at the shader cutoff on this backend; using " + + number(result.effective.alphaThreshold)); + } + + if (support.subpixelThreshold) { + const float clamped = std::clamp(requested.subpixelThreshold, support.minSubpixelThreshold, + support.maxSubpixelThreshold); + if (clamped != requested.subpixelThreshold) { + warn("subpixelThreshold", + "subpixelThreshold was clamped to the backend range; using " + number(clamped)); + } + result.effective.subpixelThreshold = clamped; + } else if (requested.subpixelThreshold != result.effective.subpixelThreshold) { + warn("subpixelThreshold", "subpixelThreshold is not configurable on this backend; using " + + number(result.effective.subpixelThreshold)); + } + + const auto flag = [&](bool supported, bool requestedValue, bool& target, const char* field, + const char* label) { + if (supported) { + target = requestedValue; + } else if (requestedValue != target) { + warn(field, std::string(label) + " is not implemented on this backend"); + } + }; + flag(support.enableFrustumCulling, requested.enableFrustumCulling, + result.effective.enableFrustumCulling, "enableFrustumCulling", "frustum culling"); + flag(support.enableHiZOcclusion, requested.enableHiZOcclusion, + result.effective.enableHiZOcclusion, "enableHiZOcclusion", "Hi-Z occlusion"); + flag(support.enableEarlyTermination, requested.enableEarlyTermination, + result.effective.enableEarlyTermination, "enableEarlyTermination", "early termination"); + + if (support.sortDepth) { + result.effective.sortDepth = requested.sortDepth; + } else if (requested.sortDepth != result.effective.sortDepth) { + warn("sortDepth", std::string("sortDepth ") + toString(requested.sortDepth) + + " is not available on this backend; using " + + toString(result.effective.sortDepth)); + } + return result; +} + +} // namespace splatkit diff --git a/packages/splatkit-engine/tests/CMakeLists.txt b/packages/splatkit-engine/tests/CMakeLists.txt index de3aa03..7465a4d 100644 --- a/packages/splatkit-engine/tests/CMakeLists.txt +++ b/packages/splatkit-engine/tests/CMakeLists.txt @@ -1,9 +1,17 @@ add_executable(splatkit_engine_tests camera/WalkCameraTest.cpp + diagnostics/BenchmarkTest.cpp + diagnostics/StatsPublisherTest.cpp + engine/SplatEnginePolicyTest.cpp rendering/GpuLayoutTest.cpp rendering/FrameOrderTest.cpp + rendering/RenderPolicyTest.cpp ) -target_link_libraries(splatkit_engine_tests PRIVATE splatkit_engine spz GTest::gtest_main) +# spz already reaches the link line through the static splat_core; only its headers are added +# here, so Apple's linker does not warn about the duplicate archive. +target_link_libraries(splatkit_engine_tests PRIVATE splatkit_engine GTest::gtest_main) +target_include_directories(splatkit_engine_tests PRIVATE + $) target_compile_options(splatkit_engine_tests PRIVATE $<$:-Wall -Wextra -Wpedantic -Werror> ) diff --git a/packages/splatkit-engine/tests/diagnostics/BenchmarkTest.cpp b/packages/splatkit-engine/tests/diagnostics/BenchmarkTest.cpp new file mode 100644 index 0000000..1aa5604 --- /dev/null +++ b/packages/splatkit-engine/tests/diagnostics/BenchmarkTest.cpp @@ -0,0 +1,50 @@ +#include "splatkit/diagnostics/Benchmark.h" + +#include + +#include +#include + +namespace splatkit { +namespace { + +TEST(Benchmark, RejectsInvalidDurationsBeforeReservingMemory) { + Benchmark benchmark; + for (const float seconds : {0.0f, -1.0f, 3601.0f, std::numeric_limits::infinity(), + std::numeric_limits::quiet_NaN()}) { + benchmark.start(seconds); + EXPECT_FALSE(benchmark.pending()); + EXPECT_FALSE(benchmark.running()); + } +} + +TEST(Benchmark, ReportsWindowsAndTailsWithoutTreatingUnavailableGpuTimesAsZeroCost) { + Benchmark benchmark; + benchmark.start(31.0f); + benchmark.begin(500); + testing::internal::CaptureStderr(); + for (int i = 0; i < 30; ++i) benchmark.step(1.0f, i == 0 ? 20.0 : 0.0); + benchmark.step(1.0f, std::numeric_limits::quiet_NaN()); + const auto log = testing::internal::GetCapturedStderr(); + EXPECT_FALSE(benchmark.running()); + EXPECT_NE(log.find("benchmark window: start 0.0 end 30.0"), std::string::npos) << log; + EXPECT_NE(log.find("benchmark window: start 30.0 end 31.0"), std::string::npos) << log; + EXPECT_NE(log.find("benchmark gpu ms: mean 20.0"), std::string::npos) << log; + EXPECT_NE(log.find("gpu samples 1 of 31"), std::string::npos) << log; + EXPECT_NE(log.find("frame p99 1000.0"), std::string::npos) << log; +} + +TEST(Benchmark, IgnoresInvalidFrameIntervalsAndCapsTheFinalRotation) { + Benchmark benchmark; + benchmark.start(1.0f); + benchmark.begin(1); + EXPECT_FLOAT_EQ(benchmark.step(0.0f, 1.0), 0.0f); + EXPECT_FLOAT_EQ(benchmark.step(-1.0f, 1.0), 0.0f); + EXPECT_FLOAT_EQ(benchmark.step(std::numeric_limits::infinity(), 1.0), 0.0f); + EXPECT_TRUE(benchmark.running()); + EXPECT_NEAR(benchmark.step(5.0f, 1.0), 6.2831853f, 1e-5f); + EXPECT_FALSE(benchmark.running()); +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-engine/tests/diagnostics/StatsPublisherTest.cpp b/packages/splatkit-engine/tests/diagnostics/StatsPublisherTest.cpp new file mode 100644 index 0000000..e05abdf --- /dev/null +++ b/packages/splatkit-engine/tests/diagnostics/StatsPublisherTest.cpp @@ -0,0 +1,47 @@ +#include + +#include "splatkit/diagnostics/StatsPublisher.h" + +namespace splatkit { +namespace { + +constexpr int64_t kHalfSecond = 500'000'000LL; + +StatsPublisher::Sample counts(uint32_t drawn, uint32_t source, double gpuMillis) { + StatsPublisher::Sample s; + s.drawn = drawn; + s.sourceSplats = source; + s.gpuMillis = gpuMillis; + return s; +} + +TEST(StatsPublisher, WindowCloseIsTheOnlyRegularRefresh) { + StatsPublisher stats; + stats.onFrame(kHalfSecond, true, [] { return counts(5, 9, 2); }); + EXPECT_EQ(stats.stats().drawnSplatCount, 0u); + stats.onFrame(2 * kHalfSecond, true, [] { return counts(5, 9, 2); }); + EXPECT_EQ(stats.stats().drawnSplatCount, 5u); + EXPECT_FLOAT_EQ(stats.stats().fps, 4.0f); +} + +// A host event such as a world's first finished frame must be able to promise that +// stats read afterwards describe it, without waiting for the window to close. +TEST(StatsPublisher, PublishRefreshesCountsWithoutClosingTheWindow) { + StatsPublisher stats; + stats.onFrame(kHalfSecond, true, [] { return counts(1, 1, 1); }); + stats.publish(counts(7, 9, 3)); + Stats now = stats.stats(); + EXPECT_EQ(now.drawnSplatCount, 7u); + EXPECT_EQ(now.splatCount, 9u); + EXPECT_FLOAT_EQ(now.gpuMillis, 3.0f); + EXPECT_FLOAT_EQ(now.fps, 0.0f); + + // The window still spans both rendered frames. + stats.onFrame(2 * kHalfSecond, true, [] { return counts(8, 9, 4); }); + now = stats.stats(); + EXPECT_EQ(now.drawnSplatCount, 8u); + EXPECT_FLOAT_EQ(now.fps, 4.0f); +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-engine/tests/engine/SplatEnginePolicyTest.cpp b/packages/splatkit-engine/tests/engine/SplatEnginePolicyTest.cpp new file mode 100644 index 0000000..c8d199d --- /dev/null +++ b/packages/splatkit-engine/tests/engine/SplatEnginePolicyTest.cpp @@ -0,0 +1,159 @@ +#include + +#include "splatkit/engine/SplatEngine.h" + +namespace splatkit { +namespace { + +// A SplatRenderer that only records the policy the engine resolves and pushes to it. +class PolicyRecordingRenderer final : public SplatRenderer { + public: + 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 {100, 100}; } + uint32_t generation() const override { return 0; } + bool uploadWorld(const splat::SplatCloud&, int) override { 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 std::nullopt; } + bool draw(const Frame&) override { return false; } + double lastGpuMillis() const override { return 0; } + const std::string& deviceDescription() const override { return description_; } + + DeviceCapabilities deviceCapabilities() const override { return capabilities; } + bool applyRenderPolicy(const RenderPolicy& policy, std::string* reason) override { + if (refuse) { + if (reason != nullptr) *reason = "backend refused"; + return false; + } + ++appliedCount; + applied = policy; + return true; + } + + DeviceCapabilities capabilities; + bool refuse = false; + uint32_t appliedCount = 0; + RenderPolicy applied; + + private: + std::string description_ = "policy-test"; +}; + +TEST(SplatEnginePolicy, EffectivePolicyReachesTheRenderer) { + auto renderer = std::make_unique(); + PolicyRecordingRenderer* observed = renderer.get(); + DeviceCapabilities capabilities; + capabilities.policy.sortDepth = true; + capabilities.policy.subpixelThreshold = true; + capabilities.policy.maxSubpixelThreshold = 4.0f; + observed->capabilities = capabilities; + + SplatEngine engine(std::move(renderer)); + RenderPolicy requested = engine.renderPolicy(); + requested.sortDepth = SortKeyBits::low16; + requested.subpixelThreshold = 2.0f; + + const RenderPolicyResolution resolved = engine.setRenderPolicy(requested); + ASSERT_TRUE(resolved.accepted) << resolved.error; + EXPECT_EQ(resolved.effective.sortDepth, SortKeyBits::low16); + EXPECT_FLOAT_EQ(resolved.effective.subpixelThreshold, 2.0f); + EXPECT_EQ(observed->appliedCount, 1u); + EXPECT_EQ(observed->applied.sortDepth, SortKeyBits::low16); + EXPECT_FLOAT_EQ(observed->applied.subpixelThreshold, 2.0f); + EXPECT_EQ(engine.renderPolicy().sortDepth, SortKeyBits::low16); +} + +TEST(SplatEnginePolicy, UnsupportedChoiceFallsBackBeforeTheRendererSeesIt) { + auto renderer = std::make_unique(); + PolicyRecordingRenderer* observed = renderer.get(); + DeviceCapabilities capabilities; + capabilities.policy.raster = false; + capabilities.policy.fallback.raster = RasterStrategy::hardware; + observed->capabilities = capabilities; + + SplatEngine engine(std::move(renderer)); + RenderPolicy requested = engine.renderPolicy(); + requested.raster = RasterStrategy::hybrid; + + const RenderPolicyResolution resolved = engine.setRenderPolicy(requested); + ASSERT_TRUE(resolved.accepted) << resolved.error; + EXPECT_EQ(resolved.effective.raster, RasterStrategy::hardware); + EXPECT_FALSE(resolved.warnings.empty()); + EXPECT_EQ(observed->applied.raster, RasterStrategy::hardware); + EXPECT_EQ(engine.renderPolicy().raster, RasterStrategy::hardware); +} + +TEST(SplatEnginePolicy, InvalidRequestKeepsThePreviouslyAppliedPolicy) { + auto renderer = std::make_unique(); + PolicyRecordingRenderer* observed = renderer.get(); + DeviceCapabilities capabilities; + capabilities.policy.sortDepth = true; + observed->capabilities = capabilities; + + SplatEngine engine(std::move(renderer)); + RenderPolicy good = engine.renderPolicy(); + good.sortDepth = SortKeyBits::low16; + ASSERT_TRUE(engine.setRenderPolicy(good).accepted); + ASSERT_EQ(observed->appliedCount, 1u); + + RenderPolicy bad = good; + bad.tileSize = 12; + const RenderPolicyResolution rejected = engine.setRenderPolicy(bad); + EXPECT_FALSE(rejected.accepted); + EXPECT_FALSE(rejected.preparationFailed); + EXPECT_EQ(observed->appliedCount, 1u); + EXPECT_EQ(engine.renderPolicy().sortDepth, SortKeyBits::low16); + // The reported policy is the one still in effect, not the backend fallback. + EXPECT_EQ(rejected.effective.sortDepth, SortKeyBits::low16); + EXPECT_EQ(rejected.effective.tileSize, good.tileSize); +} + +TEST(SplatEnginePolicy, RendererRefusalKeepsThePreviousPolicy) { + auto renderer = std::make_unique(); + PolicyRecordingRenderer* observed = renderer.get(); + DeviceCapabilities capabilities; + capabilities.policy.sortDepth = true; + observed->capabilities = capabilities; + + SplatEngine engine(std::move(renderer)); + RenderPolicy first = engine.renderPolicy(); + first.sortDepth = SortKeyBits::low16; + ASSERT_TRUE(engine.setRenderPolicy(first).accepted); + + observed->refuse = true; + RenderPolicy second = first; + second.sortDepth = SortKeyBits::full32; + const RenderPolicyResolution refused = engine.setRenderPolicy(second); + EXPECT_FALSE(refused.accepted); + EXPECT_TRUE(refused.preparationFailed); + EXPECT_EQ(refused.error, "backend refused"); + EXPECT_EQ(refused.effective.sortDepth, SortKeyBits::low16); + EXPECT_EQ(engine.renderPolicy().sortDepth, SortKeyBits::low16); +} + +TEST(SplatEnginePolicy, CapabilitiesComeFromTheRenderer) { + auto renderer = std::make_unique(); + PolicyRecordingRenderer* observed = renderer.get(); + DeviceCapabilities capabilities; + capabilities.limits.maxLodCapacitySplats = 2'200'000; + capabilities.limits.minResidencyCapacitySplats = 100'000; + capabilities.limits.maxResidencyCapacitySplats = 32'000'000; + capabilities.supportsSubgroups = true; + capabilities.maxTextureDimension = 16'384; + observed->capabilities = capabilities; + + const SplatEngine engine(std::move(renderer)); + const DeviceCapabilities reported = engine.deviceCapabilities(); + EXPECT_EQ(reported.limits.maxLodCapacitySplats, 2'200'000u); + EXPECT_EQ(reported.limits.maxResidencyCapacitySplats, 32'000'000u); + EXPECT_TRUE(reported.supportsSubgroups); + EXPECT_EQ(reported.maxTextureDimension, 16'384u); +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-engine/tests/rendering/RenderPolicyTest.cpp b/packages/splatkit-engine/tests/rendering/RenderPolicyTest.cpp new file mode 100644 index 0000000..6049751 --- /dev/null +++ b/packages/splatkit-engine/tests/rendering/RenderPolicyTest.cpp @@ -0,0 +1,120 @@ +#include + +#include "splatkit/rendering/RenderPolicy.h" + +namespace splatkit { +namespace { + +RenderPolicySupport supportWith(RenderPolicy fallback) { + RenderPolicySupport support; + support.fallback = fallback; + return support; +} + +TEST(RenderPolicy, CopiesSupportedFieldsIntoTheEffectivePolicy) { + RenderPolicy fallback; + fallback.sortDepth = SortKeyBits::full32; + RenderPolicySupport support = supportWith(fallback); + support.sortDepth = true; + support.subpixelThreshold = true; + support.minSubpixelThreshold = 0.0f; + support.maxSubpixelThreshold = 4.0f; + + RenderPolicy requested; + requested.sortDepth = SortKeyBits::low16; + requested.subpixelThreshold = 2.5f; + + const RenderPolicyResolution resolved = resolveRenderPolicy(requested, support); + ASSERT_TRUE(resolved.accepted) << resolved.error; + EXPECT_EQ(resolved.effective.sortDepth, SortKeyBits::low16); + EXPECT_FLOAT_EQ(resolved.effective.subpixelThreshold, 2.5f); + EXPECT_TRUE(resolved.warnings.empty()); +} + +TEST(RenderPolicy, UnsupportedValidChoiceFallsBackWithOneWarningPerField) { + RenderPolicy fallback; + fallback.raster = RasterStrategy::hardware; + fallback.alphaThreshold = 1.0f / 255.0f; + RenderPolicySupport support = supportWith(fallback); + support.raster = false; + support.alphaThreshold = false; + + RenderPolicy requested = fallback; + requested.raster = RasterStrategy::hybrid; + requested.alphaThreshold = 0.5f; + + const RenderPolicyResolution resolved = resolveRenderPolicy(requested, support); + ASSERT_TRUE(resolved.accepted) << resolved.error; + EXPECT_EQ(resolved.effective.raster, RasterStrategy::hardware); + EXPECT_FLOAT_EQ(resolved.effective.alphaThreshold, 1.0f / 255.0f); + ASSERT_EQ(resolved.warnings.size(), 2u); + EXPECT_EQ(resolved.warnings[0].field, "raster"); + EXPECT_EQ(resolved.warnings[1].field, "alphaThreshold"); +} + +TEST(RenderPolicy, ARequestEqualToTheFallbackWarnsAboutNothing) { + const RenderPolicy fallback{}; + const RenderPolicySupport support = supportWith(fallback); + const RenderPolicyResolution resolved = resolveRenderPolicy(fallback, support); + ASSERT_TRUE(resolved.accepted) << resolved.error; + EXPECT_TRUE(resolved.warnings.empty()); +} + +TEST(RenderPolicy, InvalidInputRejectsTheWholeRequest) { + RenderPolicySupport support = supportWith(RenderPolicy{}); + support.sortDepth = true; + + RenderPolicy requested; + requested.tileSize = 12; + const RenderPolicyResolution invalidTile = resolveRenderPolicy(requested, support); + EXPECT_FALSE(invalidTile.accepted); + EXPECT_FALSE(invalidTile.error.empty()); + + requested = RenderPolicy{}; + requested.alphaThreshold = 2.0f; + EXPECT_FALSE(resolveRenderPolicy(requested, support).accepted); + + requested = RenderPolicy{}; + requested.lodErrorPixels = 0.0f; + EXPECT_FALSE(resolveRenderPolicy(requested, support).accepted); + + requested = RenderPolicy{}; + requested.subpixelThreshold = -1.0f; + EXPECT_FALSE(resolveRenderPolicy(requested, support).accepted); +} + +TEST(RenderPolicy, BackendTileMaskRejectsAnOtherwiseValidSize) { + RenderPolicy fallback; + fallback.tileSize = 16; + RenderPolicySupport support = supportWith(fallback); + support.tileSize = true; + support.tileSizeMask = 0x2 | 0x4; // 16 and 32 only + + RenderPolicy requested = fallback; + requested.tileSize = 8; + const RenderPolicyResolution resolved = resolveRenderPolicy(requested, support); + ASSERT_TRUE(resolved.accepted) << resolved.error; + EXPECT_EQ(resolved.effective.tileSize, 16u); + ASSERT_EQ(resolved.warnings.size(), 1u); + EXPECT_EQ(resolved.warnings[0].field, "tileSize"); +} + +TEST(RenderPolicy, BackendRangesClampSupportedFloats) { + RenderPolicy fallback; + fallback.lodErrorPixels = 1.0f; + RenderPolicySupport support = supportWith(fallback); + support.lodErrorPixels = true; + support.minLodErrorPixels = 0.5f; + support.maxLodErrorPixels = 2.0f; + + RenderPolicy requested = fallback; + requested.lodErrorPixels = 9.0f; + const RenderPolicyResolution resolved = resolveRenderPolicy(requested, support); + ASSERT_TRUE(resolved.accepted) << resolved.error; + EXPECT_FLOAT_EQ(resolved.effective.lodErrorPixels, 2.0f); + ASSERT_EQ(resolved.warnings.size(), 1u); + EXPECT_EQ(resolved.warnings[0].field, "lodErrorPixels"); +} + +} // namespace +} // namespace splatkit diff --git a/packages/splatkit-ios/README.md b/packages/splatkit-ios/README.md index a173d6f..7abfd85 100644 --- a/packages/splatkit-ios/README.md +++ b/packages/splatkit-ios/README.md @@ -6,10 +6,9 @@ Requires iOS 17+ and Apple GPU family 7+ (A14/M1+); unsupported GPUs report unav ## 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: +Choose exact version `0.1.0-alpha.2`; `renderPolicy` and `deviceCapabilities` are unreleased source. -Build the static libraries with `scripts/build-ios.sh` from the repository root, then add to your target: +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, @@ -35,10 +34,11 @@ Forward `resume()`, `pause()` and `release()` from the host's lifecycle; the lay | Member | What it does | | --- | --- | -| `loadWorld(file:)` | Decodes and shows a `.spz`, `.ply` or `.lodsplat` world; the file is mapped, not copied | +| `loadWorld(file:)` | Decodes and shows a `.spz` 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 | +| `lookAt(from:target:up:)` | Scripted camera: teleports facing a target with an explicit up vector; the next touch or motion update takes over | | `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 | @@ -48,6 +48,7 @@ Forward `resume()`, `pause()` and `release()` from the host's lifecycle; the lay | `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 | +| `renderPolicy`, `deviceCapabilities` | Per-view renderer policy, re-validated on the render thread; invalid or unpreparable requests keep the previous policy. Sort depth applies with GPU sort, the sub-pixel threshold only under the tight-culling experiment | | `readStats()`, `gpuDescription` | Frame, GPU and sort times, splats drawn, device name | | `delegate` | World and collider outcomes, on the main thread | @@ -67,7 +68,7 @@ The shader is compiled at run time from the embedded source, so the library is a 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. +That path draws front to back and stops shading a pixel once it is opaque, 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`. @@ -76,7 +77,7 @@ Colours blend in the encoded space by default, on a `bgra8Unorm` layer; `linearB 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: +Dev-only switches; `--min-pixel-radius` and `--depth-key-bits` set the view's `renderPolicy`, the rest apply before renderer creation: | Switch | Effect | |---|---| diff --git a/packages/splatkit-ios/Sources/SplatKit/RenderThread.swift b/packages/splatkit-ios/Sources/SplatKit/RenderThread.swift index 6350426..fd458a4 100644 --- a/packages/splatkit-ios/Sources/SplatKit/RenderThread.swift +++ b/packages/splatkit-ios/Sources/SplatKit/RenderThread.swift @@ -186,6 +186,51 @@ final class RenderThread { func setShDegree(_ degree: Int) { post { [self] in engine?.setShDegree(Int32(degree)) } } func startBenchmark(_ seconds: Float) { post { [self] in engine?.startBenchmark(seconds) } } + // Policy and capabilities. + + /// The policy applied to the engine, cached for cheap reads off the render thread. + private var appliedPolicy = RenderThread.defaultPolicy() + var renderPolicy: SKRenderPolicy { appliedPolicy } + + /// Re-validates and applies a policy on the render thread. Keeps the previous policy + /// and returns false when the request is invalid or preparation fails. + @discardableResult + func applyRenderPolicy(_ policy: SKRenderPolicy) -> Bool { + var accepted = false + var reason: NSString? + var warnings: NSArray? + sync { [self] in + guard let engine else { return } + accepted = engine.applyRenderPolicy(policy, reason: &reason, warnings: &warnings) == .applied + if accepted { appliedPolicy = engine.renderPolicy } + } + if !accepted { + NSLog("SplatKit policy rejected: %@", reason ?? "unknown") + } else if let list = warnings as? [String] { + for message in list { NSLog("SplatKit policy: %@", message) } + } + return accepted + } + + /// Read-only; the engine has no setters for these after creation. + var deviceCapabilities: SKDeviceCapabilities { + engine?.deviceCapabilities ?? SKDeviceCapabilities() + } + + private static func defaultPolicy() -> SKRenderPolicy { + var policy = SKRenderPolicy() + policy.raster = 0 + policy.tileSize = 16 + policy.lodErrorPixels = 1 + policy.alphaThreshold = 1 / 255 + policy.subpixelThreshold = 0.5 + policy.enableFrustumCulling = true + policy.enableHiZOcclusion = false + policy.enableEarlyTermination = true + policy.sortDepth = 32 + return policy + } + var gpuDescription: String { engine?.gpuDescription ?? "" } func stats() -> SKSplatStats? { engine?.stats } } diff --git a/packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift b/packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift index 8611683..97c3294 100644 --- a/packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift +++ b/packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift @@ -244,6 +244,16 @@ public final class SplatMetalView: UIView { /// GPU name and API reported by Metal. public var gpuDescription: String { renderThread.gpuDescription } + /// The renderer policy for this view. Setting it re-validates on the render thread + /// and keeps the previous policy when the request is invalid or preparation fails. + public var renderPolicy: SKRenderPolicy { + get { renderThread.renderPolicy } + set { renderThread.applyRenderPolicy(newValue) } + } + + /// Native limits, feature flags and the policy fields this device accepts. + public var deviceCapabilities: SKDeviceCapabilities { renderThread.deviceCapabilities } + /// True while the gyroscope drives the camera. public var isMotionEnabled: Bool { motionEnabled } diff --git a/packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm b/packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm index c212fb9..05115f2 100644 --- a/packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm +++ b/packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm @@ -50,6 +50,58 @@ bool writePng(NSString* path, const std::vector& bgra, uint32_t width, } // namespace +// Bridge between the C++ policy types and their C mirror imported by Swift. +namespace { + +SKRenderPolicy fromCppPolicy(const splatkit::RenderPolicy& p) { + SKRenderPolicy out{}; + out.raster = static_cast(p.raster); + out.tileSize = p.tileSize; + out.lodErrorPixels = p.lodErrorPixels; + out.alphaThreshold = p.alphaThreshold; + out.subpixelThreshold = p.subpixelThreshold; + out.enableFrustumCulling = p.enableFrustumCulling; + out.enableHiZOcclusion = p.enableHiZOcclusion; + out.enableEarlyTermination = p.enableEarlyTermination; + out.sortDepth = static_cast(p.sortDepth); + return out; +} + +bool toCppPolicy(SKRenderPolicy p, splatkit::RenderPolicy* out) { + if (p.sortDepth != 16 && p.sortDepth != 32) return false; + out->raster = static_cast(p.raster); + out->tileSize = p.tileSize; + out->lodErrorPixels = p.lodErrorPixels; + out->alphaThreshold = p.alphaThreshold; + out->subpixelThreshold = p.subpixelThreshold; + out->enableFrustumCulling = p.enableFrustumCulling == YES; + out->enableHiZOcclusion = p.enableHiZOcclusion == YES; + out->enableEarlyTermination = p.enableEarlyTermination == YES; + out->sortDepth = p.sortDepth == 16 ? splatkit::SortKeyBits::low16 : splatkit::SortKeyBits::full32; + return true; +} + +SKRenderPolicySupport fromCppSupport(const splatkit::RenderPolicySupport& s) { + SKRenderPolicySupport out{}; + out.raster = s.raster; + out.tileSize = s.tileSize; + out.lodErrorPixels = s.lodErrorPixels; + out.alphaThreshold = s.alphaThreshold; + out.subpixelThreshold = s.subpixelThreshold; + out.enableFrustumCulling = s.enableFrustumCulling; + out.enableHiZOcclusion = s.enableHiZOcclusion; + out.enableEarlyTermination = s.enableEarlyTermination; + out.sortDepth = s.sortDepth; + out.tileSizeMask = s.tileSizeMask; + out.minLodErrorPixels = s.minLodErrorPixels; + out.maxLodErrorPixels = s.maxLodErrorPixels; + out.minSubpixelThreshold = s.minSubpixelThreshold; + out.maxSubpixelThreshold = s.maxSubpixelThreshold; + return out; +} + +} // namespace + @implementation SKSplatEngine { splatkit::MetalSplatRenderer* _renderer; // owned by the engine std::unique_ptr _engine; @@ -147,6 +199,49 @@ - (NSString*)gpuDescription { return @(_engine->gpuDescription().c_str()); } +- (SKRenderPolicy)renderPolicy { + return fromCppPolicy(_engine->renderPolicy()); +} + +- (SKDeviceCapabilities)deviceCapabilities { + const splatkit::DeviceCapabilities caps = _engine->deviceCapabilities(); + SKDeviceCapabilities out{}; + out.maxLodCapacitySplats = caps.limits.maxLodCapacitySplats; + out.minResidencyCapacitySplats = caps.limits.minResidencyCapacitySplats; + out.maxResidencyCapacitySplats = caps.limits.maxResidencyCapacitySplats; + out.supportsComputeTiles = caps.supportsComputeTiles; + out.supportsHiZOcclusion = caps.supportsHiZOcclusion; + out.supportsSubgroups = caps.supportsSubgroups; + out.maxTextureDimension = caps.maxTextureDimension; + out.policy = fromCppSupport(caps.policy); + out.fallback = fromCppPolicy(caps.policy.fallback); + return out; +} + +- (SKRenderPolicyOutcome)applyRenderPolicy:(SKRenderPolicy)policy + reason:(NSString**)reason + warnings:(NSArray**)warnings { + splatkit::RenderPolicy requested; + if (!toCppPolicy(policy, &requested)) { + if (reason != nullptr) *reason = @"sortDepth must be 16 or 32"; + return SKRenderPolicyOutcomeInvalid; + } + const splatkit::RenderPolicyResolution resolution = _engine->setRenderPolicy(requested); + if (!resolution.accepted) { + if (reason != nullptr) *reason = @(resolution.error.c_str()); + return resolution.preparationFailed ? SKRenderPolicyOutcomePreparationFailed + : SKRenderPolicyOutcomeInvalid; + } + if (warnings != nullptr) { + NSMutableArray* list = [NSMutableArray arrayWithCapacity:resolution.warnings.size()]; + for (const splatkit::RenderPolicyWarning& warning : resolution.warnings) { + [list addObject:@(warning.message.c_str())]; + } + *warnings = list; + } + return SKRenderPolicyOutcomeApplied; +} + - (void)lookWithDeltaYaw:(float)deltaYaw deltaPitch:(float)deltaPitch { _engine->look(deltaYaw, deltaPitch); } diff --git a/packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h b/packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h index e8fc46d..6bf1428 100644 --- a/packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h +++ b/packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h @@ -36,6 +36,61 @@ typedef struct { uint32_t hardwareTileCount; } SKSplatStats; +/// The renderer-applicable policy. Mirror of the host contract: raster 0 hardware, +/// 1 computeTile, 2 hybrid; tileSize 8/16/32; sortDepth 16/32. +typedef struct { + uint32_t raster; + uint32_t tileSize; + float lodErrorPixels; + float alphaThreshold; + float subpixelThreshold; + BOOL enableFrustumCulling; + BOOL enableHiZOcclusion; + BOOL enableEarlyTermination; + uint32_t sortDepth; +} SKRenderPolicy; + +/// Which policy fields this backend can apply. A false field resolves to `fallback`. +typedef struct { + BOOL raster; + BOOL tileSize; + BOOL lodErrorPixels; + BOOL alphaThreshold; + BOOL subpixelThreshold; + BOOL enableFrustumCulling; + BOOL enableHiZOcclusion; + BOOL enableEarlyTermination; + BOOL sortDepth; + uint32_t tileSizeMask; + float minLodErrorPixels; + float maxLodErrorPixels; + float minSubpixelThreshold; + float maxSubpixelThreshold; +} SKRenderPolicySupport; + +/// Resource ceilings and device features. Values come from the native adapter. +typedef struct { + uint32_t maxLodCapacitySplats; + uint32_t minResidencyCapacitySplats; + uint32_t maxResidencyCapacitySplats; + BOOL supportsComputeTiles; + BOOL supportsHiZOcclusion; + BOOL supportsSubgroups; + uint32_t maxTextureDimension; + SKRenderPolicySupport policy; + SKRenderPolicy fallback; +} SKDeviceCapabilities; + +/// How `applyRenderPolicy:` ended. Anything but applied keeps the previous policy. +typedef NS_ENUM(NSInteger, SKRenderPolicyOutcome) { + /// Applied; `warnings` lists each unsupported choice that fell back. + SKRenderPolicyOutcomeApplied = 0, + /// The request was invalid. + SKRenderPolicyOutcomeInvalid = 1, + /// The request was valid but the backend could not prepare it. + SKRenderPolicyOutcomePreparationFailed = 2, +}; + typedef NS_ENUM(NSInteger, SKSplatEvent) { SKSplatEventWorldReady = 0, SKSplatEventWorldFailed = 1, @@ -75,6 +130,18 @@ typedef NS_ENUM(NSInteger, SKSplatEvent) { @property(nonatomic, readonly) SKSplatStats stats; @property(nonatomic, readonly) NSString* gpuDescription; +/// The policy currently applied to this instance. +@property(nonatomic, readonly) SKRenderPolicy renderPolicy; +/// One capability query for this adapter: limits, features and accepted policy. +@property(nonatomic, readonly) SKDeviceCapabilities deviceCapabilities; + +/// Re-validates and applies a policy to this instance only. An invalid request, or one +/// the backend cannot prepare, keeps the previous policy and sets `reason`; otherwise +/// `warnings` lists any unsupported choice that fell back. Render thread. +- (SKRenderPolicyOutcome)applyRenderPolicy:(SKRenderPolicy)policy + reason:(NSString* _Nullable* _Nullable)reason + warnings:(NSArray* _Nullable* _Nullable)warnings; + - (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; diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.h index 7bbc446..973ab9d 100644 --- a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.h +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.h @@ -46,6 +46,9 @@ class MetalSplatRenderer final : public SplatRenderer { bool linearBlending() const override { return linearBlending_; } void setVsync(bool vsync) override; + DeviceCapabilities deviceCapabilities() const override; + bool applyRenderPolicy(const RenderPolicy& policy, std::string* reason) override; + bool ready() const override { return layer_ != nil && width_ > 0 && height_ > 0; } Extent drawExtent() const override; uint32_t generation() const override { return generation_; } diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm index 8d87205..e94ad4b 100644 --- a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm @@ -46,34 +46,21 @@ 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. + // Temporary internal opt-in, set before renderer creation by the dev app. Not a public + // SDK setting until device performance and quality are accepted. The sub-pixel radius + // and the sort key width are per-instance policy applied through applyRenderPolicy. 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->minPixelRadius_ = 0.5f; + r->depthBits_ = MetalRadixSort::KeyBits::Full32; r->gpuSort_ = - r->visibility_.create(r->device_, r->library_, experiment, minPixelRadius, r->depthBits_); + r->visibility_.create(r->device_, r->library_, experiment, r->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); + r->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) { @@ -149,10 +136,64 @@ 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. + // without vsync would need its own loop. Host frame intervals and GPU times differ. (void)vsync; } +DeviceCapabilities MetalSplatRenderer::deviceCapabilities() const { + DeviceCapabilities caps; + // MetalLOD caps a hierarchy at 2.2M nodes; the engine's residency window is [100k, 32M]. + caps.limits.maxLodCapacitySplats = 2'200'000; + caps.limits.minResidencyCapacitySplats = 100'000; + caps.limits.maxResidencyCapacitySplats = 32'000'000; + // Hybrid screen tiles are the experimental tile raster, reported as it stands. + caps.supportsComputeTiles = computeRaster_; + caps.supportsHiZOcclusion = false; + caps.supportsSubgroups = gpuSort_; + // Metal exposes no texture-dimension query; Apple family 7, the minimum this renderer + // accepts, supports 16384x16384. + caps.maxTextureDimension = device_ != nil ? 16384u : 0u; + RenderPolicySupport& policy = caps.policy; + policy.fallback.raster = computeRaster_ ? RasterStrategy::hybrid : RasterStrategy::hardware; + policy.fallback.tileSize = 16; + policy.fallback.lodErrorPixels = 1.0f; + policy.fallback.alphaThreshold = 1.0f / 255.0f; + policy.fallback.subpixelThreshold = minPixelRadius_; + policy.fallback.sortDepth = + depthBits_ == MetalRadixSort::KeyBits::Low16 ? SortKeyBits::low16 : SortKeyBits::full32; + policy.fallback.enableFrustumCulling = true; + policy.fallback.enableEarlyTermination = true; + // Only the key width is a public per-instance control today. The sub-pixel radius is + // meaningful only under the internal tight-culling experiment; alpha, tiles and + // occlusion stay fixed shader/rasterization choices. + policy.sortDepth = gpuSort_; + policy.subpixelThreshold = visibility_.tightCulling(); + return caps; +} + +bool MetalSplatRenderer::applyRenderPolicy(const RenderPolicy& policy, std::string* reason) { + const MetalRadixSort::KeyBits bits = policy.sortDepth == SortKeyBits::low16 + ? MetalRadixSort::KeyBits::Low16 + : MetalRadixSort::KeyBits::Full32; + if (bits == depthBits_ && policy.subpixelThreshold == minPixelRadius_) return true; + if (!gpuSort_) { + // No GPU ordering pipelines exist; nothing the policy can reach. + if (reason != nullptr) *reason = "Metal GPU ordering is unavailable"; + return false; + } + // Rebuilding pipelines while frames may still encode against them is unsafe. + waitIdle(); + if (!visibility_.reconfigure(policy.subpixelThreshold, bits)) { + if (reason != nullptr) *reason = "Metal visibility pipeline rebuild failed"; + return false; + } + minPixelRadius_ = policy.subpixelThreshold; + depthBits_ = bits; + LOGI("Metal policy: %u-bit sort keys, %.3fpx sub-pixel radius", static_cast(bits), + minPixelRadius_); + return true; +} + MTLPixelFormat MetalSplatRenderer::pixelFormat() const { return linearBlending_ ? MTLPixelFormatBGRA8Unorm_sRGB : MTLPixelFormatBGRA8Unorm; } diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.h b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.h index ecee7ac..f1c4457 100644 --- a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.h +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.h @@ -22,11 +22,18 @@ class MetalVisibility { bool create(id device, id library, bool experiment = false, float minPixelRadius = 0.5f, MetalRadixSort::KeyBits depthBits = MetalRadixSort::KeyBits::Full32); + // Rebuilds the visibility pipelines for a new sub-pixel radius and/or key width, + // keeping every reserved buffer and the radix scratch. The key width and the shader's + // quantized flag must agree, so both move together. Render thread, while idle. On + // failure the previous radius and key width are restored. + bool reconfigure(float minPixelRadius, MetalRadixSort::KeyBits depthBits); // 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_; } + MetalRadixSort::KeyBits depthBits() const { return depthBits_; } + bool tightCulling() const { return tightCulling_; } // 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 @@ -59,8 +66,13 @@ class MetalVisibility { static constexpr uint32_t kMaxRanges = 65536; static constexpr int kShDegrees = 4; + bool createPipelines(); + id device_ = nil; + id library_ = nil; MTLResourceOptions storage_ = MTLResourceStorageModeShared; + bool tightCulling_ = false; + float minPixelRadius_ = 0.5f; std::array, kShDegrees> visibility_{}; std::array, kShDegrees> indexedVisibility_{}; id prepareDraw_ = nil; diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm index 4997d55..1469ff5 100644 --- a/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm @@ -12,42 +12,68 @@ float minPixelRadius, MetalRadixSort::KeyBits depthBits) { if (!std::isfinite(minPixelRadius) || minPixelRadius < 0.0f) return false; device_ = device; + library_ = library; + tightCulling_ = experiment; + minPixelRadius_ = minPixelRadius; depthBits_ = depthBits; - const bool quantized = depthBits_ == MetalRadixSort::KeyBits::Low16; storage_ = experiment ? MTLResourceStorageModePrivate : MTLResourceStorageModeShared; + if (!createPipelines()) return false; + bool ok = true; + 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 && sort_.create(device, library, storage_); +} + +// Only the pipelines depend on the radius and the key width; the buffers and the radix +// scratch are reused. `tightCulling_` and the storage mode stay as the experiment set them. +bool MetalVisibility::reconfigure(float minPixelRadius, MetalRadixSort::KeyBits depthBits) { + if (device_ == nil || library_ == nil) return false; + if (!std::isfinite(minPixelRadius) || minPixelRadius < 0.0f) return false; + const float previousRadius = minPixelRadius_; + const MetalRadixSort::KeyBits previousBits = depthBits_; + minPixelRadius_ = minPixelRadius; + depthBits_ = depthBits; + if (!createPipelines()) { + minPixelRadius_ = previousRadius; + depthBits_ = previousBits; + return false; + } + return true; +} + +bool MetalVisibility::createPipelines() { + const bool quantized = depthBits_ == MetalRadixSort::KeyBits::Low16; 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:&tightCulling_ 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); + 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); + 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_); + prepareDraw_ = metal::pipeline(device_, library_, "prepareDrawArguments"); + return ok && prepareDraw_ != nil; } bool MetalVisibility::reserve(uint32_t capacity, uint32_t activeCapacity) { diff --git a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatProjection.metalh b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatProjection.metalh index 2306da2..0f2637a 100644 --- a/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatProjection.metalh +++ b/packages/splatkit-ios/Sources/SplatKitCore/rendering/shaders/SplatProjection.metalh @@ -16,7 +16,7 @@ inline float3 projectCovariance(constant Camera& cam, float3 viewPos, float4 cov 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), + 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); @@ -42,7 +42,7 @@ inline float shHalf(const device uint* shData, uint base, uint h) { } inline float3 shCoefficient(const device uint* shData, uint base, uint k) { - return float3(shHalf(shData, base, k * 3u), + return float3(shHalf(shData, base, k * 3u), shHalf(shData, base, k * 3u + 1u), shHalf(shData, base, k * 3u + 2u)); } diff --git a/packages/splatkit-ios/distribution/AGENTS.md b/packages/splatkit-ios/distribution/AGENTS.md deleted file mode 100644 index f517643..0000000 --- a/packages/splatkit-ios/distribution/AGENTS.md +++ /dev/null @@ -1,6 +0,0 @@ -# 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 deleted file mode 100644 index 6af7841..0000000 --- a/packages/splatkit-ios/distribution/CONTRIBUTING.md +++ /dev/null @@ -1,9 +0,0 @@ -# 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 deleted file mode 100644 index 89e56dd..0000000 --- a/packages/splatkit-ios/distribution/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# 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/scripts/export-ios-source.py b/scripts/export-ios-source.py index 2d2bed6..5561c2d 100644 --- a/scripts/export-ios-source.py +++ b/scripts/export-ios-source.py @@ -1,6 +1,7 @@ #!/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 @@ -17,13 +18,41 @@ ".github/workflows/ios.yml", ".github/workflows/engine.yml", ".github/workflows/core.yml", "docs/BENCHMARKS.md", "docs/benchmarks", ) +# React Native publishes this package at its repository root, with distribution/ files beside it. +RN_PACKAGE = "packages/react-native-splatkit" +# iOS publishes these files at its repository root; they link from there. +IOS_DISTRIBUTION = "packages/splatkit-ios/distribution/" 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 published(platform, name): + """The path a repository file takes in the platform's public repository.""" + if platform == "ios" and name.startswith(IOS_DISTRIBUTION): + return name[len(IOS_DISTRIBUTION):] + prefix = RN_PACKAGE + "/" + if platform != "react-native" or not name.startswith(prefix): + return name + return name[len(prefix):].removeprefix("distribution/") + + def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", choices=("ios", "android", "react-native"), default="ios") + platform = parser.parse_args().platform + packages, files = PACKAGES, FILES + if platform == "react-native": + packages, files = (RN_PACKAGE,), ("LICENSE",) + if platform == "android": + packages += ("packages/splatkit-android",) + files += ("README.md", "CONTRIBUTING.md", "AGENTS.md", "CHANGELOG.md", ".clang-tidy", + "apps/android-dev", "scripts/lint-cpp.sh", "scripts/fetch-validation-layers.sh", + "scripts/check_android_alignment.py", "scripts/benchmark_report.py", + "scripts/compare_captures.py", "scripts/requirements-validation.txt", "scripts/tests", + ".github/workflows/android.yml", ".github/workflows/lint.yml", + "docs/AGENT_HARNESS.md", "docs/VALIDATION.md") names = subprocess.check_output( - ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *PACKAGES, *FILES], + ["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *packages, *files], cwd=ROOT).decode().split("\0") selected = {} for name in sorted(set(names) - {""}): @@ -36,10 +65,11 @@ def main(): 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 - for name in ("README.md", "CONTRIBUTING.md", "AGENTS.md"): - selected[name] = ROOT / "packages/splatkit-ios/distribution" / name - destination = Path(tempfile.mkdtemp(prefix="splatkit-ios-public-")) + target = published(platform, name) + if target in selected: + raise ValueError(f"two files publish to {target}") + selected[target] = source + destination = Path(tempfile.mkdtemp(prefix=f"splatkit-{platform}-public-")) manifest = {} for name, source in selected.items(): target = destination / name diff --git a/scripts/sdk_harness.py b/scripts/sdk_harness.py index 70a4383..f29811c 100644 --- a/scripts/sdk_harness.py +++ b/scripts/sdk_harness.py @@ -116,11 +116,24 @@ def android_log(contents, pid, expected, environment): 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)] + r"Fatal signal|FATAL EXCEPTION|VUID-|Validation Error|World failed:|VK_ERROR_|Vulkan GPU frame rejected:", 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) + # Source readiness identifies the world; an uploaded LOD hierarchy has more + # resident records than original splats. Require a draw after its ready event. + resident = expected + ready = False + valid_draw = False + for message in messages: + upload = re.search(r"uploaded (\d+) splats", message) + if upload: + resident, ready = int(upload[1]), False + world = re.fullmatch(r"world ready: (\d+) splats", message) + if world: + ready = int(world[1]) == expected + draw = re.search(r"(\d+) drawn of (\d+) selected of (\d+)", message) + if ready and draw: + drawn, selected, records = map(int, draw.groups()) + valid_draw |= 0 < drawn <= selected <= records == resident 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} diff --git a/scripts/tests/test_sdk_harness.py b/scripts/tests/test_sdk_harness.py index d86aa5a..2500ef5 100644 --- a/scripts/tests/test_sdk_harness.py +++ b/scripts/tests/test_sdk_harness.py @@ -51,6 +51,23 @@ def test_empty_wrong_world_and_incomplete_logs_fail(self): def test_validation_error_fails(self): self.assertEqual(self.verdict(self.ready() + log("VUID-vkCmdDraw-None-02700"))["status"], "failed") + def test_lod_residency_is_not_original_source_count(self): + text = (log("Vulkan device: Adreno 640") + + log("uploaded 750000 splats in 10 ms, sh degree 0") + + log("world ready: 500000 splats") + + log("100 drawn of 150 selected of 750000")) + self.assertEqual(self.verdict(text)["status"], "passed") + self.assertEqual(self.verdict(text.replace("of 750000", "of 800000"))["status"], "failed") + + def test_previous_world_draw_cannot_validate_new_load(self): + text = (log("Vulkan device: Adreno 640") + + log("100 drawn of 500000 selected of 500000") + + log("world ready: 500000 splats")) + self.assertEqual(self.verdict(text)["status"], "failed") + + def test_gpu_rejection_fails_even_after_a_valid_draw(self): + self.assertEqual(self.verdict(self.ready() + log("Vulkan GPU frame rejected: diagnostic bits 0x1"))["status"], "failed") + def test_plan_never_installs_or_launches(self): steps = harness.plan("android", 2) self.assertEqual(steps[0]["argv"], ["./gradlew", "--console=plain", diff --git a/source-manifest.json b/source-manifest.json index 1889e71..e7f5a5e 100644 --- a/source-manifest.json +++ b/source-manifest.json @@ -3,11 +3,11 @@ ".github/workflows/core.yml": "913c3a20ed8c756630ad4b44a39cd3e7ca2e3ae8539fd491fd7e4b944c744480", ".github/workflows/engine.yml": "e067f16070bb38ddd8fafbc4ce587a108d7160197c8dd8d50b3b9c2278599902", ".github/workflows/ios.yml": "fbecedd3a7f827033649440ce0959e6332e696b9ce92ea56ec5919216ec6fe08", - ".gitignore": "02cc620153bd9225b9e60e8aacddf7f0af65393c9c5874d09c5c6fc09d3d905f", - "CONTEXT.md": "e0e66b9cebea14aae0ef59c382ea39242d501438734802cb4d941aaea49002ca", + ".gitignore": "bdb73a5b994861dd0c33ef61dc555cfd3326689c1736f02339e59cc60ec52142", + "CONTEXT.md": "dd0e2778b7f81186161ad876a54fbbdd8a68304c44eb151a6cde97d9099339d1", "LICENSE": "0d53b878fa23d675c2e62010d4e4fb417ba6eff00b609d47dc72ea5cf14d7aa7", "Package.swift": "fd6138340e77c491d0e7a003de3777ac0a36c69529fcbb4d06dcf2b39bd83ff3", - "docs/BENCHMARKS.md": "ec1af356caad424699910ef6b202ba9d2c194a8ffe68be2844adfddb68525fb5", + "docs/BENCHMARKS.md": "d99da27f03f71ff0551c06a9d2e0281e551797632a8924fb6a66af55b1e1e8f0", "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", @@ -24,19 +24,22 @@ "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", + "docs/benchmarks/2026-09-12-mac-lod-quality.json": "183d3ee46290fc4373883e6d1e5ccbdc9fe175fe024d99810a1700f59427f636", + "docs/benchmarks/2026-09-12-mi9-vulkan.json": "abe5e5822a3ec903fb633e3ec60a1ab941625d353e098be0d8364b8941613e7e", + "docs/benchmarks/2026-09-14-mi9-sdk-smoke.json": "1432acad02f1cba650ff11e4baee8d28704aa490a749953f86f41183f7273a5e", "packages/splat-core/CMakeLists.txt": "3f5d91bab34ca7fdf8b9b2ff86c4e1be63e200212049483d8c4651f0e742c7e3", - "packages/splat-core/README.md": "c87e6cd66d0e8a9bd309004890e33d3875079db72c211e0546df536a14e05dcc", + "packages/splat-core/README.md": "bec9f2925fc3fafea40beb7167211b63bb17c61c1b900cbf5d8d2f872a8586f5", "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/diagnostics/TimingSummary.h": "27a200aa849a7c04d507c7eab4219cb0b4f24a95720b13acd11a0d37064a78cf", "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/loading/SplatWorldLoader.h": "ba56c28b718d9e5998cd41a990e9d78b4672c99eb36a2090ed0d7e52b67e9c8b", "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", @@ -48,30 +51,30 @@ "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/SlabSorter.h": "47199bcfd9daaf7f7a1c2ed9c3e77a7ebdb1cf872ff48e07505f205caa374496", + "packages/splat-core/include/splat/sorting/SpatialOrder.h": "e2983f406b47340aa2dcef5ac9b3bf3944444dc4611a759774b4398c7381467b", + "packages/splat-core/include/splat/sorting/VisibilityPlanner.h": "d590c5490b7c6e331674f289406c7c48e62ca2ca6028684f79a8afb29945b75b", "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/TileBuilder.h": "7e650e48021ca1724362ec3a7fbd32aa6f62c48af22b57d50192a22a7a35b7e1", "packages/splat-core/include/splat/tiles/TileLoader.h": "9f51b954a6a11bd3ad24ebad0fc0a7c2e465b1d90a7c9e15fe058a4f25d5f186", - "packages/splat-core/include/splat/tiles/TileScheduler.h": "7b741a5715463df1dfbd902576a520c156b97dc5ff03948a3390b03375e2184c", + "packages/splat-core/include/splat/tiles/TileScheduler.h": "31a9aca0cbe18bfe08f08a1a17c3a340e5f6e586b7b75a12d15916a16d54e687", "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/include/splat/tiles/Tileset.h": "13455cceae20a748972598c75f4f394d9f8af8994945425215ddb9febb79774d", + "packages/splat-core/src/diagnostics/TimingSummary.cpp": "9350a625d2cdc43f2c5abc8d99a094ea555671a2e07ee0c9659581df6a0751b5", "packages/splat-core/src/formats/GlbDecoder.cpp": "a6383c5c2c18309756de9578c540adf4fad93284b40a36069900c8073def0fbd", "packages/splat-core/src/formats/SplatDecoder.cpp": "d2eae467893f43c9f99a8e88cbf71db3e64b61d2b75438d3d0f62414e7f7683a", - "packages/splat-core/src/formats/SpzDecoder.cpp": "05eb76efc31016d12a5adc848ba7bbd23dd36a5d41e893d7b5b55a09ec3a9609", + "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": "ccf0fbfd5a719b68f0daaa683c8e51904a9f138b4eca71bcdeb3769eabe2e0eb", - "packages/splat-core/src/lod/LodSelection.cpp": "e8797727c4e49aad4057e443e48b31e8f454241cb6dc3ec0892f40a5bed7e049", + "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": "be1a0c2c4f0b51013b0fd825e0104531a949b6f9cd95f4c69cbb8547d4ea2364", + "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", @@ -84,14 +87,14 @@ "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/CMakeLists.txt": "275d3d7a49946134e6445195252897994c3189b115468ce94e3ce8595c2584d4", + "packages/splat-core/tests/diagnostics/TimingSummaryTest.cpp": "8158c9cb6ded10602b2c8e1666d7e88a7b238bbddf16ccc71d4062dc85ecd86d", "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": "8503fe60b8a536fdfe8c31d8c5282ec8d4b4120b44123a3a93dc0d4569ba8e3d", + "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", @@ -113,33 +116,39 @@ "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": "a09ae98ea00ad4dea11b454330bc1ce131490df4f9f093940d3700d2a188c686", - "packages/splatkit-engine/CMakeLists.txt": "ec84c8b8629a36ca3dcc3154995de3cce78706035d4459b9029df3c047957b69", + "packages/splat-core/tools/splat-tile.cpp": "65f463734afd9830bc79881219423395b3a3df5f44a705a7e9a1d6a7b3993806", + "packages/splat-core/tools/splat_lod_build.cpp": "6dcf186c775b4f447d69d6d9e9c86fa14f1fca2214d531d5f335b0520648e565", + "packages/splatkit-engine/CMakeLists.txt": "11b79048ea966c232eecd21c9d67daa2d4299189402fd86d978792b80dad277c", "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/diagnostics/Benchmark.h": "eeee34214a7939b90c758bf3359bc2332344e1c9fee1b22376cdb293303390ea", + "packages/splatkit-engine/include/splatkit/diagnostics/StatsPublisher.h": "e29c89259580057750e918ea5393e81a192210f04d2613c528adde3e07de96cf", + "packages/splatkit-engine/include/splatkit/engine/SplatEngine.h": "49dc7adca0f8b36dc8a6361ce16e8173712b3bd26b442e0bdb3c77067fc243a0", "packages/splatkit-engine/include/splatkit/rendering/GpuLayout.h": "0cbc13f05e292003844cc9996bb8561fcbc6e9197153a1dabf8e944c6184d662", - "packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h": "83f2ed6b35a7177967c2cbe4a03835fe360cd4ef5908a2baafe74d2a84ef1a27", + "packages/splatkit-engine/include/splatkit/rendering/RenderPolicy.h": "df0ea18f8b02ce55d05318e193d8a47d4a7e308026c0e6edd193282582a6c893", + "packages/splatkit-engine/include/splatkit/rendering/SplatRenderer.h": "1b81d9546e5c5d3c6d9cd275c1e1964c3d038992dec6349f9509f1dd5694088d", "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/diagnostics/Benchmark.cpp": "2651c12125b0fc45bf53b41def4365fa29b8b6506c3f00bb307f80c1c1d62fb2", + "packages/splatkit-engine/src/diagnostics/StatsPublisher.cpp": "b1cadbbdee3b481c6f558102badc64bf7c0cd8496cca936c1cd2da62a531d3cb", + "packages/splatkit-engine/src/engine/SplatEngine.cpp": "82cc700079c79ad17398e24a007e347e23a2b8b2e6115d117288357d4e36644a", "packages/splatkit-engine/src/rendering/GpuLayout.cpp": "33b384ef738cc28f147e06608fb3004aa839f9a02bb9835abdcb74d50b642204", - "packages/splatkit-engine/tests/CMakeLists.txt": "d212c87bb5f9284f49a845bb216f4cd64617098528bf21fc626fedbd7d775583", + "packages/splatkit-engine/src/rendering/RenderPolicy.cpp": "5414a6d0314991c7a23b28322eed2187790e18859fed604166bcf2cb31c8ead0", + "packages/splatkit-engine/tests/CMakeLists.txt": "45268b357a809d8966f85b55d08bcd7834559c377ca5d604cc3c731718a11b64", "packages/splatkit-engine/tests/camera/WalkCameraTest.cpp": "69be71cdf7a672a451d39569c1c5651cbf45b1329c48f39d4ccceea997cf9465", + "packages/splatkit-engine/tests/diagnostics/BenchmarkTest.cpp": "3bd6ecebeea4206d86c5d0b93b58bfb109c39b9dbb143a257a35c22cd12b9c82", + "packages/splatkit-engine/tests/diagnostics/StatsPublisherTest.cpp": "91aed3dee521e7689afd0ffcb41fa8425f25bb77fe22575898fbcab890e8629b", + "packages/splatkit-engine/tests/engine/SplatEnginePolicyTest.cpp": "bd8bb235cae85c5a4cd6147e51eb8c323c8a9af493c4adbbcad2863335c663a3", "packages/splatkit-engine/tests/rendering/FrameOrderTest.cpp": "deeed75c2ff15efcc3ba929a3ba616d9a1b517d25daafa8f90f2f9a70b3b93fc", "packages/splatkit-engine/tests/rendering/GpuLayoutTest.cpp": "9c613ac37b790fe8a069eab6af8570b5c394801ab252680d56824d2b43edb69d", + "packages/splatkit-engine/tests/rendering/RenderPolicyTest.cpp": "b143fda238f2c90c4312428786764a06564f331d418a41e07867e662c01ef0c0", "packages/splatkit-ios/CMakeLists.txt": "4709d2fffbfe5733624ad4c46311280c300e8346be898bbed77e6617f2438ebb", - "packages/splatkit-ios/README.md": "4a31f90987a12aa8c7fc297c69b48fbbf0eae118b853eeaad315e5cd68a72019", + "packages/splatkit-ios/README.md": "6704734c6a4ec105a7a4a4ba9fc621f7a1b9fea379b2271cd434561a202e0e18", "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/SplatKit/RenderThread.swift": "611b8eb851b31f56193b3124d346cfbc9ef6a0ae784954c9d302d66038eebfcc", + "packages/splatkit-ios/Sources/SplatKit/SplatMetalView.swift": "64901eeba5fc405554cab6fbd4ae1159be1a60fe934188e7d6a47381ccd1f6a3", + "packages/splatkit-ios/Sources/SplatKitCore/engine/SKSplatEngine.mm": "3e63471d46228a5e3ee7f9cdaae9d321b62c38b3b6899e9e3111392e5ee20b43", + "packages/splatkit-ios/Sources/SplatKitCore/include/SplatKit/SKSplatEngine.h": "68a0fd85418bb5dee613909cfdd7c7f55901b63130d2460742a941b8e78b0015", "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", @@ -147,18 +156,18 @@ "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/MetalSplatRenderer.h": "c3db0c56750352069b7f843b2a01c118ea81de5f93c92cbd8ba3114d36b480d8", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalSplatRenderer.mm": "2f7192151cce3d0000c5f8bbbef82ec0b97a0528b8ec00f25f548e48258dfffa", "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/MetalVisibility.h": "aa381783a16245f95d8ce627c17a57aba454a63f1ec2463b0a33afb84071a07b", + "packages/splatkit-ios/Sources/SplatKitCore/rendering/MetalVisibility.mm": "49410811a595facee5a3d80ccec3f088cf9d4e27af7062bb6eff15cdfb3ab2ed", "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": "adf051fc4f87b147824a9dc725e325cb3077cc614462535c40439c19ba09f383", + "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", @@ -166,9 +175,10 @@ "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", + "AGENTS.md": "ae264333145a45e27170884decff8b8b136e2aaa8afbc3bebeb2cf078bcfadd6", + "CHANGELOG.md": "d5b569b02be52084d8bc0633180feaba496cc4dfd7d1bbe4641ea3514e8892bf", + "CONTRIBUTING.md": "57a79b10ecbf51252b6c36a8e03804441729b78a3b55691367ba40003872c223", + "README.md": "e645224857d13fadcea656d29d2f4141b9a3b151948ff97bd993dc997f8c4423", "packages/splatkit-ios/tests/CMakeLists.txt": "f7b4889a22cb81f0f55c5d53c526654d1651d09d839c92ea11b3ed99292a73e5", "packages/splatkit-ios/tests/MetalLODTest.mm": "438ea0657133b0b88ca2570d12ea1f066edeccb322bacd18c753c96148cb9bc2", "packages/splatkit-ios/tests/MetalRadixSortTest.mm": "ea57b2c771ea090e680686cd7ae1620dc81e771cf9a97bb786870d8f3efeb099", @@ -178,11 +188,8 @@ "packages/splatkit-ios/tests/MetalVisibilityTest.mm": "329d967cfe20a39d8c3ab4537562a294cfe9bf360cb30a7326589b53b2aa3cbc", "packages/splatkit-ios/tests/MetalWorldTest.mm": "f6a61735bfba292caf4209834c194c1accc862bda6e8f420cd4849a7d9a69305", "scripts/build-ios.sh": "c05feb1e381afb85054e4090551942fa7937e77ba43689ca1e4a70e8e9886bb9", - "scripts/export-ios-source.py": "28321de31cddda6cda1a28ea3598ad22264d89215aa3774ef90f12dd8c2c71fe", + "scripts/export-ios-source.py": "7f28301e98e3f13f519577fb49328937564dfbdd9d6e1ec4b6fcb1313bc50d1b", "scripts/package-ios.sh": "d4cd0ea92c1742bf2b5a1f8387ccae7083615e0256cff68dc6f32647d02b6a68", - "scripts/sdk_harness.py": "54792b0c25e9abc981a6d22a38692c3dfc8b17ed05c29244b81d368dc6e76a1a", - "scripts/tests/test_sdk_harness.py": "0a7785b4dfbb2e2f9dedac54c209a3fd315b99c06b2164cd8a820de9fe476c26", - "README.md": "d50f9574a921f6fbebd5038bedd1e34b3ae43882eff51d2c275b8783b0b4d2a9", - "CONTRIBUTING.md": "57a79b10ecbf51252b6c36a8e03804441729b78a3b55691367ba40003872c223", - "AGENTS.md": "ae264333145a45e27170884decff8b8b136e2aaa8afbc3bebeb2cf078bcfadd6" + "scripts/sdk_harness.py": "b21a3f5123abf1504b7c3a87a325268a246cb340e07fbb67515a34cedaef4d21", + "scripts/tests/test_sdk_harness.py": "c025dc1d5a74c704827360e071f336521e62a88e447c7dc7ec07e817b8adf938" }