Reverse-Z is the Perfect Hack

Almost every graphics technique is a trade. You spend more memory or compute or complexity and get something faster or more accurate or better looking in return. Reverse-Z is one of the rare exceptions. Flip the near and far planes and depth precision goes from a known problem to a non-issue.

I wanted to implement Reverse-Z in VGLX early on. It's always better to do it sooner rather than later. However, taking advantage of it was challenging because the engine still targets OpenGL 4.1 for macOS compatibility so I shelved the branch for now (more on that later).

Hitting that roadblock made me want to understand the math. Reverse-Z requires looking at how depth values and floating point numbers are distributed across the same range and what happens when those two distributions interact.

There are resources that implement Reverse-Z and show it works empirically 1, but I haven't seen one that reframes two compounding problems as opposing forces. Putting the pieces together was satisfying so I decided to share what I learned.


Depth Precision Problem

Perspective divide is a step in the pipeline that takes clip-space coordinates and divides each one by the homogeneous coordinate . This division is how we transform the pyramid-shaped frustum to the canonical view volume. It's a fixed step in the pipeline because division can't be expressed as matrix multiplication so the GPU does it for us as a separate hardwired step.

Dividing the and coordinates by is how we get the foreshortening effect, so standard perspective projection sets . But division applies to all three coordinates including . If we do nothing our depth information will be lost.

The solution is encoding two constants, and , in the perspective projection matrix (derived from the near and far planes) and combining them to map the depth range such that represents the near plane and represents the far plane, assuming a depth range 2.

There's one glaring problem with this approach. The depth output is a function of which means that depth values are no longer linear between the near and far planes. This nonlinearity isn't a glitch. It's how perspective works. The projection matrix was designed to make and foreshorten correctly. The depth value just comes along for the ride.

The easiest way to understand the impact of this nonlinearity is to visualize it. The graph below plots depth values against world-space distance . To keep the chart readable it uses 64 evenly spaced depth values. Each dashed line marks where one of those codes lands in world space.

Looking at this graph it's easy to see how the depth output function leads to extreme precision at the near plane and how it flattens out at the far plane where distinct depth values collapse into the same position. That's precisely why z-fighting artifacts are so common in the distance.

A common mistake is assuming that more precision will automatically mitigate this problem. You might try to switch to a 32-bit floating-point buffer. An extra 8 bits of precision must be a good thing, right? It's actually worse, but understanding why also explains why Reverse-Z works so beautifully.

Floating Point Distribution

32-bit floating-point depth buffers follow the IEEE 754 standard where numbers are represented by three components: a 1-bit sign (), an 8-bit exponent () and a 23-bit mantissa (). The formula for interpreting a floating-point number is:

This formula doesn't make the distribution obvious but it's easy to see if we break it down. The sign component is simple: a number can be positive or negative and we have a single bit to represent that. The exponent defines the power-of-two range the number belongs to, and the mantissa is an approximation of where the number sits within that range.

If you found the last sentence confusing an example should clear it up3. Let's take the familiar number . It's positive so we set . The power-of-two range of this number is so we set (the bias exists so the exponent can represent negative powers of two). But how do we determine the mantissa?

The size of the mantissa is . This means that no matter how small or large the exponent range is we always have unique values to represent numbers within that range. Since our range is we can find the location by mapping our number onto it: which makes .

The key takeaway is that the size of the mantissa is fixed which means that precision is lost as the exponent covers a wider range. At our example range of the precision is . If we take a larger power-of-two range such as the precision drops to .

Standard depth precision and floating-point numbers have the same key property: most of the precision is clustered near zero. Combining them doesn't fix the problem, it doubles down on it.

The two distributions reinforce each other. The mapping pushes most depth values toward the near plane where float precision is also the highest. The far plane gets fewer depth values from the projection and fewer representable numbers from the format. Using a floating-point depth buffer is like adding more lanes to the side of the highway that's already empty.

We cannot change how a float distributes its bits across the range because that behavior is hardwired. But the projection matrix is ours. By inverting the projection math we can force these two curves to counteract each other instead of fighting.

Reverse-Z

Reverse-Z flips the depth mapping so the near plane maps to and the far plane maps to . This swap completely changes how the two distributions interact. The projection still compresses distant objects into a tiny fraction of the depth range, but it now forces those objects down toward .

Because floating-point numbers have an exponential abundance of precision values right next to zero, the format expands where the projection compresses. The two biases cancel out perfectly.

Implementing this requires adjusting three things: changing the depth test from less to greater, clearing the depth buffer to instead of , and modifying the projection matrix. The first two are API-specific and usually take a single line of code. The projection matrix adjustment requires just a bit of algebra.

In standard perspective projection we find our matrix constants by mapping the near plane to 0 and the far plane to 1. To reverse the system, we swap those targets so that the near plane maps to 1 and the far plane maps to 0.

Seen side-by-side, the standard Vulkan projection matrix and its Reverse-Z counterpart highlight how isolated the change is. The projection matrix changes only in the third row while everything else remains untouched.

The graph below applies Reverse-Z to a simulated float buffer. The values that crowded the near plane before now land at the far plane, exactly where the projection was starving for precision.

The reversed constants have an elegant property. Push the far plane to infinity and the constants simplify rather than break down: tends to and tends to , so the third row collapses to and depth reduces to . The near plane still maps to and infinity maps to .

With standard projection an infinite far plane would be a disaster, shoving every value into the region where precision is already scarce. Reverse-Z inverts that. The far plane now lives at where float precision is densest, so you can drop it entirely and pay almost nothing. An unbounded view distance, for free.

And that's it. Near-perfect precision across the entire range. Well, almost. Even the perfect hack comes with some terms and conditions.

The Fine Print

Any shader that does manual depth comparison, reconstructs world position from depth, or implements depth-based effects must account for the flipped range. This becomes a major headache if you switch conventions mid-project 4.

Another factor is the stencil buffer. A standard 32-bit floating-point format provides increased depth precision but leaves no room for a stencil channel. That is fine if you don't use one but most non-trivial applications do.

The classic 24/8 format cleanly packed depth and stencil into a single 32-bit container. The floating-point equivalent is a format like D32F_S8X24 which yields 32 bits of depth and 8 bits of stencil but balloons to 64 bits per pixel because of the 24-bit padding. That means double the memory footprint and double the bandwidth. On constrained hardware or at high resolutions that cost is real, which explains why 24/8 remained the default for so long.

Finally, there is the clip space restriction. Reverse-Z requires a depth range of . OpenGL uses a canonical view volume with a range of by default. Because floating-point numbers cluster their precision right next to zero applying Reverse-Z in a space just crams all your precision into the center of your view volume, defeating the entire purpose.

Modern APIs handle this natively and OpenGL 4.5 added glClipControl to remap the depth range to . But if you are stuck targeting macOS for compatibility, capped at OpenGL 4.1 and without extension support, you are out of luck!


Reverse-Z is standard in most modern engines, but what makes it interesting isn't that it works. It's how it works.

Most precision problems in graphics are solved by throwing more resources at them. Reverse-Z solves one by rearranging what is already there. It turns two compounding problems into two opposing forces that perfectly balance out.

It is the perfect hack because it does not change the math in the pipeline. It just flips which end of the range maps to which. The precision improvement is enormous. No extra memory. No extra compute. Just a different mapping.

Footnotes

  1. The canonical reference for this topic is Nathan Reed's Depth Precision Visualized.

  2. There are many resources on how to derive projection matrices. Brendan Galea has a nice video about this topic called The math behind (most) 3D games - perspective projection.

  3. This example is borrowed from Fabien Sanglard Floating Point Visually Explained.

  4. Godot learned this the hard way Introducing Reverse Z (I'm sorry for breaking your shader).