UNPKG

playcanvas

Version:

PlayCanvas WebGL game engine

471 lines (468 loc) 20.1 kB
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 { GSplatRenderer } from './gsplat-renderer.js'; import { GSplatOctreeInstance } from './gsplat-octree-instance.js'; import { GSplatOctreeResource } from './gsplat-octree.resource.js'; import { GSplatWorldState } from './gsplat-world-state.js'; import { Debug } from '../../core/debug.js'; import { BoundingBox } from '../../core/shape/bounding-box.js'; import { Color } from '../../core/math/color.js'; /** * @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js' * @import { GSplatPlacement } from './gsplat-placement.js' * @import { Scene } from '../scene.js' */ const cameraPosition = new Vec3(); const cameraDirection = new Vec3(); const translation = new Vec3(); const invModelMat = new Mat4(); const tempNonOctreePlacements = new Set(); const tempOctreePlacements = new Set(); const _updatedSplats = []; const tempOctreesTicked = new Set(); const _lodColorsRaw = [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ], [ 1, 1, 0 ], [ 1, 0, 1 ] // magenta ]; // Color instances used by debug wireframe rendering 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) ]; /** * GSplatManager manages the rendering of splats using a work buffer, where all active splats are * stored and rendered from. * * @ignore */ class GSplatManager { constructor(device, director, layer, cameraNode){ /** @type {GraphNode} */ this.node = new GraphNode('GSplatManager'); /** * A map of versioned world states, keyed by version. * * @type {Map<number, GSplatWorldState>} */ this.worldStates = new Map(); /** * The version of the last world state. * * @type {number} */ this.lastWorldStateVersion = 0; /** @type {number} */ this.sortedVersion = 0; /** @type {number} */ this.framesTillFullUpdate = 0; /** @type {Vec3} */ this.lastCameraPos = new Vec3(Infinity, Infinity, Infinity); /** @type {Vec3} */ this.lastCameraFwd = new Vec3(Infinity, Infinity, Infinity); /** * Layer placements, only non-octree placements are included. * * @type {GSplatPlacement[]} */ this.layerPlacements = []; /** @type {boolean} */ this.layerPlacementsDirty = false; /** @type {Map<GSplatPlacement, GSplatOctreeInstance>} */ this.octreeInstances = new Map(); /** * Octree instances scheduled for destruction. We collect their releases and destroy them * when creating the next world state * * @type {GSplatOctreeInstance[]} */ this.octreeInstancesToDestroy = []; this.device = device; this.scene = director.scene; this.director = director; this.cameraNode = cameraNode; this.workBuffer = new GSplatWorkBuffer(device); this.renderer = new GSplatRenderer(device, this.node, this.cameraNode, layer, this.workBuffer); this.sorter = this.createSorter(); this.cooldownTicks = this.director.assetLoader.cooldownTicks; } destroy() { this.workBuffer.destroy(); this.renderer.destroy(); this.sorter.destroy(); } createSorter() { // create sorter const sorter = new GSplatUnifiedSorter(); sorter.on('sorted', (count, version, orderData)=>{ this.onSorted(count, version, orderData); }); return sorter; } /** * Supply the manager with the placements to use. This is used to update the manager when the * layer's placements have changed, called infrequently. * * @param {GSplatPlacement[]} placements - The placements to reconcile with. */ reconcile(placements) { tempNonOctreePlacements.clear(); for (const p of placements){ if (p.resource instanceof GSplatOctreeResource) { // make sure octree instance exists for placement if (!this.octreeInstances.has(p)) { this.octreeInstances.set(p, new GSplatOctreeInstance(p.resource.octree, p, this.director.assetLoader)); } tempOctreePlacements.add(p); } else { // collect non-octree placement tempNonOctreePlacements.add(p); } } // remove octree instances that are no longer present and schedule them for destruction for (const [placement, inst] of this.octreeInstances){ if (!tempOctreePlacements.has(placement)) { this.octreeInstances.delete(placement); // mark world as dirty since octree set changed this.layerPlacementsDirty = true; // queue the instance to be processed during next world state creation this.octreeInstancesToDestroy.push(inst); } } // compute dirtiness of non-octree placements compared to existing layerPlacements 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; } } } // update layerPlacements to new non-octree list this.layerPlacements.length = 0; for (const p of tempNonOctreePlacements){ this.layerPlacements.push(p); } // clear temporaries tempNonOctreePlacements.clear(); tempOctreePlacements.clear(); } updateWorldState() { // Recreate world state if there are changes const worldChanged = this.layerPlacementsDirty || this.worldStates.size === 0; if (worldChanged) { this.lastWorldStateVersion++; const splats = []; // add standalone splats for (const p of this.layerPlacements){ const splatInfo = new GSplatInfo(this.device, p.resource, p); splats.push(splatInfo); } // add octree splats for (const [, inst] of this.octreeInstances){ inst.activePlacements.forEach((p)=>{ if (p.resource) { splats.push(new GSplatInfo(this.device, p.resource, p)); } }); } // add resource centers to sorter splats.forEach((splat)=>{ this.sorter.setCenters(splat.resource.id, splat.resource.centers); }); const newState = new GSplatWorldState(this.device, this.lastWorldStateVersion, splats); // collect file-release requests from octree instances. for (const [, inst] of this.octreeInstances){ if (inst.removedCandidates && inst.removedCandidates.size) { for (const fileIndex of inst.removedCandidates){ // each entry represents a single decRef // pending releases will be applied on onSorted for this state newState.pendingReleases.push([ inst.octree, fileIndex ]); } inst.removedCandidates.clear(); } } // handle destruction of octree instances if (this.octreeInstancesToDestroy.length) { for (const inst of this.octreeInstancesToDestroy){ // collect file-release requests from octree instances const toRelease = inst.getFileDecrements(); for (const fileIndex of toRelease){ newState.pendingReleases.push([ inst.octree, fileIndex ]); } inst.destroy(); } this.octreeInstancesToDestroy.length = 0; } this.worldStates.set(this.lastWorldStateVersion, newState); this.layerPlacementsDirty = false; } } onSorted(count, version, orderData) { this.sortedVersion = version; // remove old state const oldState = this.worldStates.get(version - 1); if (oldState) { this.worldStates.delete(version - 1); oldState.destroy(); } // find the world state that has been sorted const worldState = this.worldStates.get(version); Debug.assert(worldState, `World state with version ${version} not found`); if (worldState) { // when a new version was sorted for the first time, we need to fully update work buffer // to match centers buffer / sorted data if (!worldState.sortedBefore) { worldState.sortedBefore = true; // resize work buffer if needed const textureSize = worldState.textureSize; if (textureSize !== this.workBuffer.textureSize) { this.workBuffer.resize(textureSize); this.renderer.setMaxNumSplats(textureSize * textureSize); } // render all splats to work buffer with LOD color palette const colorize = this.scene.gsplat.colorizeLod; this.workBuffer.render(worldState.splats, this.cameraNode, colorize ? _lodColorsRaw : undefined); // apply pending file-release requests if (worldState.pendingReleases && worldState.pendingReleases.length) { for (const [octree, fileIndex] of worldState.pendingReleases){ // decrement once for each staged release; refcount system guards against premature unload octree.decRefCount(fileIndex, this.cooldownTicks); } worldState.pendingReleases.length = 0; } // number of splats to render this.renderer.setNumSplats(count); } // update order texture this.workBuffer.setOrderData(orderData); } } /** * Tests if the camera has moved or rotated enough to require LOD update. * * @returns {boolean} True if camera moved/rotated over thresholds, otherwise false. */ testCameraMoved() { // distance-based movement check const distanceThreshold = this.scene.gsplat.lodUpdateDistance; const currentCameraPos = this.cameraNode.getPosition(); const cameraMoved = this.lastCameraPos.distance(currentCameraPos) > distanceThreshold; if (cameraMoved) { return true; } // rotation-based movement check (optional) let cameraRotated = false; const lodUpdateAngleDeg = this.scene.gsplat.lodUpdateAngle; if (lodUpdateAngleDeg > 0) { if (Number.isFinite(this.lastCameraFwd.x)) { const currentCameraFwd = this.cameraNode.forward; const dot = Math.min(1, Math.max(-1, this.lastCameraFwd.dot(currentCameraFwd))); const angle = Math.acos(dot); const rotThreshold = lodUpdateAngleDeg * Math.PI / 180; cameraRotated = angle > rotThreshold; } else { // first run, force update to initialize last orientation cameraRotated = true; } } return cameraMoved || cameraRotated; } update() { let fullUpdate = false; this.framesTillFullUpdate--; if (this.framesTillFullUpdate <= 0) { this.framesTillFullUpdate = 10; // if sorter can keep up if (this.sorter.jobsInFlight < 3) { fullUpdate = true; } } let anyInstanceNeedsLodUpdate = false; let anyOctreeMoved = false; let cameraMovedOrRotated = false; if (fullUpdate) { // process any pending / prefetch resource completions and collect LOD updates for (const [, inst] of this.octreeInstances){ const isDirty = inst.update(this.scene); this.layerPlacementsDirty ||= isDirty; const instNeeds = inst.consumeNeedsLodUpdate(); anyInstanceNeedsLodUpdate ||= instNeeds; } // check if any octree instances have moved enough to require LOD update const threshold = this.scene.gsplat.lodUpdateDistance; for (const [, inst] of this.octreeInstances){ const moved = inst.testMoved(threshold); anyOctreeMoved ||= moved; } // check if camera has moved/rotated enough to require LOD update cameraMovedOrRotated = this.testCameraMoved(); } Debug.call(()=>{ for (const [, inst] of this.octreeInstances){ inst.debugRender(this.scene); } }); // if parameters are dirty, rebuild world state if (this.scene.gsplat.dirty) { this.layerPlacementsDirty = true; } // when camera or octree need LOD evaluated, or params are dirty, or resources completed if (cameraMovedOrRotated || anyOctreeMoved || this.scene.gsplat.dirty || anyInstanceNeedsLodUpdate) { // update the previous position where LOD was evaluated for octree instances for (const [, inst] of this.octreeInstances){ inst.updateMoved(); } // update last camera data when LOD was evaluated this.lastCameraPos.copy(this.cameraNode.getPosition()); this.lastCameraFwd.copy(this.cameraNode.forward); // update LOD for all octree instances for (const [, inst] of this.octreeInstances){ inst.updateLod(this.cameraNode, this.scene.gsplat); } } // create new world state if needed this.updateWorldState(); // update sorter with new world state const lastState = this.worldStates.get(this.lastWorldStateVersion); if (lastState) { if (!lastState.sortParametersSet) { lastState.sortParametersSet = true; const payload = this.prepareSortParameters(lastState); this.sorter.setSortParameters(payload); } // debug render world space bounds for all splats Debug.call(()=>{ if (this.scene.gsplat.debugAabbs) { const tempAabb = new BoundingBox(); const scene = this.scene; lastState.splats.forEach((splat)=>{ tempAabb.setFromTransformedAabb(splat.aabb, splat.node.getWorldTransform()); scene.immediate.drawWireAlignedBox(tempAabb.getMin(), tempAabb.getMax(), _lodColors[splat.lodIndex], true, scene.defaultDrawLayer); }); } }); // kick off sorting this.sort(lastState); } // re-render splats that have changed their transform this frame, using last sorted state const sortedState = this.worldStates.get(this.sortedVersion); if (sortedState) { // Collect splats that have been updated sortedState.splats.forEach((splat)=>{ if (splat.update()) { _updatedSplats.push(splat); } }); // Batch render all updated splats in a single render pass if (_updatedSplats.length > 0) { const colorize = this.scene.gsplat.colorizeLod; this.workBuffer.render(_updatedSplats, this.cameraNode, colorize ? _lodColorsRaw : undefined); _updatedSplats.length = 0; } } // tick cooldowns once per frame per unique octree if (this.octreeInstances.size) { for (const [, inst] of this.octreeInstances){ const octree = inst.octree; if (!tempOctreesTicked.has(octree)) { tempOctreesTicked.add(octree); octree.updateCooldownTick(this.director.assetLoader); } } tempOctreesTicked.clear(); } // return the number of visible splats for stats const { textureSize } = this.workBuffer; return textureSize * textureSize; } /** * Sorts the splats of the given world state. * * @param {GSplatWorldState} lastState - The last world state. */ sort(lastState) { // Get camera's world-space properties 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(); // uniform scale const uniformScale = modelMat.getScale().x; // camera direction in splat's rotated space // transform by the full inverse matrix and then normalize, which cancels the (1/s) scaling factor const transformedDirection = invModelMat.transformVector(cameraDirection).normalize(); // camera position in splat's local space (for circular sorting) const transformedPosition = invModelMat.transformPoint(cameraPosition); // world-space offset modelMat.getTranslation(translation); const offset = translation.sub(cameraPosition).dot(cameraDirection); // sorter parameters 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.sorter.setSortParams(sorterRequest, this.scene.gsplat.radialSorting); this.renderer.updateViewport(cameraNode); } /** * Prepares sort parameters data for the sorter worker. * * @param {GSplatWorldState} worldState - The world state containing all needed data. * @returns {object} - Data for sorter worker. */ prepareSortParameters(worldState) { return { command: 'intervals', textureSize: worldState.textureSize, totalUsedPixels: worldState.totalUsedPixels, version: worldState.version, ids: worldState.splats.map((splat)=>splat.resource.id), lineStarts: worldState.splats.map((splat)=>splat.lineStart), padding: worldState.splats.map((splat)=>splat.padding), // TODO: consider storing this in typed array and transfer it to sorter worker intervals: worldState.splats.map((splat)=>splat.intervals) }; } } export { GSplatManager };