UNPKG

playcanvas

Version:

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

719 lines (718 loc) 25.1 kB
import { math } from "../../core/math/math.js"; import { Mat4 } from "../../core/math/mat4.js"; import { Vec3 } from "../../core/math/vec3.js"; import { BoundingBox } from "../../core/shape/bounding-box.js"; import { BlockAllocator } from "../../core/block-allocator.js"; import { GSplatInfo } from "./gsplat-info.js"; import { GSplatWorkBuffer } from "./gsplat-work-buffer.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 { GSplatBudgetBalancer } from "./gsplat-budget-balancer.js"; import { GSPLAT_DEBUG_LOD, GSPLAT_DEBUG_SH_UPDATE } from "../constants.js"; const cameraPosition = new Vec3(); const _tempVec3 = new Vec3(); const invModelMat = new Mat4(); const _localCamPos = new Vec3(); const _closestPt = new Vec3(); const _meshInstanceAabb = new BoundingBox(); const _tempPlacementAabb = new BoundingBox(); const _cameraDeltas = { translationDelta: 0 }; const tempOctreesTicked = /* @__PURE__ */ new Set(); const _queuedSplats = /* @__PURE__ */ new Set(); const _updatedSplats = []; const _splatsWithSH = []; const _changedColorAllocIds = /* @__PURE__ */ new Set(); const tempNonOctreePlacements = /* @__PURE__ */ new Set(); const tempOctreePlacements = /* @__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 ]; let _randomColorRaw = null; const ALLOCATOR_GROW_MULTIPLIER = 1.15; class GSplatWorld { _device; _scene; _workBuffer; _worldStates = /* @__PURE__ */ new Map(); _lastWorldStateVersion = 0; _currentVersion = 0; _worldStateDirty = false; _workBufferFormatVersion = -1; _workBufferRebuildRequired = false; _bufferCopyUploaded = 0; _bufferCopyTotal = 0; _stateTracker = new GSplatPlacementStateTracker(); _framesTillFullUpdate = 0; _lodUpdateRequested = false; _lastLodCameraPos = new Vec3(Infinity, Infinity, Infinity); _lastLodCameraFwd = new Vec3(Infinity, Infinity, Infinity); _lastLodCameraFov = -1; _budgetBalancer = new GSplatBudgetBalancer(); _budgetScale = 1; _allocator; _allocationMap = /* @__PURE__ */ new Map(); _lastColorUpdateCameraPos = new Vec3(Infinity, Infinity, Infinity); _layerPlacements = []; _layerPlacementsDirty = false; _placementSetChanged = false; _octreeInstances = /* @__PURE__ */ new Map(); _octreeInstancesToDestroy = []; _hasNewOctreeInstances = false; _awaitingLodUpdate = false; constructor(device, scene) { this._device = device; this._scene = scene; const budget = scene.gsplat.splatBudget; this._allocator = new BlockAllocator(budget > 0 ? Math.ceil(budget * ALLOCATOR_GROW_MULTIPLIER) : 0, ALLOCATOR_GROW_MULTIPLIER); this._workBuffer = new GSplatWorkBuffer(device, scene.gsplat.format); this._workBufferFormatVersion = this._workBuffer.format.extraStreamsVersion; } destroy() { 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._workBuffer.destroy(); } // --- read-only accessors (the one-way contract: manager reads, never reassigns) --- get workBuffer() { return this._workBuffer; } get currentVersion() { return this._currentVersion; } get lastWorldStateVersion() { return this._lastWorldStateVersion; } get bufferCopyUploaded() { return this._bufferCopyUploaded; } get bufferCopyTotal() { return this._bufferCopyTotal; } get awaitingLodUpdate() { return this._awaitingLodUpdate; } get hasOctreeInstances() { return this._octreeInstances.size > 0; } get pendingLoadCount() { let loadingCount = 0; for (const [, inst] of this._octreeInstances) { loadingCount += inst.pendingLoadCount; } return loadingCount; } get currentState() { return this._worldStates.get(this._currentVersion); } getState(version) { return this._worldStates.get(version); } get hasBounds() { return this._workBuffer.frustumCuller.totalBoundsEntries > 0; } // --- mutation entry points (manager-driven) --- resetFrameStats() { this._bufferCopyUploaded = 0; this._bufferCopyTotal = 0; } invalidate({ worldState = false, workBuffer = false } = {}) { if (worldState) this._worldStateDirty = true; if (workBuffer) this._workBufferRebuildRequired = true; } invalidateSortState() { const currentState = this._worldStates.get(this._currentVersion); if (!currentState) return null; currentState.sortParametersSet = false; currentState.sortedBefore = false; return currentState.splats; } syncFormat(result) { result.bufferRecreated = false; result.sortNeeded = false; const currentFormat = this._scene.gsplat.format; if (this._workBuffer.format !== currentFormat) { this._workBuffer.destroy(); this._workBuffer = new GSplatWorkBuffer(this._device, currentFormat); this._workBufferFormatVersion = this._workBuffer.format.extraStreamsVersion; this._workBufferRebuildRequired = true; result.bufferRecreated = true; result.sortNeeded = true; } const wbFormatVersion = this._workBuffer.format.extraStreamsVersion; if (this._workBufferFormatVersion !== wbFormatVersion) { this._workBufferFormatVersion = wbFormatVersion; this._workBuffer.syncWithFormat(); this._workBufferRebuildRequired = true; result.sortNeeded = true; } return result; } 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(); } update(camera, allowLodUpdate, requireCenters, result) { result.newVersion = false; result.overdrawDirty = false; result.sortNeeded = false; for (const [placement, inst] of this._octreeInstances) { if (inst.octree.destroyed || !placement.resource) { this._octreeInstances.delete(placement); this._layerPlacementsDirty = true; this._placementSetChanged = true; this._octreeInstancesToDestroy.push(inst); } } if (--this._framesTillFullUpdate <= 0) { this._framesTillFullUpdate = 10; this._lodUpdateRequested = true; } let fullUpdate = false; if (this._lodUpdateRequested && allowLodUpdate) { fullUpdate = true; this._lodUpdateRequested = false; } const hasNewInstances = this._hasNewOctreeInstances && allowLodUpdate; 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(camera); this._awaitingLodUpdate = false; } if (this._scene.gsplat.dirty) { this._layerPlacementsDirty = true; result.overdrawDirty = true; this._workBufferRebuildRequired = true; result.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(); } this._lastLodCameraPos.copy(camera.getPosition()); this._lastLodCameraFwd.copy(camera.forward); this._lastLodCameraFov = camera.camera.fov; const budget = this._scene.gsplat.splatBudget; if (budget > 0) { this._enforceBudget(budget, camera); } else { this._budgetScale = 1; for (const [, inst] of this._octreeInstances) { inst.updateLod(camera, this._scene.gsplat); } } } if (this._updateWorldState(requireCenters)) { result.newVersion = true; result.sortNeeded = true; } return result; } _updateWorldState(requireCenters) { 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) { return false; } this._lastWorldStateVersion++; const splats = []; for (const p of this._layerPlacements) { if (requireCenters && !p.resource.hasCenters) { continue; } p.ensureInstanceStreams(this._device); const splatInfo = new GSplatInfo(this._device, p.resource, p); splats.push(splatInfo); } for (const [, inst] of this._octreeInstances) { inst.activePlacements.forEach((p) => { if (p.resource) { const leafResource = p.resource; if (requireCenters && !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, octreeNodes, nodeInfos); splats.push(splatInfo); } }); } 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; return true; } markSorted(version, count, camera, updateBounds, result) { result.rebuilt = false; result.count = 0; result.textureSize = 0; this.cleanupOldWorldStates(version); this._currentVersion = version; const worldState = this._worldStates.get(version); if (worldState && !worldState.sortedBefore) { worldState.sortedBefore = true; this.rebuildWorkBuffer(worldState, count, false, camera, updateBounds); result.rebuilt = true; result.count = count; result.textureSize = worldState.textureSize; } return result; } onSorted(version, count, orderData, camera, updateBounds, result) { this.markSorted(version, count, camera, updateBounds, result); if (this._worldStates.get(version)) { this._workBuffer.setOrderData(orderData); } return result; } bake(version, camera, updateBounds, result) { result.rebuilt = false; result.count = 0; result.textureSize = 0; result.sortNeeded = false; const sortedState = this._worldStates.get(version); if (sortedState?.sortedBefore) { if (this._workBufferRebuildRequired) { const count = sortedState.totalActiveSplats; this.rebuildWorkBuffer(sortedState, count, true, camera, updateBounds); this._workBufferRebuildRequired = false; result.rebuilt = true; result.count = count; result.textureSize = sortedState.textureSize; } else { result.sortNeeded = this.applyWorkBufferUpdates(sortedState, camera); } this.updateColorCameraTracking(camera); } return result; } rebuildWorkBuffer(worldState, count, forceFullRebuild, camera, updateBounds) { const textureSize = worldState.textureSize; if (textureSize !== this._workBuffer.textureSize) { this._workBuffer.resize(textureSize); } if (updateBounds) { 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, camera, this.getDebugColors(), changedAllocIds); } for (let i = 0; i < worldState.splats.length; i++) { worldState.splats[i].update(); } this.updateColorCameraTracking(camera); 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; } } cleanupOldWorldStates(newVersion) { const activeState = this._worldStates.get(newVersion); if (!activeState.fullRebuild) { for (let v = this._currentVersion + 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._currentVersion + 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._currentVersion; 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, camera) { const { colorUpdateAngle } = this._scene.gsplat; const ratio = Math.tan(colorUpdateAngle * math.DEG_TO_RAD); const cameraPos = camera.getPosition(); const { translationDelta } = this.calculateColorCameraDeltas(camera); const hasCameraMovement = translationDelta > 0; let movedAny = false; 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; } movedAny = 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, camera, this.getDebugColors()); _updatedSplats.length = 0; } if (_changedColorAllocIds.size > 0) { this._workBuffer.renderColor( _splatsWithSH, camera, this.getDebugColors(), _changedColorAllocIds ); _changedColorAllocIds.clear(); } _splatsWithSH.length = 0; return movedAny; } testCameraMovedForLod(camera) { const distanceThreshold = this._scene.gsplat.lodUpdateDistance; const currentCameraPos = camera.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 = camera.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 = camera.camera.fov; const fovChanged = this._lastLodCameraFov < 0 || Math.abs(currentFov - this._lastLodCameraFov) > this._lastLodCameraFov * 0.02; return cameraMoved || cameraRotated || fovChanged; } updateColorCameraTracking(camera) { this._lastColorUpdateCameraPos.copy(camera.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(camera) { _cameraDeltas.translationDelta = 0; if (isFinite(this._lastColorUpdateCameraPos.x)) { const currentCameraPos = camera.getPosition(); _cameraDeltas.translationDelta = this._lastColorUpdateCameraPos.distance(currentCameraPos); } return _cameraDeltas; } computeGlobalMaxDistance(camera) { let maxDist = 0; cameraPosition.copy(camera.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, camera) { 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(camera); let totalOptimalSplats = 0; for (const [, inst] of this._octreeInstances) { totalOptimalSplats += inst.evaluateOptimalLods(camera, 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); } } computeAggregateAabb() { let initialized = false; const layerPlacements = this._layerPlacements; for (let i = 0; i < layerPlacements.length; i++) { initialized = this._accumulatePlacementAabb(layerPlacements[i], initialized); } for (const [, inst] of this._octreeInstances) { initialized = this._accumulatePlacementAabb(inst.placement, initialized); } return initialized ? _meshInstanceAabb : null; } _accumulatePlacementAabb(placement, initialized) { const a = placement.aabb; if (!a) return initialized; _tempPlacementAabb.setFromTransformedAabb(a, placement.node.getWorldTransform()); if (initialized) { _meshInstanceAabb.add(_tempPlacementAabb); } else { _meshInstanceAabb.copy(_tempPlacementAabb); } return true; } tickCooldowns() { 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(); } } markInstancesNeedLodUpdate() { for (const [, inst] of this._octreeInstances) { inst.needsLodUpdate = true; } } 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 { GSplatWorld };