From our sponsor: Ready to show your plugin skills? Enter the Penpot Plugins Contest (Nov 15-Dec 15) to win cash prizes!
There’s a slew of artists and creative coders on social media who regularly post satisfying, hypnotic looping animations. One example is Dave Whyte, also known as @beesandbombs on Twitter. In this tutorial I’ll explain how to recreate one of his more popular recent animations, which I’ve dubbed “Breathing Dots”. Here’s the original animation:
The Tools
Dave says he uses Processing for his animations, but I’ll be using react-three-fiber (R3F) which is a React renderer for Three.js. Why am I using a 3D library for a 2D animation? Well, R3F provides a powerful declarative syntax for WebGL graphics and grants you access to useful Three.js features such as post-processing effects. It lets you do a lot with few lines of code, all while being highly modular and re-usable. You can use whatever tool you like, but I find the combined ecosystems of React and Three.js make R3F a robust tool for general purpose graphics.
I use an adapted Codesandbox template running Create React App to bootstrap my R3F projects; You can fork it by clicking the button above to get a project running in a few seconds. I will assume some familiarity with React, Three.js and R3F for the rest of the tutorial. If you’re totally new, you might want to start here.
Step 1: Observations
First things first, we need to take a close look at what’s going on in the source material. When I look at the GIF, I see a field of little white dots. They’re spread out evenly, but the pattern looks more random than a grid. The dots are moving in a rhythmic pulse, getting pulled towards the center and then flung outwards in a gentle shockwave. The shockwave has the shape of an octagon. The dots aren’t in constant motion, rather they seem to pause at each end of the cycle. The dots in motion look really smooth, almost like they’re melting. We need to zoom in to really understand what’s going on here. Here’s a close up of the corners during the contraction phase:
Interesting! The moving dots are split into red, green, and blue parts. The red part points in the direction of motion, while the blue part points away from the motion. The faster the dot is moving, the farther these three parts are spread out. As the colored parts overlap, they combine into a solid white color. Now that we understand what exactly we want to produce, lets start coding.
Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.
Step 2: Making Some Dots
If you’re using the Codesandbox template I provided, you can strip down the main App.js
to just an empty scene with a black background:
import React from 'react'
import { Canvas } from 'react-three-fiber'
export default function App() {
return (
<Canvas>
<color attach="background" args={['black']} />
</Canvas>
)
}
Our First Dot
Let’s create a component for our dots, starting with just a single white circle mesh composed of a CircleBufferGeometry
and MeshBasicMaterial
function Dots() {
return (
<mesh>
<circleBufferGeometry />
<meshBasicMaterial />
</mesh>
)
}
Add the <Dots />
component inside the canvas, and you should see a white octagon appear onscreen. Our first dot! Since it’ll be tiny, it doesn’t matter that it’s not very round.
But wait a second… Using a color picker, you’ll notice that it’s not pure white! This is because R3F sets up color management by default which is great if you’re working with glTF models, but not if you need raw colors. We can disable the default behavior by setting colorManagement={false}
on our canvas.
More Dots
We need approximately 10,000 dots to fully fill the screen throughout the animation. A naive approach at creating a field of dots would be to simply render our dot mesh a few thousand times. However, you’ll quickly notice that this destroys performance. Rendering 10,000 of these chunky dots brings my gaming rig down to a measly 5 FPS. The problem is that each dot mesh incurs its own draw call, which means the CPU needs to send 10,000 (largely redundant) instructions to the GPU every frame.
The solution is to use instanced rendering, which means the CPU can tell the GPU about the dot shape, material, and the locations of all 10,000 instances in a single draw call. Three.js offers a helpful InstancedMesh
class to facilitate instanced rendering of a mesh. According to the docs it accepts a geometry, material, and integer count as constructor arguments. Let’s convert our regular old mesh into an <instancedMesh>
, starting with just one instance. We can leave the geometry and material slots as null
since the child elements will fill them, so we only need to specify the count.
function Dots() {
return (
<instancedMesh args={[null, null, 1]}>
<circleBufferGeometry />
<meshBasicMaterial />
</instancedMesh>
)
}
Hey, where did it go? The dot disappeared because of how InstancedMesh
is initialized. Internally, the .instanceMatrix
stores the transformation matrix of each instance, but it’s initialized with all zeros which squashes our mesh into the abyss. Instead, we should start with an identity matrix to get a neutral transformation. Let’s get a reference to our InstancedMesh
and apply the identity matrix to the first instance inside of useLayoutEffect
so that it’s properly positioned before anything is painted to the screen.
function Dots() {
const ref = useRef()
useLayoutEffect(() => {
// THREE.Matrix4 defaults to an identity matrix
const transform = new THREE.Matrix4()
// Apply the transform to the instance at index 0
ref.current.setMatrixAt(0, transform)
}, [])
return (
<instancedMesh ref={ref} args={[null, null, 1]}>
<circleBufferGeometry />
<meshBasicMaterial />
</instancedMesh>
)
}
Great, now we have our dot back. Time to crank it up to 10,000. We’ll increase the instance count and set the transform of each instance along a centered 100 x 100 grid.
for (let i = 0; i < 10000; ++i) {
const x = (i % 100) - 50
const y = Math.floor(i / 100) - 50
transform.setPosition(x, y, 0)
ref.current.setMatrixAt(i, transform)
}
We should also decrease the circle radius to 0.15
to better fit the grid proportions. We don’t want any perspective distortion on our grid, so we should set the orthographic
prop on the canvas. Lastly, we’ll lower the default camera’s zoom to 20
to fit more dots on screen.
The result should look like this:
Although you can’t notice yet, it’s now running at a silky smooth 60 FPS 😀
Adding Some Noise
There’s a variety of ways to distribute points on a surface beyond a simple grid. “Poisson disc sampling” and “centroidal Voronoi tessellation” are some mathematical approaches that generate slightly more natural distributions. That’s a little too involved for this demo, so let’s just approximate a natural distribution by turning our square grid into hexagons and adding in small random offsets to each point. The positioning logic now looks like this:
// Place in a grid
let x = (i % 100) - 50
let y = Math.floor(i / 100) - 50
// Offset every other column (hexagonal pattern)
y += (i % 2) * 0.5
// Add some noise
x += Math.random() * 0.3
y += Math.random() * 0.3
Step 3: Creating Motion
Sine waves are the heart of cyclical motion. By feeding the clock time into a sine function, we get a value that oscillates between -1 and 1. To get the effect of expansion and contraction, we want to oscillate each point’s distance from the center. Another way of thinking about this is that we want to dynamically scale each point’s intial position vector. Since we should avoid unnecessary computations in the render loop, let’s cache our initial position vectors in useMemo
for re-use. We’re also going to need that Matrix4
in the loop, so let’s cache that as well. Finally, we don’t want to overwrite our initial dot positions, so let’s cache a spare Vector3
for use during calculations.
const { vec, transform, positions } = useMemo(() => {
const vec = new THREE.Vector3()
const transform = new THREE.Matrix4()
const positions = [...Array(10000)].map((_, i) => {
const position = new THREE.Vector3()
position.x = (i % 100) - 50
position.y = Math.floor(i / 100) - 50
position.y += (i % 2) * 0.5
position.x += Math.random() * 0.3
position.y += Math.random() * 0.3
return position
})
return { vec, transform, positions }
}, [])
For simplicity let’s scrap the useLayoutEffect
call and configure all the matrix updates in a useFrame
loop. Remember that in R3F, the useFrame
callback receives the same arguments as useThree
including the Three.js <a href="https://threejs.org/docs/#api/en/core/Clock">clock</a>
, so we can access a dynamic time through clock.elapsedTime
. We’ll add some simple motion by copying each instance position into our scratch vector, scaling it by some factor of the sine wave, and then copying that to the matrix. As mentioned in the docs, we need to set .needsUpdate
to true
on the instanced mesh’s .instanceMatrix
in the loop so that Three.js knows to keep updating the positions.
useFrame(({ clock }) => {
const scale = 1 + Math.sin(clock.elapsedTime) * 0.3
for (let i = 0; i < 10000; ++i) {
vec.copy(positions[i]).multiplyScalar(scale)
transform.setPosition(vec)
ref.current.setMatrixAt(i, transform)
}
ref.current.instanceMatrix.needsUpdate = true
})
Rounded square waves
The raw sine wave follows a perfectly round, circular motion. However, as we observed earlier:
The dots aren’t in constant motion, rather they seem to pause at each end of the cycle.
This calls for a different, more boxy looking wave with longer plateaus and shorter transitions. A search through the digital signal processing StackExchange produces this post with the equation for a rounded square wave. I’ve visualized the equation here and animated the delta
parameter, watch how it goes from smooth to boxy:
The equation translates to this Javascript function:
const roundedSquareWave = (t, delta, a, f) => {
return ((2 * a) / Math.PI) * Math.atan(Math.sin(2 * Math.PI * t * f) / delta)
}
Swapping out our Math.sin
call for the new wave function with a delta
of 0.1 makes the motion more snappy, with time to rest in between:
Ripples
How do we use this wave to make the dots move at different speeds and create ripples? If we change the input to the wave based on the dot’s distance from the center, then each ring of dots will be at a different phase causing the surface to stretch and squeeze like an actual wave. We’ll use the initial distances on every frame, so let’s cache and return the array of distances in our useMemo
callback:
const distances = positions.map(pos => pos.length())
Then, in the useFrame
callback we subtract a factor of the distance from the t
(time) variable that gets plugged into the wave. That looks like this:
That already looks pretty cool!
The Octagon
Our ripple is perfectly circular, how can we make it look more octagonal like the original? One way to approximate this effect is by combining a sine or cosine wave with our distance function at an appropriate frequency (8 times per revolution). Watch how changing the strength of this wave changes the shape of the region:
A strength of 0.5 is a pretty good balance between looking like an octagon and not looking too wavy. That change can happen in our initial distance calculations:
const right = new THREE.Vector3(1, 0, 0)
const distances = positions.map((pos) => (
pos.length() + Math.cos(pos.angleTo(right) * 8) * 0.5
))
It’ll take some additional tweaks to really see the effect of this. There’s a few places that we can focus our adjustments on:
- Influence of point distance on wave phase
- Influence of point distance on wave roundness
- Frequency of the wave
- Amplitude of the wave
It’s a bit of educated trial and error to make it match the original GIF, but after fiddling with the wave parameters and multipliers eventually you can get something like this:
When previewing in full screen, the octagonal shape is now pretty clear.
Step 4: Post-processing
We have something that mimics the overall motion of the GIF, but the dots in motion don’t have the same color shifting effect that we observed earlier. As a reminder:
The moving dots are split into red, green, and blue parts. The red part points in the direction of motion, while the blue part points away from the motion. The faster the dot is moving, the farther these three parts are spread out. As the colored parts overlap, they combine into a solid white color.
We can achieve this effect using the post-processing EffectComposer
built into Three.js, which we can conveniently tack onto the scene without any changes to the code we’ve already written. If you’re new to post-processing like me, I highly recommend reading this intro guide from threejsfundamentals. In short, the composer lets you toss image data back and forth between two “render targets” (glorified image textures), applying shaders and other operations in between. Each step of the pipeline is called a “pass”. Typically the first pass performs the initial scene render, then there are some passes to add effects, and by default the final pass writes the resulting image to the screen.
An example: motion blur
Here’s a JSFiddle from Maxime R that demonstrates a naive motion blur effect with the EffectComposer
. This effect makes use of a third render target in order to preserve a blend of previous frames. I’ve drawn out a diagram to track how image data moves through the pipeline (read from the top down):
First, the scene is rendered as usual and written to rt1
with a RenderPass
. Most passes will automatically switch the read and write buffers (render targets), so our next pass will read what we just rendered in rt1
and write to rt2
. In this case we use a ShaderPass
configured with a BlendShader
to blend the contents of rt1
with whatever is stored in our third render target (empty at first, but it eventually accumulates a blend of previous frames). This blend is written to rt2
and another swap automatically occurs. Next, we use a SavePass
to save the blend we just created in rt2
back to our third render target. The SavePass
is a little unique in that it doesn’t swap the read and write buffers, which makes sense since it doesn’t actually change the image data. Finally, that same blend in rt2
(which is still the read buffer) gets read into another ShaderPass
set to a CopyShader
, which simply copies its input into the output. Since it’s the last pass on the stack, it automatically gets renderToScreen=true
which means that its output is what you’ll see on screen.
Working with post-processing requires some mental gymnastics, but hopefully this makes some sense of how different components like ShaderPass
, SavePass
, and CopyPass
work together to apply effects and preserve data between frames.
RGB Delay Effect
A simple RGB color shifting effect involves turning our single white dot into three colored dots that get farther apart the faster they move. Rather than trying to compute velocities for all the dots and passing them to the post-processing stack, we can cheat by overlaying previous frames:
This turns out to be a very similar problem as the motion blur, since it requires us to use additional render targets to store data from previous frames. We actually need two extra render targets this time, one to store the image from frame n-1
and another for frame n-2
. I’ll call these render targets delay1
and delay2
.
Here’s a diagram of the RGB delay effect:
The trick is to manually disable needsSwap
on the ShaderPass
that blends the colors together, so that the proceeding SavePass
re-reads the buffer that holds the current frame rather than the colored composite. Similarly, by manually enabling needsSwap
on the SavePass
we ensure that we read from the colored composite on the final ShaderPass
for the end result. The other tricky part is that since we’re placing the current frame’s contents in the delay2
buffer (as to not lose the contents of delay1
for the next frame), we need to swap these buffers each frame. It’s easiest to do this outside of the EffectComposer
by swapping the references to these render targets on the ShaderPass
and SavePass
within the render loop.
Implementation
This is all very abstract, so let’s see what this means in practice. In a new file (Effects.js
), start by importing the necessary passes and shaders, then extend
ing the classes so that R3F can access them declaratively.
import { useThree, useFrame, extend } from 'react-three-fiber'
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer'
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass'
import { SavePass } from 'three/examples/jsm/postprocessing/SavePass'
import { CopyShader } from 'three/examples/jsm/shaders/CopyShader'
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass'
extend({ EffectComposer, ShaderPass, SavePass, RenderPass })
We’ll put our effects inside a new component. Here is what a basic effect looks like in R3F:
function Effects() {
const composer = useRef()
const { scene, gl, size, camera } = useThree()
useEffect(() => void composer.current.setSize(size.width, size.height), [size])
useFrame(() => {
composer.current.render()
}, 1)
return (
<effectComposer ref={composer} args={[gl]}>
<renderPass attachArray="passes" scene={scene} camera={camera} />
</effectComposer>
)
}
All that this does is render the scene to the canvas. Let’s start adding in the pieces from our diagram. We’ll need a shader that takes in 3 textures and respectively blends the red, green, and blue channels of them. The vertexShader
of a post-processing shader always looks the same, so we only really need to focus on the fragmentShader
. Here’s what the complete shader looks like:
const triColorMix = {
uniforms: {
tDiffuse1: { value: null },
tDiffuse2: { value: null },
tDiffuse3: { value: null }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1);
}
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D tDiffuse1;
uniform sampler2D tDiffuse2;
uniform sampler2D tDiffuse3;
void main() {
vec4 del0 = texture2D(tDiffuse1, vUv);
vec4 del1 = texture2D(tDiffuse2, vUv);
vec4 del2 = texture2D(tDiffuse3, vUv);
float alpha = min(min(del0.a, del1.a), del2.a);
gl_FragColor = vec4(del0.r, del1.g, del2.b, alpha);
}
`
}
With the shader ready to roll, we’ll then memo-ize our helper render targets and set up some additional refs to hold constants and references to our other passes.
const savePass = useRef()
const blendPass = useRef()
const swap = useRef(false) // Whether to swap the delay buffers
const { rtA, rtB } = useMemo(() => {
const rtA = new THREE.WebGLRenderTarget(size.width, size.height)
const rtB = new THREE.WebGLRenderTarget(size.width, size.height)
return { rtA, rtB }
}, [size])
Next, we’ll flesh out the effect stack with the other passes specified in the diagram above and attach our refs:
return (
<effectComposer ref={composer} args={[gl]}>
<renderPass attachArray="passes" scene={scene} camera={camera} />
<shaderPass attachArray="passes" ref={blendPass} args={[triColorMix, 'tDiffuse1']} needsSwap={false} />
<savePass attachArray="passes" ref={savePass} needsSwap={true} />
<shaderPass attachArray="passes" args={[CopyShader]} />
</effectComposer>
)
By stating args={[triColorMix, 'tDiffuse1']}
on the blend pass, we indicate that the composer’s read buffer should be passed as the tDiffuse1
uniform in our custom shader. The behavior of these passes is unfortunately not documented, so you sometimes need to poke through the source files to figure this stuff out.
Finally, we’ll need to modify the render loop to swap between our spare render targets and plug them in as the remaining 2 uniforms:
useFrame(() => {
// Swap render targets and update dependencies
let delay1 = swap.current ? rtB : rtA
let delay2 = swap.current ? rtA : rtB
savePass.current.renderTarget = delay2
blendPass.current.uniforms['tDiffuse2'].value = delay1.texture
blendPass.current.uniforms['tDiffuse3'].value = delay2.texture
swap.current = !swap.current
composer.current.render()
}, 1)
All the pieces for our RGB delay effect are in place. Here’s a demo of the end result on a simpler scene with one white dot moving back and forth:
Putting it all together
As you’ll notice in the previous sandbox, we can make the effect take hold by simply plopping the <Effects />
component inside the canvas. After doing this, we can make it look even better by adding an anti-aliasing pass to the effect composer.
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader'
...
const pixelRatio = gl.getPixelRatio()
return (
<effectComposer ref={composer} args={[gl]}>
<renderPass attachArray="passes" scene={scene} camera={camera} />
<shaderPass attachArray="passes" ref={blendPass} args={[triColorMix, 'tDiffuse1']} needsSwap={false} />
<savePass attachArray="passes" ref={savePass} needsSwap={true} />
<shaderPass
attachArray="passes"
args={[FXAAShader]}
uniforms-resolution-value-x={1 / (size.width * pixelRatio)}
uniforms-resolution-value-y={1 / (size.height * pixelRatio)}
/>
<shaderPass attachArray="passes" args={[CopyShader]} />
</effectComposer>
)
}
And here’s our finished demo!
(Bonus) Interactivity
While outside the scope of this tutorial, I’ve added an interactive demo variant which responds to mouse clicks and cursor position. This variant uses <a href="https://www.react-spring.io/">react-spring</a>
v9 to smoothly reposition the focus point of the dots. Check it out in the “Demo 2” page of the demo linked at the top of this page, and play around with the source code to see if you can add other forms of interactivity.
Step 5: Sharing Your Work
I highly recommend publicly sharing the things you create. It’s a great way to track your progress, share your learning with others, and get feedback. I wouldn’t be writing this tutorial if I hadn’t shared my work! For perfect loops you can use the <a href="https://github.com/gsimone/use-capture">use-capture</a>
hook to automate your recording. If you’re sharing to Twitter, consider converting to a GIF to avoid compression artifacts. Here’s a thread from @arc4g explaining how they create smooth 50 FPS GIFs for Twitter.
I hope you learned something about Three.js or react-three-fiber from this tutorial. Many of the animations I see online follow a similar formula of repeated shapes moving in some mathematical rhythm, so the principles here extend beyond just rippling dots. If this inspired you to create something cool, tag me in it so I can see!