This notebook is a 2026 WebGPU update of my 2021 post, Strange Attractors on the GPU, Part 1: Implementation.
This notebook walks through simulating a strange attractor on the GPU and then rendering particle tracks as lines. The essential feature of this notebook is that it accomplishes both particle updates and rendering to the screen without data ever touching the CPU.
The Attractor
A strange attractor is a set of states toward which a dynamical system evolves over time. The Lorenz System is the canonical example. The particular attractor we’re simulating here is the Bouali Attractor, described by Safieddine Bouali in A 3D Strange Attractor with a Distinctive Silhouette. The Butterfly Effect Revisited. It is defined by the system of ordinary differential equations:
with parameters , , , . These equations exhibit chaotic behavior; nearby trajectories diverge exponentially but remain bounded within the attractor’s basin.
From WebGL to WebGPU
The original WebGL version of this simulation stored particle state in a floating-point texture. Each row represented a particle’s history as a ring buffer. The simulation was designed around the limitations of WebGL, in particular the lack of compute shaders and inability to read and write to the same texture (WebGL 2’s transform feedback notwithstanding).
A naive WebGPU port would preserve this texture-based approach, replacing fragment shader hacks with proper compute shaders. That would require three GPU operations per frame:
- Integrate: Read from the state texture, write new positions to a temporary texture
- Copy: Transfer the temporary column back into the main state texture
- Draw: Render lines by sampling the state texture in a vertex shader
I tried this texture-based approach, but when I didn’t see much performance improvement over the buffer approach, I switched back to storage buffers with read_write access. This is a natural fit for the embarrassingly parallel task of integrating independent particles with no mutual interaction, and it eliminates the copy step entirely.
With storage buffers, each frame requires just two GPU operations:
- Integrate the differential equation, reading the current state and writing one new state value for each particle.
- Draw all line segments, joins, and caps using instanced triangle strip geometry.
For line rendering, we use the webgpu-instanced-lines module. Lines are difficult to render well. The built-in line primitive in graphics APIs is typically limited to single-pixel width. To get smooth, variable-width lines with proper joins and caps, we build geometry from triangles in the vertex shader. The module handles this complexity, requiring only that we provide a function to compute vertex positions from our data.
A note on performance: For reasons I don’t understand, this WebGPU implementation performs slightly worse than the original WebGL 1 version despite using almost identically the same line rendering implementation. Performance will likely improve as WebGPU implementations mature.
State Layout
The state of our ordinary differential equation (ODE) is represented by the three-component vector . We store these in a flat storage buffer as vec3f elements. The time step of the particle is represented by the vector:
We use particle-major ordering: all time steps for particle 0 come first, then all time steps for particle 1, and so on. The buffer index for a given particle and step is particle * stepCount + step.
As we step the ODE, we compute one new history point for each particle track. To avoid shifting the entire history on every iteration, we treat each particle’s slice as a ring buffer. At each time step , we use the previous position, , to compute the next, . When we reach the end of the slice, we loop back to the start, overwriting the oldest time step with the newest.
Computation
Unlike fragment shaders which operate on pixels being rasterized, compute shaders dispatch a grid of threads which can read from and write to arbitrary locations in GPU resources.
Initialization
For historical reasons which I didn’t see it necessary to change, we initialize particles with a compute shader, although we could write data from the CPU to a buffer just fine.
We start by initializing particle positions within a sphere centered near the attractor. Generating good pseudorandom numbers on a GPU is tricky, so we use a low-discrepancy quasirandom number generator described by Martin Roberts in The Unreasonable Effectiveness of Quasirandom Sequences.
Integration
To step the ODE, we use the fourth-order Runge-Kutta (RK4) method. The integration shader reads from index particle * stepCount + srcColumn and writes to particle * stepCount + dstColumn. Since these are different indices (no particle reads what another particle writes), there are no data races.
Line Rendering
In the timeless words of Matt DesLauriers, Drawing Lines is Hard. Browsers limit the built-in line primitive to a single pixel width, so for any reasonably well-rendered lines, we need to build our own geometry.
The webgpu-instanced-lines module renders lines as instanced triangle strips, with one instance per line segment. Each instance draws the segment itself plus half of the adjacent joins. A four-point sliding window (previous, start, end, next) provides the context needed to compute join geometry.
We don’t pass vertex positions directly. Instead, we provide a vertex function which uses the integer vertex ID to read particle buffer data. To handle the ring buffer, we add an offset to the step index that shifts based on which column was most recently written. The oldest data is always at the “start” of the rendered line, and the newest at the end.
Line Breaks
Multiple particle tracks are rendered in a single draw call. We separate them using sentinel values of w = 0 in clip space. When the vertex function returns a position with w = 0, the line renderer interprets this as a break, skipping one instance and reworking adjacent joins into end caps.
Code
A selection of the code is presented below. We import webgpu-instanced-lines from NPM and instantiate a line renderer. The main call and vertex shader are printed below, but for a comprehensive, straightforward example with all the pieces in place, see this example from the module.
import { createGPULines } from 'npm:webgpu-instanced-lines';
const gpuLines = createGPULines(device, {
colorTargets: [{
format: canvasFormat,
blend: {
color: {
srcFactor: 'src-alpha',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
},
alpha: {
srcFactor: 'one',
dstFactor: 'one-minus-src-alpha',
operation: 'add'
}
}
}],
depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
multisample: { count: renderState.sampleCount, alphaToCoverageEnabled: false },
join: 'bevel',
cap: 'square',
vertexShaderBody,
fragmentShaderBody,
});
invalidation.then(() => gpuLines.destroy());Draw Loop
Finally, we put it all together into a frame loop. For each frame:
- dispatch the integrate compute shader to compute new particle positions
- Update the buffer offset uniform so lines render from oldest to newest
- Draw lines by calling into the webgpu-lines module
Summary
I’m thrilled that my new WebGPU line rendering module worked out so well. It fills a big gap in what I need WebGPU for, and I’m eager to keep using it.
The switch from hacky WebGL workarounds to proper WebGPU primitives turned out well. I haven’t done the full timing analysis I should have, but we went from three passes (integrate, copy, render) to just two (integrate, render).
And of course, the techniques here generalize beyond our strange attractors test case. Any particle system where you want to visualize trails—fluid simulations, n-body systems, gradient flows—can use this same ring buffer approach for efficient GPU-based track rendering.