Error-driven GSplat budget allocation from streamed SOG LOD error metadata - #9157
Error-driven GSplat budget allocation from streamed SOG LOD error metadata#9157slimbuck wants to merge 5 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds error-driven splat budget allocation for streamed SOG GSplat octrees when per-node/per-LOD approximation error metadata is available, with a fallback to the existing distance-bucket allocator when it isn’t.
Changes:
- Parse and validate streamed SOG
lodErrors/errorsmetadata atGSplatOctreeconstruction time and expose a per-octreelodErrorscapability flag. - Add view-dependent
NodeInfo.lodCoverageand implement an error-benefit-per-cost budget balancer (_balanceErrors) that uses Pareto-frontier LOD transitions. - Add unit tests covering allocator fallback behavior and streamed error-metadata loading/validation.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs | Adds tests for error-driven balancing vs. distance fallback and octree manifest error validation. |
| src/scene/gsplat-unified/gsplat-octree.js | Loads errors[] from manifests, validates completeness/finite values, and exposes lodErrors with a fallback warning. |
| src/scene/gsplat-unified/gsplat-octree-node.js | Extends GSplatOctreeNodeLod with an optional error field. |
| src/scene/gsplat-unified/gsplat-octree-instance.js | Computes per-node projected coverage (lodCoverage) during LOD evaluation to weight error-based priorities. |
| src/scene/gsplat-unified/gsplat-budget-balancer.js | Implements _balanceErrors allocator and keeps legacy _balanceDistance for missing/incomplete error metadata. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/scene/gsplat-unified/gsplat-budget-balancer.js:172
- This comment says a zero-cost transition would "stall" the node for the rest of the pass, but the real failure mode is that a 0/0 transition produces NaN priority and demotes the node's later upgrades into the lowest bucket (as described in the PR discussion). Updating this keeps the rationale for the strict-improvement frontier accurate.
// differ in both. That keeps every transition's cost above zero: a
// zero-cost transition would carry a 0/0 priority and, because
// upgrades apply in chain order, would stall the node there for the
// rest of the pass.
src/scene/gsplat-unified/gsplat-budget-balancer.js:19
- The
_bucketsJSDoc says the buckets store NodeInfo references, but_balanceErrorsuses the same buckets to store transition indices (numbers). This makes the comment/type description misleading for future maintainers.
This issue also appears on line 169 of the same file.
/**
* Buckets storing NodeInfo references.
* @type {Array<Array>|null}
* @private
*/
src/scene/gsplat-unified/gsplat-octree-node.js:12
GSplatOctreeNodeLod.errorcan benull(e.g. when metadata is missing/invalid, as exercised by the new tests), but the typedef only allowsnumber|undefined. This makes generated typings inaccurate for consumers.
* @property {number} count - The count of items
* @property {number|undefined} error - Approximation error relative to the finest LOD
*/
test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs:50
- This test hard-codes
63as the last distance bucket. That couples the test to the currentNUM_BUCKETS = 64value; if the constant changes, the test will fail for an unrelated reason. Prefer deriving this fromNUM_BUCKETS - 1(imported fromsrc/scene/gsplat-unified/constants.js).
inst.nodeInfos[0].budgetBucket = 0;
inst.nodeInfos[1].budgetBucket = 63;
LOD selection for streamed SOG has been purely geometric: each node picks a level from camera distance bands, and the budget balancer redistributes by distance bucket. Distance is a poor proxy for how much a scene actually loses at a given level — a node full of near-duplicate splats and one carrying fine detail are treated alike.
This spends the scene's splat budget where it buys the most quality instead, using per-node approximation errors that splat-transform now writes into
lod-meta.json(playcanvas/splat-transform#NNN). The two land together; this side falls back to the existing distance-bucket allocator, now_balanceDistance, whenever error metadata is absent.Allocation. For each visible node, the renderable levels are sorted by ascending cost (with error as a tiebreak) and swept once, keeping a level only when it strictly improves on the cheapest error seen so far. That yields the Pareto frontier over (splat count, error) in
O(L log L), and it comes out already ordered by cost. Every node is seeded at its cheapest frontier entry, and the remaining transitions are ranked bylodCoverage * benefit / cost— error reduction per additional splat, weighted by how much screen the node covers — then applied greedily in log-priority buckets until the budget is spent.Math.min(previousPriority, ...)keeps a node's marginal returns non-increasing, so a later upgrade on one node can never outrank an earlier one.Requiring a strict improvement in that sweep matters beyond tidiness: it collapses levels identical in both cost and error, so consecutive frontier entries always differ in both and every transition's cost stays above zero. A zero-cost transition would carry a
0/0priority, and because priorities accumulate throughMath.min, that NaN propagates to every later transition on the node and demotes all of them to the lowest bucket regardless of what they are worth — the node then loses budget to genuinely lower-value upgrades elsewhere in the scene.NodeInfo.lodCoverageis the view-dependent half of the ranking: squared projected radius including the FOV scale and behind-camera penalty, computed alongside the existing distance evaluation.Error metadata.
GSplatOctreeNodeLodgainserror, read from each leaf'serrorsarray by absolute LOD level. Whether that metadata is usable is settled once, inGSplatOctree, from the manifest'slodErrorsheader and confirmed against the values themselves inside thenodes.maploop the constructor already runs — so the balancer reads one boolean per instance rather than walking every node's levels each frame, and its choice of allocator is a property of the asset rather than of what the camera currently sees.A level that can be rendered must supply an error that is finite and non-negative. Errors are magnitudes relative to the finest LOD, so a negative one is meaningless, and it is more dangerous than a non-finite one: it would pass a finiteness check, let a coarse level dominate every finer level on the frontier, and pin the node there at any budget. A manifest that declares the header without meeting that contract warns and falls back.
Note the validation covers all LOD levels rather than only the configured
[rangeMin, rangeMax], which is what keeps the result independent of runtime state.