diff --git a/src/scene/gsplat-unified/gsplat-budget-balancer.js b/src/scene/gsplat-unified/gsplat-budget-balancer.js index ab4ba361b5e..53902761f06 100644 --- a/src/scene/gsplat-unified/gsplat-budget-balancer.js +++ b/src/scene/gsplat-unified/gsplat-budget-balancer.js @@ -6,9 +6,8 @@ import { NUM_BUCKETS } from './constants.js'; /** - * Balances splat budget across multiple octree instances by adjusting LOD levels. - * Uses sqrt-based bucket distribution to give more precision to nearby geometry. - * Bucket 0 = nearest to camera (highest priority), bucket N-1 = farthest (lowest priority). + * Balances splat budget across multiple octree instances by adjusting LOD levels. Uses projected + * error reduction when complete error metadata is available, otherwise the legacy distance buckets. * * @ignore */ @@ -20,6 +19,27 @@ class GSplatBudgetBalancer { */ _buckets = null; + /** @type {Array|null} @private */ + _errorNodes = null; + + /** @type {Int32Array|null} @private */ + _transitionNodes = null; + + /** @type {Int16Array|null} @private */ + _transitionFineLods = null; + + /** @type {Int16Array|null} @private */ + _transitionCoarseLods = null; + + /** @type {Float64Array|null} @private */ + _transitionCosts = null; + + /** @type {Float64Array|null} @private */ + _transitionPriorities = null; + + /** @type {Int16Array|null} @private */ + _frontierScratch = null; + /** * Initialize bucket infrastructure on first use. * @private @@ -34,17 +54,202 @@ class GSplatBudgetBalancer { } } + /** + * @param {number} capacity - Required transition capacity. + * @private + */ + _ensureTransitionCapacity(capacity) { + if ((this._transitionNodes?.length ?? 0) >= capacity) return; + const size = Math.max(capacity, (this._transitionNodes?.length ?? 0) * 2, 64); + this._transitionNodes = new Int32Array(size); + this._transitionFineLods = new Int16Array(size); + this._transitionCoarseLods = new Int16Array(size); + this._transitionCosts = new Float64Array(size); + this._transitionPriorities = new Float64Array(size); + } + /** * Balances splat budget across all octree instances by adjusting LOD levels. - * Uses sqrt-based bucket distribution to give more precision to nearby geometry. - * Makes multiple passes, adjusting by one LOD level per pass, until budget is reached - * or all nodes hit their respective limits (per-instance rangeMin or rangeMax). * * @param {Map} octreeInstances - Map of * GSplatOctreeInstance objects. * @param {number} budget - Target splat budget for octrees. */ balance(octreeInstances, budget) { + // Usable error metadata is a property of each asset, settled when its octree + // was built, so this is one lookup per instance instead of a walk over every + // node's LOD levels each frame. + let completeErrors = true; + for (const [, inst] of octreeInstances) { + if (!inst.octree.lodErrors) { + completeErrors = false; + break; + } + } + + // only the error allocator needs the visible-node count, to size its + // transition arrays + let activeNodes = 0; + if (completeErrors) { + for (const [, inst] of octreeInstances) { + const nodeInfos = inst.nodeInfos; + for (let nodeIndex = 0; nodeIndex < inst.octree.nodes.length; nodeIndex++) { + if (nodeInfos[nodeIndex].optimalLod >= 0) activeNodes++; + } + } + } + + if (completeErrors && activeNodes > 0) { + this._balanceErrors(octreeInstances, budget, activeNodes); + } else { + this._balanceDistance(octreeInstances, budget); + } + } + + /** + * Allocate budget using projected approximation-error reduction per additional splat. + * + * @param {Map} octreeInstances - Octree instances. + * @param {number} budget - Target splat budget. + * @param {number} activeNodes - Number of visible nodes. + * @private + */ + _balanceErrors(octreeInstances, budget, activeNodes) { + this._initBuckets(); + for (let i = 0; i < NUM_BUCKETS; i++) this._buckets[i].length = 0; + + if (!this._errorNodes) this._errorNodes = []; + this._errorNodes.length = 0; + + let maxLods = 0; + for (const [, inst] of octreeInstances) maxLods = Math.max(maxLods, inst.rangeMax - inst.rangeMin + 1); + this._ensureTransitionCapacity(activeNodes * Math.max(0, maxLods - 1)); + if ((this._frontierScratch?.length ?? 0) < maxLods) this._frontierScratch = new Int16Array(maxLods); + + let currentSplats = 0; + let transitionCount = 0; + let minLogPriority = Infinity; + let maxLogPriority = -Infinity; + + for (const [, inst] of octreeInstances) { + const nodes = inst.octree.nodes; + for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) { + const nodeInfo = inst.nodeInfos[nodeIndex]; + if (nodeInfo.optimalLod < 0) continue; + const lods = nodes[nodeIndex].lods; + nodeInfo.lods = lods; + + // Collect the levels this node can render, ordered by ascending cost + // and then ascending error. Insertion sort: the list is at most + // lodLevels long and usually already in order. + const scratch = this._frontierScratch; + let candidateCount = 0; + for (let lod = inst.rangeMin; lod <= inst.rangeMax; lod++) { + if (lods[lod].count <= 0) continue; + let j = candidateCount++; + while (j > 0) { + const prev = scratch[j - 1]; + if (lods[prev].count < lods[lod].count || + (lods[prev].count === lods[lod].count && lods[prev].error <= lods[lod].error)) { + break; + } + scratch[j] = prev; + j--; + } + scratch[j] = lod; + } + + // Pareto frontier in a single sweep of that order: a level is worth + // keeping only when it strictly improves on the cheapest error seen + // so far. Compacts in place, since the write index never runs ahead + // of the read index. + // + // Requiring a *strict* improvement also collapses levels with + // identical cost and error, so consecutive frontier entries always + // 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. + let frontierCount = 0; + let bestError = Infinity; + for (let i = 0; i < candidateCount; i++) { + const lod = scratch[i]; + if (lods[lod].error < bestError) { + bestError = lods[lod].error; + scratch[frontierCount++] = lod; + } + } + + if (frontierCount === 0) { + nodeInfo.optimalLod = -1; + continue; + } + + const errorNodeIndex = this._errorNodes.length; + this._errorNodes.push(nodeInfo); + nodeInfo.optimalLod = scratch[0]; + currentSplats += lods[nodeInfo.optimalLod].count; + + let previousPriority = Infinity; + for (let i = 1; i < frontierCount; i++) { + const coarseLod = scratch[i - 1]; + const fineLod = scratch[i]; + const cost = lods[fineLod].count - lods[coarseLod].count; + const benefit = lods[coarseLod].error - lods[fineLod].error; + const priority = Math.min(previousPriority, nodeInfo.lodCoverage * benefit / cost); + this._transitionNodes[transitionCount] = errorNodeIndex; + this._transitionFineLods[transitionCount] = fineLod; + this._transitionCoarseLods[transitionCount] = coarseLod; + this._transitionCosts[transitionCount] = cost; + this._transitionPriorities[transitionCount] = priority; + if (priority > 0) { + const logPriority = Math.log(priority); + minLogPriority = Math.min(minLogPriority, logPriority); + maxLogPriority = Math.max(maxLogPriority, logPriority); + } + previousPriority = priority; + transitionCount++; + } + } + } + + if (currentSplats >= budget || transitionCount === 0) return; + + const logRange = maxLogPriority - minLogPriority; + for (let i = 0; i < transitionCount; i++) { + const priority = this._transitionPriorities[i]; + let bucket = 0; + if (priority > 0 && logRange > 0) { + bucket = Math.floor((Math.log(priority) - minLogPriority) * (NUM_BUCKETS - 1) / logRange); + } else if (priority > 0) { + bucket = NUM_BUCKETS - 1; + } + this._buckets[bucket].push(i); + } + + for (let bucket = NUM_BUCKETS - 1; bucket >= 0; bucket--) { + const transitions = this._buckets[bucket]; + for (let i = 0; i < transitions.length; i++) { + const transition = transitions[i]; + const nodeInfo = this._errorNodes[this._transitionNodes[transition]]; + if (nodeInfo.optimalLod !== this._transitionCoarseLods[transition]) continue; + const cost = this._transitionCosts[transition]; + if (currentSplats + cost <= budget) { + currentSplats += cost; + nodeInfo.optimalLod = this._transitionFineLods[transition]; + } + } + } + } + + /** + * Legacy distance-bucket allocator used when error metadata is absent or incomplete. + * + * @param {Map} octreeInstances - Octree instances. + * @param {number} budget - Target splat budget. + * @private + */ + _balanceDistance(octreeInstances, budget) { // Initialize buckets on first use this._initBuckets(); diff --git a/src/scene/gsplat-unified/gsplat-octree-instance.js b/src/scene/gsplat-unified/gsplat-octree-instance.js index 55938312bf5..a817b8b6291 100644 --- a/src/scene/gsplat-unified/gsplat-octree-instance.js +++ b/src/scene/gsplat-unified/gsplat-octree-instance.js @@ -58,6 +58,11 @@ class NodeInfo { */ worldDistance = 0; + /** + * Approximate projected screen coverage of the node, including FOV and behind-camera penalty. + */ + lodCoverage = 0; + /** * Accumulated camera translation for SH color update threshold tracking. */ @@ -627,6 +632,9 @@ class GSplatOctreeInstance { nodeInfo.optimalLod = optimalLodIndex; nodeInfo.worldDistance = fovAdjustedDistance * uniformScale; + const radius = nodes[nodeIndex].boundingSphere.w; + const projectedRadius = radius / Math.max(radius + fovAdjustedDistance, 1e-12); + nodeInfo.lodCoverage = projectedRadius * projectedRadius; // Budget balancer bucket (sqrt mapping; must match GSplatBudgetBalancer). Fused here when enforcing budget. if (bucketScale > 0 && optimalLodIndex >= 0) { diff --git a/src/scene/gsplat-unified/gsplat-octree-node.js b/src/scene/gsplat-unified/gsplat-octree-node.js index 6f9342cdf1d..b05ea4f150c 100644 --- a/src/scene/gsplat-unified/gsplat-octree-node.js +++ b/src/scene/gsplat-unified/gsplat-octree-node.js @@ -8,6 +8,7 @@ import { Vec4 } from '../../core/math/vec4.js'; * @property {number} fileIndex - The file index in the octree files array * @property {number} offset - The offset in the file * @property {number} count - The count of items + * @property {number|undefined} error - Approximation error relative to the finest LOD */ const tmpMin = new Vec3(); diff --git a/src/scene/gsplat-unified/gsplat-octree.js b/src/scene/gsplat-unified/gsplat-octree.js index 609e1e5959f..af93d28f463 100644 --- a/src/scene/gsplat-unified/gsplat-octree.js +++ b/src/scene/gsplat-unified/gsplat-octree.js @@ -37,6 +37,19 @@ class GSplatOctree { */ lodLevels; + /** + * True when the manifest declares per-node approximation errors and every node + * supplies a finite, non-negative one for each of its non-empty LOD levels, + * which allows error-driven budget allocation. Established here, once, so the budget + * balancer does not walk each node's levels every frame - and so its choice of + * allocator is a property of the asset rather than of what is currently + * visible. Note this covers all LOD levels, not just a currently configured + * sub-range. + * + * @type {boolean} + */ + lodErrors = false; + /** * The file URL of the container asset, used as the base for resolving relative URLs. * @@ -135,6 +148,10 @@ class GSplatOctree { const leafNodes = []; this._extractLeafNodes(data.tree, leafNodes); + // The manifest declares whether it carries error tables; confirm the values + // are actually usable while the nodes are being built. + let lodErrors = data.lodErrors === true; + // Create nodes from the extracted leaf nodes this.nodes = leafNodes.map((nodeData) => { /** @type {GSplatOctreeNodeLod[]} */ @@ -143,12 +160,14 @@ class GSplatOctree { // Ensure we have exactly lodLevels entries for (let i = 0; i < this.lodLevels; i++) { const lodData = nodeData.lods[i.toString()]; + const error = nodeData.errors?.[i]; if (lodData) { lods.push({ file: this.files[lodData.file].url || '', fileIndex: lodData.file, offset: lodData.offset || 0, - count: lodData.count || 0 + count: lodData.count || 0, + error }); // record LOD level for the file index @@ -159,14 +178,30 @@ class GSplatOctree { file: '', fileIndex: -1, offset: 0, - count: 0 + count: 0, + error }); } + + // An unusable error on a level that can be rendered rules out + // error-driven allocation for the whole octree. Errors are + // magnitudes relative to the finest LOD, so a negative one is as + // meaningless as a non-finite one - and more dangerous, since it + // would let a coarse level dominate every finer one on the frontier + // and pin the node there at any budget. + if (lodErrors && lods[i].count > 0 && !(Number.isFinite(error) && error >= 0)) { + lodErrors = false; + } } return new GSplatOctreeNode(lods, nodeData.bound); }); + this.lodErrors = lodErrors; + if (data.lodErrors === true && !lodErrors) { + Debug.warn(`GSplatOctree: ${assetFileUrl} declares lodErrors but does not supply a finite, non-negative error for every non-empty LOD level, falling back to distance-based LOD allocation.`); + } + // precompute node bounds for CPU hot paths const nodeCount = this.nodes.length; const boundsFlat = new Float32Array(nodeCount * 6); @@ -238,7 +273,8 @@ class GSplatOctree { // This is a leaf node with LOD data leafNodes.push({ lods: node.lods, - bound: node.bound + bound: node.bound, + errors: node.errors }); } else if (node.children) { // This is a branch node, recurse into children diff --git a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs new file mode 100644 index 00000000000..c7d54b877c0 --- /dev/null +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -0,0 +1,128 @@ +import { expect } from 'chai'; + +import { GSplatBudgetBalancer } from '../../../src/scene/gsplat-unified/gsplat-budget-balancer.js'; +import { GSplatOctree } from '../../../src/scene/gsplat-unified/gsplat-octree.js'; + +const makeInstances = (nodes, rangeMin = 0, rangeMax = nodes[0].lods.length - 1) => { + // mirrors the load-time check GSplatOctree performs, so a test only has to say + // whether its nodes carry errors + const lodErrors = nodes.every(node => node.lods.every(lod => !(lod.count > 0) || Number.isFinite(lod.error))); + const inst = { + octree: { nodes, lodErrors }, + nodeInfos: nodes.map(() => ({ + optimalLod: 0, + budgetBucket: 0, + lodCoverage: 1, + inst: null, + lods: null + })), + rangeMin, + rangeMax + }; + for (const nodeInfo of inst.nodeInfos) nodeInfo.inst = inst; + return { inst, instances: new Map([[{}, inst]]) }; +}; + +// A single-leaf streamed SOG manifest: two LOD levels, the error table and header +// flag under test, and an adjustable splat count for the coarse level. +const makeOctree = (errors, lodErrors, coarseCount = 5) => new GSplatOctree('/scene/lod-meta.json', { + lodLevels: 2, + lodErrors, + filenames: ['0/meta.json', '1/meta.json'], + tree: { + bound: { min: [0, 0, 0], max: [1, 1, 1] }, + errors, + lods: { + 0: { file: 0, offset: 0, count: 10 }, + 1: { file: 1, offset: 0, count: coarseCount } + } + } +}); + +describe('GSplatBudgetBalancer', function () { + it('preserves distance-bucket allocation when error metadata is absent', function () { + const { inst, instances } = makeInstances([ + { lods: [{ count: 10 }, { count: 5 }] }, + { lods: [{ count: 10 }, { count: 5 }] } + ]); + inst.nodeInfos[0].budgetBucket = 0; + inst.nodeInfos[1].budgetBucket = 63; + + new GSplatBudgetBalancer().balance(instances, 15); + + expect(inst.nodeInfos.map(info => info.optimalLod)).to.deep.equal([0, 1]); + }); + + it('prioritizes projected error reduction per additional splat', function () { + const { inst, instances } = makeInstances([ + { lods: [{ count: 10, error: 0 }, { count: 5, error: 10 }] }, + { lods: [{ count: 10, error: 0 }, { count: 5, error: 1000 }] } + ]); + inst.nodeInfos[0].lodCoverage = 1; + inst.nodeInfos[1].lodCoverage = 0.1; + + new GSplatBudgetBalancer().balance(instances, 15); + + expect(inst.nodeInfos.map(info => info.optimalLod)).to.deep.equal([1, 0]); + }); + + it('skips trained LODs dominated in both count and error', function () { + const { inst, instances } = makeInstances([{ + lods: [ + { count: 10, error: 0 }, + { count: 8, error: 5 }, + { count: 5, error: 4 } + ] + }]); + + new GSplatBudgetBalancer().balance(instances, 15); + + expect(inst.nodeInfos[0].optimalLod).to.equal(0); + }); + + it('does not let a duplicated LOD cost a node its priority', function () { + // Node 0's LODs 1 and 2 are indistinguishable in both cost and error. Keeping + // both would put a zero-cost 0/0-priority transition in its chain, and since + // priorities are accumulated with Math.min, that NaN demotes every later + // transition on the node to the lowest bucket. Node 0's upgrade is worth 5x + // node 1's, so with only one upgrade's worth of spare budget it should win. + const { inst, instances } = makeInstances([ + { lods: [{ count: 10, error: 0 }, { count: 5, error: 5 }, { count: 5, error: 5 }] }, + { lods: [{ count: 10, error: 9 }, { count: 5, error: 10 }, { count: 0, error: 10 }] } + ]); + + // seeds are 5 + 5 splats, so a budget of 15 affords exactly one cost-5 upgrade + new GSplatBudgetBalancer().balance(instances, 15); + + expect(inst.nodeInfos[0].optimalLod).to.equal(0); + expect(inst.nodeInfos[1].optimalLod).to.equal(1); + }); + + it('loads per-LOD errors from streamed SOG metadata', function () { + const octree = makeOctree([0, 12.5], true); + + expect(octree.nodes[0].lods.map(lod => lod.error)).to.deep.equal([0, 12.5]); + expect(octree.lodErrors).to.equal(true); + }); + + it('reports no usable errors when the manifest does not declare them', function () { + // pre-3.3 manifests carry no lodErrors header; the values are ignored rather + // than trusted + expect(makeOctree([0, 12.5], undefined).lodErrors).to.equal(false); + }); + + it('reports no usable errors when a declared error is not finite', function () { + expect(makeOctree([0, null], true).lodErrors).to.equal(false); + expect(makeOctree([0], true).lodErrors).to.equal(false); + }); + + it('reports no usable errors when a declared error is negative', function () { + // errors are magnitudes relative to the finest LOD; a negative one would let + // the coarse level dominate every finer level on the frontier + expect(makeOctree([0, -3], true).lodErrors).to.equal(false); + }); + + it('ignores errors on LOD levels that hold no splats', function () { + expect(makeOctree([0, null], true, 0).lodErrors).to.equal(true); + }); +});