Sunstone dev log 19: less overdraw
I managed to improve overdraw somewhat:
This took my fragment shader invocation count from 18,360,824 to 10,568,652 (count pixel shader invocations in RenderDoc, save as CSV, then sum the column using Sqlite). However, it had no effect on my framerate. So the renderer wasn’t actually fragment-bound.
In a previous entry I mentioned that occlusion culling might be the solution to my fragment overdraw issues. While researching occlusion culling, I came across the idea of a depth prepass. Which got me thinking that something depth-related would be a better solution than occlusion culling. Even though it hasn’t fixed my performance issues, I think the overdraw solution is still worth having. These are my notes while working it all out.
Figuring out how to fix fragment overdraw
I’d prefer to use the
early fragment test
to skip occluded fragments. I don’t think that works for the current system,
though. Even if I set up a depth prepass (so that early Z could be enabled
for the colour pass), my depth buffer contains logarithmic values so I’d have
to pass those as the z component of gl_Position and they’d be interpolated
incorrectly.
If I can’t skip the fragment shader completely, then I have to rely on reducing
the work it does. I could try re-implementing an early depth test in the fragment
shader by sampling the depth texture (written from the depth pre-pass) and
discarding when it fails the depth test. I don’t know what kind of speedup to
expect from that, but it seems easy enough to build and find out.
A brief web search suggests that texture sampling
is relatively expensive, so maybe skipping 3 texture samples per fragment
(base colour, metallic-roughness, ambient specular lookup) will be enough of a
win?
12… hours… later…
Well, I tried to build it and it wasn’t as easy as I expected. The main thing I got hung up on was how to correctly sample the depth texture. It’s a multisampled texture when MSAA is on, so in addition to texture coordinates it takes a sample index. I don’t know how to map a sub-pixel fragment to the correct sample index.
I decided to replace explicit logarithmic depth with “reverse Z”.
Reverse Z interpolates correctly for fragments, and the early fragment test
works (even without a depth pre-pass) because gl_FragDepth is no longer written.
Why does reverse Z work?
This is a similar analysis to Sunstone dev log 16 - Depth buffer resolution analysis, but now the storage format is 32-bit floating point, which has variable resolution.
The layout for IEEE754 32-bit floats is:
| Bits | Content |
|---|---|
| 31 | sign |
| 30-23 | exponent |
| 22-0 | fraction |
exponent = 0x0 is reserved for 0 and “subnormal” sumbers, and exponent = 0xFF is reserved
for NaN/Infinity. That leaves 254 possible values for the exponent.
exponent = 0x1 represents -126 and exponent = 0xFE represents 127.
Here’s an enumeration of some floating point values, starting at the smallest positive “normal” number:
| Bits | Value |
|---|---|
0b0_00000001_00000000000000000000000 |
|
0b0_00000001_00000000000000000000001 |
|
0b0_00000001_00000000000000000000010 |
|
| … | … |
0b0_00000001_11111111111111111111111 |
|
0b0_00000010_00000000000000000000000 |
|
| … | … |
So the floating point numbers with exponent -126 have an even spacing of
.
Floating point numbers with exponent -125 are spaced by
,
which is precisely twice the spacing of the previous set.
Then when the exponent increases by 1, the denoted number doubles
and the spacing doubles.
This is essentially the relationship given by logarithmic depth (previous analysis), but the graph of for is a staircase instead of a straight line. For the first of each step, the ratio of to is , so I’ll use that first value as a proxy for the rest of the values in the step and say that 32-bit floating point can discriminate a change in depth of around at depth of .
When I analysed logarithmic depth, I gave the equation
for fragment depth after perspective division. For reverse Z to work, the near and far planes have to be swapped, giving and .
This gives
Again from last time, we had
For this analysis it needs to be
because is negative, due to being monotonically decreasing from 1 to 0.
If we take :
then we get the following resolutions ():
| z | Δz |
|---|---|
| 0.001 | 1.1920917e-10 |
| 0.01 | 1.192081e-9 |
| 0.1 | 1.1919736e-8 |
| 1 | 1.1909008e-7 |
| 10 | 1.1801719e-6 |
| 100 | 1.0728836e-5 |
| 1000 | N/A (resolution goes to 0 at ) |
The looks a lot like the linear relationship between and given by logarithmic depth. I was surprised when I first saw this, because the function for is still quadratic in . It works because it’s a negative quadratic function with a root at :

The resolution gets worse, but not increasingly worse. And however bad it gets, it needs to come back down as we approach the root at .
What happens if we forget to swap the near and far planes? Using standard and :
We get a positive quadratic with a root at :

The resolution gets increasingly worse and there’s no upper bound on how bad it gets.
What I built
- Use 32-bit floating point depth with reverse Z
- Enable early fragment tests
- Factor out main pipeline
Next steps
- Fix the actual performance issue
- Shade in physical units