Skip to content

Commit 04b2f62

Browse files
author
Martin Valigursky
committed
feat(gsplat): apply vertex-modify to hybrid cast shadows
The hybrid (GPU-sort) gsplat shadow renderer draws per-light shadow casters with their own ShaderMaterials, which did not pick up a user `gsplatModifyVS` chunk set on the scene gsplat material. As a result, cast shadows did not follow forward-pass per-vertex animation (e.g. animated splat positions). GSplatShadowRenderer now applies the scene material's `gsplatModifyVS` chunk to every per-light shadow material (recompiling only when the chunk changes) and forwards the scene material's parameters (e.g. `uTime`) to them each frame, so cast shadows stay in sync with the forward pass. The Gaussian Splatting > Shadows example gains an "Animate" toggle that enables a custom position/color vertex modifier, demonstrating the cast shadow following the animation.
1 parent 2802778 commit 04b2f62

5 files changed

Lines changed: 156 additions & 3 deletions

File tree

examples/src/examples/gaussian-splatting/shadows.controls.jsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { BindingTwoWay, LabelGroup, SelectInput, SliderInput } from '@playcanvas/pcui/react';
1+
import { BindingTwoWay, BooleanInput, LabelGroup, SelectInput, SliderInput } from '@playcanvas/pcui/react';
22

33
/**
44
* @import { Observer } from '@playcanvas/observer'
@@ -44,6 +44,13 @@ export function Controls({ observer }) {
4444
precision={0}
4545
/>
4646
</LabelGroup>
47+
<LabelGroup text='Animate'>
48+
<BooleanInput
49+
type='toggle'
50+
binding={new BindingTwoWay()}
51+
link={{ observer, path: 'shader' }}
52+
/>
53+
</LabelGroup>
4754
</>
4855
);
4956
}

examples/src/examples/gaussian-splatting/shadows.example.mjs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { ShadowCatcher } from 'playcanvas/scripts/esm/shadow-catcher.mjs';
1515

1616
import { data, deviceType } from 'examples/context';
1717

18+
import shaderGlslVert from './shader.glsl.vert';
19+
import shaderWgslVert from './shader.wgsl.vert';
20+
1821
const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
1922
window.focus();
2023

@@ -105,6 +108,24 @@ assetListLoader.load(() => {
105108
});
106109
data.set('alphaClip', 0.4);
107110

111+
// Optional custom vertex shader that animates each splat's position (via modifySplatCenter). The
112+
// toggle verifies the cast shadow follows the animated position, since the shadow draw uses the
113+
// same quad vertex shader as the forward pass.
114+
const sceneMat = app.scene.gsplat.material;
115+
const applyCustomShader = (enabled) => {
116+
if (enabled) {
117+
sceneMat.getShaderChunks('glsl').set('gsplatModifyVS', shaderGlslVert);
118+
sceneMat.getShaderChunks('wgsl').set('gsplatModifyVS', shaderWgslVert);
119+
} else {
120+
sceneMat.getShaderChunks('glsl').delete('gsplatModifyVS');
121+
sceneMat.getShaderChunks('wgsl').delete('gsplatModifyVS');
122+
}
123+
sceneMat.update();
124+
};
125+
data.on('shader:set', () => applyCustomShader(!!data.get('shader')));
126+
applyCustomShader(false);
127+
data.set('shader', false);
128+
108129
// Create first splat entity
109130
const biker = new pc.Entity('biker');
110131
biker.addComponent('gsplat', {
@@ -194,12 +215,21 @@ assetListLoader.load(() => {
194215
});
195216
data.set('numLights', 2);
196217

197-
// Rotate all lights together (same direction), preserving each light's fixed azimuth offset.
218+
// Rotate all lights together (same direction), preserving each light's fixed azimuth offset;
219+
// also advance the custom-shader animation time.
198220
let lightAngle = 0;
221+
let currentTime = 0;
199222
app.on('update', (/** @type {number} */ dt) => {
200223
lightAngle += dt * 20;
201224
lights.forEach((light, i) => {
202225
light.setEulerAngles(55, lightBaseAzimuths[i] + lightAngle, 0);
203226
});
227+
228+
currentTime += dt;
229+
sceneMat.setParameter('uTime', currentTime);
230+
231+
// re-dirty the scene gsplat material each frame so the per-frame uTime propagates to the
232+
// renderer's material copy (the quad renderer only re-copies template params when dirty)
233+
sceneMat.update();
204234
});
205235
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
uniform float uTime;
2+
3+
void modifySplatCenter(inout vec3 center) {
4+
// animate the splat position (this must be reflected in the cast shadow too)
5+
float heightIntensity = center.y * 0.5;
6+
center.x += sin(uTime * 5.0 + center.y) * 0.3 * heightIntensity;
7+
}
8+
9+
void modifySplatRotationScale(vec3 originalCenter, vec3 modifiedCenter, inout vec4 rotation, inout vec3 scale) {
10+
// no modification
11+
}
12+
13+
void modifySplatColor(vec3 center, inout vec4 clr) {
14+
float sineValue = abs(sin(uTime * 5.0 + center.y));
15+
16+
vec3 gold = vec3(1.0, 0.85, 0.0);
17+
float blend = smoothstep(0.9, 1.0, sineValue);
18+
clr.xyz = mix(clr.xyz, gold, blend);
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
uniform uTime: f32;
2+
3+
fn modifySplatCenter(center: ptr<function, vec3f>) {
4+
// animate the splat position (this must be reflected in the cast shadow too)
5+
let heightIntensity = (*center).y * 0.5;
6+
(*center).x += sin(uniform.uTime * 5.0 + (*center).y) * 0.3 * heightIntensity;
7+
}
8+
9+
fn modifySplatRotationScale(originalCenter: vec3f, modifiedCenter: vec3f, rotation: ptr<function, vec4f>, scale: ptr<function, vec3f>) {
10+
// no modification
11+
}
12+
13+
fn modifySplatColor(center: vec3f, clr: ptr<function, vec4f>) {
14+
let sineValue = abs(sin(uniform.uTime * 5.0 + center.y));
15+
16+
let gold = vec3f(1.0, 0.85, 0.0);
17+
let blend = smoothstep(0.9, 1.0, sineValue);
18+
(*clr) = vec4f(mix((*clr).xyz, gold, blend), (*clr).a);
19+
}

src/scene/gsplat-unified/gsplat-shadow-renderer.js

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ class GSplatShadowRenderer {
130130
*/
131131
_frustumPlanes = new Float32Array(24);
132132

133+
/**
134+
* Change-detection key for the scene material's shader chunks; when it changes, the user
135+
* `gsplatModifyVS` chunk is re-applied to the per-light shadow materials.
136+
*
137+
* @type {string}
138+
* @private
139+
*/
140+
_userChunksKey = '';
141+
142+
/**
143+
* The scene material's user `gsplatModifyVS` WGSL chunk source (or null for the default no-op).
144+
* The shadow path is WebGPU-only, so only the WGSL variant is tracked.
145+
*
146+
* @type {string|null}
147+
* @private
148+
*/
149+
_userModifyWgsl = null;
150+
133151
// The cull/args shaders are shared, but each light entry gets its OWN Compute instances
134152
// (created in _createEntry). A Compute owns a persistent uniform buffer, so a single shared
135153
// Compute dispatched once per light per frame would have all dispatches read the last-written
@@ -332,6 +350,11 @@ class GSplatShadowRenderer {
332350
// current here (cheap: one matrix per splat placement, correct for moving splats).
333351
this.world.workBuffer.frustumCuller.updateTransformsData(worldState.boundsGroups);
334352

353+
// Apply the scene material's user vertex-modify chunk + forward its parameters to the
354+
// per-light shadow materials, so cast shadows follow the same per-vertex animation as the
355+
// forward pass (the shadow draw uses the same quad VS).
356+
this._syncUserModify(gsplatParams);
357+
335358
const numIntervals = worldState.totalIntervals;
336359
const totalActiveSplats = worldState.totalActiveSplats;
337360
const textureSize = this.world.workBuffer.textureSize;
@@ -341,6 +364,56 @@ class GSplatShadowRenderer {
341364
});
342365
}
343366

367+
/**
368+
* Applies the scene material's user `gsplatModifyVS` chunk to every per-light shadow material
369+
* (recompiling only when the chunk changes) and forwards the scene material's parameters (e.g.
370+
* `uTime`) to them each frame. This keeps cast shadows in sync with any forward-pass vertex
371+
* animation, since the shadow draw uses the same quad VS + modify hooks.
372+
*
373+
* @param {GSplatParams} gsplatParams - Scene gsplat params (carries the template material).
374+
* @private
375+
*/
376+
_syncUserModify(gsplatParams) {
377+
const userMat = gsplatParams.material;
378+
if (!userMat) return;
379+
380+
// re-apply the modify chunk to all entries when the scene material's chunks change
381+
const chunksKey = userMat.shaderChunks?.key ?? '';
382+
if (chunksKey !== this._userChunksKey) {
383+
this._userChunksKey = chunksKey;
384+
this._userModifyWgsl = userMat.getShaderChunks?.('wgsl')?.get('gsplatModifyVS') ?? null;
385+
this.entries.forEach(entry => this._applyUserModify(entry));
386+
}
387+
388+
// forward user material parameters (e.g. uTime) every frame. The entry's own bindings are
389+
// set afterwards in _cullEntry, so they win on any name collision.
390+
const params = userMat.parameters;
391+
this.entries.forEach((entry) => {
392+
for (const name in params) {
393+
if (params.hasOwnProperty(name)) {
394+
entry.material.setParameter(name, params[name].data);
395+
}
396+
}
397+
});
398+
}
399+
400+
/**
401+
* Sets (or clears) the tracked user `gsplatModifyVS` chunk on one entry's material and rebuilds
402+
* its shader. Called when the chunk changes and when a new entry is created.
403+
*
404+
* @param {ShadowLightEntry} entry - The light entry.
405+
* @private
406+
*/
407+
_applyUserModify(entry) {
408+
const wgsl = entry.material.shaderChunks.wgsl;
409+
if (this._userModifyWgsl) {
410+
wgsl.set('gsplatModifyVS', this._userModifyWgsl);
411+
} else {
412+
wgsl.delete('gsplatModifyVS');
413+
}
414+
entry.material.update();
415+
}
416+
344417
/**
345418
* Dispatches the cull + indirect-args for one light entry and binds the results.
346419
*
@@ -482,7 +555,7 @@ class GSplatShadowRenderer {
482555
const cullCompute = new Compute(device, this._cullShader, 'GSplatShadowCull');
483556
const argsCompute = new Compute(device, this._argsShader, 'GSplatShadowIndirectArgs');
484557

485-
return {
558+
const entry = {
486559
light,
487560
material,
488561
meshInstance,
@@ -492,6 +565,11 @@ class GSplatShadowRenderer {
492565
cullCompute,
493566
argsCompute
494567
};
568+
569+
// apply the current user modify chunk so a newly-added light matches the existing ones
570+
this._applyUserModify(entry);
571+
572+
return entry;
495573
}
496574

497575
/**

0 commit comments

Comments
 (0)