Turning a WebGL canvas (including shaders) into a clean, looping GIF is mostly about capturing frames reliably and keeping your animation deterministic. This guide is for anyone building shader sketches, demos, or small WebGL scenes who wants a repeatable export workflow that looks the same on every machine.
Why GIF export is “different” from screen recording
A normal screen recording captures whatever your GPU happens to draw in real time, including dropped frames, variable frame pacing, and timing jitter. GIF export works best when you capture frames one-by-one at a fixed frame rate, then encode those frames into an animation. That approach gives you:
- Stable timing (e.g., exactly 60 frames = exactly 2 seconds at 30 FPS)
- Perfect loops (frame 0 matches the final frame’s state)
- Repeatability (the same input produces the same output)
Two practical approaches
1) Frame-by-frame capture in the browser
This is usually the simplest route for WebGL sketches. A popular option is CCapture.js for frame-by-frame canvas capture, which hooks into your render loop and collects frames at a fixed rate. You then encode those frames (often to WebM first, or directly to GIF depending on your setup).
2) Export frames (PNG sequence) and encode offline
If you need maximum quality, higher resolutions, or long animations, exporting a PNG sequence and encoding with a command-line encoder is the most robust method. It’s also the best choice when your scene is heavy and can’t reliably render at real time without dropped frames.
Make your animation deterministic (the secret to clean GIFs)
Most WebGL demos animate with “wall clock” time (e.g., seconds since page load). That’s fine for interactive viewing, but it can break export consistency. For exports, you want a fixed delta time:
- Pick a target FPS (common: 24, 30, 60).
- Advance your animation by
1 / FPSseconds for each captured frame. - Render exactly
durationSeconds * FPSframes.
If you’re using a shader with a time uniform, avoid passing “real” time during capture. Instead, compute:
// Example: deterministic time per frame
const fps = 30;
const durationSeconds = 4;
const totalFrames = fps * durationSeconds;
function timeForFrame(frameIndex) {
return frameIndex / fps; // 0.000, 0.033, 0.066, ...
}
This prevents small drift that can cause visible stutter or a loop seam.
WebGL canvas settings that affect capture
Preserve the drawing buffer (when you need to read pixels)
Some capture methods rely on reading the current canvas pixels after drawing. Many WebGL contexts clear or recycle buffers after presenting a frame, which can lead to blank frames or inconsistent captures. If you’re seeing black/empty frames during capture, create your context with:
const gl = canvas.getContext("webgl2", {
alpha: true,
antialias: true,
preserveDrawingBuffer: true
});
Trade-off: preserveDrawingBuffer can reduce performance. Use it for export mode, not necessarily for normal interactive playback.
Match export size to the final GIF use
GIF files get large quickly. A good starting point for shader GIFs is 480–720px wide. If you export at 1920×1080, the file size and encoding time will spike, and dithering artifacts can become more noticeable. Choose a resolution that matches where you’ll share the GIF.
Export with CCapture.js (frame-by-frame workflow)
Below is a practical pattern that works well for WebGL exports: switch into an “export mode”, render a fixed number of frames, and let CCapture collect them.
Step 1: Include CCapture.js
You can bundle it in your build pipeline, or include it via a local copy. For reference and installation options, see the project repo: CCapture.js on GitHub.
Step 2: Capture a fixed number of frames
// Basic CCapture-driven export loop (conceptual example)
const fps = 30;
const durationSeconds = 4;
const totalFrames = fps * durationSeconds;
const capturer = new CCapture({
format: "gif",
framerate: fps,
// Some setups support quality/workers; exact options vary by build.
});
function renderAtTime(tSeconds) {
// Update uniforms and draw your scene.
// For shaders: setUniform("time", tSeconds); draw();
}
async function exportGif() {
capturer.start();
for (let frame = 0; frame < totalFrames; frame++) {
const t = frame / fps;
renderAtTime(t);
// Capture AFTER drawing the frame
capturer.capture(canvas);
}
capturer.stop();
capturer.save(); // triggers download in most browsers
}
Notes:
- During export, don’t use
requestAnimationFrametiming to advance the simulation. You can still draw in a loop, but time should come from the frame index. - If your scene depends on input (mouse, audio, random), freeze or seed those values for consistency.
- If you rely on multi-pass rendering or ping-pong buffers, make sure your “reset” state is repeatable so the loop closes cleanly.
Step 3: Ensure the loop closes
A visually perfect loop usually means the animation state at the last frame equals the first frame. You can accomplish that by designing your motion around a period:
- Use periodic functions:
sin/coswith a known cycle. - Map time into
[0..1)and wrap it:t = fract(time / loopSeconds). - If your shader uses noise that evolves over time, use a looping noise technique or blend between two states.
For example, if your loop duration is 4 seconds, you can compute a loop phase:
// In JS, pass phase instead of raw time
const loopSeconds = 4;
const phase = (tSeconds % loopSeconds) / loopSeconds; // 0..1
// In GLSL you can use phase to drive periodic animation deterministically.
Common issues and fixes
Problem: Captured frames are black or transparent
- Try enabling
preserveDrawingBuffer: truefor export mode. - Make sure you’re capturing after your draw call, not before.
- If you render to an offscreen framebuffer, ensure the final composited image is drawn to the visible canvas before capture.
Problem: The export stutters or skips motion
- Don’t advance time using real time. Use
frame / fps. - Avoid logic that depends on actual frame duration (e.g., accumulating
deltaTimefrom RAF). - Disable expensive post-processing during export, or lower resolution for capture.
Problem: The GIF looks “grainy” or colours band badly
GIF is limited to 256 colours per frame, so gradients and smooth lighting can show banding. You can reduce the impact by:
- Exporting at a slightly higher resolution then downscaling for sharing (carefully).
- Reducing ultra-smooth gradients (add subtle texture/noise intentionally).
- Testing a lower FPS with longer exposure per frame (sometimes looks smoother with fewer frames).
Problem: The file is huge
- Lower the resolution first (biggest impact).
- Lower FPS (second biggest impact).
- Shorten duration, or crop to the area that matters.
Quality checklist before you publish
- Loop seam check: does the last frame blend into the first?
- Speed check: does it look correct at 24/30 FPS?
- Colour check: do gradients survive GIF quantisation?
- Size check: is the file small enough for your target platform?
