playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
426 lines (425 loc) • 16.8 kB
JavaScript
import { Vec2 } from "../../core/math/vec2.js";
import { Compute } from "../../platform/graphics/compute.js";
import { Shader } from "../../platform/graphics/shader.js";
import { StorageBuffer } from "../../platform/graphics/storage-buffer.js";
import { BindGroupFormat, BindStorageBufferFormat, BindUniformBufferFormat } from "../../platform/graphics/bind-group-format.js";
import { UniformBufferFormat, UniformFormat } from "../../platform/graphics/uniform-buffer-format.js";
import {
BUFFERUSAGE_COPY_DST,
CULLFACE_NONE,
PIXELFORMAT_RGBA16U,
SEMANTIC_POSITION,
SHADERLANGUAGE_WGSL,
SHADERSTAGE_COMPUTE,
UNIFORMTYPE_FLOAT,
UNIFORMTYPE_UINT,
UNIFORMTYPE_VEC4
} from "../../platform/graphics/constants.js";
import { BLEND_PREMULTIPLIED, LIGHTTYPE_DIRECTIONAL } from "../constants.js";
import { ShaderMaterial } from "../materials/shader-material.js";
import { MeshInstance } from "../mesh-instance.js";
import { GSplatResourceBase } from "../gsplat/gsplat-resource-base.js";
import { computeGsplatShadowCullSource } from "../shader-lib/wgsl/chunks/gsplat/compute-gsplat-shadow-cull.js";
import { computeGsplatShadowIndirectArgsSource } from "../shader-lib/wgsl/chunks/gsplat/compute-gsplat-shadow-indirect-args.js";
import computeSplatSource from "../shader-lib/wgsl/chunks/gsplat/vert/gsplatComputeSplat.js";
import gsplatModifyDefaultSource from "../shader-lib/wgsl/chunks/gsplat/vert/gsplatModify.js";
import gsplatHelpersSource from "../shader-lib/wgsl/chunks/gsplat/vert/gsplatHelpers.js";
import { GSplatIntervalCompaction } from "./gsplat-interval-compaction.js";
const WORKGROUP_SIZE = 256;
const INDEX_COUNT = 6 * GSplatResourceBase.instanceSize;
class GSplatShadowRenderer {
device;
node;
cameraNode;
layer;
world;
entries = /* @__PURE__ */ new Map();
_desiredLights = /* @__PURE__ */ new Set();
_compaction = null;
_cullDispatchSize = new Vec2(1, 1);
_frustumPlanes = new Float32Array(24);
_userChunksKey = "";
_userModifyWgsl = null;
// The cull/args shaders are shared, but each light entry gets its OWN Compute instances
// (created in _createEntry). A Compute owns a persistent uniform buffer, so a single shared
// Compute dispatched once per light per frame would have all dispatches read the last-written
// uniforms (frustum planes / draw slot) — making all but one light's shadow draw empty.
_cullShader = null;
_cullBindGroupFormat = null;
_cullFormatVersion = -1;
_cullBuiltChunksKey = null;
_cullShaderGen = 0;
_argsShader = null;
_argsBindGroupFormat = null;
constructor(device, node, cameraNode, layer, world, scratch = null) {
this.device = device;
this.node = node;
this.cameraNode = cameraNode;
this.layer = layer;
this.world = world;
this._compaction = new GSplatIntervalCompaction(device, scratch);
this._createArgsShader();
}
destroy() {
this.entries.forEach((entry) => this._destroyEntry(entry));
this.entries.clear();
this._compaction?.destroy();
this._compaction = null;
this._cullShader?.destroy();
this._cullBindGroupFormat?.destroy();
this._argsShader?.destroy();
this._argsBindGroupFormat?.destroy();
this._cullShader = null;
this._argsShader = null;
}
_ensureCullShader() {
const wbFormat = this.world.workBuffer.format;
const version = wbFormat.extraStreamsVersion;
if (!this._cullShader || version !== this._cullFormatVersion || this._userChunksKey !== this._cullBuiltChunksKey) {
this._cullFormatVersion = version;
this._cullBuiltChunksKey = this._userChunksKey;
this._buildCullShader();
}
}
_buildCullShader() {
const device = this.device;
const wbFormat = this.world.workBuffer.format;
const fixedBindings = [
new BindUniformBufferFormat("uniforms", SHADERSTAGE_COMPUTE),
// pass-1 outputs (read): the shared candidate list + its count at [numIntervals]
new BindStorageBufferFormat("compactedSplatIds", SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat("candidateCountBuffer", SHADERSTAGE_COMPUTE, true),
// per-light outputs (read/write): the final visible list + atomic count
new BindStorageBufferFormat("outputIndices", SHADERSTAGE_COMPUTE, false),
new BindStorageBufferFormat("globalCount", SHADERSTAGE_COMPUTE, false)
];
this._cullBindGroupFormat?.destroy();
this._cullBindGroupFormat = new BindGroupFormat(device, [
...fixedBindings,
...wbFormat.getComputeBindFormats()
]);
const uniformBufferFormat = new UniformBufferFormat(device, [
new UniformFormat("frustumPlanes", UNIFORMTYPE_VEC4, 6),
new UniformFormat("numIntervals", UNIFORMTYPE_UINT),
new UniformFormat("splatTextureSize", UNIFORMTYPE_UINT),
new UniformFormat("alphaClip", UNIFORMTYPE_FLOAT),
new UniformFormat("worldSizeThreshold", UNIFORMTYPE_FLOAT)
]);
const cincludes = /* @__PURE__ */ new Map();
cincludes.set("gsplatComputeSplatCS", computeSplatSource);
cincludes.set("gsplatFormatDeclCS", wbFormat.getComputeInputDeclarations(fixedBindings.length));
cincludes.set("gsplatFormatReadCS", wbFormat.getReadCode());
cincludes.set("gsplatHelpersVS", gsplatHelpersSource);
cincludes.set("gsplatModifyVS", this._userModifyWgsl ?? gsplatModifyDefaultSource);
const cdefines = /* @__PURE__ */ new Map([["{WORKGROUP_SIZE}", WORKGROUP_SIZE.toString()]]);
const colorStream = wbFormat.getStream("dataColor");
if (colorStream && colorStream.format !== PIXELFORMAT_RGBA16U) {
cdefines.set("GSPLAT_COLOR_FLOAT", "");
}
this._cullShader?.destroy();
this._cullShader = new Shader(device, {
name: "GSplatShadowCull",
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatShadowCullSource,
cincludes,
cdefines,
computeBindGroupFormat: this._cullBindGroupFormat,
computeUniformBufferFormats: { uniforms: uniformBufferFormat }
});
this._cullShaderGen++;
}
_createArgsShader() {
const device = this.device;
this._argsBindGroupFormat = new BindGroupFormat(device, [
new BindStorageBufferFormat("countBuffer", SHADERSTAGE_COMPUTE, true),
new BindStorageBufferFormat("indirectDrawArgs", SHADERSTAGE_COMPUTE, false),
new BindUniformBufferFormat("uniforms", SHADERSTAGE_COMPUTE)
]);
const uniformBufferFormat = new UniformBufferFormat(device, [
new UniformFormat("drawSlot", UNIFORMTYPE_UINT),
new UniformFormat("indexCount", UNIFORMTYPE_UINT),
new UniformFormat("pad0", UNIFORMTYPE_UINT),
new UniformFormat("pad1", UNIFORMTYPE_UINT)
]);
this._argsShader = new Shader(device, {
name: "GSplatShadowIndirectArgs",
shaderLanguage: SHADERLANGUAGE_WGSL,
cshader: computeGsplatShadowIndirectArgsSource,
cdefines: /* @__PURE__ */ new Map([["{INSTANCE_SIZE}", GSplatResourceBase.instanceSize.toString()]]),
computeBindGroupFormat: this._argsBindGroupFormat,
computeUniformBufferFormats: { uniforms: uniformBufferFormat }
});
}
setDataSource(workBuffer) {
this._compaction?.invalidateUpload();
this._cullFormatVersion = -1;
this.entries.forEach((entry) => {
this._configureMaterialWorkBuffer(entry.material);
entry.material.update();
});
}
setCastersAabb(aabb) {
if (!aabb) return;
this.entries.forEach((entry) => {
entry.meshInstance.setCustomAabb(aabb);
});
}
syncLights() {
const lights = this.layer.splitLights[LIGHTTYPE_DIRECTIONAL];
const desired = this._desiredLights;
desired.clear();
for (let i = 0; i < lights.length; i++) {
const light = lights[i];
if (!light.enabled || !light.castShadows) continue;
if (light.numCascades !== 1) {
continue;
}
desired.add(light);
}
this.entries.forEach((entry, light) => {
if (!desired.has(light)) {
this._destroyEntry(entry);
this.entries.delete(light);
}
});
desired.forEach((light) => {
if (!this.entries.has(light)) {
this.entries.set(light, this._createEntry(light));
}
});
}
cull(gsplatParams) {
const worldState = this.world.getState(this.world.currentVersion);
const ready = worldState && worldState.sortedBefore && worldState.totalActiveSplats > 0;
if (!ready) {
this.entries.forEach((entry) => {
entry.meshInstance.visible = false;
});
return;
}
this._compaction.uploadIntervals(worldState);
this.world.workBuffer.frustumCuller.updateTransformsData(worldState.boundsGroups);
this._syncUserModify(gsplatParams);
this._ensureCullShader();
const numIntervals = worldState.totalIntervals;
const totalActiveSplats = worldState.totalActiveSplats;
const textureSize = this.world.workBuffer.textureSize;
this.entries.forEach((entry) => {
this._cullEntry(entry, numIntervals, totalActiveSplats, textureSize, gsplatParams);
});
}
_syncUserModify(gsplatParams) {
const userMat = gsplatParams.material;
if (!userMat) return;
const chunksKey = userMat.shaderChunks?.key ?? "";
if (chunksKey !== this._userChunksKey) {
this._userChunksKey = chunksKey;
this._userModifyWgsl = userMat.getShaderChunks?.("wgsl")?.get("gsplatModifyVS") ?? null;
this.entries.forEach((entry) => this._applyUserModify(entry));
}
const params = userMat.parameters;
this.entries.forEach((entry) => {
for (const name in params) {
if (params.hasOwnProperty(name)) {
entry.material.setParameter(name, params[name].data);
}
}
});
}
_applyUserModify(entry) {
const wgsl = entry.material.shaderChunks.wgsl;
if (this._userModifyWgsl) {
wgsl.set("gsplatModifyVS", this._userModifyWgsl);
} else {
wgsl.delete("gsplatModifyVS");
}
entry.material.update();
}
_cullEntry(entry, numIntervals, totalActiveSplats, textureSize, gsplatParams) {
const device = this.device;
const sceneCamera = this.cameraNode.camera?.camera;
const shadowCamera = sceneCamera && entry.light.getRenderData(sceneCamera, 0).shadowCamera;
const frustum = shadowCamera && shadowCamera.frustum;
const frustumCuller = this.world.workBuffer.frustumCuller;
if (!frustum || !frustumCuller?.boundsBuffer || !frustumCuller?.transformsBuffer) {
entry.meshInstance.visible = false;
return;
}
this._fillFrustumPlanes(frustum);
const orthoHeight = shadowCamera.orthoHeight;
const shadowRes = entry.light._shadowResolution;
const minPixelSize = gsplatParams.minPixelSize;
const focal = orthoHeight > 0 && shadowRes > 0 ? shadowRes / orthoHeight : 0;
const t2 = minPixelSize * minPixelSize * 0.5 - 0.3;
const worldSizeThreshold = focal > 0 && t2 > 0 ? Math.sqrt(t2) / focal : 0;
if (totalActiveSplats > entry.allocatedIndexCount) {
entry.indexBuffer?.destroy();
entry.allocatedIndexCount = totalActiveSplats;
entry.indexBuffer = new StorageBuffer(device, totalActiveSplats * 4);
}
const compaction = this._compaction;
compaction.dispatchCompact({
boundsBuffer: frustumCuller.boundsBuffer,
transformsBuffer: frustumCuller.transformsBuffer,
frustumPlanes: this._frustumPlanes
}, numIntervals, totalActiveSplats, false);
entry.countBuffer.clear();
if (!entry.cullCompute || entry.cullComputeGen !== this._cullShaderGen) {
entry.cullCompute?.destroy();
entry.cullCompute = new Compute(device, this._cullShader, "GSplatShadowCull");
entry.cullComputeGen = this._cullShaderGen;
}
const cull = entry.cullCompute;
const userMat = gsplatParams.material;
if (userMat) {
const srcParams = userMat.parameters;
for (const name in srcParams) {
if (srcParams.hasOwnProperty(name)) {
cull.setParameter(name, srcParams[name].data);
}
}
}
cull.setParameter("compactedSplatIds", compaction.compactedSplatIds);
cull.setParameter("candidateCountBuffer", compaction.countBuffer);
cull.setParameter("outputIndices", entry.indexBuffer);
cull.setParameter("globalCount", entry.countBuffer);
cull.setParameter("frustumPlanes[0]", this._frustumPlanes);
cull.setParameter("numIntervals", numIntervals);
cull.setParameter("splatTextureSize", textureSize);
cull.setParameter("alphaClip", gsplatParams.alphaClip);
cull.setParameter("worldSizeThreshold", worldSizeThreshold);
const workBuffer = this.world.workBuffer;
for (const stream of workBuffer.format.resourceStreams) {
const texture = workBuffer.getTexture(stream.name);
if (texture) {
cull.setParameter(stream.name, texture);
}
}
const workgroupCount = Math.ceil(totalActiveSplats / WORKGROUP_SIZE);
Compute.calcDispatchSize(workgroupCount, this._cullDispatchSize, device.limits.maxComputeWorkgroupsPerDimension || 65535);
cull.setupDispatch(this._cullDispatchSize.x, this._cullDispatchSize.y, 1);
device.computeDispatch([cull], "GSplatShadowCull");
const drawSlot = device.getIndirectDrawSlot(1);
const args = entry.argsCompute;
args.setParameter("countBuffer", entry.countBuffer);
args.setParameter("indirectDrawArgs", device.indirectDrawBuffer);
args.setParameter("drawSlot", drawSlot);
args.setParameter("indexCount", INDEX_COUNT);
args.setParameter("pad0", 0);
args.setParameter("pad1", 0);
args.setupDispatch(1);
device.computeDispatch([args], "GSplatShadowIndirectArgs");
const material = entry.material;
entry.meshInstance.setIndirect(null, drawSlot, 1);
material.setParameter("compactedSplatIds", entry.indexBuffer);
material.setParameter("numSplatsStorage", entry.countBuffer);
material.setParameter("splatTextureSize", textureSize);
material.setParameter("alphaClip", gsplatParams.alphaClip);
entry.meshInstance.visible = true;
if (entry.meshInstance.instancingCount <= 0) {
entry.meshInstance.instancingCount = 1;
}
}
_fillFrustumPlanes(frustum) {
const p = this._frustumPlanes;
for (let i = 0; i < 6; i++) {
const plane = frustum.planes[i];
p[i * 4 + 0] = plane.normal.x;
p[i * 4 + 1] = plane.normal.y;
p[i * 4 + 2] = plane.normal.z;
p[i * 4 + 3] = plane.distance;
}
}
_createEntry(light) {
const device = this.device;
const material = this._createMaterial();
const meshInstance = this._createMeshInstance(light, material);
meshInstance.castShadow = true;
this.layer.addShadowCasters([meshInstance]);
const countBuffer = new StorageBuffer(device, 4, BUFFERUSAGE_COPY_DST);
const argsCompute = new Compute(device, this._argsShader, "GSplatShadowIndirectArgs");
const entry = {
light,
material,
meshInstance,
indexBuffer: null,
allocatedIndexCount: 0,
countBuffer,
cullCompute: null,
cullComputeGen: -1,
argsCompute
};
this._applyUserModify(entry);
return entry;
}
_destroyEntry(entry) {
this.layer.removeShadowCasters([entry.meshInstance]);
entry.meshInstance.destroy();
entry.material.destroy();
entry.indexBuffer?.destroy();
entry.countBuffer.destroy();
entry.cullCompute?.destroy();
entry.argsCompute.destroy();
}
_createMaterial() {
const material = new ShaderMaterial({
uniqueName: "GSplatShadowMaterial",
vertexGLSL: '#include "gsplatVS"',
fragmentGLSL: '#include "gsplatPS"',
vertexWGSL: '#include "gsplatVS"',
fragmentWGSL: '#include "gsplatPS"',
attributes: {
vertex_position: SEMANTIC_POSITION
}
});
material.setDefine("{GSPLAT_INSTANCE_SIZE}", GSplatResourceBase.instanceSize);
material.setDefine("SH_BANDS", "0");
material.setDefine("GSPLAT_SEPARATE_OPACITY", "");
material.setDefine("DITHER_NONE", "");
material.setDefine("GSPLAT_INDIRECT_DRAW", true);
this._configureMaterialWorkBuffer(material);
material.cull = CULLFACE_NONE;
material.blendType = BLEND_PREMULTIPLIED;
material.depthWrite = false;
material.update();
return material;
}
_configureMaterialWorkBuffer(material) {
const workBuffer = this.world.workBuffer;
const wbFormat = workBuffer.format;
const chunks = this.device.isWebGPU ? material.shaderChunks.wgsl : material.shaderChunks.glsl;
chunks.set("gsplatDeclarationsVS", wbFormat.getInputDeclarations());
chunks.set("gsplatReadVS", wbFormat.getReadCode());
const colorStream = wbFormat.getStream("dataColor");
if (colorStream && colorStream.format !== PIXELFORMAT_RGBA16U) {
material.setDefine("GSPLAT_COLOR_FLOAT", "");
}
const hasPcId = !!wbFormat.getStream("pcId");
material.setDefine("GSPLAT_UNIFIED_ID", hasPcId);
material.setDefine("PICK_CUSTOM_ID", hasPcId);
for (const stream of wbFormat.resourceStreams) {
const texture = workBuffer.getTexture(stream.name);
if (texture) {
material.setParameter(stream.name, texture);
}
}
}
_createMeshInstance(light, material) {
const mesh = GSplatResourceBase.createMesh(this.device);
const meshInstance = new MeshInstance(mesh, material);
meshInstance.node = this.node;
meshInstance.setInstancing(true, true);
meshInstance.instancingCount = 0;
meshInstance.pick = false;
const cameraNode = this.cameraNode;
meshInstance.isVisibleFunc = (camera) => {
const sceneCamera = cameraNode.camera?.camera;
if (!sceneCamera) return false;
return camera === light.getRenderData(sceneCamera, 0).shadowCamera;
};
return meshInstance;
}
}
export {
GSplatShadowRenderer
};