A short tutorial on how to create animated text in Three.js with three-bmfont-text and give it a nice look using shaders.

There are many ways of displaying text inside a Three.js application: drawing text to a canvas and use it as a texture, importing a 3D model of a text, creating text geometry, and using bitmap fonts — or BMFonts. This last one has a bunch of helpful properties on how to render text into a scene.

Text in WebGL opens many possibilities to create amazing things on the web. A great example is Sorry, Not Sorry by awesome folks at Resn or this refraction experiment by Jesper Vos. Let’s use Three.js with three-bmfont-text to create text in 3D and give it a nice look using shaders.

Three-bmfont-text is a tool created by Matt DesLauriers and Jam3 that renders BMFont files in Three.js, allowing to batch glyphs into a single geometry. It also supports things like word-wrapping, kerning, and msdf — please watch Zach Tellman’s talk on distance fields, he explains it very good.

With all that said, let’s begin.

Getting started

Before everything, we need to load a font file to create a geometry three-bmfont-text provides packed with bitmap glyphs. Then, we load a texture atlas of the font which is a collection of all characters inside a single image. After loading is done, we’ll pass the geometry and material to a function that will initialize a Three.js setup. To generate these files check out this repository.

const createGeometry = require('three-bmfont-text'); const loadFont = require('load-bmfont'); loadFont('fonts/Lato.fnt', (err, font) => { // Create a geometry of packed bitmap glyphs const geometry = createGeometry({ font, text: 'OCEAN' }); // Load texture containing font glyphs const loader = new THREE.TextureLoader(); loader.load('fonts/Lato.png', (texture) => { // Start and animate renderer init(geometry, texture); animate(); }); });

Creating the text mesh

It’s time to create the mesh with the msdf shader three-bmfont-text comes with. This module has a default vertex and fragment shader that forms sharp text. We’ll change them later to produce a wavy effect.

const MSDFShader = require('three-bmfont-text/shaders/msdf'); function init(geometry, texture) { // Create material with msdf shader from three-bmfont-text const material = new THREE.RawShaderMaterial(MSDFShader({ map: texture, color: 0x000000, // We'll remove it later when defining the fragment shader side: THREE.DoubleSide, transparent: true, negate: false, })); // Create mesh of text const mesh = new THREE.Mesh(geometry, material); mesh.position.set(-80, 0, 0); // Move according to text size mesh.rotation.set(Math.PI, 0, 0); // Spin to face correctly scene.add(mesh); }

And now the text should appear on screen. Cool, right? You can zoom and rotate with the mouse to see how crisp the text is.

Let’s make it more interesting in the next step.

GLSL

Vertex shader

To oscillate the text, trigonometry is our best friend. We want to make a sinusoidal movement along the Y and Z axis — up and down, inside and outside the screen. A vertex shader fits the bill for this since it handles the position of the vertices of the mesh. But before this, let’s add the shaders to the material and create a time uniform that will fuel them.

function init(geometry, texture) { // Create material with msdf shader from three-bmfont-text const material = new THREE.RawShaderMaterial(MSDFShader({ vertexShader, fragmentShader, map: texture, side: THREE.DoubleSide, transparent: true, negate: false, })); // Create time uniform from default uniforms object material.uniforms.time = { type: 'f', value: 0.0 }; } function animate() { requestAnimationFrame(animate); render(); } function render() { // Update time uniform each frame mesh.material.uniforms.time.value = this.clock.getElapsedTime(); mesh.material.uniformsNeedUpdate = true; renderer.render(scene, camera); }

Then we’ll pass it to the vertex shader:

// Variable qualifiers that come with the msdf shader attribute vec2 uv; attribute vec4 position; uniform mat4 projectionMatrix; uniform mat4 modelViewMatrix; varying vec2 vUv; // We passed this one uniform float time; void main() { vUv = uv; vec3 p = vec3(position.x, position.y, position.z); float frequency1 = 0.035; float amplitude1 = 20.0; float frequency2 = 0.025; float amplitude2 = 70.0; // Oscillate vertices up/down p.y += (sin(p.x * frequency1 + time) * 0.5 + 0.5) * amplitude1; // Oscillate vertices inside/outside p.z += (sin(p.x * frequency2 + time) * 0.5 + 0.5) * amplitude2; gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0); }

Frequency and amplitude are properties of a wave that determine their quantity and their “height”. Because we are using a sine wave to move the vertices, these properties can help control the behavior of the wave. I encourage you to tweak the values to observe different results.

Okay, so here is the tidal movement:

Fragment shader

For the fragment shader, I thought about just interpolating between two shades of blue – a light and a dark one. Simple as that.

The built-in GLSL function mix helps interpolating between two values. We can use it along with a cosine function mapped from 1 to 0, so it can go back and forth these values and change the color of the text — a value of 1 will give a dark blue and 0 a light blue, interpolating the colors between.

#ifdef GL_OES_standard_derivatives #extension GL_OES_standard_derivatives : enable #endif // Variable qualifiers that come with the shader precision highp float; uniform float opacity; uniform vec3 color; uniform sampler2D map; varying vec2 vUv; // We passed this one uniform float time; // HSL to RGB color conversion module #pragma glslify: hsl2rgb = require(glsl-hsl2rgb) float median(float r, float g, float b) { return max(min(r, g), min(max(r, g), b)); } void main() { // This is the code that comes to produce msdf vec3 sample = texture2D(map, vUv).rgb; float sigDist = median(sample.r, sample.g, sample.b) - 0.5; float alpha = clamp(sigDist/fwidth(sigDist) + 0.5, 0.0, 1.0); // Colors vec3 lightBlue = hsl2rgb(202.0 / 360.0, 1.0, 0.5); vec3 navyBlue = hsl2rgb(238.0 / 360.0, 0.47, 0.31); // Goes from 1.0 to 0.0 and vice versa float t = cos(time) * 0.5 + 0.5; // Interpolate from light to navy blue vec3 newColor = mix(lightBlue, navyBlue, t); gl_FragColor = vec4(newColor, alpha * opacity); if (gl_FragColor.a < 0.0001) discard; }

And here it is! The final result:

Other examples

There is plenty of stuff one can do with three-bmfont-text. You can make words fall:

Enter and leave:

Distortion:

Water blend:

Or mess with noise:

I encourage you to explore more to create something that gets you excited, and please share it with me via twitter or email. You can reach me there, too if you got any questions, or comment below.

Hope you learned something new. Cheers!

References and Credits