UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

839 lines (838 loc) 33 kB
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 };