Kutta simulates airflow over airfoils using Go, Ebitengine, and lattice Boltzmann methods. Here's how it works.

A 2D Wind Tunnel in Go with Ebitengine and Lattice Boltzmann


Kutta is a 2D wind tunnel simulator written in Go. It renders airflow around airfoil shapes in real time using the lattice Boltzmann method (LBM) for fluid dynamics and Ebitengine for rendering.

The project is upfront about what it is: a qualitative simulator for intuition and demos, not validated CFD for designing a real wing. That caveat actually makes the code more interesting. It is small enough to read, but it still covers a solver, rendering, scene editing, persistence, and headless checks.

The project breaks into a few focused packages: lbm for physics, viz for visualization, foil for airfoil geometry, and scene for editable scene state. Let’s look at how they fit together.

Ebitengine as the simulation loop

Kutta uses Ebitengine as its game loop and rendering engine. If you’ve used Ebitengine before, the shape is familiar: implement the ebiten.Game interface with Update(), Draw(), and Layout(). The main game struct lives in game.go, wiring the LBM simulation step to the rendering step on each frame.

main.go calls ebiten.RunGame(...), which kicks off the loop. Each tick of Update() advances the fluid simulation by one LBM step. Each Draw() call renders the current state to screen. The simulation state is updated on the game loop, while menu actions are queued back onto that same path, which keeps the lattice data from being mutated from two places at once. Ebitengine handles the platform-specific windowing and input details you do not want to think about.

If you’re building interactive simulations or visualizations in Go, Ebitengine keeps getting better. We covered another Ebitengine-based project in a terminal spreadsheet post - completely different domain, same rendering approach.

Lattice Boltzmann in lbm/lbm.go

The physics engine lives in lbm/lbm.go. The lattice Boltzmann method simulates fluid flow by tracking probability distributions of particles on a discrete grid instead of solving the Navier-Stokes equations directly. For real-time 2D work, it’s a sweet spot: the computation at each grid cell is local. You only need immediate neighbors.

The LBM package manages a 2D grid where each cell holds a set of distribution functions (9 in the D2Q9 model). Each simulation step has two phases:

Collision - distribution functions at each cell relax toward equilibrium based on local density and velocity. Streaming - those distributions propagate to neighboring cells.

The computation is intensive but embarrassingly parallel by nature. Kutta stores the grid as flat slices, which is the standard Go approach for 2D arrays when you care about performance. No slice-of-slices indirection, contiguous memory, better cache behavior. If you’ve wrestled with grid problems in Go (like the Advent of Code grid problems), you’ll recognize this pattern immediately.

Boundary conditions for solid objects use a bounce-back scheme: when a distribution function hits a solid cell, it reverses direction. This check happens inside the same step loop by testing whether a neighbor cell is flagged as solid.

Airfoil geometry in foil/

The foil/ package owns airfoil geometry. Its main path generates NACA 4- and 5-digit profiles from their equations, so the app can switch between common profiles without shipping a catalog of coordinate files. foil/dat.go also parses .dat coordinate files for imported profiles.

foil/foil.go handles the geometry: scaling the airfoil to fit the simulation grid, positioning it, rotating it by angle of attack, and converting the continuous outline into discrete grid cells. Point-in-polygon rasterization turns floating-point airfoil coordinates into boolean solid/fluid flags on the lattice.

I like the separation here. dat.go handles I/O and parsing. foil.go handles geometry. Neither one knows about the LBM simulation or the renderer. Clean boundaries that don’t feel over-engineered.

Visualization with viz/

The viz/ package has two files worth looking at: viz/colormap.go and viz/particles.go.

viz/colormap.go maps scalar fields (velocity magnitude, pressure) to colors. This is how you get those characteristic CFD rainbow plots. It converts a floating-point value in a range to an RGB color, which gets written into an image.RGBA that Ebitengine draws to screen.

viz/particles.go is more fun. It traces massless particles through the velocity field to show streamlines. Each particle has a position that gets advected by the local velocity at each time step. When particles drift out of the domain, they recycle back to the inlet. This is what gives you the animated “smoke trail” effect you see in real wind tunnel footage.

Both visualization modes read from the LBM grid’s velocity and density fields but never modify them. That read-only relationship keeps rendering decoupled from physics, which matters more than it sounds like it should.

Scene management and persistence

scene/scene.go defines animated scene objects: outlines, Bezier handles, cuts, pivots, poses, and keyframes. The sceneio/ package handles persistence. sceneio/save.go writes the scene as Filo text, and sceneio/sceneio.go loads that .afoil format back into a scene.Scene.

Users can set up an airfoil configuration, save it, reload it later, and keep editing the shape or animation. The file format is deliberately plain text rather than opaque Go marshaling.

editor.go at the project root provides an interactive editor for placing and modifying airfoils while the simulation runs. It hooks into Ebitengine’s input handling to capture mouse and keyboard events, translating them into changes to the scene and the LBM grid’s solid flags. Being able to drag an airfoil around and watch the flow field respond in real time is genuinely satisfying.

Two modes: animation and snapshot

Kutta ships with two entry points beyond the main interactive app:

cmd/anim/main.go is a headless check for animated scenes: it loads a Filo scene, moves the control surface through the same UpdateSolid path as the live app, writes PNG snapshots, and prints lift readings. cmd/snapshot/main.go runs a NACA foil for a set number of steps, performs a lift sweep, and writes speed, vorticity, and pressure PNGs.

Both reuse the same lbm and viz packages but skip the interactive Ebitengine loop, driving the simulation programmatically instead. This is a good example of structuring a Go project so the core logic lives independent of the presentation layer. The interactive window and the headless verification tools share the same physics code.

Post-processing with bloom

bloom.go implements a bloom effect for the rendered image, adding a soft glow to bright areas. It extracts a source layer, runs a separable Gaussian blur shader from blur.kage, then blends the glow back into the frame. It makes the particle visualization look better without tangling the visual effect with the solver.

Why I think this project is worth reading

Kutta covers numerical simulation, real-time rendering, file I/O, interactive editing, animation, and image processing without turning into a sprawling codebase. Each package has a clear job, and the boundaries between physics, visualization, and interaction are sharp without being fussy.

If you’re interested in Ebitengine for anything beyond games, this is one of the better references I’ve seen for using it as a general-purpose real-time visualization framework. And if numerical methods in Go interest you, the LBM implementation in lbm/lbm.go is a clean grid-based simulation that is easy to separate from the rendering and UI code around it.

For more Go projects pushing the language into unexpected territory, take a look at the AList file server post, where Go’s standard library and straightforward package structure make a complex system surprisingly approachable.