playcanvas / playcanvas/engine

Consider texture arrays for directional shadow cascades instead of a 2x2 atlas

Open
#9,442 0 comments 1 reaction 1 assignee View on GitHub

@mvaligursky is already working on this.

Since Sep 18, 2026.

area: graphics
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
  • directionalCascades in src/scene/light.js hardcodes 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#dispatchUniforms premultiplies viewportMatrix into shadowMatrix and stores the result in light._shadowMatrixPalette.
  • The shader therefore already does no viewport work: getShadowCascadeIndex picks 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) and textureGradOffset are all in the spec. textureGrad is what our textureShadow macro already uses (shader-chunks/frag/gles3.js), so that macro maps over directly. Note textureLod has no array-shadow overload — which matters for the WebGPU-GLSL path (#version 450), whose textureShadow macro is textureLod-based.
  • WebGPU: texture_depth_2d_array is already in the WGSL type table (webgpu-shader-processor-wgsl.js), depth + 2d-array bind group layouts already work, array textures already receive RENDER_ATTACHMENT, and cached per-layer texture views already exist (used for cubemap faces).
  • framebufferTextureLayer is 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 0
2 R²/2 50%
3 3R²/4 25%
4 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
  1. No cross-cascade filter bleed. PCF5x5 taps and the VSM blur can read across quadrant borders today. ShadowRenderer#applyVsmBlur already carries per-cascade scissor clamping specifically to work around this. With layers, ADDRESS_CLAMP_TO_EDGE does the right thing per layer for free.
  2. Real per-cascade clears instead of scissored clears inside a shared render target — removes the allCascadesRendering special-casing.
  3. Decoupling per-cascade resolution from cascade count becomes a clean API rather than an implicit /2.
  4. The RENDERTARGET_ORIGIN_BOTTOM constraint in shadow-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. 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.
  2. New RenderTarget plumbing. RenderTarget has no layer/slice option at all — only face, for cubemaps. WebGL needs framebufferTextureLayer (new code), and the WebGPU depth-attachment path ignores face entirely (webgpu-render-target.js), because non-clustered omni shadows aren't supported on WebGPU so it was never needed. Only the colour attachment path handles baseArrayLayer.
  3. Shader chunk duplication. getShadowPCF1x1/3x3/5x5, getShadowVSM16/32, getShadowPCSS and the EVSM/PCSS helpers are all shared between directional, spot and clustered lights via the SHADOWMAP_ACCEPT / TEXTURE_ACCEPT macros. 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 and sampleFogShadow in the volumetric fog chunks.
  4. ShaderProcessorGLSL.getTexturesShaderDeclaration emits sampler2DArray(...) for every array texture — needs a sampler2DArrayShadow branch for SAMPLETYPE_DEPTH.
  5. ShadowRendererDirectional#getLightRenderPass returns a single pass; the two call sites (RenderPassForward#updateDirectionalShadows, Lightmapper) would need to accept a list. ShadowMapCache#getKey needs 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, RenderTarget array-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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.