# Single-frame "lightning" flashes on a lit globe: bloom threshold × sliver-triangle jitter

**URL:** <https://discourse.threejs.org/t/single-frame-lightning-flashes-on-a-lit-globe-bloom-threshold-x-sliver-triangle-jitter/94341>\
**Category:** Discussion\
**Created:** [September 14, 2026, 4:01pm UTC](https://discourse.threejs.org/t/single-frame-lightning-flashes-on-a-lit-globe-bloom-threshold-x-sliver-triangle-jitter/94341 "2026-09-14T16:01:50Z")\
**Posts on this page:** 3\
**Page:** 1

<div class="post-metadata">

**Author:** ![Weisg1999](https://yyz1.discourse-cdn.com/flex035/user_avatar/discourse.threejs.org/weisg1999/32/72352_2.png) [@Weisg1999](https://discourse.threejs.org/u/Weisg1999)\
**Post date:** [September 14, 2026, 4:01pm UTC](https://discourse.threejs.org/t/single-frame-lightning-flashes-on-a-lit-globe-bloom-threshold-x-sliver-triangle-jitter/94341/1 "2026-09-14T16:01:50Z")

</div>

**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:

1. each frame, drawImage the WebGL canvas into a 2D canvas, downscale to a luminance grid;
2. frame-diff vs previous frame, connected components with |Δlum| \> 0.10 and area ≥ 400 px;
3. 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:

```javascript
// 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**

1. 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?
2. 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?
3. 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.

---

<div class="post-metadata">

**Author:** ![ryan1](https://yyz1.discourse-cdn.com/flex035/user_avatar/discourse.threejs.org/ryan1/32/73525_2.png) [@ryan1](https://discourse.threejs.org/u/ryan1)\
**Post date:** [October 15, 2026, 5:44am UTC](https://discourse.threejs.org/t/single-frame-lightning-flashes-on-a-lit-globe-bloom-threshold-x-sliver-triangle-jitter/94341/2 "2026-10-15T05:44:29Z")

</div>

Great write-up, and the “prove the canvas isn’t black before trusting the metric” point is one a lot of people will need.

Two options for anyone who hits this but can’t bake the lighting (e.g. a moving sun or time-of-day shading on the globe):

1. **Keep the plates lit but exclude them from bloom.** The selective bloom approach from the official example (`webgl_postprocessing_unreal_bloom_selective`) puts bloom-worthy objects on a separate layer. The bloom composer renders with everything else swapped to a black material, then the result is mixed back over the normal render. The plates never reach the high-pass, so threshold crossings on slivers can’t turn into bloom wedges, and markers, borders or atmosphere can still glow.

2. **Get rid of the slivers at the source.** Earcut is fast but makes long thin triangles on elongated provinces. Adding interior Steiner points on a regular lon/lat grid before triangulating, or using a constrained Delaunay triangulator (e.g. cdt2d or poly2tri), gives much better-shaped triangles. A bonus on a globe: the denser mesh follows the sphere’s curvature instead of cutting chords through it, which also helps with depth issues against the base sphere when you zoom in.

Your bake-to-vertex-colors fix is still the cheapest when lights and geometry are static, so these are only worth it if the lighting needs to move.

---

<div class="post-metadata">

**Author:** ![vielzutun.ch](https://yyz1.discourse-cdn.com/flex035/user_avatar/discourse.threejs.org/vielzutun.ch/32/21366_2.png) [@vielzutun.ch](https://discourse.threejs.org/u/vielzutun.ch)\
**Post date:** [October 15, 2026, 6:30am UTC](https://discourse.threejs.org/t/single-frame-lightning-flashes-on-a-lit-globe-bloom-threshold-x-sliver-triangle-jitter/94341/3 "2026-10-15T06:30:39Z")

</div>

The following may be completely unrelated, but nevertheless describes a mechanism leading to “flashes” I’ve experienced when doing a [roller chain animation.](https://discourse.threejs.org/t/roller-chain-animation/79652) It basically involves the improper (i.e.: inexact!) pre-determination of the number of “instances” in using InstancedMeshes:

translated the relevant section from a related [blog post of mine](https://vielzutun.ch/wordpress/?p=14567) (original in German):

> **Dark Flash** and **Bright Flash**
> 
> When viewing an animated roller chain, the eye generally does not track an individual roller; instead, it perceives the chain’s motion as a whole. If a roller is missing from the expected, regular pattern, this gap is immediately perceived as an error—regardless of where it occurs. This is what I call a “Dark Flash.” It is termed a “flash” because, at 120 fps (as is the case on my MacBook Pro M4 Max), the error lasts for only a single frame—approximately 8 milliseconds.
> 
> Conversely, the appearance of a (bright) roller outside the regular chain sequence—especially against a dark background—is highly noticeable and is perceived as a “Bright Flash.” The origin of these “Dark Flashes” and “Bright Flashes” lies in the use of `InstancedMesh` objects within the Three.js programming environment. `InstancedMesh` is employed for performance optimization when multiple objects—sharing the same geometry and material—need to be rendered with different world transformations (position, orientation, and scale). When implementing an `InstancedMesh`, the developer must pre-allocate the required number of instances using the third parameter, ``count`: `
> 
> ``mesh = new THREE.InstancedMesh( geometry, material, count );``
> 
> From that point on, the developer is responsible for ensuring that a valid world transformation matrix is ​​provided for every single allocated instance before it is rendered.
> 
> Returning to the segments of the roller chain animation: Due to the aforementioned inaccuracies at the segment boundaries, two types of errors can occur:
> 
> - **Both** adjacent segments may claim responsibility for a roller located in the boundary area. As a result, both segments attempt to draw from the pool of pre-allocated and strictly counted instances. The result is that no instance remains for the final role—leading to a “dark flash.”
> - **None** of the adjacent segments assumes responsibility for a role that lies “on the boundary.” Consequently, neither segment draws from the pool of initially allocated and precisely counted instances. This has two consequences: a gap—a “dark flash”—appears where a role should have been rendered. Furthermore, the pool of allocated instances is not entirely consumed. Since no transformation matrix was provided for this final, unused instance, it appears by default at the coordinate origin—at the center of the drive pinion—manifesting itself as a “bright flash.”
