<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>WebGL | Valentin Caries</title><link>https://vcaries.github.io/en/tag/webgl/</link><atom:link href="https://vcaries.github.io/en/tag/webgl/index.xml" rel="self" type="application/rss+xml"/><description>WebGL</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>WebGL</title><link>https://vcaries.github.io/en/tag/webgl/</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></channel></rss>