Dynamics
The previous sections visualize static objects — a function's values, a surface's shape, a field's contours. This section turns to systems that evolve in time.
A dynamical system is a rule for moving points around. The rule might be continuous (a vector field defining a flow) or discrete (an iterated map). It might be integrable or chaotic, finite-dimensional or a PDE on a function space. In every case, the fundamental questions are about long-time behavior: where do orbits go, what structures organize the phase space, and how does the answer depend on initial conditions?
Shaders are particularly well suited to this last question. "How does the answer depend on initial conditions?" is a per-pixel computation — each pixel represents a different starting point, and the GPU evaluates all of them in parallel. For simulations that evolve in time, Shadertoy's multi-pass buffers provide the state persistence: one buffer stores the current field, reads its own previous frame, computes one timestep, and writes the result.
We cover five families: vector fields, complex iteration, cellular automata, PDEs, and billiards.
4.0.1Vector Fields
A vector field
The pullback perspective still works: given a way to encode a single vector as a color, we can visualize any vector field by pulling that encoding back through
We'll build three complementary views.
The first is pointwise: at each pixel, evaluate
Version 1: Direction and Magnitude Coloring
The simplest approach: map the angle of rainbow colormap) and the magnitude to brightness.
This is literally domain coloring applied to a real vector field instead of a complex function.
The code is nearly identical — atan(v.y, v.x) gives the angle, which maps to a rainbow phase, and the magnitude modulates brightness.
Singularities — points where
The only subtlety is normalizing brightness.
If
Try the default field and locate the singularities — you should see one source, one sink, and one saddle.
Then replace V with your own: a linear field like vec2(y, -x) (rotation), a gradient field like vec2(2.0*uv.x, 2.0*uv.y), or something with a higher-order zero like vec2(uv.x*uv.x - uv.y*uv.y, 2.0*uv.x*uv.y).
Version 2: Arrow Grid
The textbook picture: tile the screen into a grid of cells, evaluate
On a CPU you'd do this with a loop: for each cell, draw an arrow. In a shader there are no loops over geometry — every pixel runs the same code independently. So the question becomes: how does a single pixel know whether it's inside an arrow?
The answer is signed distance functions (SDFs).
A signed distance function smoothstep across a pixel-width band around
The arrow is built from two SDF primitives:
Shaft: the signed distance to a line segment (from tail to the base of the head), thickened by subtracting a half-width. Geometrically this is a rectangle aligned with the arrow direction.
Head: the signed distance to a triangle (the arrowhead), computed by finding the closest point on each of the three edges and checking which side of the boundary we're on.
The union of two SDFs is just min(d1, d2) — a pixel is inside the arrow if it's inside either piece.
The grid trick. Each pixel computes which cell it belongs to via floor(uv / cellSize), finds that cell's center, and evaluates
The arrow length is scaled by a sigmoid
Try the same vector fields from the color version and compare what you can read off from each.
You can also adjust CELL_SIZE to trade arrow density against legibility.
Version 3: Animated Streamlines
A streamline is a curve
The single-pass ideas we might try all have problems. Integrating from each pixel and coloring by arc length produces a smooth scalar field whose level sets cut across streamlines, not along them. Advecting a texture by reading upstream each frame produces noise rather than clean curves.
The solution is a particle system stored in a framebuffer. This is our first shader that uses Shadertoy's multi-pass buffers, and the idea is beautifully simple.
Buffer A is the particle array.
Every pixel in Buffer A stores one particle's position (in the .rg channels).
On the first frame, particles are scattered randomly across the screen.
On each subsequent frame, every pixel reads its own previous position from Buffer A, evaluates
The key insight: the framebuffer is the particle state.
Each pixel's red and green channels are the
Buffer B draws the particles and accumulates trails.
For each pixel on screen, Buffer B loops over the particles (reading their positions from Buffer A) and checks whether any particle is close enough to draw.
This is a brute-force approach — each pixel tests against NUMBER particles — but GPUs are fast at exactly this kind of parallel work.
The trail effect comes from compositing with Buffer B's own previous frame: max(current, previous * TRAIL_DECAY).
Each frame, the old trails dim slightly, and new bright dots appear at the current particle positions.
The result: particles leave fading streaks behind them as they flow, producing animated streamlines.
The MIX_FACTOR parameter (e.g. 0.99) controls trail length: closer to 1 means longer trails.
NUMBER controls how many particles are visible (more = denser coverage, but the loop in Buffer B gets more expensive).
VEL_FACTOR controls the step size — since we normalize velocity direction, all particles move at the same screen speed regardless of
Try a field with a limit cycle, like vec2(uv.y + uv.x*(1.0 - dot(uv,uv)), -uv.x + uv.y*(1.0 - dot(uv,uv))), and watch the particles spiral inward.
Comparing the three views
These three shaders are worth running side by side on the same vector field, because each reveals different structure.
The color view is the most information-dense: every pixel encodes both direction and magnitude, so you can see the full local structure everywhere at once. It's particularly good for locating and classifying singularities — the winding pattern of hues tells you the index without any computation. But it's abstract: it takes practice to read, and it says nothing about where things actually go.
The arrow grid is the most immediately legible. It looks like a textbook figure, and anyone can read it: arrows point in the direction of the field, and longer arrows mean stronger flow. But the discrete sampling hides structure between grid points, and you can't trace trajectories by eye beyond a cell or two.
The animated streamlines show dynamics directly — you watch particles flow, accumulate in attractors, and separate along unstable manifolds. The trade-off is that the visualization has state (it depends on its own history), requires multiple buffers, and takes a few seconds to develop its trail pattern.
Together, the three views illustrate a general principle: there is no single best visualization for a mathematical object. Each encoding reveals some structure and hides other structure. The art is choosing the right encoding for the question you're trying to answer — or better, building a tool that lets you switch between them.
4.0.2Complex Dynamics
Vector fields define continuous dynamics — a flow parameterized by real time.
Complex iteration is the discrete counterpart: apply a holomorphic map
This is perhaps the most natural shader computation of all: iterate a formula at every pixel and color by the result.
The simplest nontrivial family is
The Mandelbrot Set
The Mandelbrot set is the set of parameters
This is a per-pixel computation: each pixel is a value of
vec2 z = vec2(0.0);
for (int i = 0; i < MAX_ITER; i++) {
z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
if (dot(z, z) > 256.0) break;
}
Points where the orbit stays bounded (reaches MAX_ITER without escaping) are in
The color palette itself is a cosine-based palette (a technique due to Inigo Quilez): three cosines with different frequencies and phases produce a smooth, non-repeating color cycle. You can swap in any palette you like — the only requirement is that it varies enough to make the level sets of escape time visible.
Julia Sets
The Mandelbrot set asks: for which
The shader code is identical — the only difference is what plays the role of the pixel coordinate:
// z starts at the pixel; c is fixed (from the mouse)
vec2 z = uv * 1.5;
for (int i = 0; i < MAX_ITER; i++) {
z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
if (dot(z, z) > 256.0) break;
}
The deep connection between the two:
Click and drag to change
Newton's Method
Newton's method for finding roots of
The surprise: the boundaries between basins are fractal.
A point on the boundary never converges; it wanders chaotically between roots.
And for polynomials of degree
The shader iterates Newton's method at each pixel and colors by the angle of the root it converges to. Darker regions took more iterations to settle — these trace out the fractal basin boundaries.
vec2 f(vec2 z) {
// z³ - 1 (three roots: cube roots of unity)
return cmul(z, cmul(z, z)) - vec2(1.0, 0.0);
}
vec2 df(vec2 z) {
// 3z²
return 3.0 * cmul(z, z);
}
Try switching to
Magnetic Pendulum
Fractal basins of attraction aren't specific to complex iteration — they appear whenever a dissipative system has multiple attractors. Here's a physical example: a pendulum bob swinging above a plane of magnets.
Three forces act on the bob at position
where the height
This is a second-order ODE, so the state is position and velocity — four numbers per pixel. We integrate it with symplectic Euler, the same scheme used in the wave equation:
// Acceleration: gravity + friction + magnetic forces
vec2 acc = -FRICTION * vel - GRAVITY * pos;
for (int j = 0; j < N_MAGNETS; j++) {
vec2 d = magnetPos(j) - pos;
float r = sqrt(dot(d, d) + HEIGHT * HEIGHT);
acc += STRENGTH * d / (r * r * r);
}
// Symplectic Euler: update velocity, then position
vel += DT * acc;
pos += DT * vel;
Each pixel runs this loop independently — 2000 steps of the ODE, 40 seconds of simulated time — starting from rest at that pixel's position. The coloring uses path length: the total distance the pendulum travels before settling. Points deep inside a basin settle quickly (short path, bright). Points near a basin boundary swing back and forth between magnets before committing (long path, dark). The fractal boundary is exactly the set of initial conditions where the pendulum takes the longest to decide.
Try lowering FRICTION: the basins become more intricate as the pendulum has more time to wander.
Changing N_MAGNETS to 4 or 5 gives different symmetries.
4.0.3Cellular Automata
We now shift from continuous to discrete. Cellular automata sit at the opposite extreme from everything above — discrete time, discrete space, discrete state — and yet they produce dynamics every bit as rich.
A cellular automaton is a grid of cells, each in one of finitely many states, updated simultaneously according to a local rule: the next state of a cell depends only on its current state and the states of its neighbors.
This may be the most natural computation a shader can do.
The grid is the pixel grid; the state is stored in a framebuffer; each pixel reads its neighbors, applies the rule, and writes its new state.
The framebuffer feeds back into itself — Buffer A at frame
No integration, no floating-point arithmetic, no stability analysis — just a lookup table applied in parallel.
Game of Life
Conway's Game of Life is the most famous cellular automaton: cells are alive (1) or dead (0), and the update rule is:
A live cell with 2 or 3 live neighbors survives; otherwise it dies.
A dead cell with exactly 3 live neighbors becomes alive.
That's it. From this two-line rule, initialized with random noise, you get gliders, oscillators, still lifes, and — if you're patient — universal computation.
The shader is short enough to read in one sitting.
Buffer A stores the grid: each pixel's red channel is 0 or 1.
On frame 0 it initializes randomly (a hash function thresholded at 0.5).
On every subsequent frame, each pixel reads the eight neighbors via texelFetch, sums them up, and applies the rule.
The Image tab just reads Buffer A and displays it.
The entire update rule lives in a single function at the top of Buffer A:
float rule(float self, float neighbors) {
// Conway's Game of Life (B3/S23):
if (self == 1.0) {
return (neighbors == 2.0 || neighbors == 3.0) ? 1.0 : 0.0;
} else {
return (neighbors == 3.0) ? 1.0 : 0.0;
}
}
This is the only thing you need to change to get a completely different automaton.
The notation B3/S23 means "birth on 3 neighbors, survival on 2 or 3" — it's the standard shorthand for totalistic rules.
Replace the body of rule with any of the following and watch what happens:
HighLife (B36/S23) — same as Life, but also births on 6 neighbors. Contains a small replicator: a pattern that copies itself.
Seeds (B2/S) — no cell ever survives; birth on exactly 2. Produces explosive, chaotic growth from any initial condition.
Day & Night (B3678/S34678) — symmetric between live and dead states (the complement of any pattern evolves the same way). Produces large stable regions.
Diamoeba (B35678/S5678) — grows into amoeba-like blobs with smooth, shifting boundaries.
There's no visualization cleverness here — no colormaps, no antialiasing, no SDFs. The entire shader is a handful of integer comparisons. But this is the right way to appreciate what multi-pass feedback gives you: a trivial per-pixel rule, iterated, produces emergent complexity. The GPU is doing nothing fancy; it's the mathematics of the rule that does the work.
4.0.4PDEs
Cellular automata update a discrete grid with a discrete rule.
The continuous analogue is a PDE: a field
On a pixel grid, the connection between the two is almost literal.
A discrete Laplacian at pixel
This is the same stencil as the Game of Life's neighbor sum — just with different arithmetic on top. The buffer feedback pattern is identical too: Buffer A stores the field, reads its own previous frame, computes one timestep, and writes the result. The Image tab reads Buffer A and maps values to colors.
The difference from cellular automata is that the state is now continuous (floating-point values in the framebuffer channels) and the update rule involves real arithmetic — additions, multiplications, a timestep parameter
The Heat Equation
The heat equation
The shader has a single field
// The PDE: heat equation u_t = κ Δu
// Forward Euler — just one field, no velocity
float newU = u + dt * kappa * laplacian;
Click and drag to inject heat.
Watch it spread and fade — the Gaussian pulse you inject broadens and dims, exactly as the heat kernel predicts.
The colormap runs from black (cold) through red and yellow to white (hot).
The simulation is stable as long as
Try injecting heat in several spots and watching the profiles merge.
You can also increase kappa past the stability threshold to see what a blow-up looks like — the field develops a checkerboard instability within a few frames.
The Wave Equation
The wave equation
The key difference from the heat equation is that we now need two fields: the displacement
// Symplectic Euler — update velocity first, then position with the *new* velocity
float newV = v + dt * c * c * laplacian;
float newU = u + dt * newV;
The order matters. Updating velocity first and then using the new velocity to update position is the symplectic (semi-implicit) Euler method. It preserves the Hamiltonian structure of the wave equation, which means energy doesn't drift — the simulation stays stable over thousands of frames without blowing up or damping out.
The domain is defined by an inDomain function in the Common tab — pixels outside the domain are forced to zero each frame, giving Dirichlet boundary conditions.
Waves reflect off this boundary, and the shape determines the reflection pattern:
bool inDomain(vec2 uv) {
// Circle
return length(uv) < 0.9;
}
Try switching to a square, or to the Mandelbrot set — the commented alternatives are in the Common tab. Click and drag to inject pulses. The colormap is diverging: orange for positive displacement, blue for negative, black at zero.
Compare with the heat equation: a pulse here propagates outward as a ring and bounces forever. The same pulse in the heat equation spreads and disappears. This is the distinction between hyperbolic and parabolic PDEs, made visceral.
Wave Refraction
The wave equation with a spatially varying speed,
The only change from the previous shader: replace the constant c with a function speed(uv) that returns the local wave speed at each pixel:
float speed(vec2 uv) {
// Circular lens: slower inside → converging lens
return length(uv) < 0.3 ? 0.5 : 1.0;
}
When a wavefront crosses the boundary between regions with different speeds, it bends — this is Snell's law, and it's not imposed by the shader; it falls out of the PDE automatically. The ratio of speeds determines the angle of refraction, and if the speed change is abrupt, you also get partial reflection at the interface.
The default is a circular lens in the center with half the wave speed — visible as a blue disk. Click to one side and watch the wavefront curve as it passes through — it focuses on the far side, exactly like light through glass. Try the commented alternatives: a half-plane gives refraction across a flat interface, and a gradient index bends waves continuously without any sharp boundary.
4.0.5Billiards
We close with one of the simplest dynamical systems to define: a point moves in a straight line inside a region and reflects off the boundary according to the law of reflection (angle of incidence equals angle of reflection). Despite this simplicity, billiard dynamics is extraordinarily rich — the shape of the table determines everything, and even basic questions (is a given trajectory periodic? does it visit every part of the table?) can be deep.
For a convex polygon with vertex angles that are rational multiples of
This shader is a good example of something that is not a natural fit for the pullback approach — and works anyway. A billiard trajectory is a single curve: one initial condition, one sequence of reflections. There's nothing per-pixel about it. But the shader computes the entire trajectory independently at every pixel, then measures the distance from that pixel to every segment, just to decide whether to light up. Every pixel does the same 200-bounce ray trace and throws away almost all of it. This is absurdly wasteful — a CPU drawing the same trajectory with line segments would be essentially free.
But two million redundant copies of a 200-step loop, running in parallel, still finishes in a couple of milliseconds.
The result is a clean, anti-aliased, glowing trajectory with no line-drawing library, no graphics pipeline, no state management — just a distance function and smoothstep.
Use the mouse to control the trajectory: horizontal position sets the launch angle, vertical position moves the starting point along the first edge.
Try the different table shapes — the rectangle gives familiar periodic orbits, the pentagon fills out quasiperiodic patterns, and the L-shaped room (non-convex!) produces chaotic-looking trajectories.
You can also turn on a central circular scatterer with SHOW_SCATTERER to get a Sinai-billiard-like system.
Phase portrait
The trajectory shader draws one orbit. But billiards has a natural per-pixel question too: the phase portrait.
The phase space of a billiard in a convex curve is the cylinder
This shader shows both views side by side.
The left panel is the phase portrait: each pixel represents an initial condition
The coloring uses a Poincaré section.
Each orbit is iterated forward until it crosses a fixed section of the boundary (near
The default table is an ellipse with semi-axis ratio
Try changing A: at
This shader was written by Lael Costa (Penn State), a graduate student at the IHP trimester program.
4.0.6Outer Billiards
Where ordinary billiards studies trajectories inside a convex table, outer billiards acts on the exterior.
Given a point
Unlike ordinary (inner) billiards, where a ball bounces inside a table, outer billiards acts on the unbounded complement.
The map is piecewise-isometric: in each cone it's a rotation by
The singularity set is the closure of all preimages of these rays under iterates of
This set has rich fractal structure that depends on the polygon.
For rational polygons (vertex angles that are rational multiples of
The shader computes
Try switching between different polygons — the triangle gives a simple tiling, the pentagon produces an intricate quasicrystalline pattern, and the kite breaks all the symmetry.
This shader was written by Lael Costa (Penn State), a graduate student at the IHP trimester program.