Three.js Shadow Acne: Fixing Concentric Stripes on a Bowl
In a dice game I built with Three.js, the inside of the bowl always showed concentric stripes, like wood grain. The bowl had no texture at all, just a single-color material.
The short answer: the stripes were shadow acne, an artifact from an object casting a shadow onto itself. Setting castShadow = false on the bowl removed them most cleanly. shadow.bias and shadow.normalBias also removed the stripes, but at the cost of jagged edges or a darker interior. Raising the shadow map resolution alone did not remove them.
This article reproduces the bowl, compares the result of each setting, and fixes the hot spot that became visible once the stripes were gone.
What causes concentric stripes on a Three.js bowl?
The bowl was casting a shadow onto itself, and small errors in that shadow calculation showed up as stripes on the curved surface. This is called shadow acne.
This is the bowl from Play 3D Chinchirorin in Your Browser, reproduced with the same settings in three.js r128. The concentric stripes inside the bowl are clearly visible.
Three.js shadows work by recording depth from the light’s point of view in a texture called a shadow map. When drawing a surface, it compares that surface’s depth with the recorded depth to decide whether the point is in shadow. On a surface that shadows itself, precision limits make this comparison flip between “in shadow” and “lit”. On a flat surface you get fine noise, but on a curved bowl it appears as rings.
Here are the bowl and light settings I used. The light is a PointLight, whose shadows use a six-sided cube shadow map.
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const light1 = new THREE.PointLight(0xffffff, 1.0, 100);
light1.position.set(2, 6, 6);
light1.castShadow = true; // shadow.bias stays at the default 0, shadow.mapSize at the default 512
scene.add(light1);
const geometry = new THREE.SphereGeometry(2, 64, 64, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2);
const material = new THREE.MeshStandardMaterial({
color: 0xd2b48c, roughness: 0.4, metalness: 0.2,
side: THREE.DoubleSide, transparent: true, opacity: 0.95,
});
const bowl = new THREE.Mesh(geometry, material);
bowl.castShadow = true; // the bowl casts shadows
bowl.receiveShadow = true; // the bowl receives shadows
scene.add(bowl);
The same object having both castShadow and receiveShadow set to true, with shadow.bias left at 0, is the recipe for shadow acne.
Does shadow acne still happen in the latest three.js?
Yes. In r186, the latest release as of September 2026, the pattern changed to fine horizontal stripes, but the acne remained.
Light intensity units changed in r155 and later, so I multiplied the intensities by Math.PI and set the PointLight decay to 0 to get close to the r128 look. The stripe pattern differs, but the cause and the fixes are the same.
How does each setting change the stripes?
Turning off the bowl’s castShadow was the only setting that removed the stripes without changing how the bowl looked.
These are the results in r128, changing one setting at a time.
| Setting changed | Stripes | Other effects |
|---|---|---|
shadow.mapSize to 2048 (resolution only) |
Remain | Stripes just get finer |
shadow.bias = -0.0015, mapSize 1024 |
Gone | Stair-step jaggies on the lower-right rim |
shadow.normalBias = 0.02 |
Gone | The whole inside of the bowl gets darker |
shadow.normalBias = 0.05 |
Gone | Darker inside, jaggies on the right rim |
Bowl castShadow = false |
Gone | None (the die still casts its shadow onto the bowl) |
Why doesn’t a higher shadow map resolution fix it?
A higher resolution only makes each stripe thinner. The self-comparison error does not go away.
This image raises mapSize from the default 512 to 2048. The rings multiplied and became thinner, but they are still there. A larger shadow map also costs more to render, so it is a poor choice for fixing acne.
What remains after fixing it with shadow.bias?
The stripes disappeared, but the shadow boundary shifted and left jagged steps on the bowl’s rim.
shadow.bias offsets the depth comparison slightly so that a surface is less likely to count as its own shadow. A small negative value removed the stripes. But the edge of the shadow the bowl casts on itself also moved, leaving visible stair steps on the lower-right rim.
What remains after fixing it with shadow.normalBias?
The stripes disappeared, but the whole inside of the bowl darkened as if it were in shadow.
shadow.normalBias moves the point used to query the shadow map along the surface normal. The three.js documentation says it helps reduce shadow acne, especially where light hits geometry at a shallow angle. At 0.02, the stripes were gone, but almost the entire inside of the bowl turned dark, and the bowl no longer looked like itself. My guess is that on a concave surface, the offset point falls into the shadow of the rim, but I did not verify that.
When is it safe to turn off castShadow?
When the object does not need to cast shadows on anything else. You can keep receiveShadow set to true.
The bowl is the outermost object, and nothing else receives its shadow. Turning off its castShadow loses nothing, and with receiveShadow still true, the die’s shadow still falls on the bowl.
This image combines castShadow = false with shadow.bias = -0.0015 and a mapSize of 1024. castShadow = false alone also removed the stripes, but at the default mapSize of 512, the edge of the die’s shadow looked a little rough.
▼How to choose a shadow acne fix
▶ The object does not need to cast shadows on others → set its castShadow to false
▶ It must cast shadows too → try small values of shadow.bias or shadow.normalBias, and check rims and concave areas
▶ Use resolution (mapSize) to sharpen shadow edges, not to fix stripes
How do you fix a hot spot after removing the stripes?
Moving the lights farther away, lowering their intensity and making up the difference with ambient light removed the blown-out spot at the bottom of the bowl.
Once the stripes were gone and the surface looked smooth, the blown-out bottom of the bowl became obvious. A PointLight gets dimmer with distance, so the bottom of the bowl, closest to the light, was far brighter than everything else. The stripes had been hiding it.
light1.position.set(3, 11, 9); // moved away from (2, 6, 6)
light1.intensity = 0.75; // lowered from 1.0
light2.position.set(-3, 7, -6); // moved away from (-2, 4, -4)
light2.intensity = 0.55; // lowered from 0.8
ambientLight.intensity = 0.85; // raised from 0.6 to make up for the darkness
material.roughness = 0.62; // raised from 0.4 for a wider, softer highlight
material.metalness = 0.06; // lowered from 0.2
The direction of the light (upper right, in front) did not change, so the shading keeps its character and the die’s shadow still falls in the same place.
Why did I roll back a technically correct fix?
The stripes and the hot spot were gone, but the bowl no longer looked like the original, so I restored the original settings in the app.
I applied these changes once to the iOS version of the game, which uses the same bowl. The stripes and the hot spot disappeared, so the rendering was technically correct. But side by side with the original, the bowl looked like a different object, and the first impression of the game changed.
Rendering correctness and how the game feels to players are separate concerns. Which one wins depends on the project, and this time I chose to keep the original look. When I change shadow settings now, I compare the whole scene side by side, not only whether the stripes are gone.
Related articles
▶ Play 3D Chinchirorin in Your Browser – A Dice Game Built with Three.js & Ammo.js…how the game with this bowl was built
▶ Building a Repelling Cursor Effect…an example that combines the effect with Three.js particles
Summary
▼Key points
① Concentric stripes on the bowl were shadow acne from the bowl shadowing itself
② The trigger is castShadow and receiveShadow both true, with shadow.bias left at 0
③ A higher resolution alone does not fix it. The stripes just get finer
④ If the object does not need to cast shadows, castShadow = false removes the stripes most cleanly
⑤ bias and normalBias come with trade-offs: jagged rims or a darker concave surface
I realized the stripes came from shadows, not a texture, only because the bowl had no texture. If a single-color object shows a pattern, check its castShadow and receiveShadow combination first.
Rendered with three.js r128 (UMD build) and r186 (ES modules) in Chrome 152 headless mode (SwiftShader) on September 12, 2026. How the stripes look depends on your GPU and screen resolution.