Magical Marbles in Three.js

In April 2019, Harry Alisavakis made a great write-up about the “magical marbles” effect he shared prior on Twitter. Check that out first to get a high level overview of the effect we’re after (while you’re at it, you should see some of his other excellent shader posts).

While his write-up provided a brief summary of the technique, the purpose of this tutorial is to offer a more concrete look at how you could implement code for this in Three.js to run on the web. There’s also some tweaks to the technique here and there that try to make things more straightforward.

⚠ This tutorial assumes intermediate familiarity with Three.js and GLSL

Overview

You should read Harry’s post first because he provides helpful visuals, but the gist of it is this:

  • Add fake depth to a material by offsetting the texture look-ups based on camera direction
  • Instead of using the same texture at each iteration, let’s use depth-wise “slices” of a heightmap so that the shape of our volume is more dynamic
  • Add wavy motion by displacing the texture look-ups with scrolling noise

There were a couple parts of this write-up that weren’t totally clear to me, likely due to the difference in features available in Unity vs Three.js. One is the jump from parallax mapping on a plane to a sphere. Another is how to get vertex tangents for the transformation to tangent space. Finally, I wasn’t sure if the noise for the heightmap was evaluated as code inside the shader or pre-rendered. After some experimentation I came to my own conclusions for these, but I encourage you to come up with your own variations of this technique 🙂

Here’s the Pen I’ll be starting from, it sets up a boilerplate Three.js app with an init and tick lifecycle, color management, and an environment map from Poly Haven for lighting.

See the Pen
by Matt (@mattrossman)
on CodePen.0

Step 1: A Blank Marble

Marbles are made of glass, and Harry’s marbles definitely showed some specular shine. In order to make a truly beautiful glassy material it would take some pretty complex PBR shader code, which is too much work! Instead, let’s just take one of Three.js’s built-in PBR materials and hook our magical bits into that, like the shader parasite we are.

Enter onBeforeCompile, a callback property of the THREE.Material base class that lets you apply patches to built-in shaders before they get compiled by WebGL. This technique is very hacky and not well explained in the official docs, but a good place to learn more about it is Dusan Bosnjak’s post “Extending three.js materials with GLSL”. The hardest part about it is determining which part of the shaders you need to change exactly. Unfortunately, your best bet is to just read through the source code of the shader you want to modify, find a line or chunk that looks vaguely relevant, and try tweaking stuff until the property you want to modify shows visible changes. I’ve been writing personal notes of what I discover since it’s really hard to keep track of what the different chunks and variables do.

ℹ I recently discovered there’s a much more elegant way to extend the built-in materials using Three’s experimental Node Materials, but that deserves a whole tutorial of its own, so for this guide I’ll stick with the more common onBeforeCompile approach.

For our purposes, MeshStandardMaterial is a good base to start from. It has specular and environment reflections that will make out material look very glassy, plus it gives you the option to add a normal map later on if you want to add scratches onto the surface. The only part we want to change is the base color on which the lighting is applied. Luckily, this is easy to find. The fragment shader for MeshStandardMaterial is defined in meshphysical_frag.glsl.js (it’s a subset of MeshPhysicalMaterial, so they are both defined in the same file). Oftentimes you need to go digging through the shader chunks represented by each of the #include statements you’ll see in the file, however, this is a rare occasion where the variable we want to tweak is in plain sight.

It’s the line right near the top of the main() function that says:

vec4 diffuseColor = vec4( diffuse, opacity );

This line normally reads from the diffuse and opacity uniforms which you set via the .color and .opacity JavaScript properties of the material, and then all the chunks after that do the complicated lighting work. We are going to replace this line with our own assignment to diffuseColor so we can apply whatever pattern we want on the marble’s surface. You can do this using regular JavaScript string methods on the .fragmentShader field of the shader provided to the onBeforeCompile callback.

material.onBeforeCompile = shader => {
  shader.fragmentShader = shader.fragmentShader.replace('/vec4 diffuseColor.*;/, `
    // Assign whatever you want!
    vec4 diffuseColor = vec4(1., 0., 0., 1.);
  `)
}

By the way, the type definition for that mysterious callback argument is available here.

In the following Pen I swapped our geometry for a sphere, lowered the roughness, and filled the diffuseColor with the screen space normals which are available in the standard fragment shader on vNormal. The result looks like a shiny version of MeshNormalMaterial.

See the Pen
by Matt (@mattrossman)
on CodePen.0

Step 2: Fake Volume

Now comes the harder part — using the diffuse color to create the illusion of volume inside our marble. In Harry’s earlier parallax post, he talks about finding the camera direction in tangent space and using this to offset the UV coordinates. There’s a great explanation of how this general principle works for parallax effects on learnopengl.com and in this archived post.

However, converting stuff into tangent space in Three.js can be tricky. To the best of my knowledge, there’s not a built-in utility to help with this like there are for other space transformations, so it takes some legwork to both generate vertex tangents and then assemble a TBN matrix to perform the transformation. On top of that, spheres are not a nice shape for tangents due to the hairy ball theorem (yes, that’s a real thing), and Three’s computeTangents() function was producing discontinuities for me so you basically have to compute tangents manually. Yuck!

Luckily, we don’t really need to use tangent space if we frame this as a 3D raymarching problem. We have a ray pointing from the camera to the surface of our marble, and we want to march this through the sphere volume as well as down the slices of our height map. We just need to know how to convert a point in 3D space into a point on the surface of our sphere so we can perform texture lookups. In theory you could also just plug the 3D position right into your noise function of choice and skip using the texture, but this effect relies on lots of iterations and I’m operating under the assumption that a texture lookup is cheaper than all the number crunching happening in e.g. the 3D simplex noise function (shader gurus, please correct me if I’m wrong). The other benefit of reading from a texture is that it allows us to use a more art-oriented pipeline to craft our heightmaps, so we can make all sorts of interesting volumes without writing new code.

Originally I wrote a function to do this spherical XYZ→UV conversion based on some answers I saw online, but it turns out there’s already a function that does the same thing inside of common.glsl.js called equirectUv. We can reuse that as long as put our raymarching logic after the #include <common> line in the standard shader.

Creating our heightmap

For the heightmap, we want a texture that seamlessly projects on the surface of a UV sphere. It’s not hard to find seamless noise textures online, but the problem is that these flat projections of noise will look warped near the poles when applied to a sphere. To solve this, let’s craft our own texture using Blender. One way to do this is to bend a high resolution “Grid” mesh into a sphere using two instances of the “Simple Deform modifier”, plug the resulting “Object” texture coordinates into your procedural shader of choice, and then do an emissive bake with the Cycles renderer. I also added some loop cuts near the poles and a subdivision modifier to prevent any artifacts in the bake.

The resulting bake looks something like this:

Raymarching

Now the moment we’ve been waiting for (or dreading) — raymarching! It’s actually not so bad, the following is an abbreviated version of the code. For now there’s no animation, I’m just taking slices of the heightmap using smoothstep (note the smoothing factor which helps hide the sharp edges between layers), adding them up, and then using this to mix two colors.

uniform sampler2D heightMap;
uniform vec3 colorA;
uniform vec3 colorB;
uniform float iterations;
uniform float depth;
uniform float smoothing;

/**
  * @param rayOrigin - Point on sphere
  * @param rayDir - Normalized ray direction
  * @returns Diffuse RGB color
  */
vec3 marchMarble(vec3 rayOrigin, vec3 rayDir) {
  float perIteration = 1. / float(iterations);
  vec3 deltaRay = rayDir * perIteration * depth;

  // Start at point of intersection and accumulate volume
  vec3 p = rayOrigin;
  float totalVolume = 0.;

  for (int i=0; i<iterations; ++i) {
    // Read heightmap from current spherical direction
    vec2 uv = equirectUv(p);
    float heightMapVal = texture(heightMap, uv).r;

    // Take a slice of the heightmap
    float height = length(p); // 1 at surface, 0 at core, assuming radius = 1
    float cutoff = 1. - float(i) * perIteration;
    float slice = smoothstep(cutoff, cutoff + smoothing, heightMapVal);

    // Accumulate the volume and advance the ray forward one step
    totalVolume += slice * perIteration;
    p += deltaRay;
  }
  return mix(colorA, colorB, totalVolume);
}

/**
 * We can user this later like:
 *
 * vec4 diffuseColor = vec4(marchMarble(rayOrigin, rayDir), 1.0);
 */

ℹ This logic isn’t really physically accurate — taking slices of the heightmap based on the iteration index assumes that the ray is pointing towards the center of the sphere, but this isn’t true for most of the pixels. As a result, the marble appears to have some heavy refraction. However, I think this actually looks cool and further sells the effect of it being solid glass!

Injecting uniforms

One final note before we see the fruits of our labor — how do we include all these custom uniforms in our modified material? We can’t just stuck stuff onto material.uniforms like you would with THREE.ShaderMaterial. The trick is to create your own personal uniforms object and then wire up its contents onto the shader argument inside of onBeforeCompile. For instance:

const myUniforms = {
  foo: { value: 0 }
}

material.onBeforeCompile = shader => {
  shader.uniforms.foo = myUniforms.foo

  // ... (all your other patches)
}

When the shader tries to read its shader.uniforms.foo.value reference, it’s actually reading from your local myUniforms.foo.value, so any change to the values in your uniforms object will automatically be reflected in the shader.

I typically use the JavaScript spread operator to wire up all my uniforms at once:

const myUniforms = {
  // ...(lots of stuff)
}

material.onBeforeCompile = shader => {
  shader.uniforms = { ...shader.uniforms, ...myUniforms }

  // ... (all your other patches)
}

Putting this all together, we get a gassy (and glassy) volume. I’ve added sliders to this Pen so you can play around with the iteration count, smoothing, max depth, and colors.

See the Pen
by Matt (@mattrossman)
on CodePen.0

ℹ Technically the ray origin and ray direction should be in local space so the effect doesn’t break when the marble moves. However, I’m skipping this transformation because we’re not moving the marble, so world space and local space are interchangeable. Work smarter not harder!

Step 3: Wavy Motion

Almost done! The final touch is to make this marble come alive by animating the volume. Harry’s waving displacement post explains how he accomplishes this using a 2D displacement texture. However, just like with the heightmap, a flat displacement texture warps near the poles of a sphere. So, we’ll make our own again. You can use the same Blender setup as before, but this time let’s bake a 3D noise texture to the RGB channels:

Then in our marchMarble function, we’ll read from this texture using the same equirectUv function as before, center the values, and then add a scaled version of that vector to the position used for the heightmap texture lookup. To animate the displacement, introduce a time uniform and use that to scroll the displacement texture horizontally. For an even better effect, we’ll sample the displacement map twice (once upright, then upside down so they never perfectly align), scroll them in opposite directions and add them together to produce noise that looks chaotic. This general strategy is often used in water shaders to create waves.

uniform float time;
uniform float strength;

// Lookup displacement texture
vec2 uv = equirectUv(normalize(p));
vec2 scrollX = vec2(time, 0.);
vec2 flipY = vec2(1., -1.);
vec3 displacementA = texture(displacementMap, uv + scrollX).rgb;
vec3 displacementB = texture(displacementMap, uv * flipY - scrollX).rgb;

// Center the noise
displacementA -= 0.5;
displacementB -= 0.5;

// Displace current ray position and lookup heightmap
vec3 displaced = p + strength * (displacementA + displacementB);
uv = equirectUv(normalize(displaced));
float heightMapVal = texture(heightMap, uv).r;

Behold, your magical marble!

See the Pen
by Matt (@mattrossman)
on CodePen.0

Extra Credit

Hard part’s over! This formula is a starting point from which there are endless possibilities for improvements and deviations. For instance, what happens if we swap out the noise texture we used earlier for something else like this:

This was created using the “Wave Texture” node in Blender

See the Pen
by Matt (@mattrossman)
on CodePen.0

Or how about something recognizable, like this map of the earth?

Try dragging the “displacement” slider and watch how the floating continents dance around!

See the Pen
by Matt (@mattrossman)
on CodePen.0

In that example I modified the shader to make the volume look less gaseous by boosting the rate of volume accumulation, breaking the loop once it reached a certain volume threshold, and tinting based on the final number of iterations rather than accumulated volume.

For my last trick, I’ll point back to Harry’s write-up where he suggests mixing between two HDR colors. This basically means mixing between colors whose RGB values exceed the typical [0, 1] range. If we plug such a color into our shader as-is, it’ll create color artifacts in the pixels where the lighting is blown out. There’s an easy solve for this by wrapping the color in a toneMapping() call as is done in tonemapping_fragment.glsl.js, which “tones down” the color range. I couldn’t find where that function is actually defined, but it works!

I’ve added some color multiplier sliders to this Pen so you can push the colors outside the [0, 1] range and observe how mixing these HDR colors creates pleasant color ramps.

See the Pen
by Matt (@mattrossman)
on CodePen.0

Conclusion

Thanks again to Harry for the great learning resources. I had a ton of fun trying to recreate this effect and I learned a lot along the way. Hopefully you learned something too!

Your challenge now is to take these examples and run with them. Change the code, the textures, the colors, and make your very own magical marble. Show me and Harry what you make on Twitter.

Surprise me!

The post Magical Marbles in Three.js appeared first on Codrops.

Recreating a Dave Whyte Animation in React-Three-Fiber

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.

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 clock, 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):

VML diagram depicting the flow of data through four passes of a simple motion blur effect. The process is explained below.

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:

A red, green, and blue dot overlayed like a Venn diagram depicting three consecutive 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:

VML diagram depicting the flow of data through four passes of a RGB color delay effect. Key aspects of the process is explained below.
A circle containing a value X represents the individual frame for delay X.

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 extending 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 react-spring 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 use-capture 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!

The post Recreating a Dave Whyte Animation in React-Three-Fiber appeared first on Codrops.