A frame into Metal: take a texture per plane, hold until the command buffer completes

The render side first creates a texture cache; it follows the device and is created once. The decoded frame is a pixel buffer, and one texture is taken from it per plane: one for luma, one for chroma, with the format decided by bit depth and whether chroma is subsampled.
These two textures are borrowed from the cache, not new owners; what actually keeps this memory alive is the pixel buffer that was retained once in the decode callback. So the rule here is: the borrowed textures and the retained buffer must both live until the command buffer completes, and are released together in the completion callback. The cache's own retirement is managed by a shared state layer too: it tracks the number in flight and the cache pending release, both settled in the completion callback.
Handing to Vulkan: what is shared is the surface
Rendering draws the planes into an RGBA texture, and that texture is created from a surface — that surface is what is handed to Vulkan.
The input layer on the VK side connects like this: it takes the surface from the Metal context and only swaps it when the size or the surface itself changes; when creating the image it declares the external type as “import a Metal surface” and imports that surface directly as a VkImage, with the format mapped as 8-bit RGBA.
What crosses over is not Metal's texture object but that surface. The same memory has one view in each API, the same approach as the handle on Windows and the buffer on Android, except that here what is shared is the surface.
There is no explicit cross-API synchronization object here. The order rests on convention: within the same frame, Metal produces the RGBA first, then VK takes over; the side evidence in the sample is that several consecutive frames read back correctly — whether there is tearing was never actually measured.
Native direct presentation: the surface belongs to the host, submission happens in the renderer
When the switch is off, the window side gets the view provided by the host, and the renderer treats the layer inside that view as its own target layer: each frame it takes a drawable object, draws the picture, commits, and ends a transaction so the frame actually reaches the screen.
On this side the very notion of “layer” comes from the host: the view itself belongs to the host, and what the renderer gets is only the layer on top of it. So size and visibility are also the host's business — the renderer only draws into this layer. This differs from the other two platforms: the Windows window implementation creates its own swapchain, and the Android renderer creates its own surface.
When the switch is on, the window object is replaced with the Vulkan kind and the platform renderer becomes offscreen — its RGBA no longer goes to the screen but takes the path above to Vulkan. What the two paths exclude is the presentation step, consistent with the other two platforms.
The places on the MoltenVK path that crash outright: bootstrap, empty modules, row alignment, compatibility switch

This whole thing runs on a translation layer to Metal laid over a Vulkan implementation. These places crash outright if not handled:
Bootstrap: on a device, the layer must be loaded from the framework path and the function table initialized through its loading entry point, falling back to global symbols only if that is not found; if neither is present, it drops straight back to the native Metal leg.
Empty shader modules: the translation layer segfaults outright on a module with a null handle, so when building the pipeline, any stage without a module is skipped.
Surface row-byte alignment: the bytes per row of this surface must satisfy an alignment requirement, or it asserts at certain widths — the code computes with the wider alignment.
Compatibility switch: both the decode session and rendering must declare that frame Metal compatible; without it, the texture cache mapping “succeeds” but with an invalid layout, and the first sample is a page fault. The judgment in the earlier hardware-decode integration article — rather omit a key than include the GL-compatible key — exists precisely so the session is not built on another deprecated path.
Color space: the pass-through tier must configure the layer with the extended linear color space and declare that the layer will display extended dynamic range content.
Presentation timing: presentation on the VK side is chained with semaphores and fences, with timing points recorded in the throttle, not waiting on synchronization.
The per-frame test in the input layer, and who owns that surface
The input layer is not rebuilt every frame: each time it reads the surface's size and identity, and rebuilds the image only when the size or the surface itself has changed. This is the same class of problem as “the test must count the context too” on Android — when the same resource is reused, looking only at the name or number yields an image with the old size.
That surface is created by the render side, and the importing party only borrows it; so the release order has to follow the render side's rhythm, and releasing early means sampling memory that has already been reclaimed. The ownership on this side is: the surface belongs to rendering, the texture to the cache, the buffer's reference to decoding.
The presentation side: acquire the image, wait on the fence, submit, present
Presentation on the Vulkan window side is not a single call: it chains acquiring the image, waiting on the fence, submitting and presenting, and records timing points for the presentation result, so a slow call does not go unnoticed.
Multithreaded submission has a rule too: threads sharing one device must stay synchronized when submitting commands, via a submission lock. So the submission lock must be taken before submitting.
The reverse direction: VK's result goes back to Metal
Vulkan's composited output surface can also be exported as a system surface, then wrapped as an image buffer and handed to Metal to read back; the hardware-encode side consumes the buffer on that surface. So the pair of directions is complete: input is “Metal produces, VK receives”, output is “VK produces, Metal receives”.
Of the two paths, one lands on the host's layer and is submitted by the renderer; the other hands the surface to Vulkan, sharing the RGBA and the surface behind it. Compared with the other two platforms, the shared object takes a different form, but the job is the same conversion with two destinations.
The avox engine is open source on GitHub: github.com/xxxzhou/avox, where the texture cache, that shared surface and the MoltenVK guards all live.