Finishing Phase 7

Video playback wrapped up the big user-facing features of Phase 7. But the spec had six nodes I still owed, the texture engine had a couple of leaks I’d been suspicious of, and the pool was doing a bunch of work on every frame that it didn’t need to. Cleanup episode.

Six missing nodes

All six were in the spec from day one, I kept skipping past them because the filters I had were enough to demo with. They weren’t enough to ship.

Displacement: warps pixels by sampling an RG offset map. The red channel shifts X, green shifts Y, amplitude scales both. Feed it a NoiseTexture and you get organic liquid distortion. Feed it a gradient and you get controlled push/pull. One of those nodes that feels infinite.

LUT. 1D color lookup table for grading. Takes a strip texture (256 wide, 1 tall) and remaps each input colour through it. This is how every colour grading tool in the world works, and now it’s one node.

FXAA. Fast approximate anti-aliasing. Full FXAA 3.11 in a single fragment shader. Samples the 3×3 neighbourhood, computes luminance contrast, and blends along detected edges. Not as good as MSAA, but MSAA doesn’t work on arbitrary texture pipelines. FXAA does.

Glitch: digital corruption effect. Horizontal block displacement plus RGB channel split. The amount parameter drives both, seed controls the pattern. Wire an LFO to it and your output periodically falls apart, which is exactly what you want.

ShaderToy: a compatibility shim for the custom shader node. Exposes iTime, iResolution, and fragCoord the way ShaderToy does, so you can paste in shaders from the site with minimal edits. GLSL → WGSL is still on you, but the uniform conventions are handled.

LoadImageSequence: numbered image files as an animated texture. Point it at frame_0000.png and it loads the whole sequence. Play, pause, loop, variable rate, same controls as VideoPlayer, but decoded from stills. Useful when you need frame-accurate sprite animation or when FFmpeg isn’t in the build.

That’s 6 new nodes, 4 new shaders, one CPU sprite loader, and the spec’s Phase 7 column is finally all checkmarks.

The VideoPlayer leak

VideoPlayer had a bug I’d been unconsciously ignoring: every time it uploaded a new decoded frame, it allocated a fresh GPU texture and let the previous one float. The pool would eventually reclaim them, but “eventually” at 30fps means hundreds of orphaned textures before GC catches up. Memory would climb, slowly but forever.

The fix is embarrassing in hindsight: free the previous texture before uploading the new frame. One line. Now VideoPlayer holds exactly one texture at a time, and the pool reuses the same memory for every decoded frame. Playback at steady state does zero GPU allocations.

The RenderTarget leak

Same shape of bug in RenderTarget. Every frame, it was rasterising its vector layer into a new texture and never explicitly freeing the old one. Exactly the same fix. Same one-line change.

Both leaks share a root cause: nodes that re-upload or re-render every frame need to own their output texture and reuse the handle, not allocate a fresh one each time. Now they both do, I added multi-frame allocation tests that fail if a node’s handle count grows unbounded; these would have caught both bugs on the first frame of the second iteration.

Texture engine cleanup

A handful of smaller fixes in the texture engine itself, the kind that don’t show up in a demo but matter the moment a real patch hits them:

  • Uniform buffer alignment. WGSL follows std140 rules: vec2 needs 8-byte alignment, vec3 and vec4 need 16. The engine was packing uniforms tightly and the GPU was silently reading garbage in the padding. Fixed by padding each field to its required alignment.

  • Fallback texture for missing handles. If a node references a texture handle that got freed between frames, the bind group creation used to crash. Now it falls back to a 1×1 transparent texture and logs a warning. Graceful degradation instead of panic.

  • Storage binding only where supported. Not every texture format supports STORAGE_BINDING. Depth32Float, R8, and Rg8 don’t. The engine was adding the usage flag unconditionally and wgpu was rejecting the texture creation. Fixed by gating the flag on the format’s capabilities.

  • No more unwrap() in readback_texture_sync. Two unwraps in a row on code that runs during export. Replaced with proper error handling. One more panic site gone.

  • Texture cache stays alive between frames. The engine was clearing its descriptor cache every frame, which meant every texture handle lookup triggered a fresh GPU query. Cache is now persistent and invalidated on free. Eliminates a per-frame readback stall I didn’t know I had.

Where Phase 7 landed

Phase 7 started with a GPU texture pipeline and a pool. Eight sessions later, it’s 40 nodes, 30+ WGSL shaders, video playback, custom user shaders, image export, feedback loops, and every filter I could think of. The pool leaks nothing. The engine allocates nothing per frame. The spec has no unchecked boxes left.

Time to go build something with it.

← Back to devlog