<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Simulation | Valentin Caries</title><link>https://vcaries.github.io/en/tag/simulation/</link><atom:link href="https://vcaries.github.io/en/tag/simulation/index.xml" rel="self" type="application/rss+xml"/><description>Simulation</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Fri, 12 Jun 2026 00:00:00 +0000</lastBuildDate><image><url>https://vcaries.github.io/media/icon_hu536d0651d21529ac336731b24f3e073f_144279_512x512_fill_lanczos_center_3.png</url><title>Simulation</title><link>https://vcaries.github.io/en/tag/simulation/</link></image><item><title>Web-LBM: A Real-Time CFD Wind Tunnel in the Browser</title><link>https://vcaries.github.io/en/project/web-lbm/</link><pubDate>Fri, 12 Jun 2026 00:00:00 +0000</pubDate><guid>https://vcaries.github.io/en/project/web-lbm/</guid><description>&lt;h2 id="in-one-minute">In one minute&lt;/h2>
&lt;p>&lt;strong>Web-LBM&lt;/strong> is an interactive two-dimensional wind tunnel that runs entirely in your browser. Behind the canvas sits a genuine &lt;strong>computational fluid dynamics (CFD)&lt;/strong> solver: a &lt;strong>Lattice Boltzmann (D2Q9)&lt;/strong> engine written in C, compiled to &lt;strong>WebAssembly&lt;/strong>, and rendered on the GPU through &lt;strong>WebGL2&lt;/strong>. You can drop in a cylinder or a NACA airfoil, change the angle of attack, spin an obstacle to feel the Magnus effect, or paint your own geometry with the mouse and watch the wake respond. Lift and drag coefficients update live, and virtual microphones even let you &lt;em>see the sound&lt;/em> of vortex shedding.&lt;/p>
&lt;p>CFD normally lives on clusters and overnight batch jobs. Bringing it into a browser tab, with no install, no server, no framework, and hosted as plain static files on GitHub Pages, is both a pedagogical statement and an engineering exercise. Every constraint of the platform had to be solved without giving up physical fidelity or interactivity: no shared memory by default, no filesystem, and a single thread unless you earn more.&lt;/p>
&lt;p>👉 &lt;strong>&lt;a href="https://vcaries.github.io/web_lbm/" target="_blank" rel="noopener">Try the simulation&lt;/a>&lt;/strong> · &lt;strong>&lt;a href="https://github.com/vcaries/web_lbm" target="_blank" rel="noopener">Source code on GitHub&lt;/a>&lt;/strong>&lt;/p>
&lt;h3 id="highlights">Highlights&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>A real solver.&lt;/strong> D2Q9 Lattice Boltzmann with BGK (or compile-time MRT) collision, a &lt;strong>Smagorinsky LES&lt;/strong> turbulence model, Hermite-regularized collision for stability, a Zou–He velocity inlet, absorbing sponge layers, and momentum-exchange force evaluation that yields live $C_l$ and $C_d$.&lt;/li>
&lt;li>&lt;strong>Real-time performance.&lt;/strong> A branchless, SIMD-vectorized hot loop combined with &lt;strong>WebAssembly threads&lt;/strong> (pthreads over &lt;code>SharedArrayBuffer&lt;/code>) and a deterministic, lock-free row partition.&lt;/li>
&lt;li>&lt;strong>Zero-copy rendering.&lt;/strong> Field data flows from the wasm heap to the screen with no intermediate copy: typed-array views feed an &lt;code>R32F&lt;/code> WebGL2 texture, and a colormap lookup runs in the fragment shader.&lt;/li>
&lt;li>&lt;strong>Aeroacoustics included.&lt;/strong> Because LBM is weakly compressible, the engine resolves acoustic waves. It offers schlieren ($|\nabla\rho|$) and dilatation ($\nabla\cdot\mathbf{u}$) views, plus virtual microphones with a live FFT that picks up the aeolian tone of the shedding wake.&lt;/li>
&lt;li>&lt;strong>100 % static deployment.&lt;/strong> Two engine builds (single- and multi-threaded) are selected at runtime, and a service worker supplies the cross-origin isolation headers that GitHub Pages cannot send.&lt;/li>
&lt;/ul>
&lt;h2 id="why-cfd-in-the-browser">Why CFD in the browser?&lt;/h2>
&lt;p>Three reasons, in increasing order of difficulty.&lt;/p>
&lt;p>&lt;strong>Pedagogy.&lt;/strong> Fluid dynamics is taught with equations and validated with expensive simulations, but &lt;em>intuition&lt;/em> comes from immediate cause and effect: tilt an airfoil and watch it stall, spin a cylinder and watch the lift appear. An interactive simulation whose feedback loop is measured in milliseconds is a fundamentally different learning tool from a static plot.&lt;/p>
&lt;p>&lt;strong>Accessibility.&lt;/strong> A browser page with no install and no backend reaches anyone (students, colleagues, recruiters) on any machine, indefinitely, at zero hosting cost. That requires the entire solver to run client-side, which is precisely what WebAssembly makes possible.&lt;/p>
&lt;p>&lt;strong>The engineering challenge.&lt;/strong> Real-time CFD stress-tests the whole stack at once: a numerical method stable enough to survive whatever the user throws at it, a memory layout the compiler can vectorize, a threading model that fits the browser&amp;rsquo;s security constraints, and a rendering path fast enough never to be the bottleneck. Solving all four together is the core of this project.&lt;/p>
&lt;h2 id="the-physics-lattice-boltzmann-in-brief">The physics: Lattice Boltzmann in brief&lt;/h2>
&lt;h3 id="a-different-route-to-fluid-dynamics">A different route to fluid dynamics&lt;/h3>
&lt;p>Classical CFD discretizes the &lt;strong>Navier–Stokes equations&lt;/strong> directly: velocity and pressure live on a mesh, and each timestep requires solving coupled, &lt;em>global&lt;/em> systems, typically a pressure Poisson equation whose solution links every cell in the domain to every other.&lt;/p>
&lt;p>The &lt;strong>Lattice Boltzmann Method (LBM)&lt;/strong> works one level below the macroscopic description. It evolves &lt;strong>particle distribution functions&lt;/strong> $f_i(\mathbf{x}, t)$, the amount of fluid at node $\mathbf{x}$ moving along a small set of discrete velocities $\mathbf{c}_i$. The macroscopic fields are simply moments of these distributions:&lt;/p>
$$\rho = \sum_i f_i, \qquad \rho\,\mathbf{u} = \sum_i f_i\,\mathbf{c}_i$$
&lt;p>A Chapman–Enskog expansion shows that this kinetic system reproduces the incompressible Navier–Stokes equations in the low-Mach-number limit. The fluid viscosity is controlled by a single relaxation time $\tau$:&lt;/p>
$$\nu = c_s^2\left(\tau - \tfrac{1}{2}\right)$$
&lt;p>What makes LBM exceptional for this project is its &lt;strong>locality&lt;/strong>: each node updates from its immediate neighbours only. There is no global solve, no linear algebra, and no convergence loop, so the cost per timestep is fixed and predictable, the algorithm parallelizes almost trivially, and the simulation is &lt;em>naturally unsteady&lt;/em>. Vortex shedding is not an option you enable; it is what the method computes by default. As a bonus, LBM is weakly compressible, so pressure waves propagate physically and the simulation does acoustics for free.&lt;/p>
&lt;h3 id="collision-and-streaming">Collision and streaming&lt;/h3>
&lt;p>Each timestep reduces to two conceptually simple operations. &lt;strong>Collision&lt;/strong> relaxes the distributions toward a local equilibrium (the BGK approximation):&lt;/p>
$$f_i(\mathbf{x}, t^{+}) = f_i(\mathbf{x}, t) - \frac{1}{\tau}\left[f_i(\mathbf{x}, t) - f_i^{\mathrm{eq}}(\rho, \mathbf{u})\right]$$
&lt;p>where the equilibrium is a second-order expansion of the Maxwell–Boltzmann distribution:&lt;/p>
$$f_i^{\mathrm{eq}} = w_i\,\rho\left[1 + \frac{\mathbf{c}_i\cdot\mathbf{u}}{c_s^2} + \frac{(\mathbf{c}_i\cdot\mathbf{u})^2}{2c_s^4} - \frac{\mathbf{u}\cdot\mathbf{u}}{2c_s^2}\right]$$
&lt;p>&lt;strong>Streaming&lt;/strong> then shifts each post-collision distribution to the neighbouring node in its direction of travel:&lt;/p>
$$f_i(\mathbf{x} + \mathbf{c}_i\,\Delta t,\; t + \Delta t) = f_i(\mathbf{x}, t^{+})$$
&lt;p>Collision is purely local arithmetic; streaming is a pure memory move. Heavy math with no communication, followed by communication with no math, is exactly the structure that SIMD units and multicore processors love, and the implementation below exploits it deliberately.&lt;/p>
&lt;h3 id="why-d2q9">Why D2Q9&lt;/h3>
&lt;p>The engine uses the &lt;strong>D2Q9&lt;/strong> lattice: two dimensions and nine velocities (rest, four axis-aligned, four diagonal) with weights $w_0 = 4/9$, $w_{1\text{–}4} = 1/9$, and $w_{5\text{–}8} = 1/36$. It is the smallest 2D lattice whose velocity moments are isotropic enough to recover Navier–Stokes correctly. Fewer directions would bias the physics along the grid axes, and more would cost memory and bandwidth for no gain at this level of description. Nine &lt;code>float&lt;/code> fields per node also set the memory budget: a 600 × 300 grid fits comfortably in a few tens of megabytes of wasm heap.&lt;/p>
&lt;h3 id="boundary-conditions">Boundary conditions&lt;/h3>
&lt;p>Boundaries are where LBM implementations earn their keep, because after streaming the distributions entering the domain from outside are unknown and must be reconstructed.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Inlet.&lt;/strong> A &lt;strong>Zou–He&lt;/strong> velocity boundary imposes the inflow profile (uniform, shear layer, or jet) by reconstructing the unknown distributions from mass and momentum constraints. A 400-step soft start ramps the velocity after every (re)initialization, so the impulsive start does not launch a shock down the tunnel.&lt;/li>
&lt;li>&lt;strong>Outlet.&lt;/strong> Zero-gradient extrapolation, backed by an &lt;strong>absorbing sponge layer&lt;/strong> that smoothly raises dissipation near the boundary so vortices and acoustic waves leave the domain instead of reflecting back into it.&lt;/li>
&lt;li>&lt;strong>Obstacles.&lt;/strong> &lt;strong>Bounce-back&lt;/strong> sends distributions that hit a solid node back the way they came, producing a no-slip wall with no meshing whatsoever. Its &lt;strong>moving-wall&lt;/strong> variant adds the wall-velocity momentum to the reflected populations, which is the entire implementation of the rotating cylinder, with the Magnus effect following from the physics. Summing the momentum exchanged in these reflections gives the aerodynamic force on each body (the live $C_l$ and $C_d$ in the HUD) at almost no cost.&lt;/li>
&lt;li>&lt;strong>Tunnel walls.&lt;/strong> Selectable free-slip (specular reflection) or no-slip bounce-back.&lt;/li>
&lt;/ul>
&lt;h2 id="implementation">Implementation&lt;/h2>
&lt;h3 id="architecture">Architecture&lt;/h3>
&lt;p>The project is two cleanly separated parts joined by one shared memory buffer:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">C engine (lbm.c, C99) --emcc--&amp;gt; WebAssembly + wasm heap
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ zero-copy Float32Array views
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vanilla ES6 frontend ── WebGL2 R32F textures ──&amp;gt; colormap LUT in shader
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;ul>
&lt;li>&lt;strong>The engine&lt;/strong> is a single C99 file holding the D2Q9 solver, LES, boundary conditions, analytic shape rasterizers (cylinder, flat plate, NACA 0012 / 4412), force evaluation, and derived-field computation (velocity, pressure, vorticity, schlieren, dilatation). It exposes a small flat C API (&lt;code>lbm_init&lt;/code>, &lt;code>lbm_step&lt;/code>, &lt;code>lbm_set_params&lt;/code>, …) and knows nothing about JavaScript.&lt;/li>
&lt;li>&lt;strong>The frontend&lt;/strong> is vanilla ES6 modules, with no framework, no bundler, and no build step beyond &lt;code>emcc&lt;/code> itself. &lt;code>main.js&lt;/code> drives the simulation loop and UI, &lt;code>renderer.js&lt;/code> owns WebGL2, and &lt;code>obstacles.js&lt;/code> defines the analytic presets.&lt;/li>
&lt;li>&lt;strong>Two engine builds&lt;/strong> come from the same source: a single-threaded variant that runs everywhere, and a &lt;code>-pthread&lt;/code> variant using WebAssembly threads. The app feature-detects &lt;code>crossOriginIsolated&lt;/code> at boot, picks the best one, and falls back gracefully.&lt;/li>
&lt;/ul>
&lt;h3 id="the-zero-copy-data-path">The zero-copy data path&lt;/h3>
&lt;p>The cardinal rule of the rendering pipeline is that &lt;strong>field data is never copied on the JavaScript side&lt;/strong>. The C engine computes vorticity, say, into a &lt;code>float&lt;/code> array in the wasm heap. JavaScript wraps that memory in a &lt;code>Float32Array&lt;/code> &lt;em>view&lt;/em> (no allocation, no copy) and hands it straight to &lt;code>texSubImage2D&lt;/code>, which uploads it into a single-channel floating-point (&lt;code>R32F&lt;/code>) texture. The fragment shader then maps values to colour through a small lookup-table texture, so the entire visual styling costs one texture fetch per pixel and lives on the GPU.&lt;/p>
&lt;p>Two subtleties keep this honest. When the wasm heap grows (the user picks a finer grid), the underlying &lt;code>ArrayBuffer&lt;/code> detaches and every view must be rebuilt, which the app does lazily, on demand. And in the multithreaded build the heap is a &lt;code>SharedArrayBuffer&lt;/code>, which some browsers refuse to upload from directly; a single small staging buffer is the one sanctioned exception to the no-copy rule.&lt;/p>
&lt;h3 id="multithreading-in-a-browser">Multithreading in a browser&lt;/h3>
&lt;p>The interior sweep is &lt;strong>row-partitioned&lt;/strong> across a pool of wasm pthreads, with barriers separating the phases of each timestep. The partition is designed so that every slot of the destination array has &lt;em>exactly one&lt;/em> producer. Threads never contend, so there are no locks and no atomics in the hot path. Force contributions are reduced in fixed thread order, which makes results &lt;strong>bit-deterministic for a given thread count&lt;/strong>, a property borrowed from serious HPC practice where &amp;ldquo;fast but slightly different every run&amp;rdquo; is how bugs hide.&lt;/p>
&lt;p>Browser threading comes with a catch: &lt;code>SharedArrayBuffer&lt;/code> requires the page to be &lt;strong>cross-origin isolated&lt;/strong>, which needs COOP/COEP HTTP headers that a static host like GitHub Pages cannot send. Web-LBM solves this with a root-scoped &lt;strong>service worker&lt;/strong> that injects the headers client-side (one automatic reload on first visit). If anything in that chain fails (an older browser, partial isolation), the app silently boots the single-threaded engine instead. This is progressive enhancement applied to HPC.&lt;/p>
&lt;h3 id="interaction">Interaction&lt;/h3>
&lt;p>Everything in the tunnel is manipulable while the simulation runs. Obstacles can be placed by click, rotated, spun (for rotating cylinders), or &lt;strong>painted freehand&lt;/strong> with the mouse directly into the obstacle mask. Painted cells are resampled when the grid resolution changes, while preset shapes are re-rasterized &lt;em>analytically&lt;/em> from their registry, so a NACA profile stays a crisp NACA profile at any resolution. Flow can be visualized as velocity, pressure, vorticity, schlieren, or dilatation, with streamline and particle overlays. &lt;strong>Virtual microphones&lt;/strong> can be dropped anywhere in the field to plot a live FFT of the local pressure signal; placing one behind a cylinder reveals the sharp spectral peak of the shedding frequency, the same physics as the hum of wind through wires.&lt;/p>
&lt;h2 id="engineering-challenges">Engineering challenges&lt;/h2>
&lt;h3 id="numerical-stability-without-a-referee">Numerical stability without a referee&lt;/h3>
&lt;p>An interactive simulation cannot reject user input: whatever Reynolds number, obstacle shape, or resolution the visitor chooses, the solver must not blow up. Plain BGK becomes unstable as $\tau \to 1/2$ (high Reynolds numbers), so the engine layers several defences, each addressing a distinct failure mode.&lt;/p>
&lt;ul>
&lt;li>A &lt;strong>Smagorinsky LES model&lt;/strong> ($C_s = 0.18$) computes a local eddy viscosity from the strain rate (captured in LBM directly from the non-equilibrium distributions, with no finite differences). It both models the unresolved turbulent scales and keeps the effective $\tau$ away from the unstable limit exactly where the flow is most strained.&lt;/li>
&lt;li>&lt;strong>Hermite regularization&lt;/strong> projects the non-equilibrium part of the distributions onto its physically meaningful (second-order Hermite) component before collision, filtering the spurious higher-order modes that otherwise accumulate and destabilize coarse grids.&lt;/li>
&lt;li>&lt;strong>Bulk-viscosity damping&lt;/strong> selectively dissipates the acoustic part of the stress, taming pressure oscillations without touching the shear physics that creates vortices.&lt;/li>
&lt;li>&lt;strong>Sponge layers and a soft-started inlet&lt;/strong> prevent the two classic transients, reflected outflow waves and the initial impulse, from contaminating the domain.&lt;/li>
&lt;/ul>
&lt;p>The result is a tunnel that survives being abused, which is the difference between a demo and a product.&lt;/p>
&lt;h2 id="what-you-can-explore">What you can explore&lt;/h2>
&lt;p>A few experiments that each take under a minute in the &lt;a href="https://vcaries.github.io/web_lbm/" target="_blank" rel="noopener">live tunnel&lt;/a>:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Vortex shedding.&lt;/strong> Keep the default cylinder, switch to the vorticity view, and watch the von Kármán street establish itself; the HUD shows $C_l$ oscillating at the shedding frequency.&lt;/li>
&lt;li>&lt;strong>Stall.&lt;/strong> Place a NACA 4412 airfoil and increase the angle of attack: the lift coefficient climbs, then the suction-side flow separates.&lt;/li>
&lt;li>&lt;strong>The Magnus effect.&lt;/strong> Spin a cylinder and watch the wake deflect and a steady lift force appear, the physics behind curved free kicks.&lt;/li>
&lt;li>&lt;strong>Aeroacoustics.&lt;/strong> Switch to the schlieren or dilatation view to see pressure waves radiate from the wake, then drop a microphone behind the cylinder and find the shedding tone in the live spectrum.&lt;/li>
&lt;li>&lt;strong>Your own geometry.&lt;/strong> Paint an arbitrary obstacle with the mouse and see how the flow negotiates it.&lt;/li>
&lt;/ol>
&lt;h2 id="skills-demonstrated">Skills demonstrated&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Computational fluid dynamics:&lt;/strong> the Lattice Boltzmann Method end to end, including lattice choice, collision models (BGK, MRT, regularization), boundary conditions, turbulence modelling (Smagorinsky LES), force evaluation, and aeroacoustics.&lt;/li>
&lt;li>&lt;strong>Numerical methods and scientific computing:&lt;/strong> stability analysis in practice, identifying failure modes and layering targeted remedies, in a physics engine written in portable C99 with embedded smoke tests.&lt;/li>
&lt;li>&lt;strong>Performance engineering:&lt;/strong> SIMD-friendly branchless kernels, a structure-of-arrays memory layout, lock-free deterministic multithreading, and profiling-driven separation of hot and cold paths.&lt;/li>
&lt;li>&lt;strong>Software architecture:&lt;/strong> a strict engine/frontend boundary with a zero-copy data contract across the C/JavaScript frontier, plus runtime feature detection with graceful degradation.&lt;/li>
&lt;li>&lt;strong>Web technologies:&lt;/strong> WebAssembly (Emscripten, wasm threads, shared memory), WebGL2 (floating-point textures, shader-based colormapping), service workers, and fully static deployment.&lt;/li>
&lt;li>&lt;strong>Interactive scientific visualization and UI design:&lt;/strong> real-time field rendering, multiple physically meaningful views, and an interface that invites experimentation without a manual.&lt;/li>
&lt;li>&lt;strong>Scientific communication:&lt;/strong> making graduate-level fluid dynamics tangible, and fun, for a general technical audience.&lt;/li>
&lt;/ul>
&lt;h2 id="repository">Repository&lt;/h2>
&lt;p>The full source is open: the C engine (extensively commented, including every compiler flag and its rationale), the WebGL2 renderer, the build scripts, and the physics smoke tests.&lt;/p>
&lt;p>The code lives at &lt;strong>&lt;a href="https://github.com/vcaries/web_lbm" target="_blank" rel="noopener">github.com/vcaries/web_lbm&lt;/a>&lt;/strong>, and the tunnel itself runs at &lt;strong>&lt;a href="https://vcaries.github.io/web_lbm/" target="_blank" rel="noopener">vcaries.github.io/web_lbm&lt;/a>&lt;/strong>.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Web-LBM compresses the whole span of simulation engineering into a single page that anyone can open: a kinetic numerical method, turbulence modelling, SIMD and multithreaded optimization, GPU rendering, and product-grade interaction design. The Lattice Boltzmann Method is the enabling choice, being local, parallel, naturally unsteady, and quietly capable of acoustics. The browser is the forcing function, since every one of its constraints had to be engineered around rather than waved away. The result is a wind tunnel in a tab, and a demonstration that &lt;em>real&lt;/em> scientific computing does not have to live behind a queue on a cluster.&lt;/p></description></item><item><title>BoidForge: Engineering Collective Behavior from Python to Native C</title><link>https://vcaries.github.io/en/project/boid-forge/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://vcaries.github.io/en/project/boid-forge/</guid><description>&lt;h2 id="in-one-minute">In one minute&lt;/h2>
&lt;p>&lt;strong>BoidForge&lt;/strong> is a 2D flocking simulator built around a single principle: a swarm is &lt;em>computed&lt;/em> once and &lt;em>rendered&lt;/em> afterwards, never both at the same time. A &lt;strong>solver&lt;/strong> advances the physics and streams every timestep to a compact binary file. A separate &lt;strong>visualization engine&lt;/strong> then replays that file on the GPU. The two halves share no memory, only an on disk format, so each one can be optimised on its own.&lt;/p>
&lt;p>The result is a project that is equal parts numerical computing and real time graphics. The same flocking model is implemented in &lt;strong>four interchangeable backends&lt;/strong>, ranging from a naïve Python reference to a hand written &lt;strong>C extension&lt;/strong>, and all four are proven to produce strictly identical results down to the last bit. A &lt;strong>ModernGL&lt;/strong> renderer then turns the raw trajectories into the cinematic footage below.&lt;/p>
&lt;div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden;">
&lt;iframe src="https://www.youtube.com/embed/5iVkLb3eyhE" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; border:0;" allowfullscreen title="YouTube Video">&lt;/iframe>
&lt;/div>
&lt;figcaption style="text-align:center;font-size:0.9em;margin-top:0.5em;">&lt;strong>Video.&lt;/strong> The &lt;em>turbulence&lt;/em> scene, rendered offline in 4K at 60&amp;nbsp;fps and coloured by speed. It was produced by replaying a recorded simulation through the GPU visualization engine described further down.&lt;/figcaption>
&lt;p>👉 &lt;strong>&lt;a href="https://github.com/vcaries/boid_forge" target="_blank" rel="noopener">Source code on GitHub&lt;/a>&lt;/strong>&lt;/p>
&lt;h3 id="highlights">Highlights&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>One model, four backends, identical output.&lt;/strong> Naïve Python (L1), vectorized NumPy (L2), a uniform grid spatial hash (L3), and a native C/CPython kernel (L4), all bit for bit identical under a strict determinism contract.&lt;/li>
&lt;li>&lt;strong>Up to about 330 times faster.&lt;/strong> The C backend reaches roughly 120 to 330 times the throughput of the naïve reference over the benchmarked range, and is designed to scale to tens of thousands of agents.&lt;/li>
&lt;li>&lt;strong>GPU visualization in ModernGL.&lt;/strong> Instanced point sprites, additive HDR accumulation, motion trails, bloom, and ACES tone mapping, rendered without a display and streamed straight to FFmpeg for 4K export.&lt;/li>
&lt;li>&lt;strong>Reproducible by construction.&lt;/strong> Every result is a pure function of the configuration and a seed, so the same inputs give the same bytes on any machine.&lt;/li>
&lt;/ul>
&lt;h2 id="inspirations">Inspirations&lt;/h2>
&lt;p>This project grew out of two pieces of online science and engineering culture. The first is &lt;strong>Smarter Every Day&lt;/strong>, whose &lt;a href="https://www.youtube.com/watch?v=4LWmRuB-uNU" target="_blank" rel="noopener">film on starling murmurations&lt;/a> captures how thousands of birds can behave like a single fluid body, and asks the natural question of how such order can emerge with no leader and no plan. The second is &lt;strong>Sebastian Lague&lt;/strong> and his &lt;em>Coding Adventures&lt;/em> series, whose &lt;a href="https://www.youtube.com/watch?v=bqtqltqcQhw" target="_blank" rel="noopener">episode on boids&lt;/a> shows how a few local rules translate into code and how much visual beauty a simple simulation can hold. BoidForge is my attempt to take that curiosity seriously as an engineer: to reproduce the phenomenon faithfully, to push it to a scale where the emergence becomes obvious, and to render it well enough to do the real murmurations justice.&lt;/p>
&lt;h2 id="the-flocking-model">The flocking model&lt;/h2>
&lt;p>Flocking is an &lt;em>emergent&lt;/em> behaviour. There is no leader and no global plan, yet thousands of agents organise themselves into coherent, fluid looking motion. The classic formulation is Craig Reynolds&amp;rsquo; &lt;strong>boids&lt;/strong> model (1987), in which every agent obeys three local steering rules, each computed only from the neighbours that lie within a given radius.&lt;/p>
&lt;p>Let agent $i$ have position $\mathbf{p}_i$ and velocity $\mathbf{v}_i$. Write $\mathcal{N}_i$ for the set of its neighbours inside the radius of the rule being evaluated. Each rule produces a steering acceleration.&lt;/p>
&lt;p>&lt;strong>Separation&lt;/strong> keeps the swarm from collapsing on itself. Each agent is pushed away from its close neighbours, and the push grows stronger the closer they are:&lt;/p>
$$\mathbf{a}_{\text{sep}} = w_{\text{sep}} \sum_{j \in \mathcal{N}_i} \frac{\mathbf{p}_i - \mathbf{p}_j}{\lVert \mathbf{p}_i - \mathbf{p}_j \rVert^{2}}$$
&lt;p>The inverse square weighting is the important detail: a neighbour at half the distance repels four times as hard, which is what spaces the agents out and prevents collisions.&lt;/p>
&lt;p>&lt;strong>Alignment&lt;/strong> makes neighbours travel together, by steering each agent towards the average velocity $\overline{\mathbf{v}}$ of its neighbourhood:&lt;/p>
$$\mathbf{a}_{\text{ali}} = w_{\text{ali}} \left( \overline{\mathbf{v}} - \mathbf{v}_i \right)$$
&lt;p>&lt;strong>Cohesion&lt;/strong> holds the group together, by steering each agent towards the local centre of mass $\overline{\mathbf{p}}$ of its neighbourhood:&lt;/p>
$$\mathbf{a}_{\text{coh}} = w_{\text{coh}} \left( \overline{\mathbf{p}} - \mathbf{p}_i \right)$$
&lt;p>The three contributions are summed into a single acceleration $\mathbf{a}_i$, whose magnitude is capped at a maximum steering force. The velocity is then advanced by one explicit Euler step and clamped to a speed band $[v_{\min}, v_{\max}]$ so that agents never freeze or run away:&lt;/p>
$$\mathbf{v}_i \leftarrow \mathbf{v}_i + \mathbf{a}_i \Delta t$$
&lt;p>Finally the position is advanced with that new velocity, and a boundary rule (wrap around or reflect) keeps the swarm inside the world:&lt;/p>
$$\mathbf{p}_i \leftarrow \mathbf{p}_i + \mathbf{v}_i \Delta t$$
&lt;p>Each rule uses its &lt;strong>own radius&lt;/strong>, and the three weights $w_{\text{sep}}$, $w_{\text{ali}}$, $w_{\text{coh}}$ give a run its character. Tuning them is what produces the &lt;em>murmuration&lt;/em>, &lt;em>schooling&lt;/em>, &lt;em>plasma&lt;/em>, and &lt;em>turbulence&lt;/em> scenes in the repository. The model is short to state. The engineering work is in making it fast and reproducible.&lt;/p>
&lt;h2 id="the-computational-bottleneck">The computational bottleneck&lt;/h2>
&lt;p>The cost of the model is dominated by one question. For each agent, which other agents are its neighbours? Answered naïvely, every agent is compared against every other, which means $O(N^2)$ distance calculations per timestep. At a few hundred agents this is harmless. At tens of thousands it is hopeless, because quadratic growth means a swarm ten times larger costs a hundred times more.&lt;/p>
&lt;p>BoidForge attacks the problem along two independent axes, and gives each one its own backend so that the contribution of every idea can be &lt;em>measured&lt;/em> rather than assumed:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Lower the constant factor.&lt;/strong> Do the same $O(N^2)$ work, but express it in fewer and faster instructions. This is the path from a Python loop to vectorized NumPy and then to native C.&lt;/li>
&lt;li>&lt;strong>Lower the complexity.&lt;/strong> Stop doing $O(N^2)$ work at all, by only ever inspecting agents that could plausibly be neighbours. This is the spatial grid, which brings the search down to roughly $O(N)$.&lt;/li>
&lt;/ol>
&lt;p>All four backends are held to a single &lt;strong>determinism contract&lt;/strong>: for the same configuration and seed, they must emit byte for byte identical output. The naïve backend is the reference, and any backend that disagrees with it is wrong by definition. A test runs every backend and compares their frames. This is a demanding constraint, because it forces even the C kernel to reproduce NumPy&amp;rsquo;s exact &lt;code>float32&lt;/code> summation order, but it is what makes the speedups trustworthy instead of merely fast.&lt;/p>
&lt;h2 id="four-backends-and-what-each-one-buys">Four backends, and what each one buys&lt;/h2>
&lt;h3 id="l1-naïve-python-the-reference">L1, naïve Python (the reference)&lt;/h3>
&lt;p>A direct all pairs implementation, with an explicit Python loop over every pair of agents. It is deliberately simple, because its only job is to &lt;em>define correctness&lt;/em>. Every other backend is validated against it. It is also, predictably, the slowest, since the per element overhead of the Python interpreter sits on top of the $O(N^2)$ work.&lt;/p>
&lt;h3 id="l2-vectorized-numpy">L2, vectorized NumPy&lt;/h3>
&lt;p>The same all pairs algorithm, re expressed with NumPy. The pairwise displacement, distance, and separation terms are built once as whole arrays, and the clamping and integration run as bulk array operations.&lt;/p>
&lt;p>&lt;em>Why it is faster.&lt;/em> The arithmetic moves out of the Python interpreter and into NumPy&amp;rsquo;s compiled C loops, which removes the per element interpreter overhead. On small and medium swarms this is roughly a twofold gain. It is not free, however. The pairwise arrays cost $O(N^2)$ &lt;em>memory&lt;/em>, and at the top of the benchmarked range the cost of moving those large arrays through memory cancels the gain, to the point where L2 is marginally slower than L1 at 2000 agents. That trade off is the lesson in itself: vectorization buys speed with memory, and at scale memory becomes the limiting resource.&lt;/p>
&lt;h3 id="l3-uniform-grid-spatial-hash">L3, uniform grid spatial hash&lt;/h3>
&lt;p>The first &lt;em>algorithmic&lt;/em> change. Agents are sorted into a grid whose cell side equals the neighbour radius. Each agent then inspects only its own cell and the eight cells around it, a three by three block, instead of the whole swarm. Because the cell side equals the radius, any genuine neighbour is guaranteed to fall inside that block, so the grid prunes work without ever missing a neighbour.&lt;/p>
&lt;p>&lt;em>Why it is faster.&lt;/em> It changes the complexity of the neighbour search from $O(N^2)$ to about $O(N)$. The work per agent stops growing with the size of the swarm and depends only on the local density. This is the idea that makes large swarms tractable in principle. In pure Python, though, the bookkeeping of building the grid and gathering candidates carries enough overhead that, at the modest sizes benchmarked here, its timing stays close to the naïve loop. The algorithm is correct, but it is waiting for a faster host language to pay off.&lt;/p>
&lt;h3 id="l4-native-c-extension-the-production-target">L4, native C extension (the production target)&lt;/h3>
&lt;p>That faster host is a hand written &lt;strong>CPython C extension&lt;/strong>, written against the raw C API and built with CMake through scikit-build-core. It combines the $O(N)$ grid of L3 with a set of systems level optimisations that Python cannot offer:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Struct of Arrays buffers, shared without copying.&lt;/strong> The state lives as four contiguous &lt;code>float32&lt;/code> arrays (positions and velocities) shared with NumPy through the buffer protocol. There is no copy, no per agent object overhead, and a memory layout the processor can stream through efficiently.&lt;/li>
&lt;li>&lt;strong>A counting sort into cells.&lt;/strong> The grid is built with a counting sort into a compact layout rather than a hash map, so gathering neighbours is a tight loop over contiguous indices instead of dictionary lookups.&lt;/li>
&lt;li>&lt;strong>The interpreter lock is released&lt;/strong> around the compute loop, so the heavy numerical work runs as genuine native code, free of the Python global interpreter lock.&lt;/li>
&lt;li>&lt;strong>No memory allocation inside the hot loop.&lt;/strong> A single workspace is sized once per timestep, and the inner per agent loop never calls the allocator.&lt;/li>
&lt;/ul>
&lt;p>&lt;em>Why it is faster.&lt;/em> Each of these removes a layer of overhead that the Python backends cannot escape, namely interpreter dispatch, object indirection, and allocator pressure, while keeping the $O(N)$ neighbour search. Together they deliver roughly 120 to 330 times the throughput of the naïve reference and put tens of thousands of agents within reach. It does all of this while still producing output identical to L1 down to the bit, which required reproducing NumPy&amp;rsquo;s pairwise summation algorithm exactly in C so that the &lt;code>float32&lt;/code> rounding matches.&lt;/p>
&lt;h2 id="benchmarks">Benchmarks&lt;/h2>
&lt;p>The harness times each backend across a sweep of swarm sizes, from 100 to 2000 agents, over 200 steps each and after a warm up, recording the mean time per frame and the speedup relative to L1.&lt;/p>
&lt;figure>
&lt;a href="https://vcaries.github.io/media/boid-forge/scaling.png" target="_blank" rel="noopener">&lt;img src="https://vcaries.github.io/media/boid-forge/scaling.png" alt="Per-frame time versus number of agents, log-log, for the four backends" style="width:100%;height:auto;">&lt;/a>
&lt;figcaption>&lt;strong>Figure 1.&lt;/strong> Time per frame against swarm size, on logarithmic axes. The naïve (L1), vectorized (L2), and Python spatial hash (L3) backends sit close together at these sizes, while the native C backend (L4) runs one to two orders of magnitude below them. The gap widening towards the right is the $O(N)$ grid pulling away from the $O(N^2)$ all pairs cost. &lt;em>(Click to enlarge.)&lt;/em>&lt;/figcaption>
&lt;/figure>
&lt;figure>
&lt;a href="https://vcaries.github.io/media/boid-forge/speedup.png" target="_blank" rel="noopener">&lt;img src="https://vcaries.github.io/media/boid-forge/speedup.png" alt="Speedup of each backend relative to the naïve baseline, per swarm size" style="width:100%;height:auto;">&lt;/a>
&lt;figcaption>&lt;strong>Figure 2.&lt;/strong> Speedup over the naïve baseline. The native backend dominates, peaking around 330 times at 200 agents and holding well above 100 times across the range, while the pure Python optimisations (L2 and L3) stay close to 1 at these sizes. This confirms that the decisive gain comes from moving the $O(N)$ algorithm into C, and not from vectorization on its own.&lt;/figcaption>
&lt;/figure>
&lt;p>At 2000 agents the naïve reference spends about 202 ms per frame, the equivalent of roughly 5 frames per second, while the C backend spends about 1.7 ms, the equivalent of roughly 590 frames per second. That is the same physics computed about 120 times faster, with its lead still growing as the swarm gets larger.&lt;/p>
&lt;h2 id="visualization-with-moderngl">Visualization with ModernGL&lt;/h2>
&lt;p>The renderer never simulates. It reads precomputed frames from the binary stream and turns each one into an image on the GPU. It is built directly on &lt;strong>ModernGL&lt;/strong>, a thin modern binding to OpenGL 3.3, with hand written GLSL shaders, and it is deliberately a small post processing pipeline rather than a game engine:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Upload and draw.&lt;/strong> Each frame is uploaded to a GPU vertex buffer and every agent is drawn as a single &lt;strong>point sprite&lt;/strong>. The fragment shader shapes each sprite into a soft glowing core surrounded by a wide halo, and its colour is read from a small colour map texture indexed by speed, heading, local density, or a fixed value.&lt;/li>
&lt;li>&lt;strong>Additive HDR accumulation.&lt;/strong> Sprites are blended additively into a floating point buffer, so overlapping agents build up genuine brightness. This is what gives dense clusters their luminous look.&lt;/li>
&lt;li>&lt;strong>Motion trails.&lt;/strong> Instead of clearing that buffer between frames, it is faded slightly towards black. Agents therefore leave decaying trails behind them, which is the source of the silky, fluid sense of motion.&lt;/li>
&lt;li>&lt;strong>Bloom.&lt;/strong> A bright pass keeps only the most luminous pixels, a separable Gaussian blur spreads them out, and the glow is added back, producing the cinematic halo around fast and crowded regions.&lt;/li>
&lt;li>&lt;strong>Composite.&lt;/strong> A final fullscreen shader applies an ACES filmic tone map to bring the high dynamic range scene into a displayable range, together with exposure, an optional vignette, the background tint, and gamma correction.&lt;/li>
&lt;/ol>
&lt;p>Because the GPU context can be created off screen, the very same renderer runs without a display on a headless machine. For video, the rendered frames are streamed straight into an &lt;strong>FFmpeg&lt;/strong> subprocess that encodes H.264, without ever holding the whole clip in memory. That is how the 4K footage at the top of this page was produced.&lt;/p>
&lt;h2 id="architecture-two-subsystems-one-contract">Architecture: two subsystems, one contract&lt;/h2>
&lt;p>The design rule behind everything above is a strict separation between &lt;em>computing&lt;/em> the swarm and &lt;em>drawing&lt;/em> it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">Solver (compute) ---&amp;gt; .bfs binary stream ---&amp;gt; Visualization engine ---&amp;gt; frames / video
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The solver imports nothing from the visualization layer, with no OpenGL and no graphics of any kind, and the visualization layer never advances the physics. Their only shared knowledge is a small versioned &lt;strong>binary format&lt;/strong> (&lt;code>.bfs&lt;/code>): a 32 byte header followed by one record per frame holding the agent count and the four &lt;code>float32&lt;/code> arrays back to back. That layout is chosen so the solver can write each array in a single bulk operation and the renderer can map it straight into a GPU buffer with no repacking on either side. A static test on the import boundaries enforces the separation so that it cannot erode over time.&lt;/p>
&lt;p>This decoupling is what lets the solver be tuned for raw throughput and the renderer for image quality, each on its own terms. It is also why a long simulation can be computed once and then re rendered later with different colours, trails, or resolution as many times as needed.&lt;/p>
&lt;h2 id="skills-demonstrated">Skills demonstrated&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>High performance computing:&lt;/strong> algorithmic complexity reduction from $O(N^2)$ to $O(N)$ with spatial hashing, NumPy vectorization and its memory trade offs, and a hand written CPython C extension with zero copy buffers, interpreter lock release, and an allocation free hot loop.&lt;/li>
&lt;li>&lt;strong>Numerical rigour:&lt;/strong> a bit exact determinism contract across four independent implementations, including reproducing NumPy&amp;rsquo;s &lt;code>float32&lt;/code> pairwise summation in C.&lt;/li>
&lt;li>&lt;strong>GPU and real time graphics:&lt;/strong> a ModernGL and GLSL pipeline with HDR accumulation, motion trails, bloom, and ACES tone mapping, running headless and exporting through FFmpeg.&lt;/li>
&lt;li>&lt;strong>Software architecture:&lt;/strong> two strictly decoupled subsystems joined only by a versioned binary format, with an enforced import boundary.&lt;/li>
&lt;li>&lt;strong>Engineering discipline:&lt;/strong> typed, documented, and tested Python (&lt;code>ruff&lt;/code>, &lt;code>mypy --strict&lt;/code>, &lt;code>pytest&lt;/code>), a CMake and scikit-build-core native build, and reproducibility treated as a first class requirement.&lt;/li>
&lt;/ul>
&lt;h2 id="repository">Repository&lt;/h2>
&lt;p>The full source is open, including the four solver backends, the C kernel, the binary input and output layer, the benchmark harness, and the ModernGL visualization engine.&lt;/p>
&lt;p>The code lives at &lt;strong>&lt;a href="https://github.com/vcaries/boid_forge" target="_blank" rel="noopener">github.com/vcaries/boid_forge&lt;/a>&lt;/strong>, alongside an architecture document that describes the binary format and the determinism contract in full.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>A flocking model is three lines of vector algebra. Making it &lt;em>scale&lt;/em> is an engineering project. BoidForge carries the model from a transparent Python reference to a native C kernel that is two orders of magnitude faster, proves at every step that the extra speed costs nothing in correctness, and then renders the result as something genuinely worth watching, all inside a clean architecture where computation and rendering never touch. The hard part was never the boids. It was making them fast, reproducible, and beautiful.&lt;/p></description></item></channel></rss>