How to Make a Perfect Loop

Looping shader animations feel “effortless” when the first and last frame match exactly – not just visually, but mathematically. This guide is for anyone making GLSL/WebGL animations (especially for GIF exports) who wants seamless, repeatable motion without a visible hitch at the loop point.

What “perfect loop” actually means

A perfect loop happens when your rendered output is identical at t = 0 and t = 1 (or at the start and end of your loop duration). In practice, that means every time-dependent value in your shader must be periodic and align on the same phase at the loop boundary.

If any part of your motion is non-periodic (e.g., accumulating offsets, drifting noise, or easing that doesn’t return to the same state), you’ll get a jump.

Step 1: Normalise time into a loop phase

Start by turning “seconds since start” into a phase that always fits inside one loop cycle. A common pattern is:

// seconds: real time in seconds
// period: loop length in seconds (e.g. 2.0, 4.0, 6.0)
float phase(float seconds, float period){
    return fract(seconds / period); // 0..1 repeating
}

From here on, try to build your animation from p (phase) rather than raw time. Phase is stable, bounded, and repeatable.

Step 2: Prefer circular time (sin/cos) over linear ramps

The easiest way to guarantee continuity is to use trigonometric functions. If you convert phase into an angle, you get smooth, loop-safe motion:


#define TAU 6.28318530718

float p = phase(time, 4.0);      // 4-second loop
float a = p * TAU;               // 0..2π

vec2 orbit = vec2(cos(a), sin(a)); // returns to same value at loop end

This works beautifully for: orbital motion, breathing/pulsing, waving, rotations, colour cycling, and camera motion.

Common pitfall: drifting values

Avoid logic like pos += velocity * time unless you wrap it. Linear accumulation creates a discontinuity when phase resets.

If you need motion that “travels” but loops, wrap the travel distance using mod or use circular coordinates (e.g. rotate around a centre, or move along a closed curve).

Step 3: Use loop-safe waveforms (triangle, saw, ping-pong)

Sometimes you want motion that moves back and forth rather than around in a circle. You can still do this with phase-safe waveforms.

Triangle wave


// 0..1..0 over one cycle
float tri(float p){
    return 1.0 - abs(2.0 * p - 1.0);
}

Ping-pong motion (smooth)

A triangle wave gives the right shape, but its slope changes abruptly at the turning points. If you want softer motion, ease the triangle wave:


float easeInOut(float x){
    // smoothstep-like curve, 0..1
    return x * x * (3.0 - 2.0 * x);
}

float pingpong(float p){
    float t = 1.0 - abs(2.0 * p - 1.0); // triangle 0..1..0
    return easeInOut(t);                // eased triangle
}

Use this for: oscillating offsets, bouncing shapes, scanlines, and any animation that should “turn around” cleanly.

Step 4: Make noise loop (the part that usually breaks)

Noise is the #1 reason loops show a hitch. Many noise functions are continuous in time, but not periodic – meaning noise(t) won’t match noise(t + period).

To make noise loop, you need to either:

  • Use a periodic noise function (tileable noise), or
  • Map time onto a circle and sample noise in 2D/3D space so the path closes.

Technique A: “Time on a circle” noise sampling

Instead of sampling noise at time, sample noise along a circular trajectory in an extra dimension:


#define TAU 6.28318530718

// p: 0..1 loop phase
vec2 loopVec(float p){
    float a = p * TAU;
    return vec2(cos(a), sin(a));
}

// Example usage: treat loopVec(p) as a moving offset
vec2 q = uv * 3.0 + loopVec(p) * 0.5;

// Then feed q into your favourite 2D noise / hash-based pattern

This works even if your underlying noise isn’t “tileable”, because your sampling path is closed – so the start and end sample point are the same.

Technique B: Periodic domain wrapping

If your pattern is based on a grid (cells/tiles), you can often wrap indices with mod and keep everything repeatable. This is especially effective for repeating textures, cellular patterns, and “infinite” tile maps.

If you want to go deeper into how domain manipulation affects patterns and sampling, this chapter on texture coordinates and sampling is a useful companion: The Book of Shaders – working with textures and coordinates.

Step 5: Ensure any rotation returns to the same orientation

Rotations are loop-friendly if the angle completes an integer number of turns over the period. If you want half a turn, that’s fine – as long as it lands exactly on the same end state you intend.


#define TAU 6.28318530718

mat2 rot(float a){
    float c = cos(a), s = sin(a);
    return mat2(c, -s, s, c);
}

float p = phase(time, 3.0);      // 3-second loop
float turns = 1.0;               // 1 full rotation each loop
float ang = p * TAU * turns;

vec2 centred = uv - 0.5;
centred = rot(ang) * centred;

Tip: if you’re exporting a GIF and want the loop to feel “calm”, try smaller motion with more frequent repeats (e.g., subtle rotation + subtle pulsing) instead of large sweeps.

Step 6: Lock loop duration to frames when exporting GIF

Even if your shader is mathematically loop-perfect, your export can introduce a stutter if the frame sampling doesn’t align with the period.

A reliable approach for GIF exports is to render exactly N frames for one loop, and compute time from the frame index:


// pseudo-logic (done in JS when rendering frames):
// for i in 0..N-1:
//   t = i / N * period

// Important: do NOT render frame i == N (it equals the first frame)

Key points:

  • Choose a period that divides nicely into your frame count (e.g., 2s at 30fps = 60 frames).
  • Render frames 0..N-1, not 0..N, otherwise you duplicate the first frame and the loop “sticks”.
  • Keep easing consistent: if you apply easing in JavaScript and also in GLSL, you can unintentionally change the phase and introduce mismatch.

Worked example: a simple loopable shader “pulse + orbit”

This example combines three loop-safe ideas: circular time, a pulse, and a repeating pattern. It’s intentionally minimal so you can adapt it to your own scenes.


#ifdef GL_ES
precision highp float;
#endif

#define TAU 6.28318530718

uniform vec2  resolution;
uniform float time;

float phase(float seconds, float period){
    return fract(seconds / period);
}

float ease(float x){
    return x * x * (3.0 - 2.0 * x);
}

void main(){
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    uv.x *= resolution.x / resolution.y;

    float p = phase(time, 4.0);
    float a = p * TAU;

    // loop-safe orbit
    vec2 wobble = vec2(cos(a), sin(a)) * 0.15;

    // loop-safe pulse (0..1..0, eased)
    float t = 1.0 - abs(2.0 * p - 1.0);
    float pulse = ease(t);

    // pattern coordinates
    vec2 q = (uv - 0.5) + wobble;
    float d = length(q);

    // rings + pulse
    float rings = sin((d * 18.0 - pulse * 3.0) * TAU);
    float shade = 0.5 + 0.5 * rings;

    // soft vignette
    shade *= smoothstep(0.9, 0.2, d);

    gl_FragColor = vec4(vec3(shade), 1.0);
}

Why it loops:

  • cos/sin return to the same values at the end of the cycle.
  • The pulse is built from phase and is symmetric over the loop.
  • No values accumulate over time; everything is derived from p.

Troubleshooting: why your loop still “jumps”

The jump is subtle, like a tiny brightness change

  • Check any random/hash usage: if the random seed depends on time, it won’t loop.
  • Check noise sampling: convert time to a loop vector and sample along a closed path.
  • Check colour grading/post effects: small differences can look like a flicker at the seam.

The loop “sticks” briefly at the seam

  • You may be exporting N+1 frames (duplicating the first frame).
  • Your GIF encoder may be adding an unexpected delay on the last frame – verify per-frame timing.
  • If you’re capturing from a live preview, your frame pacing might be uneven; render deterministic frames instead.

The motion resets position (obvious jump)

  • Look for any use of raw time (seconds) instead of phase. Replace with fract(time / period).
  • Replace linear movement with circular movement, or wrap translation using mod.

If you’re also comparing coding assistants for writing and refining GLSL, shader demos, and front-end graphics code, check the best AI coding tools 2026 ranking artcile which breaks down the strongest options for real-world development workflows.

Internal links to continue learning