← Blog
·8 min read·I Love Sprites Team·Three.js

Animating Sprite Sheets in Three.js: The UV Window Trick, Frame Timing, and Filters

Don't swap sixty textures — slide one. How to play sprite sheet animations in Three.js with texture repeat and offset, avoid the bottom-left UV origin trap, keep timing stable across refresh rates, and choose between Sprite billboards and plane meshes.

Sprite sheet animation in Three.js — the UV window One texture, one material — repeat scales the window to a frame, offset slides it around the sheet The sheet: 4 columns × 4 rows frame 5 → column 1, row 1 UV origin is bottom-left — count rows from the bottom The three lines that do the work repeat.set(1/COLS, 1/ROWS) Shrinks the visible window to one frame offset.x = col / COLS Slides the window horizontally offset.y = 1 - (row+1) / ROWS Slides it vertically, bottom-left origin Pixel art? Two more lines: magFilter = NearestFilter colorSpace = SRGBColorSpace No smearing, correct colors Why this beats one-texture-per-frame One texture upload, one material, zero texture switching while the animation plays — the GPU never changes state, you just move two numbers per frame ilovesprites.com — make the sheet from any video or GIF in your browser

Three.js is a 3D library, but a huge share of real projects — web games, product configurators, portfolio pieces — need 2D animation inside the 3D scene: a character, a coin spin, an explosion, an animated UI element. The wrong way is loading sixty PNGs and swapping material.map every frame. The right way is one sprite sheet, one texture, and two numbers that move. This post covers the full setup: loading, filtering, the UV window trick, frame timing that doesn't drift, and the choice between Sprite and a plane mesh.

Why a Sheet Beats Sixty Textures

Every texture you swap onto a material is GPU state the renderer has to change, and sixty separate images means sixty uploads, sixty decode hits at load time, and a texture bind every time the frame advances. A sprite sheet inverts that: one upload, one bind, and animation becomes arithmetic on the texture's repeat and offset — which costs essentially nothing. If you don't have a sheet yet, the Video to Sprite Sheet tool converts any clip or GIF into a grid sheet in the browser, and the Sprite Sheet Splitter goes the other way when you need to re-cut an existing one.

Loading the Sheet Correctly

Three lines of setup prevent the three most common visual bugs — washed-out colors, smeared pixel art, and blurry frames:

const tex = new THREE.TextureLoader().load("run-cycle.png");
tex.colorSpace = THREE.SRGBColorSpace; // correct colors
tex.magFilter = THREE.NearestFilter;   // pixel art: crisp, not smeared
tex.repeat.set(1 / COLS, 1 / ROWS);    // show exactly one frame

colorSpace tells Three.js the image is sRGB (every PNG you exported from an art tool is); without it the renderer treats color data as linear and everything looks pale. NearestFilter matters for pixel art only — for high-resolution painted frames, keep the default linear filtering. And repeat is the trick that makes the whole technique work: it shrinks the visible UV window to exactly one cell of your grid.

The UV Window

With the window sized to one frame, animating is just sliding it around the sheet:

function setFrame(i) {
  const col = i % COLS;
  const row = Math.floor(i / COLS);
  tex.offset.x = col / COLS;
  tex.offset.y = 1 - (row + 1) / ROWS;
}

The offset.y line is where everyone gets bitten once: UV space in WebGL has its origin at the bottom-left, while you almost certainly numbered your frames from the top-left of the image. The 1 - (row + 1) / ROWS conversion walks rows from the top the way your sheet is actually laid out. If your animation plays in the wrong row order, this line is the suspect.

Frame Timing That Doesn't Drift

Advancing the frame every render tick locks your animation speed to the viewer's refresh rate — a 144 Hz monitor plays your run cycle at 2.4× speed. Use an accumulator driven by delta time instead:

let acc = 0, frame = 0;
const FRAME_TIME = 1 / 12; // 12 fps animation

function update(dt) {
  acc += dt;
  while (acc >= FRAME_TIME) {
    acc -= FRAME_TIME;
    frame = (frame + 1) % FRAME_COUNT;
    setFrame(frame);
  }
}

The while loop (rather than an if) keeps long frames honest: if the tab stutters for 100 ms, the animation catches up instead of slowing down. Twelve to fifteen animation FPS is the sweet spot for most game sprites — sheets exported at that rate are also a quarter the size of 60 FPS ones.

Sprite or Plane?

Three.js gives you two hosts for the texture. THREE.Sprite with a SpriteMaterial is a billboard — it always faces the camera, which is what you want for particles, pickups, and enemies in a 2.5D scene. A PlaneGeometry with MeshBasicMaterial stays fixed in world space — right for posters on walls, animated screens inside the scene, or fully 2D games with an orthographic camera. Both use the same texture and the same offset trick; transparent frames need transparent: true on the material, and if you see dark fringes around edges, your sheet's frames need padding — regenerate with a couple of pixels of transparent padding between cells.

Performance Notes From Production

Keep sheet dimensions power-of-two (1024, 2048) — safest across devices and required by compressed texture formats if you graduate to them later. If one character needs more frames than fits a 2048 sheet comfortably, split animations across sheets by state (idle, run, attack) rather than one giant sheet: you'll usually only bind one state at a time anyway. And when many objects share one animation, don't clone the texture per object — share it, and if objects need independent playback positions, clone the texture but not the image (tex.clone() reuses the GPU upload in recent Three.js releases as long as the source image is shared). For a scene full of independent animated sprites, the real answer is an atlas and instancing — which is the next post in this series.