Depth & Transparency
Make z-fighting happen on purpose, then fix it from the frustum — and find out why transparency needs sorting.
What you should come away with: That depth precision is set by the near plane rather than the model, and that the depth buffer cannot answer the question transparency asks.
Assumes: Projection & the Frustum. It will still make sense without it, but that one comes first.
Every pixel on screen carries a colour and one other number: how far away the thing that coloured it was. That second number is the , and it is what lets triangles be submitted in any order and still come out with the near ones in front. Before a is written its depth is compared against what is already stored there, and if it loses the comparison it is discarded — the draw call that produced it is never told.
The buffer is finite, twenty-four bits per pixel in the arithmetic the readouts here use, and it is spent very unevenly along the view direction. Both of the failures in this lab come out of that one fact. is the buffer running out of precision and being unable to decide between two surfaces; broken transparency is the buffer deciding when it should have kept out of it.
The near plane spends the buffer#
Perspective does not store distance. After the , the value written for a surface d units away is f(d − n) / (d(f − n)) — hyperbolic in d, not linear. Half of the buffer’s entire range is gone by the time that expression reaches 0.5, which happens at d = 2nf/(f + n). With a near plane of 0.02 and a far plane of 200, half of every value the buffer can hold is spent between 0.02 and 0.04 units in front of the camera. Everything from there to the far plane shares the rest.
The two panels below are two thousandths of a unit apart, eighty units away. Whether the buffer can tell them apart is arithmetic rather than luck, and the readout does it: a 24-bit buffer resolves about z²(f − n) / (n·f·2²⁴) at distance z. Move the near plane and watch the prediction and the picture change together.
Nothing about the geometry changed. The panels are the same size, the same distance away, the same two thousandths apart; two entries of the projection matrix changed and the failure went with them. Pushing the near plane from 0.02 out to 1 buys a factor of fifty — 1.9 × 10⁻² down to 3.8 × 10⁻⁴ — because the resolvable gap is inversely proportional to n and to almost nothing else.
Distance is the other half of it, and it is quadratic. Those same panels in that same frustum need 1.2 × 10⁻³ of separation to be safe at twenty units, 4.8 × 10⁻³ at forty and 1.9 × 10⁻² at eighty. Doubling how far away something is quadruples the gap you have to leave inside it, which is why coplanar decals — a poster on a wall, a tyre mark on a road — sit still under the camera and shimmer at the end of the street.
The far plane is the control that does nothing#
Write the prediction as z²/(n·2²⁴) × (1 − n/f) and the far plane’s entire contribution is that second factor. With a near plane of 0.05, dragging f from 10 to 1000 moves it from 0.995 to 0.99995.
It is nevertheless the first control most people reach for, and close to the least effective one on offer. It is also the one with a hard floor: in the instrument below the far slider goes down to 10, and taking it under the panels’ own eighty units does not dim them or fade them out — they disappear. Near and far are a clip rather than a falloff, which is what Projection & the Frustum is about.
The flicker deserves a moment on its own. Below the threshold, which of two surfaces wins a given pixel is settled by rounding, and rounding changes when the camera moves. That is why z-fighting in a real scene is not a fixed pattern sitting on a wall; it is a shimmer that follows you around the room.
Your scene is 200 units deep and the distant walls are z-fighting. Which change fixes it?
The buffer answers what is nearest; transparency asks what is behind#
Switch scenes. Three translucent panes, three units apart, drawn with premultiplied over: the fragment shader emits vec4(colour * opacity, opacity) and the is ONE, ONE_MINUS_SRC_ALPHA. Read that literally and it says the result is this pane’s contribution plus whatever was already in the framebuffer, faded by how opaque this pane is. It requires that what is behind the pane has already been drawn.
The depth buffer’s whole job is to stop what is behind from being drawn. The two requirements are in direct opposition, and the switch that decides between them is depth writing.
Depth testing stays on the entire time: translucent geometry still has to be hidden by the opaque geometry in front of it. Only the write comes off. A pane that tests but does not write is still occluded by anything nearer that has already claimed the pixel, and hides nothing drawn after it — which is what you wanted, and also why there is now nothing left to put the three of them in order.
Sorting is the half you have to do yourself#
Over is not commutative. Red over green is a different colour from green over red, so the panes have to arrive back to front, and no piece of render state arranges that. The lab sorts them on the CPU, every frame, from where the camera is at that moment.
That the sort is per camera is not a detail. From the viewpoint the lab opens at, the camera sits at about z = +14.7 and the array order — red, green, blue — is already back to front; all four combinations of the two switches render the same pixels there, and both bugs stay invisible until you drag the panes round. Come at them from the other side and the identical array is front to back, and both failures appear at once.
That also answers the optimisation everyone proposes. The order cannot be computed once and stored with the model, because it is not a property of the model. It changes when the camera moves, so it is redone every frame, on the CPU, for as long as the scene contains anything translucent. That is the real cost of transparency, and it is paid in rather than in shading.
One thing worth checking in the instrument, once you have dragged the panes round: sort them correctly and then turn depth writing back on, and the pixels do not change. In back-to-front order every pane is nearer than everything already in the buffer, so nothing is ever rejected. Depth writing only bites when the order is already wrong — another way of saying that the sort is the load-bearing half.
What I got wrong here: the scene that never fought#
The first build of this lab never fought. Its distance control orbited the camera around the panels, and the belief underneath that is worth naming: that distance is a property of the picture. Move the panels away, or pull the camera back, and they arrive on screen at the same size, so the two looked like the same operation. They are not the same operation for the depth buffer, which is indifferent to where the panels sit and cares only how far the frustum has to reach to hold them.
The symptom was a lab that looked well behaved. The slider said anything from 5 units to 120 and the view distance stayed pinned at 14, so the scene sat comfortably inside precision at every setting and the panels never tore. A clean render makes no complaint. It reads as a lab that works, or at worst as a failure that some other control has to be turned up to produce — and the control that was supposed to produce it was the one holding it off.
What caught it was measuring the pixels rather than looking at them. Counting how much of the far panel breaks through the near one turns “it looks fine” into a number: at a near plane of 0.02 with the panels 0.002 apart, 52 pixels of the far panel come through, in 52 separate transitions, and at a near plane of 1 the count is 0. In the broken build nothing tore at any setting, so that count was flat wherever it was taken. The arithmetic beside the picture was right the whole time; it was the camera that was wrong, which is why reading the source would not have found it either.
The fix, in commit ba5d487, stops the camera. The eye sits 1.5 units from the origin and the panels move away from it, sized in proportion to distance so that the picture holds still and only the precision behind it changes. What guards a control that does nothing is the check moving a control changes the picture in test/render.smoke.ts, which drives one control on every lab’s instrument in a real browser and fails the build when the canvas does not move further than that lab’s own noise. It is honest about what it cannot see: on this lab it drives the scene switch rather than the frustum, because the bands are finer than its sampler resolves — moving the near plane from 0.02 to 1 under the Make it fight preset changes 0.049 per cent of the picture even at the canvas’s native resolution.
Both failures, with every control#
Below is the instrument the figures were cut from. The segmented control picks which failure you are looking at. Z-fighting gets the frustum — near, far, distance, and the separation between the panels — with the prediction computed live beside the picture. Transparency gets the two switches and an opacity slider. The presets are the quickest way in: three of them are the frustum arguments above, already dialled in.
One case the arithmetic above does not cover. Take the separation to exactly zero and the panels stop fighting rather than start: identical geometry produces identical depth, the test is LEQUAL, and the panel drawn second passes and wins every pixel cleanly. It is the one setting where a smaller gap is more stable than a larger one, and the readout calls it out rather than predicting a fight.
Two panels 0.010 apart, 40 units away · near 0.10
What the depth buffer can tell apart
The prediction above is arithmetic, not a guess: a 24-bit depth buffer distributed hyperbolically resolves roughly z²(f−n) / (n·f·2²⁴) at distance z. Set the separation below that number and the panels must fight; set it above and they cannot. Move the near plane and watch the prediction and the picture change together.
Almost all of that precision is decided by the near plane, because the buffer spends most of its range close to the camera. The far plane is the control everybody reaches for first, and dragging it from 10 to 1000 barely moves the number. That is why the fix for z-fighting is so rarely in the model.
Where that leaves you
You can now work out from the near plane and the distance whether two surfaces will fight before you look at them, and say why the ordering of translucent geometry is the half that no piece of render state will do for you.
This did not teach you the ways out of sorting. Order-independent blending, a prepass and a reversed floating-point buffer are each a real answer to a failure on this page, and each one is a change to the pass structure or to the format the numbers are stored in rather than to a value a slider can reach.