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.
Media unavailable: SpriteKit-MetalShader.mp4
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
-
Achraf Kassioui, SKRenderer and MTKView, 2026.
-
Apple Documentation, SKRenderer.
-
Apple Documentation, MTKView.