playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
777 lines (776 loc) • 31.7 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 { Debug } from "../../core/debug.js";
import { SEMANTIC_POSITION, CULLFACE_NONE } from "../../platform/graphics/constants.js";
import {
BLEND_NONE,
BLEND_PREMULTIPLIED,
BLEND_ADDITIVE,
GSPLAT_FORWARD,
SHADOWCAMERA_NAME
} from "../constants.js";
import { ShaderMaterial } from "../materials/shader-material.js";
import { GSplatResourceBase } from "../gsplat/gsplat-resource-base.js";
import { MeshInstance } from "../mesh-instance.js";
import { GSplatRenderer } from "./gsplat-renderer.js";
import { GSplatProjector } from "./gsplat-projector.js";
import { GSplatIntervalCompaction } from "./gsplat-interval-compaction.js";
import { ComputeRadixSort } from "../graphics/radix-sort/compute-radix-sort.js";
import { CACHE_STRIDE } from "./gsplat-projector-constants.js";
import { ALPHA_VISIBILITY_THRESHOLD } from "./constants.js";
import { Camera } from "../camera.js";
const _invProjMat = new Mat4();
const _shaderProjMat = new Mat4();
const _camPos = new Vec3();
const _camDir = new Vec3();
const _tmpV = new Vec3();
class GSplatHybridRenderer extends GSplatRenderer {
/**
* @param {GraphicsDevice} device - The graphics device.
* @param {GraphNode} node - The graph node.
* @param {GraphNode} cameraNode - The camera node.
* @param {Layer} layer - The layer to add mesh instances to.
* @param {GSplatWorkBuffer} workBuffer - The work buffer (kept for parent compatibility;
* the hybrid renderer does not bind work-buffer textures itself).
* @param {import('./gsplat-hybrid-renderer-scratch.js').GSplatHybridRendererScratch|null} [scratch] -
* Manager-owned shared scratch; forwarded to the interval compaction (shared with the shadow cull).
*/
constructor(device, node, cameraNode, layer, workBuffer, scratch = null) {
super(device, node, cameraNode, layer, workBuffer);
/** @type {ShaderMaterial} */
__publicField(this, "_material");
/** @type {MeshInstance} */
__publicField(this, "meshInstance");
/** @type {ShaderMaterial|null} */
__publicField(this, "_pickMaterial", null);
/** @type {MeshInstance|null} */
__publicField(this, "_pickMeshInstance", null);
/**
* Per-camera `clipToViewZ` value for the forward material. Persistent: the GPU
* upload happens at draw time, so the buffer must outlive `setHybridSortedRendering`.
*
* @type {Float32Array}
*/
__publicField(this, "_clipToViewZ", new Float32Array(4));
/**
* Per-camera `clipToViewZ` value for the pick material. Allocated on first
* `prepareForPicking` call and reused thereafter.
*
* @type {Float32Array|null}
*/
__publicField(this, "_clipToViewZPick", null);
/** @type {number} */
__publicField(this, "originalBlendType", BLEND_ADDITIVE);
/** @type {Set<string>} */
__publicField(this, "_internalDefines", /* @__PURE__ */ new Set());
/** @type {boolean} */
__publicField(this, "forceCopyMaterial", true);
/** @type {string} */
__publicField(this, "_lastSourceChunksKey", "");
/**
* The projection cache stride in u32 words: the base layout plus user varying stream words.
*
* @type {number}
*/
__publicField(this, "_cacheStride", CACHE_STRIDE);
/**
* GPU radix sorter for the projected cache indices.
*
* @type {ComputeRadixSort|null}
*/
__publicField(this, "gpuSorter", null);
/**
* Compute projector that builds the per-camera projection cache + sort keys.
*
* @type {GSplatProjector|null}
*/
__publicField(this, "projector", null);
/**
* Interval-based GPU culling + compaction (lazily created on first sort).
*
* @type {GSplatIntervalCompaction|null}
*/
__publicField(this, "intervalCompaction", null);
/**
* Manager-owned shared scratch injected at construction; forwarded to the interval compaction so
* its compacted index list is shared with the directional-shadow cull. Null when not provided.
*
* @type {import('./gsplat-hybrid-renderer-scratch.js').GSplatHybridRendererScratch|null}
* @private
*/
__publicField(this, "_scratch", null);
/**
* Per-frame indirect draw slot index (-1 when unallocated).
*
* @type {number}
*/
__publicField(this, "indirectDrawSlot", -1);
/**
* Per-frame indirect dispatch slot index (projector + radix sort passes).
*
* @type {number}
*/
__publicField(this, "indirectDispatchSlot", -1);
/**
* Total intervals from the last interval-compaction dispatch (index into the prefix sum
* for the visible count).
*
* @type {number}
*/
__publicField(this, "lastCompactedNumIntervals", 0);
this._scratch = scratch;
this._material = new ShaderMaterial({
uniqueName: "UnifiedSplatHybridMaterial",
vertexWGSL: '#include "gsplatHybridVS"',
fragmentWGSL: '#include "gsplatPS"',
attributes: {
vertex_position: SEMANTIC_POSITION
}
});
this._material.setDefine("{GSPLAT_INSTANCE_SIZE}", GSplatResourceBase.instanceSize);
this._material.setDefine("{CACHE_STRIDE}", CACHE_STRIDE);
this.configureMaterial();
this._material.defines.forEach((value, key) => {
this._internalDefines.add(key);
});
this._internalDefines.add("{GSPLAT_INSTANCE_SIZE}");
this._internalDefines.add("{CACHE_STRIDE}");
this._internalDefines.add("GSPLAT_UNIFIED_ID");
this._internalDefines.add("PICK_CUSTOM_ID");
this._internalDefines.add("GSPLAT_OVERDRAW");
this._internalDefines.add("GSPLAT_NO_FOG");
this._internalDefines.add("GSPLAT_XR");
this.meshInstance = this.createMeshInstance();
}
/**
* Sets the render mode. The hybrid path does not add shadow casters; shadow cameras
* need their own projection cache and remain unsupported here.
*
* @param {number} renderMode - Bitmask flags controlling render passes.
*/
setRenderMode(renderMode) {
const oldRenderMode = this.renderMode ?? 0;
const wasForward = (oldRenderMode & GSPLAT_FORWARD) !== 0;
const isForward = (renderMode & GSPLAT_FORWARD) !== 0;
if (wasForward && !isForward) {
this.layer.removeMeshInstances([this.meshInstance], true);
}
if (!wasForward && isForward) {
this.layer.addMeshInstances([this.meshInstance], true);
}
super.setRenderMode(renderMode);
}
destroy() {
if (this.renderMode && this.renderMode & GSPLAT_FORWARD) {
this.layer.removeMeshInstances([this.meshInstance], true);
}
this.gpuSorter?.destroy();
this.gpuSorter = null;
this.projector?.destroy();
this.projector = null;
this.intervalCompaction?.destroy();
this.intervalCompaction = null;
this._material.destroy();
this._pickMaterial?.destroy();
this.meshInstance.destroy();
this._pickMeshInstance?.destroy();
super.destroy();
}
get material() {
return this._material;
}
get usesGpuSort() {
return true;
}
get requiresBounds() {
return true;
}
onWorkBufferFormatChanged() {
this.configureMaterial();
}
configureMaterial() {
this._material.setDefine("SH_BANDS", "0");
this._material.setDefine("GSPLAT_INDIRECT_DRAW", true);
this._updateIdDefines(this._material);
const dither = false;
this._material.setDefine(`DITHER_${dither ? "BLUENOISE" : "NONE"}`, "");
this._material.cull = CULLFACE_NONE;
this._material.blendType = dither ? BLEND_NONE : BLEND_PREMULTIPLIED;
this._material.depthWrite = !!dither;
this._material.update();
}
/**
* Toggles the XR stereo (GSPLAT_XR) variant of the forward material. The vertex shader then
* reads the per-eye stereo projection-cache layout and selects the eye via `view_index`. Only
* recompiles when the stereo state changes, so it is cheap to call every frame. Must stay in
* sync with the projector's stereo variant (both driven by the same isStereo value).
*
* @param {boolean} enabled - Whether stereo (2-view) rendering is active.
*/
setStereo(enabled) {
if (this._material.getDefine("GSPLAT_XR") !== enabled) {
this._material.setDefine("GSPLAT_XR", enabled);
this._material.update();
}
}
update(count, textureSize) {
if (this.meshInstance.instancingCount <= 0) {
this.meshInstance.instancingCount = 1;
}
this.meshInstance.visible = count > 0;
}
invalidateCullUpload() {
this.intervalCompaction?.invalidateUpload();
}
/**
* Lazily creates the GPU sort pipeline resources on first forward use. Kept out of the
* constructor so a hybrid renderer that never renders a forward pass (e.g. one owned by a
* shadow-only manager) allocates none of them.
*
* @private
*/
_ensureGpuPipeline() {
if (!this.gpuSorter) this.gpuSorter = new ComputeRadixSort(this.device, { indirect: true });
if (!this.projector) this.projector = new GSplatProjector(this.device);
if (!this.intervalCompaction) this.intervalCompaction = new GSplatIntervalCompaction(this.device, this._scratch);
}
/**
* Per-frame forward render preparation: derives the viewport (handling stereo XR), runs the
* cull + projector + radix sort for the current camera, and binds the result for indirect
* drawing. Runs every frame — the indirect args are per-frame and the post-projector visible
* count differs from the interval prefix sum.
*
* @param {GSplatWorld} world - The world providing the work buffer, bounds and states.
* @param {GSplatWorldState} worldState - The render-ready world state to draw.
* @param {GSplatRenderViewParams} params - Per-call params + camera (see GSplatManager#_fillRenderViewParams).
* @returns {boolean} True if a GPU dispatch ran (false when there are no active splats).
*/
prepareRenderView(world, worldState, params) {
const cameraNode = params.cameraNode;
const cam = cameraNode.camera;
const sceneCam = cam.camera;
const rt = cam.renderTarget;
const rect = cam.rect;
const xrView = sceneCam.xrActive ? sceneCam.xrViews[0] ?? null : 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 xrViewCount = sceneCam.xrActive ? sceneCam.xrViews.length : 0;
if (xrViewCount > 2) {
Debug.errorOnce(`GSplatHybridRenderer: the hybrid GPU-sort renderer supports at most 2 XR views (stereo), but the session has ${xrViewCount}. Additional views will not render correctly.`);
}
const isStereo = xrViewCount === 2;
this.setStereo(isStereo);
const sortedIndices = this.sortAndProjectForCamera(
world,
worldState,
cameraNode,
viewportWidth,
viewportHeight,
Math.max(ALPHA_VISIBILITY_THRESHOLD, params.alphaClipForward),
false,
isStereo,
params
);
if (!sortedIndices) return false;
this.setHybridSortedRendering(
this.indirectDrawSlot,
sortedIndices,
/** @type {StorageBuffer} */
this.projector.projCache,
/** @type {StorageBuffer} */
this.intervalCompaction.numSplatsBuffer
);
return true;
}
/**
* Picker render preparation: runs the cull + projector + radix sort for the picker camera
* (pick mode, mono) and returns the configured pick mesh instance.
*
* @param {GSplatWorld} world - The world providing the work buffer, bounds and states.
* @param {GSplatWorldState} worldState - The render-ready world state.
* @param {GSplatRenderViewParams} pickParams - Per-call params + picker camera (see GSplatManager#_fillPickParams).
* @returns {MeshInstance|null} The pick mesh instance, or null if nothing was dispatched.
*/
preparePickingView(world, worldState, pickParams) {
const pickMode = !!world.workBuffer.format.getStream("pcId");
const sortedIndices = this.sortAndProjectForCamera(
world,
worldState,
pickParams.cameraNode,
pickParams.width,
pickParams.height,
Math.max(ALPHA_VISIBILITY_THRESHOLD, pickParams.alphaClip),
pickMode,
false,
pickParams
);
if (!sortedIndices) return null;
return this.prepareForPicking(
this.indirectDrawSlot,
sortedIndices,
/** @type {StorageBuffer} */
this.projector.projCache,
/** @type {StorageBuffer} */
this.intervalCompaction.numSplatsBuffer,
pickParams.alphaClip,
pickParams.alphaClipForward,
pickParams.cameraNode
);
}
/**
* Runs interval cull + compaction, the projector, and the indirect radix sort for a specific
* camera/view. Shared by the forward render and the picker. Assumes the work buffer is baked and
* render-ready (the manager's version lifecycle marks it before delegating).
*
* @param {GSplatWorld} world - The world providing the work buffer, bounds and states.
* @param {GSplatWorldState} worldState - The world state to sort.
* @param {GraphNode} cameraNode - Camera node used for projection and sort keys.
* @param {number} viewportWidth - Projection viewport width in pixels.
* @param {number} viewportHeight - Projection viewport height in pixels.
* @param {number} alphaClip - Projector producer alpha threshold.
* @param {boolean} pickMode - Whether the projector writes pcId into the cache.
* @param {boolean} isStereo - Whether to project both XR eyes in one pass (forward only).
* @param {GSplatRenderViewParams} params - Per-call gsplat params.
* @returns {StorageBuffer|null} The sorted cache indices, or null if no work was dispatched.
* @private
*/
sortAndProjectForCamera(world, worldState, cameraNode, viewportWidth, viewportHeight, alphaClip, pickMode, isStereo, params) {
const elementCount = worldState.totalActiveSplats;
if (elementCount === 0) return null;
this._ensureGpuPipeline();
const gpuSorter = (
/** @type {ComputeRadixSort} */
this.gpuSorter
);
const projector = (
/** @type {GSplatProjector} */
this.projector
);
this.intervalCompaction.uploadIntervals(worldState);
if (world.hasBounds) {
const state = world.getState(world.currentVersion);
if (state) {
this._runFrustumCulling(world, state, cameraNode, params);
}
}
const fisheyeProj = this.fisheyeProj;
const numIntervals = worldState.totalIntervals;
const totalActiveSplats = worldState.totalActiveSplats;
this.intervalCompaction.dispatchCompact(world.workBuffer.frustumCuller, numIntervals, totalActiveSplats, fisheyeProj.enabled);
this.allocateAndWriteIntervalIndirectArgs(numIntervals);
const ic = (
/** @type {GSplatIntervalCompaction} */
this.intervalCompaction
);
const compactedSplatIds = ic.compactedSplatIds;
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, params.radialSorting);
const sortIndirectInfo = gpuSorter.prepareIndirect();
projector.dispatch({
workBuffer: world.workBuffer,
cameraNode,
compactedSplatIds: (
/** @type {StorageBuffer} */
compactedSplatIds
),
sortElementCountBuffer: (
/** @type {StorageBuffer} */
ic.sortElementCountBuffer
),
totalCapacity: elementCount,
radialSort: params.radialSorting,
numBits: roundedNumBits,
minDist,
maxDist,
alphaClip,
minPixelSize: params.minPixelSize * 0.5,
minContribution: params.minContribution,
foveationStrength: params.foveationStrength,
foveationCenter: params.foveationCenter,
viewportWidth,
viewportHeight,
flipY: !!cameraNode.camera.renderTarget?.flipY,
pickMode,
fisheyeProj,
antiAlias: params.antiAlias,
isStereo,
material: params.material,
userCacheWords: params.varyings.words
});
projector.writeIndirectArgs(
this.indirectDrawSlot,
this.indirectDispatchSlot + 1,
/** @type {StorageBuffer} */
ic.numSplatsBuffer,
/** @type {StorageBuffer} */
ic.sortElementCountBuffer,
sortIndirectInfo
);
if (pickMode) {
this.device.submit();
}
return gpuSorter.sortIndirect(
/** @type {StorageBuffer} */
projector.sortKeys,
elementCount,
roundedNumBits,
this.indirectDispatchSlot + 1,
/** @type {StorageBuffer} */
ic.sortElementCountBuffer,
void 0,
false,
true
// destructiveKeys: projector overwrites sortKeys each frame before the sort
);
}
/**
* Allocates per-frame indirect draw and dispatch slots and writes the interval-compaction
* indirect args.
*
* @param {number} numIntervals - Total interval count (index into prefix sum for visible count).
* @private
*/
allocateAndWriteIntervalIndirectArgs(numIntervals) {
const gpuSorter = (
/** @type {ComputeRadixSort} */
this.gpuSorter
);
const sortInfo = gpuSorter.prepareIndirect();
const sortSlotCount = sortInfo[0];
this.indirectDrawSlot = this.device.getIndirectDrawSlot(1);
this.indirectDispatchSlot = this.device.getIndirectDispatchSlot(1 + sortSlotCount);
const ic = (
/** @type {GSplatIntervalCompaction} */
this.intervalCompaction
);
ic.writeIndirectArgs(this.indirectDrawSlot, this.indirectDispatchSlot, numIntervals, sortInfo);
this.lastCompactedNumIntervals = numIntervals;
}
/**
* Prepares frustum culling data: updates the GPU transform buffers and computes frustum planes
* from the camera. The actual culling test runs inline in the interval compaction compute shader.
*
* @param {object} world - The {@link GSplatWorld} owning the frustum culler.
* @param {object} worldState - The world state whose splats provide transforms.
* @param {GraphNode} cameraNode - Camera node to cull against.
* @param {object} params - Per-call gsplat params (for fisheye).
* @private
*/
_runFrustumCulling(world, worldState, cameraNode, params) {
world.workBuffer.frustumCuller.updateTransformsData(worldState.boundsGroups);
const cam = cameraNode.camera;
const sceneCamera = cam.camera;
const xrViews = sceneCamera.xrViews;
if (xrViews?.length) {
sceneCamera.updateViewTransforms();
sceneCamera.updateXrFrustum();
world.workBuffer.frustumCuller.setFrustumPlanes(sceneCamera.frustum);
} else {
world.workBuffer.frustumCuller.computeFrustumPlanes(cam.projectionMatrix, cam.viewMatrix);
}
const fp = this.fisheyeProj;
fp.update(this.resolveFisheye(params.fisheye), cam.fov, cam.projectionMatrix);
if (fp.enabled) {
world.workBuffer.frustumCuller.setFisheyeData(
cameraNode.getPosition(),
cameraNode.forward,
fp.maxTheta
);
}
}
/**
* Computes the min/max effective distances for the current world state (radial or linear).
*
* @param {object} worldState - The world state.
* @param {GraphNode} cameraNode - Camera node to measure distances from.
* @param {boolean} radialSort - Whether radial sorting is enabled.
* @returns {{minDist: number, maxDist: number}} The distance range.
* @private
*/
computeDistanceRange(worldState, cameraNode, radialSort) {
const cameraMat = cameraNode.getWorldTransform();
cameraMat.getTranslation(_camPos);
cameraMat.getZ(_camDir).normalize();
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++) {
_tmpV.x = i & 1 ? aabbMax.x : aabbMin.x;
_tmpV.y = i & 2 ? aabbMax.y : aabbMin.y;
_tmpV.z = i & 4 ? aabbMax.z : aabbMin.z;
modelMat.transformPoint(_tmpV, _tmpV);
if (radialSort) {
const dist = _tmpV.distance(_camPos);
if (dist > maxDist) maxDist = dist;
} else {
const dist = _tmpV.sub(_camPos).dot(_camDir);
if (dist < minDist) minDist = dist;
if (dist > maxDist) maxDist = dist;
}
}
}
if (maxDist === 0 || maxDist === -Infinity) {
return { minDist: 0, maxDist: 1 };
}
return { minDist, maxDist };
}
/**
* Configures the renderer to draw from the projector's caches.
*
* @param {number} drawSlot - The indirect draw slot index.
* @param {StorageBuffer} sortedIndices - Globally-sorted indices into projCache.
* @param {StorageBuffer} projCache - Per-splat projection cache produced by the projector.
* @param {StorageBuffer} numSplatsBuffer - GPU-written visible-splat count.
*/
setHybridSortedRendering(drawSlot, sortedIndices, projCache, numSplatsBuffer) {
this.meshInstance.setIndirect(null, drawSlot, 1);
this._material.setParameter("sortedIndices", sortedIndices);
this._material.setParameter("projCache", projCache);
this._material.setParameter("numSplatsStorage", numSplatsBuffer);
this._computeClipToViewZ(this.cameraNode, this._clipToViewZ);
this._material.setParameter("clipToViewZ", this._clipToViewZ);
this.meshInstance.visible = true;
if (this.meshInstance.instancingCount <= 0) {
this.meshInstance.instancingCount = 1;
}
}
/**
* Configures and returns a transient pick mesh instance for the picker render pass.
*
* @param {number} drawSlot - The indirect draw slot index.
* @param {StorageBuffer} sortedIndices - Globally-sorted indices into projCache.
* @param {StorageBuffer} projCache - Per-splat projection cache produced by the projector.
* @param {StorageBuffer} numSplatsBuffer - GPU-written visible-splat count.
* @param {number} alphaClip - Fragment alpha threshold for picking.
* @param {number} alphaClipForward - Forward alpha floor (must match {@link GSplatRenderer#frameUpdate}).
* @param {GraphNode} cameraNode - The picker camera node, used to derive the
* `clipToViewZ` reconstruction uniform.
* @returns {MeshInstance} The pick mesh instance.
*/
prepareForPicking(drawSlot, sortedIndices, projCache, numSplatsBuffer, alphaClip, alphaClipForward, cameraNode) {
if (!this._pickMaterial) {
this._pickMaterial = new ShaderMaterial({
uniqueName: "UnifiedSplatHybridPickMaterial",
vertexWGSL: '#include "gsplatHybridVS"',
fragmentWGSL: '#include "gsplatPS"',
attributes: {
vertex_position: SEMANTIC_POSITION
}
});
this._pickMaterial.setDefine("{GSPLAT_INSTANCE_SIZE}", GSplatResourceBase.instanceSize);
this._pickMaterial.setDefine("{CACHE_STRIDE}", this._cacheStride);
this._pickMaterial.setDefine("SH_BANDS", "0");
this._pickMaterial.setDefine("GSPLAT_INDIRECT_DRAW", true);
this._pickMaterial.setDefine("DITHER_NONE", "");
this._updateIdDefines(this._pickMaterial);
this._pickMaterial.cull = CULLFACE_NONE;
this._pickMaterial.blendType = BLEND_NONE;
this._pickMaterial.depthWrite = false;
this._pickMaterial.update();
const mesh = GSplatResourceBase.createMesh(this.device);
this._pickMeshInstance = new MeshInstance(mesh, this._pickMaterial);
this._pickMeshInstance.node = this.node;
this._pickMeshInstance.setInstancing(true, true);
this._pickMeshInstance.instancingCount = 1;
} else {
if (this._updateIdDefines(this._pickMaterial)) {
this._pickMaterial.update();
}
}
const pickMaterial = (
/** @type {ShaderMaterial} */
this._pickMaterial
);
const pickMeshInstance = (
/** @type {MeshInstance} */
this._pickMeshInstance
);
pickMeshInstance.setIndirect(null, drawSlot, 1);
pickMaterial.setParameter("sortedIndices", sortedIndices);
pickMaterial.setParameter("projCache", projCache);
pickMaterial.setParameter("numSplatsStorage", numSplatsBuffer);
pickMaterial.setParameter("alphaClip", alphaClip);
pickMaterial.setParameter("alphaClipForward", alphaClipForward);
this._clipToViewZPick ?? (this._clipToViewZPick = new Float32Array(4));
this._computeClipToViewZ(cameraNode, this._clipToViewZPick);
pickMaterial.setParameter("clipToViewZ", this._clipToViewZPick);
return pickMeshInstance;
}
/**
* Computes the per-camera `clipToViewZ` value into `dst`. The hybrid VS dot-products
* this with the cached `clipPos` to recover linear view depth, used by fog / overdraw
* / prepass.
*
* - Perspective + orthographic: `dst = -inverse(matrix_projection)[row 2]`. The
* projector stores `clipPos.w` in slot [3] and the dot-product yields `-view.z`.
* - Fisheye: `dst = (0, 0, far - near, near)`. The projector stores depthNdc in
* slot [2] and `1.0` in slot [3], so the dot-product reduces to
* `depthNdc * (far - near) + near`, which equals linear `-view.z`.
*
* The destination buffer must be retained by the caller (typically a per-material
* instance field) because the GPU upload happens at draw time.
*
* @param {GraphNode} cameraNode - Camera node to derive the uniform from.
* @param {Float32Array} dst - 4-element destination, written in place.
* @private
*/
_computeClipToViewZ(cameraNode, dst) {
const camComp = cameraNode.camera;
const cam = camComp.camera;
if (this.fisheyeProj.enabled) {
const near = cam.nearClip;
const far = cam.farClip;
dst[0] = 0;
dst[1] = 0;
dst[2] = far - near;
dst[3] = near;
return;
}
const flipY = !!camComp.renderTarget?.flipY;
_invProjMat.copy(Camera.applyShaderProjectionTransform(cam.projectionMatrix, _shaderProjMat, flipY, this.device.isWebGPU)).invert();
const d = _invProjMat.data;
dst[0] = -d[2];
dst[1] = -d[6];
dst[2] = -d[10];
dst[3] = -d[14];
}
/**
* The hybrid path is GPU-driven only; CPU-sort is handled by the quad renderer
* (the manager swaps renderers when the active mode changes).
*/
setCpuSortedRendering() {
this.meshInstance.setIndirect(null, -1);
this.meshInstance.visible = false;
}
setOrderData() {
}
frameUpdate(params) {
this._material.setParameter("alphaClip", params.alphaClip);
this._material.setParameter("alphaClipForward", params.alphaClipForward);
this._pickMaterial?.setParameter("alphaClip", params.alphaClip);
this._pickMaterial?.setParameter("alphaClipForward", params.alphaClipForward);
if (params.colorRamp) {
this._material.setParameter("colorRampIntensity", params.colorRampIntensity);
}
const noFog = !params.useFog;
if (noFog !== this._lastNoFog) {
this._lastNoFog = noFog;
this._material.setDefine("GSPLAT_NO_FOG", noFog);
this._material.update();
}
const cacheStride = CACHE_STRIDE + params.varyings.words;
if (cacheStride !== this._cacheStride) {
this._cacheStride = cacheStride;
this._material.setDefine("{CACHE_STRIDE}", cacheStride);
this._material.update();
if (this._pickMaterial) {
this._pickMaterial.setDefine("{CACHE_STRIDE}", cacheStride);
this._pickMaterial.update();
}
}
if (this.forceCopyMaterial || params.material.dirty) {
this.copyMaterialSettings(params.material);
this.forceCopyMaterial = false;
}
}
/**
* Copies material settings from a source material to the internal material.
* Preserves internal defines while copying user defines, parameters, and shader chunks.
* This delivers user customizations (e.g. the `gsplatModifyPS` fragment chunk and its
* parameters) set on `app.scene.gsplat.material` to the hybrid render material. Note that
* the `gsplatModifyVS` chunk is handled by the projector compute instead, and even when
* copied here it is not referenced by the hybrid vertex shader.
*
* @param {ShaderMaterial} sourceMaterial - The source material to copy settings from.
* @private
*/
copyMaterialSettings(sourceMaterial) {
const keysToDelete = [];
this._material.defines.forEach((value, key) => {
if (!this._internalDefines.has(key) && !sourceMaterial.defines.has(key)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key) => this._material.setDefine(key, void 0));
sourceMaterial.defines.forEach((value, key) => {
this._material.setDefine(key, value);
});
const srcParams = sourceMaterial.parameters;
for (const paramName in srcParams) {
if (srcParams.hasOwnProperty(paramName)) {
this._material.setParameter(paramName, srcParams[paramName].data);
}
}
if (sourceMaterial.hasShaderChunks) {
const sourceChunksKey = sourceMaterial.shaderChunks.key;
if (sourceChunksKey !== this._lastSourceChunksKey) {
this._material.shaderChunks.copy(sourceMaterial.shaderChunks);
this._lastSourceChunksKey = sourceChunksKey;
}
}
this._material.update();
}
/**
* Updates pick ID defines to match the work-buffer format.
*
* @param {ShaderMaterial} material - Material to update.
* @returns {boolean} True if the material defines changed.
* @private
*/
_updateIdDefines(material) {
const hasPcId = !!this.workBuffer.format.getStream("pcId");
const changed = material.getDefine("GSPLAT_UNIFIED_ID") !== hasPcId || material.getDefine("PICK_CUSTOM_ID") !== hasPcId;
material.setDefine("GSPLAT_UNIFIED_ID", hasPcId);
material.setDefine("PICK_CUSTOM_ID", hasPcId);
return changed;
}
updateOverdrawMode(params) {
const overdrawEnabled = !!params.colorRamp;
const wasOverdrawEnabled = this._material.getDefine("GSPLAT_OVERDRAW");
if (overdrawEnabled) {
this._material.setParameter("colorRamp", params.colorRamp);
this._material.setParameter("colorRampIntensity", params.colorRampIntensity);
}
if (overdrawEnabled !== wasOverdrawEnabled) {
this._material.setDefine("GSPLAT_OVERDRAW", overdrawEnabled);
if (overdrawEnabled) {
this.originalBlendType = this._material.blendType;
this._material.blendType = BLEND_ADDITIVE;
} else {
this._material.blendType = this.originalBlendType;
}
this._material.update();
}
}
createMeshInstance() {
const mesh = GSplatResourceBase.createMesh(this.device);
const meshInstance = new MeshInstance(mesh, this._material);
meshInstance.node = this.node;
meshInstance.setInstancing(true, true);
meshInstance.instancingCount = 0;
meshInstance.pick = false;
const thisCamera = this.cameraNode.camera;
meshInstance.isVisibleFunc = (camera) => {
const renderMode = this.renderMode ?? 0;
if (thisCamera.camera === camera && renderMode & GSPLAT_FORWARD) {
return true;
}
if (camera.node?.name === SHADOWCAMERA_NAME) {
return false;
}
return false;
};
return meshInstance;
}
}
export {
GSplatHybridRenderer
};