playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
839 lines (838 loc) • 33 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { Debug } from "../../core/debug.js";
import { math } from "../../core/math/math.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 { GsplatAllocId } from "./gsplat-alloc-id.js";
import { GSPLAT_DEBUG_NODE_AABBS } from "../constants.js";
import { NUM_BUCKETS } from "./constants.js";
const _invWorldMat = new Mat4();
const _localCameraPos = new Vec3();
const _localCameraFwd = new Vec3();
const _tempCompletedUrls = [];
const _tempDebugAabb = new BoundingBox();
const REF_TAN_HALF_FOV = Math.tan(22.5 * math.DEG_TO_RAD);
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)
];
class NodeInfo {
constructor() {
/**
* Current LOD index being rendered. -1 indicates node is not visible.
*/
__publicField(this, "currentLod", -1);
/**
* Optimal LOD index based on distance/visibility (before underfill).
*/
__publicField(this, "optimalLod", -1);
/**
* World-space distance from camera to this node.
* Used for non-linear bucket mapping in budget enforcement.
*/
__publicField(this, "worldDistance", 0);
/**
* Accumulated camera translation for SH color update threshold tracking.
*/
__publicField(this, "colorAccumulatedTranslation", 0);
/**
* Back-reference to owning GSplatOctreeInstance.
*
* @type {GSplatOctreeInstance|null}
*/
__publicField(this, "inst", null);
/**
* Cached reference to this node's LOD array for fast budget balancing.
*
* @type {Array|null}
*/
__publicField(this, "lods", null);
/**
* Distance bucket index [0, NUM_BUCKETS - 1] for global budget balancing (sqrt mapping).
* Written during {@link GSplatOctreeInstance.evaluateNodeLods} when a global max distance
* is supplied (budget enforcement path only).
*
* @type {number}
*/
__publicField(this, "budgetBucket", 0);
/**
* Unique allocation identifier for persistent work buffer allocation tracking.
*
* @type {number}
*/
__publicField(this, "allocId", GsplatAllocId.get());
}
/**
* Resets all LOD values to -1 (invisible/uninitialized).
*/
resetLod() {
this.currentLod = -1;
this.optimalLod = -1;
}
}
class GSplatOctreeInstance {
/**
* @param {GraphicsDevice} device - The graphics device.
* @param {GSplatOctree} octree - The octree.
* @param {GSplatPlacement} placement - The placement.
*/
constructor(device, octree, placement) {
/** @type {GSplatOctree} */
__publicField(this, "octree");
/** @type {GSplatPlacement} */
__publicField(this, "placement");
/** @type {Set<GSplatPlacement>} */
__publicField(this, "activePlacements", /* @__PURE__ */ new Set());
/** @type {boolean} */
__publicField(this, "dirtyModifiedPlacements", false);
/**
* Set to true when placements are added or removed, signaling that the manager needs to
* create a new world state and trigger a full work buffer rebuild.
*/
__publicField(this, "dirtyPlacementSetChanged", false);
/** @type {GraphicsDevice} */
__publicField(this, "device");
/**
* Array of NodeInfo instances, one per octree node.
*
* @type {NodeInfo[]}
*/
__publicField(this, "nodeInfos");
/**
* Array of current placements per file. Index is fileIndex, value is GSplatPlacement or null.
* Value null indicates file is not used / no placement.
*
* @type {(GSplatPlacement|null)[]}
*/
__publicField(this, "filePlacements");
/**
* Set of pending file loads (file indices).
*
* @type {Set<number>}
*/
__publicField(this, "pending", /* @__PURE__ */ 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 }>}
*/
__publicField(this, "pendingDecrements", /* @__PURE__ */ new Map());
/**
* Files that became unused by this instance this update. Each entry represents a single decRef.
*
* @type {Set<number>}
*/
__publicField(this, "removedCandidates", /* @__PURE__ */ new Set());
/**
* Minimum allowed LOD index for this instance, clamped to valid octree bounds.
*/
__publicField(this, "rangeMin", 0);
/**
* Maximum allowed LOD index for this instance, clamped to valid octree bounds.
*/
__publicField(this, "rangeMax", 0);
/**
* 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.
*/
__publicField(this, "previousPosition", new Vec3());
/**
* Set when a resource has completed loading and LOD should be re-evaluated.
*/
__publicField(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>}
*/
__publicField(this, "prefetchPending", /* @__PURE__ */ 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>}
*/
__publicField(this, "pendingVisibleAdds", /* @__PURE__ */ new Map());
/**
* Environment placement.
*
* @type {GSplatPlacement|null}
*/
__publicField(this, "environmentPlacement", null);
/**
* Event handle for device lost event.
*
* @type {EventHandle|null}
* @private
*/
__publicField(this, "_deviceLostEvent", null);
/**
* Reusable scratch for LOD distance thresholds.
*
* @type {Float32Array|null}
* @private
*/
__publicField(this, "_lodMinDistThresholds", null);
this.device = device;
this.octree = octree;
this.placement = placement;
this.nodeInfos = new Array(octree.nodes.length);
for (let i = 0; i < octree.nodes.length; i++) {
const nodeInfo = new NodeInfo();
nodeInfo.inst = this;
this.nodeInfos[i] = nodeInfo;
}
const numFiles = octree.files.length;
this.filePlacements = new Array(numFiles).fill(null);
if (octree.environmentUrl) {
octree.incEnvironmentRefCount();
octree.ensureEnvironmentResource();
}
this._deviceLostEvent = device.on("devicelost", this._onDeviceLost, this);
}
/**
* 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;
if (this.octree.environmentUrl && !this.environmentPlacement) {
count++;
}
return count;
}
/**
* Destroys this octree instance and clears internal references.
*
* @param {boolean} [skipRefCounting] - When true, skip decrementing file ref counts
* on the octree. Used when the caller handles ref counting externally via pendingReleases
* (e.g. during world state updates where decrements must be deferred).
*/
destroy(skipRefCounting = false) {
if (!skipRefCounting && this.octree && !this.octree.destroyed) {
const filesToDecRef = this.getFileDecrements();
for (const fileIndex of filesToDecRef) {
this.octree.decRefCount(fileIndex, 0);
}
for (const fileIndex of this.pending) {
if (!this.filePlacements[fileIndex]) {
this.octree.unloadResource(fileIndex);
}
}
for (const fileIndex of this.prefetchPending) {
if (!this.filePlacements[fileIndex]) {
this.octree.unloadResource(fileIndex);
}
}
if (this.environmentPlacement) {
this.octree.decEnvironmentRefCount();
}
}
this.pending.clear();
this.pendingDecrements.clear();
this.filePlacements.length = 0;
if (this.environmentPlacement) {
this.activePlacements.delete(this.environmentPlacement);
this.environmentPlacement = null;
}
this._deviceLostEvent?.off();
this._deviceLostEvent = null;
}
/**
* Handles device lost event by releasing all loaded resources.
*
* @private
*/
_onDeviceLost() {
for (let i = 0; i < this.filePlacements.length; i++) {
if (this.filePlacements[i]) {
this.octree.decRefCount(i, 0);
}
}
this.filePlacements.fill(null);
this.activePlacements.clear();
this.pending.clear();
this.pendingDecrements.clear();
this.removedCandidates.clear();
this.prefetchPending.clear();
this.pendingVisibleAdds.clear();
for (const nodeInfo of this.nodeInfos) {
nodeInfo.resetLod();
}
if (this.environmentPlacement) {
this.activePlacements.delete(this.environmentPlacement);
this.environmentPlacement = null;
this.octree.unloadEnvironmentResource();
}
this.dirtyModifiedPlacements = true;
this.dirtyPlacementSetChanged = 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;
}
/**
* 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);
for (let lod = optimalLodIndex; lod <= allowedMaxCoarseLod; lod++) {
const fi = node.lods[lod].fileIndex;
if (fi !== -1 && this.octree.getFileResource(fi)) {
return lod;
}
}
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 (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;
}
const targetLod = Math.max(optimalLodIndex, desiredLodIndex - 1);
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 { lodBaseDistance, lodMultiplier } = this.placement;
const { lodRangeMin, lodRangeMax } = params;
const rangeMin = Math.max(0, Math.min(lodRangeMin ?? 0, maxLod));
const rangeMax = Math.max(rangeMin, Math.min(lodRangeMax ?? maxLod, maxLod));
const uniformScale = this.placement.node.getWorldTransform().getScale().x;
this.evaluateNodeLods(cameraNode, maxLod, lodBaseDistance, lodMultiplier, rangeMin, rangeMax, params, uniformScale, false);
this.applyLodChanges(maxLod, params);
}
/**
* Ensures the reusable threshold buffer can store indices 1 through maxLod and fills
* buf[k] = d0 * m^(k-1) for k from 1 to maxLod (same distance bands as truncating 1 + log(d/d0) / log(m)).
*
* @param {number} maxLod - Maximum LOD index (>= 1).
* @param {number} d0 - lodBaseDistance in FOV-adjusted distance space.
* @param {number} m - lodMultiplier.
* @returns {Float32Array} Buffer; index 0 unused; entries 1..maxLod set.
* @private
*/
_ensureLodMinDistThresholds(maxLod, d0, m) {
const needLen = maxLod + 1;
let buf = this._lodMinDistThresholds;
if (!buf || buf.length < needLen) {
buf = new Float32Array(needLen);
this._lodMinDistThresholds = buf;
}
let t = d0;
buf[1] = t;
for (let k = 2; k <= maxLod; k++) {
t *= m;
buf[k] = t;
}
return buf;
}
/**
* 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.
*
* Uses geometric LOD distances (lodBaseDistance * lodMultiplier^i) with FOV compensation
* so that LOD transitions are perceptually uniform under perspective projection.
*
* @param {GraphNode} cameraNode - The camera node.
* @param {number} maxLod - Maximum LOD index (lodLevels - 1).
* @param {number} lodBaseDistance - Base distance for first LOD transition.
* @param {number} lodMultiplier - Geometric ratio between successive LOD thresholds.
* @param {number} rangeMin - Minimum allowed LOD index.
* @param {number} rangeMax - Maximum allowed LOD index.
* @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters.
* @param {number} uniformScale - Uniform scale of the octree transform for world-space conversion.
* @param {boolean} [accumulateSplats] - When true (default), sum splat counts for the chosen LOD per node and return the total (budget path). When false, skip counting (faster; return value unused).
* @param {number} [globalMaxDistanceForBuckets] - When > 0, writes {@link NodeInfo.budgetBucket} using the same sqrt mapping as the budget balancer. Omit or pass 0 when not enforcing global budget.
* @returns {number} Total number of splats that would be used by optimal LODs when accumulateSplats is true; otherwise 0.
* @private
*/
evaluateNodeLods(cameraNode, maxLod, lodBaseDistance, lodMultiplier, rangeMin, rangeMax, params, uniformScale, accumulateSplats = true, globalMaxDistanceForBuckets = 0) {
const { lodBehindPenalty } = params;
const camera = cameraNode.camera;
let tanHalfVFov = Math.tan(camera.fov * 0.5 * math.DEG_TO_RAD);
if (camera.horizontalFov) {
tanHalfVFov /= camera.aspectRatio;
}
const tanHalfHFov = tanHalfVFov * camera.aspectRatio;
const fovScale = Math.min(tanHalfVFov, tanHalfHFov) / REF_TAN_HALF_FOV;
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;
const boundsFlat = this.octree.nodeBoundsMinMax;
const px = localCameraPosition.x;
const py = localCameraPosition.y;
const pz = localCameraPosition.z;
const fwx = localCameraForward.x;
const fwy = localCameraForward.y;
const fwz = localCameraForward.z;
let totalSplats = 0;
let minDistBuf = null;
if (maxLod >= 1) {
minDistBuf = this._ensureLodMinDistThresholds(maxLod, lodBaseDistance, lodMultiplier);
}
const bucketScale = globalMaxDistanceForBuckets > 0 ? NUM_BUCKETS / Math.sqrt(globalMaxDistanceForBuckets) : 0;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex++) {
const nodeInfo = nodeInfos[nodeIndex];
const b = nodeIndex * 6;
let qx = px;
const minX = boundsFlat[b];
const maxX = boundsFlat[b + 3];
if (qx < minX) qx = minX;
else if (qx > maxX) qx = maxX;
let qy = py;
const minY = boundsFlat[b + 1];
const maxY = boundsFlat[b + 4];
if (qy < minY) qy = minY;
else if (qy > maxY) qy = maxY;
let qz = pz;
const minZ = boundsFlat[b + 2];
const maxZ = boundsFlat[b + 5];
if (qz < minZ) qz = minZ;
else if (qz > maxZ) qz = maxZ;
const dx = qx - px;
const dy = qy - py;
const dz = qz - pz;
const actualDistance = Math.sqrt(dx * dx + dy * dy + dz * dz);
let penalizedDistance = actualDistance;
if (lodBehindPenalty > 1 && actualDistance > 0.01) {
const dotOverDistance = (fwx * dx + fwy * dy + fwz * dz) / actualDistance;
if (dotOverDistance < 0) {
const t = -dotOverDistance;
const factor = 1 + t * (lodBehindPenalty - 1);
penalizedDistance = actualDistance * factor;
}
}
const fovAdjustedDistance = penalizedDistance * fovScale;
let optimalLodIndex;
if (maxLod === 0 || fovAdjustedDistance < lodBaseDistance) {
optimalLodIndex = 0;
} else {
optimalLodIndex = maxLod;
while (optimalLodIndex > 1 && fovAdjustedDistance < minDistBuf[optimalLodIndex]) {
optimalLodIndex--;
}
}
if (optimalLodIndex < rangeMin) optimalLodIndex = rangeMin;
if (optimalLodIndex > rangeMax) optimalLodIndex = rangeMax;
nodeInfo.optimalLod = optimalLodIndex;
nodeInfo.worldDistance = fovAdjustedDistance * uniformScale;
if (bucketScale > 0 && optimalLodIndex >= 0) {
const bucket = Math.sqrt(nodeInfo.worldDistance) * bucketScale >>> 0;
nodeInfo.budgetBucket = bucket < NUM_BUCKETS ? bucket : NUM_BUCKETS - 1;
}
if (accumulateSplats) {
const lod = nodes[nodeIndex].lods[optimalLodIndex];
if (lod && lod.count) {
totalSplats += lod.count;
}
}
}
return totalSplats;
}
/**
* Evaluates optimal LOD for all nodes without applying changes.
* Called by GSplatManager during phased global budget enforcement.
*
* @param {GraphNode} cameraNode - The camera node.
* @param {import('./gsplat-params.js').GSplatParams} params - Global gsplat parameters.
* @param {number} [budgetScale] - Dynamic scale applied to LOD parameters to shift
* boundaries closer to the budget target. Applied to lodBaseDistance directly, and
* gently to lodMultiplier via pow(budgetScale, -0.2). Defaults to 1.
* @param {number} [globalMaxDistanceForBuckets] - When > 0, {@link NodeInfo.budgetBucket} is populated during LOD evaluation for budget balancing.
* @returns {number} Total optimal splat count.
*/
evaluateOptimalLods(cameraNode, params, budgetScale = 1, globalMaxDistanceForBuckets = 0) {
const maxLod = this.octree.lodLevels - 1;
const { lodBaseDistance, lodMultiplier } = this.placement;
const { lodRangeMin, lodRangeMax } = params;
const rangeMin = Math.max(0, Math.min(lodRangeMin ?? 0, maxLod));
const rangeMax = Math.max(rangeMin, Math.min(lodRangeMax ?? maxLod, maxLod));
this.rangeMin = rangeMin;
this.rangeMax = rangeMax;
const uniformScale = this.placement.node.getWorldTransform().getScale().x;
const effectiveBase = lodBaseDistance * budgetScale;
const effectiveMult = Math.max(1.2, lodMultiplier * Math.pow(budgetScale, -0.2));
return this.evaluateNodeLods(
cameraNode,
maxLod,
effectiveBase,
effectiveMult,
rangeMin,
rangeMax,
params,
uniformScale,
true,
globalMaxDistanceForBuckets
);
}
/**
* 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.
*/
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;
const desiredLodIndex = this.selectDesiredLodIndex(node, optimalLodIndex, maxLod, lodUnderfillLimit);
if (desiredLodIndex !== currentLodIndex) {
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;
const pendingEntry = this.pendingDecrements.get(nodeIndex);
if (pendingEntry) {
if (pendingEntry.newFileIndex !== desiredFileIndex) {
const prevPendingPlacement = this.filePlacements[pendingEntry.newFileIndex];
if (prevPendingPlacement) {
this.decrementFileRef(pendingEntry.newFileIndex, nodeIndex);
}
if (wasVisible && willBeVisible) {
this.pendingDecrements.set(nodeIndex, { oldFileIndex: pendingEntry.oldFileIndex, newFileIndex: desiredFileIndex });
} else {
this.pendingDecrements.delete(nodeIndex);
}
}
}
if (!wasVisible && willBeVisible) {
const prevPendingFi = this.pendingVisibleAdds.get(nodeIndex);
if (prevPendingFi !== void 0 && prevPendingFi !== desiredFileIndex) {
this.decrementFileRef(prevPendingFi, nodeIndex);
this.pendingVisibleAdds.delete(nodeIndex);
}
this.incrementFileRef(desiredFileIndex, nodeIndex, desiredLodIndex);
const newPlacement = this.filePlacements[desiredFileIndex];
if (newPlacement?.resource) {
nodeInfo.currentLod = desiredLodIndex;
this.pendingVisibleAdds.delete(nodeIndex);
} else {
this.pendingVisibleAdds.set(nodeIndex, desiredFileIndex);
}
} else if (wasVisible && !willBeVisible) {
const pendingEntry2 = this.pendingDecrements.get(nodeIndex);
if (pendingEntry2) {
this.decrementFileRef(pendingEntry2.newFileIndex, nodeIndex);
this.pendingDecrements.delete(nodeIndex);
}
this.decrementFileRef(currentFileIndex, nodeIndex);
nodeInfo.currentLod = -1;
this.pendingVisibleAdds.delete(nodeIndex);
} else if (wasVisible && willBeVisible) {
this.incrementFileRef(desiredFileIndex, nodeIndex, desiredLodIndex);
const newPlacement = this.filePlacements[desiredFileIndex];
if (newPlacement?.resource) {
this.decrementFileRef(currentFileIndex, nodeIndex);
this.pendingDecrements.delete(nodeIndex);
nodeInfo.currentLod = desiredLodIndex;
this.pendingVisibleAdds.delete(nodeIndex);
} else {
this.pendingDecrements.set(nodeIndex, { oldFileIndex: currentFileIndex, newFileIndex: desiredFileIndex });
this.pendingVisibleAdds.delete(nodeIndex);
}
}
}
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;
let placement = this.filePlacements[fileIndex];
if (!placement) {
placement = new GSplatPlacement(null, this.placement.node, lodIndex, null, this.placement);
this.filePlacements[fileIndex] = placement;
const removeScheduled = this.removedCandidates.delete(fileIndex);
if (!removeScheduled) {
this.octree.incRefCount(fileIndex);
}
if (!this.addFilePlacement(fileIndex)) {
this.octree.ensureFileResource(fileIndex);
this.pending.add(fileIndex);
}
}
const nodes = this.octree.nodes;
const node = nodes[nodeIndex];
const lod = node.lods[lodIndex];
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) {
placement.intervals.delete(nodeIndex);
this.dirtyModifiedPlacements = true;
if (placement.intervals.size === 0) {
if (placement.resource) {
this.activePlacements.delete(placement);
if (this.activePlacements.size === 0) {
this.dirtyPlacementSetChanged = true;
}
}
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) {
const placement = this.filePlacements[fileIndex];
if (placement) {
placement.resource = res;
if (this.activePlacements.size === 0) {
this.dirtyPlacementSetChanged = true;
}
this.activePlacements.add(placement);
this.dirtyModifiedPlacements = true;
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.
*
* @returns {boolean} True if octree instance is dirty, false otherwise.
*/
update() {
if (this.placement.lodDirty) {
this.placement.lodDirty = false;
this.needsLodUpdate = true;
}
if (this.pending.size) {
for (const fileIndex of this.pending) {
this.octree.ensureFileResource(fileIndex);
if (this.addFilePlacement(fileIndex)) {
_tempCompletedUrls.push(fileIndex);
for (const [nodeIndex, { oldFileIndex, newFileIndex }] of this.pendingDecrements) {
if (newFileIndex === fileIndex) {
this.decrementFileRef(oldFileIndex, nodeIndex);
this.pendingDecrements.delete(nodeIndex);
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;
}
}
}
}
if (_tempCompletedUrls.length > 0) {
this.needsLodUpdate = true;
}
for (const fileIndex of _tempCompletedUrls) {
this.pending.delete(fileIndex);
}
_tempCompletedUrls.length = 0;
}
this.pollPrefetchCompletions();
if (this.octree.environmentUrl && !this.environmentPlacement) {
this.octree.ensureEnvironmentResource();
const envResource = this.octree.environmentResource;
if (envResource) {
this.environmentPlacement = new GSplatPlacement(envResource, this.placement.node, 0, null, this.placement);
this.environmentPlacement.aabb.copy(envResource.aabb);
this.activePlacements.add(this.environmentPlacement);
this.dirtyModifiedPlacements = true;
this.dirtyPlacementSetChanged = true;
envResource.releaseTextureSources?.();
}
}
const dirty = this.dirtyModifiedPlacements;
this.dirtyModifiedPlacements = false;
return dirty;
}
/**
* Consumes and returns whether the active placement set membership changed (add/remove).
*
* @returns {boolean} True if placements were added or removed since last call.
*/
consumePlacementSetChanged() {
const changed = this.dirtyPlacementSetChanged;
this.dirtyPlacementSetChanged = false;
return changed;
}
// debug render world space bounds for octree nodes based on current LOD selection
debugRender(scene) {
Debug.call(() => {
if (scene.gsplat.debug === GSPLAT_DEBUG_NODE_AABBS) {
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) {
for (const fileIndex of this.prefetchPending) {
this.octree.ensureFileResource(fileIndex);
if (this.octree.getFileResource(fileIndex)) {
_tempCompletedUrls.push(fileIndex);
}
}
if (_tempCompletedUrls.length > 0) {
this.needsLodUpdate = true;
}
for (const fileIndex of _tempCompletedUrls) {
this.prefetchPending.delete(fileIndex);
}
_tempCompletedUrls.length = 0;
}
}
}
export {
GSplatOctreeInstance,
NodeInfo
};