From 0662d398786df1d9a9ea0240ead0f578e0773df3 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Sat, 1 Aug 2026 21:02:29 +0100 Subject: [PATCH 1/4] latest --- .../gsplat-unified/gsplat-budget-balancer.js | 200 +++++++++++++++++- .../gsplat-unified/gsplat-octree-instance.js | 8 + .../gsplat-unified/gsplat-octree-node.js | 1 + src/scene/gsplat-unified/gsplat-octree.js | 9 +- .../gsplat-budget-balancer.test.mjs | 80 +++++++ 5 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs diff --git a/src/scene/gsplat-unified/gsplat-budget-balancer.js b/src/scene/gsplat-unified/gsplat-budget-balancer.js index ab4ba361b5e..77981e2d5cb 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,185 @@ 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) { + let completeErrors = true; + let activeNodes = 0; + 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; + activeNodes++; + const lods = nodes[nodeIndex].lods; + for (let lod = inst.rangeMin; lod <= inst.rangeMax; lod++) { + if (lods[lod].count > 0 && !Number.isFinite(lods[lod].error)) { + completeErrors = false; + break; + } + } + if (!completeErrors) break; + } + if (!completeErrors) break; + } + + 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; + + let frontierCount = 0; + for (let lod = inst.rangeMin; lod <= inst.rangeMax; lod++) { + if (lods[lod].count <= 0) continue; + let dominated = false; + for (let other = inst.rangeMin; other <= inst.rangeMax; other++) { + if (other === lod || lods[other].count <= 0) continue; + const noMoreCost = lods[other].count <= lods[lod].count; + const noMoreError = lods[other].error <= lods[lod].error; + const strictlyBetter = lods[other].count < lods[lod].count || lods[other].error < lods[lod].error; + if (noMoreCost && noMoreError && strictlyBetter) { + dominated = true; + break; + } + } + if (!dominated) this._frontierScratch[frontierCount++] = lod; + } + + if (frontierCount === 0) { + nodeInfo.optimalLod = -1; + continue; + } + + for (let i = 1; i < frontierCount; i++) { + const lod = this._frontierScratch[i]; + let j = i; + while (j > 0 && lods[this._frontierScratch[j - 1]].count > lods[lod].count) { + this._frontierScratch[j] = this._frontierScratch[j - 1]; + j--; + } + this._frontierScratch[j] = lod; + } + + const errorNodeIndex = this._errorNodes.length; + this._errorNodes.push(nodeInfo); + nodeInfo.optimalLod = this._frontierScratch[0]; + currentSplats += lods[nodeInfo.optimalLod].count; + + let previousPriority = Infinity; + for (let i = 1; i < frontierCount; i++) { + const coarseLod = this._frontierScratch[i - 1]; + const fineLod = this._frontierScratch[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..46ef57df370 100644 --- a/src/scene/gsplat-unified/gsplat-octree.js +++ b/src/scene/gsplat-unified/gsplat-octree.js @@ -148,7 +148,8 @@ class GSplatOctree { file: this.files[lodData.file].url || '', fileIndex: lodData.file, offset: lodData.offset || 0, - count: lodData.count || 0 + count: lodData.count || 0, + error: nodeData.errors?.[i] }); // record LOD level for the file index @@ -159,7 +160,8 @@ class GSplatOctree { file: '', fileIndex: -1, offset: 0, - count: 0 + count: 0, + error: nodeData.errors?.[i] }); } } @@ -238,7 +240,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..b1add9164b1 --- /dev/null +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -0,0 +1,80 @@ +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) => { + const inst = { + octree: { nodes }, + 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]]) }; +}; + +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('loads per-LOD errors from streamed SOG metadata', function () { + const octree = new GSplatOctree('/scene/lod-meta.json', { + lodLevels: 2, + filenames: ['0/meta.json', '1/meta.json'], + tree: { + bound: { min: [0, 0, 0], max: [1, 1, 1] }, + errors: [0, 12.5], + lods: { + 0: { file: 0, offset: 0, count: 10 }, + 1: { file: 1, offset: 0, count: 5 } + } + } + }); + + expect(octree.nodes[0].lods.map(lod => lod.error)).to.deep.equal([0, 12.5]); + }); +}); From 8d9a0cf0921794299b1d54a3dec5b5da61cb816c Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 10 Aug 2026 11:25:33 +0200 Subject: [PATCH 2/4] latest --- .../gsplat-unified/gsplat-budget-balancer.js | 31 ++++++------ src/scene/gsplat-unified/gsplat-octree.js | 33 +++++++++++- .../gsplat-budget-balancer.test.mjs | 50 ++++++++++++++----- 3 files changed, 85 insertions(+), 29 deletions(-) diff --git a/src/scene/gsplat-unified/gsplat-budget-balancer.js b/src/scene/gsplat-unified/gsplat-budget-balancer.js index 77981e2d5cb..95185da5453 100644 --- a/src/scene/gsplat-unified/gsplat-budget-balancer.js +++ b/src/scene/gsplat-unified/gsplat-budget-balancer.js @@ -76,24 +76,27 @@ class GSplatBudgetBalancer { * @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; - let activeNodes = 0; 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; - activeNodes++; - const lods = nodes[nodeIndex].lods; - for (let lod = inst.rangeMin; lod <= inst.rangeMax; lod++) { - if (lods[lod].count > 0 && !Number.isFinite(lods[lod].error)) { - completeErrors = false; - break; - } + 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) break; } - if (!completeErrors) break; } if (completeErrors && activeNodes > 0) { diff --git a/src/scene/gsplat-unified/gsplat-octree.js b/src/scene/gsplat-unified/gsplat-octree.js index 46ef57df370..91132a6506e 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 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,13 +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, - error: nodeData.errors?.[i] + error }); // record LOD level for the file index @@ -161,14 +179,25 @@ class GSplatOctree { fileIndex: -1, offset: 0, count: 0, - error: nodeData.errors?.[i] + error }); } + + // an unusable error on a level that can be rendered rules out + // error-driven allocation for the whole octree + if (lodErrors && lods[i].count > 0 && !Number.isFinite(error)) { + 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 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); diff --git a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs index b1add9164b1..cdc4fa6e972 100644 --- a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -4,8 +4,11 @@ import { GSplatBudgetBalancer } from '../../../src/scene/gsplat-unified/gsplat-b 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 }, + octree: { nodes, lodErrors }, nodeInfos: nodes.map(() => ({ optimalLod: 0, budgetBucket: 0, @@ -20,6 +23,22 @@ const makeInstances = (nodes, rangeMin = 0, rangeMax = nodes[0].lods.length - 1) 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([ @@ -62,19 +81,24 @@ describe('GSplatBudgetBalancer', function () { }); it('loads per-LOD errors from streamed SOG metadata', function () { - const octree = new GSplatOctree('/scene/lod-meta.json', { - lodLevels: 2, - filenames: ['0/meta.json', '1/meta.json'], - tree: { - bound: { min: [0, 0, 0], max: [1, 1, 1] }, - errors: [0, 12.5], - lods: { - 0: { file: 0, offset: 0, count: 10 }, - 1: { file: 1, offset: 0, count: 5 } - } - } - }); + 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('ignores errors on LOD levels that hold no splats', function () { + expect(makeOctree([0, null], true, 0).lodErrors).to.equal(true); }); }); From db994b276e8f6ccbac1aca70f407b66672580bb7 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 10 Aug 2026 12:09:06 +0200 Subject: [PATCH 3/4] latest --- .../gsplat-unified/gsplat-budget-balancer.js | 60 ++++++++++++------- .../gsplat-budget-balancer.test.mjs | 18 ++++++ 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/scene/gsplat-unified/gsplat-budget-balancer.js b/src/scene/gsplat-unified/gsplat-budget-balancer.js index 95185da5453..53902761f06 100644 --- a/src/scene/gsplat-unified/gsplat-budget-balancer.js +++ b/src/scene/gsplat-unified/gsplat-budget-balancer.js @@ -139,21 +139,45 @@ class GSplatBudgetBalancer { const lods = nodes[nodeIndex].lods; nodeInfo.lods = lods; - let frontierCount = 0; + // 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 dominated = false; - for (let other = inst.rangeMin; other <= inst.rangeMax; other++) { - if (other === lod || lods[other].count <= 0) continue; - const noMoreCost = lods[other].count <= lods[lod].count; - const noMoreError = lods[other].error <= lods[lod].error; - const strictlyBetter = lods[other].count < lods[lod].count || lods[other].error < lods[lod].error; - if (noMoreCost && noMoreError && strictlyBetter) { - dominated = true; + 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 (!dominated) this._frontierScratch[frontierCount++] = lod; } if (frontierCount === 0) { @@ -161,25 +185,15 @@ class GSplatBudgetBalancer { continue; } - for (let i = 1; i < frontierCount; i++) { - const lod = this._frontierScratch[i]; - let j = i; - while (j > 0 && lods[this._frontierScratch[j - 1]].count > lods[lod].count) { - this._frontierScratch[j] = this._frontierScratch[j - 1]; - j--; - } - this._frontierScratch[j] = lod; - } - const errorNodeIndex = this._errorNodes.length; this._errorNodes.push(nodeInfo); - nodeInfo.optimalLod = this._frontierScratch[0]; + nodeInfo.optimalLod = scratch[0]; currentSplats += lods[nodeInfo.optimalLod].count; let previousPriority = Infinity; for (let i = 1; i < frontierCount; i++) { - const coarseLod = this._frontierScratch[i - 1]; - const fineLod = this._frontierScratch[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); diff --git a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs index cdc4fa6e972..b4d7f1d5083 100644 --- a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -80,6 +80,24 @@ describe('GSplatBudgetBalancer', function () { 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); From 11051cd073118d678c98449ae904e3d02361cb20 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Mon, 10 Aug 2026 15:25:51 +0200 Subject: [PATCH 4/4] latest --- src/scene/gsplat-unified/gsplat-octree.js | 16 ++++++++++------ .../gsplat-budget-balancer.test.mjs | 6 ++++++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/scene/gsplat-unified/gsplat-octree.js b/src/scene/gsplat-unified/gsplat-octree.js index 91132a6506e..af93d28f463 100644 --- a/src/scene/gsplat-unified/gsplat-octree.js +++ b/src/scene/gsplat-unified/gsplat-octree.js @@ -39,8 +39,8 @@ class GSplatOctree { /** * True when the manifest declares per-node approximation errors and every node - * supplies a finite one for each of its non-empty LOD levels, which allows - * error-driven budget allocation. Established here, once, so the budget + * 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 @@ -183,9 +183,13 @@ class GSplatOctree { }); } - // an unusable error on a level that can be rendered rules out - // error-driven allocation for the whole octree - if (lodErrors && lods[i].count > 0 && !Number.isFinite(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; } } @@ -195,7 +199,7 @@ class GSplatOctree { this.lodErrors = lodErrors; if (data.lodErrors === true && !lodErrors) { - Debug.warn(`GSplatOctree: ${assetFileUrl} declares lodErrors but does not supply a finite error for every non-empty LOD level, falling back to distance-based LOD allocation.`); + 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 diff --git a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs index b4d7f1d5083..c7d54b877c0 100644 --- a/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs +++ b/test/scene/gsplat-unified/gsplat-budget-balancer.test.mjs @@ -116,6 +116,12 @@ describe('GSplatBudgetBalancer', function () { 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); });