Skip to content

Add an opt-in reversed depth buffer - #7860

Open
dustinspace217 wants to merge 2 commits into
scp-fs2open:masterfrom
dustinspace217:graphics/reversed-z
Open

dustinspace217 wants to merge 2 commits into
scp-fs2open:masterfrom
dustinspace217:graphics/reversed-z

Conversation

@dustinspace217

@dustinspace217 dustinspace217 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What?

Adds an opt-in reversed depth buffer: a "Reversed Depth" graphics option, plus a -reversed_depth launcher flag that works where in-game options are off. It's off by default.

Why?

Space scenes put a cockpit a meter away and capital ships kilometers away in the same depth buffer. With the default 1 m near plane, 1000 km far plane and a 24-bit buffer, the depth step at 5 km is about 1.5 m, so overlapping hull plates and details on distant ships flicker against each other. Reversed depth with a floating-point buffer keeps the step at a tiny fraction of the distance all the way out.

How?

  • The projection maps the near plane to depth 1 and the far plane to 0, closer surfaces pass with GREATER, and depth buffers are cleared to 0. The reversed perspective terms are written out directly; flipping the normal ones in float moved the far plane by about 5%.
  • Vulkan already uses a [0, 1] depth range and a 32-bit float depth buffer. OpenGL needs glClipControl to get the same range, which is core in 4.5 or available as ARB_clip_control. The bundled loader doesn't include it, so it is fetched by name at startup. Without it, as on macOS or GLES, the option is ignored and depth works as before. On OpenGL the scene, cockpit, back and MSAA depth buffers and render-target renderbuffers become 32-bit float with stencil.
  • Shadow maps are reversed as well, because they share the depth test. Depth bias changes sign.
  • The shaders that read depth back follow a new REVERSED_Z define: fog, nebula volumetrics, decals, soft particles, lightshafts, the per-vertex fog distance and the shadow lookup. With the option off every shader is unchanged.
  • glClipControl comes from glad, regenerated at the existing settings with GL_ARB_clip_control added.

Screenshots?

SM1-01 with a transport moved in front of the player, and SM1-05 in the full nebula, option off and on, on OpenGL and Vulkan (attached). They look the same, which is the point.

Testing?

  • Linux, GCC, Release: builds with 0 warnings; unittests pass, including new tests for near and far depth on both renderers, two surfaces 0.5 m apart at 20 km (equal in a 24-bit buffer, distinct with reversed depth), the shadow and orthographic projections, the clear value and the shader define.
  • With a temporary build that printed the decision: Mesa OpenGL 4.6 and NVIDIA Vulkan both turn it on from the flag. With Mesa forced to OpenGL 4.3 it stays off without ARB_clip_control and turns on with it. A build with the depth compare deliberately left at LESS made the ships disappear, which shows the reversed path really runs.
  • SM1-01 close-up and SM1-05 full nebula with shadows on, OpenGL and Vulkan: geometry, lighting, shadows, fog, nebula clouds and engine glows look the same with the option off and on. The close-up also renders correctly on OpenGL with the option on and -msaa 4.
  • Not tested: macOS, GLES, Windows drivers, AMD and Intel GPUs, and these paths, whose shader and buffer changes were reviewed but not rendered: volumetric nebulae, decals, -no_deferred, render-to-texture, and a Vulkan device that only offers a 24-bit depth format. On a driver without clip control the option is ignored, so it behaves as today. The flicker reduction itself is shown by the arithmetic and the unit test; I didn't find a way to measure it in a headless capture.

Anything Else?

While checking every depth reader I noticed three existing issues that aren't part of this PR: Vulkan fog uses the OpenGL depth formula, Vulkan's depth bias maps OpenGL's two parameters the other way round and with the opposite sign, and the soft particle depth formula skips the * 2 - 1 step. Happy to send those separately. I used Claude Code to inventory the depth-dependent code, write the change and run the comparisons.

Distant surfaces flicker against each other because a 24-bit depth
buffer with a 1 m near plane and a 1000 km far plane has almost no
precision left at long range. With the new Reversed Depth option (or the
-reversed_depth flag, which also works where in-game options are off),
the near plane maps to depth 1 and the far plane to 0, closer surfaces
pass with GREATER, and the scene depth buffers are 32-bit float, which
keeps precision nearly constant out to the far plane. The reversed
perspective terms are written out directly, because flipping the normal
ones loses enough float precision to move the far plane by about 5%.

Vulkan's depth range is already [0, 1]. OpenGL needs glClipControl to
use [0, 1], so it is turned on only with OpenGL 4.5 or ARB_clip_control
and otherwise the option is ignored. Shadow maps are reversed too, since
they share the depth test, depth bias changes sign, depth clears use 0,
and the shaders that read depth (fog, volumetrics, decals, soft
particles, lightshafts, fog distance, shadow lookup) follow a new
REVERSED_Z define, so with the option off every shader is unchanged.

@BMagnu BMagnu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This conflates multiple depth buffers, some of which do not behave like typical buffers.
We have the position G-buffer, which feeds some of the shaders like effects-f.
We also have the actual depth buffer, used for depth testing in shadows and rendering.

Reverse depth is useful for the actual depth test. It is decidely not useful for the G-buffer, because we already have purpose built Z-compression active there, which would in fact get substantially worse with flipped Z.

So this PR would need to ensure that the G-buffer is not touched and stays identical.
Similarly, I would really like for the PR to not do multiple things at once for one flag.
Why does activating reverse-Z increase the precision of the depth buffer? That should be a seperate flag, if at all, and only if we figure out that that actually substantially benefits us for the performance it costs.

Also, as a general question:
Does this actually fix an observed issue? Otherwise this is a fair bit of maintenance burden, especially with both passes active, that I'd like to not have if not necessary.

@dustinspace217

dustinspace217 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I went back and tested each point.

The G-buffer: The PR doesn't change the position G-buffer or its Z compression. z-compress.sdr and main-f.sdr are untouched. The position buffer is written from the view-space position, which doesn't depend on the projection. In effect-f.sdr, only the hardware-depth branch changes; the linear_depth path that reads the G-buffer is the same as before.

This does indeed fix an actual issue. I parked MediaVPs 5.0.2 capital ships in a test mission and viewed them from 6 km. On the GTD Orion, window and hatch insets punch through to the hull behind them with normal depth, and render correctly with reversed depth. Moving the camera 1 m at a time makes the broken areas change size from frame to frame, which is the shimmer players sometimse see on distant capships. Reversed depth holds steady.

I tested this on both OpenGL and Vulkan

  • OGL (24-bit depth): one hatch area varied between 795 and 1309 broken pixels with normal depth, 205 to 225 with reversed.
  • Vulkan (32-bit float depth in both modes): 353 to 604 normal, 156 to 160 reversed.

Attached: a close-up for each renderer (top row normal, bottom row reversed) and a full-hull diff of normal vs reversed, where white marks every pixel that differs. Several other ships I checked showed little or nothing at that distance, so it seems to depend on how closely a model stacks its detail.

Precision vs. reversal. Vulkan already uses a 32-bit float depth buffer in both modes, so the fix there comes from the reversal alone. On OGL the reversal only pays off together with a float buffer, which is why the flag also switches OpenGL to GL_DEPTH32F_STENCIL8 (depth/stencil memory goes from 32 to 64 bits per pixel).

Maintenance. That's fair. Here's a split that I think might make it easier to review and maintain:

  1. A refactor with no visible change. The depth comparisons, the sky/far-depth value and the depth-to-distance conversion move into one shared shader include and one C++ helper. Every shader calls those instead of carrying its own #ifdef, so reversed depth ends up being a change in one place.
  2. Reversed depth on Vulkan. Small, since Vulkan's depth range and float buffer already fit.
  3. Reversed depth on OpenGL: the clip-control setup, the float depth buffer and the polygon-offset sign.

Also open to exploring alternative approaches, if you have any suggestions.

orion6k-vulkan-evidence orion6k-vulkan-diff orion6k-opengl-evidence orion6k-opengl-diff vk2-reversed vk2-normal vk-zoom-hatch

glad is regenerated at its existing settings (0.1.36, core 4.3, no loader) with
GL_ARB_clip_control added, so reversed depth checks GLAD_GL_ARB_clip_control
like the other extensions instead of scanning the extension list by hand. The
current generator also makes the extension loop index unsigned.
@BMagnu BMagnu added the LLM-authored For PR's mainly created through LLMs and NOT submitted by badged developers label Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

LLM-authored For PR's mainly created through LLMs and NOT submitted by badged developers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants