Skip to content
Back to Examples

Animation

Animate sprites using spritesheets with three-flatland.

import { WebGPURenderer } from 'three/webgpu'
import { Scene, OrthographicCamera, NearestFilter } from 'three'
import { AnimatedSprite2D, SpriteSheetLoader, Layers, createDevtoolsProvider } from 'three-flatland'
import { createPane } from '@three-flatland/devtools'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
/* HMR-tracked teardown state. Without this, every dev save accumulates
* a fresh renderer + animate() loop while the previous one keeps
* RAFing forever. Dev-only — `import.meta.hot` is undefined in prod. */
let rafId = 0
let activeRenderer: WebGPURenderer | null = null
async function main() {
const scene = new Scene()
;(scene as any).backgroundNode = gemGradientNode({ gem: GEM })
// Orthographic camera for 2D rendering
const frustumSize = 200
const aspect = window.innerWidth / window.innerHeight
const camera = new OrthographicCamera(
(-frustumSize * aspect) / 2,
(frustumSize * aspect) / 2,
frustumSize / 2,
-frustumSize / 2,
0.1,
1000
)
camera.position.z = 100
// WebGPU Renderer (required for TSL materials)
const renderer = new WebGPURenderer({ antialias: false })
activeRenderer = renderer
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setPixelRatio(1) // Pixel-perfect for pixel art
renderer.domElement.style.imageRendering = 'pixelated'
document.body.appendChild(renderer.domElement)
// Wait for renderer to initialize
await renderer.init()
// Load the knight spritesheet
const spriteSheet = await SpriteSheetLoader.load('./sprites/knight.json')
// Use nearest neighbor filtering for pixel art
spriteSheet.texture.minFilter = NearestFilter
spriteSheet.texture.magFilter = NearestFilter
// Create the animated sprite
const knight = new AnimatedSprite2D({
spriteSheet,
animationSet: {
fps: 10,
animations: {
idle: {
frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'],
fps: 8,
loop: true,
},
run: {
frames: [
'run_0',
'run_1',
'run_2',
'run_3',
'run_4',
'run_5',
'run_6',
'run_7',
'run_8',
'run_9',
'run_10',
'run_11',
'run_12',
'run_13',
'run_14',
'run_15',
],
fps: 12,
loop: true,
},
roll: {
frames: [
'roll_0',
'roll_1',
'roll_2',
'roll_3',
'roll_4',
'roll_5',
'roll_6',
'roll_7',
],
fps: 15,
loop: true,
},
hit: {
frames: ['hit_0', 'hit_1', 'hit_2', 'hit_3'],
fps: 10,
loop: false,
},
death: {
frames: [
'death_0',
'death_1',
'death_2',
'death_3',
],
fps: 8,
loop: false,
},
},
},
animation: 'idle',
layer: Layers.ENTITIES,
anchor: [0.5, 0.5],
})
// Scale up for visibility (pixel art is 16x16, scale 8x = 128px)
knight.scale.set(128, 128, 1)
knight.position.set(0, 0, 0)
scene.add(knight)
// Tweakpane UI
const { pane, update: updateDevtools } = createPane({ driver: 'manual' })
const devtools = createDevtoolsProvider({ name: 'animation' })
const animFolder = pane.addFolder({ title: 'Animation' })
const animNames = ['idle', 'run', 'roll', 'hit', 'death']
const animLabels = ['Idle', 'Run', 'Roll', 'Hit', 'Death']
const animGrid = animFolder.addBlade({
view: 'radiogrid',
groupName: 'animation',
size: [5, 1],
cells: (x: number) => ({ title: animLabels[x]!, value: animNames[x]! }),
value: 'idle',
label: 'anim',
} as any) as any
function playAnimation(name: string) {
knight.play(name, {
onComplete: () => {
if (name === 'hit' || name === 'death') {
animGrid.value.rawValue = 'idle'
knight.play('idle')
}
},
})
}
animGrid.on('change', (ev: any) => {
playAnimation(ev.value as string)
})
const speeds = [0.5, 1, 1.5, 2, 3]
const speedLabels = ['0.5x', '1x', '1.5x', '2x', '3x']
;(animFolder.addBlade({
view: 'radiogrid',
groupName: 'speed',
size: [5, 1],
cells: (x: number) => ({ title: speedLabels[x]!, value: speeds[x]! }),
value: 1,
label: 'speed',
} as any) as any).on('change', (ev: any) => {
knight.speed = ev.value
})
// Handle resize
window.addEventListener('resize', () => {
const aspect = window.innerWidth / window.innerHeight
camera.left = (-frustumSize * aspect) / 2
camera.right = (frustumSize * aspect) / 2
camera.top = frustumSize / 2
camera.bottom = -frustumSize / 2
camera.updateProjectionMatrix()
renderer.setSize(window.innerWidth, window.innerHeight)
})
// Animation loop
let lastTime = performance.now()
function animate() {
rafId = requestAnimationFrame(animate)
const now = performance.now()
const deltaMs = now - lastTime
lastTime = now
// Update sprite animation
knight.update(deltaMs)
devtools.beginFrame(performance.now(), renderer)
renderer.render(scene, camera)
devtools.endFrame(renderer)
updateDevtools()
}
animate()
}
main()
if (import.meta.hot) {
import.meta.hot.dispose(() => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = 0
}
if (activeRenderer) {
activeRenderer.dispose?.()
activeRenderer.domElement.remove()
activeRenderer = null
}
})
}
import { Suspense, useState, useRef, useCallback, useEffect, useLayoutEffect } from 'react'
import { Canvas, extend, useFrame, useThree, useLoader } from '@react-three/fiber/webgpu'
import type { OrthographicCamera as ThreeOrthographicCamera } from 'three'
import {
AnimatedSprite2D,
SpriteSheetLoader,
Layers,
type AnimationSetDefinition,
} from 'three-flatland/react'
import { DevtoolsProvider, usePane, usePaneFolder } from '@three-flatland/devtools/react'
import { GemBackground } from './GemBackground'
import { GEM } from './gem'
// Register AnimatedSprite2D with R3F (tree-shakeable)
extend({ AnimatedSprite2D })
function OrthoCamera({ viewSize }: { viewSize: number }) {
const camera = useThree((s) => s.camera) as ThreeOrthographicCamera
const size = useThree((s) => s.size)
useLayoutEffect(() => {
const aspect = size.width / size.height
camera.left = (-viewSize * aspect) / 2
camera.right = (viewSize * aspect) / 2
camera.top = viewSize / 2
camera.bottom = -viewSize / 2
camera.updateProjectionMatrix()
}, [camera, size, viewSize])
return null
}
// Animation definitions
const animationSet: AnimationSetDefinition = {
fps: 10,
animations: {
idle: {
frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'],
fps: 8,
loop: true,
},
run: {
frames: [
'run_0', 'run_1', 'run_2', 'run_3', 'run_4', 'run_5', 'run_6', 'run_7',
'run_8', 'run_9', 'run_10', 'run_11', 'run_12', 'run_13', 'run_14', 'run_15',
],
fps: 12,
loop: true,
},
roll: {
frames: ['roll_0', 'roll_1', 'roll_2', 'roll_3', 'roll_4', 'roll_5', 'roll_6', 'roll_7'],
fps: 15,
loop: true,
},
hit: {
frames: ['hit_0', 'hit_1', 'hit_2', 'hit_3'],
fps: 10,
loop: false,
},
death: {
frames: ['death_0', 'death_1', 'death_2', 'death_3'],
fps: 8,
loop: false,
},
},
}
interface KnightProps {
animation: string
speed: number
onAnimationComplete: () => void
}
function Knight({ animation, speed, onAnimationComplete }: KnightProps) {
const ref = useRef<AnimatedSprite2D>(null)
const sheet = useLoader(SpriteSheetLoader, './sprites/knight.json')
const lastAnimation = useRef(animation)
// Update animation when it changes
if (ref.current && lastAnimation.current !== animation) {
ref.current.play(animation, {
onComplete: () => {
if (animation === 'hit' || animation === 'death') {
onAnimationComplete()
}
},
})
lastAnimation.current = animation
}
// Update speed
if (ref.current) {
ref.current.speed = speed
}
// Update animation each frame
useFrame((_, delta) => {
ref.current?.update(delta * 1000)
})
return (
<animatedSprite2D
ref={ref}
spriteSheet={sheet}
animationSet={animationSet}
animation="idle"
layer={Layers.ENTITIES}
anchor={[0.5, 0.5]}
scale={[128, 128, 1]}
/>
)
}
function Scene() {
const { pane } = usePane()
const animFolder = usePaneFolder(pane, 'Animation', { expanded: true })
// Use state so changes trigger re-render → Knight gets new props
const [animation, setAnimation] = useState('idle')
const [speed, setSpeed] = useState(1)
// RadioGrid for animation selection
const animGridRef = useRef<any>(null)
useEffect(() => {
if (!animFolder) return
const names = ['idle', 'run', 'roll', 'hit', 'death']
const labels = ['Idle', 'Run', 'Roll', 'Hit', 'Death']
const blade = animFolder.addBlade({
view: 'radiogrid',
groupName: 'animation',
size: [5, 1],
cells: (x: number) => ({ title: labels[x]!, value: names[x]! }),
value: 'idle',
label: 'anim',
} as any) as any
animGridRef.current = blade
blade.on('change', (ev: any) => { setAnimation(ev.value) })
return () => { blade.dispose(); animGridRef.current = null }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animFolder])
// RadioGrid for speed selection
useEffect(() => {
if (!animFolder) return
const speeds = [0.5, 1, 1.5, 2, 3]
const labels = ['0.5x', '1x', '1.5x', '2x', '3x']
const blade = animFolder.addBlade({
view: 'radiogrid',
groupName: 'speed',
size: [5, 1],
cells: (x: number) => ({ title: labels[x]!, value: speeds[x]! }),
value: 1,
label: 'speed',
} as any) as any
blade.on('change', (ev: any) => { setSpeed(ev.value) })
return () => blade.dispose()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animFolder])
const handleAnimationComplete = useCallback(() => {
setAnimation('idle')
// Sync radiogrid
if (animGridRef.current) {
animGridRef.current.value.rawValue = 'idle'
}
}, [])
return (
<>
<GemBackground gem={GEM} />
<Suspense fallback={null}>
<Knight
animation={animation}
speed={speed}
onAnimationComplete={handleAnimationComplete}
/>
</Suspense>
</>
)
}
export default function App() {
return (
<>
{/* Attribution */}
<div
style={{
position: 'fixed',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
color: '#555',
fontSize: 9,
fontFamily: 'monospace',
zIndex: 100,
whiteSpace: 'nowrap',
}}
>
Knight sprite by{' '}
<a
href="https://analogstudios.itch.io/camelot"
target="_blank"
style={{ color: '#777' }}
>
analogStudios_
</a>{' '}
(CC0)
</div>
<Canvas
orthographic
dpr={1}
camera={{
position: [0, 0, 100],
near: 0.1,
far: 1000,
left: -1, right: 1, top: 1, bottom: -1,
}}
renderer={{ antialias: false }}
onCreated={({ gl }) => {
gl.domElement.style.imageRendering = 'pixelated'
}}
>
<OrthoCamera viewSize={200} />
<DevtoolsProvider name="animation" />
<Scene />
</Canvas>
</>
)
}

The AnimatedSprite2D class extends Sprite2D with frame-based animation driven by a SpriteSheet and an animationSet of named animations:

import { AnimatedSprite2D, SpriteSheetLoader } from 'three-flatland';
const spriteSheet = await SpriteSheetLoader.load('/sprites/knight.json');
const sprite = new AnimatedSprite2D({
spriteSheet,
animationSet: {
fps: 10, // Default fps for all animations
animations: {
idle: { frames: ['idle_0', 'idle_1', 'idle_2', 'idle_3'], fps: 8, loop: true },
run: { frames: ['run_0', 'run_1', 'run_2', 'run_3'], fps: 12, loop: true },
},
},
animation: 'idle', // Start playing immediately
});
sprite.play('run'); // Play by name
sprite.play('hit', {
onComplete: () => sprite.play('idle'), // Chain after non-looping anim
});
sprite.speed = 1.5; // 1.5x speed

Advance the animation each frame by calling update(deltaMs):

let lastTime = performance.now();
function animate() {
const now = performance.now();
const deltaMs = now - lastTime;
lastTime = now;
sprite.update(deltaMs);
renderer.render(scene, camera);
requestAnimationFrame(animate);
}