How to write Metal Shaders on iOS?

Introduction
If you’ve ever wondered how to get started with Apple’s Metal framework for GPU-accelerated graphics, you’re in the right place! In this article, we’ll walk through a simple Metal project that renders a ripple-like wave animation. We’ll break down the code step-by-step — both the Swift and Metal shader code — while keeping things casual and approachable.
What is Metal?
Metal is Apple’s low-level 3D graphics and compute framework that gives you direct access to the GPU (Graphics Processing Unit). It’s designed for high-performance tasks like rendering and data-parallel computations. If you’ve used frameworks like OpenGL or Vulkan, Metal is Apple’s streamlined solution that’s deeply integrated with iOS, macOS, watchOS, and tvOS.
In simpler terms, Metal helps you tap into the raw power of the GPU to draw graphics and run compute kernels. This includes anything from gaming, 3D rendering, image processing, or, as in our example, creating shaders.

What Is a Shader?
A shader is a small program that runs on the GPU to determine how graphics (like vertices, pixels, or entire textures) are processed and displayed on the screen. In most modern graphics pipelines, shaders are divided into different stages:
- Vertex Shader: Processes individual vertices of your geometry (e.g., positions, normals, texture coordinates).
- Fragment (or Pixel) Shader: Calculates the final color of each pixel on the screen.
In Metal, you write these shaders in the Metal Shading Language (MSL). The GPU executes these shaders in parallel for massive speed gains compared to running the same logic on the CPU.
Understanding the Project Structure
We have three main files to focus on:
- WaveShader.metal: Contains both our vertex shader and fragment shader written in MSL.
- MetalView.swift: A subclass of `MTKView` that sets up our Metal pipeline and draws the content.
HomeViewController.swift: A simple view controller that hosts our MetalView.
Let’s start with the shader code, because that’s where the GPU magic happens.
The Shader
Vertex Shader
#include <metal_stdlib>
using namespace metal;
struct VertexOut {
float4 position [[position]];
float2 texCoord;
};
vertex VertexOut vertexShader(uint vertexID [[vertex_id]],
constant float2 *vertices [[buffer(0)]]) {
VertexOut out;
out.position = float4(vertices[vertexID], 0.0, 1.0);
out.texCoord = vertices[vertexID] * 0.5 + 0.5;
return out;
}
Inputs:
- vertexID [[vertex_id]]: A special argument that tells the shader which vertex it’s processing.
- constant float2 *vertices [[buffer(0)]]: Our array of vertices stored in a buffer. A float2 is just an (x, y) coordinate.
Outputs:
- A VertexOut struct containing:
- position [[position]]: The final clip-space coordinates of the vertex (used by the GPU to place the vertex on screen).
- texCoord: The texture (or UV) coordinates we pass to the fragment shader. Here we map the [-1, 1] range to [0, 1] by doing vertices[vertexID] * 0.5 + 0.5.
Essentially, our vertex shader takes the raw positions (in a range of -1 to 1 for both X and Y) and passes them along to the rasterizer. We also compute a texCoord, which you can think of as a 2D coordinate system used by the fragment shader to decide how to color each pixel.
Fragment Shader
struct Uniforms {
float2 resolution;
float time;
};
float4 waves(float2 fragCoord, float2 resolution, float time) {
float h = 11.0 - (fragCoord.y / resolution.y) / 0.1;
float c = round(h);
float y = sin((fragCoord.x / resolution.y) / 0.06 + time * c + c * c) * 0.4;
float o = 1.0 - resolution.y / 30.0 * abs(y - h + c);
return float4(o, o, o, 1.0);
}
fragment float4 fragmentShader(VertexOut in [[stage_in]],
constant Uniforms &uniforms [[buffer(0)]]) {
return waves(in.texCoord * uniforms.resolution,
uniforms.resolution,
uniforms.time);
}
We define a Uniforms struct that matches what we’ll send from Swift:
struct Uniforms {
float2 resolution;
float time;
};
The function waves is where the wave logic resides:
- We calculate h and c based on the fragCoord.y. We use c = round(h) in our wave equation.
- We plug these values, along with time, into a sine function to simulate wave motion.
- Finally, we calculate a final intensity o, which we set as the RGB color in a float4.
The fragmentShader calls waves(…), passing in the texCoord scaled by resolution, the resolution, and the time.
High-level summary:
- Our fragment shader receives the pixel coordinate (in normalized 0–1 space) plus the current time.
- It uses a sine function to create a wave pattern that changes over time.
- The result is a grayscale “wave” effect drawn on the screen.
The Metal View
import MetalKit
struct Uniforms {
var resolution: SIMD2<Float>
var time: Float
var padding: Float = 0
}
final class MetalView: MTKView {
var commandQueue: MTLCommandQueue!
var pipelineState: MTLRenderPipelineState!
var time: Float = 0.0
var vertexBuffer: MTLBuffer!
var animationSpeed: Float = 1
let vertices: [SIMD2<Float>] = [
[-1.0, -1.0], [1.0, -1.0], [-1.0, 1.0],
[-1.0, 1.0], [1.0, -1.0], [1.0, 1.0]
]
...
}
Key Properties
- commandQueue: A queue that schedules GPU work.
- pipelineState: Encapsulates our compiled vertex and fragment shaders, plus other configurations needed for rendering.
- time: A simple floating-point counter we’ll increment every frame for wave animation.
- vertexBuffer: Stores our triangle vertex data in GPU memory.
- animationSpeed: Allows us to control how fast the waves move.
init and setupMetal()
init(frame: CGRect, animationSpeed: Float = 1.0) {
super.init(frame: frame, device: MTLCreateSystemDefaultDevice())
self.animationSpeed = animationSpeed
setupMetal()
}
required init(coder: NSCoder) {
super.init(coder: coder)
setupMetal()
}
We initialize the MTKView with a default Metal device. This device is your connection to the GPU.
func setupMetal() {
guard let device = self.device else { fatalError("Metal is not supported") }
self.commandQueue = device.makeCommandQueue()
guard let library = device.makeDefaultLibrary(),
let fragmentFunction = library.makeFunction(name: "fragmentShader"),
let vertexFunction = library.makeFunction(name: "vertexShader") else { return }
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexFunction
pipelineDescriptor.fragmentFunction = fragmentFunction
pipelineDescriptor.colorAttachments[0].pixelFormat = self.colorPixelFormat
do {
self.pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
} catch {
fatalError("Failed to create pipeline state: \(error)")
}
vertexBuffer = device.makeBuffer(bytes: vertices,
length: MemoryLayout<SIMD2<Float>>.stride * vertices.count,
options: [])
}
Device and Command Queue
- We create a commandQueue, which sends instructions to the GPU.
Shader Library and Functions
- We grab our vertexShader and fragmentShader from the default library (compiled from our .metal files).
Pipeline Descriptor
- We combine our shaders into a render pipeline and compile it into a pipelineState.
Vertex Buffer
- We create a GPU buffer (vertexBuffer) from our vertices array. These vertices define two triangles that fill the entire screen.
Drawing with draw(_ rect:)
override func draw(_ rect: CGRect) {
guard let drawable = self.currentDrawable,
let commandBuffer = commandQueue.makeCommandBuffer(),
let renderPassDescriptor = self.currentRenderPassDescriptor else { return }
let commandEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor)!
commandEncoder.setRenderPipelineState(pipelineState)
commandEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
var uniforms = Uniforms(
resolution: SIMD2<Float>(Float(rect.width), Float(rect.height)),
time: time
)
commandEncoder.setVertexBytes(&uniforms, length: MemoryLayout<Uniforms>.size, index: 1)
commandEncoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.size, index: 0)
commandEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: vertices.count)
commandEncoder.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
time += 0.01 * animationSpeed
}
- Get the Drawable and Command Buffer
- A drawable is the texture you’ll render into.
- A command buffer is where you record all the drawing commands for the GPU.
2. Create a Command Encoder
- A command encoder writes the actual render commands (e.g., setting shaders, buffers, issuing draw calls).
3. Set the Pipeline State
- Tells the GPU which shaders (compiled pipeline) to use for this draw call.
4. Set Buffers
- We bind our vertexBuffer and uniforms (for both vertex and fragment stages).
- Notice we update uniforms.time each frame to advance the animation.
5. Draw Primitives
- We specify .triangleStrip with a vertexCount = 6. We’re drawing two triangles to fill the entire screen.
6. Commit
- We finalize (endEncoding) and submit (commit) the command buffer to the GPU.
- present(drawable) presents the rendered texture on the screen.
7. Increment time
- On each frame, we increase time by 0.01 * animationSpeed, giving us that wave effect.
Implementation in the ViewController
final class HomeViewController: UIViewController {
lazy var metalView = MetalView(frame: view.frame, animationSpeed: 0.2)
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(metalView)
}
}
In the HomeViewController, we create an instance of our MetalView (with a custom animationSpeed if desired) and add it to the view hierarchy. That’s it!

Full Project Code
You can see the full project code in my github repo
The Original Shader
I saw the original shader written in GLSL on here. Then I really liked the animations so I wanted to make the Metal version of it 😁.
Further Resources
- Apple Developer Documentation for Metal
- Metal Shading Language Specification
- Metal Performance Shaders (for advanced compute tasks)
Conclusion
Congrats! You’ve just walked through a simple Metal project that draws an animated wave on iOS devices. We covered:
- How Metal works at a high level.
- Vertex and fragment shader basics.
- Setting up an MTKView to manage the GPU pipeline.
- How to animate graphics by changing a uniform (time) across frames.
For more iOS development insights, be sure to check out my previous articles:
Let’s Connect
🚀 Found this article helpful? Hit the clap button or share it with your fellow developers.
💬 Have questions or ideas? Drop a comment — I’d love to hear from you!
🌏 You can see all of my works and social media on my website.
Happy coding! 😊