Update light cookie texture or offset on live to project a shadow

Good afternoon,

I’m working on an isometric game, and I’m having an hard time trying to improve the light of my scene.

I want to project a cloud shadow on my map ( on every object, not only the floor material), by using a spot light and a cookies.

The cookie work fine, but it’s seem impossible to change the texture of it, or even just this position, using cookie offset parameter.
Before trying something else, I did some investigation, I found this on the github : Clustered lighting cookie renderer should support changes to the texture. · Issue #4926 · playcanvas/engine · GitHub

I guess it’s on the roadmap of the engine for the future ?

And for now, what is the solution? to use the legacy light system ? Because I tried it, but it seem my spot light is not working when I switch to it.

Any advice to found a workaround ?

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.

Thank for the reply, what is your AI ? My gpt cortex is not able to go this deep on playcanvas. It’s a bit scary about our job :sweat_smile:

I was looking for a solution that doesn’t need to be applied to all material one by one …

Now, I trying to turn my texture into a cylinder, and to use an omni spot light instead. I will then make it turn around itself, since my cloud are moving slowly.

Not sure of how it’s gonna render but maybe it’s gonna look fine :slight_smile:

Edit : also, do you know why turning off the clustered lighting turn off the spot light ? Is the legacy lighting systems disabled ?

I use Cursor, and gave it source code of the engine.
With Editor projects, you should use the VSCode extension VS Code Extension | PlayCanvas Developer Site and use some AI provider there, that works really well.

1 Like

Legacy lighting still works - but I guess you have less lights to work with. But few lights woudl function without a problem.

I would probably go the route of a global shader chunk override. That would be automatically used by all standard materials, no need to set up materials. This would need a texture and few other uniforms for scrolling - those I would set up on a global scope with unique names (so nothing overwrites those) and again, no need to touch materials at all.

1 Like

I got Cursor to generate a simple engine example for this:

see it deployed here:
https://engine-lc4o5d4ab-playcanvas.vercel.app/#/shaders/cloud-shadows

1 Like