red character 'A' without the horizontal bar Achraf Kassioui

SpriteKit and Metal Shaders

Blog / Apple Dev,

Apply Metal shaders to SpriteKit content with SKRenderer and a MetalKit view.

SpriteKit native API only supports GLSL fragment shaders. But if we use SKRenderer and a MetalKit view, we can run any Metal vertex or fragment shader.

Below is an example that applies a Metal effect to select nodes of a SpriteKit scene: the content nodes are stylized with a Metal shader, while the text node is left unaffected. This is an example of what we can do with an SKRenderer and MTKView setup.

The content nodes are stylized with a CRT shader effect, while the foreground text node is not. Both are SpriteKit content, not SwiftUI or UIKit. In the top right is the Metal HUD debug overlay.

Here is the full code. Copy it in a file in Xcode, and present the view SKRendererMetalShaderView():

/**
 
 # SKRenderer + Metal Shader
 
 This experiment applies a Metal shader to a specific layer in the scene.
 
 Achraf Kassioui
 Created 1 Jul 2026
 Updated 5 Jul 2026
 
 */
import SwiftUI
import SpriteKit
import MetalKit

// MARK: View

struct SKRendererMetalShaderView: View {
    @State private var scene = SKRendererMetalShaderScene(size: CGSize(width: 200, height: 200))
    
    var body: some View {
        ZStack {
            SKRendererMetalShaderRepresentable(scene: scene)
                .ignoresSafeArea()
                .background(.black)
            
            VStack {
                Spacer()
                Toggle(isOn: $scene.enableMetalShader, label: {
                    Text("Metal Shader")
                        .foregroundStyle(.white)
                })
                .fixedSize()
                .padding()
            }
        }
    }
}

struct SKRendererMetalShaderRepresentable: UIViewRepresentable {
    let scene: SKRendererMetalShaderScene
    
    func makeUIView(context: Context) -> SKRendererMetalShader {
        SKRendererMetalShader(scene: scene)
    }
    
    func updateUIView(_ metalView: SKRendererMetalShader, context: Context) {
        
    }
}

// MARK: MetalKit View

class SKRendererMetalShader: MTKView, MTKViewDelegate {
    
    // MARK: Properties
    
    let scene: SKRendererMetalShaderScene
    private let renderer: SKRenderer
    private let commandQueue: MTLCommandQueue
    
    private var contentTexture: MTLTexture?
    private let postProcessPipeline: MTLRenderPipelineState
    
    // MARK: Lifecycle
    
    init(scene: SKRendererMetalShaderScene) {
        guard let device = MTLCreateSystemDefaultDevice() else {
            fatalError("Could not create Metal device")
        }
        
        guard let commandQueue = device.makeCommandQueue() else {
            fatalError("Could not create Metal command queue")
        }
        
        /// Setup shaders
        let postProcessPipeline: MTLRenderPipelineState
        
        do {
            let metalLibrary = try device.makeLibrary(source: Self.shaderSource, options: nil)
            
            let pipelineDescriptor = MTLRenderPipelineDescriptor()
            pipelineDescriptor.vertexFunction = metalLibrary.makeFunction(name: "fullscreenVertex")
            pipelineDescriptor.fragmentFunction = metalLibrary.makeFunction(name: "postProcessFragment")
            pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
            
            postProcessPipeline = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
        } catch {
            fatalError("Could not create Metal post-process pipeline: \(error)")
        }
        
        /// Setup SKRenderer
        let renderer = SKRenderer(device: device)
        renderer.scene = scene
        renderer.ignoresSiblingOrder = true
        renderer.showsNodeCount = true
        renderer.showsDrawCount = true
        
        /// Setup view
        self.scene = scene
        self.renderer = renderer
        self.commandQueue = commandQueue
        self.postProcessPipeline = postProcessPipeline
        
        super.init(frame: .zero, device: device)
        
        isMultipleTouchEnabled = true
        contentMode = .center
        preferredFramesPerSecond = 120
        
        /// Conform to MTKViewDelegate to auto refresh the view
        delegate = self
    }
    
    required init(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: MTKViewDelegate
    
    /// Called when view size changes
    func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
        /// Update the offscreen texture to match the new size in pixels
        contentTexture = nil
    }
    
    /// Render loop
    func draw(in view: MTKView) {
        guard let commandBuffer = commandQueue.makeCommandBuffer(),
              let drawable = currentDrawable
        else {
            return
        }
        
        /// Call all SpriteKit update functions.
        renderer.update(atTime: CACurrentMediaTime())
        
        /// Path with no shader. SKRenderer draws directly into the view's drawable texture.
        if scene.enableMetalShader == false {
            scene.contentLayer.isHidden = false
            scene.uiLayer.isHidden = false
            
            let directPassDescriptor = MTLRenderPassDescriptor()
            directPassDescriptor.colorAttachments[0].texture = drawable.texture
            directPassDescriptor.colorAttachments[0].loadAction = .clear
            directPassDescriptor.colorAttachments[0].storeAction = .store
            directPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
            
            renderer.render(
                withViewport: CGRect(origin: .zero, size: drawableSize),
                commandBuffer: commandBuffer,
                renderPassDescriptor: directPassDescriptor
            )
            
            commandBuffer.present(drawable)
            commandBuffer.commit()
            return
        }
        
        /// Path with shader.
        /// Update the offscreen texture if needed.
        /// This texture will store the content render. It must match the drawable size and pixel format.
        createContentTextureIfNeeded()
        
        guard let contentTexture else {
            return
        }
        
        /**
         
         Pass 1.
         
         The UI layer is hidden.
         SpriteKit renders only the content layer into the offscreen texture.
         
         */
        scene.contentLayer.isHidden = false
        scene.uiLayer.isHidden = true
        
        let contentPassDescriptor = MTLRenderPassDescriptor()
        contentPassDescriptor.colorAttachments[0].texture = contentTexture
        contentPassDescriptor.colorAttachments[0].loadAction = .clear
        contentPassDescriptor.colorAttachments[0].storeAction = .store
        contentPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
        
        renderer.render(
            withViewport: CGRect(origin: .zero, size: drawableSize),
            commandBuffer: commandBuffer,
            renderPassDescriptor: contentPassDescriptor
        )
        
        /**
         
         Pass 2.
         
         Metal applies the shader and draws the processed content texture into the drawable.
         
         */
        let shaderPassDescriptor = MTLRenderPassDescriptor()
        shaderPassDescriptor.colorAttachments[0].texture = drawable.texture
        shaderPassDescriptor.colorAttachments[0].loadAction = .clear
        shaderPassDescriptor.colorAttachments[0].storeAction = .store
        shaderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
        
        if let shaderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: shaderPassDescriptor) {
            var time = Float(CACurrentMediaTime())
            
            shaderEncoder.setRenderPipelineState(postProcessPipeline)
            shaderEncoder.setFragmentTexture(contentTexture, index: 0)
            shaderEncoder.setFragmentBytes(&time, length: MemoryLayout<Float>.size, index: 0)
            shaderEncoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
            shaderEncoder.endEncoding()
        }
        
        /**
         
         Pass 3.
         
         Make the UI layer visible and the content hidden.
         Then ask SKRenderer to render into the drawable.
         
         */
        scene.contentLayer.isHidden = true
        scene.uiLayer.isHidden = false
        
        let uiPassDescriptor = MTLRenderPassDescriptor()
        uiPassDescriptor.colorAttachments[0].texture = drawable.texture
        /// `.load` means SKRenderer will draw directly on top of the existing drawable, without clearing
        uiPassDescriptor.colorAttachments[0].loadAction = .load
        uiPassDescriptor.colorAttachments[0].storeAction = .store
        
        renderer.render(
            withViewport: CGRect(origin: .zero, size: drawableSize),
            commandBuffer: commandBuffer,
            renderPassDescriptor: uiPassDescriptor
        )
        
        /// Restore normal visibility for the next frame.
        scene.contentLayer.isHidden = false
        scene.uiLayer.isHidden = false
        
        commandBuffer.present(drawable)
        commandBuffer.commit()
    }
    
    // MARK: Texture Allocation
    
    /// Allocate GPU texture memory for offscreen rendering.
    private func createContentTextureIfNeeded() {
        let textureWidth = Int(drawableSize.width)
        let textureHeight = Int(drawableSize.height)
        
        guard textureWidth > 0, textureHeight > 0 else {
            return
        }
        
        if contentTexture?.width == textureWidth,
           contentTexture?.height == textureHeight {
            return
        }
        
        let descriptor = MTLTextureDescriptor.texture2DDescriptor(
            pixelFormat: self.colorPixelFormat,
            width: textureWidth,
            height: textureHeight,
            mipmapped: false
        )
        
        descriptor.usage = [.renderTarget, .shaderRead]
        descriptor.storageMode = .private
        
        contentTexture = device?.makeTexture(descriptor: descriptor)
    }
    
    // MARK: Touch
    
    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)
        }
    }
    
    private func scenePoint(from viewPoint: CGPoint) -> CGPoint {
        /// UIKit coordinates are top-left based.
        /// The scene uses anchorPoint 0.5, so the scene origin is centered.
        return CGPoint(
            x: (viewPoint.x / bounds.width - 0.5) * scene.size.width,
            y: (0.5 - viewPoint.y / bounds.height) * scene.size.height
        )
    }
    
    // MARK: Metal Shader
    
    private static let shaderSource =
"""
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
    float4 position [[position]];
    float2 uv;
};
vertex VertexOut fullscreenVertex(uint vertexID [[vertex_id]]) {
    float2 positions[3] = {
        float2(-1.0, -1.0),
        float2( 3.0, -1.0),
        float2(-1.0,  3.0)
    };
    
    float2 textureCoordinates[3] = {
        float2(0.0, 1.0),
        float2(2.0, 1.0),
        float2(0.0, -1.0)
    };
    
    VertexOut out;
    out.position = float4(positions[vertexID], 0.0, 1.0);
    out.uv = textureCoordinates[vertexID];
    return out;
}
fragment half4 postProcessFragment(
    VertexOut in [[stage_in]],
    texture2d<half> contentTexture [[texture(0)]],
    constant float &time [[buffer(0)]]
) {
    constexpr sampler textureSampler(coord::normalized, address::clamp_to_edge, filter::linear);
    
    float2 uv = clamp(in.uv, float2(0.0), float2(1.0));
    
    /// Curve the image as if it were projected on old CRT glass.
    float2 centered = uv - float2(0.5);
    float radius = dot(centered, centered);
    float2 curvedUV = uv + centered * radius * 0.22;
    
    /// Darken outside the valid curved area.
    float insideMask =
        step(0.0, curvedUV.x) *
        step(curvedUV.x, 1.0) *
        step(0.0, curvedUV.y) *
        step(curvedUV.y, 1.0);
    
    curvedUV = clamp(curvedUV, float2(0.0), float2(1.0));
    
    half4 color = contentTexture.sample(textureSampler, curvedUV);
    
    /// Horizontal scanlines.
    float scanline = 0.86 + 0.14 * sin(uv.y * 1400.0);
    
    /// Animated roll.
    float roll = 0.025 * sin(uv.y * 60.0 - time * 8.0);
    
    /// Corner darkening.
    float vignette = smoothstep(0.75, 0.15, radius);
    
    color.rgb *= half(scanline * vignette * insideMask);
    color.rgb += half(roll);
    
    return color;
}
"""
    
}

// MARK: Scene

@Observable
class SKRendererMetalShaderScene: SKScene {
    
    // MARK: Properties
    
    let contentLayer = SKNode()
    let uiLayer = SKNode()
    
    var enableMetalShader: Bool = true
    
    private var activeDrags: [UITouch: DragState] = [:]
    
    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)
        
        createContent()
    }
    
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // MARK: Content
    
    private func createContent() {
        addChild(contentLayer)
        addChild(uiLayer)
        
        /// Tiled background from sprite nodes and a gap
        let rows: Int = 20
        let columns: Int = 20
        let tileSize = CGSize(width: 95, height: 95)
        let gap: CGFloat = 10
        
        /// Distance from one tile center to the next tile center.
        let tileStride = CGSize(
            width: tileSize.width + gap,
            height: tileSize.height + gap
        )
        
        /// The total size lets the grid stay centered around the content layer origin.
        let totalGridSize = CGSize(
            width: CGFloat(columns) * tileSize.width + CGFloat(columns - 1) * gap,
            height: CGFloat(rows) * tileSize.height + CGFloat(rows - 1) * gap
        )
        
        for rowIndex in 0..<rows {
            for columnIndex in 0..<columns {
                let tileColor: UIColor = (rowIndex + columnIndex).isMultiple(of: 2)
                ? .gray.withAlphaComponent(0.7)
                : .gray.withAlphaComponent(0.6)
                
                let tile = SKSpriteNode(color: tileColor, size: tileSize)
                
                tile.position = CGPoint(
                    x: -totalGridSize.width / 2 + tileSize.width / 2 + CGFloat(columnIndex) * tileStride.width,
                    y: -totalGridSize.height / 2 + tileSize.height / 2 + CGFloat(rowIndex) * tileStride.height
                )
                tile.zPosition = 0
                
                contentLayer.addChild(tile)
            }
        }
        
        let redSprite = SKSpriteNode(color: .systemRed, size: CGSize(width: 120, height: 120))
        redSprite.name = "draggable"
        redSprite.position = CGPoint(x: -120, y: 0)
        redSprite.zPosition = 10
        contentLayer.addChild(redSprite)
        
        let yellowShape = SKShapeNode(rectOf: CGSize(width: 110, height: 110), cornerRadius: 18)
        yellowShape.name = "draggable"
        yellowShape.fillColor = .systemYellow
        yellowShape.strokeColor = .black
        yellowShape.lineWidth = 4
        yellowShape.position = CGPoint(x: 130, y: 20)
        yellowShape.zPosition = 20
        contentLayer.addChild(yellowShape)
        
        let text =
"""
UI Layer
+
Metal Shader on content layer
"""
        
        let paragraphStyle = NSMutableParagraphStyle()
        paragraphStyle.alignment = .center
        paragraphStyle.lineHeightMultiple = 1.2
        
        let shadow = NSShadow()
        shadow.shadowOffset = CGSize(width: 0, height: 7)
        shadow.shadowColor = SKColor.black.withAlphaComponent(0.6)
        shadow.shadowBlurRadius = 1
        
        let attributes: [NSAttributedString.Key: Any] = [
            .paragraphStyle: paragraphStyle,
            .font: UIFont(name: "Menlo-Bold", size: 32) ?? .systemFont(ofSize: 32),
            .foregroundColor: SKColor.white,
            .shadow: shadow
        ]
        
        let label = SKLabelNode()
        label.name = "draggable"
        label.attributedText = NSAttributedString(string: text, attributes: attributes)
        label.numberOfLines = 0
        label.verticalAlignmentMode = .center
        label.position = CGPoint(x: 0, y: 200)
        label.zPosition = 100
        uiLayer.addChild(label)
    }
    
    // MARK: Dragging
    
    func beginDrag(for touch: UITouch, at scenePoint: CGPoint) {
        guard let node = draggableNode(at: scenePoint) else {
            return
        }
        
        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? {
        var candidate: SKNode? = atPoint(scenePoint)
        
        while let node = candidate {
            if node.name == "draggable" {
                return node
            }
            
            candidate = node.parent
        }
        
        return nil
    }
    
}

Links