Kinetic Typography with Three.js

Discover how to use Three.js to render kinetic 3D typography.

Kinetic Typography may sound complicated but it’s just the elegant way to say “moving text” and, more specifically, to combine motion with text to create animations.

Imagine text on top of a 3D object, now could you see it moving along the object’s shape? Nice! That’s exactly what we’ll do in this article, we’ll learn how to move text on a mesh using Three.js and three-bmfont-text.

We’re going to skip a lot of basics, so to get the most from this article we recommend you have some basic knowledge about Three.js, GLSL shaders, and three-bmfont-text.

Basis

The main idea for all these demos is to have a texture with text, use it on a mesh and play with it inside shaders. The simplest way of doing it is to have an image with text and then use it as a texture. But it can be a pain to figure out the correct size to try to display crisp text on the mesh, and later to change whatever text is in the image.

To avoid all these issues, we can generate that texture using code! We create a Render Target (RT) where we can have a scene that has text rendered with three-bmfont-text, and then use it as the texture of a mesh. This way we have more freedom to move, change, or color text. We’ll be taking this route following the next steps:

  1. Set up a RT with the text
  2. Create a mesh and add the RT texture
  3. Change the texture inside the fragment shader

To begin, we’ll run everything after the font file and atlas are loaded and ready to be used with three-bmfont-text. We won’t be going over this since I explained it in one of my previous articles.

The structure goes like this:

init() {
  // Create geometry of packed glyphs
  loadFont(fontFile, (err, font) => {
    this.fontGeometry = createGeometry({
      font,
      text: "ENDLESS"
    });

    // Load texture containing font glyphs
    this.loader = new THREE.TextureLoader();
    this.loader.load(fontAtlas, texture => {
      this.fontMaterial = new THREE.RawShaderMaterial(
        MSDFShader({
          map: texture,
          side: THREE.DoubleSide,
          transparent: true,
          negate: false,
          color: 0xffffff
        })
      );

      // Methods are called here
    });
  });
}

Now take a deep breath, grab your tea or coffee, chill, and let’s get started.

Render Target

A Render Target is a texture you can render to. Think of it as a canvas where you can draw whatever is inside and place it wherever you want. Having this flexibility makes the texture dynamic, so we can later add, change, or remove stuff in it.

Let’s set a RT along with a camera and a scene where we’ll place the text.

createRenderTarget() {
  // Render Target setup
  this.rt = new THREE.WebGLRenderTarget(
    window.innerWidth,
    window.innerHeight
  );

  this.rtCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
  this.rtCamera.position.z = 2.5;

  this.rtScene = new THREE.Scene();
  this.rtScene.background = new THREE.Color("#000000");
}

Once we have the RT scene, let’s use the font geometry and material previously created to make the text mesh.

createRenderTarget() {
  // Render Target setup
  this.rt = new THREE.WebGLRenderTarget(
    window.innerWidth,
    window.innerHeight
  );

  this.rtCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
  this.rtCamera.position.z = 2.5;

  this.rtScene = new THREE.Scene();
  this.rtScene.background = new THREE.Color("#000000");

  // Create text with font geometry and material
  this.text = new THREE.Mesh(this.fontGeometry, this.fontMaterial);

  // Adjust text dimensions
  this.text.position.set(-0.965, -0.275, 0);
  this.text.rotation.set(Math.PI, 0, 0);
  this.text.scale.set(0.008, 0.02, 1);

  // Add text to RT scene
  this.rtScene.add(this.text);
 
  this.scene.add(this.text); // Add to main scene
}

Note that for now, we added the text to the main scene to render it on the screen.

Cool! Let’s make it more interesting and “paste” the scene over a shape next.

Mesh and render texture

For simplicity, we’ll first use the shape of a BoxGeometry together with ShaderMaterial to pass custom shaders, time and the render texture uniforms.

createMesh() {
  this.geometry = new THREE.BoxGeometry(1, 1, 1);

  this.material = new THREE.ShaderMaterial({
    vertexShader,
    fragmentShader,
    uniforms: {
      uTime: { value: 0 },
      uTexture: { value: this.rt.texture }
    }
  });

  this.mesh = new THREE.Mesh(this.geometry, this.material);

  this.scene.add(this.mesh);
}

The vertex shader won’t be doing anything interesting this time; we’ll skip it and focus on the fragment instead, which is sampling the colors of the RT texture. It’s inverted for now to stand out from the background (1. - texture).

varying vec2 vUv;

uniform sampler2D uTexture;

void main() {
  vec3 texture = texture2D(uTexture, vUv).rgb;

  gl_FragColor = vec4(1. - texture, 1.);
}

Normally, we would just render the main scene directly, but with a RT we have to first render to it before rendering to the screen.

render() {
  ...

  // Draw Render Target
  this.renderer.setRenderTarget(this.rt);
  this.renderer.render(this.rtScene, this.rtCamera);
  this.renderer.setRenderTarget(null);
  this.renderer.render(this.scene, this.camera);
}

And now a box should appear on the screen where each face has the text on it:

Looks alright so far, but what if we want to repeat the text many times around the shape?

Repeating the texture

GLSL’s built-in function <a href="https://thebookofshaders.com/glossary/?search=fract">fract</a> comes handy to make repetition. We’ll use it to repeat the texture coordinates when multiplying them by a scalar so it wraps between 0 and 1.

varying vec2 vUv;

uniform sampler2D uTexture;

void main() {
  vec2 repeat = vec2(2., 6.); // 2 columns, 6 rows
  vec2 uv = fract(vUv * repeat);

  vec3 texture = texture2D(uTexture, uv).rgb;
  texture *= vec3(uv.x, uv.y, 1.);

  gl_FragColor = vec4(texture, 1.);
}

Notice here that we are also multiplying the texture by the uv components so that we can see the modified texture coordinates visually. This helps us figure out what is going on, since there are very few resources for debugging shaders, so the more ways we can visualize what’s going on, the easier it is to debug! Once we know it’s working the way we intend, we can just comment out, or remove that line.

We’re getting there, right? The text should also follow the object’s shape. Here’s where time comes in! We’re going to add it to the x component of the texture coordinate so that the texture moves horizontally.

varying vec2 vUv;

uniform sampler2D uTexture;
uniform float uTime;

void main() {
  float time = uTime * 0.75;
  vec2 repeat = vec2(2., 6.);
  vec2 uv = fract(vUv * repeat + vec2(-time, 0.));

  vec3 texture = texture2D(uTexture, uv).rgb;

  gl_FragColor = vec4(texture, 1.);
}

And for a sweet touch, let’s blend the color with the the background.

This is basically the process! RT texture, repetition, and motion. Now that we’ve looked at the mesh for so long, using a BoxGeometry gets kind of boring, doesn’t it? Let’s change it in the next final bonus chapter.

Changing the geometry

As a kid, I loved playing and twisting these tangle toys, perhaps that’s the reason why I find satisfaction with knots and twisted shapes? Let this be an excuse to work with a torus knot geometry.

For the sake of rendering smooth text we’ll exaggerate the amount of tubular segments the knot has.

createMesh() {
  this.geometry = new THREE.TorusKnotGeometry(9, 3, 768, 3, 4, 3);

  ...
}

Inside the fragment shader, we’ll repeat any number of columns we want just to make sure to leave the same number of rows as the number of radial segments, which is 3.

varying vec2 vUv;

uniform sampler2D uTexture;
uniform float uTime;

void main() {
  vec2 repeat = vec2(12., 3.); // 12 columns, 3 rows
  vec2 uv = fract(vUv * repeat);

  vec3 texture = texture2D(uTexture, uv).rgb;
  texture *= vec3(uv.x, uv.y, 1.);

  gl_FragColor = vec4(texture, 1.);
}

And here’s our tangled torus knot:

Before adding time to the texture coordinates, I think we can make a fake shadow to give a better sense of depth. For that we’ll need to pass the position coordinates from the vertex shader using a varying.

varying vec2 vUv;
varying vec3 vPos;

void main() {
  vUv = uv;
  vPos = position;

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);
}

We now can use the z-coordinates and clamp them between 0 and 1, so that regions of the mesh that are farther from the screen get darker (towards 0), and those closer to the screen, lighter (towards 1).

varying vec3 vPos;

void main() {
  float shadow = clamp(vPos.z / 5., 0., 1.);

  gl_FragColor = vec4(vec3(shadow), 1.);
}

See? It sort of looks like white bone:

Now the final step! Multiply the shadow to blend it with the texture, and add time again.

varying vec2 vUv;
varying vec3 vPos;

uniform sampler2D uTexture;
uniform float uTime;

void main() {
  float time = uTime * 0.5;
  vec2 repeat = -vec2(12., 3.);
  vec2 uv = fract(vUv * repeat - vec2(time, 0.));

  vec3 texture = texture2D(uTexture, uv).rgb;

  float shadow = clamp(vPos.z / 5., 0., 1.);

  gl_FragColor = vec4(texture * shadow, 1.);
}

Fresh out of the oven! Look at this sexy torus coming out of the darkness. Internet high five!


We’ve just scratched the surface making repeated tiles of text, but there are many ways to add fun to the mixture. Could you use trigonometry or noise functions? Play with color? Text position? Or even better, do something with the vertex shader. The sky’s the limit! I encourage you to explore this and have fun with it.

Oh! And don’t forget to share it with me on Twitter. If you got any questions or suggestions, let me know.

Hope you learned something new. Till next time!

References and Credits

Tagged with:

Mario Carrillo

Creative Coder. Exploring technology and art. Freelance.

Stay up to date with the latest web design and development news and relevant updates from Codrops.

Feedback 2

Comments are closed.