This project shows how to export Metal-rendered content to an image sequence or H.264 video, using SpriteKit, SKRenderer, Metal, IOSurface, and AVFoundation.
Context
The use case I had in mind was recording user-generated content. Here's the scenario:
-
The user interacts with an editor built with a game engine (SpriteKit).
-
The user creates animations and simulations.
-
The user wants to record the scene and share it as a video or a series of images.
The project grew into a SpriteKit offline renderer that uses several Apple frameworks and technologies. It demonstrates how to:
-
Set up SKRenderer, SpriteKit's programmatic Metal renderer.
-
Render content offscreen into a Metal texture.
-
Export Metal textures as a PNG image sequence.
-
Export Metal textures as H.264 video using IOSurface and AVFoundation.
-
Apply Core Image filters.
-
Explore SpriteKit determinism.
The project does not focus on the UX of exporting and sharing media files. Instead, it shows how to build a backend that consumes Metal content and produces flat media files.
Demo
Download SKRenderer Demo from GitHub and open the project in Xcode. The demo app runs in Xcode Live Preview, Simulator, iOS, and Mac Catalyst.
When rendering starts, the live SKView is paused to free resources for the offline renderer. When rendering completes:
-
On Mac/Simulator/Live Preview: the file path is printed to the Xcode console.
-
On iOS: a share sheet appears to save or share the file.
Media unavailable: SKRenderer-Demo-XcodeConsole.pngRead below about how the project is set up.
SKRenderer
SKRenderer takes a SpriteKit scene and outputs a Metal texture. The texture can be used in a Metal pipeline:
-
A Metal-backed view can display the output texture on screen, calling update and render each frame in sync with the display refresh rate.
-
Views such as ARView can composite their own render texture with the one produced by SKRenderer. See ARView RenderCallbacks and postProcess.
-
SKRenderer can run offscreen, with no view attached. In this mode, the app drives the update/render loop and retrieves the texture. This app uses that approach.
What SKRenderer is not:
-
SKRenderer does not expose SKView internals such as its framebuffer. SKView and SKRenderer are separate rendering paths.
-
SKRenderer is not magic. It's a renderer that needs the scene to be updated and computed at regular intervals. RealityKit and SceneKit can integrate SpriteKit content via SKRenderer, but only if the SpriteKit scene can update and render within the available frame budget. In practice, SpriteKit is light enough for this to work well.
Setup
Below is the boilerplate setup done once when SKRenderer is created:
// Get the GPU
let device = MTLCreateSystemDefaultDevice()
// Factory for creating command buffers, used later each frame
// Command buffers = instructions for the GPU
let commandQueue = device.makeCommandQueue()
// Allocate GPU memory for the texture we'll render into
// Texture = a block of GPU memory holding pixels
// The memory allocation stays constant (let), the pixel data changes each frame
let textureDesc = MTLTextureDescriptor()
textureDesc.width = pixelWidth
textureDesc.height = pixelHeight
let renderTexture = device.makeTexture(descriptor: textureDesc)
// Create an SKRenderer instance and assign a scene to render
let renderer = SKRenderer(device: device)
renderer.scene = scene
Then for each frame, we run code in the following form:
// Update scene
// This calls all SKScene delegate functions, from update to didFinishUpdate
renderer.update(atTime: currentTime)
// Configure the rendering operation for this frame
// Set the created texture as the render target and specifies clear/store actions
let renderPassDescriptor = MTLRenderPassDescriptor()
renderPassDescriptor.colorAttachments[0].texture = renderTexture
renderPassDescriptor.colorAttachments[0].loadAction = .clear
renderPassDescriptor.colorAttachments[0].storeAction = .store
// Create a command buffer to hold this frame's GPU instructions
let commandBuffer = commandQueue.makeCommandBuffer()
// Viewport is required by the API but appears ignored by SKRenderer in this context
// The texture dimensions determine the actual output size
let viewport = CGRect(origin: .zero, size: sceneSize)
// Render the scene into the texture
// SKRenderer writes drawing commands into commandBuffer
renderer.render(
withViewport: viewport,
commandBuffer: commandBuffer,
renderPassDescriptor: renderPassDescriptor
)
// GPU work is asynchronous with CPU work
// commit() submits work but returns immediately
// completion handler ensures GPU work is done for this frame
commandBuffer.addCompletedHandler {
/// Do something with the completed texture, like image encoding
encodeFrame(from: texture)
}
// Send the command buffer to GPU for execution
commandBuffer.commit()
Resolution and Scale Factor
A SpriteKit scene is sized in points. A Metal texture is sized in pixels. If a scene is created at 1920x1080 and SKRenderer draws it, the output will be 1920x1080 pixels. In order to get Retina resolution, we must multiply the size of the allocated texture by a scale factor. SKRenderer will handle the mapping between the point-based scene and the pixel-based texture. This is reminiscent of UIView's contentScaleFactor property.
let scene = SKScene(size: CGSize(width: 1920, height: 1080))
// Scale allocated texture before rendering
let textureDesc = MTLTextureDescriptor()
textureDesc.width = Int(1920 * renderScale) // @3x: 5760 pixels
textureDesc.height = Int(1080 * renderScale) // @3x: 3240 pixels
renderer.render(...)
Known Issues and Workarounds
HiDPI scaling doesn't work for all nodes. SKShapeNode with antialiasing enabled renders at @1x no matter the resolution of the Metal texture descriptor, and therefore will appear blurry at Retina scales. A workaround is to use supersampling: create shapes upsized by the scale factor, then scale them down:
let scaleFactor: CGFloat = 3 // iPhone Retina scale
let shape = SKShapeNode(rectOf: CGSize(width: 150 * scaleFactor, height: 75 * scaleFactor))
shape.lineWidth = 3 * scaleFactor
shape.setScale(1/scaleFactor)
// Physics body must match final size, not supersampled size
shape.physicsBody = SKPhysicsBody(rectangleOf: CGSize(width: 150, height: 75))
An alternative is to set isAntialiased = false on shape nodes, which will force SKRenderer to draw them at the correct resolution, but curves will appear jagged.
Textures created programmatically should also be scaled to match the Retina target. Pass the scale factor to the generator and scale texture creation accordingly:
let textureSize = CGSize(width: 2 * scaleFactor, height: 2 * scaleFactor)
// Generate a texture with Core Graphics
let cgRenderer = UIGraphicsImageRenderer(size: textureSize)
let squareTexture = SKTexture(image: cgRenderer.image { context in
SKColor.white.setFill()
context.fill(CGRect(origin: .zero, size: textureSize))
})
Another issue you may encounter is the renderer crashing when Core Image filters are activated. A workaround is to disable Metal API Validation in Product > Scheme > Edit Scheme > Diagnostics.
Media Export
Here's how each export pipeline works:
Export to PNG
-
A new instance of the scene is passed to SKRenderer.
-
SKRenderer updates and renders each frame to a Metal render texture.
-
The render texture is copied with
texture.getBytes()to raw pixel data. -
Pixel data is converted to a CGImage, then to a PNG image.
The getBytes() method is convenient, but slow for high-performance needs. For high frame rates or large textures, PNG export is slower than video encoding due to this CPU copy and per-frame PNG compression.
Export to Video
-
A new instance of the scene is passed to SKRenderer.
-
SKRenderer updates and renders each frame to a Metal render texture.
-
The render texture is blit (fast GPU copy) to an IOSurface-backed texture.
-
The video writer transfers (memcpy) the IOSurface to CVPixelBuffer.
-
AVAssetWriter encodes the pixel buffer to H.264.
-
After all frames are encoded, AVAssetWriter finalizes the video file.
Video export uses IOSurface, which is a low-level memory management framework. IOSurface provides memory that both GPU and CPU can access quickly.
The blit is fast. The memcpy from IOSurface to CVPixelBuffer is faster than getBytes() from a Metal texture. Compared to PNG's getBytes(), this pipeline reduces per-frame overhead from ~5ms to <1ms on iPhone 13 at 1080p.
Update Timestep
SpriteKit's update function takes a current time value, not a delta time. When the scene is updated with SKRenderer update(atTime:) method, the correct value must be passed. I found that time values must start from a CACurrentMediaTime() and not 0. Then, each tick, add a delta time:
var currentTime: TimeInterval = CACurrentMediaTime()
let deltaTime = 1.0 / fps
for frame in 0..<totalFrames {
currentTime += deltaTime
renderer.update(atTime: currentTime)
}
This time setup is called "monotonically increasing", i.e. the current time is positive and always increasing. I explored various delta time values to understand SpriteKit's internal clock for update, actions, and physics. Below are my findings. In each of these scenarios, current time starts at CACurrentMediaTime().
Backwards
A delta time is subtracted from the current time each frame:
-
Actions with duration or delay > 0 do not run.
-
Particles spawn but do not animate.
-
Physics bodies don't run.
-
Physics fields don't run.
-
The update function is called only twice.
Frozen
The same current time value is passed every frame:
-
Actions with duration or delay > 0 do not run.
-
Particles spawn but do not animate or change.
-
Physics bodies run at 1x speed.
-
Physics fields don't run.
Speed Control
A multiple of delta time is added every frame, and scene.physicsWorld.speed is set at different values.
Delta time * 10, physicsWorld.speed = 1:
-
Actions run at 10×.
-
Particles sped up 10×.
-
Physics bodies run at 10x.
-
Physics fields run at 10x.
Delta time * 10, physicsWorld.speed = 0.1:
-
Actions run at 10×.
-
Particles run at 10×.
-
Physics bodies run at 1x (they look real-time, not sped up).
-
Physics fields run at 10x.
physicsWorld.speed = 0:
-
Physics bodies don't run, regardless of current time.
-
Physics fields run at current time. Fields are not impacted by
physicsWorld.speed.
Conclusion
-
physicsWorld.speedsets the rate of the physics engine and is different from update's current time. -
physicsWorld.speedoverrides the current time value IF the rate is not 1. -
For proper timing control: set both the current time AND
physicsWorld.speedexplicitly.
Simulation Rollback
In order to record a specific segment of the simulation, the SpriteKit scene must be set up to recover a given state and replay the simulation. Typically this means having a deterministic state initializer + a command pattern on top of SpriteKit:
// Live interaction mutates the scene by issuing commands:
run(Command.create(..))
run(Command.move(..))
// The same interaction can be reproduced later:
history = [
Action(time: 1.0, command: .create(...)),
Action(time: 1.5, command: .move(...)),
// ...
]
A recording pass would be implemented like this:
-
Reset the scene to the desired initial state (nodes, transforms, assets…).
-
Start the update loop.
-
At each frame, replay any commands scheduled for that timestamp.
-
Let SKRenderer render as many frames as needed for the interval.
-
Export the result to video or images.
This enables capturing complex simulations at any resolution and frame rate, fully decoupled from real-time display limits.
Determinism
Interaction and behavior must be deterministic for frame-perfect replay. Consider the figure below: each render is from the same scene, and each image is the 500th frame of a 10 seconds simulation.
Media unavailable: SKRenderer-determinism.pngFrom empirical testing, I found the following to be deterministic:
-
SKActions and typical code written in update.
-
Physics fields, including effects on particles like turbulence, appear predictable, which was a pleasant surprise!
-
Particles themselves aren't identical from run to run, but the overall behavior is repeatable. Mind you, we can't "teleport" particles into a particular state. If the current state of a particle emitter is the result of an interaction with a field, the simulation must be replayed from the first interaction in order to recover the current state.
I found the following to not be deterministic, despite the fixed time step supplied to the renderer:
-
Colliding physics bodies. We can see that the bouncing balls above are in different positions at similar simulation time.
If your setup depends on precise physics body positions interacting over multiple seconds, use guide rails to direct behavior, such as careful level design and checkpoints.
License
This project is licensed under the Apache License 2.0.
If this project helps your work, attribution or a link back is appreciated: https://www.achrafkassioui.com/spritekit-offline-rendering/