The issue you found is related to using animated texture, like a movie, for the cookie texture. In general, cookie texture do not support scrolling or repeating (clusted lights are slightly more limited here), so that is not very suitable for clouds I think.
I pointed AI to the engine source code, and these are the recommendations:
Cloud Shadow Texture - Implementation Options
Here are three approaches for implementing cloud shadows using PlayCanvas shader chunk overrides, ordered from simplest to most physically accurate.
Option 1: Override the endPS Chunk
The endPS chunk runs after all lighting and before tonemapping/gamma. Override it to sample a cloud texture and darken the final color, using vPositionW (world position) to compute scrolling UVs:
Custom endPS chunk (GLSL):
uniform sampler2D cloudShadowMap;
uniform float cloudTime;
uniform float cloudScale;
uniform float cloudIntensity;
gl_FragColor.rgb = combineColor(litArgs_albedo, litArgs_sheen_specularity, litArgs_clearcoat_specularity);
gl_FragColor.rgb += litArgs_emission;
vec2 cloudUV = vPositionW.xz * cloudScale + vec2(cloudTime * 0.1, cloudTime * 0.05);
float cloudShadow = texture2D(cloudShadowMap, cloudUV).r;
cloudShadow = mix(1.0, cloudShadow, cloudIntensity);
gl_FragColor.rgb *= cloudShadow;
gl_FragColor.rgb = addFog(gl_FragColor.rgb);
gl_FragColor.rgb = toneMap(gl_FragColor.rgb);
gl_FragColor.rgb = gammaCorrectOutput(gl_FragColor.rgb);
Application code:
const material = meshInstance.material;
material.getShaderChunks(pc.SHADERLANGUAGE_GLSL).set('endPS', cloudEndChunkGLSL);
material.shaderChunksVersion = '2.8';
material.update();
material.setParameter('cloudShadowMap', cloudTexture);
material.setParameter('cloudTime', 0);
material.setParameter('cloudScale', 0.01);
material.setParameter('cloudIntensity', 0.5);
Animate cloudTime in your update loop.
Pros: Very simple, works on everything, affects the final image.
Cons: Applies after all lighting, so it darkens emission and specular too. Must be applied to every material individually.
Option 2: Use the litUserMainEndPS / litUserDeclarationPS Chunks
These are the official user injection points in the lit shader. The forward main shader has this structure:
void main(void) {
#include "litUserMainStartPS"
// ... all lighting evaluation happens here ...
#include "litUserMainEndPS"
}
Use litUserDeclarationPS for uniforms and litUserMainEndPS for the cloud shadow logic:
litUserDeclarationPS chunk:
uniform sampler2D cloudShadowMap;
uniform float cloudTime;
uniform float cloudScale;
uniform float cloudIntensity;
litUserMainEndPS chunk:
vec2 cloudUV = vPositionW.xz * cloudScale + vec2(cloudTime * 0.1, cloudTime * 0.05);
float cloudShadow = texture2D(cloudShadowMap, cloudUV).r;
cloudShadow = mix(1.0, cloudShadow, cloudIntensity);
gl_FragColor.rgb *= cloudShadow;
Application code:
const material = meshInstance.material;
const chunks = material.getShaderChunks(pc.SHADERLANGUAGE_GLSL);
chunks.set('litUserDeclarationPS', declarationChunk);
chunks.set('litUserMainEndPS', mainEndChunk);
material.shaderChunksVersion = '2.8';
material.update();
material.setParameter('cloudShadowMap', cloudTexture);
material.setParameter('cloudTime', 0);
material.setParameter('cloudScale', 0.01);
material.setParameter('cloudIntensity', 0.5);
Pros: Uses the official user extension points. Core chunks stay untouched. Same pattern as the engine’s trees example.
Cons: Still applies after all lighting. Must be applied to every material individually.
Option 3: Override the diffusePS Chunk (Albedo-only)
For more physical accuracy, modulate only the albedo so that specular highlights and emission remain unaffected under cloud shadows:
Custom diffusePS chunk:
uniform sampler2D cloudShadowMap;
uniform float cloudTime;
uniform float cloudScale;
uniform float cloudIntensity;
void getAlbedo() {
dAlbedo = material_diffuse.rgb;
#ifdef STD_DIFFUSE_TEXTURE
vec3 albedoBase = {STD_DIFFUSE_TEXTURE_DECODE}(texture2DBias({STD_DIFFUSE_TEXTURE_NAME}, {STD_DIFFUSE_TEXTURE_UV}, textureBias)).{STD_DIFFUSE_TEXTURE_CHANNEL};
dAlbedo *= albedoBase;
#endif
#ifdef STD_DIFFUSE_VERTEX
dAlbedo *= gammaCorrectInput(saturate(vVertexColor.{STD_DIFFUSE_VERTEX_CHANNEL}));
#endif
vec2 cloudUV = vPositionW.xz * cloudScale + vec2(cloudTime * 0.1, cloudTime * 0.05);
float cloudShadow = texture2D(cloudShadowMap, cloudUV).r;
cloudShadow = mix(1.0, cloudShadow, cloudIntensity);
dAlbedo *= cloudShadow;
}
Pros: More physically accurate – specular highlights and emission stay bright under cloud shadows.
Cons: Requires copying the full default diffusePS chunk, so it’s more fragile if the engine updates that chunk.
General Notes
- All approaches require applying the chunk override to every material that should receive cloud shadows.
- Pass the cloud texture and animation uniforms via
material.setParameter().
vPositionW is available in fragment shaders and gives you world-space position for computing cloud UVs.
- To align the shadow with the sun direction, project the world position along the light direction vector rather than using plain XZ.
- For WebGPU support, provide WGSL versions of the chunks as well.
- The engine’s trees example demonstrates the shader chunk override pattern.