1294 MODELS INDEXED Account
GUIDE · THREE.JS RENDERING

Three.js shadows: acne, bias, pixelation and soft shadows

UPDATED
20 MIN READ · BY 3D ASSET

Most three.js shadow problems come down to three numbers: the world size of one shadow-map texel (shadow camera width ÷ mapSize), normalBias in world units, and bias, which is a fraction of the shadow camera’s depth range. Fit the shadow camera to what you see plus the objects that throw shadows into it, check which faces go into the shadow map, then add normalBias and bias in small steps. On r182, also replace PCFSoftShadowMap with PCFShadowMap, and use r183 or later for WebGPURenderer.

Find your case

Everything below uses one DirectionalLight with its orthographic shadow camera, a scene modelled at 1 unit = 1 m, and plain WebGLRenderer in three.js r187, unless a section names another release or renderer. The images come from a test scene built for this article from our pack models. React Three Fiber uses the same renderer, so the same rules apply there.

The same tank, box, wall and sphere scene. Left half: grey moiré over the ground and a blocky shadow. Right half: clean ground and crisp shadows
Same scene, same light. Left: DoubleSide materials, ground casting shadows, bias 0, 512² map. Right: shadowSide = BackSide, normalBias 0.02, bias −0.0005, 2048² map.

The route that fixes most scenes, in this order:

  1. Check the release. r182 has two shadow bugs: PCFSoftShadowMap without filtering and acne in WebGPURenderer. Update before you tune anything.
  2. Look at the shadow camera. Add new THREE.CameraHelper( light.shadow.camera ) and see what it covers: the visible area, plus anything outside it that throws a shadow into it.
  3. Fit its size and depth range, and compute the size of one texel (pixelation, peter-panning).
  4. Check which faces go into the map: side and shadowSide of the materials (acne).
  5. Add normalBias, then bias, in small steps (the matrix).
  6. Choose the softness last: PCF radius, VSM or PCSS (filters, soft shadows).
Symptom → cause → fix, 13 cases

Scroll the table sideways for all columns →

What you seeCauseFix
Moiré or stripes on lit surfacesA surface shadows itself: its lit faces are in the shadow mapshadowSide = BackSide for closed DoubleSide meshes, then a small normalBias
Acne only with VSMVSM draws the material’s own side, and receivers cast tooTighten near/far, then a small negative bias
Shadows start a few centimetres away from the objectToo much bias for the shadow camera’s depth rangeTighten far − near, lower bias, try normalBias instead
Thin parts (antennas, railings, leaves) lose their shadowTexels too large, or normalBias moves the lookup past the partFit the frustum or raise mapSize, then lower normalBias
Blocky, blurry shadow edgesOne texel covers too much groundFit the shadow camera to the view, or use cascades
Shadows of objects just off screen are missingShadow camera fitted to the view onlyExtend the frustum towards the light by the longest shadow
Shadows shimmer when the camera movesThe shadow camera moves by fractions of a texelSnap the shadow camera to whole texels
PCF looks noisy or grainyr182+ PCF uses 5 rotated samplesKeep radius ≤ 3–4; VSM or PCSS for wide penumbrae
Light bleeds through overlapping VSM shadowsVariance estimate between two occludersSmaller VSM radius, tighter depth range, or PCF
A character needs a grounding shadow; shadow maps are too expensiveThe shadow pass draws all casters againBlob quad, or a contact shadow rendered once
Frame time doubles when shadows are onCasters drawn again (CPU) and the map filled and sampled (GPU)Measure CPU and GPU separately; fewer casters, static map, smaller frustum
Pixelated shadows after updating to r182r182 silently fell back to unfiltered shadowsPCFShadowMap (+ radius) or update to r183+
Sawtooth acne only in WebGPURendererr182 WebGPU drew the lit faces into the mapUpdate to r183+ before tuning bias

How a shadow map decides

A directional shadow takes two renders. First three.js draws every mesh with castShadow — a caster — from the light into a depth texture, the shadow map, through an orthographic camera, light.shadow.camera. The box of that camera, its frustum (left/right/top/bottom across the light, near/far along it), decides what gets into the map. Then, for every pixel of a mesh with receiveShadow — a receiver — the shader projects the pixel into the map and compares depths: if the map holds something closer to the light, the pixel is in shadow.

The map is a grid. One cell, a texel, stores a single depth. Between texel centres the receiver’s true depth and the stored depth differ by up to half a texel times the surface slope as seen from the light, so a lit surface that is itself in the map can shadow itself in a regular pattern. That is shadow acne. Three settings work against it, in different units:

  • shadow.bias is added to the receiver’s depth in shadow-map space, where 0 is near and 1 is far. For the orthographic camera of a directional light that is a depth offset along the light direction of bias × (far − near) world units: −0.0005 is 6 cm with near 1 / far 120 and 50 cm with far 1000. Negative values move receivers towards the light. This is not the gap you see on the ground; that also depends on the angle between the light and the surface. Spot and point lights use a perspective shadow camera with non-linear depth, so the formula does not carry over to them.
  • shadow.normalBias moves the point that is looked up in the map along the receiver’s normal, in world units. The casters in the map do not change; what changes is where each receiver pixel samples. It clears acne on sloped surfaces with less depth offset than bias, but the shadow edge on the receiver moves, and contacts can open.
  • material.shadowSide chooses which faces are drawn into the map. With null (the default) three.js draws the opposite side: back faces of FrontSide materials. On a closed mesh the depth stored behind a lit front face is then the back of the object, usually far behind it, so acne on lit faces is rare.
// three.js r187, src/renderers/webgl/WebGLShadowMap.js (abridged)
const shadowSide = {
  [ FrontSide ]: BackSide, [ BackSide ]: FrontSide, [ DoubleSide ]: DoubleSide
};
const side = material.shadowSide !== null ? material.shadowSide
  : type === VSMShadowMap ? material.side      // VSM: the same side
  : shadowSide[ material.side ];               // others: the opposite

// shadowmap_vertex.glsl.js: normalBias offsets the lookup in world space
shadowWorldPosition = worldPosition + vec4(
  shadowWorldNormal * directionalLightShadows[ i ].shadowNormalBias, 0 );

The other number that decides quality is the world size of one texel: shadow camera width ÷ mapSize. Keep a helper like this next to your light setup; every value in this article is converted with it.

function shadowStats( light ) {
  const c = light.shadow.camera, s = light.shadow.mapSize;
  return {
    texel: ( c.right - c.left ) / s.x,                  // m per texel
    biasDepth: Math.abs( light.shadow.bias ) * ( c.far - c.near ), // m
    normalBias: light.shadow.normalBias                 // m
  };
}

// a starting point for a ~30 m play area; tune it for your scene
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;  // r182+: soft by itself
const sun = new THREE.DirectionalLight( 0xffffff, 3 );
sun.castShadow = true;
sun.shadow.mapSize.set( 2048, 2048 );
const cam = sun.shadow.camera;
cam.left = cam.bottom = -16;
cam.right = cam.top = 16;        // 32 m / 2048 = 1.56 cm per texel
cam.near = 1; cam.far = 120;     // bias is a fraction of this range
sun.shadow.normalBias = 0.02;    // ≈ 1.3 texels
sun.shadow.bias = -0.0005;       // ≈ 6 cm of light-space depth
sun.position.set( 30, 40, -30 );
sun.target.position.set( 0, 0, 0 );
scene.add( sun, sun.target );    // a moved target must be in the scene
scene.add( new THREE.CameraHelper( cam ) );   // while tuning

Shadow acne: why it appears and the fix order

Four close-ups of a sphere on a tiled ground. Top left: clean. Top right: grey moiré over the ground and sphere. Bottom left: stronger stripes. Bottom right: clean again
Bias 0, normalBias 0 in all four. Closed FrontSide meshes are clean by default. DoubleSide plus a ground that casts puts the lit faces into the map: PCF turns the acne into grey moiré, BasicShadowMap into stripes. shadowSide: BackSide removes it here without any bias.

Because of the opposite-side rule, a scene of closed FrontSide meshes shows little acne even with bias = 0 (top left). Acne shows up when the lit side itself is in the map:

  • DoubleSide materials. glTF files often arrive with doubleSided: true, set by an exporter whenever backface culling was off in Blender. Both sides go into the map, so every lit face is compared with itself.
  • A double-sided floor or terrain with castShadow = true — usually from a traverse() that switches shadows on for every mesh.
  • VSM, which draws the material’s own side and also draws receivers into the map — see the VSM notes.
  • Near the terminator, the line where a curved surface turns from lit to unlit. Front and back faces are almost the same distance from the light there; on closed meshes this is the main remaining source.

Fix it in this order; each step has fewer side effects than the next:

  1. Closed meshes that are DoubleSide by accident: set material.shadowSide = THREE.BackSide. No runtime cost, and contacts stay where they are. Only for closed solids: a single plane (leaf, grass card, flag, cloth, thin panel) drawn back-face-only casts no shadow when its front faces the light. Those keep DoubleSide and rely on normalBias.
  2. Floors and terrain: a flat floor can usually be receive-only (castShadow = false), which keeps its lit faces out of the map. Terrain with hills, ledges or overhangs that should shade other surfaces keeps castShadow and goes on to the next two steps.
  3. normalBias for what is left: sloped and curved surfaces, cards. In our test scene one to two texels was enough; start there and adjust.
  4. A small negative bias, only if acne remains. Check its size as a depth offset, bias × (far − near).
// Closed solids that the exporter marked doubleSided. List them from
// your own model: a material name does not tell you a mesh is closed.
const CLOSED = new Set( [ 'Hull', 'Turret', 'Wheels' ] );

gltf.scene.traverse( ( o ) => {
  if ( ! o.isMesh ) return;
  o.receiveShadow = true;
  const list = Array.isArray( o.material ) ? o.material : [ o.material ];
  for ( const m of list ) {
    if ( m.side === THREE.DoubleSide && CLOSED.has( m.name ) ) {
      m.shadowSide = THREE.BackSide;
    }
  }
} );
// castShadow per mesh, by your own rule; a flat floor can stay receive-only
floor.castShadow = false;

bias × normalBias: the matrix in world units

A 4 by 4 grid of close-ups of a sphere and its shadow on the ground. The top-left tiles show grey moiré on the ground; tiles to the right and further down are clean
The worst case from above (DoubleSide, ground casting, PCF, 2048² map over 32 m = 1.6 cm per texel, near 1 / far 120). Rows: bias; columns: normalBias. Look at the ground left of the sphere and at the sphere’s shaded side. Click or tap the image to enlarge it.

In this scene a normalBias of about one texel clears acne on the flat ground by itself, while bias needs roughly 6 cm of depth offset to do the same at this sun angle. The same values, as world-space offsets:

SettingValueOffset in this sceneResult
bias−0.00011.2 cm of light-space depthAcne remains
bias−0.00056.0 cm of light-space depthClean; contacts still look attached
bias−0.00224 cm of light-space depthClean, but contacts start to detach; the gap on the ground depends on the light angle
normalBias0.011 cm along the normal = 0.6 texelClean on the ground; terminator acne stays on the sphere
normalBias0.033 cm along the normal = 1.9 texelsClean in this view
normalBias0.110 cm along the normal = 6.4 texelsClean; the shadow edges visibly move

Two consequences for this kind of setup. The normalBias that works scales with the texel, so when you change mapSize or the width of the shadow camera, re-derive it; 1–1.5 texels was our starting point, not a law. The bias that works scales with far − near, so a forum value like −0.0001 means 1 cm of depth in a scene with far 100 and 10 cm with far 1000. The light angle matters too, because grazing light raises the depth change per texel. Compare values as world-space offsets, not as raw numbers.

Peter-panning and thin casters

Three views of a box, the tank tracks and a sphere with their shadows. In the middle view a bright gap separates the box base and the sphere from their shadows
Top: −0.0005 with near 1 / far 120 is 6 cm of depth — contacts look attached. Middle: the same −0.0005 with far 1000 is 50 cm — the shadows float off. Bottom: normalBias 0.15 — the contact under the sphere opens up.

Too much bias pushes the shadow away from the caster, so the object seems to float above it. This is called peter-panning, after Peter Pan losing his shadow. The middle image shows the most common cause: the shadow camera’s far set large “to be safe”, with a bias copied from another scene. The fix is a tighter depth range rather than a smaller bias; it also improves depth precision:

  • near/far should enclose every caster and receiver along the light direction. Derive them from the scene’s bounds, not from the sun’s distance: transform the corners of the bounding box of everything that casts or receives into shadow-camera space and use the depth range with a small margin. The CameraHelper shows the result.
  • The shadow camera starts at the light. For a DirectionalLight, light.position sets the direction and also where the frustum begins: a sun 1000 m from its target needs far above 1000 just to reach the scene.
  • On flat ground normalBias moves contacts less than bias does, but it has side effects of its own (bottom image).

A large normalBias makes each receiver pixel sample the map at a point further out along its normal. Near a contact that point can fall outside the caster’s shadow, so the contact opens, as under the sphere. Where a shadow is narrow — an antenna, a fence rail — its edges move by a similar amount, and the shadow can thin out or break up. How much depends on the light and surface angles, so there is no fixed threshold. If you need a large normalBias to stop acne, the texels are usually too large; see the next section.

Pixelated shadows: texel size and the shadow camera

A cartoon village street with a stone tower, a fence and a pond; the tower and trees cast long shadows over the grass
The village from the Cozy Town pack, 142 × 124 m. Crops below are the lower-left corner of this view: tree, lamp post and tower shadows.
Four crops of the same tree and lamp-post shadows. Top left blurry and blocky, top right a little sharper, bottom row crisp
Same 2048² or 4096² map, different shadow camera. ±95 m covers the whole village at 9.3 cm per texel; doubling mapSize to 4096² halves that and quadruples the memory; a ±25 m camera around the view gives 2.4 cm with the same 2048². Bottom right: SunLight with two cascades.

A shadow map has a fixed number of texels; the shadow camera decides how much ground they are spread over. Texel size = (right − left) / mapSize.x. Covering the whole village with ±95 m at 2048² gives 9.3 cm per texel: a lamp post narrower than a texel can drop out of the map entirely, and every edge is a staircase of 9 cm steps blurred by the filter. Raising mapSize helps only linearly — 4096² costs four times the memory (64 MB as a 32-bit depth texture) for half the texel size — and many mobile GPUs cap textures at 4096. Shrinking the camera helps the same way without extra memory.

So size the shadow camera to what the player sees plus whatever throws a shadow into that area. The second part is easy to miss: a tree just outside the frame must still be in the shadow map to shade the visible road, and with a low sun its shadow is long. Extend the frustum towards the light, and upward for tall casters, by the longest shadow you expect, and make near/far cover those casters too. three.js’s own cascaded sun does the same: SunLightShadow raises its near plane by a full shadow range towards the light “so casters outside the view frustum still cast into it”.

For a game camera, centre the shadow camera on the player (or slightly ahead) and move it with them. Moving it by fractions of a texel makes edges crawl from frame to frame — shimmering — so snap its position to whole texels in light space. A sketch of the pattern; check it against your own light setup:

// every frame before render(); dir = unit vector towards the sun
const _rot = new THREE.Matrix4(), _inv = new THREE.Matrix4();
const _p = new THREE.Vector3(), _o = new THREE.Vector3();
function followShadow( sun, focus, dir, distance ) {
  const c = sun.shadow.camera;
  const texel = ( c.right - c.left ) / sun.shadow.mapSize.x;
  _rot.lookAt( dir, _o, THREE.Object3D.DEFAULT_UP ); // light axes
  _inv.copy( _rot ).transpose();                     // world → light
  _p.copy( focus ).applyMatrix4( _inv );
  _p.x = Math.round( _p.x / texel ) * texel;
  _p.y = Math.round( _p.y / texel ) * texel;
  _p.applyMatrix4( _rot );                           // light → world
  sun.target.position.copy( _p );
  sun.position.copy( _p ).addScaledVector( dir, distance );
}

For views that reach the horizon, one map cannot be both sharp near the camera and far-reaching; that is what cascaded shadow maps do: several maps, small near the camera and large further out. three.js r187 ships a cascaded SunLight in the addons (two cascades, fitted to the view camera and snapped automatically, with a fade between them); older releases have examples/jsm/csm/CSM.js, which patches materials. In our village the two-cascade light keeps street-level shadows as sharp as the ±25 m camera while covering 200 m.

import { SunLight } from 'three/addons/lights/SunLight.js';  // r187
const sun = new SunLight( 0xfff1dc, 3 );
sun.position.set( -0.87, 0.5, 0 );    // direction only; no target
sun.castShadow = true;
sun.shadow.mapSize.set( 2048, 2048 ); // per cascade
sun.shadow.camera.far = 200;          // shadow distance from the view
scene.add( sun );
// WebGPURenderer: SunLight.registerNode( SunLightNode ) first

SunLight’s docs advise against it for small bounded scenes such as dioramas or model viewers: most of the content ends up in the far, low-resolution cascade. For those, a fitted single shadow camera is better.

Basic, PCF, PCFSoft, VSM: what the filters do now

The tank, box and sphere seen from the shadow side; railing shadows fall on the ground in the middle
Where the zooms below come from: the railing shadow under the tank, 1024² map over 32 m (3.1 cm per texel) so that texels are visible.
Nine 2× zooms of a railing shadow edge with different filters: Basic is stair-stepped, PCF and PCFSoft are blurred differently, r187 PCF radius 3 is grainy, VSM is smooth and soft, PCSS is soft with grain
Nearest-neighbour 2× zooms. Look at the stair-steps along the rail edges and at the grain inside the penumbra. r180 PCF averages 17 depth comparisons at offsets scaled by radius; r180 PCFSoft is a bilinearly weighted 3×3 neighbourhood that ignores radius. r187 PCF is five rotated, hardware-filtered samples, grainy as the radius grows. VSM blurs the map itself: smooth, but the edge sits a little further out and detail is lost. PCSS is our demonstration shader (below). Click or tap the image to enlarge it.
Typer180–r181r182+ (WebGL)Use it for
BasicShadowMapOne comparison, nearestSamePixel-art looks; the cheapest sampling
PCFShadowMap17 comparisons, offsets × radius5 Vogel-disk samples, hardware compare with bilinear filtering, per-pixel rotation; radius in texelsThe default for most scenes
PCFSoftShadowMapBilinearly weighted 3×3 texels; ignores radiusDeprecated → PCF (broken in r182)Nothing new; migrate to PCF
VSMShadowMapMean/variance map + blur passesSame idea, native depth + blurWide soft shadows; static scenes; no point lights

r182 rewrote the WebGLRenderer filters. The map became a depth texture with hardware comparison (sampler2DShadow), and PCF went from 17 fixed comparisons (r181 shader) to five samples on a Vogel disk, each a hardware-filtered 2×2 comparison (r187 shader). PCFSoftShadowMap was deprecated because PCF is now soft by itself. Two things you can tune:

  • shadow.radius still scales the sample offsets in texels (default 1), as the old PCF did, so old radius values carry over roughly. Scenes that used PCFSoftShadowMap, which ignored radius, may see a change if they had set one.
  • Rotation: the five samples are rotated per pixel with interleaved gradient noise, which trades banding for fine grain: fine at radius 1–3, visibly noisy beyond that (see below).

VSM needs its own notes, because it breaks the rules above:

  • Receivers are drawn into the map too (the docs: “all shadow receivers will also cast shadows”), and with their own side, not the opposite one. Every lit surface is therefore in the map, and acne is expected until the depth range and bias are set for it.
  • The depth range comes first. With near 1 / far 120 the ground is striped. Near 40 / far 80 removes most of the stripes, and a bias of −0.002 (8 cm of depth at that range) clears what is left.
  • Light bleeding: where two occluders overlap at different depths, the variance estimate lets light through. three.js clips the lower 30 % of the estimate to hide it; a larger radius makes it worse.
  • Cost: two blur passes over the whole map, every time the map updates. blurSamples and map size both matter — see the measurements.
Three close-ups of the sphere with VSM: the first has stripes on the ground, the second is mostly clean, the third is clean
VSM, FrontSide materials, radius 4, 8 blur samples. Left: the default depth range stripes the ground. Middle: tight near/far. Right: tight near/far plus bias −0.002.

Soft shadows: radius, VSM, PCSS, or baking

Five crops of the sphere shadow: PCF radius 1 sharp, radius 4 softer, radius 10 grainy, VSM smooth and soft, PCSS sharp near the sphere and softer away from it
PCF radius 1 → 4 softens the edge; at 10 the five samples cannot cover the disk and the edge turns to grain. VSM blur is smooth and even. PCSS keeps the shadow sharp at the contact and softens it with distance.

Real shadows are sharp where the caster touches the receiver and soften with distance, in proportion to the light’s angular size (the sun: 0.53°). Your options, from cheapest:

  • PCF radius (r182+). Uniform blur, no extra passes; changing it does not re-render the map. Good up to 3–4; beyond that the five samples show grain, which TAA or a denoiser would have to hide.
  • VSM with radius and blurSamples. Smooth and uniform; pays for the blur whenever the map updates. Watch for bleeding and the depth range.
  • PCSS (percentage-closer soft shadows): search the map for the average blocker depth, then widen the filter by the blocker–receiver distance. three.js does not ship it; the old example patched ShaderChunk for pre-r182 PCF. On r182+ the PCF map is compared in hardware and cannot be read as depth, so a PCSS patch has to use BasicShadowMap, whose map is a plain depth texture. Ours is a demonstration patch of about 40 lines, below, with its limits.
  • Bake. For static lighting, baked lightmaps give soft shadows with no per-frame shadow cost, paid for with texture memory and a bake step. Baked AO darkens creases and contacts but does not replace a directional shadow. The forum thread settled on baking for static scenes on mobile; we did not measure phones.
The tank and box from the shadow side with PCSS: the railing shadow is crisp near the tracks and blurred further away; the antenna shadow fades along its length
PCSS on BasicShadowMap, 1024² map, 2° light: the railing shadow is crisp at the tracks and blurs with height; the antenna shadow blurs along its length.
// Demonstration PCSS: WebGLRenderer r187, one DirectionalLight.
// Patches the BASIC branch, whose map is a plain depth texture.
renderer.shadowMap.type = THREE.BasicShadowMap;  // before the first render
const texel = ( cam.right - cam.left ) / sun.shadow.mapSize.x;  // m
const tanL = Math.tan( THREE.MathUtils.degToRad( 2 ) );  // 2° light size
const k = ( cam.far - cam.near ) * tanL / texel;  // texels per unit depth

const pcss = /* glsl */`
vec2 vogel( int i, int n, float phi ) {
  float r = sqrt( ( float( i ) + 0.5 ) / float( n ) );
  float t = float( i ) * 2.399963 + phi;
  return vec2( cos( t ), sin( t ) ) * r;
}
float pcssShadow( sampler2D map, vec2 uv, float z, vec2 texel ) {
  vec2 q = vec2( 0.06711056, 0.00583715 );
  float ign = fract( 52.9829189 * fract( dot( gl_FragCoord.xy, q ) ) );
  float phi = ign * 6.2831853;            // per-pixel rotation
  // 1. average blocker depth within a fixed 24-texel radius
  float blocker = 0.0, n = 0.0;
  for ( int i = 0; i < 16; i ++ ) {
    vec2 o = vogel( i, 16, phi ) * 24.0 * texel;
    float d = textureLod( map, uv + o, 0.0 ).r;
    if ( d < z ) { blocker += d; n += 1.0; }
  }
  if ( n == 0.0 ) return 1.0;
  // 2. penumbra width from the blocker–receiver distance
  float dz = z - blocker / n;
  float pen = clamp( dz * ${ k.toFixed( 3 ) }, 0.0, 24.0 ) + 1.0;
  // 3. filter with that width
  float s = 0.0;
  for ( int i = 0; i < 24; i ++ ) {
    vec2 o = vogel( i, 24, phi + 1.7 ) * pen * texel;
    s += step( z, textureLod( map, uv + o, 0.0 ).r );
  }
  return s / 24.0;
}`;

const SEARCH = 'float depth = texture2D( shadowMap, shadowCoord.xy ).r;';
const chunk = THREE.ShaderChunk.shadowmap_pars_fragment;
if ( ! chunk.includes( SEARCH ) ) throw new Error( 'PCSS: chunk changed' );
THREE.ShaderChunk.shadowmap_pars_fragment =
  '#if defined( USE_SHADOWMAP ) && defined( SHADOWMAP_TYPE_BASIC )\n'
  + pcss + '\n#endif\n'
  + chunk.replace( SEARCH, 'float depth = 0.0;'
    + ' shadow = pcssShadow( shadowMap, shadowCoord.xy, shadowCoord.z,'
    + ' 1.0 / shadowMapSize ); return mix( 1.0, shadow, shadowIntensity );' );

Limits of this patch. It replaces the shadow function for every BasicShadowMap light in the app. k is computed from one shadow camera and map size and baked into the shader, so recompute it and recompile when either changes. The blocker search radius is fixed at 24 texels. It assumes an orthographic shadow camera (a directional light) and standard, not reversed, depth. We tested it on WebGLRenderer r187 only. Treat it as a starting point, not a drop-in feature.

Two cheaper tricks are worth more than any filter in many scenes. A static shadow map — renderer.shadowMap.autoUpdate = false, then renderer.shadowMap.needsUpdate = true whenever the sun or a caster moves — removes the shadow pass from frames where nothing changed; light.shadow.autoUpdate does the same for a single light. Camera-fitted cascades (SunLight, CSM) also need updating when the view camera moves, even in a fully static scene. And a soft contact shadow under dynamic objects (next section) gives the grounding that soft shadows are often wanted for, at a fixed cost.

Cheap ground shadows: blob, planar, projected contact shadow

Six renders of the same tank: no shadow, a soft elliptical blob, a sharp flattened projection, a soft contact shadow, a shadow-map shadow, and shadow map plus contact shadow
One T-90M, high sun. The blob and contact shadow ignore the sun direction; the planar projection follows it but is hard and flat; the shadow map is correct but needs the full pass; adding a contact shadow darkens the contact where the shadow map alone looks thin.

For characters, vehicles and props on a flat floor, often all you need is the cue that the object touches the ground. Three cheap techniques, usable with or instead of a shadow map:

  • Blob: one quad per object with a radial-gradient texture, transparent, depthWrite: false and polygonOffset. One draw call, no extra pass. It does not change with height or the light and only works on flat ground; because it is a quad at floor level, it does not go through a platform, which was the forum question.
  • Planar projection: draw every mesh a second time, flattened onto the ground along the light direction, in a translucent black material. Use the stencil buffer so overlapping triangles do not darken twice. Sharp and directional, but it doubles the draw calls and works on one plane only.
  • Projected contact shadow: the three.js webgl_shadow_contact example and drei’s <ContactShadows>. An orthographic camera looks up from below the floor and renders the scene’s depth into a small render target, which is blurred twice and drawn on a plane. This is not the screen-space contact shadow of some engines, which ray-marches the depth buffer towards each light. It fades with height (below) and costs one extra scene render each time it updates.
The tank floating 1.2 m above the ground with three techniques: the blob looks unchanged, the contact shadow is faint, the shadow-map shadow is offset
The tank lifted 1.2 m. The blob looks the same as on the ground — a false cue for jumps; the contact shadow fades; the shadow map shows the true offset.

The contact shadow has one trap that our measurements caught. renderer.render() runs the shadow pass whenever shadowMap.autoUpdate is on or needsUpdate is set (WebGLShadowMap) — also for the extra render into the contact-shadow target, a mirror, a cube camera or a minimap. In our 24-vehicle scene that redundant shadow update cost 6.3 ms of CPU per frame: the whole shadow pass a second time.

Which render should update the map? The contact render does not sample shadow maps at all (its override material is unlit), so for it the update is pure waste. For a plain DirectionalLight the map does not depend on the view camera, so it only matters that it is updated once per frame, after lights and casters have moved. Camera-fitted shadows — SunLight cascades, CSM — are fitted to the camera passed to render(), so let the main view update them; a secondary camera would fit them to the wrong view. A mirror or cube camera that does sample shadows can reuse the map if it renders after the map is current; otherwise it sees the previous frame’s map.

function renderContactShadow() {
  const sm = renderer.shadowMap;
  const prev = {
    auto: sm.autoUpdate, needs: sm.needsUpdate,
    target: renderer.getRenderTarget(),
    override: scene.overrideMaterial, background: scene.background
  };
  sm.autoUpdate = false;   // no shadow pass in this render() ...
  sm.needsUpdate = false;  // ... even if an update is pending
  try {
    scene.background = null;
    scene.overrideMaterial = depthMaterial; // alpha = (1 − depth) × darkness
    renderer.setRenderTarget( contactTarget );
    renderer.render( scene, contactCamera );
    blur( contactTarget );                  // horizontal + vertical pass
  } finally {
    scene.overrideMaterial = prev.override;
    scene.background = prev.background;
    renderer.setRenderTarget( prev.target );
    sm.autoUpdate = prev.auto;
    sm.needsUpdate = prev.needs;  // a pending update goes to the main view
  }
}
// static props: call once after loading; moving objects: every 1–3 frames

What shadows cost

24 vehicles (3,800 draw calls): CPU time of renderer.render() per frame

  • CPU: renderer.render()
0 ms10 ms20 ms30 msNo ground shadowCPU: renderer.render(), No ground shadow: 9.2 ms9.2 msBlob per vehicleCPU: renderer.render(), Blob per vehicle: 10 ms10 msContact, onceCPU: renderer.render(), Contact, once: 9.3 ms9.3 msMap 2048², staticCPU: renderer.render(), Map 2048², static: 10 ms10 msMap 2048², per frameCPU: renderer.render(), Map 2048², per frame: 17 ms17 msContact 512², per frameCPU: renderer.render(), Contact 512², per frame: 17 ms17 msContact 1024², per frameCPU: renderer.render(), Contact 1024², per frame: 18 ms18 msPlanar projectionCPU: renderer.render(), Planar projection: 20 ms20 msMap + contactCPU: renderer.render(), Map + contact: 24 ms24 msMap + contact, extra passCPU: renderer.render(), Map + contact, extra pass: 31 ms31 ms
Six military vehicle models × 4 on a ground plane, ~100 meshes each. “CPU” is the JavaScript time spent inside renderer.render() (scene traversal and command submission), not the whole frame; “GPU” comes from timer queries. Draw calls in the table are those of the main render() including the shadow pass; the contact-shadow render is a separate call with the same ~3,800. This scene is CPU-bound: GPU time tracks the CPU time within a millisecond, because the GPU waits for draw calls. The cost of a technique is estimated as the difference between its median and the “no ground shadow” median. Median of 120 frames × 3 page loads; min–max across loads in the table. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome 156, ANGLE → Direct3D 11, 1600×900.
All values
MethodCPU render()min–maxGPUDraw calls
No ground shadow9.2 ms8.9 ms–12 ms9.4 ms3801
Blob texture per vehicle10 ms9.4 ms–10 ms10 ms3825
Contact shadow 1024², rendered once9.3 ms9.3 ms–10 ms9.5 ms3802
Shadow map 2048², static10 ms10 ms–11 ms10 ms3801
Shadow map 2048², every frame17 ms16 ms–21 ms16 ms7601
Contact shadow 512², every frame17 ms16 ms–18 ms17 ms3802
Contact shadow 1024², every frame18 ms17 ms–18 ms18 ms3802
Planar projection (stencil)20 ms20 ms–23 ms19 ms7601
Shadow map + contact, every frame24 ms24 ms–25 ms24 ms7602
Same, but the contact render() runs the shadow pass again (redundant shadow update)31 ms30 ms–31 ms30 ms7602

In this scene the shadow pass costs CPU before it costs GPU. Turning on a 2048² shadow map with 24 vehicles doubled the draw calls (3,801 → 7,601) and added 7.5 ms of render() time; the same map kept static added 1.1 ms. The planar projection, which also draws everything twice, cost 11 ms. A per-frame contact shadow re-renders the whole fleet into its target (8.8 ms at 1024², 8.0 ms at 512² — here resolution barely matters, the draw calls do), while the same contact shadow rendered once costs 0.13 ms. Twenty-four blobs: 0.90 ms, within noise.

That is a property of this scene: thousands of draw calls with simple materials. Elsewhere the shadow pass may be limited by the GPU instead — map fill, dense geometry, alpha-tested casters, filtering, memory bandwidth. Measure it on both sides before choosing a fix: compare CPU time and GPU time with the shadow map updating every frame and kept static.

Village, one sun shadow: GPU time per frame by filter and map size (lower is better)

  • NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame
  • NVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off)
0 ms0.5 ms1 ms1.5 ms2 msBasic 1024²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, Basic 1024²: 0.85 ms0.85 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), Basic 1024²: 0.42 ms0.42 msBasic 2048²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, Basic 2048²: 0.85 ms0.85 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), Basic 2048²: 0.35 ms0.35 msBasic 4096²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, Basic 4096²: 0.87 ms0.87 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), Basic 4096²: 0.31 ms0.31 msPCF 1024²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, PCF 1024²: 1.0 ms1.0 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), PCF 1024²: 0.41 ms0.41 msPCF 2048²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, PCF 2048²: 0.92 ms0.92 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), PCF 2048²: 0.37 ms0.37 msPCF 4096²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, PCF 4096²: 0.91 ms0.91 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), PCF 4096²: 0.33 ms0.33 msVSM 1024²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, VSM 1024²: 0.99 ms0.99 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), VSM 1024²: 0.39 ms0.39 msVSM 2048²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, VSM 2048²: 1.1 ms1.1 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), VSM 2048²: 0.30 ms0.30 msVSM 4096²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, VSM 4096²: 1.8 ms1.8 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), VSM 4096²: 0.38 ms0.38 msPCSS 1024²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, PCSS 1024²: 1.2 ms1.2 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), PCSS 1024²: 0.65 ms0.65 msPCSS 2048²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, PCSS 2048²: 1.0 ms1.0 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), PCSS 2048²: 0.67 ms0.67 msPCSS 4096²NVIDIA GeForce RTX 4060 Laptop GPU, shadows every frame, PCSS 4096²: 1.2 ms1.2 msNVIDIA GeForce RTX 4060 Laptop GPU, static (autoUpdate off), PCSS 4096²: 0.69 ms0.69 ms
The Cozy Town village (142 × 124 m, 305 meshes), shadow camera ±95 m, camera at street level. GPU time from EXT_disjoint_timer_query_webgl2 around four back-to-back frames, divided by four; median of 120 frames, median of 3 page loads. “Static” renders the shadow map once (renderer.shadowMap.autoUpdate = false) and only samples it, so the difference between the two medians is our estimate of the shadow-map pass itself. VSM includes its two blur passes. NVIDIA GeForce RTX 4060 Laptop GPU, Chrome 156, ANGLE → Direct3D 11, 1600×900.
All values, with draw calls
ConfigGPU, every frameGPU, staticCPU render()Draw calls
none0.33 ms—0.40 ms188
basic-10240.85 ms0.42 ms0.93 ms563
pcf-10241.0 ms0.41 ms0.95 ms563
vsm-10240.99 ms0.39 ms1.0 ms565
pcss-10241.2 ms0.65 ms0.90 ms563
basic-20480.85 ms0.35 ms0.93 ms563
pcf-20480.92 ms0.37 ms0.93 ms563
vsm-20481.1 ms0.30 ms0.95 ms565
pcss-20481.0 ms0.67 ms0.90 ms563
basic-40960.87 ms0.31 ms0.90 ms563
pcf-40960.91 ms0.33 ms0.93 ms563
vsm-40961.8 ms0.38 ms0.93 ms565
pcss-40961.2 ms0.69 ms0.90 ms563
pcf-81921.00 ms0.28 ms0.95 ms563
vsm-2048-r161.3 ms—0.97 ms565
r180-pcf-20480.91 ms—1.0 ms188
r180-pcfsoft-20480.94 ms—0.95 ms188
r180-basic-20481.3 ms—1.0 ms188
csm-20481.5 ms—1.3 ms870

With few casters, the picture changes. In the village (305 meshes) every configuration on the NVIDIA GeForce RTX 4060 Laptop GPU stayed around a millisecond: the shadow pass for PCF 2048² took 0.55 ms of GPU time, and going from 1024² to 4096² changed it by 0.06 ms. VSM paid for its blur at 4096² (1.4 ms for the pass), and PCSS was the only filter whose sampling cost showed up in the static bars (0.67 ms vs 0.37 ms for PCF). Note also the draw calls: the main view culls most of the village (188 calls), the ±95 m shadow camera culls almost nothing (375 more calls in the shadow pass). A fitted shadow camera saves CPU as well as texels.

These are one laptop’s numbers (NVIDIA GeForce RTX 4060 Laptop GPU, Chrome 156, ANGLE → Direct3D 11, 1600×900). Frame times on this machine drift by up to 30 % between sessions; compare bars within one chart, not across articles. We did not measure mobile GPUs; the fill-heavy options (large maps, VSM blur, PCSS) are the ones most likely to rank differently there.

Release bug: PCFSoftShadowMap looks pixelated in r182

Four crops of the same shadows on grass. The r182 PCFSoftShadowMap tile has hard stair-stepped edges; the others are smooth
The forum thread’s own settings (map 4096², ±50 m, bias −0.0001, normalBias 0.005, near 1, far 300), three releases. Only r182 with PCFSoftShadowMap shows hard, aliased edges; PCFShadowMap on r182 is fine.

If shadows turned blocky the day you updated to three.js r182, this is why. r182 rewrote the WebGL shadow filters (described above) and deprecated PCFSoftShadowMap. The deprecation was meant to switch it to PCFShadowMap automatically, but the check read the wrong object:

// three.js r182, WebGLShadowMap.render( lights, scene, camera )
if ( lights.type === PCFSoftShadowMap ) {  // `lights` is the light array
  warn( 'WebGLShadowMap: PCFSoftShadowMap has been deprecated. ...' );
  lights.type = PCFShadowMap;
}
// r183: if ( this.type === PCFSoftShadowMap ) { ... }

With no switch, the type stayed at 2. The new shader table only knows PCF and VSM, so the program was compiled as SHADOWMAP_TYPE_BASIC, and the depth texture got nearest filtering with no comparison. Result: unfiltered shadows, no warning in the console. This was reported as issue #32591 and fixed in PR #32593 for r183. On r182, set renderer.shadowMap.type = THREE.PCFShadowMap. On r183 and later PCFSoftShadowMap works but logs a deprecation warning (r187: “has been removed”), so change it anyway.

Release bug: acne after switching to WebGPURenderer (r182)

Four close-ups of the sphere on the ground. WebGPU r182 shows a sawtooth pattern on the sphere and ground; the other three are clean
Bias 0, normalBias 0, FrontSide materials — the settings from the forum thread. WebGPURenderer r182 shows sawtooth acne and hard edges; r183, r187 and WebGLRenderer are clean with the same settings.
Two crops of village shadows rendered by WebGPU. r182 has stair-stepped edges and a faint pattern on the grass; r187 is smooth
PCFShadowMap in WebGPURenderer: r182 averages its five samples but lacks hardware bilinear filtering of each one, so edges are harder and the grass shows acne; r187 has smoother edges.

“Severe shadow acne after switching to WebGPURenderer”: the same scene, same bias, clean in WebGL and full of sawtooth in WebGPU. This was a release bug too. According to the fix, PR #32705 (issue #32698), the main cause was the side drawn into the shadow map: r182’s WebGPURenderer drew material.side — the lit front faces — where WebGLRenderer draws the opposite side. That is the self-shadowing case from the acne section, on every FrontSide mesh. The same PR made other changes that show in our images:

  • depth textures for PCF got linear filtering, which is why r182 edges in the village are hard;
  • the depth comparison changed from Less to LessEqual, as in WebGLRenderer;
  • blending is disabled in the shadow pass.

Separately, r183’s ShadowNode no longer remaps the receiver depth from [0, 1] to [−1, 1] for the WebGPU backend, which r182 did.

Practical consequence: on r182, update before you tune. The forum workaround — normalBias = 2 — hides the pattern by moving every lookup two metres along the normal, which detaches contacts and breaks up small shadows, as in the peter-panning section. After the fix, the same bias values work in both renderers in our test scene. Two WebGPU details still differ: the r187 node shadow has no PCFSoftShadowMap entry at all (setting it gives no filtering function), and VSM in Safari showed a line at the shadow-map edge (#32708).

Checklist

  1. Set castShadow only on what should cast; flat floors can usually stay receive-only.
  2. Fit the shadow camera to the visible area plus the casters that shade it; follow the view with texel snapping, or use cascades for long views.
  3. Fit near/far to the casters and receivers along the light, not to the sun’s distance.
  4. DoubleSide closed meshes: shadowSide = THREE.BackSide. Open cards keep DoubleSide.
  5. Compute the texel size; start normalBias at about 1–1.5 texels and adjust.
  6. Add bias only if acne remains, and check its depth offset, bias × (far − near).
  7. r182+: PCFShadowMap with radius 1–3 instead of PCFSoftShadowMap. WebGPU: r183 or later.
  8. VSM: tight depth range first, then bias; watch for bleeding.
  9. Static scenes: shadowMap.autoUpdate = false + needsUpdate on change, or bake.
  10. Keep secondary render() calls that do not need a fresh map (contact shadows, minimaps) from running the shadow pass again.
  11. For grounding only: blob or a once-rendered contact shadow before a full shadow map.
  12. Measure the shadow pass on CPU and GPU separately before optimising.

Frequently asked questions

What causes shadow acne in three.js?

A lit surface that is also in the shadow map is compared with its own depth. The map stores one depth per texel, so between texel centres the surface’s true depth can come out slightly behind the stored one, and the surface shadows itself in stripes or moiré. three.js avoids most of this by drawing the opposite side into the map; acne appears with DoubleSide materials, floors that cast, VSM, and near the terminator of curved surfaces. Fix it with shadowSide = BackSide on closed meshes, then a small normalBias, then a small bias.

What is the difference between bias and normalBias?

bias is added to the receiver’s depth in shadow-map space; for a directional light’s orthographic shadow camera it is a depth offset of bias × (far − near) along the light. normalBias moves the point looked up in the map along the receiver’s normal, in world units; the casters in the map do not change. normalBias clears acne on sloped surfaces with less depth offset, but it moves shadow edges and can open contacts.

Why are my three.js shadows pixelated?

One shadow-map texel covers too much ground. Texel size is the shadow camera width divided by mapSize; fit left/right/top/bottom to the visible area plus off-screen casters, or use cascades, before raising mapSize. On r182 specifically, PCFSoftShadowMap fell back to unfiltered shadows — use PCFShadowMap.

Is PCFSoftShadowMap deprecated?

Yes. Since r182 PCFShadowMap uses five rotated, hardware-filtered samples and is soft by itself; tune it with shadow.radius. r183+ maps PCFSoftShadowMap to PCF with a warning; r182 had a bug and rendered it without filtering.

How do I get soft shadows in three.js?

PCF with radius 2–4 for mild softness, VSM with radius and blurSamples for wide uniform softness, or a custom PCSS shader for shadows that are sharp at the contact. For static lighting, consider baking. Under moving objects, a blurred contact shadow is often enough.

What is the cheapest shadow under a character?

A quad with a radial-gradient texture: one draw call, no extra pass. A contact shadow rendered once is nearly as cheap for static props; rendering it every frame costs one extra scene render.

Sources

More deep dives

ALL DEEP DIVES →