vidar.games

PCSS contact-hardening soft shadows

2026-11-08 ยท Discuss on X

Real shadows are sharp where an object touches the ground and soft where it is far away. A single shadow-map test can't do that. PCSS can, from the same shadow map.

The problem

A basic shadow map gives one yes/no per pixel, so every edge is equally hard. Fixed-radius PCF blurs every edge by the same amount, which looks wrong at contact points: the foot of a pole should be crisp, the shadow of its top should spread.

How it works

The source (a Unity screen-space shadow shader) does three steps per pixel:

  1. Blocker search: read the shadow map at Poisson-disc offsets around the pixel and average the depth of every sample closer to the light than the receiver.
  2. Penumbra estimate: receiver depth minus average blocker depth. Far blockers give a wide penumbra, touching blockers give almost none. A falloff curve shapes it.
  3. PCF: filter the shadow with the same Poisson disc, scaled by that penumbra.

The disc is rotated by a per-pixel noise value, so too few samples show up as grain instead of banding. Depth bias follows the receiver's slope, taken from screen-space derivatives of the shadow coordinate.

What I hit

On the first heatmap, the whole ground lit up as "in penumbra". The blocker search, with its wide radius, was finding the tilted ground itself as a blocker. The source has an optional slope-based bias for the blocker search (off by default). Turning it on fixed it.

The source filters with hardware shadow compares. I compare each tap in the shader, so each tap is a plain in/out test.

My build

three.js (r187), WebGL2. One 2048 x 2048 depth map from an orthographic light over a 60 m square. 16 blocker and 16 PCF samples, as in the source's defaults; the close-up compares with 32 and 32. The 32-point Poisson table is copied from the source.

Scene: five 9 m poles, two street lamps, a 16-post fence, four cubes floating 0.05 to 3.2 m above the ground, a sphere and a knot.

The left side of the first split is one depth compare per pixel against the same map.

Trade-offs

When to use it

Sun and area-light shadows where objects meet the ground: poles, fences, characters, props. For tiny lights or far-away shadows, plain PCF is cheaper and looks the same.

New breakdowns go out on X first: @vidar_0208. If this one helped, you can buy me a coffee.

Source and credits

Technique source: UnityPCSS by TheMasonX, https://github.com/TheMasonX/UnityPCSS, MIT license, commit 7ebf495e0366cdb8805af1cf6c692455d06493c9. Ported to three.js and GLSL from its PCSS shader: the Poisson-disc blocker search, the penumbra estimate (receiver depth minus average blocker depth, with its falloff curve), the Poisson-disc PCF scaled by that penumbra, the per-pixel rotation of the disc, and the receiver-plane depth bias. The 32-point Poisson table and the default sample counts (16 and 16) come from the source. No files were copied unchanged.

Render framework: three.js by mrdoob and contributors, https://github.com/mrdoob/three.js, MIT license, branch dev at commit fa01dd6ad164c48554f1bb9456f6500db052ec4f. Used for rendering, the depth render target and materials. It is not the source of the PCSS technique.

My own code: the light setup and shadow-map pass, the hard-shadow comparison, the penumbra heatmap, the light-size slider, the procedural poles, lamps, fence and floating blocks, labels and video. No external models or assets. No IA model.