Pixels2GenAI
Path i Foundations
M 05 · 5.1.1 · hands-on

5.1.1 Sand Simulation

Build a particle system from scratch — thousands of independent grains, each carrying a few floats of state, animated frame by frame into a GIF of wind-blown sand.

Duration18–22 min
Levelbeginner-intermediate
Load3 core concepts
Prereqs1.1.1 (RGB arrays), basic Python classes

Overview

Watch sand blow across a desert and what looks like one shifting carpet is actually thousands of grains, each moving on its own. A particle system is the computational mirror of that observation: many small agents, each with a tiny piece of state — position, velocity, colour — updated under the same simple rules every frame. William Reeves invented this technique at Lucasfilm in 1982 for the Genesis sequence in Star Trek II, and the same pattern now powers smoke in games, sparks in films, and the digital sand you will build here [1]. By the end of this lesson you will have a GIF of roughly 6,700 grains drifting and accelerating to the right, generated from one Python script and one tight render loop.

Learning objectives

  1. Model a particle as an object with float-valued position, velocity, and per-particle delay, colour, and state.
  2. Apply Euler integration — position += velocity, velocity *= acceleration — to thousands of agents per frame.
  3. Use a Gaussian distribution to stagger particle start times so the flock peels away naturally rather than marching in lockstep.
  4. Render each frame into a NumPy array and assemble the sequence into an animated GIF with imageio.

Quick start — watch the sand blow away

The minimum viable particle system: 500 grains, eighty frames, exactly one acceleration rule.

python · quick_start.py
import random
import numpy as np
from PIL import Image
import imageio.v2 as imageio

particles = []
for _ in range(500):
    particles.append({
        'x': 150 + random.randint(0, 100),
        'y': 100 + random.randint(0, 100),
        'vx': random.uniform(-1, 2),
        'vy': random.uniform(-0.5, 0.5),
        'delay': random.randint(0, 60),
    })

frames = []
for frame in range(80):
    img = np.zeros((200, 300, 3), dtype=np.uint8)
    for p in particles:
        if p['delay'] > 0:
            p['delay'] -= 1
            colour = (50, 40, 30)
        else:
            p['x'] += p['vx']
            p['y'] += p['vy']
            p['vx'] *= 1.05
            colour = (194, 178, 128)
        x, y = int(p['x']), int(p['y'])
        if 0 <= x < 297 and 0 <= y < 197:
            img[y:y + 3, x:x + 3] = colour
    frames.append(img)

imageio.mimsave('quick_sand.gif', frames, fps=24)
Animated GIF of beige sand grains drifting from left to right against a dark background
Fig. 1 The full simulation — around 6,700 grains starting from rest, then accelerating rightward in staggered waves.

Core concepts

Concept 1 — A particle is a bag of state

A particle system is a collection of small independent objects, each carrying its own state. Every particle in our sand simulation owns:

  • a position (x, y) in canvas pixels (stored as floats, so motion can be sub-pixel),
  • a velocity (vx, vy) in pixels per frame,
  • a delay counting down to when this grain starts moving,
  • a colour sampled with small per-grain jitter so the dune does not look painted.
Diagram of three particle states: waiting (dark grey), moving (beige), and finished (off-canvas)
Fig. 2 A grain's life cycle: WAITING (counting down the delay), MOVING (Euler integration), FINISHED (off the canvas, no longer rendered).

The state lives on the particle itself — there is no shared global update — which is what makes particle systems trivially parallelisable on a GPU and easy to extend. Add a radius field and you can simulate raindrops of varying sizes. Add an age field and you can fade particles over time. The architecture is open by default.

Concept 2 — Euler integration in two lines

Particles move by the cheapest possible numerical-integration rule: Euler’s method. New position equals old position plus velocity; new velocity equals old velocity times an acceleration multiplier.

python · update_demo.py
def update(self):
    self.x += self.velocity_x
    self.y += self.velocity_y

    if self.velocity_x < 1.0:
        self.velocity_x += 0.2     # nudge leftward-moving grains rightward
    else:
        self.velocity_x *= 1.08    # exponential speed-up once moving right
Vector diagram of a particle with velocity arrow and acceleration arrow attached
Fig. 3 One particle, two vectors. Velocity moves the particle this frame; acceleration changes the velocity for next frame.

Euler is not the most accurate integrator — it accumulates error over long simulations, which is why we will switch to RK4 in lesson 5.3.3 — but for short animations of small, fast-moving objects it is perfect. The reason floats matter here is sub-pixel motion: self.x += 0.3 does nothing visible by itself, but after ten frames the grain has moved three pixels. Integer positions would round those increments to zero and the simulation would freeze.

Concept 3 — Gaussian delays for natural variation

If every grain starts at frame zero, the wind looks like a shove. If they start at evenly-spaced intervals, it looks mechanical. Real sand has clustered variation — most grains move around the same time, with a few stragglers and a few that jump early. That is a Gaussian distribution.

python · delay_demo.py
import random

self.delay = max(0, int(random.gauss(50, 15)))

random.gauss(50, 15) draws a sample centred at frame 50 with a standard deviation of 15 — so roughly two-thirds of grains start between frames 35 and 65, with a small minority outside that band. The max(0, ...) clamps the rare negative draws so no grain has a “negative delay”.

Four panels showing the same simulation with different delay, velocity, and colour settings
Fig. 4 Four parameter variations of the same engine: tighter delay, looser velocity, sunset palette, narrower spawn rectangle. Same code, different mood.

Exercises

Three exercises in Execute → Modify → Create order: run the desert as it ships, retune four parameters, then build your own particle effect on top of the same engine.

EXECUTE I.

Run the desert

Download sand_simulation.py and run it. It will print progress and write sand_animation.gif to the current folder.

sand_simulation.py — complete reference implementation

Reflection questions

  • Why do the grains not all start moving at the same time? What visual effect does that create?
  • Follow one grain with your eyes. Does it travel in a straight line, or does it curve?
  • Watch grains far to the right. Are they speeding up, slowing down, or moving at constant speed?
MODIFY II.

Retune the wind

Open your copy of sand_simulation.py and change one variable at a time. Re-render the GIF and confirm each modification produces the expected effect.

Goals

  1. Reverse the wind: make sand blow left instead of right.
  2. Add gravity: make sand fall straight down like an hourglass.
  3. Fast start: collapse the Gaussian delay to frames 0–20 so the whole desert moves at once.
  4. Sunset palette: swap the beige base for orange/red so the sand looks like Mars.
CREATE III.

Build your own particle effect

Use sand_simulation_starter.py as a base and implement one of these three effects from scratch. The engine — the per-frame loop, the rendering, the GIF save — stays the same; you change the Particle class and the spawning logic.

sand_simulation_starter.py — starter with TODOs

Choose one

  • Rain drops — spawn at the top edge, fall straight down with slight horizontal drift, vanish at the bottom.
  • Rising bubbles — spawn at the bottom edge, float upward, add a small sine wiggle to the x-position so they look effervescent.
  • Confetti burst — spawn all particles at the centre, give each one a random direction-of-explosion, apply friction so they decelerate naturally.

Make it your own

  • Stack two systems: a “rocket” rises, then at its apex spawns 50 confetti particles. That is a one-shot firework.
  • Replace the delay countdown with a per-particle lifetime so grains fade after they have travelled a fixed number of frames. Now the desert breathes.
  • Render onto a slowly-darkening background so trails persist for a few frames. Cheap motion-blur for free.

Downloads

sand.py — minimal quick-start used in the Overview sand_simulation.py — full reference implementation sand_simulation_starter.py — Exercise 3 scaffold

Summary

Common pitfalls to avoid

  • Storing x and y as ints. Sub-pixel motion vanishes and the grains freeze.
  • Forgetting the off-canvas check. Particles that fly away forever still cost compute every frame.
  • Same delay for every grain. The desert lurches as one block; the eye reads it as one rigid object.
  • Skipping the max(0, ...) clamp on random.gauss. A negative delay means a grain that started “in the past” — usually harmless, occasionally a crash.
  • Spawning ten thousand particles before the loop is fast. Profile on a hundred first.

References

  1. [1] Reeves, W. T. (1983). Particle systems: A technique for modeling a class of fuzzy objects. ACM SIGGRAPH Computer Graphics, 17(3), 359–375. doi:10.1145/964967.801167
  2. [2] Shiffman, D. (2012). The Nature of Code, Chapter 4: Particle Systems. natureofcode.com
  3. [3] Sims, K. (1990). Particle animation and rendering using data parallel computation. ACM SIGGRAPH Computer Graphics, 24(4), 405–413.
  4. [4] Reynolds, C. W. (1987). Flocks, herds and schools: A distributed behavioral model. ACM SIGGRAPH Computer Graphics, 21(4), 25–34. doi:10.1145/37402.37406
  5. [5] Harris, C. R., et al. (2020). Array programming with NumPy. Nature, 585, 357–362. doi:10.1038/s41586-020-2649-2
  6. [6] Burden, R. L., & Faires, J. D. (2015). Numerical Analysis (10th ed.), Chapter 5: Initial-Value Problems for Ordinary Differential Equations. Cengage Learning.
  7. [7] Klein, A., et al. (2024). imageio: Python library for reading and writing image data. imageio.readthedocs.io