mtl12: Direct3D 12 auf Metal
Ein frühes Forschungsprojekt von Fabian Moor: eine quelloffene Direct3D-12-Schicht, mit der Windows-Programme unter Wine auf Apple-Silicon-Macs über Metal laufen sollen. Mit Devlog, Testzahlen und Galerie.

Stand des Projekts
- Konformitätstests
- 873 von 967 bestanden, 26 fehlgeschlagen, 68 übersprungen; vollständiger Lauf aus Eintrag 077 vom 3. Oktober 2026
- Spiele
- Laufen noch keine.
- Entwicklung
- Fabian Moor entwickelt mtl12 und prüft jeden Schritt gegen Tests.
- Einordnung
- Ein frühes Forschungsprojekt, kein fertiges Produkt.
Über das Projekt
Windows-Spiele und -Programme, die Direct3D 12 verwenden, laufen auf Apple-Silicon-Macs heute vor allem über Apples D3DMetal, das nicht quelloffen ist. mtl12 untersucht eine offene Alternative: eine Implementierung von d3d12.dll und den benötigten Teilen von dxgi.dll auf Metal 4, die unter Wine läuft. Das Projekt steht unter der LGPL-2.1-or-later. Bei dieser Arbeit ist auch ein Sicherheitsbefund im GPU-Stack von Apple entstanden: Stellungnahme.
Mehr zum Projekt
Am Anfang stand keine Grundsatzdiskussion, sondern je ein kleines Experiment gegen die riskanteste Annahme von drei Architekturen. Der Umweg über vkd3d-proton und eine Vulkan-auf-Metal-Schicht scheiterte bereits beim Erstellen eines Geräts. Gewählt wurde eine eigene D3D12-Schicht direkt auf Metal 4. Shader übersetzt sie mit bestehenden Open-Source-Werkzeugen: dxil-spirv von Hans-Kristian Arntzen und SPIRV-Cross, betreut von der Khronos Group.
Fabian Moor entwickelt mtl12 als eigenes Forschungsprojekt. Er legt Architektur und Regeln fest und prüft jeden Schritt gegen Tests. Das Devlog entsteht aus dieser Arbeit und ist auf Englisch verfasst.
Bei einer so grossen Schnittstelle zählt nur, was sich belegen lässt. Gemessen wird an den öffentlichen Konformitätssuiten von vkd3d und vkd3d-proton sowie an Referenzbildern, die Microsofts Software-Renderer WARP erzeugt. Jeder Devlog-Eintrag nennt seine Testzahlen, auch wenn sie ernüchternd sind. Eine Clean-Room-Regel schliesst Beiträge aus, die aus dem Disassemblieren oder Dekompilieren proprietärer Software wie D3DMetal stammen.
Für die Sicherheitsarbeit ist das Projekt aus zwei Gründen interessant. Erstens zeigt es, wie sich komplexe Entwicklungsarbeit nachvollziehbar belegen lässt: mit klaren Grenzen, überprüfbaren Belegen und Prüfung statt blindem Vertrauen. Zweitens bricht Metal bei vielen Fehlern den ganzen Prozess ab, statt einen Fehlercode zu liefern. Eingaben müssen deshalb geprüft werden, bevor sie die Grafikschnittstelle erreichen – ein Grundsatz, der auch für sichere Software allgemein gilt.
Einträge
Die Einträge sind auf Englisch verfasst. 72 Einträge, neueste zuerst. Diese Seite zeigt 077 bis 065.
- Entry 077
Triangle fans from Execute
Indirect, where only the GPU knows how many there are mtl12 now draws triangle fans and strips with adjacency that a D3D12 program issues with Execute
Indirect, by sizing and converting their indices on the GPU. - Entry 076
Three formats the tables say Metal can write, and one that a buffer actually takes
mtl12 now tells D3D12 programs they can write BGRA8 pixels from compute shaders; two other formats Apple's tables list as writable were left out, because on an M1 their buffer stores vanish.
- Entry 074
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.
- Entry 073
Asking a device about itself: ID3D12
Device Configuration1 mtl12 devices and device factories now answer ID3D12
Device Configuration1, which reports how the device was set up and can pull a root signature out of a compiled shader library by name. - Entry 072
Culling triangles in a pass that has no pixel shader
mtl12 now honours Direct3D 12 cull distances in depth-only passes by generating an empty pixel shader for SPIRV-Cross to add the discard to, and a gallery scene found a second cull-distance bug on the way.
- Entry 071
Buffer views that start in the middle of an element
mtl12 now supports Direct3D 12 buffer views given in bytes, including 12-byte-per-element views that start 4 or 8 bytes into an element, which Metal's texture buffers can't start at.
- Entry 070
Counting, flipping and packing: three small Direct3D 12 options on Metal
mtl12 now reports three more optional Direct3D 12 features: indirect draws that know their own index, upside-down viewports, and resources packed tighter than 64 KB.
- Entry 069
Reading past the end of a vertex buffer, the Direct3D way
Direct3D 12 promises zeros when a draw reads past the end of a vertex buffer and Metal promises nothing, so mtl12 now has each vertex shader check its own reads.
- Entry 068
Triangle fans on a GPU API that has none
Metal can't draw triangle fans, so mtl12 rewrites each fan into a triangle list on the GPU just before the draw, keeping the vertex that flat shading reads in the right place.
- Entry 067
A zero the Metal compiler must not see: keeping wave operations inside their loop
A common GPU loop pattern gave wrong results on Metal because the compiler could prove a break was always taken; reading one constant zero through a volatile pointer fixes it.
- Entry 066
Shader Model 6.8 was already there: it took two flags and one validation rule
The layer now tells D3D12 applications it supports Shader Model 6.8. The shader translation already handled everything 6.8 adds; the one fix needed was letting buffers start in the unordered-access state.
- Entry 065
Metal's SIMD-group functions take no 64-bit values, so they travel in two halves
Any D3D12 shader that ran a wave operation on a 64-bit integer failed to compile on Metal, because Metal's SIMD-group functions have no 64-bit overloads. A SPIRV-Cross patch now splits such values into two 32-bit halves, or loops over the lanes for sums, products, min and max.
Galerie
Die Beschreibungen der Szenen sind auf Englisch.
Small D3D12 scenes, each exercising one feature. The first five were rendered by our D3D12 on Metal under Wine on (a development build, including work not yet committed at that time). The image at the top of this page shows them next to the first triangle from entry 005.

blend three discs at alpha 0.6, blended with SRC_ALPHA / INV_SRC_ALPHA 
depth two triangles that pass through each other, D32_FLOAT depth with LESS; nearer is brighter 
instanced one quad drawn 144 times from a per-instance vertex buffer (offset, rotation, colour) 
texture a 4x4 texture uploaded with CopyTextureRegion, point (left) and linear (right) sampling 
mandelbrot a compute shader writing into a UAV texture, 256 iterations, smooth colouring
Feature scenes ()
One scene per devlog entry's visible feature, rendered on from a development build. The entry link under each scene leads to the entry that explains it. No WARP reference exists for these scenes; unlike the room sample below, they are not automated tests yet.

clear Entry 002 ClearRenderTargetView only, whole target then rects, one call per colour; no draw 
replay Entry 004 one command list recorded once, executed six times; the CPU moves the hexagon in between ![Scene indirect: ExecuteIndirect, 48 records of [root constants | draw] making three rings](/wissen/mtl12/media/gallery/indirect.png)
indirect Entry 008 ExecuteIndirect, 48 records of [root constants | draw] making three rings 
border Entry 011 arbitrary sampler border colours (amber, rose, sky) with linear filtering into the edge 
samplemask Entry 012 the same star through SampleMask 0x1, 0x3, 0x7, 0xF on 4x MSAA, resolved 
depthclear Entry 013 D16 depth cleared in bands with rects; a depth ramp drawn with LESS fills each band 
tiled Entry 014 a reserved texture with the tiles spelling "12" remapped to NULL, which must read zero 
samplers Entry 015 2048 samplers in one heap, each with its own border colour on a ramp 
minlod Entry 016 nine flat-colour mips sampled with MinLOD 0-7 per column: a staircase 
icb Entry 018 ExecuteIndirect with a count buffer: 32 commands recorded, 21 counted, 21 drawn 
streamout Entry 019 stream output captures a star; the buffer is drawn again as four tinted copies 
predication Entry 020 a heart drawn twice: 99 predicated draws (left) and 99 predicated copies (right) 
derivatives Entry 022 a compute shader samples a floor with automatic mips: one colour per mip, bands to the horizon 
barycentrics Entry 024 a heightfield in perspective: SV_Barycentrics edges, GetAttributeAtVertex colour at each vertex 
intborder Entry 025 a uint texture point-sampled past its edges through three UINT border colours; the third (0x01000007) survives only as bits 
tessellation Entry 027 three triangle patches, fractional_odd factors 3, 6.5 and 12, filled then in wireframe; the domain shader bulges the inside 
gsstreamout Entry 030 a GS turns 7 points into ring strips, captured by stream output; the right copy draws the capture as a list, every other triangle darker 
meshlets Entry 031 one DispatchMesh: amplification threadgroups keep the cells of a 32x18 grid inside a heart, each mesh threadgroup draws a hexagon with a colour per triangle rayquery Entry 033 a compute shader traces one RayQuery per pixel into a TLAS of seven instances (a floor BLAS, six of a pyramid BLAS) and one shadow ray per hit 
rtindirect Entry 035 one ExecuteIndirect of four DispatchRays: three cameras each ray-trace a quarter (closest-hit, shadow miss); the bottom-left command asks for 2^31 rays and is dropped typeduav Entry 037 typed UAV loads: a compute pass reads each texel of an R16_UINT UAV and writes it back advanced by its ring's speed (read-modify-write, TypedUAVLoadAdditionalFormats); the phase colours five colour wheels 
rov Entry 041 twenty translucent cards blended "over" in the pixel shader through a RasterizerOrderedTexture2D in a 4x MSAA pass; each card lies on the one before, except along each card's inner diagonal, where two fragments of one pixel race views Entry 042 one draw of a cube through a four-view pipeline: each view (SV_ViewID) turns the camera a quarter turn further and lands in its own viewport, so the quadrants show four sides feedback Entry 044 a floor in perspective sampled with WriteSamplerFeedbackGrad into a MIN_MIP map of 16x16-texel regions; the right half is the map decoded with ResolveSubresourceRegion, each region in the colour of the finest mip sampled there (teal near, amber at the horizon, grey behind the camera); animated, the floor turns once depthbounds Entry 045 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 conservative Entry 046 a 32x18 target enlarged 20 times, with a pixel grid and the exact triangles as white lines: three thin slivers drawn normally (left) break into dashes or vanish, and with ConservativeRaster ON (right) light every pixel they touch; animated, the slivers turn one full circle vrs Entry 047 a fine spiral drawn with a shading-rate image (one rate per 16x16 tile), like foveated rendering: 1x1 near the yellow focus ring (grey), 2x2 around it (teal) and 4x4 elsewhere (purple), where the spiral turns into 4-pixel blocks; the tint comes from the shader's SV_ShadingRate; animated, the focus circles the centre once lines Entry 048 a 64x36 target enlarged 10 times, with a pixel grid and the exact segments as white lines: five spokes drawn as ALIASED (top left, Metal's own lines), QUADRILATERAL_NARROW (top right, 1 pixel wide), QUADRILATERAL_WIDE (bottom left, 1.4 pixels) and ALPHA_ANTIALIASED (bottom right, coverage blended into the background) lines; animated, the spokes turn a fifth of a circle workgraph Entry 049 a quadtree built by a work graph: one recursive broadcasting node gets fifteen 128-pixel squares from DispatchGraph and, where its 8x8 samples disagree on inside or outside the Mandelbrot set, sends records for its four quarters back to itself, down to 4 pixels; leaves are coloured by depth, from dark blue (0) to orange (5); animated, the view zooms in 6 times and back 
startlocations Entry 066 nine draws, direct and indexed, in six rows: each reads SV_StartVertexLocation (StartVertexLocation, or BaseVertexLocation when indexed, is 6 x row) for its row and SV_StartInstanceLocation for its first column, so every quad sits at column StartInstanceLocation + SV_InstanceID; each draw's instance 0 is lighter; gaps fall between draws in rows 2 and 4 flipindirect Entry 070 one ExecuteIndirect of 60 draw-only records whose INCREMENTING_CONSTANT index places each quad of a spiral, run in the left half with a normal viewport and in the right half with a negative height, which mirrors it; animated, the spiral turns once cullnops Entry 072 a depth pre-pass without a pixel shader culls a grid of triangles by their distance from the white circle (SV_CullDistance); the colour pass draws all of them dim and, with depth test EQUAL, bright where the pre-pass left depth: triangles fully outside are culled, triangles the circle crosses are kept whole; animated, the circle grows and shrinks depthclamp Entry 074 three viewports, one per panel, get the same sine wave as pixel-shader depth (SV_Depth); a second pass plots the depth each column kept. Left, range [0, 1]: the whole wave. Middle, [0.25, 0.75], and right, inverted 0.9 to 0.4: the wave cut flat at the white lines, the viewport's range; animated, the wave scrolls one period 
hdr Entry 050 the first presented frame of an HDR10 swap chain (R10G10B10A2, PQ, BT.2020) as the layer's SDR preview: 100 nits is white and brighter light clips, so the log grey ramp (0.1 to 1000 nits, a tick at each decade) turns white at the 100-nit tick; the bottom band is every hue at 80 nits 
trianglefan Entry 068 left, one triangle fan of 26 vertices, a disc of 24 wedges, each coloured by its provoking vertex, so the colours step around the rim in order; right, one indexed draw with the strip cut 0xFFFFFFFF: a hexagon, a square and a pentagon, three fans split by cuts, each around its own hub, with no slivers joining them vertexbounds Entry 069 a grid of 40 x 22 vertices coloured from a vertex buffer, read through a view that covers only part of it; vertices past the view's end read (0, 0, 0, 1), shown as flat grey, and the triangles across the boundary blend into it
The first five were checked by hand against values that follow from the scene, not from our output. All five matched exactly:
- blend, purple-only pixel (320, 80): 0.6 × (147, 51, 234) + 0.4 × (22, 22, 30) = (97, 39, 152);
- the background everywhere is (22, 22, 30), the clear colour;
- texture, point-sampled texels (0, 0) and (3, 2): (147, 51, 234) and (45, 212, 191);
- mandelbrot, inside the set (400, 180): the background colour.
derivatives was checked the same way at pixel (339, 345): uv's derivatives give LOD log2(1.208) = 0.27 between the teal and blue mips, so (48, 206, 207) is expected, and the render has (48, 205, 208).
Test samples

room Entry 023 a game-style frame: a sun shadow map, an instanced lit scene in 16-bit float, a compute pass that marches the light shafts through the shadow map, and a tonemap; an automated test, checked against an analytic ray-traced reference
mtl12 ist ein Forschungsprojekt und kein Versprechen, dass bestimmte Spiele laufen werden. Das Devlog dokumentiert jeden Schritt mit seinen Zahlen, auch die Rückschläge.