MOOR security

Dieser Eintrag ist auf Englisch verfasst.

Depth bounds on an M1: test against a copy of the depth buffer

Entry 045796 of 914 tests pass

The D3D12 depth bounds test, which only Apple's newest GPUs have natively, is now emulated on an M1 Mac by reading a copy of the depth buffer in the pixel shader.

What changed

The depth bounds test discards a pixel when the depth already stored there is outside a range (min, max). It doesn't depend on the depth of the thing being drawn. Engines use it to limit light and shadow passes to the pixels whose geometry is at the light's depth. Metal has it only on the Apple10 GPU family, and this M1 Max (Apple7) doesn't have it. The layer now reports DepthBoundsTestSupported and emulates the test.

Before a draw with bounds, the layer copies the depth attachment on a compute encoder. A patch to SPIRV-Cross (0034) then makes the fragment function (Metal's pixel shader) read the stored depth from that copy first and discard the pixel when it's outside the range. With multisampling it builds a mask of the samples in range and hands Metal only those. Consecutive bounds draws share one copy until something writes depth. Pipelines without a pixel shader get a generated one.

What a draw with depth bounds does: copy check, copy, pass, count-only draw, and the test at the fragment function's entry.Draw with DepthBoundsTestEnable → snapshot of the depth attachment still valid?; snapshot of the depth attachment still valid? → end the render pass; compute copy of the depth range into a 2DArray (or MS array) (no: a depth write, another command, or new attachments); snapshot of the depth attachment still valid? → render pass: bind the copy at texture 0, float4(min, max, enabled, 0) at buffer 30 (yes); end the render pass; compute copy of the depth range into a 2DArray (or MS array) → render pass: bind the copy at texture 0, float4(min, max, enabled, 0) at buffer 30; render pass: bind the copy at texture 0, float4(min, max, enabled, 0) at buffer 30 → early fragment tests and an occlusion query?; early fragment tests and an occlusion query? → fragment entry (SPIRV-Cross patch 0034): read the stored depth per sample; outside [min, max] → discard, or clear the sample's bit in sample_mask (no); early fragment tests and an occlusion query? → count-only draw: same VS, generated bounds function, no writes (yes); count-only draw: same VS, generated bounds function, no writes → the real draw, visibility off; the real draw, visibility off → fragment entry (SPIRV-Cross patch 0034): read the stored depth per sample; outside [min, max] → discard, or clear the sample's bit in sample_maskDraw with DepthBoundsTestEnablesnapshot of the depthattachment still valid?end the render pass;compute copy of the depth rangeinto a 2DArray (or MS array)render pass: bind the copy at texture 0,float4(min, max, enabled, 0) at buffer 30early fragment testsand an occlusion query?fragment entry (SPIRV-Cross patch 0034):read the stored depth per sample;outside [min, max] → discard,or clear the sample's bit in sample_maskcount-only draw: same VS,generated bounds function, no writesthe real draw,visibility offno: a depth write, anothercommand, or new attachmentsyesnoyes
What a draw with depth bounds does: copy check, copy, pass, count-only draw, and the test at the fragment function's entry. (Mermaid source)
Gallery scene depthbounds: a ground in perspective writes its depth (SV_Depth, rolling hills); then three flat full-screen draws with DepthBoundsTestEnable and no depth test, each with its own OMSetDepthBounds range, colour only the pixels whose stored depth lies in the range, so each band is a contour line of the ground; animated, the bands move away from the camera. All scenes in the gallery

The interesting part

The occlusion query was the tricky part. It counts the samples that pass. With [earlydepthstencil], Metal counts at the early depth test, before the shader runs, so it also counts samples that the bounds test discards. While a query is active, such a draw is therefore drawn twice. The first pass counts, using a generated shader that does only the bounds test and writes no colour. The second draws for real, with counting switched off.

The review found a second problem: multisampling combined with a pixel shader that writes to buffers. Metal runs such a shader once per sample, where D3D12 runs it once per pixel. Entry 041 added a gate (patch 0032) that lets only the first covered sample do the writes. The bounds test has to feed into that gate: a discarded pixel must not write, and "first covered" has to mean "first covered and in range".

While tracking down the last miscount, a small spike found something unrelated to depth bounds. Metal clips the usual full-screen triangle, which reaches past the screen edges, into pieces. A multisampled pixel on a seam between two pieces runs the shader twice, each run with half of the samples. The once-per-pixel gate then writes twice on those pixels. The test now draws a triangle that needs no clipping. The general case is open.

The gallery scene shows the test from the application's side. A terrain is drawn once, then three coloured bands are drawn as flat quads over the whole screen. The depth bounds cut each band down to a thin slice of the terrain's depth, which moves from near to far.

Numbers

  • Full run: 796 pass, 26 fail, 92 skip of 914; 0 newly failing, 1 newly passing, the new api/depth_bounds (246 checks over 21 cases).
  • Previous run: 795 pass, 26 fail, 92 skip of 913.
  • In the spike, 9 of 40 pixels ran the "first sample" twice with the clipped triangle and none with the unclipped one.
Tests by suite at the end of this entry: 796 of 914 pass (data as CSV)
  • pass
  • fail
  • skip

suite · share of each row's total

  1. vkd3d-proton472 pass, 17 fail, 88 skip of 577
  2. vkd3d177 pass, 9 fail, 4 skip of 190
  3. apps77 pass, 0 fail, 0 skip of 77
  4. api (+1)66 pass, 0 fail, 0 skip of 66
  5. samples4 pass, 0 fail, 0 skip of 4