playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
637 lines (636 loc) • 25.2 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { Mat4 } from "../../core/math/mat4.js";
import { Vec3 } from "../../core/math/vec3.js";
import { GraphNode } from "../graph-node.js";
import { GSplatUnifiedSorter } from "./gsplat-unified-sorter.js";
import { GSplatWorld } from "./gsplat-world.js";
import { GSplatQuadRenderer } from "./gsplat-quad-renderer.js";
import { GSplatHybridRenderer } from "./gsplat-hybrid-renderer.js";
import { GSplatHybridRendererScratch } from "./gsplat-hybrid-renderer-scratch.js";
import { GSplatShadowRenderer } from "./gsplat-shadow-renderer.js";
import { Debug } from "../../core/debug.js";
import { BoundingBox } from "../../core/shape/bounding-box.js";
import {
GSPLAT_RENDERER_RASTER_GPU_SORT,
GSPLAT_FORWARD,
GSPLAT_SHADOW,
GSPLAT_DEBUG_AABBS
} from "../constants.js";
import { Color } from "../../core/math/color.js";
const cameraPosition = new Vec3();
const cameraDirection = new Vec3();
const translation = new Vec3();
const invModelMat = new Mat4();
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)
];
class GSplatManager {
/**
* @param {GraphicsDevice} device - The graphics device.
* @param {GSplatDirector} director - The director.
* @param {Layer} layer - The layer.
* @param {GraphNode} cameraNode - The camera node.
*/
constructor(device, director, layer, cameraNode) {
/** @type {GraphicsDevice} */
__publicField(this, "device");
/** @type {GraphNode} */
__publicField(this, "node", new GraphNode("GSplatManager"));
/**
* Owns the work buffer, versioned world states, allocation, octree/LOD evaluation, streaming,
* budget, and the work-buffer bake. Created 1:1 per manager (no sharing yet).
*
* @type {GSplatWorld}
*/
__publicField(this, "world");
/** @type {GSplatRenderer} */
__publicField(this, "renderer");
/**
* Casts directional shadows for the GPU-sort (hybrid) forward renderer, which cannot self-cast.
* Null when the forward renderer is CPU-sort (the quad renderer self-casts) or when this
* manager has no shadow render mode. Shares this manager's {@link GSplatWorld}.
*
* @type {GSplatShadowRenderer|null}
*/
__publicField(this, "shadowRenderer", null);
/**
* Shared GPU scratch for the GPU-sort (hybrid) path, created while a GPU-sort renderer is in use
* and injected into both the forward {@link GSplatHybridRenderer} and the
* {@link GSplatShadowRenderer} so they share the compaction candidate buffer. Null for the
* CPU-sort (quad) renderer.
*
* @type {GSplatHybridRendererScratch|null}
* @private
*/
__publicField(this, "_hybridScratch", null);
/**
* The currently active renderer mode. Starts as undefined so the first
* prepareRendererMode() call always creates the appropriate resources.
*
* @type {number|undefined}
*/
__publicField(this, "activeRenderer");
/**
* CPU-based sorter (used by the quad renderer; the hybrid renderer owns its own GPU sort).
*
* @type {GSplatUnifiedSorter|null}
*/
__publicField(this, "cpuSorter", null);
/**
* Tracks last seen centersVersion per resource ID for detecting centers updates. Used to feed
* the CPU sorter when the world creates a new world-state version.
*
* @type {Map<number, number>}
* @private
*/
__publicField(this, "_centersVersions", /* @__PURE__ */ new Map());
/** @type {Vec3} */
__publicField(this, "lastSortCameraPos", new Vec3(Infinity, Infinity, Infinity));
/** @type {Vec3} */
__publicField(this, "lastSortCameraFwd", new Vec3(Infinity, Infinity, Infinity));
/** @type {boolean} */
__publicField(this, "sortNeeded", true);
/**
* Event handle for the graphics device restored event.
*
* @type {EventHandle|null}
* @private
*/
__publicField(this, "_deviceRestoredEvent", null);
/** @type {GraphNode} */
__publicField(this, "cameraNode");
/** @type {Scene} */
__publicField(this, "scene");
/**
* Bitmask flags controlling which render passes this manager participates in.
*
* @type {number|undefined}
*/
__publicField(this, "renderMode");
/**
* Persistent result objects written (out-param) by the {@link GSplatWorld} APIs to avoid
* per-frame allocation. Consumed synchronously by the manager after each call.
*
* @private
*/
__publicField(this, "_updateResult", { newVersion: false, overdrawDirty: false, sortNeeded: false });
/** @private */
__publicField(this, "_bakeResult", { rebuilt: false, count: 0, textureSize: 0, sortNeeded: false });
/** @private */
__publicField(this, "_markResult", { rebuilt: false, count: 0, textureSize: 0 });
/** @private */
__publicField(this, "_formatResult", { bufferRecreated: false, sortNeeded: false });
/**
* Frame token of the last {@link updateStreaming} run, for once-per-frame dedup between the
* component-system streaming tick and the render path.
*
* @type {number}
* @private
*/
__publicField(this, "_lastStreamToken", -1);
/**
* Whether the most recent streaming pass produced new data a render would show (new world-state
* version or work-buffer recreation). Used by the director to decide whether to fire frame:request.
*
* @type {boolean}
* @private
*/
__publicField(this, "_streamAdvanced", false);
/**
* Reused per-call parameter bag passed to the renderer's forward {@link GSplatRenderer#prepareRenderView}.
* Avoids per-frame allocation and keeps the renderer free of a back-reference to the manager/scene.
*
* @type {GSplatRenderViewParams}
* @private
*/
__publicField(
this,
"_renderViewParams",
/** @type {GSplatRenderViewParams} */
{}
);
/**
* Reused per-call parameter bag passed to the renderer's {@link GSplatRenderer#preparePickingView}.
* Separate from {@link _renderViewParams} so mid-frame picking can't corrupt the forward params.
*
* @type {GSplatRenderViewParams}
* @private
*/
__publicField(
this,
"_pickParams",
/** @type {GSplatRenderViewParams} */
{}
);
this.device = device;
this.scene = director.scene;
this.director = director;
this.cameraNode = cameraNode;
this.world = new GSplatWorld(device, this.scene);
this.layer = layer;
this._createRenderer(this.scene.gsplat.currentRenderer);
this._deviceRestoredEvent = this.device.on("devicerestored", this._onDeviceRestored, this);
}
destroy() {
this._destroyed = true;
this._deviceRestoredEvent?.off();
this._deviceRestoredEvent = null;
this.destroyCpuSorting();
this.world.destroy();
this.renderer.destroy();
this.shadowRenderer?.destroy();
this.shadowRenderer = null;
this._hybridScratch?.destroy();
this._hybridScratch = null;
}
/**
* Handles a graphics context restore: the work buffer render target is recreated empty, so
* force a full rebuild and re-sort to re-materialize the splats from the (auto-restored) source
* textures.
*
* Skipped when the world has streaming octree instances: those destroy and asynchronously
* reload their source resources from URL via their own device-lost handling, and rebuilding the
* work buffer here would render from textures that have been destroyed (and not yet reloaded).
*
* @private
*/
_onDeviceRestored() {
if (this.world.hasOctreeInstances) return;
this.world.invalidate({ workBuffer: true });
this.sortNeeded = true;
}
/**
* Destroys CPU sorting resources (worker-based sorter).
*
* @private
*/
destroyCpuSorting() {
this.cpuSorter?.destroy();
this.cpuSorter = null;
}
/**
* Creates the CPU sorter and prepares it for the current world state. Disables any
* GPU-side indirect draw and hides the mesh until the first sort result arrives.
*
* @private
*/
initCpuSorting() {
if (!this.cpuSorter) {
this.cpuSorter = this.createSorter();
}
const splats = this.world.invalidateSortState();
if (splats) {
this.cpuSorter.updateCentersForSplats(splats);
}
this.renderer.setCpuSortedRendering();
}
get material() {
return this.renderer.material;
}
/**
* Number of work-buffer blocks uploaded this frame (forwarded from the world for stats).
*
* @type {number}
*/
get bufferCopyUploaded() {
return this.world.bufferCopyUploaded;
}
/**
* Total number of work-buffer blocks this frame (forwarded from the world for stats).
*
* @type {number}
*/
get bufferCopyTotal() {
return this.world.bufferCopyTotal;
}
/**
* True when the CPU sorter has a completed sort result waiting to be applied by a render. Used
* by the director to request a render so the pending result is applied.
*
* @type {boolean}
*/
get hasPendingSort() {
return !!this.cpuSorter?.pendingSorted;
}
/**
* Dispatches a renderer-specific pick pipeline and returns the configured pick mesh instance.
* The hybrid renderer refreshes its shared projector/sort buffers for the picker camera and
* returns a transient pick mesh.
*
* @param {object} camera - The camera.
* @param {number} width - Pick target width.
* @param {number} height - Pick target height.
* @returns {import('../mesh-instance.js').MeshInstance|null} The pick mesh instance, or null.
*/
prepareForPicking(camera, width, height) {
if (!this.renderer.usesGpuSort || !camera.node) return null;
const sortedState = this.world.getState(this.world.currentVersion);
if (!sortedState?.sortedBefore) return null;
return this.renderer.preparePickingView(this.world, sortedState, this._fillPickParams(camera, width, height));
}
/**
* Writes the current scene gsplat params into a renderer per-view parameter bag. Lets the
* renderer run its GPU pipeline without a back-reference to the manager or scene.
*
* @param {GSplatRenderViewParams} p - The parameter bag to populate.
* @private
*/
_writeGsplatParams(p) {
const gsplat = this.scene.gsplat;
p.radialSorting = gsplat.radialSorting;
p.alphaClip = gsplat.alphaClip;
p.alphaClipForward = gsplat.alphaClipForward;
p.minPixelSize = gsplat.minPixelSize;
p.minContribution = gsplat.minContribution;
p.foveationStrength = gsplat.foveationStrength;
p.foveationCenter = gsplat.foveationCenter;
p.antiAlias = gsplat.antiAlias;
p.fisheye = gsplat.fisheye;
p.material = gsplat.material;
p.varyings = gsplat.varyings;
}
/**
* Fills and returns the reused forward-view parameter bag for the manager's camera.
*
* @returns {GSplatRenderViewParams} The populated {@link _renderViewParams}.
* @private
*/
_fillRenderViewParams() {
const p = this._renderViewParams;
this._writeGsplatParams(p);
p.cameraNode = this.cameraNode;
return p;
}
/**
* Fills and returns the reused picking parameter bag for the picker camera.
*
* @param {object} camera - The picker camera.
* @param {number} width - Pick target width.
* @param {number} height - Pick target height.
* @returns {GSplatRenderViewParams} The populated {@link _pickParams}.
* @private
*/
_fillPickParams(camera, width, height) {
const p = this._pickParams;
this._writeGsplatParams(p);
p.cameraNode = camera.node;
p.width = width;
p.height = height;
return p;
}
/**
* Creates the CPU sorter (Web Worker based).
*
* @returns {GSplatUnifiedSorter} The created sorter.
*/
createSorter() {
const sorter = new GSplatUnifiedSorter(this.scene);
sorter.on("sorted", (count, version, orderData) => {
this.onSorted(count, version, orderData);
});
return sorter;
}
/**
* Sets the render mode for this manager and its renderer.
*
* @param {number} renderMode - Bitmask flags controlling render passes (GSPLAT_FORWARD, GSPLAT_SHADOW, or both).
* @ignore
*/
setRenderMode(renderMode) {
this.renderMode = renderMode;
this.renderer.setRenderMode(renderMode);
this._syncShadowRenderer();
}
/**
* Creates or destroys {@link shadowRenderer} to match the current render mode and forward
* renderer. The GPU-sort (hybrid) renderer cannot self-cast shadows, so when shadow rendering
* is requested and the forward renderer uses GPU sort, a dedicated {@link GSplatShadowRenderer}
* casts on its behalf (sharing this manager's world). The CPU-sort quad renderer self-casts, so
* no shadow renderer is created for it.
*
* @private
*/
_syncShadowRenderer() {
const wantShadow = !!(this.renderMode & GSPLAT_SHADOW) && this.renderer.usesGpuSort;
if (wantShadow && !this.shadowRenderer) {
this.shadowRenderer = new GSplatShadowRenderer(this.device, this.node, this.cameraNode, this.layer, this.world, this._hybridScratch);
} else if (!wantShadow && this.shadowRenderer) {
this.shadowRenderer.destroy();
this.shadowRenderer = null;
}
if (!this.renderer.usesGpuSort && this._hybridScratch) {
this._hybridScratch.destroy();
this._hybridScratch = null;
}
}
/**
* Creates the renderer and sort resources for the given mode. Used at init time.
*
* @param {number} mode - The GSPLAT_RENDERER_* constant.
* @private
*/
_createRenderer(mode) {
const workBuffer = this.world.workBuffer;
if (mode === GSPLAT_RENDERER_RASTER_GPU_SORT) {
this._hybridScratch ?? (this._hybridScratch = new GSplatHybridRendererScratch(this.device));
this.renderer = new GSplatHybridRenderer(this.device, this.node, this.cameraNode, this.layer, workBuffer, this._hybridScratch);
} else {
this.renderer = new GSplatQuadRenderer(this.device, this.node, this.cameraNode, this.layer, workBuffer);
this.initCpuSorting();
}
this.activeRenderer = mode;
}
/**
* Checks whether the resolved renderer mode has changed and transitions to the new mode
* (CPU raster quad <-> hybrid GPU sort).
*
* @private
*/
prepareRendererMode() {
const requested = this.scene.gsplat.currentRenderer;
if (requested === this.activeRenderer) return;
this.world.invalidate({ worldState: true });
this.destroyCpuSorting();
this.renderer.destroy();
this._createRenderer(requested);
this.renderer.setRenderMode(this.renderMode);
this._syncShadowRenderer();
this.world.invalidate({ workBuffer: true });
this.sortNeeded = true;
}
/**
* 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) {
this.world.reconcile(placements);
}
onSorted(count, version, orderData) {
const updateBounds = this.renderer.requiresBounds;
const result = this.world.onSorted(version, count, orderData, this.cameraNode, updateBounds, this._markResult);
if (result.rebuilt) {
this.renderer.update(result.count, result.textureSize);
}
this.renderer.setOrderData();
}
/**
* On the first sort of a world-state version, advances the render-ready version (cleanup +
* first-sort work-buffer rebuild) and applies the renderer rebuild reaction. The world version
* lifecycle is owned by the manager; the GPU pipeline (in the renderer) assumes a baked,
* render-ready work buffer. This is the synchronous GPU-sort counterpart of {@link onSorted}
* (the async CPU path). No-op once the version has been sorted before.
*
* @param {GSplatWorldState} worldState - The world state about to be sorted.
* @private
*/
_markSortedIfNeeded(worldState) {
if (!worldState.sortedBefore) {
this.world.markSorted(worldState.version, worldState.totalActiveSplats, this.cameraNode, true, this._markResult);
if (this._markResult.rebuilt) {
this.renderer.update(this._markResult.count, this._markResult.textureSize);
}
}
}
/**
* Tests if the camera has moved enough to require re-sorting.
* - For radial sorting: only position matters (rotation doesn't affect sort order)
* - For directional sorting: only forward direction matters (position doesn't affect sort order)
*
* @returns {boolean} True if camera moved enough to require re-sorting, otherwise false.
*/
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;
}
/**
* Fires the frame:ready event with current sorting and loading state.
*/
fireFrameReadyEvent() {
const ready = this.world.currentVersion === this.world.lastWorldStateVersion && !this.world.awaitingLodUpdate;
const loadingCount = this.world.pendingLoadCount;
this.director.eventHandler.fire("frame:ready", this.cameraNode.camera, this.renderer.layer, ready, loadingCount);
}
/**
* CPU streaming pass: work-buffer format sync, renderer-mode transition, and the world's LOD /
* octree streaming / world-state creation. Runs every frame from the component system's
* framerender tick (even when rendering is skipped), and once from {@link update} on the render
* path. Deduped via `token` so it runs at most once per frame. Performs no render-pass / draw
* work — only CPU/IO and GPU resource creation.
*
* @param {number} token - Per-frame token; a repeated token is a no-op (returns the cached result).
* @returns {boolean} True if new data was produced that a render would show (new world-state
* version or work-buffer recreation).
*/
updateStreaming(token) {
if (token === this._lastStreamToken) return this._streamAdvanced;
this._lastStreamToken = token;
this.world.syncFormat(this._formatResult);
if (this._formatResult.bufferRecreated) {
this.renderer.setDataSource(this.world.workBuffer);
this.shadowRenderer?.setDataSource(this.world.workBuffer);
}
if (this._formatResult.sortNeeded) this.sortNeeded = true;
this.prepareRendererMode();
const allowLodUpdate = !this.renderer.requiresCpuSort || this.cpuSorter && this.cpuSorter.jobsInFlight < 3;
this.world.update(this.cameraNode, allowLodUpdate, !!this.cpuSorter, this._updateResult);
if (this._updateResult.overdrawDirty) this.renderer.updateOverdrawMode(this.scene.gsplat);
if (this._updateResult.sortNeeded) this.sortNeeded = true;
if (this._updateResult.newVersion) this._feedCpuSorterCenters();
this.world.tickCooldowns();
this._streamAdvanced = this._updateResult.newVersion || this._formatResult.bufferRecreated;
return this._streamAdvanced;
}
update() {
this.world.resetFrameStats();
this.updateStreaming(this.director._streamToken);
if (this.cpuSorter) {
this.cpuSorter.applyPendingSorted();
}
if (this.testCameraMovedForSort()) {
this.sortNeeded = true;
}
const lastState = this.world.getState(this.world.lastWorldStateVersion);
if (lastState) {
Debug.call(() => {
if (this.scene.gsplat.debug === GSPLAT_DEBUG_AABBS) {
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);
});
}
});
if (this.cpuSorter && !lastState.sortParametersSet) {
lastState.sortParametersSet = true;
const payload = this.world.prepareSortParameters(lastState);
this.cpuSorter.setSortParameters(payload);
}
}
const updateBounds = this.renderer.requiresBounds;
this.world.bake(this.world.currentVersion, this.cameraNode, updateBounds, this._bakeResult);
if (this._bakeResult.rebuilt) {
this.renderer.update(this._bakeResult.count, this._bakeResult.textureSize);
this.renderer.setOrderData();
this.renderer.invalidateCullUpload();
}
if (this._bakeResult.sortNeeded) this.sortNeeded = true;
if (lastState) {
if (this.renderer.usesGpuSort) {
this._markSortedIfNeeded(lastState);
if (this.renderMode & GSPLAT_FORWARD) {
this.renderer.prepareRenderView(this.world, lastState, this._fillRenderViewParams());
}
} else if (this.sortNeeded) {
this.sortCpu(lastState);
}
if (this.sortNeeded) {
this.sortNeeded = false;
this.lastSortCameraPos.copy(this.cameraNode.getPosition());
this.lastSortCameraFwd.copy(this.cameraNode.forward);
}
}
this.shadowRenderer?.syncLights();
const aggregateAabb = this.world.computeAggregateAabb();
this.renderer?.meshInstance?.setCustomAabb(aggregateAabb);
this.shadowRenderer?.setCastersAabb(aggregateAabb);
this.fireFrameReadyEvent();
if (this.scene.gsplat.dirty) {
this.world.markInstancesNeedLodUpdate();
}
const fogParams = this.scene.gsplat.useFog ? this.cameraNode.camera.fogParams ?? this.scene.fog : null;
this.renderer.frameUpdate(this.scene.gsplat, this.scene.exposure, fogParams);
const sortedState = this.world.getState(this.world.currentVersion);
return sortedState ? sortedState.totalActiveSplats : 0;
}
/**
* Post-cull shadow pass. Called from the director after cullComposition has fitted each
* directional light's shadow-camera frustum, and before the frame graph renders the shadow
* maps. Dispatches the per-light gsplat shadow cull and binds the results. No-op unless this
* manager has a {@link shadowRenderer} (GPU-sort forward path with a shadow render mode).
*/
updateShadows() {
this.shadowRenderer?.cull(this.scene.gsplat);
}
/**
* Feeds the CPU sorter the centers for the splats in the latest world-state version. Called
* after the world creates a new version (the version-change detection lives here because the
* CPU sorter is manager-owned).
*
* @private
*/
_feedCpuSorterCenters() {
if (!this.cpuSorter) return;
const state = this.world.getState(this.world.lastWorldStateVersion);
if (!state) return;
const splats = state.splats;
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);
}
/**
* Sorts the splats using CPU worker (asynchronous).
*
* @param {GSplatWorldState} lastState - The last world state.
*/
sortCpu(lastState) {
Debug.assert(this.cpuSorter, "CPU sorter not initialized");
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);
}
}
export {
GSplatManager
};