▶ YouTube Summaries

← All summaries

Detaching the FFT balls in Sowon2, and a four-hour fight with raylib's DrawTexturePro

2026-09-25 Fri ⏱ 3 hr 13 min tsodingdaily

A four-hour Jai session on Sowon2, Tsoding's own music player. The FFT visualizer - a row of stems with glowing balls on top, each ball trailing a smear - looks dead when the song is paused: everything just collapses. The goal for the stream is that pausing should detach the balls from their stems, let them float and repel each other in open space, and unpausing should send them flying back onto their stems. A Rust prototype of the free-floating blob animation already exists; the stream is about porting it into the Jai/raylib codebase.

The rendering trick behind the visualizer

The glow is entirely smoke and mirrors. Ball and smear are the same circle texture drawn through the same fragment shader, with two parameters: an inner radius (a percentage of the texture) and a power that controls how tightly the falloff concentrates - low power spreads the glow out, high power tightens it into a bloom. The smear is literally half of that same circle, stretched along the direction of travel. Colouring the smear differently for a moment shows how thin the illusion is: the moment ball and trail don't share a colour, the blob effect evaporates.

The existing code implemented this inline, with a stack-allocated scratch variable Tsoding had named after his conclusion that Jai only forbids passing small r-values by pointer because Blow didn't bother implementing it. The first refactor of the stream pulls the drawing into fft_glowing_ball (center, radius, inner radius, power, colour) - which incidentally makes the scratch variable unnecessary, since parameters already live on the stack - and later a matching fft_glowing_smear taking a start and end position. He also adds a debug frame config flag that draws rectangle outlines around what's being rendered; it turns out to be the thing that eventually cracks the bug.

A side note on batching: raylib's SetShader flushes the current batch, so the extra draw calls can't be batched. He starts to worry about it, then catches himself - it's 60 FPS with a handful of balls. "I'm not writing Crysis, calm down."

The raylib fight

The port doesn't port. The existing smear code only handled vertical motion (it inspected X or Y separately and swapped texture regions based on sign), so two-dimensional smears need the rotation path of DrawTexturePro - and Tsoding cannot get it to behave. He redraws the geometry on a whiteboard and verifies every piece by hand: width is two radii, height is the distance between start and end, the destination is offset by half the width, the angle is atan2 of the delta converted to degrees. All correct. It still renders garbage.

The breakthrough comes from the debug frame: the origin parameter displaces the sprite even when rotation is zero. raylib's docs ("origin is relative to the destination rectangle size") and its own official example don't explain this, and reading the source reveals the usual pile of special cases - including a branch where a zero width silently flips the texture. Tsoding's mental model is Photoshop's pivot: moving the pivot shouldn't move the object. raylib disagrees. Chat's attempts to explain it as sprite-style positioning don't land either, since the origin lives in texture coordinates rather than world coordinates.

He eventually builds an isolated test app just to characterise the function, and lands on a compensation: add the origin back into the destination position, then add 90 degrees to the rotation. It works. He's explicit that he still doesn't understand why, and files it under "the API is broken, here's the trick to unbreak it." The wider point he draws: an API is a liability - once published you support it forever, so raylib can't fix this without a second function. This is the second time this same function has made him rage on stream.

With the smear working in arbitrary directions, the original vertical-only smear code deletes cleanly and both render paths collapse onto the shared functions.

(Aside: a viewer once told him the visualization looks like a slime mold, so the official name of the effect is now "glowing slime mold.")

Detaching, repelling, reattaching

The first design has fft_detach_balls append balls into a separate dynamic array rendered independently, with a display_balls flag to suppress the stem-mounted versions. Each detached ball gets a center, a smear position that lags behind it (the smear chases the center at a fixed speed - around 15 - which is what produces the trail), and a velocity initialised to the smear-to-ball delta, damped by multiplying by ~0.95 each frame.

Repulsion is the naive O(n²) pass over unordered pairs - fine at this n. For each pair closer than a minimum distance, both get a velocity contribution along the normalized separation, scaled so that repulsion is strongest at zero distance and vanishes at the threshold. It does nothing for a long stretch, and the bug is exactly the kind he predicted it would be: he'd written max where the falloff needed min, so the factor was pinned at one. Chat finds it. He'd also named the constant max_ball_distance when it's a minimum - a mistake he notes nobody in chat flagged either.

Halfway through he rearchitects: the separate balls array means constantly shuttling state between two entities, so the balls move inside the FFT struct as a parallel array indexed like the buckets. That kills the display_balls flag too - a ball simply isn't drawn on its stem if it's detached. And the boolean detached becomes a three-state enum - attached, detached, attaching - because returning takes time: an attaching ball chases its stem tip, and flips to attached once within an epsilon distance. Because the balls are a parallel array, the colour field can be dropped entirely and read from the palette instead, which means switching songs while paused also recolours the floating balls.

The result works: pause and the balls shoot off, spread apart, and drift; unpause and they snap back onto their stems. Roughly four hours for something Tsoding notes required a real restructuring of the FFT code, not just an addition. Left for later: containing the balls within the lower region of the screen, polar-coordinate noise to make them wander (a first attempt was far too jittery and needs a slower flip rate), and retracting the smears faster on the way back, since long trails look wrong during reattachment.