Postmortem 16 min read

Culled: A WebGL2 Porting Post-Mortem

Chasing a fake fluid-dynamics energy bug across Rust, TypeScript, Three.js, and a sticky WebGL2 state machine — the real bug was one missing gl.disable() call.

Diagram of a line geometry bisected by GL_CULL_FACE, one half rendering as an incomplete cull and the other as the intact flux reference

We spent a stretch of hard, stubborn debugging chasing a fluid-dynamics energy deficit that turned out not to exist, across a Rust/WGSL reference, a TypeScript/WebGL2 port, and a browser sandbox that lied about its own frame rate. The actual bug was one missing gl.disable() call, and finding it meant building an entire parallel diagnostics API for a screensaver.

This is the story of a rendering bug that took far longer to find than it should have, not because it was subtle, but because everything around it looked like a more interesting, more plausible bug. It’s also a story about the specific ways AI-assisted debugging goes wrong, and what actually got us out of it. If you’re porting a GPU simulation between languages, frameworks, or graphics APIs, or you’re directing an agent to do that work, the details below are worth stealing.

Flux, and the Screensaver It’s Chasing

sandydoo/flux is an open-source tribute to macOS’s Drift screensaver: a 2D incompressible fluid field, solved with a textbook Jos Stam “Stable Fluids” method, stirred continuously by injected simplex noise, and visualized not as a velocity heatmap but as thousands of individually-animated glowing line ribbons, each one a little momentum-damped spring chasing the fluid’s local velocity, additively blended into a dense, combed-hair swirl.

What makes flux an interesting porting target is that its own repository doesn’t have one reference implementation. It has three, at different points in the project’s history:

CrateStackStatus
flux-glGLSL / WebGL2legacy, kept for compatibility
fluxWGSL / WebGPUlive, what flux.sandydoo.me actually ships
flux-desktopnative, wgpuscreensaver binary

The two web crates have quietly diverged over time: different default constants, a different grid layout algorithm, different render-time vs. simulation-time scaling for the same visual parameter. Porting “flux” without first deciding which flux you mean is its own trap, and it’s the first one this project fell into.

Night Current’s Architecture

The port, Night Current, is a deliberately hybrid piece. The fluid solver lives inside Three.js as a conventional ping-pong WebGLRenderTarget pipeline (advection, BFECC correction, Jacobi diffusion, Jacobi pressure projection, noise injection, one ShaderMaterial pass each), while the line advection drops to raw WebGL2 underneath Three’s renderer: transform feedback, hand-managed VAOs, a real GPU-resident simulation problem that Three’s abstractions don’t have a clean answer for.

That split isn’t an accident. It’s a convention borrowed from sibling pieces on the same site (Aura, Aurora Flare), which established the pattern: let Three own the fluid grid, and reach past it for anything that needs transform feedback. It’s also exactly the seam where this bug would eventually hide.

Milestones, and a Lot of Verified-Correct Math

The build proceeded in the sensible order — M1 fluid grid core, M2 line advection and rendering, M3 color and persistence — with every milestone cross-checked against the reference’s own source, constant by constant. By the time this investigation picked up, the build log already documented an extraordinary amount of correctly-ruled-out ground: noise channel counts, viscosity, pressure-solve iteration counts, blend functions, texture precision, grid formulas. Almost everything that could be diffed against source had been.

And yet the port’s lines were consistently shorter and sparser than the reference’s: thin, scattered filaments where the reference showed a dense, coherent “combed hair” field. Every formula matched. The picture still didn’t.

A Theory That Was Never Actually Measured

The working theory, carried forward across several sessions, was that the port’s fluid solver had a genuinely lower steady-state kinetic energy than the reference’s. It had circumstantial support: a real, confirmed ramp-up curve (the field visibly grows more energetic over the first several seconds after a cold start), and a line-length formula — length = lineLength × |velocity| — that put the blame on the velocity term once the length math itself was verified.

It was a reasonable theory, and it was never checked against the thing it was a theory about: the reference’s own live velocity field. Every session re-verified the port’s formulas were correct. None of them could read what the reference was actually doing at runtime, because its Elm/WASM build exposed no debug hook, and reading a live GPU buffer through DevTools isn’t something you back into by accident. Re-verifying your own formulas isn’t the same experiment as checking the other side’s actual runtime state — a theory can survive every check you know how to run and still not be a theory that’s been tested, if none of those checks were capable of touching the thing it claims.

One session did try compensating for it directly: a VELOCITY_GAIN multiplier, tuned up from 10× to 40× on the sampled fluid velocity, layered on top of a solver that had already been verified line-for-line against the reference. It was reverted in the same session once a longer forced-stepping run showed the “weak” reading was just an early measurement, and the field does keep converging given real time. But the instinct to reach for a gain knob instead of a root cause is worth naming — it’s the instinct that would have made this bug permanent if anyone had trusted it.

When Your Test Environment Lies About Time

Confirming or denying the energy theory meant getting the reference actually running, which meant installing a Rust toolchain, building the WGSL crate, and serving it locally next to the port. Once both were up, the obvious next move — open two tabs, compare — ran straight into the investigation’s second real time-sink: this sandbox’s browser automation freezes requestAnimationFrame on any tab that isn’t the sole focused one. Not throttled, frozen. Sim time reads the same value ten seconds and ten minutes later.

That’s a nasty failure mode to debug against, because it looks exactly like the app being broken — a black canvas, a HUD stuck at 0.1 seconds — and it happened on both the port and the reference, which briefly read as corroborating evidence for something being wrong with both, rather than the actual explanation: the harness, not the app.

The fix was to stop trusting the render loop entirely. Both the port and the reference expose an animate(timestamp)/step() entry point; calling it directly, in a tight loop, with a manually incremented timestamp, drives the simulation at an exact, controllable rate with zero dependency on the browser’s event loop ever scheduling a frame. Everything that mattered from this point on was measured through forced-stepping, never through watching the screen run.

The Turning Point: Instrumenting Both Sides

The actual unlock wasn’t a clever guess. It came from noticing that every session so far had only instrumented one side of the comparison — the port, which already had a debug HUD reading live GPU buffers. The reference had nothing, so every claim about its behavior was inference from screenshots.

Closing that gap meant writing new Rust: a wgpu::Texture → Buffer copy, an async map_async bridged into a JS Promise, half-float decoding by hand, all exposed through wasm-bindgen as debug_velocity_stats() and debug_line_stats(), callable straight from DevTools. Once both projects could report their own numbers on command, this stopped being a source-reading exercise and became an actual experiment.

// flux-wasm/src/wasm_wrapper.rs
//
// Copy the live velocity texture to a CPU-readable buffer, decode
// Rgba16Float by hand, report avg/max magnitude — callable as
// `await flux.debug_velocity_stats()` with zero dependency on rAF.
pub async fn debug_velocity_stats(&self) -> Result<JsValue, JsValue> {
    // texture → staging buffer → map_async → half::f16::from_le_bytes(..)
    // … avg/max computed directly from the reference's own GPU state.
}

The result, once both sides could actually be asked: the reference’s own instrumented steady-state velocity band was statistically the same as the port’s. The energy-gap theory, the load-bearing assumption of the entire investigation up to this point, was wrong.

Even the Disproof Needed Proving

Reading the reference’s raw line-state buffer directly turned up a second, smaller lesson: the port and the reference don’t store the same quantity in that buffer, even though the final on-screen result is meant to match. The reference’s momentum spring chases the raw fluid velocity and applies the lineLength scale only at render time; the port bakes lineLength directly into the spring target instead.

// reference — place_lines.comp.wgsl
new_velocity = (1.0 - dt * momentum) * line.velocity
             + (velocity - line.endpoint) * delta_boost * dt;
// scaling happens later, in line.wgsl:  line_length * endpoint * vertex.y
// port — lineSim.ts
vNewVelocityVector = (1.0 - dt * momentumBoost) * aVelocityVector
                   + (uLineLength * velocity - aEndpointVector) * deltaBoost * dt;
// already scaled — nothing further happens at render time

Comparing the two buffers’ raw numbers directly makes the port look roughly 60% longer than the reference at every sample, a large and entirely fake discrepancy. The fix wasn’t a code fix, it was an algebraic one: substitute endpoint_port = lineLength × endpoint_reference into both recurrences and the two are identical at every timestep, for any shared constant lineLength and matching initial conditions. Once that scale factor is applied before comparing, the two systems’ average line length agree to within the noise of two independently-phased chaotic simulations sampled at the same wall-clock moment, plus or minus 30% within either individual series.

It’s a small thing, but it’s the kind of small thing that manufactures a confident, wrong conclusion if you don’t catch it. When two implementations are provably equivalent under a known transform, prove the transform before trusting a raw-number comparison.

Clean Negatives Are Still Progress

With length calibrated correctly, the same instrumentation was pointed at everything else the visual symptom could plausibly implicate, and each came back clean, which mattered as much as anything that would eventually come back dirty:

None of this found the bug. All of it earned the right to stop looking at the simulation and start looking at the renderer.

What a Screenshot Could See That Statistics Couldn’t

The pivot came from a human looking at the actual running app and describing what they saw, in plain language: isolated rounded shapes, no visible connecting body, “like only the tip — the rounded half-circle — of the line is rendering.” That single observation reframed the investigation. It wasn’t a magnitude problem, it was a geometry problem: something was making the long streak invisible while leaving only its rounded end-cap on screen.

Resolution: Two Real Bugs

Bug one: persistence was a no-op

The trail-fade effect was implemented as a Three.js ShaderMaterial with blending: THREE.NormalBlending set, but not transparent: true. Three’s own WebGLState.setMaterial() silently downgrades that exact combination to NoBlending. Instead of fading the trail buffer by 10% a frame, the “fade” quad was hard-overwriting the entire trail to solid black every frame — persistence had been fully inert since the feature was added, and the flashing bug it was meant to mask had never actually been fixed.

new THREE.ShaderMaterial({
  blending: THREE.NormalBlending,
  // transparent: true  ←  missing. Silently becomes NoBlending.
  transparent: true,   //  ←  the fix
})

A real bug, correctly fixed, and numerically confirmed by watching pixel brightness accumulate across forced frames instead of resetting. It did not, on its own, fix the reported symptom.

Bug two: the actual root cause

Disproving bug one as sufficient meant a more surgical test: inject one artificial line, deliberately oversized and fully opaque, at a known position and direction, force-render one frame, and read back actual canvas pixels along its predicted path — not a screenshot, exact byte values at exact coordinates.

t (base → tip)predicted fadepixel read
0.10≈ 0(0, 0, 0)
0.50≈ 0.09(0, 0, 0)
0.75≈ 0.63(0, 0, 0)
0.90≈ 0.93(0, 0, 0)
1.00 (tip / cap)(255, 255, 255)

Expected: brightness ramping smoothly upward with t. Actual: pure black along the entire body, then a hard jump to white at the cap.

Zero pixels along the entire body, for a line that should have been almost fully bright by 90% of the way to its tip. Only the separately-drawn end-cap survived. That’s not a fade problem, that’s a quad that never rasterizes anything.

The cause: LineSim.draw() enables blending and disables depth-test with raw gl.* calls, but never touches GL_CULL_FACE. Three’s material system enables face culling by default for any non-DoubleSide material, including the fade and blit quads rendered immediately before and after this raw draw call, and that GL state is sticky. The line body’s local basis rotates with the endpoint vector’s own direction, so its screen-space winding order depends on which way the line is pointing; the end-cap is a fixed, un-rotated square, so its winding never changes. With culling silently left on, the cap always survives and the body never does, for every line, in every direction.

gl.enable(gl.BLEND);
gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE, gl.ONE, gl.ONE);
gl.disable(gl.DEPTH_TEST);
gl.depthMask(false);
gl.disable(gl.CULL_FACE);   //  ←  the actual fix, one line

Re-running the exact injection test afterward matched the predicted smoothstep fade curve almost to the byte at every sample point. Force-rendering the real, un-injected simulation produced the dense, coherently-swirling field the reference had shown from the start.

Timeline

StatusPhase
Fixed, small effectGrid layout ported from the wrong crate — legacy stretch-to-fill swapped for the live crate’s centered lattice
Fixed, small effectNoise field was missing its time-varying “breathing” scale — a real divergence, closed, but didn’t move the energy band
Confirmed matchingTexture wrap mode, boundary conditions, timestep bookkeeping — byte-for-byte against the live crate’s own source
Confirmed matchingEvery settings field, Elm UI through to Rust default — identical
Theory disprovedReference’s own instrumented velocity field measured directly — statistically identical to the port’s
Theory disprovedLine-length buffers compared, after proving the scale-factor calibration — averages agree within chaotic-system noise
Ruled outWidth/opacity boost and per-line direction stability — port’s width was higher, not lower; direction drift was smooth, not erratic
Fixed, insufficient aloneTrail persistence found silently disabled — missing transparent: true
Root causePixel-injection test: the line body rasterized nothing — GL_CULL_FACE left enabled by Three.js, culling every direction-dependent line-body quad

Lessons

Re-verifying your own formulas isn’t the same experiment as checking the other side’s actual runtime state. A theory that “explains” a symptom isn’t confirmed until it’s measured against the thing it’s a theory about — if your checks structurally can’t reach the other side, they can only fail to falsify, never confirm.

The bigger unlock here wasn’t a smarter guess, it was instrumenting both sides symmetrically before trusting a comparison. The whole investigation was source-reading the port against the reference until the reference itself got a debug API.

Equivalences need proving before raw numbers get compared. Whenever two implementations are allowed to store the same idea in different units or at different pipeline stages, the scale-factor mixup here would have produced a confident, entirely fabricated 60% “gap” if nobody had checked.

Clean negative results are real progress, not wasted effort. Ruling out length, width, and direction stability is precisely what earned the right to stop suspecting the simulation and start suspecting the renderer.

A sharp human observation of the actual artifact can outperform a lot of statistics. Nobody derived “only the cap renders” from a spreadsheet — someone looked at the screen and said what they saw.

Synthetic test cases isolate what a natural simulation hides. The natural simulation is noisy, chaotic, and small in magnitude, exactly the conditions that hide a total rendering failure behind “maybe it’s just faint.” One artificial, oversized, known-direction test line made the failure impossible to misread.

And sandboxed automation is not a neutral observer. A frozen render loop looks exactly like a broken app. If your test environment’s timing can’t be trusted, stop trusting it — drive the system directly instead of waiting for it to schedule itself.

Field Notes for Doing This Work With an AI Agent

Several of the failure modes above are specifically agent failure modes, the kind of thing that’s easy to fall into when you can generate a plausible-sounding hypothesis and a confirming test for it in the same breath. A few habits that changed the outcome here:

Don’t just re-cite a prior session’s “confirmed matching.” Re-derive it independently when the stakes justify it — the lineLength-baking equivalence in this investigation had been asserted for sessions before being algebraically proven in this one.

If a comparison only has instrumentation on one side, that’s the next thing to build, not the conclusion to accept. A one-sided diagnostic can only rule out bugs in the thing you can see.

When a technique stalls, stop retrying it and switch. Repeated screenshot comparisons across a rAF-frozen tab wasted real effort before forced-stepping replaced it entirely — recognize the loop and change tools rather than grinding the same failing action.

Test through the same code path production uses, not a parallel reimplementation built for the test. Extracting stepSimulation()/renderFrame() out of the real render loop, rather than writing bespoke test-only stepping logic, is what made the forced-stepping results trustworthy.

Prefer an exact number over an eyeballed screenshot whenever a hypothesis needs confirming, but don’t discount the screenshot for generating the hypothesis in the first place. Visual pattern-matching and quantitative verification are different tools for different steps.

Label debug-only code unmistakably, and keep it. Every instrumentation method added this session is a real, reusable diagnostic, clearly commented as debug-only, cheap to leave in, expensive to rebuild from scratch next time something looks wrong.

Notes for a WebGL Project Like This One

Every raw-GL escape hatch inside a framework-managed renderer needs to set all the state it depends on, not just the state you remember changing. GL is one big global machine; Three.js’s cache doesn’t know about your calls, and your code shouldn’t assume its defaults either. Set blend, depth, and cull state explicitly at the top of every raw draw sequence.

blending without transparent: true is silently ignored. It’s a one-line trap with no console warning, and it looks like your shader math is wrong when the actual issue is that the blend mode never applied.

Call renderer.resetState() after any raw-GL section, so Three’s own state cache doesn’t skip a call downstream because it thinks its last known state is still current. This project already did that correctly — it was the state going into the raw section that needed the same discipline.

Direction-dependent geometry, anything whose local basis rotates with a runtime vector, needs backface culling explicitly considered, not inherited from whatever the previous draw call happened to leave enabled.

Build the GPU-state debug hooks early, not when you’re desperate. A getVelocityStats()/getStats() pair that reads real buffers back to the CPU costs almost nothing to add during the initial build and is the single most valuable tool once something looks wrong months later.

When porting a formula that gets rescaled at a different pipeline stage than the original, write down the equivalence proof, not just a comment saying “this should be the same.” It’s cheap to get wrong and expensive to un-trust later.

Night Current is a Scratch project. sandydoo/flux is MIT licensed.