Hi PlayCanvas team,
We're trying to port our custom gsplatModifyVS chunks from the legacy per-component pipeline to unified rendering in 2.18.1, mainly so we can use splatBudget for LOD streaming on mobile. The chunks themselves work — they compile, they run, they read uniforms set at install. But we can't get per-frame uniform updates to reach the shader, and we think it might be a bug. Sharing our reproduction in case it's useful.
Our chunks drive wind, water, weather, time-of-day, and per-splat relighting — all per-frame uniform stuff (uTime, uWindSpeed, etc). It's worked great in legacy mode since the 2.17 chunk-set API change.
When we flip the entity into unified mode and install the same kind of chunk on app.scene.gsplat.material, the very first frame looks right — but then it freezes:
splatEntity.gsplat.unified = true;
splatEntity.gsplat.workBufferUpdate = pc.WORKBUFFER_UPDATE_ALWAYS;
const sceneMat = app.scene.gsplat.material;
sceneMat.getShaderChunks('glsl').set('gsplatModifyVS', uniform float uTime; void modifySplatCenter(inout vec3 center) { center.x += sin(uTime * 2.0 + center.y * 0.5) * 2.0; } void modifySplatRotationScale(vec3 oc, vec3 mc, inout vec4 r, inout vec3 s) {} void modifySplatColor(vec3 c, inout vec4 col) {});
sceneMat.update();
// Push uTime each frame:
const start = performance.now();
const loop = () => {
const t = (performance.now() - start) / 1000;
sceneMat.setParameter('uTime', t);
requestAnimationFrame(loop);
};
loop();
I'd expect the splat to sway continuously. Instead it gets displaced sideways once (at whatever uTime was when the shader compiled) and then sits there. The parameter bag IS receiving the new values each frame — sceneMat.parameters['uTime'] updates as expected — but the GL uniform location seems to be bound once and never re-read. We also tried pushing via splatEntity.gsplat.setParameter('uTime', t) per frame, same frozen result.
We figured setWorkBufferModifier was probably the right path for time-driven stuff, since the docs explicitly say it's "useful when using custom shader code that depends on external factors like time or animated uniforms". But when we install our chunk that way:
splatEntity.gsplat.setWorkBufferModifier({ glsl: uniform float uTime; void modifySplatCenter(inout vec3 center) { center.x += sin(uTime * 2.0 + center.y * 0.5) * 2.0; } void modifySplatRotationScale(vec3 oc, vec3 mc, inout vec4 r, inout vec3 s) {} void modifySplatColor(vec3 c, inout vec4 col) {} });
…it doesn't compile:
ERROR: 0:276: 'modifySplatRotationScale' : no matching overloaded function found
ERROR: 0:387: 'modifySplatCenter' : no matching overloaded function found
ERROR: 0:400: 'modifySplatColor' : no matching overloaded function found
So the COPY-phase modifier seems to want different signatures than the RENDER-phase chunk in 2.18.1. The three signatures above are exactly what the Custom Shaders docs call out, and they ARE accepted when set on app.scene.gsplat.material — just not via setWorkBufferModifier. We can't tell from outside the source what the COPY-phase actually wants.
A few things that DO work in unified mode (so the chunk infra is fine — it's specifically the per-frame propagation):
Pure constant displacement (center.y += 5.0) — splat moves 5 units up, no problem.
Uniform declared in the chunk + setParameter at install time only — splat picks up the value.
File-scope state shared between modifySplatCenter and modifySplatColor (e.g. writing _worldPos in one, reading in the other) — works.
So I think there's one of three things going on, and I'd love to know which:
There's a real bug in unified-mode uniform binding where the GL location only picks up the first value.
There's an undocumented API we missed for pushing per-frame data to unified-mode chunks.
Unified mode is intentionally restricted to static shader chunks today, and the docs/page should say so explicitly — we lost about a day chasing this before we were sure.
For context on the "why" — we have a production gsplat platform built around gsplatModifyVS chunks (wind, water, weather, fog, vortex/bloom intros, per-splat relight, colour zones). All per-frame uniform-driven. LOD streaming would be a meaningful UX win for our mobile users and large scenes, but we don't want to ship a "LOD disables all effects" degraded mode if there's a path that keeps them working.
Environment: PC engine 2.18.1 via importmap (cdn.jsdelivr.net), Chrome stable, WebGL2 (also reproduced with WebGPU disabled). 2.49M-splat SOG file. Repro tested in console after a normal asset load — happy to put together a minimal HTML+JS repo if it'd help.
Thanks for the engine work, especially the unified rendering pipeline — splatBudget plus our chunks is exactly the architecture we'd love to ship.
Hi PlayCanvas team,
We're trying to port our custom gsplatModifyVS chunks from the legacy per-component pipeline to unified rendering in 2.18.1, mainly so we can use splatBudget for LOD streaming on mobile. The chunks themselves work — they compile, they run, they read uniforms set at install. But we can't get per-frame uniform updates to reach the shader, and we think it might be a bug. Sharing our reproduction in case it's useful.
Our chunks drive wind, water, weather, time-of-day, and per-splat relighting — all per-frame uniform stuff (uTime, uWindSpeed, etc). It's worked great in legacy mode since the 2.17 chunk-set API change.
When we flip the entity into unified mode and install the same kind of chunk on app.scene.gsplat.material, the very first frame looks right — but then it freezes:
splatEntity.gsplat.unified = true;
splatEntity.gsplat.workBufferUpdate = pc.WORKBUFFER_UPDATE_ALWAYS;
const sceneMat = app.scene.gsplat.material;
sceneMat.getShaderChunks('glsl').set('gsplatModifyVS',
uniform float uTime; void modifySplatCenter(inout vec3 center) { center.x += sin(uTime * 2.0 + center.y * 0.5) * 2.0; } void modifySplatRotationScale(vec3 oc, vec3 mc, inout vec4 r, inout vec3 s) {} void modifySplatColor(vec3 c, inout vec4 col) {});sceneMat.update();
// Push uTime each frame:
const start = performance.now();
const loop = () => {
const t = (performance.now() - start) / 1000;
sceneMat.setParameter('uTime', t);
requestAnimationFrame(loop);
};
loop();
I'd expect the splat to sway continuously. Instead it gets displaced sideways once (at whatever uTime was when the shader compiled) and then sits there. The parameter bag IS receiving the new values each frame — sceneMat.parameters['uTime'] updates as expected — but the GL uniform location seems to be bound once and never re-read. We also tried pushing via splatEntity.gsplat.setParameter('uTime', t) per frame, same frozen result.
We figured setWorkBufferModifier was probably the right path for time-driven stuff, since the docs explicitly say it's "useful when using custom shader code that depends on external factors like time or animated uniforms". But when we install our chunk that way:
splatEntity.gsplat.setWorkBufferModifier({ glsl:
uniform float uTime; void modifySplatCenter(inout vec3 center) { center.x += sin(uTime * 2.0 + center.y * 0.5) * 2.0; } void modifySplatRotationScale(vec3 oc, vec3 mc, inout vec4 r, inout vec3 s) {} void modifySplatColor(vec3 c, inout vec4 col) {}});…it doesn't compile:
ERROR: 0:276: 'modifySplatRotationScale' : no matching overloaded function found
ERROR: 0:387: 'modifySplatCenter' : no matching overloaded function found
ERROR: 0:400: 'modifySplatColor' : no matching overloaded function found
So the COPY-phase modifier seems to want different signatures than the RENDER-phase chunk in 2.18.1. The three signatures above are exactly what the Custom Shaders docs call out, and they ARE accepted when set on app.scene.gsplat.material — just not via setWorkBufferModifier. We can't tell from outside the source what the COPY-phase actually wants.
A few things that DO work in unified mode (so the chunk infra is fine — it's specifically the per-frame propagation):
Pure constant displacement (center.y += 5.0) — splat moves 5 units up, no problem.
Uniform declared in the chunk + setParameter at install time only — splat picks up the value.
File-scope state shared between modifySplatCenter and modifySplatColor (e.g. writing _worldPos in one, reading in the other) — works.
So I think there's one of three things going on, and I'd love to know which:
There's a real bug in unified-mode uniform binding where the GL location only picks up the first value.
There's an undocumented API we missed for pushing per-frame data to unified-mode chunks.
Unified mode is intentionally restricted to static shader chunks today, and the docs/page should say so explicitly — we lost about a day chasing this before we were sure.
For context on the "why" — we have a production gsplat platform built around gsplatModifyVS chunks (wind, water, weather, fog, vortex/bloom intros, per-splat relight, colour zones). All per-frame uniform-driven. LOD streaming would be a meaningful UX win for our mobile users and large scenes, but we don't want to ship a "LOD disables all effects" degraded mode if there's a path that keeps them working.
Environment: PC engine 2.18.1 via importmap (cdn.jsdelivr.net), Chrome stable, WebGL2 (also reproduced with WebGPU disabled). 2.49M-splat SOG file. Repro tested in console after a normal asset load — happy to put together a minimal HTML+JS repo if it'd help.
Thanks for the engine work, especially the unified rendering pipeline — splatBudget plus our chunks is exactly the architecture we'd love to ship.