UNPKG

playcanvas

Version:

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

1,084 lines (1,083 loc) 39 kB
import { math } from "../../core/math/math.js"; import { Mat4 } from "../../core/math/mat4.js"; import { Vec3 } from "../../core/math/vec3.js"; import { GraphNode } from "../graph-node.js"; import { GSplatInfo } from "./gsplat-info.js"; import { GSplatUnifiedSorter } from "./gsplat-unified-sorter.js"; import { GSplatWorkBuffer } from "./gsplat-work-buffer.js"; import { GSplatQuadRenderer } from "./gsplat-quad-renderer.js"; import { GSplatHybridRenderer } from "./gsplat-hybrid-renderer.js"; import { GSplatComputeLocalRenderer } from "./gsplat-compute-local-renderer.js"; import { GSplatProjector } from "./gsplat-projector.js"; import { GSplatOctreeInstance } from "./gsplat-octree-instance.js"; import { GSplatOctreeResource } from "./gsplat-octree.resource.js"; import { GSplatWorldState } from "./gsplat-world-state.js"; import { GSplatPlacementStateTracker } from "./gsplat-placement-state-tracker.js"; import { GSplatIntervalCompaction } from "./gsplat-interval-compaction.js"; import { ComputeRadixSort } from "../graphics/radix-sort/compute-radix-sort.js"; import { BoundingBox } from "../../core/shape/bounding-box.js"; import { GSPLAT_RENDERER_RASTER_CPU_SORT, GSPLAT_RENDERER_RASTER_GPU_SORT, GSPLAT_RENDERER_COMPUTE, GSPLAT_DEBUG_LOD, GSPLAT_DEBUG_SH_UPDATE, GSPLAT_DEBUG_AABBS } from "../constants.js"; import { Color } from "../../core/math/color.js"; import { GSplatBudgetBalancer } from "./gsplat-budget-balancer.js"; import { BlockAllocator } from "../../core/block-allocator.js"; import { ALPHA_VISIBILITY_THRESHOLD } from "./constants.js"; const cameraPosition = new Vec3(); const cameraDirection = new Vec3(); const translation = new Vec3(); const _tempVec3 = new Vec3(); const invModelMat = new Mat4(); const NO_SORT_INDIRECT_INFO = new Uint32Array([0, 0, 0, 0]); const tempNonOctreePlacements = /* @__PURE__ */ new Set(); const tempOctreePlacements = /* @__PURE__ */ new Set(); const _updatedSplats = []; const _splatsWithSH = []; const _changedColorAllocIds = /* @__PURE__ */ new Set(); const _cameraDeltas = { translationDelta: 0 }; const _localCamPos = new Vec3(); const _closestPt = new Vec3(); const tempOctreesTicked = /* @__PURE__ */ new Set(); const _queuedSplats = /* @__PURE__ */ new Set(); const _lodColorsRaw = [ [1, 0, 0], // red [0, 1, 0], // green [0, 0, 1], // blue [1, 1, 0], // yellow [1, 0, 1], // magenta [0, 1, 1], // cyan [1, 0.5, 0], // orange [0.5, 0, 1] // purple ]; 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), new Color(0, 1, 1), new Color(1, 0.5, 0), new Color(0.5, 0, 1) ]; let _randomColorRaw = null; class GSplatManager { device; node = new GraphNode("GSplatManager"); workBuffer; renderer; worldStates = /* @__PURE__ */ new Map(); lastWorldStateVersion = 0; activeRenderer; _worldStateDirty = false; cpuSorter = null; gpuSorter = null; intervalCompaction = null; projector = null; indirectDrawSlot = -1; indirectDispatchSlot = -1; lastCompactedNumIntervals = 0; sortedVersion = 0; _awaitingLodUpdate = false; _workBufferFormatVersion = -1; _workBufferRebuildRequired = false; bufferCopyUploaded = 0; bufferCopyTotal = 0; _stateTracker = new GSplatPlacementStateTracker(); _centersVersions = /* @__PURE__ */ new Map(); framesTillFullUpdate = 0; lastLodCameraPos = new Vec3(Infinity, Infinity, Infinity); lastLodCameraFwd = new Vec3(Infinity, Infinity, Infinity); lastLodCameraFov = -1; lastSortCameraPos = new Vec3(Infinity, Infinity, Infinity); lastSortCameraFwd = new Vec3(Infinity, Infinity, Infinity); lastCullingCameraFwd = new Vec3(Infinity, Infinity, Infinity); lastCullingProjMat = new Mat4(); sortNeeded = true; _budgetBalancer = new GSplatBudgetBalancer(); _budgetScale = 1; _allocator; _allocationMap = /* @__PURE__ */ new Map(); lastColorUpdateCameraPos = new Vec3(Infinity, Infinity, Infinity); cameraNode; scene; layerPlacements = []; layerPlacementsDirty = false; _placementSetChanged = false; octreeInstances = /* @__PURE__ */ new Map(); octreeInstancesToDestroy = []; hasNewOctreeInstances = false; renderMode; constructor(device, director, layer, cameraNode) { this.device = device; this.scene = director.scene; this.director = director; this.cameraNode = cameraNode; const allocatorGrowMultiplier = 1.15; const budget = this.scene.gsplat.splatBudget; this._allocator = new BlockAllocator(budget > 0 ? Math.ceil(budget * allocatorGrowMultiplier) : 0, allocatorGrowMultiplier); this.workBuffer = new GSplatWorkBuffer(device, this.scene.gsplat.format); this.layer = layer; this._createRenderer(this.scene.gsplat.currentRenderer); this._workBufferFormatVersion = this.workBuffer.format.extraStreamsVersion; } destroy() { this._destroyed = true; for (const [, worldState] of this.worldStates) { for (const splat of worldState.splats) { splat.resource.decRefCount(); } worldState.destroy(); } this.worldStates.clear(); for (const [, instance] of this.octreeInstances) { instance.destroy(); } this.octreeInstances.clear(); for (const instance of this.octreeInstancesToDestroy) { instance.destroy(); } this.octreeInstancesToDestroy.length = 0; this.destroyGpuSorting(); this.destroyCpuSorting(); this.workBuffer.destroy(); this.renderer.destroy(); } destroyGpuSorting() { this.gpuSorter?.destroy(); this.gpuSorter = null; this.projector?.destroy(); this.projector = null; const useCpuSort = false; this.renderer.setCpuSortedRendering(); this.destroyIntervalCompaction(useCpuSort); } destroyIntervalCompaction(useCpuSort = true) { if (this.intervalCompaction) { if (useCpuSort) { this.renderer.setCpuSortedRendering(); } this.intervalCompaction.destroy(); this.intervalCompaction = null; } } destroyCpuSorting() { this.cpuSorter?.destroy(); this.cpuSorter = null; } initHybridSorting() { if (!this.gpuSorter) { this.gpuSorter = new ComputeRadixSort(this.device, { indirect: true }); } if (!this.projector) { this.projector = new GSplatProjector(this.device); } } initCpuSorting() { if (!this.cpuSorter) { this.cpuSorter = this.createSorter(); } const currentState = this.worldStates.get(this.sortedVersion); if (currentState) { currentState.sortParametersSet = false; currentState.sortedBefore = false; this.cpuSorter.updateCentersForSplats(currentState.splats); } this.renderer.setCpuSortedRendering(); } get material() { return this.renderer.material; } prepareForPicking(camera, width, height) { if (this.activeRenderer === GSPLAT_RENDERER_RASTER_GPU_SORT) { const sortedState = this.worldStates.get(this.sortedVersion); if (!sortedState?.sortedBefore || !camera.node) return null; const sortedIndices = this.sortGpuHybridForCamera( sortedState, camera.node, width, height, Math.max(ALPHA_VISIBILITY_THRESHOLD, this.scene.gsplat.alphaClip), !!this.workBuffer.format.getStream("pcId") ); if (!sortedIndices) return null; const proj = this.projector; const ic = this.intervalCompaction; return this.renderer.prepareForPicking( this.indirectDrawSlot, sortedIndices, proj.projCache, ic.numSplatsBuffer, this.scene.gsplat.alphaClip, this.scene.gsplat.alphaClipForward, camera.node ); } if (this.activeRenderer !== GSPLAT_RENDERER_COMPUTE) return null; const localRenderer = this.renderer; return localRenderer.dispatchPick(camera, width, height); } createSorter() { const sorter = new GSplatUnifiedSorter(this.scene); sorter.on("sorted", (count, version, orderData) => { this.onSorted(count, version, orderData); }); return sorter; } setRenderMode(renderMode) { this.renderMode = renderMode; this.renderer.setRenderMode(renderMode); } get canCull() { return this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT && this.workBuffer.frustumCuller.totalBoundsEntries > 0; } _createRenderer(mode) { if (mode === GSPLAT_RENDERER_COMPUTE) { this.renderer = new GSplatComputeLocalRenderer(this.device, this.node, this.cameraNode, this.layer, this.workBuffer); } else if (mode === GSPLAT_RENDERER_RASTER_GPU_SORT) { this.renderer = new GSplatHybridRenderer(this.device, this.node, this.cameraNode, this.layer, this.workBuffer); this.initHybridSorting(); } else { this.renderer = new GSplatQuadRenderer(this.device, this.node, this.cameraNode, this.layer, this.workBuffer); this.initCpuSorting(); } this.activeRenderer = mode; } prepareRendererMode() { const requested = this.scene.gsplat.currentRenderer; if (requested === this.activeRenderer) return; this._worldStateDirty = true; this.destroyGpuSorting(); this.destroyCpuSorting(); this.renderer.destroy(); this._createRenderer(requested); this.renderer.setRenderMode(this.renderMode); this._workBufferRebuildRequired = true; this.sortNeeded = true; } reconcile(placements) { tempNonOctreePlacements.clear(); for (const p of placements) { if (p.resource instanceof GSplatOctreeResource) { if (!this.octreeInstances.has(p)) { this.octreeInstances.set(p, new GSplatOctreeInstance(this.device, p.resource.octree, p)); this.hasNewOctreeInstances = true; } tempOctreePlacements.add(p); } else { tempNonOctreePlacements.add(p); } } for (const [placement, inst] of this.octreeInstances) { if (!tempOctreePlacements.has(placement)) { this.octreeInstances.delete(placement); this.layerPlacementsDirty = true; this._placementSetChanged = true; this.octreeInstancesToDestroy.push(inst); } } this.layerPlacementsDirty || (this.layerPlacementsDirty = this.layerPlacements.length !== tempNonOctreePlacements.size); if (!this.layerPlacementsDirty) { for (let i = 0; i < this.layerPlacements.length; i++) { const existing = this.layerPlacements[i]; if (!tempNonOctreePlacements.has(existing)) { this.layerPlacementsDirty = true; break; } } } this._placementSetChanged || (this._placementSetChanged = this.layerPlacementsDirty); this.layerPlacements.length = 0; for (const p of tempNonOctreePlacements) { this.layerPlacements.push(p); } tempNonOctreePlacements.clear(); tempOctreePlacements.clear(); } updateWorldState() { let stateChanged = this._stateTracker.hasChanges(this.layerPlacements); for (const [, inst] of this.octreeInstances) { if (this._stateTracker.hasChanges(inst.activePlacements)) { stateChanged = true; } } const placementsChanged = this.layerPlacementsDirty; const worldChanged = placementsChanged || stateChanged || this.worldStates.size === 0 || this._worldStateDirty; if (worldChanged) { this.lastWorldStateVersion++; const splats = []; for (const p of this.layerPlacements) { if (this.cpuSorter && !p.resource.hasCenters) { continue; } p.ensureInstanceStreams(this.device); const splatInfo = new GSplatInfo(this.device, p.resource, p, p.consumeRenderDirty.bind(p)); splats.push(splatInfo); } for (const [, inst] of this.octreeInstances) { inst.activePlacements.forEach((p) => { if (p.resource) { const leafResource = p.resource; if (this.cpuSorter && !leafResource.hasCenters) { return; } p.ensureInstanceStreams(this.device); const octreeNodes = p.intervals.size > 0 ? inst.octree.nodes : null; const nodeInfos = octreeNodes ? inst.nodeInfos : null; const splatInfo = new GSplatInfo(this.device, p.resource, p, p.consumeRenderDirty.bind(p), octreeNodes, nodeInfos); splats.push(splatInfo); } }); } if (this.cpuSorter) { for (const splat of splats) { const resource = splat.resource; const lastVersion = this._centersVersions.get(resource.id); if (lastVersion !== resource.centersVersion) { this._centersVersions.set(resource.id, resource.centersVersion); this.cpuSorter.setCenters(resource.id, null); this.cpuSorter.setCenters(resource.id, resource.centers); } } } this.cpuSorter?.updateCentersForSplats(splats); const newState = new GSplatWorldState( this.device, this.lastWorldStateVersion, splats, this._allocator, this._allocationMap ); for (const splat of newState.splats) { splat.resource.incRefCount(); } for (const [, inst] of this.octreeInstances) { if (inst.removedCandidates && inst.removedCandidates.size) { for (const fileIndex of inst.removedCandidates) { newState.pendingReleases.push([inst.octree, fileIndex]); } inst.removedCandidates.clear(); } } if (this.octreeInstancesToDestroy.length) { for (const inst of this.octreeInstancesToDestroy) { if (inst.removedCandidates && inst.removedCandidates.size) { for (const fileIndex of inst.removedCandidates) { newState.pendingReleases.push([inst.octree, fileIndex]); } inst.removedCandidates.clear(); } const toRelease = inst.getFileDecrements(); for (const fileIndex of toRelease) { newState.pendingReleases.push([inst.octree, fileIndex]); } inst.destroy(true); } this.octreeInstancesToDestroy.length = 0; } if (this._placementSetChanged) { newState.fullRebuild = true; } this.worldStates.set(this.lastWorldStateVersion, newState); this.layerPlacementsDirty = false; this._placementSetChanged = false; this._worldStateDirty = false; this.sortNeeded = true; } } onSorted(count, version, orderData) { this.cleanupOldWorldStates(version); this.sortedVersion = version; const worldState = this.worldStates.get(version); if (worldState) { if (!worldState.sortedBefore) { worldState.sortedBefore = true; this.rebuildWorkBuffer(worldState, count); } this.workBuffer.setOrderData(orderData); this.renderer.setOrderData(); } } rebuildWorkBuffer(worldState, count, forceFullRebuild = false) { const textureSize = worldState.textureSize; if (textureSize !== this.workBuffer.textureSize) { this.workBuffer.resize(textureSize); } if (this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT) { this.workBuffer.frustumCuller.updateBoundsData(worldState.boundsGroups); this.workBuffer.frustumCuller.updateTransformsData(worldState.boundsGroups); } const renderAll = forceFullRebuild || worldState.fullRebuild; const splatsToRender = renderAll ? worldState.splats : worldState.needsUpload; const changedAllocIds = renderAll ? null : worldState.needsUploadIds; if (splatsToRender.length > 0) { const totalBlocks = this._allocationMap.size; const uploadBlocks = renderAll ? totalBlocks : worldState.needsUploadIds.size; this.bufferCopyUploaded += uploadBlocks; this.bufferCopyTotal = totalBlocks; this.workBuffer.render(splatsToRender, this.cameraNode, this.getDebugColors(), changedAllocIds); } for (let i = 0; i < worldState.splats.length; i++) { worldState.splats[i].update(); } this.updateColorCameraTracking(); if (worldState.pendingReleases && worldState.pendingReleases.length) { const cooldownTicks = this.scene.gsplat.cooldownTicks; for (const [octree, fileIndex] of worldState.pendingReleases) { octree.decRefCount(fileIndex, cooldownTicks); } worldState.pendingReleases.length = 0; } this.renderer.update(count, textureSize); } cleanupOldWorldStates(newVersion) { const activeState = this.worldStates.get(newVersion); if (!activeState.fullRebuild) { for (let v = this.sortedVersion + 1; v < newVersion; v++) { if (this.worldStates.get(v)?.fullRebuild) { activeState.fullRebuild = true; break; } } } if (!activeState.fullRebuild) { const activeIds = activeState.needsUploadIds; const lookup = activeState.allocIdToSplat; for (let v = this.sortedVersion + 1; v < newVersion; v++) { const oldState = this.worldStates.get(v); if (oldState) { for (const allocId of oldState.needsUploadIds) { if (!activeIds.has(allocId)) { activeIds.add(allocId); const splat = lookup.get(allocId); if (splat && !_queuedSplats.has(splat)) { activeState.needsUpload.push(splat); _queuedSplats.add(splat); } } } } } _queuedSplats.clear(); } for (let v = this.sortedVersion; v < newVersion; v++) { const oldState = this.worldStates.get(v); if (oldState) { for (const splat of oldState.splats) { splat.resource.decRefCount(); } this.worldStates.delete(v); oldState.destroy(); } } } applyWorkBufferUpdates(state) { const { colorUpdateAngle } = this.scene.gsplat; const ratio = Math.tan(colorUpdateAngle * math.DEG_TO_RAD); const cameraPos = this.cameraNode.getPosition(); const { translationDelta } = this.calculateColorCameraDeltas(); const hasCameraMovement = translationDelta > 0; let uploadedBlocks = 0; state.splats.forEach((splat) => { if (splat.update()) { _updatedSplats.push(splat); uploadedBlocks += splat.intervalAllocIds.length; if (splat.nodeInfos) { for (const ni of splat.intervalNodeIndices) { splat.nodeInfos[ni].colorAccumulatedTranslation = 0; } } else { splat.colorAccumulatedTranslation = 0; } this.sortNeeded = true; } else if (hasCameraMovement && splat.hasSphericalHarmonics) { _splatsWithSH.push(splat); if (splat.nodeInfos) { const nodeIndices = splat.intervalNodeIndices; for (let j = 0; j < nodeIndices.length; j++) { const nodeInfo = splat.nodeInfos[nodeIndices[j]]; nodeInfo.colorAccumulatedTranslation += translationDelta; const threshold = ratio * Math.max(1, nodeInfo.worldDistance); if (nodeInfo.colorAccumulatedTranslation >= threshold) { _changedColorAllocIds.add(splat.intervalAllocIds[j]); nodeInfo.colorAccumulatedTranslation = 0; uploadedBlocks++; } } } else { splat.colorAccumulatedTranslation += translationDelta; invModelMat.copy(splat.node.getWorldTransform()).invert(); invModelMat.transformPoint(cameraPos, _localCamPos); splat.aabb.closestPoint(_localCamPos, _closestPt); const dist = _localCamPos.distance(_closestPt) * splat.node.getWorldTransform().getScale().x; const threshold = ratio * Math.max(1, dist); if (splat.colorAccumulatedTranslation >= threshold) { _changedColorAllocIds.add(splat.allocId); uploadedBlocks += splat.intervalAllocIds.length; splat.colorAccumulatedTranslation = 0; } } } }); this.bufferCopyUploaded += uploadedBlocks; this.bufferCopyTotal = this._allocationMap.size; if (_updatedSplats.length > 0) { this.workBuffer.render(_updatedSplats, this.cameraNode, this.getDebugColors()); _updatedSplats.length = 0; } if (_changedColorAllocIds.size > 0) { this.workBuffer.renderColor( _splatsWithSH, this.cameraNode, this.getDebugColors(), _changedColorAllocIds ); _changedColorAllocIds.clear(); } _splatsWithSH.length = 0; } testCameraMovedForLod() { const distanceThreshold = this.scene.gsplat.lodUpdateDistance; const currentCameraPos = this.cameraNode.getPosition(); const cameraMoved = this.lastLodCameraPos.distance(currentCameraPos) > distanceThreshold; if (cameraMoved) { return true; } let cameraRotated = false; const lodUpdateAngleDeg = this.scene.gsplat.lodUpdateAngle; if (lodUpdateAngleDeg > 0) { if (Number.isFinite(this.lastLodCameraFwd.x)) { const currentCameraFwd = this.cameraNode.forward; const dot = Math.min(1, Math.max(-1, this.lastLodCameraFwd.dot(currentCameraFwd))); const angle = Math.acos(dot); const rotThreshold = lodUpdateAngleDeg * math.DEG_TO_RAD; cameraRotated = angle > rotThreshold; } else { cameraRotated = true; } } const currentFov = this.cameraNode.camera.fov; const fovChanged = this.lastLodCameraFov < 0 || Math.abs(currentFov - this.lastLodCameraFov) > this.lastLodCameraFov * 0.02; return cameraMoved || cameraRotated || fovChanged; } testCameraMovedForSort() { const epsilon = 1e-3; if (this.scene.gsplat.radialSorting) { const currentCameraPos = this.cameraNode.getPosition(); return this.lastSortCameraPos.distance(currentCameraPos) > epsilon; } if (Number.isFinite(this.lastSortCameraFwd.x)) { const currentCameraFwd = this.cameraNode.forward; const dot = Math.min(1, Math.max(-1, this.lastSortCameraFwd.dot(currentCameraFwd))); return Math.acos(dot) > epsilon; } return true; } testFrustumChanged() { const epsilon = 1e-3; if (!this.lastCullingProjMat.equals(this.cameraNode.camera.projectionMatrix)) { return true; } const currentCameraFwd = this.cameraNode.forward; const dot = Math.min(1, Math.max(-1, this.lastCullingCameraFwd.dot(currentCameraFwd))); return Math.acos(dot) > epsilon; } updateColorCameraTracking() { this.lastColorUpdateCameraPos.copy(this.cameraNode.getPosition()); } getDebugColors() { const debug = this.scene.gsplat.debug; if (debug === GSPLAT_DEBUG_SH_UPDATE) { _randomColorRaw ?? (_randomColorRaw = []); const r = Math.random(); const g = Math.random(); const b = Math.random(); for (let i = 0; i < _lodColorsRaw.length; i++) { _randomColorRaw[i] ?? (_randomColorRaw[i] = [0, 0, 0]); _randomColorRaw[i][0] = r; _randomColorRaw[i][1] = g; _randomColorRaw[i][2] = b; } return _randomColorRaw; } else if (debug === GSPLAT_DEBUG_LOD) { return _lodColorsRaw; } return void 0; } calculateColorCameraDeltas() { _cameraDeltas.translationDelta = 0; if (isFinite(this.lastColorUpdateCameraPos.x)) { const currentCameraPos = this.cameraNode.getPosition(); _cameraDeltas.translationDelta = this.lastColorUpdateCameraPos.distance(currentCameraPos); } return _cameraDeltas; } fireFrameReadyEvent() { const ready = this.sortedVersion === this.lastWorldStateVersion && !this._awaitingLodUpdate; let loadingCount = 0; for (const [, inst] of this.octreeInstances) { loadingCount += inst.pendingLoadCount; } this.director.eventHandler.fire("frame:ready", this.cameraNode.camera, this.renderer.layer, ready, loadingCount); } computeGlobalMaxDistance() { let maxDist = 0; cameraPosition.copy(this.cameraNode.getPosition()); for (const [, inst] of this.octreeInstances) { const worldTransform = inst.placement.node.getWorldTransform(); const aabb = inst.placement.aabb; worldTransform.transformPoint(aabb.center, _tempVec3); const scale = worldTransform.getScale().x; const dist = _tempVec3.distance(cameraPosition) + aabb.halfExtents.length() * scale; if (dist > maxDist) maxDist = dist; } return Math.max(maxDist, 1); } _enforceBudget(budget) { const textureWidth = this.workBuffer.textureSize; let fixedSplats = 0; let paddingEstimate = 0; for (const p of this.layerPlacements) { const resource = p.resource; if (resource) { const numSplats = resource.numSplats ?? 0; fixedSplats += numSplats; paddingEstimate += (textureWidth - numSplats % textureWidth) % textureWidth; } } const octreeBudget = Math.max(1, budget - fixedSplats); const globalMaxDistance = this.computeGlobalMaxDistance(); let totalOptimalSplats = 0; for (const [, inst] of this.octreeInstances) { totalOptimalSplats += inst.evaluateOptimalLods(this.cameraNode, this.scene.gsplat, this._budgetScale, globalMaxDistance); for (const placement of inst.activePlacements) { const resource = placement.resource; const numSplats = resource?.numSplats ?? 0; paddingEstimate += (textureWidth - numSplats % textureWidth) % textureWidth; } } const adjustedBudget = Math.max(1, octreeBudget - paddingEstimate); if (totalOptimalSplats > 0) { const ratio = totalOptimalSplats / adjustedBudget; const budgetScaleDeadZone = 0.4; const budgetScaleBlendRate = 0.3; if (ratio > 1 + budgetScaleDeadZone || ratio < 1 - budgetScaleDeadZone) { const invCorrection = 1 / Math.sqrt(ratio); this._budgetScale *= 1 + (invCorrection - 1) * budgetScaleBlendRate; this._budgetScale = Math.max(0.01, Math.min(this._budgetScale, 100)); } } this._budgetBalancer.balance(this.octreeInstances, adjustedBudget); for (const [, inst] of this.octreeInstances) { const maxLod = inst.octree.lodLevels - 1; inst.applyLodChanges(maxLod, this.scene.gsplat); } } handleFormatChange() { const currentFormat = this.scene.gsplat.format; if (this.workBuffer.format !== currentFormat) { this.workBuffer.destroy(); this.workBuffer = new GSplatWorkBuffer(this.device, currentFormat); this.renderer.setDataSource(this.workBuffer); this._workBufferFormatVersion = this.workBuffer.format.extraStreamsVersion; this._workBufferRebuildRequired = true; this.sortNeeded = true; } } update() { this.bufferCopyUploaded = 0; this.bufferCopyTotal = 0; this.handleFormatChange(); const wbFormatVersion = this.workBuffer.format.extraStreamsVersion; if (this._workBufferFormatVersion !== wbFormatVersion) { this._workBufferFormatVersion = wbFormatVersion; this.workBuffer.syncWithFormat(); this._workBufferRebuildRequired = true; this.sortNeeded = true; } this.prepareRendererMode(); if (this.cpuSorter) { this.cpuSorter.applyPendingSorted(); } const sorterAvailable = this.activeRenderer !== GSPLAT_RENDERER_RASTER_CPU_SORT || this.cpuSorter && this.cpuSorter.jobsInFlight < 3; let fullUpdate = false; this.framesTillFullUpdate--; if (this.framesTillFullUpdate <= 0) { this.framesTillFullUpdate = 10; if (sorterAvailable) { fullUpdate = true; } } const hasNewInstances = this.hasNewOctreeInstances && sorterAvailable; if (hasNewInstances) this.hasNewOctreeInstances = false; let anyInstanceNeedsLodUpdate = false; let anyOctreeMoved = false; let cameraMovedOrRotatedForLod = false; if (fullUpdate) { for (const [, inst] of this.octreeInstances) { const isDirty = inst.update(); this.layerPlacementsDirty || (this.layerPlacementsDirty = isDirty); this._placementSetChanged || (this._placementSetChanged = inst.consumePlacementSetChanged()); const instNeeds = inst.consumeNeedsLodUpdate(); anyInstanceNeedsLodUpdate || (anyInstanceNeedsLodUpdate = instNeeds); } const threshold = this.scene.gsplat.lodUpdateDistance; for (const [, inst] of this.octreeInstances) { const moved = inst.testMoved(threshold); anyOctreeMoved || (anyOctreeMoved = moved); } cameraMovedOrRotatedForLod = this.testCameraMovedForLod(); this._awaitingLodUpdate = false; } if (this.testCameraMovedForSort()) { this.sortNeeded = true; } if (this.intervalCompaction && !this.sortNeeded && this.testFrustumChanged()) { this.lastCullingCameraFwd.copy(this.cameraNode.forward); this.lastCullingProjMat.copy(this.cameraNode.camera.projectionMatrix); this.sortNeeded = true; } if (this.scene.gsplat.dirty) { this.layerPlacementsDirty = true; this.renderer.updateOverdrawMode(this.scene.gsplat); this._workBufferRebuildRequired = true; this.sortNeeded = true; if (this.octreeInstances.size > 0) { this._awaitingLodUpdate = true; } } if (cameraMovedOrRotatedForLod || anyOctreeMoved || this.scene.gsplat.dirty || anyInstanceNeedsLodUpdate || hasNewInstances) { for (const [, inst] of this.octreeInstances) { inst.updateMoved(); } const cameraNode = this.cameraNode; this.lastLodCameraPos.copy(cameraNode.getPosition()); this.lastLodCameraFwd.copy(cameraNode.forward); this.lastLodCameraFov = cameraNode.camera.fov; const budget = this.scene.gsplat.splatBudget; if (budget > 0) { this._enforceBudget(budget); } else { this._budgetScale = 1; for (const [, inst] of this.octreeInstances) { inst.updateLod(this.cameraNode, this.scene.gsplat); } } } this.updateWorldState(); const lastState = this.worldStates.get(this.lastWorldStateVersion); if (lastState) { if (this.cpuSorter && !lastState.sortParametersSet) { lastState.sortParametersSet = true; const payload = this.prepareSortParameters(lastState); this.cpuSorter.setSortParameters(payload); } } const sortedState = this.worldStates.get(this.sortedVersion); if (sortedState?.sortedBefore) { if (this._workBufferRebuildRequired) { const count = sortedState.totalActiveSplats; this.rebuildWorkBuffer(sortedState, count, true); this._workBufferRebuildRequired = false; this.renderer.setOrderData(); if (this.intervalCompaction) { this.intervalCompaction._uploadedVersion = -1; } } else { this.applyWorkBufferUpdates(sortedState); } } let gpuSortedThisFrame = false; if (this.sortNeeded && lastState) { if (this.activeRenderer === GSPLAT_RENDERER_COMPUTE) { this.compactGpu(lastState); gpuSortedThisFrame = true; } else if (this.activeRenderer === GSPLAT_RENDERER_RASTER_GPU_SORT) { this.sortGpuHybrid(lastState); gpuSortedThisFrame = true; } else { this.sortCpu(lastState); } this.sortNeeded = false; this.lastSortCameraPos.copy(this.cameraNode.getPosition()); this.lastSortCameraFwd.copy(this.cameraNode.forward); this.lastCullingCameraFwd.copy(this.cameraNode.forward); this.lastCullingProjMat.copy(this.cameraNode.camera.projectionMatrix); } if (this.activeRenderer === GSPLAT_RENDERER_RASTER_GPU_SORT && lastState && !gpuSortedThisFrame) { this.sortGpuHybrid(lastState); gpuSortedThisFrame = true; } if (sortedState?.sortedBefore) { this.updateColorCameraTracking(); } if (this.octreeInstances.size) { const cooldownTicks = this.scene.gsplat.cooldownTicks; for (const [, inst] of this.octreeInstances) { const octree = inst.octree; if (!tempOctreesTicked.has(octree)) { tempOctreesTicked.add(octree); octree.updateCooldownTick(cooldownTicks); } } tempOctreesTicked.clear(); } this.fireFrameReadyEvent(); if (this.scene.gsplat.dirty) { for (const [, inst] of this.octreeInstances) { inst.needsLodUpdate = true; } } const fogParams = this.scene.gsplat.useFog ? this.cameraNode.camera.fogParams ?? this.scene.fog : null; this.renderer.frameUpdate(this.scene.gsplat, this.scene.exposure, fogParams); return sortedState ? sortedState.totalActiveSplats : 0; } sortGpuHybrid(worldState) { const cam = this.cameraNode.camera; const sceneCam = cam.camera; const rt = cam.renderTarget; const rect = cam.rect; const xrView = sceneCam.xr?.session ? sceneCam.xr.views.list[0] : null; const viewportWidth = Math.floor((xrView ? xrView.viewport.z : rt ? rt.width : this.device.width) * rect.z); const viewportHeight = Math.floor((xrView ? xrView.viewport.w : rt ? rt.height : this.device.height) * rect.w); const sortedIndices = this.sortGpuHybridForCamera( worldState, this.cameraNode, viewportWidth, viewportHeight, Math.max(ALPHA_VISIBILITY_THRESHOLD, this.scene.gsplat.alphaClipForward), false ); if (sortedIndices) { this.applyGpuSortResults(sortedIndices); } } sortGpuHybridForCamera(worldState, cameraNode, viewportWidth, viewportHeight, alphaClip, pickMode) { const gpuSorter = this.gpuSorter; const projector = this.projector; if (!gpuSorter || !projector) return null; const elementCount = worldState.totalActiveSplats; if (elementCount === 0) return null; if (!this.intervalCompaction) { this.intervalCompaction = new GSplatIntervalCompaction(this.device); } if (!worldState.sortedBefore) { worldState.sortedBefore = true; this.cleanupOldWorldStates(worldState.version); this.sortedVersion = worldState.version; this.rebuildWorkBuffer(worldState, elementCount); } this.intervalCompaction.uploadIntervals(worldState); if (this.canCull) { const state = this.worldStates.get(this.sortedVersion); if (state) { this._runFrustumCulling(state, cameraNode); } } const fisheyeProj = this.renderer.fisheyeProj; const numIntervals = worldState.totalIntervals; const totalActiveSplats = worldState.totalActiveSplats; this.intervalCompaction.dispatchCompact(this.workBuffer.frustumCuller, numIntervals, totalActiveSplats, fisheyeProj.enabled); this.allocateAndWriteIntervalIndirectArgs(numIntervals); const ic = this.intervalCompaction; const compactedSplatIds = ic.compactedSplatIds; const gsplat = this.scene.gsplat; const numBits = Math.max(10, Math.min(20, Math.round(Math.log2(elementCount / 4)))); const radixBits = gpuSorter.radixBits; const roundedNumBits = Math.ceil(numBits / radixBits) * radixBits; const { minDist, maxDist } = this.computeDistanceRange(worldState, cameraNode); const sortIndirectInfo = gpuSorter.prepareIndirect(); projector.dispatch({ workBuffer: this.workBuffer, cameraNode, compactedSplatIds, sortElementCountBuffer: ic.sortElementCountBuffer, totalCapacity: elementCount, radialSort: gsplat.radialSorting, numBits: roundedNumBits, minDist, maxDist, alphaClip, minPixelSize: gsplat.minPixelSize * 0.5, minContribution: gsplat.minContribution, viewportWidth, viewportHeight, flipY: !!cameraNode.camera.renderTarget?.flipY, pickMode, fisheyeProj, antiAlias: gsplat.antiAlias }); projector.writeIndirectArgs( this.indirectDrawSlot, this.indirectDispatchSlot + 1, ic.numSplatsBuffer, ic.sortElementCountBuffer, sortIndirectInfo ); return gpuSorter.sortIndirect( projector.sortKeys, elementCount, roundedNumBits, this.indirectDispatchSlot + 1, ic.sortElementCountBuffer, void 0, false, true // destructiveKeys: projector overwrites sortKeys each frame before the sort ); } compactGpu(worldState) { if (!this.intervalCompaction) { this.intervalCompaction = new GSplatIntervalCompaction(this.device); } const elementCount = worldState.totalActiveSplats; if (elementCount === 0) return; if (!worldState.sortedBefore) { worldState.sortedBefore = true; this.cleanupOldWorldStates(worldState.version); this.sortedVersion = worldState.version; this.rebuildWorkBuffer(worldState, elementCount); } this.intervalCompaction.uploadIntervals(worldState); if (this.canCull) { const state = this.worldStates.get(this.sortedVersion); if (state) { this._runFrustumCulling(state); } } const numIntervals = worldState.totalIntervals; const totalActiveSplats = worldState.totalActiveSplats; this.intervalCompaction.dispatchCompact(this.workBuffer.frustumCuller, numIntervals, totalActiveSplats, this.renderer.fisheyeProj.enabled); this.allocateAndWriteIntervalIndirectArgs(numIntervals); const ic = this.intervalCompaction; const localRenderer = this.renderer; localRenderer.setCompactedData( ic.compactedSplatIds, ic.sortElementCountBuffer, worldState.textureSize, totalActiveSplats ); } allocateAndWriteIntervalIndirectArgs(numIntervals) { const gpuSorter = this.gpuSorter; const sortInfo = gpuSorter ? gpuSorter.prepareIndirect() : NO_SORT_INDIRECT_INFO; const sortSlotCount = sortInfo[0]; this.indirectDrawSlot = this.device.getIndirectDrawSlot(1); this.indirectDispatchSlot = this.device.getIndirectDispatchSlot(1 + sortSlotCount); const ic = this.intervalCompaction; ic.writeIndirectArgs(this.indirectDrawSlot, this.indirectDispatchSlot, numIntervals, sortInfo); this.lastCompactedNumIntervals = numIntervals; } applyGpuSortResults(sortedIndices) { const proj = this.projector; const ic = this.intervalCompaction; this.renderer.setHybridSortedRendering( this.indirectDrawSlot, sortedIndices, proj.projCache, ic.numSplatsBuffer ); } _runFrustumCulling(worldState, cameraNode = this.cameraNode) { this.workBuffer.frustumCuller.updateTransformsData(worldState.boundsGroups); const cam = cameraNode.camera; this.workBuffer.frustumCuller.computeFrustumPlanes(cam.projectionMatrix, cam.viewMatrix); const gsplat = this.scene.gsplat; const fp = this.renderer.fisheyeProj; fp.update(gsplat.fisheye, cam.fov, cam.projectionMatrix); if (fp.enabled) { this.workBuffer.frustumCuller.setFisheyeData( cameraNode.getPosition(), cameraNode.forward, fp.maxTheta ); } } computeDistanceRange(worldState, cameraNode = this.cameraNode) { const cameraMat = cameraNode.getWorldTransform(); cameraMat.getTranslation(cameraPosition); cameraMat.getZ(cameraDirection).normalize(); const radialSort = this.scene.gsplat.radialSorting; let minDist = radialSort ? 0 : Infinity; let maxDist = radialSort ? 0 : -Infinity; for (const splat of worldState.splats) { const modelMat = splat.node.getWorldTransform(); const aabbMin = splat.aabb.getMin(); const aabbMax = splat.aabb.getMax(); for (let i = 0; i < 8; i++) { _tempVec3.x = i & 1 ? aabbMax.x : aabbMin.x; _tempVec3.y = i & 2 ? aabbMax.y : aabbMin.y; _tempVec3.z = i & 4 ? aabbMax.z : aabbMin.z; modelMat.transformPoint(_tempVec3, _tempVec3); if (radialSort) { const dist = _tempVec3.distance(cameraPosition); if (dist > maxDist) maxDist = dist; } else { const dist = _tempVec3.sub(cameraPosition).dot(cameraDirection); if (dist < minDist) minDist = dist; if (dist > maxDist) maxDist = dist; } } } if (maxDist === 0 || maxDist === -Infinity) { return { minDist: 0, maxDist: 1 }; } return { minDist, maxDist }; } sortCpu(lastState) { if (!this.cpuSorter) return; const cameraNode = this.cameraNode; const cameraMat = cameraNode.getWorldTransform(); cameraMat.getTranslation(cameraPosition); cameraMat.getZ(cameraDirection).normalize(); const sorterRequest = []; lastState.splats.forEach((splat) => { const modelMat = splat.node.getWorldTransform(); invModelMat.copy(modelMat).invert(); const uniformScale = modelMat.getScale().x; const transformedDirection = invModelMat.transformVector(cameraDirection).normalize(); const transformedPosition = invModelMat.transformPoint(cameraPosition); modelMat.getTranslation(translation); const offset = translation.sub(cameraPosition).dot(cameraDirection); const aabbMin = splat.aabb.getMin(); const aabbMax = splat.aabb.getMax(); sorterRequest.push({ transformedDirection, transformedPosition, offset, scale: uniformScale, modelMat: modelMat.data.slice(), aabbMin: [aabbMin.x, aabbMin.y, aabbMin.z], aabbMax: [aabbMax.x, aabbMax.y, aabbMax.z] }); }); this.cpuSorter.setSortParams(sorterRequest, this.scene.gsplat.radialSorting); } prepareSortParameters(worldState) { return { command: "intervals", textureSize: worldState.textureSize, totalActiveSplats: worldState.totalActiveSplats, version: worldState.version, ids: worldState.splats.map((splat) => splat.resource.id), pixelOffsets: worldState.splats.map((splat) => splat.intervalOffsets), // TODO: consider storing this in typed array and transfer it to sorter worker intervals: worldState.splats.map((splat) => splat.intervals) }; } } export { GSplatManager };