Skip to content
Back to Examples

Animation

Animate sprites using spritesheets with three-flatland.

import { WebGPURenderer } from 'three/webgpu'
import { Scene, NearestFilter } from 'three'
import {
AnimatedSprite2D,
PixelPerfectCamera,
SpriteSheetLoader,
SortLayers,
createDevtoolsProvider,
} from 'three-flatland'
import { createPane } from '@three-flatland/devtools'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
import { initializeRenderer } from './renderStartupError'
import { configureExampleRendererColor } from './rendererColorManagement'
/* 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 })
const camera = new PixelPerfectCamera({ viewSize: 200 })
// WebGPU Renderer (required for TSL materials)
const renderer = new WebGPURenderer({ antialias: false })
configureExampleRendererColor(renderer)
activeRenderer = renderer
renderer.setPixelRatio(1) // Pixel-perfect for pixel art
renderer.setSize(window.innerWidth, window.innerHeight)
camera.setDrawingBufferSize(renderer.domElement.width, renderer.domElement.height)
renderer.setViewport(camera.getLogicalViewport(renderer.getPixelRatio()))
renderer.domElement.style.imageRendering = 'pixelated'
document.body.appendChild(renderer.domElement)
// Wait for renderer to initialize
if (!(await initializeRenderer(renderer))) return
// 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',
sortLayer: SortLayers.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 paneBundle = createPane({ driver: 'manual' })
const { pane } = paneBundle
const updateDevtools = () => paneBundle.update()
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', () => {
renderer.setSize(window.innerWidth, window.innerHeight)
camera.setDrawingBufferSize(renderer.domElement.width, renderer.domElement.height)
renderer.setViewport(camera.getLogicalViewport(renderer.getPixelRatio()))
})
// 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()
}
void main().catch((error: unknown) => console.error('[three-flatland] Example startup failed', error))
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 } from 'react'
import { Canvas, extend, useFrame, useLoader } from '@react-three/fiber/webgpu'
import {
AnimatedSprite2D,
SpriteSheetLoader,
SortLayers,
usePixelPerfectCamera,
type AnimationSetDefinition,
} from 'three-flatland/react'
import { DevtoolsProvider, usePane, usePaneFolder } from '@three-flatland/devtools/react'
import { exampleRendererColorConfig } from './rendererColorManagement'
import { ExampleFallback } from './ExampleFallback'
import { GemBackground } from './GemBackground'
import { GEM } from './gem'
// Register AnimatedSprite2D with R3F (tree-shakeable)
extend({ AnimatedSprite2D })
function Camera() {
usePixelPerfectCamera({ viewSize: 200 })
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"
sortLayer={SortLayers.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}
fallback={<ExampleFallback />}
camera={{
position: [0, 0, 100],
near: 0.1,
far: 1000,
left: -1,
right: 1,
top: 1,
bottom: -1,
}}
renderer={{ antialias: false, ...exampleRendererColorConfig }}
onCreated={({ renderer }) => {
renderer.domElement.style.imageRendering = 'pixelated'
}}
>
<Camera />
<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);
}