Alpha-to-coverage, and the black edges it doesn't fix

webgpu, three-js, tsl, alpha-to-coverage, foliage

A field of 790,000 grass cards has a problem before it has a look: every card is an alpha cutout, they overlap constantly, and the camera walks inside them. Sorting back-to-front is the textbook answer and it is unavailable — the order changes every frame, per card, and there is no order that is correct for a quad you are standing in the middle of.

Alpha-to-coverage solves that. It does not solve the black edges, and for a long time I thought it was supposed to.

Blending is not an option

Alpha-to-coverage turns a fractional alpha into a number of covered MSAA samples. Coverage is written to the depth/colour buffers per-sample, so two cards at the same depth resolve by sample count instead of by draw order. No sorting, no popping, no halos, and the camera can sit inside the field:

material.transparent = false
material.alphaToCoverage = true
material.side = THREE.DoubleSide

Two things about this bite silently.

It needs multisampling, and nothing tells you when it doesn't have it. A2C is only enabled on the WebGPU pipeline when sampleCount > 1. In three.js that follows antialias, which reads like a cosmetic flag:

const renderer = new THREE.WebGPURenderer({
  canvas,
  // Not cosmetic. Without this, sampleCount is 1, alpha-to-coverage is a
  // no-op, and the cards need the depth sorting we cannot do.
  antialias: true,
})

renderer.samples is read-only and follows the same flag, and an offscreen pass() target inherits it — which is what keeps A2C alive in a post-processing chain. Ask a RenderTarget for its own samples instead and you are in different trouble (MSAA on a RenderTarget breaks GTAO).

The WebGL fallback has to be refused, not accepted. renderer.init() resolves happily after falling back to WebGL2, where these materials quietly become alpha-blended and every card halos. So the fallback is a hard error rather than a degraded mode:

await renderer.init()
if (renderer.backend?.isWebGLBackend === true) {
  renderer.dispose()
  throw new WebGPUUnavailableError()
}

The alpha collapses before the card does

Now the distance problem. A frond is mostly gap. Sample its cell at mip 5 and every texel is the average of frond and gap — so a plume whose fill fraction is 38% reads as alpha 0.38, and A2C dutifully renders it 38% opaque. Distant crowns go see-through, and the sparse tip dissolves first, which is why a far tree reads blunt and upside-down.

The instinct is to multiply the alpha back up. That fails: gain lifts the anti-aliased haze around each frond exactly as much as the frond, so crowns stay ghostly and the silhouette grows a fog. What works is re-steepening the edge the mips flattened — a contrast curve, not a gain:

// mid-distance: push crown interiors back to solid, leave sparse tips sparse
const solid = smoothstep(0.045, 0.16, albedo.a)

Up close the problem inverts. A fixed curve tuned at 10 m binarises the hugely magnified gradients at arm's length into scalloped paper cutouts. So near foliage sharpens against the screen-space gradient instead, which keeps about one pixel of anti-aliased edge at any magnification:

const aaw = fwidth(albedo.a)
const sharp = clamp(albedo.a.sub(0.45).div(max(aaw, 1e-4)).add(0.5), 0.0, 1.0)
const alpha = mix(sharp, solid, revive)   // revive ramps 10m -> 45m

One more place alpha has to arrive: the shadow pass. Without an explicit cast node the depth pass evaluates the colour node — which samples the shadow map, recursing into itself. Only alpha matters for depth anyway, and taking it from the atlas is what makes a plume cast a plume-shaped shadow instead of a rectangle:

material.castShadowNode = Fn(() => vec4(0, 0, 0, texture(albedoMap, vAtlas).a))()

The colour in the transparent texels

None of that touches the black edges, because they are not an alpha problem.

An atlas baked against a transparent background has undefined RGB wherever alpha is zero, and most bakers write black there. Nothing samples those texels directly — but mip reduction averages them into their neighbours, and A2C resolves partial coverage by blending the pixel's colour. Both read RGB from texels the alpha says are empty.

Measured on the susuki atlas, 4096×2048, 8×2 cells:

regionsharemean RGB
fully transparent84.1%17, 17, 16
partial alpha (the edge)7.3%171, 178, 154
fully opaque8.6%115, 123, 81

The empty 84% carries RGB 17 while the frond edges beside it carry 171 — an order of magnitude darker. So every mip level drags the silhouette toward black. It darkens rather than halos, which is the more insidious failure: it looks like shading.

The fix is to stop treating those texels as empty and give them a colour. Flood-fill RGB outward from the fully-opaque core, and never touch alpha:

known = alpha >= 250          # not > 0
# dilate RGB into everything else, nearest-opaque-wins; alpha unmodified

The threshold is the interesting part. I assumed partial-alpha pixels were premultiplied and could be recovered by dividing by alpha — measuring says no. Divide and they overshoot to 440/255. They are genuine mixtures of leaf colour with the background, so they cannot be recovered, only replaced. Which is the correct meaning for a cutout texture anyway: RGB should say "the colour if this pixel were leaf", and alpha alone should carry coverage.

On the tree atlas I baked with this, edge RGB went from 44,45,25 to 65,69,36 against an opaque interior of 80,87,45 — most of the way back, and the fronds stopped having ink outlines.

What fixed what

Three symptoms that all look like "the transparency is wrong", with three unrelated causes:

symptomcausefix
halos, popping, sorting artifactsalpha blendingalpha-to-coverage + antialias: true
distant crowns go see-throughmip alpha collapsecontrast curve, not gain
dark outlines on every frondundefined RGB in empty texelsflood-fill RGB, leave alpha alone

The habit worth keeping is measuring the atlas rather than reasoning about it. Both of the wrong turns here — reaching for a gain, and assuming premultiplied alpha — were plausible from the pixels on screen and took about four lines of numpy to disprove. An atlas is a file you can print statistics about; the alpha channel and the colour channel fail independently, and the screen shows you their sum.