Variable-rate shading on an M1: four threads share the work of a coarse pixel
D3D12 variable-rate shading, where one pixel-shader run colours a block of up to 4x4 pixels, is now emulated on an M1 Mac by having each 2x2 group of shader threads shade coarse pixels and share the results.
What changed
Variable-rate shading (VRS) lets a game shade parts of the screen more coarsely. One run of the pixel shader (the program that computes a pixel's colour) then colours a block of 1x2 up to 4x4 pixels. The rate can be set per draw, per triangle from the vertex shader (SV_ShadingRate), or per 16x16 screen tile from a small image. Metal on this M1 Max has no shading rate. Its "rasterization rate maps" shrink parts of the render target instead, which is a different thing. The layer reported tier 0 and ignored the calls. It now reports tier 2, the highest.
The layer emulates coarse pixels inside the pixel shader, with a patch to SPIRV-Cross (the tool that turns the shaders into Metal's language). GPUs run pixel shaders in quads, groups of 2x2 threads that can read each other's values. With the patch, each quad does this:
- It works out the rate from the draw, the triangle and the image tile.
- It picks a 2x2 block of coarse pixels. Each thread shades one of them at its centre, reading its inputs there with
interpolate_at_offset. So derivatives (the differences between neighbouring threads, used for texture filtering) are coarse-pixel sized, as D3D12 requires. - Every pixel copies its colour, and whether it was discarded, from the thread that shaded its coarse pixel (
quad_shuffle). The other threads are marked as helpers, so a store to memory happens once per coarse pixel.

The interesting part
The first version had a fixed thread own each coarse pixel. The colours were right, but a new test case with a triangle edge crossing coarse pixels lost 72 memory writes. On an edge, the owner's own pixel is often outside the triangle, and Metal then runs it as a helper thread. Metal silently drops a helper's stores and atomics, while quad_shuffle still reads its values. Now each quad chooses among up to four block positions one where every covered pixel's owner is itself covered. If none fits, it falls back to 1x1.
A spike first had to show that interpolate_at_offset works 6 pixels away, well beyond the half pixel it is documented for. It does: exact without perspective, within 3.8e-6 with it.
This emulation is correct but saves no work. Every thread still runs the shader.
Numbers
- Full run: 804 pass, 26 fail, 86 skip of 916; 0 newly failing, 7 newly passing.
- Previous entry's run: 797 pass, 26 fail, 92 skip of 915.
- The new api/vrs test: 90,113 checks over 34 cases, all passing.
- The six vkd3d-proton VRS tests, which skipped before, pass: 1,656 checks, up from 63.
- The edge bug: 72 failed checks before the block choice, 0 after (api/vrs).
- pass
- fail
- skip
suite · share of each row's total
- vkd3d-proton (+6)478 pass, 17 fail, 82 skip of 577
- vkd3d177 pass, 9 fail, 4 skip of 190
- apps77 pass, 0 fail, 0 skip of 77
- api (+1)68 pass, 0 fail, 0 skip of 68
- samples4 pass, 0 fail, 0 skip of 4
- before
- after
vkd3d-proton test (test_vrs…) · checks
- (plain)11 before, 90 after
- dxil12 before, 190 after
- image11 before, 205 after
- clip dist.9 before, 39 after
- depth dxbc10 before, 566 after
- depth dxil10 before, 566 after