Chapters

Hide chapters

Metal by Tutorials

Second Edition · iOS 13 · Swift 5.1 · Xcode 11

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

Section I: The Player

Section 1: 8 chapters
Show chapters Hide chapters

Section III: The Effects

Section 3: 10 chapters
Show chapters Hide chapters

16. Particle Systems
Written by Marius Horga

One of the many ways to create art and present science in code is by making use of particles. A particle is a tiny graphical object that carries basic information about itself such as color, position, life, speed and direction of movement.

Nothing explains a visual effect better than an image showing what you’ll be able to achieve at the end of this chapter.

Particle systems are widely used in:

  • Video games and animation: hair, cloth, fur.
  • Modeling of natural phenomena: fire, smoke, water, snow.
  • Scientific simulations: galaxy collisions, cellular mitosis, fluid turbulence.

Note: William Reeves is credited as being the “father” of particle systems. While at Lucasfilm, Reeves created the Genesis Effect in 1982 while working on the movie Star Trek II: The Wrath of Khan. Later, he joined Pixar Animation Studios where he’s still creating amazing animations using particles.

In a moment, you’ll get your feet wet trying out one such practical application: fireworks. But first, what exactly is a particle?

Particle

Newtonian dynamics describe the relationship between any small body — a particle — and the forces acting upon it, as well as its motion in response to those forces. Newton’s three laws of motion define the relationship between them.

The first two laws define motion as a result of either inertia or force interference upon the particle’s current state of motion (stationary or moving). You’ll be working with them in this chapter.

The third law, however, defines motion as a reaction of two or more particles interacting with each other. You’ll work with this law in Chapter 17, “Particle Behavior.”

A fourth law, if you wish, is the law of life. It’s not one of the Newtonian motion laws, but it does indeed apply to particles. Particles are born; they move and interact with the environment; and then, they die.

You need a particle system to create fireworks. First, however, you need to define a particle that has — at a minimum — a position, direction, speed, color and life.

What makes a particle system cohesive, however, are emitters.

Emitter

An emitter is nothing more than a particle generator — in other words, a source of particles. You can make your particle system more exciting by having several emitters shooting out particles from different positions.

Fireworks are sequential explosions that occur a few seconds apart from each other. Emitters fit here perfectly so you’ll create one first.

Open Fireworks.playground located in the starter folder for this chapter. This playground has an MTKView with a Renderer as the delegate. Currently, the only thing Renderer does is set the background color to (0.5, 0.5, 0.5, 1.0) which is the RGB translation for gray.

In the Sources folder, create a new file named Emitter.swift, and add this code to it:

import MetalKit

struct Particle {
  var position: float2
  var direction: Float
  var speed: Float
  var color: float3
  var life: Float
}

This is a struct to hold the particle’s properties.

Now, create an emitter with a buffer to hold the particles.

public struct Emitter {
  public let particleBuffer: MTLBuffer
}

Inside Emitter, add an initializer that will take in the particle count and the particle’s lifetime:

public init(particleCount: Int, size: CGSize, 
            life: Float, device: MTLDevice) {
  let bufferSize = MemoryLayout<Particle>.stride * particleCount
  particleBuffer = device.makeBuffer(length: bufferSize)!
}

Here, you create the buffer with a size that represents the area in which the particles will be drawn.

You’ll randomize the initial position and color once for all particles, and then randomize each particle’s speed and direction based on the size of the display area.

Add the following code to init(particleCount:size:life:device):

var pointer = 
    particleBuffer.contents().bindMemory(to: Particle.self,
                                   capacity: particleCount)
let width = Float(size.width)
let height = Float(size.height)
let position = float2(Float.random(in: 0...width),
                      Float.random(in: 0...height))
let color = float3(Float.random(in: 0...life) / life,
                   Float.random(in: 0...life) / life,
                   Float.random(in: 0...life) / life)
   
for _ in 0..<particleCount {
  let direction = 
      2 * Float.pi * Float.random(in: 0...width) / width
  let speed = 3 * Float.random(in: 0...width) / width
  pointer.pointee.position = position
  pointer.pointee.direction = direction
  pointer.pointee.speed = speed
  pointer.pointee.color = color
  pointer.pointee.life = life
  pointer = pointer.advanced(by: 1)
}

Here, you loop through the buffer using a pointer to access each particle object and set their properties.

You’ve now created lots of particles, each with distinct direction and speed. However, all of them have the same original position, color and life.

Back in Renderer, create the following properties:

let particleCount = 10000
let maxEmitters = 8
var emitters: [Emitter] = []
let life: Float = 256
var timer: Float = 0

Next, you’ll set up an array of emitters, each with 10,000 particles. Add this method to Renderer:

func update(size: CGSize) {
  timer += 1
  if timer >= 50 {
    timer = 0
    if emitters.count > maxEmitters {
      emitters.removeFirst()
    }
    let emitter = Emitter(particleCount: particleCount, 
                          size: size, life: life, 
                          device: device)
    emitters.append(emitter)
  }
}

You reset a timer variable every time it reaches a threshold (50 in this case). At that point, you add a new emitter and then remove the oldest one.

Call this method at the top of draw(in:), after the guard statement, so that it’s called every frame:

update(size: view.drawableSize)

Run the playground to verify everything compiles. Note, however, you’ll see the same solid gray color as you did in the beginning.

The shader function is where the particles’ life and position are updated. For this to work, you need more granular control of the GPU threads than you had with render encoding.

Enter compute encoding!

Compute

What is compute? Simply put, compute programming is the only other way to use a GPU besides rendering. Compute is also widely known as General Purpose GPU (GPGPU) programming.

GPUs transitioned about a decade ago from a fixed-pipeline architecture to programmable pipelines, enabling the many-core GPU architectures to speed up parallel computation needs such as image/video processing, scientific simulations and more recently, machine learning.

Compute uses a compute command encoder. This is another encoder type Metal provides, along with the render command encoder and the blit command encoder that you have already used.

All right, time to write some compute code!

In Renderer, add this property to the top of the class:

let pipelineState: MTLComputePipelineState!

Then, update initializeMetal to return a value for it:

private static func initializeMetal() -> (
  device: MTLDevice, commandQueue: MTLCommandQueue,
  pipelineState: MTLComputePipelineState)? {
  guard let device = MTLCreateSystemDefaultDevice(),
    let commandQueue = device.makeCommandQueue(),
    let path = Bundle.main.path(forResource: "Shaders",
                                ofType: "metal") 
      else { return nil }
  
  let pipelineState: MTLComputePipelineState
  
  do {
    let input = try String(contentsOfFile: path,
                           encoding: String.Encoding.utf8)
    let library = try device.makeLibrary(source: input, 
                                         options: nil)
    guard let function = library.makeFunction(name: "compute")
      else { return nil }
    pipelineState = 
        try device.makeComputePipelineState(function: function)
  }
  catch {
    print(error.localizedDescription)
    return nil
  }
  return (device, commandQueue, pipelineState)
}

Finally, assign the value in init() before calling `super.init():

pipelineState = initialized?.pipelineState

This part should be familiar from previous chapters. You create the Metal library, a kernel function and a pipeline state. However, what’s new here is the way in which you create the pipeline state.

Notice that you didn’t need a pipeline state descriptor anymore but instead created the pipeline state directly from the kernel function — the third (and last) type of shader function Metal provides, along with the vertex and fragment functions.

You’ll learn more about the kernel function shortly.

Threads and threadgroups

Next, you’ll create a compute pass. For that, you need to specify how many times you want the kernel function to run. To determine this, you need to know the size of the array, texture or volume you want to process. This size is known as the grid and consists of threads organized into threadgroups.

The grid is defined in three dimensions: width, height and depth — but often, especially when you’re processing images, you’ll only work with a 1D or 2D grid. Every point in the grid runs one instance of the kernel function, each on a separate thread.

In this example, you have an image of 512 by 384 pixels. This grid is split up into 384 threadgroups — that’s 16 by 24 threadgroups. Each threadgroup has 512 threads — that’s 32 by 16 threads.

See how a threadgroup can only affect a tiny part of the grid and process it independently from other threadgroups?

Threadgroups have the advantage of executing a group of threads together and also share a small chunk of memory. Even though sometimes threads run independently of each other, it’s also common to organize threads into threadgroups so they can work on smaller parts of the problem, independently from other threadgroups.

You can choose how you split up the group. In the following image, a 16 by 12 grid is split first into 2✕3 threadgroups and secondly into 4✕4 thread groups.

Within each grid, you can locate each pixel. The red pixel in both grids is located at (11, 1). You can also uniquely identify each thread within the threadgroup. The blue threadgroup on the left is located at (1, 0), and on the right at (2, 0). The red pixels in both grids are threads located within their threadgroup at (3, 1).

You have control over the number of threadgroups. However, you need to add an extra threadgroup to the size of the grid to make sure at least one threadgroup will execute.

The following code demonstrates how you could set up the threads and threadgroups to process the cat image above. It is only for exemplification, and you don’t need to put it into your project:

let width = 32
let height = 16
let threadsPerThreadgroup = MTLSizeMake(width, height, 1)
let gridWidth = Int(view.drawableSize.width)
let gridHeight = Int(view.drawableSize.height)
let threadGroupCount = 
    MTLSizeMake((gridWidth + width - 1) / width,
                (gridHeight + height - 1) / height,
                1)
computeEncoder.dispatchThreadgroups(threadGroupCount,
                threadsPerThreadgroup: threadsPerThreadgroup)

You specify the threads per threadgroup. In this case, the threadgroup will consist of 32 threads wide, 16 threads high and 1 thread deep. This is a typical threadgroup for processing a 2D image. You calculate the threadgroup count from the grid (image) width and height. In this case, the image size is 512 by 384 pixels, so threadGroupCount will be calculated as (16, 24, 1). You then tell the compute command encoder to dispatch this batching information to the GPU.

In this example, the threads and threadgroups work out evenly across the grid, but if the size of your data does not match the size of the grid, you may have to perform boundary checks in the kernel function.

In the following example, with a threadgroup size of 32 by 16 threads, the number of threadgroups necessary to process the image would be 10 by 13. You’d have to check that the threadgroup is not using threads that are off the edge of the image.

The threads that are off the edge, are underutilized threads; that is, threads that you dispatched but there was no work for them to do.

In the most recent version of iOS and macOS, it’s now possible to only specify the size of your grid and the size of a threadgroup. From there, Metal will figure out the number of threadgroups for you to use.

This is an excellent feature because Metal will provide nonuniform threadgroups if the grid size is not a multiple of the threadgroup size:

You calculate the number of threads per threadgroup based on two compute pipeline state properties:

  • maxTotalThreadsPerThreadgroup: its name is self-explanatory
  • threadExecutionWidth: represents the number of threads scheduled to execute in parallel on the GPU.

Note: maxTotalThreadsPerThreadgroup depends on the device, kernel register usage and thread group memory usage. maxTotalThreadsPerThreadgroup will not change once you created the compute pipeline state but two compute pipeline states on the same device may have different maxTotalThreadsPerThreadgroup values. Also, for the most efficient execution of the kernel function, you should set the number of threads in the threadgroup as a multiple of threadExecutionWidth.

Back to code! In Renderer.swift, in draw(in:), replace these lines:

let renderEncoder = makeRenderCommandEncoder(commandBuffer, 
                                             drawable.texture)
renderEncoder.endEncoding()

With this code:

// 1
guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() 
           else { return }
computeEncoder.setComputePipelineState(pipelineState)
computeEncoder.setTexture(drawable.texture, index: 0)
// 2
var width = pipelineState.threadExecutionWidth
var height = pipelineState.maxTotalThreadsPerThreadgroup / width
let threadsPerThreadgroup = MTLSizeMake(width, height, 1)
width = Int(view.drawableSize.width)
height = Int(view.drawableSize.height)
var threadsPerGrid = MTLSizeMake(width, height, 1)
// 3
computeEncoder.dispatchThreads(threadsPerGrid, 
                   threadsPerThreadgroup: threadsPerThreadgroup)
computeEncoder.endEncoding()

Going through the code:

  1. You create the compute command encoder and set its pipeline state. You also send the drawable texture to the GPU at index 0 for writing.
  2. You declare the number of threads per group and the number of threads per grid.
  3. You dispatch these threads to do the work in parallel on the GPU.

You’ll get a compiler warning for the var, but you’ll be reusing threadsPerGrid shortly.

Note: A kernel function executes once per thread just like a vertex function executes once per vertex. The main difference between the kernel function and the other two shader functions is that a kernel function’s return type is always void so it will never return anything.

In the Resources folder, create a new file named Shaders.metal, and add the kernel function:

#include <metal_stdlib>
using namespace metal;

kernel void compute(texture2d<half, access::read_write> 
                                     output [[texture(0)]],
                    uint2 id [[thread_position_in_grid]]) {
  output.write(half4(0.0, 0.0, 0.0, 1.0), id);
}

You create a kernel function that takes, as arguments, the drawable texture you sent from the CPU and the thread index. Then, you write the same color (black) to the drawable texture for each thread/pixel.

The kernel function’s index parameter uses the [[thread_position_in_grid]] attribute qualifier which uniquely locates a thread within the compute grid and enables it to work distinctly from the others.

The thread index could be 1-, 2- or 3-dimensions depending on how you configured the number of threads per grid before dispatching them. Since you declared the grid would be of width * height size back in Renderer: threadsPerGrid = MTLSizeMake(width, height, 1), you match it with a uint2 (2-dimensional) index in the kernel function for the threads to be correctly dispatched.

Run the playground again and now that you’re writing a color to the view’s drawable texture, you’ll finally see the view color turns from gray to black.

All right, you managed to transition from a rendering pipeline to a compute pipeline fully. So far so good! But what about the particles?

You learned in Chapter 14, “Multipass and Deferred Rendering” how you can use the output of one pass as the input of a second pass. You could apply that concept here and use two pipeline states: one to clear the screen, as you just did, and another one for cool fireworks particles! :]

Fireworks

At the top of Renderer, add a new pipeline state:

let particlePipelineState: MTLComputePipelineState!

Add a particlePipelineState to the end of the signature for initializeMetal:

private static func initializeMetal() -> (
  device: MTLDevice, commandQueue: MTLCommandQueue,
  pipelineState: MTLComputePipelineState, 
  particlePipelineState: MTLComputePipelineState)?

In initializeMetal, before the do block, add this declaration:

let particlePipelineState: MTLComputePipelineState

Change the end of the do block from the guard onwards to this:

guard let function = library.makeFunction(name: "compute"),
  let particleFunction = 
      library.makeFunction(name: "particleKernel") 
  else { return nil }
pipelineState = 
    try device.makeComputePipelineState(function: function)
particlePipelineState = try
  device.makeComputePipelineState(function: particleFunction)

Add particlePipelineState to the returned tuple, disambiguating it from pipelineState by using labels for each:

return (
  device, commandQueue,
  pipelineState: pipelineState, 
  particlePipelineState: particlePipelineState)

Lastly, assign the value in init() before calling super.init():

particlePipelineState = initialized?.particlePipelineState

In draw(in:), add these lines below // second command encoder:

// 1
guard let particleEncoder = commandBuffer.makeComputeCommandEncoder() 
            else { return }
particleEncoder.setComputePipelineState(particlePipelineState)
particleEncoder.setTexture(drawable.texture, index: 0)
// 2
threadsPerGrid = MTLSizeMake(particleCount, 1, 1)
for emitter in emitters {
  // 3    
  let particleBuffer = emitter.particleBuffer
  particleEncoder.setBuffer(particleBuffer, offset: 0, index: 0)
  particleEncoder.dispatchThreads(threadsPerGrid, 
                  threadsPerThreadgroup: threadsPerThreadgroup)
}
particleEncoder.endEncoding()

Going through this code:

  1. You create a second command encoder and set the particle pipeline state and drawable texture to it.
  2. You change the dimensionality from 2D to 1D and set the number of threads per grid to equal the number of particles.
  3. You dispatch threads for each emitter in the array.

Since your threadsPerGrid is now 1D, you need to match [[thread_position_in_grid]] in the shader kernel function with a uint parameter. Threads will not be dispatched for each pixel anymore but rather for each particle, so [[thread_position_in_grid]] in this case will only affect a particular pixel if there is a particle emitted at the current pixel.

All right, time for some physics chatter!

Particle dynamics

Particle dynamics makes heavy use of Newton’s laws of motion. Particles are considered to be small objects approximated as point masses.

Since volume is not something that characterizes particles, scaling or rotational motion will not be considered. Particles will, however, make use of translation motion so they’ll always need to have a position.

Besides a position, particles might also have a direction and speed of movement (velocity), forces that influence them (e.g., gravity), a mass, a color and an age.

Since the particle footprint is so small in memory, modern GPUs can generate 4+ million particles, and they can follow the laws of motion at 60 fps!

For now, you’re going to ignore gravity, so its value will be 0. Time in this example won’t change so it will have a value of 1. As a consequence, velocity will always be the same. You can also assume the particle mass is always 1, for convenience.

To calculate the position, you use this formula:

Where x2 is the new position, x1 is the old position, v1 is the old velocity, t is time, and a is acceleration.

This is the formula to calculate the new velocity from the old one:

However, since the acceleration is 0 in this case, the velocity will always have the same value.

As you might remember from Physics class, the formula for velocity is:

velocity = speed * direction

Plugging all this information into the first formula above gives you the final equation to use in the kernel.

Again, as for the second formula, since acceleration is 0, the last term cancels out:

newPosition = oldPosition * velocity

Finally, you’re creating exploding fireworks, so your firework particles will move in a circle that keeps growing away from the initial emitter origin, so you need to know the equation of a circle:

Using the angle that the particle direction makes with the axes, you can re-write the velocity equation from the parametric form of the circle equation using the trigonometric functions sine and cosine as follows:

xVelocity = speed * cos(direction)
yVelocity = speed * sin(direction)

Great! Why don’t you write all this down in code now?

In Shaders.metal, add the following:

struct Particle {
  float2 position;
  float  direction;
  float  speed;
  float3 color;
  float  life;
};

This defines a particle struct to match the one from Emitter.swift which you created earlier. Now, add a second kernel function:

kernel void particleKernel(texture2d<half, access::read_write> 
                             output [[texture(0)]],
  // 1
                    device Particle *particles [[buffer(0)]],
                    uint id [[thread_position_in_grid]]) {
  // 2
  float xVelocity = particles[id].speed 
                       * cos(particles[id].direction);
  float yVelocity = particles[id].speed 
                       * sin(particles[id].direction) + 3.0;
  particles[id].position.x += xVelocity;
  particles[id].position.y += yVelocity;
  // 3
  particles[id].life -= 1.0;
  half4 color;
  color.rgb = 
     half3(particles[id].color * particles[id].life / 255.0);
  // 4
  color.a = 1.0;
  uint2 position = uint2(particles[id].position);
  output.write(color, position);
  output.write(color, position + uint2(0, 1));
  output.write(color, position - uint2(0, 1));
  output.write(color, position + uint2(1, 0));
  output.write(color, position - uint2(1, 0));
}

Going through this code:

  1. Get the particle buffer from the CPU and use a 1D index to match the number of threads per grid you dispatched earlier.
  2. Compute the velocity and update the position for the current particle according to the laws of motion and using the circle equation as explained above.
  3. Update the life variable and compute a new color after each update. The color will fade as the value held by the life variable gets smaller and smaller.
  4. Write the updated color at the current particle position, as well as its neighboring particles to the left, right, top and bottom to create the look and feel of a thicker particle.

Run the playground once more and finally enjoy the cool fireworks!

You can improve the realism of particle effects in at least a couple of ways. One of them is to attach a sprite or a texture to each particle. Instead of a dull point, you’ll then be able to see a textured point which looks way more lively.

You can practice this technique in the next particle endeavor: a snowing simulation.

Particle systems

Open up the Particles starter project. You’ll get a warning compile message, but that will disappear when you start adding code.

This project contains the pipeline states you’ll need, in addition to an Emitter class similar to the struct in your fireworks playground.

Particle systems can be very complex with many different options for particle movement, colors and sizes, but this Emitter class is a fairly simple example of a generic particle system where you can create many different types of particles.

For example, you’re going to create snow falling, but also a fire blazing upwards. These particle systems will have different speeds, textures and directions.

In Emitter.swift, you have a ParticleDescriptor. To create a particle system, you create a descriptor which describes all the characteristics of your particle system. Many of the properties in ParticleDescriptor are ClosedRanges.

For example, as well as position, there is a positionXRange and positionYRange. This allows you to specify a starting position but also allows randomness within limits. If you specify a position of [10, 0], and a positionXRange of 0...180, then each particle will be within the range of 10 to 190.

Emitter also has a birthRate property. This allows you to slowly release particles (like a gentle snow flurry) or send them out more quickly (like a blazing fire).

Each particle has a startScale and an endScale. By setting the startScale to 1 and the endScale to 0, you can make the particle get smaller over its lifespan.

When you create the particle system, you create a buffer the size of all the particles. emit() processes each new particle and creates it with the particle settings you set up in ParticleDescriptor.

More complex particle systems would maintain a live buffer and a dead buffer. As particles die, they move from live to dead, and as the system requires new particles, it recovers them from dead. However, in this more simple system, a particle never dies. As soon as a particle’s age reaches its life-span, it’s reborn with the values it started with.

Snow

You’ll attach a texture to each snow particle to improve the realism of your rendering. To render textured particles, as well as having a compute kernel to update the particles, you’ll also have a render pipeline with vertex and fragment functions to render them.

To start, open Renderer.swift and examine the contents of Renderer. You’ll find a method that builds both the pipeline to update the particles with a compute pipeline, and the pipeline for rendering the particles. draw(in:) is just a skeleton that is waiting for you to fill out the passes.

In draw(in:), under // first command encoder, add this:

guard let computeEncoder = 
    commandBuffer.makeComputeCommandEncoder()
  else { return }
computeEncoder.setComputePipelineState(particlesPipelineState)
let width = particlesPipelineState.threadExecutionWidth
let threadsPerGroup = MTLSizeMake(width, 1, 1)
for emitter in emitters {
  let threadsPerGrid = MTLSizeMake(emitter.particleCount, 1, 1)
  computeEncoder.setBuffer(emitter.particleBuffer, 
                           offset: 0, index: 0)
  computeEncoder.dispatchThreads(threadsPerGrid,
                     threadsPerThreadgroup: threadsPerGroup)
}
computeEncoder.endEncoding()

Here you create the compute encoder that will update each emitter’s particles, and create a 1D grid that dispatches a new set of threads for each emitter.

Note: The previous code, which may create non-uniform threadgroup sizes, will only work on macOS, and iOS devices included in Apple 4 and later GPUs. See Chapter 24, “Performance Optimization” for GPU family breakdown. In addition, the iOS simulator does not support non-uniform threadgroup sizes.

Moving on, below // second command encoder, add the following code for the render command encoder:

// 1
let renderEncoder = 
 commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)!
renderEncoder.setRenderPipelineState(renderPipelineState)
// 2
var size = float2(Float(view.drawableSize.width), 
                  Float(view.drawableSize.height))
renderEncoder.setVertexBytes(&size, 
                     length: MemoryLayout<float2>.stride, 
                     index: 0)
// 3
for emitter in emitters {
  renderEncoder.setVertexBuffer(emitter.particleBuffer, 
                                offset: 0, index: 1)
  renderEncoder.setVertexBytes(&emitter.position,
                     length: MemoryLayout<float2>.stride,
                     index: 2)
  renderEncoder.setFragmentTexture(emitter.particleTexture, 
                                   index: 0)
  renderEncoder.drawPrimitives(type: .point, vertexStart: 0, 
                     vertexCount: 1, 
                     instanceCount: emitter.currentParticles)
}
renderEncoder.endEncoding()

Going through this code, you:

  1. Create a new render command encoder and set the render pipeline state to use the two shader functions.
  2. Determine the size of the rendering window and send that size to the vertex shader.
  3. For each emitter, you send the emitter buffer and the emitter’s position to the vertex shader, set the particle’s texture and draw a point primitive for each particle.

Now that you’ve set up the Swift side, you can build and run to ensure the project works. You should get a blank screen with the gray color of the view set in init(metalView:).

All right, time to configure the shader functions.

Replace the kernel function in Shaders.metal with this one:

// 1
kernel void compute(device Particle *particles [[buffer(0)]],
                    uint id [[thread_position_in_grid]]) {
  // 2
  float xVelocity = particles[id].speed 
                       * cos(particles[id].direction);
  float yVelocity = particles[id].speed 
                       * sin(particles[id].direction);
  particles[id].position.x += xVelocity;
  particles[id].position.y += yVelocity;
  // 3
  particles[id].age += 1.0;
  float age = particles[id].age / particles[id].life;
  particles[id].scale =  mix(particles[id].startScale,
                             particles[id].endScale, age);
  // 4
  if (particles[id].age > particles[id].life) {
    particles[id].position = particles[id].startPosition;
    particles[id].age = 0;
    particles[id].scale = particles[id].startScale;
  }
}

This code:

  1. Takes in the particle buffer as a shader function argument and sets a 1D index to iterate over this buffer.

  2. Calculates the velocity for each particle and then updates its position by adding the velocity to it.

  3. Updates the particle’s age and scales the particle depending on its age.

  4. If the particle’s age has reached its total lifespan, reset the particle to its original properties.

Add a new struct for the vertex and fragment shaders:

struct VertexOut {
  float4 position   [[position]];
  float  point_size [[point_size]];
  float4 color;
};

The only notable change here is the use of the [[point_size]] attribute. By setting this in the vertex function, you can change the point size.

Note: As opposed to [[position]], which is mandatory in a struct that is used as a return type (i.e. VertexOut in your case), you can omit [[point_size]] when not needed. However, you won’t be able to access the point size in the fragment function.

Replace the vertex shader with this one:

// 1
vertex VertexOut vertex_particle(
             constant float2 &size [[buffer(0)]],
             const device Particle *particles [[buffer(1)]],
             constant float2 &emitterPosition [[ buffer(2) ]],
             uint instance [[instance_id]]) {
  VertexOut out;
  // 2
  float2 position = particles[instance].position 
                         + emitterPosition;
  out.position.xy = position.xy / size * 2.0 - 1.0;
  out.position.z = 0;
  out.position.w = 1;
  // 3
  out.point_size = particles[instance].size 
                         * particles[instance].scale;
  out.color = particles[instance].color;
  return out;
}

Going through this code:

  1. Get the drawable size from the CPU as well as the updated particle buffer and the emitter’s position, and use a 1D index to iterate over all particles.
  2. Offset the particle position by the emitter’s position, and map the particle positions from a [0, 1] range to a [-1, 1] range so that the middle of the screen is now the origin (0, 0).
  3. Set the particle’s point size and color.

Finally, replace the fragment shader with this code:

// 1
fragment float4 fragment_particle(
           VertexOut in [[stage_in]],
           texture2d<float> particleTexture [[texture(0)]],
           float2 point [[point_coord]]) {
  constexpr sampler default_sampler;
  float4 color = particleTexture.sample(default_sampler, point);
  if (color.a < 0.5) {
    discard_fragment();
  }
  color = float4(color.xyz, 0.5);
  color *= in.color;
  return color;
}

Going through this code:

  1. Get the processed particle fragments via [[stage_in]] and the snowflake texture from the CPU. The [[point_coord]] attribute is generated by the rasterizer and is a 2D coordinate that indicates where the current fragment is located within a point primitive (in a [0, 1] range).
  2. Create a sampler and use it to sample from the given texture at the current fragment position.
  3. Apply alpha testing so you can get rid of very low alpha values.
  4. Return the texture color combined with the particle color.

You’ve set up the particle computing and rendering structure — now to set up an emitter for snow particles.

Open Particles.swift. fire(size:) is already set up for you, giving you a preview of what your snow emitter is going to look like.

Add this new method:

func snow(size: CGSize) -> Emitter {
  let emitter = Emitter()

  // 1
  emitter.particleCount = 100
  emitter.birthRate = 1
  emitter.birthDelay = 20

  // 2
  emitter.particleTexture = 
      Emitter.loadTexture(imageName: "snowflake")!

  // 3
  var descriptor = ParticleDescriptor()
  descriptor.position.x = 0
  descriptor.positionXRange = 0...Float(size.width)
  descriptor.direction = -.pi / 2
  descriptor.speedRange =  2...6
  descriptor.pointSizeRange = 80 * 0.5...80
  descriptor.startScale = 0
  descriptor.startScaleRange = 0.2...1.0

  // 4
  descriptor.life = 500
  descriptor.color = [1, 1, 1, 1]
  emitter.particleDescriptor = descriptor
  return emitter
}

Here’s what’s happening:

  1. You tell the emitter how many particles in total should be in the system. birthRate and birthDelay control how fast the particles emit. With these parameters, you’ll emit one snowflake every twenty frames until there are 100 snowflakes in total. If you want a blizzard rather than a few flakes, then you can set the birthrate higher and the delay between each emission less.
  2. Load a snowflake texture to render onto the particle.
  3. The descriptor describes how each particle should be initialized. You set up ranges for position, speed and scale.
  4. A particle has an age and a life-span. A snowflake particle will remain alive for 500 frames and then recycle. You want the snowflake to travel from the top of the screen all the way down to the bottom of the screen. life has to be long enough for this to happen. If you give your snowflake a short life, it will disappear while still on screen.

Particle parameters are really fun to experiment with. Once you have your snowflakes falling, change any of these parameters to see what the effect is.

In Renderer.swift, at the end of init(metalView:), set up your snow emitter:

let snowEmitter = snow(size: metalView.drawableSize)
snowEmitter.position = [0, Float(metalView.drawableSize.height)]
emitters.append(snowEmitter)

This sets the emitter at the top of the screen. Any particles with position (0, 0) will emit from that position.

Add similar code to mtkView(_:drawableSizeWillChange:) so that the emitter and particles will update when the screen size changes:

func mtkView(_ view: MTKView, 
             drawableSizeWillChange size: CGSize) {
  emitters.removeAll()
  let snowEmitter = snow(size: size)
  snowEmitter.position = [0, Float(size.height)]
  emitters.append(snowEmitter)
}

At the top of draw(in:), add this to set the emitters emitting:

for emitter in emitters {
  emitter.emit()
}

The emitters will create all the particles gradually over time, depending on the emitters’ birthRate and particleCount.

Build and run the project, and enjoy the relaxing snow with variable snowflake speeds and sizes:

Go back and experiment with some of the particle settings. With particleCount of 800, birthDelay of 2 and speedRange of 4…8, you start off with a gentle snowfall that gradually turns into a veritable blizzard.

Fire

Brrr. That snow is so cold, you need a fire. In Renderer.swift, at the end of init(metalView) change the snow emitter to:

let fireEmitter = fire(size: metalView.drawableSize)
fireEmitter.position = [0, -10]
emitters.append(fireEmitter)

This positions the emitter just off the bottom of the screen. Change the code in mtkView(_:drawableSizeWillChange:) so that the fire is always in the center of the screen:

let fireEmitter = fire(size: size)
fireEmitter.position = [0, -10]
emitters.append(fireEmitter)

Look at the fire settings in Particles.swift and see if you can work out what the particle system will look like.

You’re loading more particles than for snow, and a different texture. The birth rate is higher, and there’s no delay. The direction is upwards, with a slight variation in range. The particle scales down over its life. The color is fiery orange.

Build and run to see this new particle system in action.

The particles are certainly performing as they should, but it doesn’t really look anything like a nice blazing fire with yellow and white heat in the middle of it.

You can achieve this by enabling alpha blending so that particle colors can blend together. Currently, when two particles are in the same position, the top color wins out. However, if you blend the two particles, they’ll combine to yellow, and if there are enough blended particles in one position, to white.

Also, you don’t care about the order the particles were rendered, so you need to adopt an order-independent transparency methodology which uses an additive blending formula that doesn’t depend on the order in which particles are rendered. This is needed to create a fluid, homogeneous color.

In Renderer.swift, in buildPipelineStates() after this line:

descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm

Add this:

// 1
descriptor.colorAttachments[0].isBlendingEnabled = true
descriptor.colorAttachments[0].rgbBlendOperation = .add
// 2
descriptor.colorAttachments[0].sourceRGBBlendFactor 
      = .sourceAlpha
descriptor.colorAttachments[0].destinationRGBBlendFactor = .one

Going through this code:

  1. Enable additive blending.
  2. Set the source blending factor to its alpha channel and the destination blending factor to the value of 1 which will be added to the source color.

Build and run the project so you can finally enjoy the burning fire.

Where to go from here?

You’ve only just begun playing with particles! There are many more particle characteristics you could include in your particle system:

  • Color over life.

  • Gravity.

  • Acceleration.

  • Instead of scaling linearly over time, how about scaling slowly then faster?

If you want more ideas, review the links in this chapter’s references.markdown.

There are some things you haven’t yet looked at, like collisions or reaction to acting forces. You have also not read anything about intelligent agents and their behaviors. You’ll learn more about all this next, in Chapter 17, “Particle Behavior.”

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.