Vol. 07 · Dispatch2026-07-13

Four bugs that all looked like shadow acne

A road-surface flicker in our Infinitown gamecenter port survived every shadow-parameter tweak we threw at it — bias, normalBias, PCF radius, a 4096² shadow map, snap-tracked light. It turned out to be four separate depth-buffer bugs stacked on top of each other, only the last of which was a shadow problem. This is the debugging log — what we tried, what the symptom kept telling us, and the diagnostic toggles that finally isolated each layer.

by MetaWorldOS Engineering
Three.jsWebGLDepth BufferShadow MappingZ-fighting

TL;DR — Fine banded flicker on the road in our Infinitown port looked exactly like shadow acne, so we spent a lot of time on shadow tuning. Turning shadows off entirely was the diagnostic that broke the case: the flicker persisted. The real cause was four independent depth-buffer conflicts stacked on top of one another — (1) gltf building pads overhanging into the road ±20…±30 lane strip with alpha-blended edges, (2) adjacent chunks emitting the same lane at the same world coordinates, (3) each chunk’s intersection tile coplanar with its own merged road, and (4) logarithmicDepthBuffer: true interacting badly with polygonOffset during camera motion. Each fix reduced the symptom by ~30% and made the remainder easier to diagnose. Full write-up of each layer and the toggles we used to identify it.

Code: src/features/gamecenter/games/infinitown/**.

Our gamecenter has ~15 mini-games at this point. The newest is an Infinitown port — an infinitely-tiling 3D toy city that you drag around from a bird’s-eye view, with cars weaving through the streets and a directional light casting soft shadows on the road. The original is a Vite + vanilla-three.js project; porting it into our Next.js + React 19 + [email protected] shell was mechanically straightforward.

Then we swapped the buildings from the game’s baked main.json format to individual .gltf files loaded through a shared GLTFLoader helper. Everything rendered. And immediately the road showed a fine banded flicker — thin dark stripes racing across the asphalt when the camera moved, and even when it didn’t.

The flicker looked exactly like shadow acne. So we did what you do to shadow acne. We tuned bias. Tuned normalBias. Bumped the shadow map to 4096². Softened the PCF kernel. Moved the directional light out of the moving chunk container to stop shadow-map swimming and snap-tracked it to the visible area on a 1-unit world grid. Made the shadow frustum dynamic so texel density scaled with the camera height.

None of it fully fixed the flicker.

The symptom that finally broke it: turning shadows off entirely didn’t help. That’s when we knew we’d been debugging the wrong thing.

Layer 1: the polite one that hid the others

The gltf buildings ship with their own ground pads — flat rectangular tiles that the building sits on, with grass or paving textured onto them. We were sizing every building to occupy a 45-unit footprint inside the 60-unit chunk cell. Roads sit at ±30 from chunk center.

That leaves 7.5 units of margin on each side. Except the lane geometry in Infinitown isn’t a hairline stripe along y=±30 — the road tiles are wide, and each lane’s actual mesh extends inward maybe 10 units. So the “road” occupies roughly ±20 to ±30. A 45-unit building pad reaches ±22.5. The pad overhangs the road by ~2.5 units on each side.

At the overhang, the pad is 0.05 world units above the road (we lifted gltf buildings by 0.05 to avoid coplanar with the y=0 road plane). That’s plenty of depth separation. So it shouldn’t Z-fight.

But the pad has alpha-blended fringe edges. Not fully-transparent — semi-transparent grass fade at the boundary. When alpha blending renders coplanar-ish geometry into an antialiased frame with subpixel jitter, you don’t get Z-fighting exactly. You get stripes. Fine banded stripes that look identical to shadow acne, because both come from tiny per-pixel depth or alpha decisions oscillating with camera motion.

The fix: shrink the footprint so the pad can’t overhang.

// src/features/gamecenter/games/infinitown/engine/loader/GltfBlockLoader.ts
// Chunk is 60 units on a side; roads sit at ±30 and lane geometry
// extends ~10 units inward from that boundary, occupying ±20..±30.
// Building footprint must therefore fit inside ±20 (max width 40)
// or its ground pad overhangs the road and produces stripey blend
// artefacts where pad-edge alpha bleeds into the road texture.
const CHUNK_TARGET = 36
const MAX_HORIZONTAL = 40

We also lifted the entire building by 0.5 world units instead of 0.05. From a bird’s-eye camera at y≥180 that’s less than a pixel of visible offset, but it puts an unambiguous gap between the pad and any road surface.

Flicker went from “everywhere on the road” to “mostly at the road, still lots of it, mainly where chunks meet”. Progress, but not done.

Layer 2: the chunks quietly duplicating road

Infinitown tiles chunks in a 9×9 grid that scrolls with the camera. Each chunk is 60 units square, generated with four lane segments — one along the north edge, one south, one east, one west, all at the chunk’s ±30 boundary. Inside each chunk those four lanes get merged into a single mesh (via mergeGeometries).

Adjacent chunks are placed 60 units apart, back to back. Which means:

  • Chunk (0, 0) emits an east lane at its local x = +30, which lands at world x = chunk0_center + 30.
  • Chunk (1, 0) emits a west lane at its local x = -30, which lands at world x = chunk1_center - 30 = (chunk0_center + 60) - 30 = chunk0_center + 30.

Same world position. Two coplanar road tiles at exactly the same y. Classic Z-fighting geometry. Depending on subpixel sampling and view angle, one wins some pixels, the other wins others → visible stripes.

The clean fix would be to have each chunk only emit lanes on two sides, so shared boundaries have exactly one lane. That would require rewiring the car-spawn logic that piggybacks on the lane array. We went cheaper.

// src/features/gamecenter/games/infinitown/engine/core/SceneManager.ts
protected refreshChunkScene(): void {
    this.chunkScene!.forEachChunk((chunkContainer, xOffset, yOffset) => {
        const xcor = this.gridCoords.x + xOffset
        const ycor = this.gridCoords.y + yOffset
        const v = this.cityChkTbl!.getChunkData(xcor, ycor)
        if (!v) return
        chunkContainer.remove(chunkContainer.getObjectByName("chunk"))
        chunkContainer.add(v.node)
        // Anti-Z-fight for the shared road edge between adjacent chunks:
        // nudge every second chunk up 0.01 units on a checker pattern so
        // no two boundary lanes are coplanar.
        const parity = (((xcor + ycor) % 2) + 2) % 2
        chunkContainer.position.y = parity === 0 ? 0 : 0.01
    })
}

Checkerboard the whole chunk y. Adjacent chunks always differ in (x+y) parity by 1, so they always end up at different heights. 0.01 units is invisible from the bird’s-eye camera but wins the depth test consistently.

This got us from “obvious stripes everywhere” to “subtle stripes at intersections, and only when the camera moves”. Two down.

Layer 3: intersections stepping on lanes

Each chunk also drops an intersection tile at one corner (-30, 0, 30). The intersection is horizontal road-surface geometry too, at the same y=0 as the merged road that surrounds it.

Same problem, smaller scale. Where the intersection tile overlaps the lane merge at the corner, two coplanar surfaces fight. And unlike the cross-chunk case, the parity fix from Layer 2 doesn’t help: intersection and lane are inside the same chunk, at the same y, they get the same parity offset.

// src/features/gamecenter/games/infinitown/engine/core/CityChunkTbl.ts
const r = MiscFunc.getRandElement(this.intersections).clone()
// Lift intersections a hair above the merged road plane so the
// corner tile doesn't Z-fight the lane geometry that also lands
// near y=0. Sub-perceptual from the bird's-eye camera.
r.position.set(-30, 0.02, 30)
chunkIns.add(r)

// Push the merged road plane on the depth axis so intra-merge
// coplanar triangles (from applyMatrix4 shuffling the 4 lane
// pieces into overlapping regions) resolve consistently.
if (result.material) {
    const mat = result.material
    mat.polygonOffset = true
    mat.polygonOffsetFactor = 1
    mat.polygonOffsetUnits = 1
}

Two moves on this layer:

  1. Intersection lifted 0.02 units. Higher than the parity nudge (0.01), so it wins over both parity classes of neighbouring chunk roads without needing case analysis.
  2. polygonOffset on the merged road material. The merge concatenates 4 lane geometries, some of which have applyMatrix4 translations that place their triangles in overlapping regions. Even within a single merged mesh, coplanar tris fight each other. polygonOffsetFactor: 1 pushes the whole merged mesh slightly deeper in the depth buffer, giving a consistent winner at any near-coplanar tris — including the intersection above.

Better still. Now flicker only appears when the camera rotates or zooms, and specifically at the building-to-road boundary.

Three layers of geometry issues, and it looked like shadow acne throughout.

Layer 4: the depth buffer setting nobody remembers turning on

At this point the geometry is clean. Buildings don’t overlap roads. Adjacent chunks don’t emit the same lane. Intersections don’t stack on lanes. The remaining flicker is motion-only and lives at building/road boundaries.

That’s a very specific fingerprint. Static frames = clean. Motion = shimmer. Nothing to do with what’s rendered — has to do with how fragments are being ordered as the projection matrix changes frame to frame.

Ran through the renderer options:

this.renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    powerPreference: "high-performance",
    logarithmicDepthBuffer: true,   // <- this
})

logarithmicDepthBuffer: true. Three.js’s log-depth mode. It changes how fragments write to the depth buffer — instead of the standard linear-in-clip-space mapping, it uses a logarithmic mapping that trades near-plane precision for far-plane precision. Useful when your scene has a huge dynamic range (planetary, astronomical) and you’re getting Z-fighting because near-far ratio is like 10⁸.

Our scene has near=10, far=400. Dynamic range of 40. Standard 24-bit depth buffer resolves ~5×10⁻⁶ world units at that range. We don’t need log depth.

And three.js implements log depth by writing gl_FragDepth per-fragment in shaders. That interacts poorly with polygonOffset (which manipulates depth values at the rasterizer level, before the frag shader), and worse, it interacts with camera motion: as the projection matrix changes frame-to-frame, the log-depth values shift subtly at near-coplanar geometry boundaries. Result: edge shimmer that appears only when the camera moves. Which is exactly what we were seeing.

this.renderer = new THREE.WebGLRenderer({
    antialias: true,
    alpha: true,
    powerPreference: "high-performance",
    // logarithmicDepthBuffer trades near-plane precision for far,
    // and it interacts poorly with polygonOffset — during camera
    // motion, tris at close-but-not-coplanar depths (like our
    // 0.5-unit-lifted building pads vs the road) shimmer at
    // their edges. With near=10 / far=400 the standard depth
    // buffer has ample precision; log depth is unnecessary here.
    logarithmicDepthBuffer: false,
})

One flag flip. Flicker completely gone. Performance also slightly better because we’re not writing gl_FragDepth per fragment across the whole scene.

The diagnostic that broke the case

We got here by way of a boolean toggle. When shadow tuning kept not helping, we introduced a fully off switch:

// GVar.ts
public static bGltfShadowsOff : boolean = true
// GltfBlockLoader.ts — enableShadows()
if (shadowsOff) {
    model.traverse(obj => {
        const m = obj as THREE.Mesh
        if (m.isMesh) {
            m.castShadow = false
            m.receiveShadow = false
        }
    })
    return
}

Flipped it on. Reloaded. Stripes still there.

That’s the moment the actual debugging started, because we finally had proof that we’d been tuning the wrong subsystem. Every prior “fix” — bias, normalBias, PCF radius, 4096² map, snap-tracked light — was compensating for a symptom we thought was shadow-related. When shadows dropped out and stripes stayed, the only thing left to blame was geometry and depth.

Diagnostic toggles are worth their weight in dev cycles. If you can turn a subsystem completely off with a boolean and the bug is still there, you know it’s not that subsystem. Cheaper than the hours we spent shrinking the shadow frustum dynamically.

What we’ll clean up

Some of the tuning we did before finding the real bugs is now overkill:

  • SHADOWMAP_RESOLUTION = 4096 on desktop is more than we need. Real cause was never shadow texel size; can drop back to 2048 and save ~48 MB VRAM.
  • bias = -0.002 / normalBias = 0.12 / radius = 4 are aggressive settings from when we were fighting phantom acne. Standard values (bias = -0.001 / normalBias = 0.02 / radius = 1) should be fine now.
  • The per-frame dirLight snap-track in SceneManager.update() was added to fix shadow-map swimming (real, but subtle). Not doing much now that flicker’s gone; can remove and rely on the light being attached to the root scene.
  • bGltfShadowsOff should flip back to false and be deleted once we confirm buildings-with-shadows renders cleanly against the fixed geometry.

We’ll leave those cleanups to a follow-up PR so they land as a single “post-mortem cleanup” commit against a scene that’s proven stable.

What we learned (the general form)

Three things worth writing down:

  1. Fine banded flicker on flat surfaces has many causes that look identical. Shadow acne, Z-fighting, coplanar alpha blending, and log-depth precision jitter all produce the same visual — narrow stripes racing across a surface. Don’t assume the symptom’s fingerprint tells you the cause. Prove it with a diagnostic that isolates each subsystem.

  2. When a “fix” reduces a symptom but doesn’t eliminate it, be suspicious. Every shadow tweak we did reduced the flicker a bit, because acne compounds with other depth issues; softening acne with normalBias made the underlying Z-fight less contrasty, which we misread as progress. If the fix doesn’t fully close the bug, the fix might not be addressing the actual root cause.

  3. logarithmicDepthBuffer is a Chekhov’s gun. It’s opt-in, it’s for very-large-scale scenes, and it has documented interaction issues with polygonOffset and shadow mapping. If your scene doesn’t need it — and unless you’re rendering solar systems, it doesn’t — leave it off. If you’re debugging depth artifacts and don’t remember whether it’s on, check.

The bug is fixed. The mini-game runs. And Infinitown, from bird’s-eye view, is stable.

Get an email when we publish a new post — engineering deep-dives, ~once a month, no marketing.