Building a Fluid Simulation in Zig
A while ago I got curious about how well Zig would handle a real physics project, so I decided to port Matthias Müller’s Ten Minute Physics FLIP fluid simulation. The result is ZFF (Zig Flip Fluid) — part experiment, part learning exercise, and honestly just an excuse to play with fluid dynamics and WebAssembly.
The main goal wasn’t to build a perfect engine. I mostly wanted to answer two questions:
- Can Zig comfortably handle performance-heavy physics code?
- How painful (or painless) is it to ship the same simulation to the web?
If you want to poke around the code, it’s all here:
https://github.com/burakssen/zff
For testing yourself:
https://burakssen.com/zff
Why Zig?
Fluid simulations aren’t exactly lightweight. You’re updating thousands of particles every frame, running iterative solvers, and constantly pushing data around. That’s the kind of workload where language design actually matters.
A few things about Zig made it appealing for this project:
Manual Memory Management I wanted full control over allocations — especially for large, contiguous particle buffers — without worrying about GC hiccups mid-simulation.
Performance
ReleaseFastbuilds are seriously fast, and the generated binaries ended up being leaner than I expected.Comptime Compile-time features helped remove a lot of repetitive glue code. For example, managing multiple arrays in a Structure of Arrays (SoA) layout becomes trivial with
inline for.WebAssembly Support Zig’s cross-compilation story is refreshingly straightforward. Targeting WASM didn’t feel like a separate project.
The Architecture
I split the project into three layers:
core— basic types and the mainAppStatefluid— the simulation itself (no rendering dependencies)graphics— rendering via Raylib
This separation turned out to be really useful. Keeping fluid independent means I could swap out the renderer later — maybe Sokol or raw WebGL — or even run the simulation headless for testing or benchmarking.
Data-Oriented Design
One decision I’m particularly happy with was switching from an Array of Structures (AoS) to a Structure of Arrays (SoA) layout in src/fluid/particle_data.zig.
1pub const ParticleData = struct {
2 pub const fields = .{ "pos_x", "pos_y", "vel_x", "vel_y", "color_r", "color_g", "color_b" };
3
4 pos_x: std.ArrayList(f32),
5 pos_y: std.ArrayList(f32),
6 // ... other components
7
8 pub fn deinit(self: *ParticleData) void {
9 inline for (fields) |field_name| {
10 @field(self, field_name).deinit(self.allocator);
11 }
12 }
13};
Using comptime to iterate over struct fields means I can add new particle attributes without touching the deinit or resize logic. Most physics passes operate on one attribute at a time, so SoA ended up being more cache-friendly and easier to optimize.
The Physics: FLIP Method
FLIP (Fluid Implicit Particle) is a hybrid technique that mixes particle-based and grid-based simulation. You get the flexibility of particles with the stability of grid solvers, which is a nice balance for real-time work.
The main loop in src/fluid/flip_fluid.zig looks roughly like this:
- Advect Particles — move particles using current velocities
- Spatial Hashing — build a fast lookup structure for neighbors
- Particle Collisions — resolve close contacts
- Grid Transfer — move particle velocities onto a MAC grid
- Pressure Solve — enforce incompressibility via an iterative Poisson solve
- Particle Update — transfer corrected velocities back to particles
Optimizing the Spatial Hash
A naive neighbor search is $O(N^2)$. To keep it real-time, I implemented a spatial hash. One optimization I implemented in Zig was using a pre-allocated scratch_particles buffer to avoid heap allocations during the particle sorting step:
1// Sort particles into scratch_particles
2for (0..n) |i| {
3 const idx = self.hashIndex(xi, yi);
4 const dest = cursor_items[idx];
5 cursor_items[idx] += 1;
6
7 inline for (ParticleData.fields) |field_name| {
8 @field(self.scratch_particles, field_name).items[dest] = @field(self.particles, field_name).items[i];
9 }
10}
This keeps the simulation loop extremely tight and allocation-free after initialization.
Going to the Web
One of the most pleasant surprises was how easy it was to push the project to the browser. Zig handles cross-compilation cleanly, so most of the work was just adapting a few platform differences.
The main loop needed adjustment because browsers don’t like infinite while(true) loops. Instead, Emscripten expects a callback-based loop:
1if (builtin.os.tag == .emscripten) {
2 const emsdk = @cImport(@cInclude("emscripten/emscripten.h"));
3 emsdk.emscripten_set_main_loop_arg(runLoop, self, 0, true);
4} else {
5 while (!c.WindowShouldClose()) {
6 try self.update();
7 }
8}
Once that was in place, building for the web was basically:
1zig build -Doptimize=ReleaseFast -Dtarget=wasm32-emscripten
Conclusion
This project ended up being a really fun way to explore both fluid simulation techniques and Zig’s system-level ergonomics. The simulation runs comfortably at 100 FPS on desktop with 34000 particles, and having a WebAssembly build makes it easy to share and experiment with.
If you’re curious about physics programming, I definitely recommend checking out Ten Minute Physics. And if you’re wondering whether Zig is viable for performance-heavy simulations, at least for this kind of project, the answer is a solid yes.