I did hack together a minimal Metal app using SwiftUI
with the MTKView
wrapped in a UIRepresentable
. Tapping the button changes the colour of the MTKView
. I am quite new to iOS so beware of my likely less than optimal example. The problem is solved by grabbing the CAMetalLayer
through the current drawable and using it to call nextDrawable()
. Now the MTLRenderPassDescriptor
must be created manually using the new drawable. This solution works but feels very uncomfortable so I can't believe it is the intended approach - grabbing so indirectly at the lower levels from MTKView
(I also have yet to handle the depth and stencil render pass attachments). I would be very grateful if anyone can point me to a better solution of how to handle manual draw calls to an MTKView
Best regards,
App.swift:
import SwiftUI
import MetalKit
@main
struct ManualRenderingTestApp: App {
var body: some Scene {
WindowGroup { ContentView() }
}
}
struct ContentView: View {
let view = ColorView()
var body: some View {
VStack {
view
Button("Change color") { view.changeColor() }
}
}
}
class ColorViewCoordinator : NSObject, MTKViewDelegate {
let view: MTKView
var commandQueue: MTLCommandQueue!
var pipelineState: MTLRenderPipelineState!
let inFlightSemaphore = DispatchSemaphore(value: 3)
let vertexData: [Float] = [-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0]
var colors = [simd_float4(1.0, 0.0, 0.0, 1.0), simd_float4(0.0, 1.0, 0.0, 1.0), simd_float4(0.0, 0.0, 1.0, 1.0), simd_float4(1.0, 1.0, 0.0, 1.0)]
var colorIndex = 0
init(mtkView: MTKView) {
self.view = mtkView
let metalDevice = MTLCreateSystemDefaultDevice()!
view.device = metalDevice
super.init()
self.commandQueue = metalDevice.makeCommandQueue()!
let library = metalDevice.makeDefaultLibrary()!
let vertexDescriptor = MTLVertexDescriptor()
vertexDescriptor.attributes[0].format = .float2
vertexDescriptor.attributes[0].offset = 0
vertexDescriptor.attributes[0].bufferIndex = 0
vertexDescriptor.layouts[0].stride = MemoryLayout<SIMD2<Float>>.stride
vertexDescriptor.layouts[0].stepRate = 1
vertexDescriptor.layouts[0].stepFunction = .perVertex
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
pipelineDescriptor.vertexFunction = library.makeFunction(name: "planeVertexShader")
pipelineDescriptor.fragmentFunction = library.makeFunction(name: "constantColorFragmentShader")
pipelineDescriptor.vertexDescriptor = vertexDescriptor
do {
pipelineState = try metalDevice.makeRenderPipelineState(descriptor: pipelineDescriptor)
} catch {
fatalError("Failed to create visualization render pipeline.")
}
}
func incrementColor() { colorIndex = (colorIndex + 1) % colors.count }
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {}
func getCALayer(view: MTKView) -> CAMetalLayer? { if let drawable = view.currentDrawable { return drawable.layer }; return nil }
func createRenderPassDescriptor(view: MTKView, drawable: CAMetalDrawable) -> MTLRenderPassDescriptor {
let renderPassDescriptor = MTLRenderPassDescriptor()
renderPassDescriptor.colorAttachments[0].texture = drawable.texture
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].storeAction = .store
// depth and stencil attachments have to be set manually here
return renderPassDescriptor
}
func draw(in view: MTKView) {
_ = inFlightSemaphore.wait(timeout: DispatchTime.distantFuture)
guard let commandBuffer = commandQueue.makeCommandBuffer() else { return }
let semaphore = inFlightSemaphore
commandBuffer.addCompletedHandler { (_ commandBuffer)-> Swift.Void in
semaphore.signal()
}
guard let layer = getCALayer(view: view) else { return }
guard let drawable = layer.nextDrawable() else { return }
let renderPassDescriptor = createRenderPassDescriptor(view: view, drawable: drawable)
guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { return }
renderEncoder.setVertexBytes(vertexData, length: vertexData.count * MemoryLayout<Float>.stride, index: 0)
renderEncoder.setFragmentBytes(&colors[colorIndex], length: MemoryLayout<SIMD4<Float>>.stride, index: 0) // set color from user input
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
renderEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
struct ColorView: UIViewRepresentable {
var mtkView: MTKView = MTKView()
func makeCoordinator() -> ColorViewCoordinator {
return ColorViewCoordinator(mtkView: mtkView)
}
func makeUIView(context: UIViewRepresentableContext<ColorView>) -> MTKView {
mtkView.delegate = context.coordinator
mtkView.isPaused = true // only push data
mtkView.enableSetNeedsDisplay = false // only push data
mtkView.framebufferOnly = true // we don't render to anything but the screen
mtkView.colorPixelFormat = .bgra8Unorm
mtkView.drawableSize = mtkView.frame.size
return mtkView
}
func updateUIView(_ uiView: MTKView, context: UIViewRepresentableContext<ColorView>) {}
func changeColor() {
let coordinator = mtkView.delegate as? ColorViewCoordinator
coordinator!.incrementColor()
autoreleasepool {
mtkView.delegate!.draw(in: mtkView)
}
}
}
Shaders.Metal:
#include <metal_stdlib>
using namespace metal;
typedef struct { float2 position [[ attribute(0) ]]; } VertexIn;
typedef struct { float4 position [[position]]; } FragIn;
vertex FragIn planeVertexShader(VertexIn in [[stage_in]]) {
FragIn out;
out.position = float4(in.position, 0.0, 1.0);
return out;
}
fragment float4 constantColorFragmentShader(
FragIn in [[stage_in]],
constant float4& color
) { return color; }