This note covers render passes: how to render into a texture instead of the screen, and the unassuming set of action settings in Metal that actually decide your performance.
What a render pass is
One MTLRenderCommandEncoder is one render pass. It binds a set of attachments (colour, depth, stencil), runs a series of draws against them, and ends. Rendering to a different target means a new encoder.
Swiftlet descriptor = MTLRenderPassDescriptor()
descriptor.colorAttachments[0].texture = offscreenTexture
descriptor.colorAttachments[0].loadAction = .clear
descriptor.colorAttachments[0].storeAction = .store
descriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
descriptor.depthAttachment.texture = depthTexture
descriptor.depthAttachment.loadAction = .clear
descriptor.depthAttachment.storeAction = .dontCare // depth is not needed afterwards
descriptor.depthAttachment.clearDepth = 1.0
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)!
// ... draws ...
encoder.endEncoding()
Load and store actions: the most important switches on TBDR
These two settings barely matter on a desktop GPU, but on Apple's tile-based deferred rendering architecture they decide your memory bandwidth outright.
An Apple GPU splits the screen into tiles, and during rendering each tile's attachment data lives entirely in on-chip memory β a small, extremely fast SRAM. A pass runs as:
- load β read the attachments from device memory into tile memory;
- run every draw inside tile memory;
- store β write tile memory back to device memory.
Load and store actions control steps 1 and 3:
| loadAction | Meaning |
|---|---|
.clear | Do not read device memory; fill tile memory with the clear value. Fastest |
.load | Read existing contents from device memory. Costs bandwidth |
.dontCare | Contents undefined. Fastest when you guarantee every pixel is written |
| storeAction | Meaning |
|---|---|
.store | Write back to device memory |
.dontCare | Discard. Zero bandwidth |
.multisampleResolve | Resolve MSAA and write back only the single-sample result |
The key conclusion: the depth buffer should almost always use .dontCare as its store action. Depth is used for visibility inside the pass; unless a later pass reads it (SSAO, soft particles), writing it back to device memory is pure waste. A 4K depth32 buffer is 32 MB β writing it once per frame is 2 GB/s of bandwidth you did not need to spend.
Likewise, an MSAA colour attachment should use .multisampleResolve rather than .store: the raw 4Γ MSAA attachment is four times the size of the resolved image, and there is no reason to write it back.
Swiftdescriptor.colorAttachments[0].texture = msaaTexture
descriptor.colorAttachments[0].resolveTexture = drawable.texture
descriptor.colorAttachments[0].storeAction = .multisampleResolve
These changes are a few lines each and routinely return low double-digit percentages. Make it a habit: every time you open a render pass, decide deliberately what these two actions should be for every attachment.
Offscreen rendering
Rendering to a texture is the basis of shadow maps, post-processing and reflection probes. The target must declare the .renderTarget usage:
Swiftfunc makeRenderTarget(size: CGSize, format: MTLPixelFormat) -> MTLTexture {
let d = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: format,
width: Int(size.width), height: Int(size.height),
mipmapped: false)
d.usage = [.renderTarget, .shaderRead]
d.storageMode = .private
return device.makeTexture(descriptor: d)!
}
storageMode = .private matters: the texture is only used by the GPU, the CPU never needs to see it, and the driver is therefore free to pick an optimal layout such as a compressed tile format.
On iOS and Apple Silicon there is a stronger option: .memoryless. Such a texture occupies no device memory at all and exists only in tile memory. Depth buffers, intermediate MSAA attachments and intermediate G-Buffer layers can all be memoryless, provided they are used within a single pass.
Swiftd.storageMode = .memoryless
d.usage = .renderTarget // memoryless cannot be shaderRead across passes
A 4K depth buffer goes from 32 MB to 0 MB, which on a mobile device is a real saving.
Organizing a frame
A typical frame is a chain of passes:
Swiftfunc draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let drawable = view.currentDrawable else { return }
// Pass 1: shadow map
encodeShadowPass(commandBuffer)
// Pass 2: main render into an HDR offscreen texture
encodeMainPass(commandBuffer, target: hdrTexture)
// Pass 3: bloom (possibly several sub-passes)
encodeBloomPass(commandBuffer)
// Pass 4: tone mapping, output to the drawable
encodeTonemapPass(commandBuffer, target: drawable.texture)
commandBuffer.present(drawable)
commandBuffer.commit()
}
A few things worth noting.
All passes share one command buffer. There is no reason to create one per pass β each submission has a fixed overhead, and within one buffer Metal inserts the synchronization the passes need automatically.
Dependencies between passes are implicit. Metal tracks resource reads and writes and inserts barriers for you. This contrasts with Vulkan, where you write barriers by hand β far easier to work with, but it also means synchronization cost is less visible, and you need a GPU frame capture to see where it went.
Acquire currentDrawable as late as possible. It can block waiting for a free drawable. Encode everything that does not depend on it before you ask for it.
Use .rgba16Float for the HDR intermediate. Rendering straight into an 8-bit drawable makes bloom and tone mapping meaningless, because everything above 1.0 has already been clipped.
Managing transient resources with MTLHeap
A post-processing chain has many "use once and discard" intermediates. Creating and destroying them every frame is not free, and keeping them all alive wastes memory. MTLHeap is the answer: a pre-allocated block in which textures can alias:
Swiftlet heapDescriptor = MTLHeapDescriptor()
heapDescriptor.size = 64 * 1024 * 1024
heapDescriptor.storageMode = .private
let heap = device.makeHeap(descriptor: heapDescriptor)!
let tempA = heap.makeTexture(descriptor: descA)!
// once tempA is finished with
tempA.makeAliasable()
// that memory can now be reused by a new texture
let tempB = heap.makeTexture(descriptor: descB)!
Two textures whose lifetimes do not overlap can share the same physical memory. In a complex post chain this routinely halves the memory spent on intermediates. It is also exactly what a render graph does in a modern engine β analyse the dependencies between passes, then derive resource aliasing and the load/store actions automatically.
The next note walks through a real multi-pass flow with shadow mapping.