Drei SpriteAnimator: The Bridge Between 2D and 3D

Michalis Dobekidis
7L International
Published in
6 min readJan 31, 2024

--

Old school spritesheet

In this article I will showcase how to use SpriteAnimator from drei, a popular utilities and components library for R3F (React Three Fiber)

https://github.com/pmndrs/drei

As the original author of the component I can also share my vision and reasons for creating this component.

At a point in time when I was researching Threejs and R3F for a project at work, I needed some elaborate effects that required either extensive use of particles and shaders, or the use of 2D assets in the form of a spritesheet that our designer could easily provide. The solution on how to best serve these assets was pretty straightforward for me as I have been using the excellent software TexturePacker (https://www.codeandweb.com/texturepacker) for years. But there was no way to utilize the export formats of texture packer that offer high versatility when used with R3F. So I started building SpriteAnimator not only for my own needs but also as a way to give back to the excellent community of developers that is Poimandres (https://github.com/pmndrs)

But enough with the history lesson let’s move to the implementation!

SpriteAnimator can not only handle simple standalone spritesheet images of same frame width and height but also JSON-Hash and JSON-Array formats, even those with variable width and height. Those options offer great versatility in terms of packaging multiple animations and loading them all at once.

Let’s see a quick example of how to display a spritesheet using the SpriteAnimator component

import {SpriteAnimator} from "@react-three/drei"

<SpriteAnimator
position={[4.5, 0.5, 0.1]}
startFrame={0}
autoPlay={true}
loop={true}
scale={5}
textureImageURL={'./flame.png'}
textureDataURL={'./flame.json'}
alphaTest={0.001}
asSprite={false}
/>

Simple and powerful!

Here’s an explanation for each property in the SpriteAnimator component used for the flame effect above:

  • position={[4.5, 0.5, 0.1]}: This sets the position of the sprite in the 3D space. The array represents the [x, y, z] coordinates.
  • startFrame={0}: This sets the frame that the sprite animation should start from. In this case, it starts from the first frame (0).
  • autoPlay={true}: This determines whether the sprite animation should start playing as soon as it is loaded. If true, the animation will start playing immediately.
  • loop={true}: This determines whether the sprite animation should loop. If true, the animation will repeat indefinitely.
  • scale={5}: This sets the scale of the sprite. In this case, the sprite is scaled up by a factor of 5.
  • textureImageURL={'./flame.png'}: This is the URL of the image file that contains the sprite sheet. The sprite sheet is a single image that contains all the frames of the animation (you get this from TexturePacker or other similar software).
  • textureDataURL={'./flame.json'}: This is the URL of the JSON file that contains the metadata for the sprite sheet. The metadata includes information like the size of each frame, the number of frames, etc (you get this from TexturePacker or other similar software).
  • alphaTest={0.001}: This sets the alpha value that is used to determine whether a pixel should be discarded or not. If the alpha value of a pixel is less than this value, the pixel is discarded (i.e., it becomes transparent), we use this because the sprite contains white-space around it.
  • asSprite={false}: This determines whether the sprite should be rendered as a sprite. If false, the sprite is rendered as a mesh.

Now that we sorted out the basics we can move on to a more complex examples.

The advanced one!

One of the nice features of SpriteAnimator is that you get complete control over the life-cycle of the animation with event callbacks (onStart, onFrame, onEnd), this way you can orchestrate complex chains of animations.

So how did we achieve the chaining of the “puff smoke” and then the “fire trail” animation? Easy!

const [started, setStarted] = useState(false)
const vanish = () => {
setStarted(true)
}

// puff animation effect
<SpriteAnimator
visible={!started}
scale={[4, 4, 4]}
position={[0, 0.0, -0.5]}
autoPlay={true}
loop={false}
onEnd={vanish}
startFrame={0}
fps={12}
asSprite={false}
rotation={[0, 0, 0]}
alphaTest={0.001}
textureImageURL={'https://gwcjylrsyylsuacdrnov.supabase.co/storage/v1/object/public/models/puff_down.png'}
textureDataURL={'https://gwcjylrsyylsuacdrnov.supabase.co/storage/v1/object/public/models/puff_down.json'}
/>

// trail flame animation effect
<SpriteAnimator
visible={started}
scale={3}
position={[-0.05, -2.1, -0.25]}
autoPlay={started}
loop={true}
startFrame={0}
fps={16}
alphaTest={0.01}
asSprite={false}
textureImageURL={'https://gwcjylrsyylsuacdrnov.supabase.co/storage/v1/object/public/models/trail%20(1).png'}
textureDataURL={'https://gwcjylrsyylsuacdrnov.supabase.co/storage/v1/object/public/models/trail%20(1).json'}
/>

So as you see here one of the animations has an onEnd callback defined and when that callback is called we change the state of the started variable which then sets the autoPlay property of the fire-trail-animation to true and starts the animation.

This is a simple demonstration of how to chain animations when one of them ends. But there is an even more powerful callback, the onUpdate which allows animations to be chained when the animation is on a specific frame, thus creating perfectly aligned animations. Let’s see an example

The complex one!

The important parts from the above sandbox are the following properties of the SpriteAnimator components

 const [frameName, setFrameName] = useState('idle')
const [frameNameCyclops, setFrameNameCyclops] = useState('idle')
const [hit, setHit] = useState(false)
const [ogreHit, setOgreHit] = useState(false)

// for cyclops card
onFrame={onFrameCyclops}
onClick={playCyclopsAttack}
frameName={frameNameCyclops}
onLoopEnd={onEndCyclops}
animationNames={['idle', 'attacking', 'hurt']}

// for knight card
onClick={playAttack}
frameName={frameName}
onLoopEnd={onEnd}
onFrame={onFrameKnight}
animationNames={['idle', 'attacking', 'hurt']}

The frameName and frameNameCyclops are controlling which sprite animations are currently running from (idle, attacking, hurt), cycling through these allows for a truly interactive and dynamic component.

Let’s see how we can fine tune these to change when a specific frame is currently shown.

Let us see one chain example:

  // when knight is clicked
const playAttack = () => {
console.log('clicked')
setFrameName('attacking')
}

// when knight returns to the idle sequence
const onEnd = ({ currentFrameName, currentFrame }: any) => {
if (currentFrameName !== 'idle') {
setFrameName('idle')
}
}

// when the cyclops returns to the idle sequence
const onEndCyclops = ({ currentFrameName, currentFrame }: any) => {
if (currentFrameName !== 'idle') {
setFrameNameCyclops('idle')
}
}

// when the knight attacking animation is running
// we check for a specific frame
const onFrameKnight = ({ currentFrameName, currentFrame }: any) => {
if (currentFrame === 8 && currentFrameName === 'attacking') {
setFrameNameCyclops('hurt')
setHit(true)
}
}

When the ogre card is clicked the playAttack function is called which sets the animation to “attacking”

While “attacking” the onFrame callback (onFrameKnight) is called on every frame which checks if the frame is the 8th (when the sword is forward) and if the current animation set is “attacking”. If those conditions are met the cyclops sprite is set to animate the sequence of hurt and the hit variable is set to true (this variable controls the show/hide condition of the explosion animation).

Finally when the “attacking” or “hurt” animation of the ogre sprite are completed the onEndCyclops function runs which resets the sprite animation to the looping idle one.

Phew! I know this was quite complex but if you go step by step you’ll be able to figure out how all these animations are linked together and create a very fluid sequence of animations.

Loader included

Of course loading sprites one by one kinda defeats the purpose, so naturally I thought to include a useSpriteLoader hook in order to make loading of a gigantic spritesheet at start and then feed the loaded and parsed data into as many SpriteAnimator components as you want.

You can host multiple animations of the same character, or different animations altogether, it doesn’t matter, the loader will load and parse them all and make them available for consumption.

The syntax is pretty simple, just pass the url for the texture and/or the url for the JSON file, the animation names of the spritesheet or the number of frames (for standalone spritesheets without JSON)

 const { spriteObj } = useSpriteLoader(
'multiasset.png',
'multiasset.json',

['orange', 'Idle Blinking', '_Bat'],
null
)

<SpriteAnimator
position={[4.5, 0.5, 0.1]}
autoPlay={true}
loop={true}
scale={5}
frameName={'_Bat'}
animationNames={['_Bat']}
spriteDataset={spriteObj}
alphaTest={0.01}
asSprite={false}
/>

<SpriteAnimator
position={[8.5, 0.5, 6.8]}
autoPlay={true}
loop={true}
scale={5}
frameName={'Idle Blinking'}
animationNames={['Idle Blinking']}
spriteDataset={spriteObj}
alphaTest={0.01}
asSprite={false}
/>
Multi asset loading via useSpriteLoader

Extra mile

With the latest addition to the SpriteAnimator that added manual update of the animation frame you are able to link to it things like animation libraries or ScrollControls from drei and completely own the way the SpriteAnimator animates, you can be in total control of the speed and even play the animation backwards!

These options allow for a vast majority of scenarios to be available and possible in 3D world!

manual update of sprite animation via scrollcontrols

Feel free to follow me on X for more updates on SpriteAnimator and other components! https://twitter.com/netgfx

If you need work done in the 3rd dimension, at 7LInternational we can build experiences to wow your users. Check out our services page: https://7linternational.com/services/

Enjoy!

--

--

Senior software engineer (7linternational.com) with a passion for games (playing and creating). I love the creative part of coding and things that are exciting!