playcanvas
Version:
PlayCanvas WebGL game engine
867 lines (864 loc) • 41.3 kB
JavaScript
import { Debug } from '../../core/debug.js';
import { Mat4 } from '../../core/math/mat4.js';
import { Vec2 } from '../../core/math/vec2.js';
import { Vec3 } from '../../core/math/vec3.js';
import { BoundingBox } from '../../core/shape/bounding-box.js';
import { Color } from '../../core/math/color.js';
import { GSplatPlacement } from './gsplat-placement.js';
/**
* @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js'
* @import { GraphNode } from '../graph-node.js'
* @import { GSplatOctree } from './gsplat-octree.js'
* @import { Scene } from '../scene.js'
* @import { EventHandle } from '../../core/event-handle.js'
*/ const _invWorldMat = new Mat4();
const _localCameraPos = new Vec3();
const _localCameraFwd = new Vec3();
const _dirToNode = new Vec3();
const _tempCompletedUrls = [];
const _tempDebugAabb = new BoundingBox();
// Color instances used by debug wireframe rendering for LOD visualization
const _lodColors = [
new Color(1, 0, 0),
new Color(0, 1, 0),
new Color(0, 0, 1),
new Color(1, 1, 0),
new Color(1, 0, 1)
];
/**
* Stores LOD state for a single octree node.
*
* @ignore
*/ class NodeInfo {
/**
* Resets all LOD values to -1 (invisible/uninitialized).
*/ reset() {
this.currentLod = -1;
this.optimalLod = -1;
this.importance = 0;
}
constructor(){
/**
* Current LOD index being rendered. -1 indicates node is not visible.
* @type {number}
*/ this.currentLod = -1;
/**
* Optimal LOD index based on distance/visibility (before underfill).
* @type {number}
*/ this.optimalLod = -1;
/**
* Importance of this node (0..1 range, higher = more important).
* Used for budget enforcement - higher importance nodes maintain quality when budget is exceeded.
* @type {number}
*/ this.importance = 0;
}
}
class GSplatOctreeInstance {
/**
* Returns the count of resources pending load or prefetch, including environment if loading.
*
* @type {number}
*/ get pendingLoadCount() {
let count = this.pending.size + this.prefetchPending.size;
// Add environment if it's configured but not yet loaded
if (this.octree.environmentUrl && !this.environmentPlacement) {
count++;
}
return count;
}
/**
* @param {GraphicsDevice} device - The graphics device.
* @param {GSplatOctree} octree - The octree.
* @param {GSplatPlacement} placement - The placement.
*/ constructor(device, octree, placement){
/** @type {Set<GSplatPlacement>} */ this.activePlacements = new Set();
/** @type {boolean} */ this.dirtyModifiedPlacements = false;
/**
* Set of pending file loads (file indices).
* @type {Set<number>}
*/ this.pending = new Set();
/**
* Map of nodeIndex -> { oldFileIndex, newFileIndex } that needs to be decremented when the
* new LOD resource loads. This ensures we decrement even if the node switches LOD again
* before the new resource arrives.
*
* @type {Map<number, { oldFileIndex: number, newFileIndex: number }>}
*/ this.pendingDecrements = new Map();
/**
* Files that became unused by this instance this update. Each entry represents a single decRef.
*
* @type {Set<number>}
*/ this.removedCandidates = new Set();
/**
* Previous node position at which LOD was last updated. This is used to determine if LOD needs
* to be updated as the octree splat moves.
*
* @type {Vec3}
*/ this.previousPosition = new Vec3();
/**
* Set when a resource has completed loading and LOD should be re-evaluated.
*
* @type {boolean}
*/ this.needsLodUpdate = false;
/**
* Tracks prefetched file indices that are being loaded without active placements.
* When any completes, we trigger LOD re-evaluation to allow promotion.
*
* @type {Set<number>}
*/ this.prefetchPending = new Set();
/**
* Tracks invisible->visible pending adds per node: nodeIndex -> fileIndex.
* Ensures only a single pending placement exists for a node while it's not yet displayed.
* @type {Map<number, number>}
*/ this.pendingVisibleAdds = new Map();
/**
* Cached splat budget value.
* @type {number}
*/ this.splatBudget = 0;
/**
* Reusable array of node indices for budget enforcement sorting.
* Lazy-allocated on first budget enforcement, then reused.
* @type {Uint32Array|null}
* @private
*/ this._nodeIndices = null;
/**
* Environment placement.
* @type {GSplatPlacement|null}
*/ this.environmentPlacement = null;
/**
* Event handle for device lost event.
*
* @type {EventHandle|null}
* @private
*/ this._deviceLostEvent = null;
this.device = device;
this.octree = octree;
this.placement = placement;
// Initialize nodeInfos array with NodeInfo instances for all nodes
this.nodeInfos = new Array(octree.nodes.length);
for(let i = 0; i < octree.nodes.length; i++){
this.nodeInfos[i] = new NodeInfo();
}
// Initialize file placements array
const numFiles = octree.files.length;
this.filePlacements = new Array(numFiles).fill(null);
// Handle environment if configured
if (octree.environmentUrl) {
octree.incEnvironmentRefCount();
octree.ensureEnvironmentResource();
}
// Register device lost handler
this._deviceLostEvent = device.on('devicelost', this._onDeviceLost, this);
}
/**
* Destroys this octree instance and clears internal references.
*/ destroy() {
// Only decrement refs if octree is still alive
// Skip ref counting if octree was force-destroyed (e.g., asset unloaded)
if (this.octree && !this.octree.destroyed) {
// Decrement ref counts for all files currently in use (loaded files)
const filesToDecRef = this.getFileDecrements();
for (const fileIndex of filesToDecRef){
this.octree.decRefCount(fileIndex, 0);
}
// Also unload files that are pending (requested but not loaded yet)
for (const fileIndex of this.pending){
// Skip if already in filePlacements (already handled above)
if (!this.filePlacements[fileIndex]) {
this.octree.unloadResource(fileIndex);
}
}
// Same for prefetch pending
for (const fileIndex of this.prefetchPending){
if (!this.filePlacements[fileIndex]) {
this.octree.unloadResource(fileIndex);
}
}
// Clean up environment if present
if (this.environmentPlacement) {
this.octree.decEnvironmentRefCount();
}
}
this.pending.clear();
this.pendingDecrements.clear();
this.filePlacements.length = 0;
// Clean up environment placement
if (this.environmentPlacement) {
this.activePlacements.delete(this.environmentPlacement);
this.environmentPlacement = null;
}
// Remove device event listener
this._deviceLostEvent?.off();
this._deviceLostEvent = null;
}
/**
* Handles device lost event by releasing all loaded resources.
*
* @private
*/ _onDeviceLost() {
// Decrement ref counts for all currently loaded file resources
for(let i = 0; i < this.filePlacements.length; i++){
if (this.filePlacements[i]) {
// zero cooldown, immediate unload
this.octree.decRefCount(i, 0);
}
}
// Clear all internal state
this.filePlacements.fill(null);
this.activePlacements.clear();
this.pending.clear();
this.pendingDecrements.clear();
this.removedCandidates.clear();
this.prefetchPending.clear();
this.pendingVisibleAdds.clear();
// Reset all nodes to invisible
for (const nodeInfo of this.nodeInfos){
nodeInfo.reset();
}
// Clean up environment if present
if (this.environmentPlacement) {
this.activePlacements.delete(this.environmentPlacement);
this.environmentPlacement = null;
this.octree.unloadEnvironmentResource();
}
// Mark that LOD needs to be re-evaluated after context restore
this.dirtyModifiedPlacements = true;
this.needsLodUpdate = true;
}
/**
* Returns the file indices currently referenced by this instance that should be decremented
* when the instance is destroyed.
*
* @returns {number[]} Array of file indices to decRef.
*/ getFileDecrements() {
const toRelease = [];
for(let i = 0; i < this.filePlacements.length; i++){
if (this.filePlacements[i]) {
toRelease.push(i);
}
}
return toRelease;
}
/**
* Calculate LOD index for a specific node using pre-calculated local camera position.
* @param {Vec3} localCameraPosition - The camera position in local space.
* @param {Vec3} localCameraForward - The camera forward direction in local space (normalized).
* @param {number} nodeIndex - The node index.
* @param {number} maxLod - The maximum LOD index (lodLevels - 1).
* @param {number[]} lodDistances - Array of distance thresholds per LOD.
* @param {number} lodBehindPenalty - Multiplier for behind-camera distance. 1 disables penalty.
* @returns {number} The LOD index for this node, or -1 if node should not be rendered.
*/ calculateNodeLod(localCameraPosition, localCameraForward, nodeIndex, maxLod, lodDistances, lodBehindPenalty) {
const node = this.octree.nodes[nodeIndex];
// Calculate the nearest point on the bounding box to the camera for accurate distance
node.bounds.closestPoint(localCameraPosition, _dirToNode);
// Calculate direction from camera to nearest point on box
_dirToNode.sub(localCameraPosition);
let distance = _dirToNode.length();
// Apply angular-based multiplier for nodes behind the camera when enabled
if (lodBehindPenalty > 1 && distance > 0.01) {
// dot using unnormalized direction to avoid extra normalize; divide by distance
const dotOverDistance = localCameraForward.dot(_dirToNode) / distance;
// Only apply penalty when behind the camera (dot < 0)
if (dotOverDistance < 0) {
const t = -dotOverDistance; // 0 .. 1 for front -> directly behind
const factor = 1 + t * (lodBehindPenalty - 1);
distance *= factor;
}
}
// Find appropriate LOD based on distance and available LOD levels
for(let lod = 0; lod < maxLod; lod++){
if (distance < lodDistances[lod]) {
return lod;
}
}
// If distance is greater than all thresholds, use the highest available LOD
return maxLod;
// return -1 for past far plane
}
/**
* Selects desired LOD index for a node using the underfill strategy. When underfill is enabled,
* it prefers already-loaded LODs within [optimalLodIndex .. optimalLodIndex + lodUnderfillLimit].
* If none are loaded, it selects the coarsest available LOD within the range.
*
* @param {import('./gsplat-octree-node.js').GSplatOctreeNode} node - The octree node.
* @param {number} optimalLodIndex - Optimal LOD index based on camera/distance.
* @param {number} maxLod - Maximum LOD index.
* @param {number} lodUnderfillLimit - Allowed coarse range above optimal.
* @returns {number} Desired LOD index to display.
*/ selectDesiredLodIndex(node, optimalLodIndex, maxLod, lodUnderfillLimit) {
if (lodUnderfillLimit > 0) {
const allowedMaxCoarseLod = Math.min(maxLod, optimalLodIndex + lodUnderfillLimit);
// prefer highest quality already-loaded within the allowed range
for(let lod = optimalLodIndex; lod <= allowedMaxCoarseLod; lod++){
const fi = node.lods[lod].fileIndex;
if (fi !== -1 && this.octree.getFileResource(fi)) {
return lod;
}
}
// fallback: choose the coarsest available within the range
for(let lod = allowedMaxCoarseLod; lod >= optimalLodIndex; lod--){
const fi = node.lods[lod].fileIndex;
if (fi !== -1) {
return lod;
}
}
}
return optimalLodIndex;
}
/**
* Prefetch only the next-better LOD toward optimal. This stages loading in steps across all
* nodes, avoiding intermixing requests before coarse is present.
*
* @param {import('./gsplat-octree-node.js').GSplatOctreeNode} node - The octree node.
* @param {number} desiredLodIndex - Currently selected LOD for display (may be coarser than optimal).
* @param {number} optimalLodIndex - Target optimal LOD.
*/ prefetchNextLod(node, desiredLodIndex, optimalLodIndex) {
if (desiredLodIndex === -1 || optimalLodIndex === -1) return;
// If we're already at optimal but it's not loaded yet, request it
if (desiredLodIndex === optimalLodIndex) {
const fi = node.lods[optimalLodIndex].fileIndex;
if (fi !== -1) {
this.octree.ensureFileResource(fi);
if (!this.octree.getFileResource(fi)) {
this.prefetchPending.add(fi);
}
}
return;
}
// Step one level finer toward optimal
const targetLod = Math.max(optimalLodIndex, desiredLodIndex - 1);
// Find first valid fileIndex between targetLod..optimalLodIndex
for(let lod = targetLod; lod >= optimalLodIndex; lod--){
const fi = node.lods[lod].fileIndex;
if (fi !== -1) {
this.octree.ensureFileResource(fi);
if (!this.octree.getFileResource(fi)) {
this.prefetchPending.add(fi);
}
break;
}
}
}
/**
* Updates the octree instance when LOD needs to be updated.
*
* @param {GraphNode} cameraNode - The camera node.
* @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters.
*/ updateLod(cameraNode, params) {
const maxLod = this.octree.lodLevels - 1;
const lodDistances = this.placement.lodDistances || [
5,
10,
15,
20,
25,
30,
35,
40,
45,
50,
55,
60
];
// Clamp configured LOD range to valid bounds [0, maxLod] and ensure min <= max
const { lodRangeMin, lodRangeMax } = params;
const rangeMin = Math.max(0, Math.min(lodRangeMin ?? 0, maxLod));
const rangeMax = Math.max(rangeMin, Math.min(lodRangeMax ?? maxLod, maxLod));
// Pass 1: Evaluate optimal LOD for each node (distance-based)
const totalOptimalSplats = this.evaluateNodeLods(cameraNode, maxLod, lodDistances, rangeMin, rangeMax, params);
// Enforce splat budget if enabled (bidirectional: degrade or upgrade)
if (this.splatBudget > 0) {
this.enforceSplatBudget(totalOptimalSplats, this.splatBudget, rangeMin, rangeMax);
}
// Pass 2: Calculate desired LOD (underfill) and apply changes
this.applyLodChanges(maxLod, params);
}
/**
* Evaluates optimal LOD indices for all nodes based on camera position and parameters.
* This is Pass 1 of the LOD update process. Results are stored in nodeInfos array.
*
* @param {GraphNode} cameraNode - The camera node.
* @param {number} maxLod - Maximum LOD index (lodLevels - 1).
* @param {number[]} lodDistances - Array of distance thresholds per LOD.
* @param {number} rangeMin - Minimum allowed LOD index.
* @param {number} rangeMax - Maximum allowed LOD index.
* @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters.
* @returns {number} Total number of splats that would be used by optimal LODs.
* @private
*/ evaluateNodeLods(cameraNode, maxLod, lodDistances, rangeMin, rangeMax, params) {
const { lodBehindPenalty } = params;
// transform camera position to octree local space
const worldCameraPosition = cameraNode.getPosition();
const octreeWorldTransform = this.placement.node.getWorldTransform();
_invWorldMat.copy(octreeWorldTransform).invert();
const localCameraPosition = _invWorldMat.transformPoint(worldCameraPosition, _localCameraPos);
const worldCameraForward = cameraNode.forward;
const localCameraForward = _invWorldMat.transformVector(worldCameraForward, _localCameraFwd).normalize();
const nodes = this.octree.nodes;
const nodeInfos = this.nodeInfos;
let totalSplats = 0;
// Use distance threshold for max LOD range to normalize importance
const maxDistance = lodDistances[rangeMax] || 100;
for(let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++){
const node = nodes[nodeIndex];
// Calculate the nearest point on the bounding box to the camera for accurate distance
node.bounds.closestPoint(localCameraPosition, _dirToNode);
// Calculate direction from camera to nearest point on box
_dirToNode.sub(localCameraPosition);
const actualDistance = _dirToNode.length();
// Apply angular-based multiplier for nodes behind the camera when enabled
let penalizedDistance = actualDistance;
let importanceMultiplier = 1.0;
if (lodBehindPenalty > 1 && actualDistance > 0.01) {
// dot using unnormalized direction to avoid extra normalize; divide by distance
const dotOverDistance = localCameraForward.dot(_dirToNode) / actualDistance;
// Only apply penalty when behind the camera (dot < 0)
if (dotOverDistance < 0) {
const t = -dotOverDistance; // 0 .. 1 for front -> directly behind
const factor = 1 + t * (lodBehindPenalty - 1);
penalizedDistance = actualDistance * factor;
importanceMultiplier = 1.0 / factor; // inverse for importance
}
}
// Find appropriate LOD based on penalized distance
let optimalLodIndex = maxLod;
for(let lod = 0; lod < maxLod; lod++){
if (penalizedDistance < lodDistances[lod]) {
optimalLodIndex = lod;
break;
}
}
// Clamp to configured range
if (optimalLodIndex < rangeMin) optimalLodIndex = rangeMin;
if (optimalLodIndex > rangeMax) optimalLodIndex = rangeMax;
// Calculate importance: inverse of distance, normalized, with behind-camera penalty
const normalizedDistance = Math.min(actualDistance / maxDistance, 1.0);
const importance = (1.0 - normalizedDistance) * importanceMultiplier;
// Store optimal LOD and importance
nodeInfos[nodeIndex].optimalLod = optimalLodIndex;
nodeInfos[nodeIndex].importance = importance;
// Count splats for this optimal LOD
const lod = nodes[nodeIndex].lods[optimalLodIndex];
if (lod && lod.count) {
totalSplats += lod.count;
}
}
return totalSplats;
}
/**
* Adjusts optimal LOD indices to fit within the splat budget bidirectionally.
* When over budget: degrades quality for lower-importance nodes first.
* When under budget: upgrades quality for higher-importance nodes first.
* Uses multiple passes, adjusting by one level per pass, until budget is reached
* or all nodes hit their respective limits (rangeMin or rangeMax).
*
* @param {number} totalSplats - Current total splat count with optimal LODs.
* @param {number} splatBudget - Target splat count to reach.
* @param {number} rangeMin - Minimum allowed LOD index.
* @param {number} rangeMax - Maximum allowed LOD index.
* @private
*/ enforceSplatBudget(totalSplats, splatBudget, rangeMin, rangeMax) {
const nodes = this.octree.nodes;
const nodeInfos = this.nodeInfos;
// Lazy-allocate node indices array on first use
if (!this._nodeIndices) {
this._nodeIndices = new Uint32Array(nodes.length);
for(let i = 0; i < nodes.length; i++){
this._nodeIndices[i] = i;
}
}
// Sort node indices by importance (lowest first) - done once
const nodeIndices = this._nodeIndices;
nodeIndices.sort((a, b)=>nodeInfos[a].importance - nodeInfos[b].importance);
let currentSplats = totalSplats;
// Skip if already at budget
if (currentSplats === splatBudget) {
return;
}
// Determine direction and set iteration parameters
const isOverBudget = currentSplats > splatBudget;
const lodDelta = isOverBudget ? 1 : -1;
// Multiple passes: adjust by one LOD level per pass until budget is reached
while(isOverBudget ? currentSplats > splatBudget : currentSplats < splatBudget){
let modified = false;
if (isOverBudget) {
// DEGRADE: process from lowest to highest importance
for(let i = 0; i < nodeIndices.length; i++){
const nodeIndex = nodeIndices[i];
const nodeInfo = nodeInfos[nodeIndex];
const node = nodes[nodeIndex];
const currentOptimalLod = nodeInfo.optimalLod;
// Try degrading to next coarser LOD (respect rangeMax constraint)
if (currentOptimalLod < rangeMax) {
const currentLod = node.lods[currentOptimalLod];
const nextLod = node.lods[currentOptimalLod + 1];
const splatsSaved = currentLod.count - nextLod.count;
// Degrade to coarser LOD
nodeInfo.optimalLod += lodDelta;
currentSplats -= splatsSaved;
modified = true;
if (currentSplats <= splatBudget) {
break; // Within budget
}
}
}
} else {
// UPGRADE: process from highest to lowest importance
for(let i = nodeIndices.length - 1; i >= 0; i--){
const nodeIndex = nodeIndices[i];
const nodeInfo = nodeInfos[nodeIndex];
const node = nodes[nodeIndex];
const currentOptimalLod = nodeInfo.optimalLod;
// Try upgrading to next finer LOD (respect rangeMin constraint)
if (currentOptimalLod > rangeMin) {
const currentLod = node.lods[currentOptimalLod];
const nextLod = node.lods[currentOptimalLod - 1];
const splatsAdded = nextLod.count - currentLod.count;
// Only upgrade if we won't exceed budget
if (currentSplats + splatsAdded <= splatBudget) {
// Upgrade to finer LOD
nodeInfo.optimalLod += lodDelta;
currentSplats += splatsAdded;
modified = true;
if (currentSplats >= splatBudget) {
break; // At budget
}
}
}
}
}
// If no nodes were modified, we can't adjust further
if (!modified) {
break;
}
}
}
/**
* Applies calculated LOD changes and manages file placements.
* This is Pass 2 of the LOD update process. Reads from nodeInfos array populated by evaluateNodeLods().
*
* @param {number} maxLod - Maximum LOD index (lodLevels - 1).
* @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters.
* @private
*/ applyLodChanges(maxLod, params) {
const nodes = this.octree.nodes;
const { lodUnderfillLimit = 0 } = params;
for(let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++){
const node = nodes[nodeIndex];
const nodeInfo = this.nodeInfos[nodeIndex];
const optimalLodIndex = nodeInfo.optimalLod;
const currentLodIndex = nodeInfo.currentLod;
// Apply underfill strategy to determine desired LOD for streaming
const desiredLodIndex = this.selectDesiredLodIndex(node, optimalLodIndex, maxLod, lodUnderfillLimit);
// if desired LOD differs from currently displayed LOD
if (desiredLodIndex !== currentLodIndex) {
// Determine visibility based on the presence of a valid file index
const currentFileIndex = currentLodIndex >= 0 ? node.lods[currentLodIndex].fileIndex : -1;
const desiredFileIndex = desiredLodIndex >= 0 ? node.lods[desiredLodIndex].fileIndex : -1;
const wasVisible = currentFileIndex !== -1;
const willBeVisible = desiredFileIndex !== -1;
// if there's a pending transition, manage it without dropping the currently visible LOD
const pendingEntry = this.pendingDecrements.get(nodeIndex);
if (pendingEntry) {
// if desired target changed while previous target was still loading, cancel previous target for this node
if (pendingEntry.newFileIndex !== desiredFileIndex) {
// remove this node's interval from the previously pending target if it still exists
const prevPendingPlacement = this.filePlacements[pendingEntry.newFileIndex];
if (prevPendingPlacement) {
this.decrementFileRef(pendingEntry.newFileIndex, nodeIndex);
}
// update or clear pending transition
if (wasVisible && willBeVisible) {
this.pendingDecrements.set(nodeIndex, {
oldFileIndex: pendingEntry.oldFileIndex,
newFileIndex: desiredFileIndex
});
} else {
// no longer targeting a visible LOD; clear pending and let normal logic handle hide/show
this.pendingDecrements.delete(nodeIndex);
}
}
// if target stays the same, keep pending as-is until the resource loads
}
if (!wasVisible && willBeVisible) {
// becoming visible (invisible -> visible)
// if we had a previous pending visible-add for a different file, cancel it
const prevPendingFi = this.pendingVisibleAdds.get(nodeIndex);
if (prevPendingFi !== undefined && prevPendingFi !== desiredFileIndex) {
this.decrementFileRef(prevPendingFi, nodeIndex);
this.pendingVisibleAdds.delete(nodeIndex);
}
this.incrementFileRef(desiredFileIndex, nodeIndex, desiredLodIndex);
const newPlacement = this.filePlacements[desiredFileIndex];
if (newPlacement?.resource) {
// resource is ready now, display immediately
nodeInfo.currentLod = desiredLodIndex;
// clear any pending visible-add entry
this.pendingVisibleAdds.delete(nodeIndex);
} else {
// keep displayed as invisible until resource arrives; next update will promote
this.pendingVisibleAdds.set(nodeIndex, desiredFileIndex);
}
} else if (wasVisible && !willBeVisible) {
// becoming invisible (visible -> invisible)
// if there was a pending target for this node, cancel it first
const pendingEntry2 = this.pendingDecrements.get(nodeIndex);
if (pendingEntry2) {
this.decrementFileRef(pendingEntry2.newFileIndex, nodeIndex);
this.pendingDecrements.delete(nodeIndex);
}
this.decrementFileRef(currentFileIndex, nodeIndex);
nodeInfo.currentLod = -1;
// clear any pending visible-add entry
this.pendingVisibleAdds.delete(nodeIndex);
} else if (wasVisible && willBeVisible) {
// switching between visible LODs (visible -> visible)
this.incrementFileRef(desiredFileIndex, nodeIndex, desiredLodIndex);
const newPlacement = this.filePlacements[desiredFileIndex];
if (newPlacement?.resource) {
// new LOD ready - remove old LOD immediately
this.decrementFileRef(currentFileIndex, nodeIndex);
// clear any pending for this node if exists
this.pendingDecrements.delete(nodeIndex);
// update displayed lod now that switch is complete
nodeInfo.currentLod = desiredLodIndex;
// clear any pending visible-add entry
this.pendingVisibleAdds.delete(nodeIndex);
} else {
// new LOD not ready - track pending decrement for when it loads
this.pendingDecrements.set(nodeIndex, {
oldFileIndex: currentFileIndex,
newFileIndex: desiredFileIndex
});
// keep displayed lod as current until pending resolves
// ensure no pending visible-add entry remains
this.pendingVisibleAdds.delete(nodeIndex);
}
}
}
// Prefetch loading: request only the next-better LOD toward optimal
this.prefetchNextLod(node, desiredLodIndex, optimalLodIndex);
}
}
/**
* Increments reference count for a file and creates placement immediately.
*
* @param {number} fileIndex - The file index.
* @param {number} nodeIndex - The octree node index.
* @param {number} lodIndex - The LOD index for this node.
*/ incrementFileRef(fileIndex, nodeIndex, lodIndex) {
if (fileIndex === -1) return;
// check if this is the first reference
let placement = this.filePlacements[fileIndex];
if (!placement) {
// create placement (with null resource initially)
placement = new GSplatPlacement(null, this.placement.node, lodIndex);
this.filePlacements[fileIndex] = placement;
// If we scheduled a remove for this file in this update, cancel it
const removeScheduled = this.removedCandidates.delete(fileIndex);
if (!removeScheduled) {
this.octree.incRefCount(fileIndex);
}
// if resource is already loaded, allow it to be used
if (!this.addFilePlacement(fileIndex)) {
// resource not loaded yet, kick off load and add to pending
this.octree.ensureFileResource(fileIndex);
this.pending.add(fileIndex);
}
}
// Add interval for this node to the placement
const nodes = this.octree.nodes;
const node = nodes[nodeIndex];
const lod = node.lods[lodIndex];
// Create interval as Vec2(start, end)
const interval = new Vec2(lod.offset, lod.offset + lod.count - 1);
placement.intervals.set(nodeIndex, interval);
this.dirtyModifiedPlacements = true;
}
/**
* Decrements reference count for a file and removes placement if needed.
*
* @param {number} fileIndex - The file index.
* @param {number} nodeIndex - The octree node index.
*/ decrementFileRef(fileIndex, nodeIndex) {
if (fileIndex === -1) return;
const placement = this.filePlacements[fileIndex];
if (!placement) {
return;
}
if (placement) {
// remove interval for this node from the placement
placement.intervals.delete(nodeIndex);
this.dirtyModifiedPlacements = true;
// if this was the last reference, remove placement
if (placement.intervals.size === 0) {
// Only remove if it was added (has resource)
if (placement.resource) {
this.activePlacements.delete(placement);
}
// schedule a single decRef via world state
this.removedCandidates.add(fileIndex);
this.filePlacements[fileIndex] = null;
this.pending.delete(fileIndex);
}
}
}
/**
* Updates existing placement with loaded resource and adds to manager.
*
* @param {number} fileIndex - The file index.
* @returns {boolean} True if placement was updated and added to manager, false otherwise.
*/ addFilePlacement(fileIndex) {
const res = this.octree.getFileResource(fileIndex);
if (res) {
// get the existing placement and update its resource
const placement = this.filePlacements[fileIndex];
if (placement) {
placement.resource = res;
placement.aabb.copy(res.aabb);
this.activePlacements.add(placement);
this.dirtyModifiedPlacements = true;
// clear pending removal if we are reusing the file
this.removedCandidates.delete(fileIndex);
return true;
}
}
return false;
}
/**
* Tests if the octree instance has moved by more than the provided LOD update distance.
*
* @param {number} threshold - Distance threshold to trigger an update.
* @returns {boolean} True if the octree instance has moved by more than the threshold, false otherwise.
*/ testMoved(threshold) {
const position = this.placement.node.getPosition();
const length = position.distance(this.previousPosition);
if (length > threshold) {
return true;
}
return false;
}
/**
* Updates the previous position of the octree instance.
*/ updateMoved() {
this.previousPosition.copy(this.placement.node.getPosition());
}
/**
* Updates the octree instance each frame.
*
* @param {Scene} scene - Optional scene for debug rendering.
* @returns {boolean} True if octree instance is dirty, false otherwise.
*/ update(scene) {
// Sync splat budget from placement and detect changes
const currentBudget = this.placement.splatBudget;
if (currentBudget !== this.splatBudget) {
this.splatBudget = currentBudget;
this.needsLodUpdate = true;
}
// handle pending loads
if (this.pending.size) {
for (const fileIndex of this.pending){
// check if the asset has finished loading and store it if so
this.octree.ensureFileResource(fileIndex);
// if resource became available, update placement and execute any pending decrements
if (this.addFilePlacement(fileIndex)) {
_tempCompletedUrls.push(fileIndex);
// Execute any pending decrements for nodes whose tracked newFileIndex now matches
for (const [nodeIndex, { oldFileIndex, newFileIndex }] of this.pendingDecrements){
if (newFileIndex === fileIndex) {
this.decrementFileRef(oldFileIndex, nodeIndex);
this.pendingDecrements.delete(nodeIndex);
// set displayed LOD to the LOD that maps to the newly ready file
let newLodIndex = 0;
const nodeLods = this.octree.nodes[nodeIndex].lods;
for(let li = 0; li < nodeLods.length; li++){
if (nodeLods[li].fileIndex === newFileIndex) {
newLodIndex = li;
break;
}
}
this.nodeInfos[nodeIndex].currentLod = newLodIndex;
}
}
}
}
// mark LOD update if any resource completed
if (_tempCompletedUrls.length > 0) {
this.needsLodUpdate = true;
}
// remove completed items from pending
for (const fileIndex of _tempCompletedUrls){
this.pending.delete(fileIndex);
}
// clear temp array
_tempCompletedUrls.length = 0;
}
// watch prefetched loads for completion to allow promotion
this.pollPrefetchCompletions();
// handle environment loading
if (this.octree.environmentUrl && !this.environmentPlacement) {
// poll for environment resource completion
this.octree.ensureEnvironmentResource();
const envResource = this.octree.environmentResource;
if (envResource) {
// create environment placement with the loaded resource
this.environmentPlacement = new GSplatPlacement(envResource, this.placement.node, 0);
this.environmentPlacement.aabb.copy(envResource.aabb);
this.activePlacements.add(this.environmentPlacement);
this.dirtyModifiedPlacements = true;
}
}
// check if any placements need LOD update
const dirty = this.dirtyModifiedPlacements;
this.dirtyModifiedPlacements = false;
return dirty;
}
// debug render world space bounds for octree nodes based on current LOD selection
debugRender(scene) {
Debug.call(()=>{
if (scene.gsplat.debugNodeAabbs) {
const modelMat = this.placement.node.getWorldTransform();
const nodes = this.octree.nodes;
for(let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++){
const lodIndex = this.nodeInfos[nodeIndex].currentLod;
if (lodIndex >= 0) {
const color = _lodColors[Math.min(lodIndex, _lodColors.length - 1)];
_tempDebugAabb.setFromTransformedAabb(nodes[nodeIndex].bounds, modelMat);
scene.immediate.drawWireAlignedBox(_tempDebugAabb.getMin(), _tempDebugAabb.getMax(), color, true, scene.defaultDrawLayer);
}
}
}
});
}
/**
* Returns true if this instance requests LOD re-evaluation and resets the flag.
* @returns {boolean} True if LOD should be re-evaluated.
*/ consumeNeedsLodUpdate() {
const v = this.needsLodUpdate;
this.needsLodUpdate = false;
return v;
}
/**
* Polls prefetched file indices for completion and updates state.
*/ pollPrefetchCompletions() {
if (this.prefetchPending.size) {
// poll loader and store resource in octree if ready
for (const fileIndex of this.prefetchPending){
this.octree.ensureFileResource(fileIndex);
if (this.octree.getFileResource(fileIndex)) {
_tempCompletedUrls.push(fileIndex);
}
}
// remove completed from prefetchPending
if (_tempCompletedUrls.length > 0) {
this.needsLodUpdate = true;
}
for (const fileIndex of _tempCompletedUrls){
this.prefetchPending.delete(fileIndex);
}
_tempCompletedUrls.length = 0;
}
}
}
export { GSplatOctreeInstance };