red character 'A' without the horizontal bar Achraf Kassioui

SKRenderer and MTKView

Blog / Apple Dev,

Render SpriteKit directly into a Metal view with SKRenderer instead of SKView/SpriteView. This setup provides more control over the rendering pipeline, and runs more smoothly on some devices like iPhone 13.

I wrote a minimal setup that renders a SpriteKit scene with SKRenderer and MTKView. The screenshot below shows a simple SpriteKit scene running on macOS 27 in a MetalKit view with the Metal HUD enabled:

SKRenderer-MTKView.png
A SpriteKit scene rendered on Mac directly into a Metal view using SKRenderer and MetalKit.

Setting up MTKView is surprisingly approachable. It automatically handles many details such as pixel format and color space. If MTKView conforms to MTKViewDelegate, it will automatically refresh the view and drive the scene and render updates.

Here is the structure of the draw function of MTKView, called for each new frame:

func draw(in view: MTKView) {
    /// Metal pipeline
    guard let commandBuffer = commandQueue.makeCommandBuffer(),
          let renderPassDescriptor = currentRenderPassDescriptor,
          let drawable = currentDrawable
    else {
        return
    }
    
    renderPassDescriptor.colorAttachments[0].loadAction = .clear
    renderPassDescriptor.colorAttachments[0].storeAction = .store
    renderPassDescriptor.colorAttachments[0].clearColor = clearColor
    
    /// Call all SKScene update methods
    renderer.update(atTime: CACurrentMediaTime())
    
    /// Render the scene
    renderer.render(
        withViewport: CGRect(origin: .zero, size: drawableSize),
        commandBuffer: commandBuffer,
        renderPassDescriptor: renderPassDescriptor
    )
    
    commandBuffer.present(drawable)
    commandBuffer.commit()
}

This setup is a base to explore custom Metal rendering with SpriteKit. Each frame:

Below is the full code file. It includes a regular SpriteView, which you can use to compare both rendering paths.

/**
 
 # SKRenderer + MetalKit View
 
 A minimal setup to render a SpriteKit scene with SKRenderer and MTKView.
 
 The same scene can run in two modes:
 - Presented with SpriteView/SKView.
 - Rendered manually with SKRenderer and MTKView.
 
 This setup is a base for custom Metal rendering with SpriteKit:
 Render the scene, render extra Metal passes, then composite the final output.
 
 Achraf Kassioui
 Created 30 Jun 2026
 Updated 4 Jul 2026
 
 */
import SwiftUI
import SpriteKit
import MetalKit

// MARK: SwiftUI
/**
 
 Use this view to present SpriteKit with SKView.
 
 */
struct MinimalSpriteView: View {
    /// Init scene with arbitrary size. The scene scale mode will match the size to the host view.
    @State private var scene = SampleScene(size: CGSize(width: 200, height: 200))
    
    var body: some View {
        SpriteView(
            scene: scene,
            preferredFramesPerSecond: 120,
            options: [.ignoresSiblingOrder],
            debugOptions: [.showsFPS, .showsNodeCount, .showsDrawCount]
        )
        .ignoresSafeArea()
        .background(.black)
    }
}

/**
 
 Use this view to present SpriteKit with SKRenderer and MetalKit.
 
 */
struct MinimalSKRendererMetalKitView: View {
    /// Init scene with arbitrary size. The scene scale mode will match the size to the host view.
    @State private var scene = SampleScene(size: CGSize(width: 200, height: 200))
    
    var body: some View {
        MinimalMetalKitViewRepresentable(scene: scene)
            .ignoresSafeArea()
            .background(.black)
    }
}

struct MinimalMetalKitViewRepresentable: UIViewRepresentable {
    let scene: SampleScene
    
    func makeUIView(context: Context) -> MinimalMetalKitView {
        MinimalMetalKitView(scene: scene)
    }
    
    func updateUIView(_ metalView: MinimalMetalKitView, context: Context) {
        
    }
}

// MARK: MetalKit View

class MinimalMetalKitView: MTKView, MTKViewDelegate {
    
    // MARK: Properties
    
    let scene: SampleScene
    private let renderer: SKRenderer
    private let commandQueue: MTLCommandQueue
    
    // MARK: Lifecycle
    
    init(scene: SampleScene) {
        /// Create what SKRenderer needs
        guard let device = MTLCreateSystemDefaultDevice() else {
            fatalError("Could not create Metal device")
        }
        
        guard let commandQueue = device.makeCommandQueue() else {
            fatalError("Could not create Metal command queue")
        }
        
        self.scene = scene
        self.renderer = SKRenderer(device: device)
        self.commandQueue = commandQueue
        
        /// Init the view with the same device as SKRenderer
        super.init(frame: .zero, device: device)
        
        /// Conform to MTKViewDelegate to make the view auto refresh and drive the update cycle
        delegate = self
        
        isMultipleTouchEnabled = true
        contentMode = .center /// Optional, better behavior during device rotation
        preferredFramesPerSecond = 120 /// Limited to 60 otherwise
        
        /// Setup SKRenderer
        renderer.scene = scene
        renderer.ignoresSiblingOrder = true
        renderer.showsNodeCount = true
        renderer.showsDrawCount = true
    }
    
    required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: Render Loop
    /**
     
     `mtkView` and `draw` are both methods of the MTKViewDelegate protocol.
     
     */
    /// Called whenever the view size is about to change
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {

    }
    
    /// Called for each new frame to render
    func draw(in view: MTKView) {
        /// Metal pipeline
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let renderPassDescriptor = currentRenderPassDescriptor,
              let drawable = currentDrawable
        else {
            return
        }
        
        renderPassDescriptor.colorAttachments[0].texture = drawable.texture
        renderPassDescriptor.colorAttachments[0].loadAction = .clear
        renderPassDescriptor.colorAttachments[0].storeAction = .store
        renderPassDescriptor.colorAttachments[0].clearColor = clearColor
        
        /// Call all SKScene update methods
        renderer.update(atTime: CACurrentMediaTime())
        
        /// Render the scene
        renderer.render(
            withViewport: CGRect(origin: .zero, size: drawableSize),
            commandBuffer: commandBuffer,
            renderPassDescriptor: renderPassDescriptor
        )
        
        commandBuffer.present(drawable)
        commandBuffer.commit()
    }
    
    // MARK: Touch
    /**
     
     Input is intercepted by this view, and forwarded to the scene.
     Important: convert coordinates from UIView to SKScene before sending.
     
     */
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            scene.beginDrag(for: touch, at: scenePoint(from: touch.location(in: self)))
        }
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            scene.updateDrag(for: touch, to: scenePoint(from: touch.location(in: self)))
        }
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            scene.endDrag(for: touch)
        }
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        for touch in touches {
            scene.endDrag(for: touch)
        }
    }
    
    /// UIKit view coordinates start at the top-left.
    /// The scene uses anchorPoint 0.5, so scene origin is the view center.
    private func scenePoint(from viewPoint: CGPoint) -> CGPoint {
        return CGPoint(
            x: viewPoint.x - bounds.width * 0.5,
            y: bounds.height * 0.5 - viewPoint.y
        )
    }
    
}

// MARK: Scene

class SampleScene: SKScene {
    
    // MARK: Properties
    
    private var activeDrags: [UITouch: DragState] = [:]
    private var titleLabel: SKLabelNode?
    
    struct DragState {
        let node: SKNode
        let offset: CGPoint
    }
    
    // MARK: Lifecycle
    
    override init(size: CGSize) {
        super.init(size: size)
        
        scaleMode = .resizeFill
        backgroundColor = .black
        anchorPoint = CGPoint(x: 0.5, y: 0.5)
        
        cleanup()
        
        createContent()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func didMove(to view: SKView) {
        size = view.bounds.size
        view.isMultipleTouchEnabled = true
        view.contentMode = .center
    }
    
    override func didChangeSize(_ oldSize: CGSize) {
        updateLabel()
    }
    
    deinit {
        cleanup()
    }
    
    private func cleanup() {
        removeAllChildren()
        activeDrags.removeAll()
    }
    
    // MARK: Content
    
    private func createContent() {
        let label = SKLabelNode()
        label.fontName = "Menlo-Bold"
        label.fontSize = 22
        label.zPosition = 30
        label.name = "draggable"
        addChild(label)
        titleLabel = label
        
        let sprite = SKSpriteNode(color: .systemRed, size: CGSize(width: 100, height: 100))
        sprite.zPosition = 10
        sprite.name = "draggable"
        addChild(sprite)
        
        let action = SKAction.rotate(byAngle: .pi, duration: 1)
        sprite.run(SKAction.repeatForever(action))
        
        let shape = SKShapeNode(rectOf: CGSize(width: 75, height: 75), cornerRadius: 12)
        shape.lineWidth = 3
        shape.fillColor = .systemYellow
        shape.zPosition = 20
        shape.name = "draggable"
        addChild(shape)
    }
    
    private func updateLabel() {
        let text = view != nil ? "SKView" : "SKRenderer + MTKView"
        titleLabel?.text = text
        titleLabel?.position = CGPoint(x: 0, y: size.height * 0.35)
    }
    
    // MARK: Touch
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        /// `scene.view` is nil if the scene isn't presented by an SKView.
        /// Here, proceed only if the scene is presented by SKView.
        /// Otherwise, ignore touch events. The MetalKit host view will handle them.
        guard view != nil else { return }
        
        for touch in touches {
            beginDrag(for: touch, at: touch.location(in: self))
        }
    }
    
    override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard view != nil else { return }
        
        for touch in touches {
            updateDrag(for: touch, to: touch.location(in: self))
        }
    }
    
    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard view != nil else { return }
        
        for touch in touches {
            endDrag(for: touch)
        }
    }
    
    override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard view != nil else { return }
        
        for touch in touches {
            endDrag(for: touch)
        }
    }
    
    // MARK: Dragging
    
    func beginDrag(for touch: UITouch, at scenePoint: CGPoint) {
        guard let node = draggableNode(at: scenePoint) else {
            return
        }
        
        /// Avoid assigning the same node to two touches at the same time.
        guard activeDrags.values.contains(where: { $0.node === node }) == false else {
            return
        }
        
        activeDrags[touch] = DragState(
            node: node,
            offset: CGPoint(
                x: node.position.x - scenePoint.x,
                y: node.position.y - scenePoint.y
            )
        )
    }
    
    func updateDrag(for touch: UITouch, to scenePoint: CGPoint) {
        guard let dragState = activeDrags[touch] else {
            return
        }
        
        dragState.node.position = CGPoint(
            x: scenePoint.x + dragState.offset.x,
            y: scenePoint.y + dragState.offset.y
        )
    }
    
    func endDrag(for touch: UITouch) {
        activeDrags.removeValue(forKey: touch)
    }
    
    private func draggableNode(at scenePoint: CGPoint) -> SKNode? {
        /// atPoint returns the top visible node at this scene position.
        /// If the hit node is inside a draggable parent, walk upward until that parent is found.
        var candidate: SKNode? = atPoint(scenePoint)
        
        while let node = candidate {
            if node.name == "draggable" {
                return node
            }
            
            candidate = node.parent
        }
        
        return nil
    }
    
}

Links