Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 211 additions & 6 deletions src/scene/gsplat-unified/gsplat-budget-balancer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
Expand All @@ -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<GSplatPlacement, GSplatOctreeInstance>} 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<GSplatPlacement, GSplatOctreeInstance>} 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<GSplatPlacement, GSplatOctreeInstance>} octreeInstances - Octree instances.
* @param {number} budget - Target splat budget.
* @private
*/
_balanceDistance(octreeInstances, budget) {
// Initialize buckets on first use
this._initBuckets();

Expand Down
8 changes: 8 additions & 0 deletions src/scene/gsplat-unified/gsplat-octree-instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/scene/gsplat-unified/gsplat-octree-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
42 changes: 39 additions & 3 deletions src/scene/gsplat-unified/gsplat-octree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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[]} */
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading