Inverted viewports for free, and the depth clamp that wasn't there
mtl12 now reports inverted viewport depth, which Metal does natively, and clamps the depth a pixel shader writes to the viewport's range, which Metal doesn't do.
What changed
A D3D12 viewport has a depth range, MinDepth to MaxDepth, that the 0-to-1 depth of each triangle is mapped into. A newer D3D12 cap (a feature flag the device reports), InvertedViewportDepthFlipsZSupported, says that MinDepth may be larger than MaxDepth: depth then runs backwards, and the triangle is still drawn. Metal's MTLViewport already behaves this way. Depth flips, nothing is culled, and clipping uses the range. I wrote an api test for it, api/inverted_viewport_depth, with 13 cases on a 32-bit float depth buffer, and reported the cap.
Three of those cases failed, and not because of inversion. In D3D12, depth that a pixel shader writes itself (SV_Depth) is clamped to the viewport's range, smaller end to larger end. Metal clamps it to [0, 1] and no further. A shader writing 0.9 with a viewport from 0.2 to 0.8 stored 0.9, where D3D12 stores 0.8. Ranges that aren't inverted were just as wrong, so this was an old gap that the new test happened to find.
The interesting part
The fix is a clamp at the end of the pixel shader. The cost is where it lives. The shader needs each viewport's range, which comes from a small per-draw buffer, and draws that read such a buffer can't go into Metal's indirect command buffers (pre-recorded draw lists that the GPU replays). So the clamp is in a pipeline variant, a second copy of the pipeline that is made on first use, like the one for variable-rate shading. Only a draw whose pixel shader writes depth, with a depth buffer bound and a viewport range other than [0, 1], uses it. Every other draw keeps the plain pipeline, where Metal's own [0, 1] clamp is already right.
The clamp itself is a patch to SPIRV-Cross, the library that turns shaders into Metal's language (patch 0046). It adds the line depth = clamp(depth, min, max) with the bounds for the viewport the triangle went to, adding a viewport index input when the shader has none. When a draw also uses a depth bounds test, both features share one buffer slot, so the clamp reads its ranges through the other feature's argument. A review after the first commit asked for a test of that path, and the test now has three depth bounds cases.
Switching Metal's depth clip mode to clamp would have been shorter. It did clamp written depth to the range, but it also stops clipping, which D3D12 keeps while DepthClipEnable is on.
Numbers
api/inverted_viewport_depthbefore the clamp: 215 checks, 48 failed, the three pixel-shader-depth cases with 16 pixels each. After it: 215 ok. With a [0, 1] case and two viewport-index cases: 263 ok. With the depth bounds cases: 311 ok.- Full run: 870 pass, 26 fail and 68 skip of 964. 1 test newly passes, the new api test, and none newly fail.
- ok
- failed
run · checks
- before the clamp167 ok, 48 failed of 215
- clamp variant215 ok, 0 failed of 215
- + 3 cases263 ok, 0 failed of 263
- + 3 bounds cases311 ok, 0 failed of 311
- pass
- fail
- skip
suite · share of each row's total
- vkd3d-proton499 pass, 13 fail, 65 skip of 577
- vkd3d178 pass, 9 fail, 3 skip of 190
- api (+1)93 pass, 0 fail, 0 skip of 93
- apps80 pass, 0 fail, 0 skip of 80
- wine-dxgi16 pass, 4 fail, 0 skip of 20
- samples4 pass, 0 fail, 0 skip of 4