# Alpha-to-coverage, and the black edges it doesn't fix — Tobi Moccagatta

> 790,000 grass cards you can walk inside cannot be depth-sorted. Alpha-to-coverage solves the ordering, a contrast curve solves mip alpha collapse, and neither touches the dark outlines.

- Site: https://tobis.vision — Tobi Moccagatta, Creative Developer at basement.studio
- Canonical: https://tobis.vision/notes/alpha-to-coverage-foliage-atlases
- Contact: contact@tobis.vision

_Published 2026-09-02 · 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:

```js
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:

```js
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](/notes/msaa-breaks-gtao-on-webgpu)).

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

```js
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:

```js
// 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:

```js
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:

```js
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:

| region | share | mean RGB |
|---|---|---|
| fully transparent | 84.1% | 17, 17, 16 |
| partial alpha (the edge) | 7.3% | 171, 178, 154 |
| fully opaque | 8.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:

```python
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:

| symptom | cause | fix |
|---|---|---|
| halos, popping, sorting artifacts | alpha blending | alpha-to-coverage + `antialias: true` |
| distant crowns go see-through | mip alpha collapse | contrast curve, not gain |
| dark outlines on every frond | undefined RGB in empty texels | flood-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.

---

## Other pages

- [Creative Developer](https://tobis.vision) — hey, I'm tobi. creative developer at basement.studio, working on shaders and 3D on the web. peek into my vision. Markdown: https://tobis.vision/index.md
- [Experiments](https://tobis.vision/experiments) — A growing list of experiments across shaders, sound, scenes, and interaction. Markdown: https://tobis.vision/experiments.md
- [Works](https://tobis.vision/works) — Shaders and real-time 3D for Coinbase, Modal, Baseten, E2B and Rox at basement.studio, plus Shader Lab and Annex. Markdown: https://tobis.vision/works.md
- [About](https://tobis.vision/about) — Creative developer at basement.studio working on shaders, WebGL and WebGPU. Self-taught, in Buenos Aires, open to freelance. Markdown: https://tobis.vision/about.md
- [Contact](https://tobis.vision/contact) — How to reach Tobi Moccagatta: one email address, what to put in it, what he takes on, and current availability from Buenos Aires (UTC-3). Markdown: https://tobis.vision/contact.md
- [Privacy](https://tobis.vision/privacy) — What this site collects and what it does not: cookieless analytics, no forms, no trackers, and an in-memory rate-limit counter that expires with its window. Markdown: https://tobis.vision/privacy.md
- [Developer resources](https://tobis.vision/developers) — Machine-readable tobis.vision: llms.txt, the OpenAPI spec, the read-only content API, and the markdown representation of every page. Markdown: https://tobis.vision/developers.md
- [Notes](https://tobis.vision/notes) — Write-ups from building shaders and real-time 3D on the web: WebGPU, TSL and three.js, mostly the parts that went wrong. Markdown: https://tobis.vision/notes.md
- [Grass (2026)](https://tobis.vision/experiments/grass) — A walkable Val d'Orcia field: 790,000 instanced grass cards on a procedurally baked atlas, backlit through a translucency map, bent by a travelling wind field and parted by a displacement trail you leave behind you. Markdown: https://tobis.vision/experiments/grass.md
- [Critters](https://tobis.vision/experiments/critters) — Soft vinyl creatures raymarched from signed distance fields, morphing between forms as one continuous surface and reacting to your cursor. Markdown: https://tobis.vision/experiments/critters.md
- [LPV](https://tobis.vision/experiments/lpv) — Real-time global illumination via light propagation volumes, hand-written in TSL on WebGPU compute. Markdown: https://tobis.vision/experiments/lpv.md
- [BIP](https://tobis.vision/experiments/bip) — A holoprojector experiment with BIP, the robot influencer. Markdown: https://tobis.vision/experiments/bip.md
- [Fluid](https://tobis.vision/experiments/fluid) — A WebGPU fluid simulation ported from Pavel Dobryakov's classic to three.js TSL. Markdown: https://tobis.vision/experiments/fluid.md
- [Real-time GI that just brightened the walls](https://tobis.vision/notes/real-time-gi-that-just-brightened-the-walls) — A light propagation volume can solve live and still read as a brightness slider: the missing sun-patch bounce, the dynamic range propagation destroys, and the read-side falloff that gives it back. Markdown: https://tobis.vision/notes/real-time-gi-that-just-brightened-the-walls.md
- [An MCP server that lets an agent write WebGPU shaders](https://tobis.vision/notes/mcp-server-that-writes-webgpu-shaders) — You cannot render WebGPU in a Node process, so the server owns nothing and relays into a live editor tab. The compile feedback loop is what makes an agent able to write shaders at all. Markdown: https://tobis.vision/notes/mcp-server-that-writes-webgpu-shaders.md
- [MSAA on a RenderTarget breaks GTAO on WebGPU](https://tobis.vision/notes/msaa-breaks-gtao-on-webgpu) — Why three.js GTAO fails with `Invalid ShaderModule "fragment_GTAO"` the moment a RenderTarget asks for samples, and what to do instead. Markdown: https://tobis.vision/notes/msaa-breaks-gtao-on-webgpu.md
- [Making three.js bloom 9x faster with the Call of Duty blur](https://tobis.vision/notes/three-js-bloom-jimenez-dual-filter) — three.js renders a fixed 5-mip chain of separable Gaussians every frame and ignores your radius. The Advanced Warfare downsample/upsample took bloom from 8.2ms to 0.9ms. Markdown: https://tobis.vision/notes/three-js-bloom-jimenez-dual-filter.md

## Machine-readable

- [llms.txt](https://tobis.vision/llms.txt)
- [sitemap.xml](https://tobis.vision/sitemap.xml)
- [robots.txt](https://tobis.vision/robots.txt)
- [openapi.json](https://tobis.vision/openapi.json)
- [developer resources](https://tobis.vision/developers)
