Chapters

Hide chapters

Metal by Tutorials

Fourth Edition · macOS 14, iOS 17 · Swift 5.9 · Xcode 15

Section I: Beginning Metal

Section 1: 10 chapters
Show chapters Hide chapters

Section II: Intermediate Metal

Section 2: 8 chapters
Show chapters Hide chapters

Section III: Advanced Metal

Section 3: 8 chapters
Show chapters Hide chapters

17. Particle Systems
Written by Caroline Begbie & Marius Horga

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Heads up... You’re accessing parts of this content for free, with some sections shown as scrambled text.

Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now

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
Particle systems

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 18, “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.

Particle life
Particle life

You need a particle system to create fireworks. But first, you need to define a particle that has — at a minimum — a position, direction, speed, color and life. What makes a particle system cohesive, though, 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.

The Starter Project

➤ In Xcode, open the starter project for this chapter, then build and run it.

The starter project
Khi snaxgin jsixucs

Creating a Particle and Emitter

➤ In the Shaders group, open Common.h, and add a new structure:

struct Particle {
  vector_float2 position;
  float direction;
  float speed;
  vector_float4 color;
  float life;
};
import MetalKit

struct FireworksEmitter {
  let particleBuffer: MTLBuffer

  init(
    particleCount: Int,
    size: CGSize,
    life: Float
  ) {
    let bufferSize =
      MemoryLayout<Particle>.stride * particleCount
    particleBuffer =
      Renderer.device.makeBuffer(length: bufferSize)!
  }
}
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 = float4(
  Float.random(in: 0...life) / life,
  Float.random(in: 0...life) / life,
  Float.random(in: 0...life) / life,
  1)
var pointer =
  particleBuffer.contents().bindMemory(
    to: Particle.self,
    capacity: particleCount)
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)
}
let particleCount = 10000
let maxEmitters = 8
var emitters: [FireworksEmitter] = []
let life: Float = 256
var timer: Float = 0
timer += 1
if timer >= 50 {
  timer = 0
  if emitters.count > maxEmitters {
    emitters.removeFirst()
  }
  let emitter = FireworksEmitter(
    particleCount: particleCount,
    size: size,
    life: life)
  emitters.append(emitter)
}

The Compute Pipeline State Object

➤ Open Pipelines.swift and add this to PipelineStates:

static func createComputePSO(function: String)
  -> MTLComputePipelineState {
  guard let kernel = Renderer.library.makeFunction(name: function)
  else { fatalError("Unable to create \(function) PSO") }
  let pipelineState: MTLComputePipelineState
  do {
    pipelineState =
    try Renderer.device.makeComputePipelineState(function: kernel)
  } catch {
    fatalError(error.localizedDescription)
  }
  return pipelineState
}

The Fireworks Pass

➤ Open Fireworks.swift, and add the pipeline states and initializer:

let clearScreenPSO: MTLComputePipelineState
let fireworksPSO: MTLComputePipelineState

init() {
  clearScreenPSO =
    PipelineStates.createComputePSO(function: "clearScreen")
  fireworksPSO =
    PipelineStates.createComputePSO(function: "fireworks")
}

Clearing the Screen

The clear screen kernel function will run on every pixel in the drawable texture. The texture has a width and height, and is therefore a two dimensional grid.

// 1
guard let computeEncoder = commandBuffer.makeComputeCommandEncoder(),
  let drawable = view.currentDrawable
  else { return }
computeEncoder.setComputePipelineState(clearScreenPSO)
computeEncoder.setTexture(drawable.texture, index: 0)
// 2
var threadsPerGrid = MTLSize(
  width: Int(view.drawableSize.width),
  height: Int(view.drawableSize.height),
  depth: 1)
// 3
let width = clearScreenPSO.threadExecutionWidth
var threadsPerThreadgroup = MTLSize(
  width: width,
  height: clearScreenPSO.maxTotalThreadsPerThreadgroup / width,
  depth: 1)
// 4
computeEncoder.dispatchThreads(
  threadsPerGrid,
  threadsPerThreadgroup: threadsPerThreadgroup)
computeEncoder.endEncoding()
#import "Common.h"

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

kernel void fireworks()
{ }
metalView.framebufferOnly = false
Drawable cleared to black
Mhecelqe qqiecuv qa dbeqf

Dispatching the Particle Buffer

Now that you’ve cleared the screen, you’ll set up a new encoder to dispatch the particle buffer to the GPU.

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

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.

velocity = speed * direction
newPosition = oldPosition * velocity

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

Implementing Particle Physics

➤ Open Fireworks.metal and replace fireworks with:

kernel void fireworks(
  texture2d<half, access::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 = half4(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));
}
Fireworks!
Jobovofkb!

Particle Systems

The fireworks particle system was tailor-made for fireworks. However, particle systems can be very complex with many different options for particle movement, colors and sizes. In the Particles group, the Emitter class in the starter project is a simple example of a generic particle system where you can create many different types of particles using a particle descriptor.

Scaling over time
Qzelacv exur nima

Resetting the Scene

To add this new, more generic particle system, remove your fireworks simulation from Renderer.

var fireworks: Fireworks
fireworks = Fireworks()
// Render Fireworks with compute shaders
fireworks.update(size: view.drawableSize)
fireworks.draw(commandBuffer: commandBuffer, view: view)
Reset project
Fexoy jzimuxw

var particleEffects: [Emitter] = []

Updating the Particle Structure

With a more complex particle system, you need to store more particle properties.

float age;
float size;
float scale;
float startScale;
float endScale;
vector_float2 startPosition;

Rendering a Particle System

You’ll attach a texture to snow particles 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 to perform a render pass.

let computePSO: MTLComputePipelineState
let renderPSO: MTLRenderPipelineState
let blendingPSO: MTLRenderPipelineState

init(view: MTKView) {
  computePSO = PipelineStates.createComputePSO(
    function: "computeParticles")
  renderPSO = PipelineStates.createParticleRenderPSO(
    pixelFormat: view.colorPixelFormat)
  blendingPSO = PipelineStates.createParticleRenderPSO(
    pixelFormat: view.colorPixelFormat,
    enableBlending: true)
}
// 1
guard let computeEncoder =
  commandBuffer.makeComputeCommandEncoder() else { return }
computeEncoder.label = label
computeEncoder.setComputePipelineState(computePSO)
// 2
let threadsPerGroup = MTLSize(
  width: computePSO.threadExecutionWidth, height: 1, depth: 1)
// 3
for emitter in scene.particleEffects {
  emitter.emit()
  if emitter.currentParticles <= 0 { continue }
  // 4
  let threadsPerGrid = MTLSize(
    width: emitter.particleCount, height: 1, depth: 1)
  computeEncoder.setBuffer(
    emitter.particleBuffer,
    offset: 0,
    index: 0)
  computeEncoder.dispatchThreads(
    threadsPerGrid,
    threadsPerThreadgroup: threadsPerGroup)
}
computeEncoder.endEncoding()
guard let descriptor = descriptor,
  let renderEncoder =
    commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)
  else { return }
renderEncoder.label = label
var size: float2 = [Float(size.width), Float(size.height)]
renderEncoder.setVertexBytes(
  &size,
  length: MemoryLayout<float2>.stride,
  index: 0)
// 1
for emitter in scene.particleEffects {
  if emitter.currentParticles <= 0 { continue }
  renderEncoder.setRenderPipelineState(
    emitter.blending ? blendingPSO : renderPSO)
  // 2
  renderEncoder.setVertexBuffer(
    emitter.particleBuffer,
    offset: 0,
    index: 1)
  renderEncoder.setVertexBytes(
    &emitter.position,
    length: MemoryLayout<float2>.stride,
    index: 2)
  renderEncoder.setFragmentTexture(
    emitter.particleTexture,
    index: 0)
  // 3
  renderEncoder.drawPrimitives(
    type: .point,
    vertexStart: 0,
    vertexCount: 1,
    instanceCount: emitter.currentParticles)
}
renderEncoder.endEncoding()

The Vertex and Fragment Functions

All right, time to configure the shader functions.

#import "Common.h"

// 1
kernel void computeParticles(
  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;
  }
}
struct VertexOut {
  float4 position  [[position]];
  float  point_size [[point_size]];
  float4 color;
};
// 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]])
{
  // 2
  float2 position = particles[instance].position
    + emitterPosition;
  VertexOut out {
    // 3
    .position =
      float4(position.xy / size * 2.0 - 1.0, 0, 1),
    // 4
    .point_size = particles[instance].size
      * particles[instance].scale,
    .color = particles[instance].color
  };
  return out;
}
// 1
fragment float4 fragment_particle(
  VertexOut in [[stage_in]],
  texture2d<float> particleTexture [[texture(0)]],
  float2 point [[point_coord]])
{
  // 2
  constexpr sampler default_sampler;
  float4 color = particleTexture.sample(default_sampler, point);
  // 3
  if (color.a < 0.5) {
    discard_fragment();
  }
  // 4
  color = float4(color.xyz, 0.5);
  color *= in.color;
  return color;
}

Configuring Particle Effects

The particle computing and rendering structure is complete. All you have to do now is configure an emitter for snow particles.

static func createSnow(size: CGSize) -> Emitter {
  // 1
  var descriptor = ParticleDescriptor()
  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
  // 2
  descriptor.life = 500
  descriptor.color = [1, 1, 1, 1]
  // 3
  return Emitter(
    descriptor,
    texture: "snowflake",
    particleCount: 100,
    birthRate: 1,
    birthDelay: 20)
}
let snow = ParticleEffects.createSnow(size: size)
snow.position = [0, Float(size.height) + 100]
particleEffects = [snow]
A snow particle system
O lbeh yoxtazzo trtgej

Fire

Brrr. That snow is so cold, you need a fire.

let fire = ParticleEffects.createFire(size: size)
fire.position = [0, 0]
particleEffects = [snow, fire]
Fire and snow
Voci aym nwaq

Key Points

  • Particle emitters emit particles. These particles carry information about themselves, such as position, velocity and color.
  • Particle attributes can vary over time. A particle may have a life and decay after a certain amount of time.
  • As each particle in a particle system has the same attributes, the GPU is a good fit for updating them in parallel.
  • Particle systems, depending on given attributes, can simulate physics or fluid systems, or even hair and grass systems.

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:

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.
© 2024 Kodeco Inc.

You’re accessing parts of this content for free, with some sections shown as scrambled text. Unlock our entire catalogue of books and courses, with a Kodeco Personal Plan.

Unlock now