playcanvas / playcanvas/engine
Consider texture arrays for directional shadow cascades instead of a 2x2 atlas
@mvaligursky is already working on this.
Since Sep 18, 2026.
- Dominant language
- JavaScript
- Stars
- 16.8k
- Forks
- 2k
- Avg merge
- 4h 32m
- Merged PRs (30d)
- 222
Description
Summary
Directional shadow cascades currently share one square shadow map, split into 2x2 quadrants. Using a 2D depth texture array (one layer per cascade) instead is core on both WebGL2 and WebGPU. This issue records an investigation into whether it is worth doing — the conclusion is "maybe later, and not primarily for the reasons you'd expect".
Filing for future reference, no work planned right now.
How it works today
directionalCascadesinsrc/scene/light.jshardcodes the 2x2 quadrant viewports. 1 cascade uses the whole texture(0,0,1,1); 2–4 cascades use quadrants.- The quadrant is folded into the shadow matrix on the CPU, not handled in the shader —
ShadowRenderer#dispatchUniformspremultipliesviewportMatrixintoshadowMatrixand stores the result inlight._shadowMatrixPalette. - The shader therefore already does no viewport work:
getShadowCascadeIndexpicks an index and indexes the matrix palette (shadowCascades.js,lightFunctionShadow.js). - All cascades render in a single
RenderPassShadowDirectional, switching viewport/scissor per face.
So "simpler sampling, no viewports" is already largely true. An array would not simplify sampling; it would replace one CPU-side matrix multiply with an extra array-index argument threaded through every shadow sampling function.
API support — confirmed available
- WebGL2 / GLSL ES 3.00:
float texture(sampler2DArrayShadow, vec4 P),float textureGrad(sampler2DArrayShadow, vec4 P, vec2, vec2)andtextureGradOffsetare all in the spec.textureGradis what ourtextureShadowmacro already uses (shader-chunks/frag/gles3.js), so that macro maps over directly. NotetextureLodhas no array-shadow overload — which matters for the WebGPU-GLSL path (#version 450), whosetextureShadowmacro istextureLod-based. - WebGPU:
texture_depth_2d_arrayis already in the WGSL type table (webgpu-shader-processor-wgsl.js), depth +2d-arraybind group layouts already work, array textures already receiveRENDER_ATTACHMENT, and cached per-layer texture views already exist (used for cubemap faces). framebufferTextureLayeris core WebGL2 but is currently unused anywhere in the engine.
The memory saving, measured
Per-cascade resolution today is shadowResolution / 2 for 2–4 cascades. Matching that with layers:
| cascades | today | texture array | saving |
|---|---|---|---|
| 1 | R² | R² | 0 |
| 2 | R² | R²/2 | 50% |
| 3 | R² | 3R²/4 | 25% |
| 4 | R² | R² | 0 |
The win exists only at 2 and 3 cascades, and numCascades defaults to 1.
There's also an API wrinkle: shadowResolution is documented as the size of the shadow map texture, so with layers it would mean R/2 per cascade for multi-cascade and R for single-cascade — or we redefine it as per-cascade resolution, which is a 4x memory increase at the same setting (breaking).
Wins that are more interesting than memory
- No cross-cascade filter bleed. PCF5x5 taps and the VSM blur can read across quadrant borders today.
ShadowRenderer#applyVsmBluralready carries per-cascade scissor clamping specifically to work around this. With layers,ADDRESS_CLAMP_TO_EDGEdoes the right thing per layer for free. - Real per-cascade clears instead of scissored clears inside a shared render target — removes the
allCascadesRenderingspecial-casing. - Decoupling per-cascade resolution from cascade count becomes a clean API rather than an implicit /2.
- The
RENDERTARGET_ORIGIN_BOTTOMconstraint inshadow-map.js("the shadow map sampling math (matrix scale-bias derived uvs, atlas viewport rects) is written against the WebGL layout, so replicate it on all graphics APIs") is atlas-viewport-driven and would relax.
Costs
- 1 render pass becomes N. An array layer cannot be switched mid render pass, so a 4-cascade light needs 4 begin/end render passes instead of one pass with 4 viewports. On tiled GPUs that's 4x the fixed per-pass cost. This is the strongest argument against.
- New
RenderTargetplumbing.RenderTargethas no layer/slice option at all — onlyface, for cubemaps. WebGL needsframebufferTextureLayer(new code), and the WebGPU depth-attachment path ignoresfaceentirely (webgpu-render-target.js), because non-clustered omni shadows aren't supported on WebGPU so it was never needed. Only the colour attachment path handlesbaseArrayLayer. - Shader chunk duplication.
getShadowPCF1x1/3x3/5x5,getShadowVSM16/32,getShadowPCSSand the EVSM/PCSS helpers are all shared between directional, spot and clustered lights via theSHADOWMAP_ACCEPT/TEXTURE_ACCEPTmacros. Directional would need array variants while clustered keeps its 2D atlas, and both appear in the same shader — so a macro redefinition cannot serve both. The bodies get duplicated, x2 shader languages. Plus the VSM blur shader andsampleFogShadowin the volumetric fog chunks. ShaderProcessorGLSL.getTexturesShaderDeclarationemitssampler2DArray(...)for every array texture — needs asampler2DArrayShadowbranch forSAMPLETYPE_DEPTH.ShadowRendererDirectional#getLightRenderPassreturns a single pass; the two call sites (RenderPassForward#updateDirectionalShadows,Lightmapper) would need to accept a list.ShadowMapCache#getKeyneeds the cascade count added.
Cheaper alternative if memory is the only goal
A non-square atlas achieves an identical saving with zero shader changes: 2 cascades in an R x R/2 texture (two halves side by side), 3 cascades in 1.5R x R/2. Same texel count as the array in both cases.
The only blocker is that shadowParams.x carries a single resolution value used for texel size — and there is already a comment anticipating exactly this in ForwardRenderer#dispatchDirectLights:
params[0] = directional._shadowResolution; // Note: this needs to change for non-square shadow maps (2 cascades). Currently square is used
Suggested framing if picked up later
- If the goal is memory only, do the non-square atlas — much smaller change.
- Do the texture array for the other benefits: per-cascade resolution as a real API, clean per-cascade filtering borders, and the layer-render-target plumbing (useful beyond shadows). If so,
RenderTargetarray-layer support is worth landing first and independently — it is self-contained and testable on its own. - Either way, benchmark the N-render-pass cost on a tiler before committing.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.