Render Output Units

A fragment has run the gauntlet: rasterized, early-Z tested, shaded, textured. It is now a colour with an address. One question remains — who actually writes it into the framebuffer? Not the shader. On every GPU, the last mile belongs to a ring of fixed-function blocks called render output units (ROPs — historically "raster operations"). They are the only hardware allowed to touch the framebuffer, and everything about them follows from one uncomfortable fact: the final write is not a write at all. With blending, depth and stencil in play, it is a read–modify–write — and thousands of fragments are trying to do it to the same buffer, concurrently, in a machine built to be massively parallel. The ROPs are where that chaos is forced back into order.

Their job list: the final depth/stencil test (the authoritative late-Z, for draws that early-Z had to skip), alpha blending, MSAA resolve, and framebuffer compression — each a read–modify–write specialist.

Blending: a read–modify–write with a referee

The standard "over" blend for a translucent fragment with opacity \alpha is

\text{out} = \text{src} \cdot \alpha + \text{dst} \cdot (1 - \alpha)

— where \text{dst} is whatever the framebuffer currently holds. Work one: a red-ish fragment \text{src} = 0.8 at \alpha = 0.25 over a background \text{dst} = 0.2 gives 0.8 \cdot 0.25 + 0.2 \cdot 0.75 = 0.35. Trivial arithmetic — but note what it requires: read the old pixel, blend, write the new one back, atomically. If two fragments hit the same pixel and their read–modify–writes interleave, one update is simply lost.

And it is worse than atomicity: the maths is order-dependent. Blending A then B does not equal B then A — and the API makes a hard promise: pixels are updated in the order you submitted the primitives, no matter how the hardware parallelised everything in between. That promise is why blending is fixed-function. Shader cores could do the multiply-adds easily; what they cannot cheaply do is referee thousands of concurrent RMWs into per-pixel submission order. The ROPs do it structurally: each ROP owns a fixed slice of the screen, so every fragment for a given pixel funnels through the same unit, and that unit's queues keep primitive order. The arithmetic is the cheap part; the ordering guarantee is the product.

The last mile, drawn

Step through one fragment's read–modify–write, then see what the pixel looks like when MSAA gives it four samples.

MSAA: test per sample, shade per fragment, resolve at the end

Multisample anti-aliasing is a ROP specialty because it is a storage trick, not a shading trick. With 4\times MSAA each pixel keeps four colour + depth samples at slightly different positions. At a triangle's edge, the rasterizer tests coverage and the ROP tests depth per sample — but the fragment shader still runs once per fragment, and its one colour is stored into whichever samples the triangle covers. At the end of the frame the resolve pass averages each pixel's samples: a pixel whose four samples hold 0.8, 0.8, 0.2, 0.2 resolves to 0.5 — a smooth edge, for one shading's worth of work and four samples' worth of bookkeeping. That bookkeeping (per-sample depth tests, per-sample writes, the resolve averaging) is all ROP arithmetic, which is why "MSAA cost" is largely a ROP-and-bandwidth story, not a shader story.

Bandwidth, again: framebuffer compression and where ROPs live

Every blend reads and writes the framebuffer; MSAA multiplies the samples. The same medicine you saw for depth applies to colour: lossless per-tile compression. Neighbouring pixels are usually similar, so the hardware stores a tile as one base colour plus small deltas (and an MSAA tile whose samples all agree — every interior pixel — collapses to a single value). Like depth compression it saves traffic, not capacity: the worst case must still fit, but the common case moves a fraction of the bytes.

Geography follows function: on the die, the ROPs sit next to the L2 cache slices and memory controllers — not next to the shaders. A ROP's life is memory traffic; wiring it into the memory crossbar keeps its read–modify–writes local. And counting them gives the classic ceiling: ROP count × pixels-per-clock × clock = fill rate, the hard limit on how fast the GPU can write pixels no matter how simple the shading. A GPU with 96 ROPs at 2 GHz tops out at 192 Gpixels/s — full-screen passes, UI layers and particle storms all queue at that gate.

Order matters — prove it in ten lines

The compositor below stacks translucent layers over a background in submission order, then in shuffled order. Same layers, same arithmetic, different picture. Then an MSAA resolve on a tiny edge shows the four-samples-one-shade trick.

// "Over" blending: out = src·α + dst·(1−α), single channel for clarity. interface Layer { name: string; src: number; a: number; } const layers: Layer[] = [ { name: "haze", src: 0.9, a: 0.5 }, { name: "glass", src: 0.1, a: 0.5 }, { name: "smoke", src: 0.6, a: 0.5 }, ]; function composite(order: Layer[], bg: number): number { let dst = bg; for (const L of order) dst = L.src * L.a + dst * (1 - L.a); // read–modify–write return dst; } const bg = 0.2; const submitted = composite(layers, bg); const shuffled = composite([layers[2], layers[0], layers[1]], bg); console.log(`submission order : ${submitted.toFixed(4)}`); console.log(`shuffled order : ${shuffled.toFixed(4)}`); console.log(`same layers, different pixel — blending is order-dependent!\n`); // MSAA 4×: per-sample coverage against an edge at x = 2.4, one shade per fragment. // Rotated-grid sample offsets inside each pixel: const offsets = [[0.375, 0.125], [0.875, 0.375], [0.125, 0.625], [0.625, 0.875]]; const edgeX = 2.4, triColour = 1.0, bgColour = 0.0; let shades = 0; const row: string[] = []; for (let px = 0; px < 5; px++) { const covered = offsets.filter(([ox]) => px + ox < edgeX).length; if (covered > 0) shades++; // the shader runs ONCE per covered fragment const resolved = (covered * triColour + (4 - covered) * bgColour) / 4; row.push(resolved.toFixed(2)); } console.log(`edge at x = ${edgeX}, resolved pixel row: ${row.join(" ")}`); console.log(`fragment shader ran ${shades} times for ${5 * 4} depth-tested samples`);

Swap the shuffled order around: every permutation gives a different colour. Whatever parallel adventure the fragments had upstream, the ROP must land them in the order you drew them.

The ROP's ordering guarantee is narrower than it sounds. It promises that fragments blend in submission order — the order your draw calls and triangles were issued. It does not know which order is visually correct. Translucent geometry must be blended back-to-front (the "over" operator assumes the far thing is already in the buffer), so if your engine submits transparent objects unsorted, the ROP will faithfully, deterministically blend them in the wrong order — glass behind smoke drawn as if in front, every frame, no error raised. This is why engines sort transparent objects by depth every frame, and why unsortable cases (intersecting or self-overlapping transparency) push people to order-independent techniques that restore commutativity by changing the maths (additive blending, weighted OIT). The referee enforces the order you gave it — garbage order in, deterministic garbage out.

GPU compute has atomic memory operations — so couldn't blending be a shader loop with atomics? Arithmetically yes; practically it would fall off a cliff. An atomic RMW to memory costs tens of cycles and serialises at the memory, exactly where contention is worst (every fragment of an overlapping particle system hits the same pixels). And atomics give you atomicity but not order — submission order would need a whole software ticketing scheme on top. The ROP solves both structurally: screen-slice ownership means no two units ever race on a pixel, and in-order queues make primitive order a property of the plumbing rather than a synchronisation protocol. It is this module's recurring lesson in its purest form: the win of fixed-function hardware is not doing arithmetic faster — it is making an expensive guarantee (per-pixel atomic, ordered RMW at billions per second) fall out of physical structure for free.