Sampling twice to get a border colour Metal doesn't have
Metal lacks several D3D12 sampler and rasterizer features, so this session emulates them inside the generated shaders: arbitrary border colours, LOD bias and cull distances.
What changed
This session filled gaps between D3D12 and Metal. The shader translator, a patched SPIRV-Cross (the tool that turns SPIR-V shader code into Metal Shading Language), now emulates three things Metal's hardware path doesn't give me:
- Custom border colours. A sampler's "border colour" is what a texture read returns outside the texture. D3D12 allows any RGBA value; Metal only offers transparent black, opaque black and opaque white.
- Sampler LOD bias. This is an offset added to the mip level a sample picks. On my M1 Max, Metal's
lodBiashad no measurable effect, and nearest-mip selection rounded half down where D3D12 rounds up. - Cull distances. A vertex shader can write per-vertex values, and a triangle whose value is negative at all three vertices is dropped. Metal has clip distances but no cull distances.
Other changes: BeginRenderPass/EndRenderPass, fractional viewports, resource alignment rules, and a fix for a flaky timestamp test. After the full run, small textures that Metal wants at 16 KB can be placed at D3D12's 4 KB alignment: the reported size includes the slack, and the texture starts at the next 16 KB boundary inside it.

The interesting part
The border-colour trick uses only the colours Metal already has. Each emulated sampler is really two Metal samplers: one with a transparent-black border, one with an opaque-white border. The shader samples through both. Inside the texture the results are equal. Outside it they differ by exactly how much the border contributed to the filtered result, so a + (b − a) × colour gives the D3D12 answer, including filtered edges. Afterwards a test with 256 views showed that D3D12 also swizzles the border colour by the view's component mapping. Metal's view swizzle only reaches texel data, so the view's swizzle now also sits in the texture's descriptor record for the shader to apply.
Cull distances use a similar move. The pixel shader sees one interpolated value. It also sees the barycentric coordinates (the weights of the three vertices at this pixel) and screen-space derivatives of both. That is enough to solve a 2×2 system for the value at each vertex and discard every pixel of a triangle that should have been culled. It costs pixel-shader work, but the results are correct.
The timestamp flake was a different kind of surprise. A resolve sometimes read 0 because Metal occasionally drops an encoder (a batch of GPU commands) that contains no work, and the barrier attached to it goes with it. A small test program failed 2 in 60 runs with an empty encoder. With a 4-byte copy added to the encoder, it failed 0 in 600 runs.
Numbers
- Full suite: 471 pass, 112 fail, 190 skip of 773, against 455 / 126 / 192 of 773 at the start of the session. Failed checks fell from 35,992 to 14,238.
- test_custom_border_color_srgb: 240 failed checks before, 0 after.
- test_undefined_structured_raw_read_typed: 396 → 28 failed checks per variant.
- test_resource_alignment: 1231 → 450 failed checks, then 442.
- test_suballocate_small_textures_size: 12,822 → 2,717 failed checks after the full run; not included in the full-run numbers above.
- pass
- fail
- skip
suite · share of each row's total
- vkd3d-proton (+35)320 pass, 78 fail, 179 skip of 577
- vkd3d (+4)145 pass, 34 fail, 11 skip of 190
- api3 pass, 0 fail, 0 skip of 3
- samples (+2)3 pass, 0 fail, 0 skip of 3