PPM Wave Shader Jam
• weekend experiment
Why I did this
Earlier this week I watched Tsoding run through a quick PPM demo. He draws a red square, turns it into a checkerboard, feeds the frames to ffmpeg, and eventually swaps in a GLSL shader for the animation. It’s worth the watch. I just wanted to follow the same trail in JavaScript instead of C.
PPM in a nutshell
The format is tiny. Jef Poskanzer built PBM in the 1980s for email-friendly bitmaps, added PGM/PPM in 1988’s Pbmplus release, and the Netpbm project took over in 1993 to keep the family alive.
Keeping it this stripped-down still helps: you can stream a PPM file directly to standard output, feed it into ffmpeg or convert, and see every byte your code produced. It’s great for teaching, debugging shader math, or patching together quick render pipelines where transparency, animation, and color profiles aren’t required.
Porting the steps to JavaScript
I copied the same order:
- Red frame test. 16×9 tiles at 60 pixels each with every pixel written as
0xff,0x00,0x00. - Checkerboard. A little integer division (
(x / size + y / size) % 2) gets you alternating tiles. - Sliding pattern. Emit a bunch of frames, move the checkerboard phase a bit each time, and drop them into a folder.
#!/usr/bin/env node const fs = require("fs"); const path = require("path");const FRAMES = 60; const TILE = 60; const WIDTH = 16 * TILE; const HEIGHT = 9 * TILE;
function writeFrame(frameIndex) { const frame = frameIndex.toString().padStart(2, "0"); const filename = path.resolve(
output-${frame}.ppm); const fd = fs.openSync(filename, "w");// Write PPM header fs.writeSync(fd, Buffer.from(
P6\n${WIDTH} ${HEIGHT}\n255\n, "ascii"));const row = Buffer.alloc(WIDTH * 3); for (let y = 0; y < HEIGHT; y++) { for (let x = 0; x < WIDTH; x++) { const checker = (Math.floor((x + frameIndex) / TILE) + Math.floor((y + frameIndex) / TILE)) % 2;
const offset = x * 3; row[offset] = checker ? 0xff : 0x00; row[offset + 1] = 0x00; row[offset + 2] = 0x00; } fs.writeSync(fd, row);}
fs.closeSync(fd); console.log(
Generated ${filename}); }function main() { for (let i = 0; i < FRAMES; i++) { writeFrame(i); } }
main();
Once the frames finish generation, ffmpeg will happily take them. Point it at a numbered pattern and it treats each file as a frame, so stitching an animation is a one-liner; no extra container work required because PPM files already carry width, height, and color depth in their headers:
ffmpeg -framerate 30 -i frame-%03d.ppm -c:v libvpx-vp9 -pix_fmt yuv420p checker.webm
Swapping in a shader
I searched for “wood shader” and settled on this fragment. After pulling its noise, rotation, and line helpers into JavaScript, I started messing with the math—rotating the coords, shifting some sine inputs, adding a bit of noise—until it drifted away from clean wood rings and into the wavy look the rest of the project had going.
Result
The first pass drifts into a smooth black-and-white wave. Six hundred frames go by fast when everything is just sine curves and noise.
After watching it for a while I missed the circular grain, so I brought the rotation math back, eased off the wobble, and left just a little jitter on x. The second render keeps more of the wood streaks while still wiggling.
For the last pass I layered in a modern palette (lifted from the light-and-color notes) and let the domain warps push the wood lines into tighter wave interference.
What’s next
This was just for fun, but the pipeline is ready for other shaders. I might try a CRT mask, or maybe a fake watercolor wash. Either way it’s nice to remember that a weekend project can be a single plain script and a pile of bytes.