Choosing the architecture with three experiments
Three small experiments settled how to run D3D12 games on Apple Silicon: directly on Metal 4, with an open-source shader pipeline, not through Vulkan.
What changed
I'm building an open-source Direct3D 12 implementation that runs Windows games under Wine on Apple Silicon Macs, using Metal. Today the only production-quality option is Apple's D3DMetal, which is closed source.
I considered three designs:
- A: translate D3D12 directly to Metal 4.
- B: reuse vkd3d-proton, the D3D12-on-Vulkan layer Proton uses on Linux and the Steam Deck, on top of a Vulkan-on-Metal driver (MoltenVK or KosmicKrisp).
- C: a hybrid, with my own D3D12 layer and existing open-source shader translators.
Rather than argue it out, I wrote a small experiment against the riskiest assumption of each design. The decision is A, with C's shader pipeline: DXIL → dxil-spirv → SPIR-V → SPIRV-Cross → Metal Shading Language.
The interesting part
On paper, B looks like the obvious shortcut, since vkd3d-proton is mature. In practice it can't create a device on either Vulkan-on-Metal driver:
- vkd3d-proton requires transform feedback, and MoltenVK doesn't have it:
Lacking support for transform feedback. - KosmicKrisp has a lot, but it also lacks transform feedback and geometry shaders.
Both features belong to D3D12 feature level 11_0, the minimum any D3D12 device has to support. Metal has neither, so they have to be emulated whichever way I go. Under B, that emulation would live inside a forked Vulkan driver, behind two translation layers. Under A, I write it once, directly against Metal. The gap B was supposed to skip is the same work in a worse place.
A's risk was D3D12's descriptor heap, a GPU-visible table of up to a million resource descriptors that shaders index freely. A Metal 4 experiment with 1,048,576 mixed entries and 4.2 million random GPU reads returned zero mismatches.
Numbers
Descriptor heap on Metal 4, M1 Max (spike A):
| Native | Rosetta | |
|---|---|---|
| 4M random heap reads | 3.1 ms | 3.3 ms |
| Mismatches | 0 | 0 |
| Creating a texture view | 0.2 µs | 0.5 µs |
Rosetta matters because Wine's D3D stack runs as x86_64 under Rosetta.
Shader path (spike C): 596 of 760 test shaders (78.4%) from dxil-spirv's own test corpus produced code that Metal accepts. The remaining failures are:
- real Metal gaps: 64-bit atomics, fp64 in buffers, geometry shaders;
- fixable translator bugs, mostly in tessellation.
- accepted
- rejected
shader stage · shaders
- vertex33 accepted, 2 rejected of 35
- pixel347 accepted, 50 rejected of 397
- compute212 accepted, 79 rejected of 291
- hull4 accepted, 4 rejected of 8
- domain0 accepted, 11 rejected of 11
- geometry0 accepted, 18 rejected of 18
"Metal accepts the code" doesn't mean the shader is correct. Correctness is measured by the test suites.
vkd3d-proton on MoltenVK under Wine 11.18 (spike B): 2,445 tests ran, 675 were skipped, and every test that needs a device skipped because no device could be created.