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

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 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.

import MetalKit

struct Particle {
  var position: float2
  var direction: Float
  var speed: Float
  var color: float3
  var life: Float
}
public struct Emitter {
  public let particleBuffer: MTLBuffer
}
public init(particleCount: Int, size: CGSize, 
            life: Float, device: MTLDevice) {
  let bufferSize = MemoryLayout<Particle>.stride * particleCount
  particleBuffer = device.makeBuffer(length: bufferSize)!
}
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)
}
let particleCount = 10000
let maxEmitters = 8
var emitters: [Emitter] = []
let life: Float = 256
var timer: Float = 0
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)
  }
}
update(size: view.drawableSize)

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.

let pipelineState: MTLComputePipelineState!
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)
}
pipelineState = initialized?.pipelineState

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.

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)

let renderEncoder = makeRenderCommandEncoder(commandBuffer, 
                                             drawable.texture)
renderEncoder.endEncoding()
// 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()
#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);
}

Fireworks

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

let particlePipelineState: MTLComputePipelineState!
private static func initializeMetal() -> (
  device: MTLDevice, commandQueue: MTLCommandQueue,
  pipelineState: MTLComputePipelineState, 
  particlePipelineState: MTLComputePipelineState)?
let particlePipelineState: MTLComputePipelineState
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)
return (
  device, commandQueue,
  pipelineState: pipelineState, 
  particlePipelineState: particlePipelineState)
particlePipelineState = initialized?.particlePipelineState
// 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()

Particle dynamics

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

velocity = speed * direction
newPosition = oldPosition * velocity

xVelocity = speed * cos(direction)
yVelocity = speed * sin(direction)
struct Particle {
  float2 position;
  float  direction;
  float  speed;
  float3 color;
  float  life;
};
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));
}

Particle systems

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

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.

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()
// 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()
// 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;
  }
}
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]]) {
  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;
}
// 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;
}
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
}
let snowEmitter = snow(size: metalView.drawableSize)
snowEmitter.position = [0, Float(metalView.drawableSize.height)]
emitters.append(snowEmitter)
func mtkView(_ view: MTKView, 
             drawableSizeWillChange size: CGSize) {
  emitters.removeAll()
  let snowEmitter = snow(size: size)
  snowEmitter.position = [0, Float(size.height)]
  emitters.append(snowEmitter)
}
for emitter in emitters {
  emitter.emit()
}

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)
let fireEmitter = fire(size: size)
fireEmitter.position = [0, -10]
emitters.append(fireEmitter)

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

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