TL;DR On a choropleth globe (ear-clipped province plates + MeshStandardMaterial + UnrealBloomPass), camera orbiting produced single-frame bright wedge flashes — individual sliver triangles of the triangulation lighting up for one frame (~188 events/min). Nothing in the usual anti-aliasing playbook fixed it (FXAA, MSAA render target, metalness=0, FrontSide, radial normals, specularIntensity=0). The discriminator turned out to be lit vs unlit: the lit plate’s luminance sits at the bloom high-pass threshold, so sub-pixel rasterization jitter of sliver triangles crosses it frame-by-frame and bloom’s blur paints the whole thin triangle. Baking the (static) lighting into vertex colors and rendering the plate unlit took it from 188 → 0 events/min with no visual regression.
Setup
- three r160, WebGL2 through ANGLE/D3D11 on Intel UHD Graphics (integrated).
- Composer chain: RenderPass → UnrealBloomPass(strength .38, radius .55, threshold .82, smoothWidth .2) → OutputPass → ShaderPass(FXAA).
- Globe plates built from GeoJSON polygons via ShapeGeometry (earcut triangulation), projected onto a sphere; MeshStandardMaterial({vertexColors, transparent, opacity .96}).
- Lights and geometry are static in world space; only the camera orbits (~103 s/rev).
Symptom
During orbit: single-frame (16–25 ms) bright flashes every 0.1–0.4 s. Frame-accurate capture shows each flash is one sliver triangle of a province plate, jumping dark-violet ↔ near-white/magenta for a frame; bloom then reads as a solid white wedge (up to ~144×112 px).
How we caught it (method, reusable)
preserveDrawingBuffer:false, so readback must happen inside a rAF callback registered after the app’s render:
- each frame, drawImage the WebGL canvas into a 2D canvas, downscale to a luminance grid;
- frame-diff vs previous frame, connected components with |Δlum| > 0.10 and area ≥ 400 px;
- on an event, crop that region at full resolution in the same frame and export it — this is what identified the wedge as a single triangle.
Two traps worth sharing:
- MSAA silent failure: new WebGLRenderTarget(w, h, { type: HalfFloatType, samples: 4 }) produced a silently black canvas on this Intel/ANGLE path (gl.getError() === 0, CSS2D labels still fine). A/B confirmed samples=4 → black, samples=0 → fine.
- False-positive verification: a black canvas yields zero flash events. Any flash metric must first prove the canvas is rendering (mean luminance, fraction of lit pixels, no all-black frames) — we nearly shipped a “fix” that was just a black screen.
What did NOT work (A/B, same detector, 60 s orbit each, hard reload per mode)
| mode | change | block events/min |
|---|---|---|
| base | as-is (lit MeshStandardMaterial) | 188 |
| front | side = FrontSide | 207 |
| radial | normals replaced by radial (normalize(position)) | 245 |
| front+radial | both | 171 |
| nospec | specularIntensity = 0, roughness = 1 | 191 |
| unlit | plate → MeshBasicMaterial({vertexColors}) | 0 |
So it is not culling, not normal interpolation, not the specular lobe. FXAA reduced thin-line shimmer but not the blocks; smoothWidth softening alone didn’t remove them either.
Root cause (working model)
Because lights and geometry are static in world space, the per-vertex diffuse term is constant; what changes per frame is rasterization. The lit plate’s linear luminance lands at/above the bloom high-pass threshold (0.82) and near the ACES knee. As the camera orbits, sub-pixel coverage of long sliver triangles jitters; boundary pixels cross the high-pass on individual frames; UnrealBloom’s separable blur then spreads that energy across the whole thin triangle — which is why the flash looks like a filled wedge rather than an edge, and why channel clipping gives it a magenta cast. The unlit plate sits at ~0.62 linear luminance, below the threshold, so there is nothing to cross and nothing to amplify. FXAA cannot help (spatial filter, applied after the fact), and threshold smoothing keeps the crossing binary per pixel.
Caveat, in the interest of honesty: the A/B proves lit-vs-unlit is the discriminator and the fix works; the exact per-frame trigger (coverage jitter vs half-float rounding vs dithering) is our working model, not fully instrumented. See questions below.
Fix (keeps the look, removes the temporal signal)
The lighting was static anyway, so we bake it at build time and render unlit — per-frame luminance becomes constant by construction:
// build time: bake two static directional lights into vertex colors
const n = position.clone().normalize(); // plate is a spherical patch
const d1 = Math.max(0, n.dot(L1)); // L1/L2 = fixed light dirs
const d2 = Math.max(0, n.dot(L2));
const shade = Math.min(1.12, 0.60 + 0.40 * d1 + 0.12 * d2);
vertexColor.multiplyScalar(shade);
// render time: unlit -> luminance can never cross the bloom threshold
new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true,
opacity: 0.96, side: THREE.FrontSide, depthWrite: true });
// hover/select emphasis: color multiplier, capped so peak luminance
// stays BELOW the bloom threshold (otherwise hovering re-introduces flashes)
material.color.setScalar(1 + hoverT * 0.10 + selT * 0.16);
Regression with the same detector: 188 → 0 block events/min (and 0 thin-line events), 0 black frames, in both the modular build and the inlined single-file build. Baked directional shading preserves the lit look (bright toward the key light, dark away); choropleth colors, hover highlight and drill-down borders unchanged.
Questions for the community
- Has anyone instrumented the exact per-frame trigger of threshold crossings on static lit geometry under camera orbit — sub-pixel coverage, half-float rounding in the HDR buffer, or dithering?
- For people who want to keep a genuinely lit look: what remedies have worked in practice — TAA (community implementations), rendering bloom from a selective layer, clamping HDR before the high-pass, something else?
- Is the Intel/ANGLE samples + HalfFloatType silent-black render target known upstream (Chromium/ANGLE bug), or should we file it?
Happy to share the detector script and the full A/B harness if useful.