Draw `MTLTexture` to `CAMetalLayer`

I am drawing stuff onto an off-screen MTLTexture. (using Skia Canvas)

At a later point, I want to render this MTLTexture into a CAMetalLayer to display it on the screen.

Since I was using Skia for the off-screen drawing operations, my code is quite simple and I don't have the typical Metal setup (no MTLLibrary, MTLRenderPipelineDescriptor, MTLRenderPassDescriptor, MTLRenderEncoder, etc).

I now simply want to draw that MTLTexture into a CAMetalLayer, but haven't figured out how to do so simply.

This is where I draw my stuff to the MTLTexture _texture (Skia code):

- (void) renderNewFrameToCanvas(Frame frame) {
  if (_skContext == nullptr) {
    GrContextOptions grContextOptions;
    _skContext = GrDirectContext::MakeMetal((__bridge void*)_device,
                                            // TODO: Use separate command queue for this context?
                                            (__bridge void*)_commandQueue,
                                            grContextOptions);
  }

  @autoreleasepool {
    // Lock Mutex to block the runLoop from overwriting the _texture
    std::lock_guard lockGuard(_textureMutex);

    auto texture = _texture;

    // Get & Lock the writeable Texture from the Metal Drawable
    GrMtlTextureInfo fbInfo;
    fbInfo.fTexture.retain((__bridge void*)texture);
    GrBackendRenderTarget backendRT(texture.width,
                                    texture.height,
                                    1,
                                    fbInfo);

    // Create a Skia Surface from the writable Texture
    auto skSurface = SkSurface::MakeFromBackendRenderTarget(_skContext.get(),
                                                            backendRT,
                                                            kTopLeft_GrSurfaceOrigin,
                                                            kBGRA_8888_SkColorType,
                                                            nullptr,
                                                            nullptr);

    auto canvas = skSurface->getCanvas();
    auto surface = canvas->getSurface();

    // Clear anything that's currently on the Texture
    canvas->clear(SkColors::kBlack);

    // Converts the Frame to an SkImage - RGB.
    auto image = SkImageHelpers::convertFrameToSkImage(_skContext.get(), frame);
    canvas->drawImage(image, 0, 0);

    // Flush all appended operations on the canvas and commit it to the SkSurface
    canvas->flush();

    // TODO: Do I need to commit?
    /*
    id<MTLCommandBuffer> commandBuffer([_commandQueue commandBuffer]);
    [commandBuffer commit];
     */
  }
}

Now, since I have the MTLTexture _texture in memory, I want to draw it to the CAMetalLayer _layer. This is what I have so far:

- (void) setup {
  // I set up a runLoop that calls render() 60 times a second.
  // [removed to simplify]

  _renderPassDescriptor = [[MTLRenderPassDescriptor alloc] init];
  
  // Load the compiled Metal shader (PassThrough.metal)
  auto baseBundle = [NSBundle mainBundle];
  auto resourceBundleUrl = [baseBundle URLForResource:@"VisionCamera" withExtension:@"bundle"];
  auto resourceBundle = [[NSBundle alloc] initWithURL:resourceBundleUrl];
  auto shaderLibraryUrl = [resourceBundle URLForResource:@"PassThrough" withExtension:@"metallib"];
  
  id<MTLLibrary> defaultLibrary = [_device newLibraryWithURL:shaderLibraryUrl error:nil];
  id<MTLFunction> vertexFunction = [defaultLibrary newFunctionWithName:@"vertexPassThrough"];
  id<MTLFunction> fragmentFunction = [defaultLibrary newFunctionWithName:@"fragmentPassThrough"];
  
  // Create a Pipeline Descriptor that connects the CPU draw operations to the GPU Metal context
  auto pipelineDescriptor = [[MTLRenderPipelineDescriptor alloc] init];
  pipelineDescriptor.label = @"VisionCamera: Frame Texture -> Layer Pipeline";
  pipelineDescriptor.vertexFunction = vertexFunction;
  pipelineDescriptor.fragmentFunction = fragmentFunction;
  pipelineDescriptor.colorAttachments[0].pixelFormat = MTLPixelFormatBGRA8Unorm;
  
  _pipelineState = [_device newRenderPipelineStateWithDescriptor:pipelineDescriptor error:nil];
}
    
- (void) render() {
  @autoreleasepool {
    // Blocks until the next Frame is ready (16ms at 60 FPS)
    auto drawable = [_layer nextDrawable];
    
    std::unique_lock lock(_textureMutex);
    auto texture = _texture;
    
    MTLRenderPassDescriptor* renderPassDescriptor = [[MTLRenderPassDescriptor alloc] init];
    renderPassDescriptor.colorAttachments[0].texture = drawable.texture;
    renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear;
    renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor();
    
    id<MTLCommandBuffer> commandBuffer([_commandQueue commandBuffer]);
    
    auto renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor];
    [renderEncoder setLabel:@"VisionCamera: PreviewView Texture -> Layer"];
    [renderEncoder setRenderPipelineState:_pipelineState];
    [renderEncoder setFragmentTexture:texture atIndex:0];
    [renderEncoder endEncoding];
    
    [commandBuffer presentDrawable:drawable];
    [commandBuffer commit];
    
    lock.unlock();
  }
}

And along with that, I have created the PassThrough.metal shader which is just for passing through a texture:

#include <metal_stdlib>
using namespace metal;

// Vertex input/output structure for passing results from vertex shader to fragment shader
struct VertexIO
{
    float4 position [[position]];
    float2 textureCoord [[user(texturecoord)]];
};

// Vertex shader for a textured quad
vertex VertexIO vertexPassThrough(const device packed_float4 *pPosition  [[ buffer(0) ]], const device packed_float2 *pTexCoords [[ buffer(1) ]], uint vid [[ vertex_id ]]) {
    VertexIO outVertex;

    outVertex.position = pPosition[vid];
    outVertex.textureCoord = pTexCoords[vid];

    return outVertex;
}

// Fragment shader for a textured quad
fragment half4 fragmentPassThrough(VertexIO inputFragment [[ stage_in ]], texture2d<half> inputTexture  [[ texture(0) ]], sampler samplr [[ sampler(0) ]]) {
    return inputTexture.sample(samplr, inputFragment.textureCoord);
}

Running this crashes the app with the following exception:

validateRenderPassDescriptor:782: failed assertion `RenderPass Descriptor Validation
Texture at colorAttachment[0] has usage (0x01) which doesn't specify MTLTextureUsageRenderTarget (0x04)

This now raises three questions for me:

  1. Do I have to do all of that Metal setting up, packing along the PassThrough.metal shader, render pass stuff, etc just to draw the MTLTexture to the CAMetalLayer? Is there no simpler way?
  2. Why is the code above failing?
  3. When is the drawing from Skia actually committed to the MTLTexture? Do I need to commit the command buffer (as seen in my TODO)?

1

CAMetalLayer provides MTLTexture objects through -[CAMetalLayer nextDrawable] as you noticed. By default these MTLTexture are created with MTLTextureUsageRenderTarget only as usage flag, so you can only write to them through a render pipeline. This might be what you need anyway as this allows easily applying some transform to your input texture while rendering it into the drawable texture.

In case you can guarantee that your RGBA data width and height will always match the size of the drawable texture, and you don't need to apply any transformation, things can be a bit simpler: you can set CAMetalLayer.framebufferOnly property to false so that the provided textures also become writable, which means that you can copy from your own (with shared or managed MTLStorageMode) MTLTexture to the drawable texture with a simple blit command instead of render command, which would remove the need for a custom shader. So the pipeline would look as follow:

At init:

  • create 3 staging textures with shared/managed storage mode

At each frame:

  • pick current staging texture and fill it with the contents rendered by Skia
  • ask for the drawable texture
  • schedule blit from your staging texture to your drawable texture
  • commit
  • increase staging texture index

One thing I don't know is the MTLStorageMode of the drawable texture: in case it's already shared/managed, you don't need the staging texture and can directly fill the drawable texture instead of going through a staging texture fill + blit command.

Bonus: as an additional optimization and in case drawable texture isn't shared/managed, if you detect that your MTLDevice is an Apple one, you don't need to render with Skia to some buffer before filling the MTLTexture: you can render with Skia directly into the MTLTexture. To do that: create MTLBuffer objects of the appropriate size with shared storage mode, use -[MTLBuffer newTextureWithDescriptor:offset:bytesPerRow:] to create each staging texture from them. Now if you render with Skia into MTLBuffer.contents, this will be directly available when you use the MTLTexture for your render or blit command. Just don't forget to use -[MTLBuffer didModifyRange:] after writing to MTLBuffer.contents.

2

Just from above sample code I don't know, because the only shown render pass is using drawable.texture, and the drawable would already properly configure the texture. 0x01 is MTLTextureUsageShaderRead so you might have another render pipeline somewhere trying to render in a texture with that usage. When you get this error, I assume that the debugger shows the callstack for the command buffer being submitted that has this issue.

3

I suppose that you mean "when is the GPU done with using the texture that was filled with data from Skia" ? In that case the answer is: when the command buffer that references that texture has completed (you can know it through -[MTLCommandBuffer addCompletedHandler:]). Before that it's not safe to fill again the texture with new data. That's why in above pipeline I'm using 3 staging textures: this way you can continue filling textures while the GPU is using the previously filled ones. I picked number 3 because that's the maximum for CAMetalerLayer.maximumDrawableCount, so you can't have more than 3 staging textures needed at a time.

By the way this means that your current usage of _textureMutex is not useful as is and also doesn't prevent the GPU from reading the texture while you write to it for next frame scheduling.

Draw `MTLTexture` to `CAMetalLayer`
 
 
Q