playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
365 lines (362 loc) • 16.5 kB
JavaScript
import { PIXELFORMAT_RGBA16U, CULLFACE_NONE, SEMANTIC_POSITION } from '../../platform/graphics/constants.js';
import { GSPLAT_FORWARD, GSPLAT_SHADOW, BLEND_PREMULTIPLIED, BLEND_ADDITIVE, 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 { StorageBuffer } from '../../platform/graphics/storage-buffer.js'
* @import { GraphNode } from '../graph-node.js'
* @import { GraphicsDevice } from '../../platform/graphics/graphics-device.js'
* @import { Layer } from '../layer.js'
* @import { GSplatWorkBuffer } from './gsplat-work-buffer.js'
*/ /**
* Renders splats from the work buffer using instanced quad rendering.
*
* @ignore
*/ class GSplatQuadRenderer extends GSplatRenderer {
/**
* Sets the render mode for this renderer, managing layer array membership.
*
* @param {number} renderMode - Bitmask flags controlling render passes (GSPLAT_FORWARD, GSPLAT_SHADOW, or both).
*/ setRenderMode(renderMode) {
const oldRenderMode = this.renderMode ?? 0;
// Calculate what changed
const wasForward = (oldRenderMode & GSPLAT_FORWARD) !== 0;
const wasShadow = (oldRenderMode & GSPLAT_SHADOW) !== 0;
const isForward = (renderMode & GSPLAT_FORWARD) !== 0;
const isShadow = (renderMode & GSPLAT_SHADOW) !== 0;
// Update mesh instance castShadow state FIRST, before adding to arrays
this.meshInstance.castShadow = isShadow;
// Remove from old arrays if needed
if (wasForward && !isForward) {
this.layer.removeMeshInstances([
this.meshInstance
], true);
}
if (wasShadow && !isShadow) {
this.layer.removeShadowCasters([
this.meshInstance
]);
}
// Add to new arrays if needed
if (!wasForward && isForward) {
this.layer.addMeshInstances([
this.meshInstance
], true);
}
if (!wasShadow && isShadow) {
this.layer.addShadowCasters([
this.meshInstance
]);
}
super.setRenderMode(renderMode);
}
destroy() {
// Remove mesh instance from appropriate layer arrays based on render mode
if (this.renderMode) {
if (this.renderMode & GSPLAT_FORWARD) {
this.layer.removeMeshInstances([
this.meshInstance
], true);
}
if (this.renderMode & GSPLAT_SHADOW) {
this.layer.removeShadowCasters([
this.meshInstance
]);
}
}
this._material.destroy();
this.meshInstance.destroy();
super.destroy();
}
get material() {
return this._material;
}
onWorkBufferFormatChanged() {
this.configureMaterial();
}
configureMaterial() {
const { workBuffer } = this;
// Inject format's shader chunks (uses workBuffer.format)
this._injectFormatChunks();
// Set defines
this._material.setDefine('SH_BANDS', '0');
this._material.setDefine('GSPLAT_SEPARATE_OPACITY', '');
// Set GSPLAT_COLOR_FLOAT define based on work buffer's color format
const colorStream = workBuffer.format.getStream('dataColor');
if (colorStream && colorStream.format !== PIXELFORMAT_RGBA16U) {
this._material.setDefine('GSPLAT_COLOR_FLOAT', '');
}
// Enable unified ID defines when pcId stream exists
this._updateIdDefines();
// Bind work buffer textures from the texture map
this._bindWorkBufferTextures();
this._material.setParameter('numSplats', 0);
this.setOrderData();
this._material.setDefine(`DITHER_${'NONE'}`, '');
this._material.cull = CULLFACE_NONE;
this._material.blendType = BLEND_PREMULTIPLIED;
this._material.depthWrite = false;
this._material.update();
}
/**
* Binds work buffer textures to the material.
*
* @private
*/ _bindWorkBufferTextures() {
const { workBuffer } = this;
for (const stream of workBuffer.format.resourceStreams){
const texture = workBuffer.getTexture(stream.name);
if (texture) {
this._material.setParameter(stream.name, texture);
}
}
}
/**
* Injects format shader chunks into the material.
* Called during initialization and after copying settings from user material.
*
* @private
*/ _injectFormatChunks() {
const chunks = this.device.isWebGPU ? this._material.shaderChunks.wgsl : this._material.shaderChunks.glsl;
const wbFormat = this.workBuffer.format;
// Use work buffer format for declarations and read code
// getInputDeclarations() returns all streams (base + extra)
chunks.set('gsplatDeclarationsVS', wbFormat.getInputDeclarations());
chunks.set('gsplatReadVS', wbFormat.getReadCode());
}
update(count, textureSize) {
// limit splat render count to exclude those behind the camera
this.meshInstance.instancingCount = Math.ceil(count / GSplatResourceBase.instanceSize);
// update splat count on the material
this._material.setParameter('numSplats', count);
this._material.setParameter('splatTextureSize', textureSize);
// disable rendering if no splats to render
this.meshInstance.visible = count > 0;
}
/**
* Configures the renderer to use GPU-sorted data for rendering.
*
* @param {number} drawSlot - The indirect draw slot index in the device's buffer.
* @param {StorageBuffer} sortedIds - Buffer containing sorted visible splat IDs.
* @param {StorageBuffer} numSplatsBuffer - Buffer containing numSplats for vertex shader.
* @param {number} textureSize - The work buffer texture size.
*/ setGpuSortedRendering(drawSlot, sortedIds, numSplatsBuffer, textureSize) {
this.meshInstance.setIndirect(null, drawSlot, 1);
// Bind compaction buffers for vertex shader
this._material.setParameter('compactedSplatIds', sortedIds);
this._material.setParameter('numSplatsStorage', numSplatsBuffer);
// Set GSPLAT_INDIRECT_DRAW define if not already set
if (!this._material.getDefine('GSPLAT_INDIRECT_DRAW')) {
this._material.setDefine('GSPLAT_INDIRECT_DRAW', true);
this._material.update();
}
this._material.setParameter('splatTextureSize', textureSize);
this.meshInstance.visible = true;
// Ensure instancingCount is non-zero so the forward/shadow renderers don't
// skip this draw call. The actual instance count is GPU-driven via indirect args.
if (this.meshInstance.instancingCount <= 0) {
this.meshInstance.instancingCount = 1;
}
}
/**
* Switches the renderer to CPU-sorted rendering mode.
*/ setCpuSortedRendering() {
this.meshInstance.setIndirect(null, -1);
if (this._material.getDefine('GSPLAT_INDIRECT_DRAW')) {
this._material.setDefine('GSPLAT_INDIRECT_DRAW', false);
this._material.update();
}
// Restore order data from work buffer (CPU upload path)
this.setOrderData();
// Hide until update() is called with valid CPU sort data
this.meshInstance.visible = false;
}
setOrderData() {
// Set the appropriate order data resource based on device type
if (this.device.isWebGPU) {
this._material.setParameter('splatOrder', this.workBuffer.orderBuffer);
} else {
this._material.setParameter('splatOrder', this.workBuffer.orderTexture);
}
}
frameUpdate(params) {
// Update colorRampIntensity parameter every frame when overdraw is enabled
if (params.colorRamp) {
this._material.setParameter('colorRampIntensity', params.colorRampIntensity);
}
// Update fisheye projection
const cam = this.cameraNode.camera;
this.fisheyeProj.update(params.fisheye, cam.fov, cam.projectionMatrix);
const fisheyeEnabled = this.fisheyeProj.enabled;
if (fisheyeEnabled !== this._lastFisheyeEnabled) {
this._lastFisheyeEnabled = fisheyeEnabled;
this._material.setDefine('GSPLAT_FISHEYE', fisheyeEnabled);
this._material.update();
}
if (fisheyeEnabled) {
const fp = this.fisheyeProj;
this._material.setParameter('fisheye_k', fp.k);
this._material.setParameter('fisheye_inv_k', fp.invK);
this._material.setParameter('fisheye_projMat00', fp.projMat00);
this._material.setParameter('fisheye_projMat11', fp.projMat11);
}
const noFog = !params.useFog;
if (noFog !== this._lastNoFog) {
this._lastNoFog = noFog;
this._material.setDefine('GSPLAT_NO_FOG', noFog);
this._material.update();
}
// Check if work buffer format has changed (extra streams added)
this._syncWithWorkBufferFormat();
// Copy material settings from params.material if dirty or on first update
if (this.forceCopyMaterial || params.material.dirty) {
this.copyMaterialSettings(params.material);
this.forceCopyMaterial = false;
}
}
/**
* Updates the ID-related defines based on whether pcId stream exists.
*
* @private
*/ _updateIdDefines() {
// GSPLAT_UNIFIED_ID enables reading component ID from work buffer
// PICK_CUSTOM_ID prevents pick.js from declaring meshInstanceId uniform
const hasPcId = !!this.workBuffer.format.getStream('pcId');
this._material.setDefine('GSPLAT_UNIFIED_ID', hasPcId);
this._material.setDefine('PICK_CUSTOM_ID', hasPcId);
}
/**
* Syncs with work buffer format when extra streams are added.
*
* @private
*/ _syncWithWorkBufferFormat() {
const wbFormat = this.workBuffer.format;
if (this._workBufferFormatVersion !== wbFormat.extraStreamsVersion) {
this._workBufferFormatVersion = wbFormat.extraStreamsVersion;
// Sync work buffer textures with format
this.workBuffer.syncWithFormat();
// Re-inject format chunks with extra stream declarations
this._injectFormatChunks();
// Bind any new textures from the work buffer
this._bindWorkBufferTextures();
// Enable unified ID defines when pcId stream exists
this._updateIdDefines();
this._material.update();
}
}
/**
* Copies material settings from a source material to the internal material.
* Preserves internal defines while copying user defines, parameters, and shader chunks.
*
* @param {ShaderMaterial} sourceMaterial - The source material to copy settings from.
* @private
*/ copyMaterialSettings(sourceMaterial) {
// Clear user defines (preserve internal defines)
const keysToDelete = [];
this._material.defines.forEach((value, key)=>{
if (!this._internalDefines.has(key)) {
keysToDelete.push(key);
}
});
keysToDelete.forEach((key)=>this._material.defines.delete(key));
// Copy defines from source material
sourceMaterial.defines.forEach((value, key)=>{
this._material.defines.set(key, value);
});
// Copy parameters
const srcParams = sourceMaterial.parameters;
for(const paramName in srcParams){
if (srcParams.hasOwnProperty(paramName)) {
this._material.setParameter(paramName, srcParams[paramName].data);
}
}
// Copy shader chunks if they exist
if (sourceMaterial.hasShaderChunks) {
this._material.shaderChunks.copy(sourceMaterial.shaderChunks);
}
// Re-inject format chunks that may have been overwritten by copy
this._injectFormatChunks();
this._material.update();
}
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) {
// TODO: when overdraw mode is enabled, we could disable sorting of splats,
// as additive blend mode does not require them to be sorted
// Store the current blend type before switching to additive
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);
// only start rendering the splat after we've received the splat order data
meshInstance.instancingCount = 0;
// custom culling to only disable rendering for matching camera
// TODO: consider using aabb as well to avoid rendering off-screen splats
const thisCamera = this.cameraNode.camera;
meshInstance.isVisibleFunc = (camera)=>{
const renderMode = this.renderMode ?? 0;
// visible for main camera in forward rendering mode
if (thisCamera.camera === camera && renderMode & GSPLAT_FORWARD) {
return true;
}
// visible for shadow cameras in shadow rendering mode
if (renderMode & GSPLAT_SHADOW) {
return camera.node?.name === SHADOWCAMERA_NAME;
}
return false;
};
return meshInstance;
}
/**
* @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 containing splat data.
*/ constructor(device, node, cameraNode, layer, workBuffer){
super(device, node, cameraNode, layer, workBuffer), /** @type {number} */ this.originalBlendType = BLEND_ADDITIVE, /** @type {Set<string>} */ this._internalDefines = new Set(), /** @type {boolean} */ this.forceCopyMaterial = true, /** @private */ this._lastFisheyeEnabled = false;
this._material = new ShaderMaterial({
uniqueName: 'UnifiedSplatMaterial',
vertexGLSL: '#include "gsplatVS"',
fragmentGLSL: '#include "gsplatPS"',
vertexWGSL: '#include "gsplatVS"',
fragmentWGSL: '#include "gsplatPS"',
attributes: {
vertex_position: SEMANTIC_POSITION
}
});
this._material.setDefine('{GSPLAT_INSTANCE_SIZE}', GSplatResourceBase.instanceSize);
this.configureMaterial();
// Capture internal define names to protect them from being cleared
this._material.defines.forEach((value, key)=>{
this._internalDefines.add(key);
});
// Also protect defines that may be added dynamically
this._internalDefines.add('{GSPLAT_INSTANCE_SIZE}');
this._internalDefines.add('GSPLAT_UNIFIED_ID');
this._internalDefines.add('PICK_CUSTOM_ID');
this._internalDefines.add('GSPLAT_INDIRECT_DRAW');
this._internalDefines.add('GSPLAT_SEPARATE_OPACITY');
this._internalDefines.add('GSPLAT_FISHEYE');
this.meshInstance = this.createMeshInstance();
}
}
export { GSplatQuadRenderer };