Learn Creative Coding (#71) - 3D Typography
Way back in episode 26 we turned letterforms into generative art on a flat canvas -- splitting words into individual characters, placing them along curves, feeding them through noise fields. That was 2D. The letters lived on a plane and all the depth was an illusion made from layering and transparency.
Now we have Three.js and a real 3D pipeline. Text can be geometry. Actual extruded meshes with depth, normals, lighting, shadows. Each letter becomes an object you can grab, rotate, explode, scatter into particles, or wrap around a helix. Typography becomes sculpture.
This episode covers the full stack: loading fonts, creating TextGeometry, centering and alignment, per-character control for animation, dissolving text into particles, wrapping text along 3D paths, flat sprite-based text for performance, and a creative exercise where we build a breathing typographic sculpture. It's one of those episodes where the creative possibilities really open up once you see what's possible.
Loading fonts
Three.js doesn't use TTF or OTF files directly. It needs a JSON font format that describes glyph outlines as 2D paths. The FontLoader reads these JSON files and hands you a Font object that TextGeometry can work with.
The Three.js repo ships a few converted fonts in examples/fonts/. For custom fonts, the Facetype.js converter (facetype.js online tool) takes any TTF/OTF and spits out the JSON. Simpler fonts with fewer control points per glyph render faster and create less geometry -- something to keep in mind if you're instancing thousands of characters.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x080810);
const camera = new THREE.PerspectiveCamera(
60, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.set(0, 2, 8);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
scene.add(new THREE.AmbientLight(0x222244, 0.8));
const sun = new THREE.DirectionalLight(0xffeedd, 2.0);
sun.position.set(4, 6, 3);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
scene.add(sun);
const loader = new FontLoader();
loader.load(
'https://threejs.org/examples/fonts/helvetiker_bold.typeface.json',
function (font) {
createText(font);
}
);
The FontLoader is async -- it fetches the JSON file and calls your callback with the parsed Font object. Everything that depends on the font goes inside that callback (or you wrap it in a Promise/async-await pattern, whatever you prefer).
TextGeometry: extruding letters into 3D
Once you have a font, TextGeometry takes a string and produces a BufferGeometry where each letter is extruded from its 2D outline into a solid 3D shape:
function createText(font) {
const geometry = new TextGeometry('HELLO', {
font: font,
size: 1.0, // height of the letters
depth: 0.3, // extrusion depth (how thick the text is)
curveSegments: 6, // smoothness of curved letter parts
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 3
});
const material = new THREE.MeshStandardMaterial({
color: 0x4488cc,
roughness: 0.3,
metalness: 0.4
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
scene.add(mesh);
}
The bevel parameters add a rounded edge to the letters. Without bevel the extrusion has sharp 90-degree edges that look harsh under lighting. With bevel the edges catch highlights smoothly, giving the text a polished, manufactured look. bevelThickness is how deep the bevel cuts into the extrusion depth. bevelSize is how far it extends from the letter outline. bevelSegments controls the smoothness of the bevel curve -- 3-4 is usually enough.
curveSegments affects how many line segments approximate the curves in letters like O, S, B. At 4 you can see the faceting. At 12 it's smooth. At 24 you're wasting triangles. The sweet spot for most fonts is 6-8.
The depth parameter is what makes this 3D -- it's the Z thickness of the extruded text. A depth of 0 gives you flat text outlines (still a mesh, but paper-thin). Values around 0.2-0.5 relative to the font size give a nice chunky look. Go higher for block letters, lower for elegant thin type.
Centering text
Here's the thing that catches everyone: TextGeometry doesn't auto-center. The geometry starts at the origin and extends to the right and up. If you just add it to the scene, the H is at (0,0,0) and the O is way off to the right. For centered compositions you need to compute the bounding box and offset:
function createCenteredText(font, text) {
const geometry = new TextGeometry(text, {
font: font,
size: 1.0,
depth: 0.3,
curveSegments: 6,
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
bevelSegments: 3
});
// compute bounding box
geometry.computeBoundingBox();
const bbox = geometry.boundingBox;
// center the geometry
const cx = -(bbox.max.x - bbox.min.x) / 2 - bbox.min.x;
const cy = -(bbox.max.y - bbox.min.y) / 2 - bbox.min.y;
const cz = -(bbox.max.z - bbox.min.z) / 2 - bbox.min.z;
geometry.translate(cx, cy, cz);
const material = new THREE.MeshStandardMaterial({
color: 0xccaa44,
roughness: 0.25,
metalness: 0.5
});
const mesh = new THREE.Mesh(geometry, material);
mesh.castShadow = true;
return mesh;
}
computeBoundingBox() calculates the axis-aligned bounding box from the vertex data. Then we translate the geometry so that the center of the bounding box sits at the origin. Now the text is centered and you can position, rotate, and scale it from the middle -- way more intuitive for composition.
This is the same technique you'd use for any asymmetric geometry, not just text. Procedural meshes from ep066 often need the same centering step. The geometry doesn't know where you want the pivot point to be -- you have to tell it.
Per-character control
A single TextGeometry for the whole string is fine for static text. But the creative magic starts when each character is its own mesh. Now every letter can be positioned, rotated, scaled, and animated independently:
function createPerCharacterText(font, text) {
const group = new THREE.Group();
const material = new THREE.MeshStandardMaterial({
color: 0x66aaff,
roughness: 0.3,
metalness: 0.4
});
let xOffset = 0;
const charMeshes = [];
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === ' ') {
xOffset += 0.5;
continue;
}
const charGeo = new TextGeometry(char, {
font: font,
size: 1.0,
depth: 0.25,
curveSegments: 6,
bevelEnabled: true,
bevelThickness: 0.02,
bevelSize: 0.015,
bevelSegments: 2
});
charGeo.computeBoundingBox();
const charWidth = charGeo.boundingBox.max.x - charGeo.boundingBox.min.x;
const mesh = new THREE.Mesh(charGeo, material.clone());
mesh.position.x = xOffset;
mesh.castShadow = true;
charMeshes.push({
mesh: mesh,
baseX: xOffset,
baseY: 0,
baseZ: 0,
width: charWidth,
index: i
});
group.add(mesh);
xOffset += charWidth + 0.08; // char width + kerning gap
}
// center the whole group
const totalWidth = xOffset - 0.08;
group.position.x = -totalWidth / 2;
return { group, charMeshes };
}
Each character gets its own TextGeometry and Mesh. We track the xOffset manually for spacing -- the bounding box width of each character plus a small gap for kerning. Real font kerning tables would give better spacing (the JSON font format does include kerning pairs) but manual spacing with a fixed gap works fine for creative coding purposes.
We clone the material so each character can be colored independently later. Without .clone(), changing one character's color changes them all (they'd share the same material instance).
The charMeshes array stores each character's base position and index -- we'll need this for animation.
Wave animation
With per-character control, a wave animation is straightforward. Each letter oscillates vertically with a phase offset based on its position:
const clock = new THREE.Clock();
const { group, charMeshes } = createPerCharacterText(font, 'CREATIVE CODE');
scene.add(group);
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
for (let i = 0; i < charMeshes.length; i++) {
const c = charMeshes[i];
// vertical wave
c.mesh.position.y = c.baseY + Math.sin(t * 2.0 + c.index * 0.5) * 0.3;
// subtle rotation
c.mesh.rotation.z = Math.sin(t * 1.5 + c.index * 0.4) * 0.08;
// color shift based on wave position
const hue = 0.55 + Math.sin(t * 0.8 + c.index * 0.3) * 0.1;
c.mesh.material.color.setHSL(hue, 0.6, 0.45);
}
controls.update();
renderer.render(scene, camera);
}
animate();
The phase offset (c.index * 0.5) creates the wave propagation -- each letter peaks slightly after the previous one. The Z rotation adds a gentle rocking motion. The color shift means the hue ripples across the text in sync with the vertical wave. It's a simple effect but it reads clearly and looks alive.
You can layer more motion on top. Scale pulsing (1.0 + Math.sin(t + index * 0.3) * 0.1), X displacement for a breathing spread, Z push-pull for depth. Each layer is just another Math.sin with different frequency and phase offset. Same as 2D animation from ep016 and ep017, but now with three axes and full 3D lighting picking up every subtle tilt and shift.
Entrance animation
Per-character entrance creates that classic text-reveal where letters fly in one by one. Each character starts offscreen (or invisible) and animates to its final position with a staggerd delay:
function animateEntrance(charMeshes, t) {
const entranceDuration = 0.6;
const staggerDelay = 0.08;
for (let i = 0; i < charMeshes.length; i++) {
const c = charMeshes[i];
const charTime = t - i * staggerDelay;
if (charTime < 0) {
// not started yet
c.mesh.scale.setScalar(0.001);
c.mesh.position.y = c.baseY + 3;
continue;
}
const progress = Math.min(charTime / entranceDuration, 1.0);
// ease out cubic
const ease = 1.0 - Math.pow(1.0 - progress, 3);
c.mesh.position.y = c.baseY + (1.0 - ease) * 3;
c.mesh.scale.setScalar(ease);
c.mesh.rotation.x = (1.0 - ease) * Math.PI * 0.5;
// fade in via opacity if using transparent material
c.mesh.material.opacity = ease;
}
}
The stagger delay is the key. Each character starts its animation 0.08 seconds after the previous one. The cubic ease-out gives a snappy start that decelerates smoothly into the final position. The rotation on X makes each letter flip forward as it lands. Combined with the scale-up from near-zero, it's a punchy entrance that draws the eye left to right across the word.
For the material opacity to work, you'd need transparent: true on the material. Or skip the opacity and just use the scale/position animation -- scaling from near-zero to 1.0 already creates a convincing pop-in.
Text to particles
This is one of my favourite text effects. Take the solid text geometry and dissolve it into a cloud of particles. The idea: sample points on the surface of the text mesh, create a particle for each sample point, then animate the particles outward. The text shape emerges from the particle cloud (or dissolves into it, depending on direction).
import { MeshSurfaceSampler } from 'three/addons/math/MeshSurfaceSampler.js';
function textToParticles(font, text) {
// create the text geometry (we won't render this directly)
const textGeo = new TextGeometry(text, {
font: font,
size: 1.5,
depth: 0.4,
curveSegments: 8,
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
bevelSegments: 2
});
textGeo.computeBoundingBox();
const bbox = textGeo.boundingBox;
textGeo.translate(
-(bbox.max.x + bbox.min.x) / 2,
-(bbox.max.y + bbox.min.y) / 2,
-(bbox.max.z + bbox.min.z) / 2
);
// create a temporary mesh for sampling
const tempMesh = new THREE.Mesh(
textGeo,
new THREE.MeshBasicMaterial()
);
// sample points on the text surface
const sampler = new MeshSurfaceSampler(tempMesh).build();
const particleCount = 15000;
const positions = new Float32Array(particleCount * 3);
const targets = new Float32Array(particleCount * 3);
const randoms = new Float32Array(particleCount * 3);
const tempPosition = new THREE.Vector3();
for (let i = 0; i < particleCount; i++) {
sampler.sample(tempPosition);
const i3 = i * 3;
// target position: on the text surface
targets[i3] = tempPosition.x;
targets[i3 + 1] = tempPosition.y;
targets[i3 + 2] = tempPosition.z;
// start position: scattered randomly in a sphere
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const r = 3 + Math.random() * 4;
randoms[i3] = r * Math.sin(phi) * Math.cos(theta);
randoms[i3 + 1] = r * Math.sin(phi) * Math.sin(theta);
randoms[i3 + 2] = r * Math.cos(phi);
// initial positions match scattered positions
positions[i3] = randoms[i3];
positions[i3 + 1] = randoms[i3 + 1];
positions[i3 + 2] = randoms[i3 + 2];
}
const particleGeo = new THREE.BufferGeometry();
particleGeo.setAttribute('position',
new THREE.BufferAttribute(positions, 3));
const particleMat = new THREE.PointsMaterial({
color: 0x44ccff,
size: 0.025,
sizeAttenuation: true,
transparent: true,
opacity: 0.8
});
const particles = new THREE.Points(particleGeo, particleMat);
return { particles, positions, targets, randoms };
}
MeshSurfaceSampler is the hero utility here -- it picks random points distributed evenly across the surface of any mesh. We used Points for basic particles in ep065 and briefly mentioned surface sampling; this is a concrete application. Each sampled point becomes a particle's target position (where it should end up to form the text shape). The start position is randomly scattered in a sphere around the origin.
Now animate between the two states:
const { particles, positions, targets, randoms } = textToParticles(font, 'CODE');
scene.add(particles);
function animateParticles(t) {
// morph factor: 0 = scattered, 1 = text shape
const morph = (Math.sin(t * 0.4) + 1) / 2; // oscillates 0..1
const pos = particles.geometry.attributes.position.array;
for (let i = 0; i < pos.length; i += 3) {
pos[i] = randoms[i] * (1 - morph) + targets[i] * morph;
pos[i + 1] = randoms[i + 1] * (1 - morph) + targets[i + 1] * morph;
pos[i + 2] = randoms[i + 2] * (1 - morph) + targets[i + 2] * morph;
// add subtle noise to prevent perfect stillness
pos[i] += Math.sin(t * 2 + i) * 0.005 * (1 - morph * 0.8);
pos[i + 1] += Math.cos(t * 1.7 + i * 0.5) * 0.005 * (1 - morph * 0.8);
}
particles.geometry.attributes.position.needsUpdate = true;
}
The morph factor lerps each particle between its random position and its target on the text surface. When morph is 0, particles are scattered. When morph is 1, they form the text. The sine wave makes it oscillate back and forth -- the text assembles, holds briefly, then dissolves again. The subtle noise prevents perfect stillness when the text is formed, so it always has a slight shimmer. Very organic.
15,000 particles with per-frame position updates is well within javascript performance limits. For 100K+ you'd want to move the morph calculation into a vertex shader (same approach as the grass field animation from ep070 -- store both positions as attributes, lerp in the shader based on a uniform).
Text on a 3D path
Placing characters along a 3D curve is the 3D version of what we did in episode 26 with text on bezier paths. In Three.js we can use any Curve3 subclass -- CatmullRomCurve3, CubicBezierCurve3, or a simple custom curve:
function textOnPath(font, text, curve) {
const group = new THREE.Group();
const material = new THREE.MeshStandardMaterial({
color: 0xcc6644,
roughness: 0.35,
metalness: 0.3
});
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (char === ' ') continue;
const t = (i + 1) / (text.length + 1); // parameter along curve
// position on curve
const point = curve.getPointAt(t);
// tangent for orientation
const tangent = curve.getTangentAt(t);
const charGeo = new TextGeometry(char, {
font: font,
size: 0.5,
depth: 0.12,
curveSegments: 5,
bevelEnabled: true,
bevelThickness: 0.01,
bevelSize: 0.008,
bevelSegments: 2
});
// center each character
charGeo.computeBoundingBox();
const bb = charGeo.boundingBox;
charGeo.translate(
-(bb.max.x + bb.min.x) / 2,
-(bb.max.y + bb.min.y) / 2,
-(bb.max.z + bb.min.z) / 2
);
const mesh = new THREE.Mesh(charGeo, material.clone());
mesh.position.copy(point);
// orient character to face along the curve tangent
const up = new THREE.Vector3(0, 1, 0);
const axis = new THREE.Vector3().crossVectors(up, tangent).normalize();
const radians = Math.acos(up.dot(tangent));
if (axis.length() > 0.001) {
mesh.quaternion.setFromAxisAngle(axis, radians);
}
// rotate so the face of the text points outward
mesh.rotateOnAxis(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
mesh.castShadow = true;
group.add(mesh);
}
return group;
}
// helix curve
const helixPoints = [];
for (let i = 0; i <= 200; i++) {
const t = i / 200;
const angle = t * Math.PI * 4; // two full turns
helixPoints.push(new THREE.Vector3(
Math.cos(angle) * 3,
t * 4 - 2,
Math.sin(angle) * 3
));
}
const helix = new THREE.CatmullRomCurve3(helixPoints);
const helixText = textOnPath(font, 'CREATIVE CODING IN 3D', helix);
scene.add(helixText);
Each character is positioned at a point on the curve and oriented using the curve's tangent vector. The tangent tells us which direction the curve is going at that point -- we rotate the character to face along that direction so the text follows the curve naturally.
The quaternion math looks intimidating but the idea is simple: compute the rotation that takes the default "up" direction and aligns it with the tangent vector. crossVectors gives us the rotation axis, acos(dot(...)) gives us the rotation angle, and setFromAxisAngle creates the quaternion. Then one extra rotation to flip the text face outward (since we computed alignment for the Y axis but want the text readable from outside the curve).
A helix, a spiral on a plane, a trefoil knot -- any 3D curve works. The text wraps around it like thread on a spool. For animated text-on-path, update the curve points or the parameter offsets each frame and the letters flow along the curve. Combine with per-character color and scale variation for extra richness.
Flat text with CanvasTexture
TextGeometry creates real 3D meshes with potentially thousands of triangles per character. For labels, HUD text, captions, or any text that doesn't need to be a 3D object, there's a much cheaper approach: render text onto a canvas, use it as a texture on a flat plane.
function createTextSprite(text, fontSize, color) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// measure text to size the canvas
ctx.font = `${fontSize}px Arial`;
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
canvas.width = Math.ceil(textWidth) + 20;
canvas.height = fontSize + 20;
// re-set font after canvas resize (it resets)
ctx.font = `${fontSize}px Arial`;
ctx.fillStyle = color;
ctx.textBaseline = 'middle';
ctx.fillText(text, 10, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
texture.minFilter = THREE.LinearFilter;
const mat = new THREE.SpriteMaterial({
map: texture,
transparent: true
});
const sprite = new THREE.Sprite(mat);
sprite.scale.set(
canvas.width / canvas.height * 2,
2,
1
);
return sprite;
}
// add labels to a scene
const label = createTextSprite('Vertex Count: 12,480', 48, '#88ccff');
label.position.set(0, 4, 0);
scene.add(label);
Sprites always face the camera (billboarding), so the text is always readable regardless of camera angle. The canvas rendering uses whatever fonts the browser has installed -- no JSON font loading needed. And the GPU cost is one textured quad per label, nothing compared to TextGeometry's thousands of triangles.
The tradeoff is obvious: no 3D depth, no lighting response, no shadows. This is for informational text, not sculptural text. Use TextGeometry when the text IS the art. Use CanvasTexture sprites when the text DESCRIBES the art.
SDF text in shaders
For resolution-independent text with glow, outlines, and shadow effects, signed distance field (SDF) rendering is the technique most game engines use. We touched on SDFs in episode 33 (drawing shapes) -- the same principle applies to text.
The idea: pre-render each character as a distance field texture where each pixel stores how far it is from the nearest edge of the glyph. In the fragment shader, threshold the distance to get a crisp glyph edge at any scale. Adjust the threshold for outlines, soften it for glow, offset it for drop shadows.
// SDF text material (assuming you have an SDF font atlas texture)
const sdfMaterial = new THREE.ShaderMaterial({
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D uFontAtlas;
uniform vec3 uColor;
uniform vec3 uOutlineColor;
uniform float uTime;
varying vec2 vUv;
void main() {
float dist = texture2D(uFontAtlas, vUv).a;
// crisp text edge
float alpha = smoothstep(0.45, 0.55, dist);
// outline (slightly larger threshold)
float outline = smoothstep(0.35, 0.45, dist);
// glow (even larger, softer threshold)
float glow = smoothstep(0.1, 0.5, dist) * 0.3;
glow *= 0.5 + sin(uTime * 2.0) * 0.2; // pulsing glow
vec3 col = mix(uOutlineColor, uColor, alpha);
float finalAlpha = max(outline, glow);
gl_FragColor = vec4(col, finalAlpha);
}
`,
uniforms: {
uFontAtlas: { value: null }, // your SDF font atlas texture
uColor: { value: new THREE.Color(0xffffff) },
uOutlineColor: { value: new THREE.Color(0x2244aa) },
uTime: { value: 0 }
},
transparent: true,
side: THREE.DoubleSide
});
The SDF approach gives you infinitely scalable text from a single texture -- zoom in as far as you want and the edges stay crisp (within the SDF resolution). Compare that to CanvasTexture where zooming in reveals pixelation, or TextGeometry where scaling up reveals polygon edges. SDF is the sweet spot when you need high-quality flat text at varying distances.
Building a full SDF font atlas is its own topic (tools like msdf-gen create multi-channel signed distance field textures that are even sharper). For creative coding, the concept matters more than the production pipeline -- knowing that SDF text exists and how it works means you can reach for it when CanvasTexture isn't sharp enough and TextGeometry is too heavy.
Creative exercise: breathing typographic sculpture
Allez, time to put it all together. We'll create a single word as individual 3D characters, apply noise-based vertex displacement so each letter deforms organically, add emissive material with bloom, and slowly orbit the camera. A word as a glowing, breathing piece of 3D art.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { FontLoader } from 'three/addons/loaders/FontLoader.js';
import { TextGeometry } from 'three/addons/geometries/TextGeometry.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x040408);
const camera = new THREE.PerspectiveCamera(
55, window.innerWidth / window.innerHeight, 0.1, 80
);
camera.position.set(0, 1, 10);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.8;
scene.add(new THREE.AmbientLight(0x111122, 0.3));
const rim = new THREE.DirectionalLight(0x4466aa, 1.0);
rim.position.set(-4, 3, -5);
scene.add(rim);
// post-processing
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
1.2, 0.5, 0.3
);
composer.addPass(bloom);
composer.addPass(new OutputPass());
const clock = new THREE.Clock();
const charData = [];
const loader = new FontLoader();
loader.load(
'https://threejs.org/examples/fonts/helvetiker_bold.typeface.json',
function (font) {
const word = 'DREAM';
let xOffset = 0;
for (let i = 0; i < word.length; i++) {
const charGeo = new TextGeometry(word[i], {
font: font,
size: 2.0,
depth: 0.8,
curveSegments: 10,
bevelEnabled: true,
bevelThickness: 0.05,
bevelSize: 0.04,
bevelSegments: 4
});
charGeo.computeBoundingBox();
const bb = charGeo.boundingBox;
const charWidth = bb.max.x - bb.min.x;
// center vertically and in depth
charGeo.translate(
0,
-(bb.max.y + bb.min.y) / 2,
-(bb.max.z + bb.min.z) / 2
);
// store original positions for displacement
const posAttr = charGeo.attributes.position;
const origPositions = new Float32Array(posAttr.array.length);
origPositions.set(posAttr.array);
const hue = 0.55 + (i / word.length) * 0.2;
const charMat = new THREE.MeshStandardMaterial({
color: 0x000000,
emissive: new THREE.Color().setHSL(hue, 0.7, 0.35),
emissiveIntensity: 2.5,
roughness: 0.15,
metalness: 0.3
});
const mesh = new THREE.Mesh(charGeo, charMat);
mesh.position.x = xOffset;
scene.add(mesh);
charData.push({
mesh: mesh,
material: charMat,
origPositions: origPositions,
posAttr: posAttr,
index: i,
baseX: xOffset
});
xOffset += charWidth + 0.15;
}
// center the whole word
const halfWidth = (xOffset - 0.15) / 2;
for (const c of charData) {
c.mesh.position.x -= halfWidth;
c.baseX -= halfWidth;
}
animate();
}
);
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
for (const c of charData) {
const pos = c.posAttr.array;
const orig = c.origPositions;
// vertex displacement: noise-like deformation
for (let j = 0; j < pos.length; j += 3) {
const ox = orig[j];
const oy = orig[j + 1];
const oz = orig[j + 2];
const displaceAmount = 0.08;
const freq = 3.0;
const speed = 1.2;
pos[j] = ox + Math.sin(oy * freq + t * speed + c.index) * displaceAmount;
pos[j + 1] = oy + Math.sin(oz * freq + t * speed * 0.8 + c.index * 1.3) * displaceAmount;
pos[j + 2] = oz + Math.sin(ox * freq + t * speed * 1.1 + c.index * 0.7) * displaceAmount;
}
c.posAttr.needsUpdate = true;
c.mesh.geometry.computeVertexNormals();
// gentle breathing scale
const breathe = 1.0 + Math.sin(t * 0.8 + c.index * 0.6) * 0.03;
c.mesh.scale.setScalar(breathe);
// emissive pulse
c.material.emissiveIntensity = 2.5 + Math.sin(t * 1.2 + c.index * 0.8) * 0.6;
}
controls.update();
composer.render();
}
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
Each letter deforms independently using sine waves that sample from the vertex's original position -- this creates a noise-like warping effect where the surface ripples and breathes. The per-character index offset means each letter's deformation is out of phase with its neighbors. computeVertexNormals() recalculates the lighting normals each frame so the deformed surface catches light correctly (without this, the lighting would be based on the original flat normals and the deformation would look wrong).
The emissive gradient across the letters (blue-ish to purple-ish via the hue shift) means each letter glows a slightly different color. The bloom pass picks up the emissive intensity and wraps each letter in a soft colored halo. The auto-rotating camera orbits the sculpture slowly so you see it from all angles.
This is a case where every technique from the recent Three.js episodes comes together: TextGeometry for the base shapes, per-vertex displacement from ep066, emissive materials from ep068, bloom post-processing from ep069, and the general animation patterns from ep067. The word "DREAM" becomes a glowing, deforming, breathing 3D sculpture that feels genuinely organic despite being made entirely of code.
Performance with text geometry
TextGeometry produces a lot of triangles. A single character in a detailed font with bevel can easily be 2,000-4,000 triangles. A 10-character word with bevel could be 30,000-40,000 triangles. That's not a problem for a single word, but if you want hundreds of words (a typographic landscape, a wall of text, a font specimen), the triangle count adds up fast.
Some strategies:
Reduce curveSegments and bevelSegments. These are the biggest geometry multipliers. curveSegments: 4 and bevelSegments: 1 give you maybe 30% of the triangles compared to the defaults, and for distant or small text the quality difference is barely visible.
Use simpler fonts. Geometric sans-serif fonts (like Helvetica, Arial, Futura) have fewer control points than serif fonts (Times, Garamond) or decorative fonts. Fewer control points = fewer triangles per character.
Instancing for repeated characters. If you're displaying text with repeated letters (think a matrix rain effect), create one geometry per unique character and instance it. 26 geometries for the alphabet, instanced thousands of times -- this is exactly the InstancedMesh pattern from ep070. One draw call per unique character instead of one per instance.
CanvasTexture for non-sculptural text. If the text doesn't need to be a 3D object (no lighting, no depth, no deformation), use the flat sprite approach. A single textured quad is orders of magnitude cheaper than an extruded geometry.
What's ahead
We've now covered text as a first-class 3D creative material -- loaded fonts, extruded them into solid geometry, centered and aligned them, built per-character animation systems, dissolved text into particles, wrapped letters along 3D curves, rendered cheap flat text via canvas textures, and built a full typographic sculpture with vertex displacement and bloom.
Next we'll explore how to make 3D scenes respond to audio input -- connecting frequency and amplitude data to geometry, lights, materials, and camera parameters. Sound-reactive 3D combines everything we've built in this arc into something you can feel as much as see.
't Komt erop neer...
- Three.js uses JSON font files (converted from TTF/OTF via Facetype.js). FontLoader loads them asynchronously, then TextGeometry extrudes the 2D glyph outlines into solid 3D meshes. Parameters:
sizefor letter height,depthfor extrusion thickness,curveSegmentsfor curve smoothness,bevel*for rounded edges - TextGeometry doesn't auto-center. Call
geometry.computeBoundingBox()thengeometry.translate()to offset the geometry so its center sits at the origin. Same technique works for any asymmetric procedural geometry - Per-character control: create a separate TextGeometry per character, track each one's position and index. This enables wave animations (sin with phase offset per index), staggered entrance animations (delayed start per character), independent rotation, scale, and color changes. Clone materials per character for independent coloring
- Text to particles: use
MeshSurfaceSamplerto sample random points on the text mesh surface, create a Points system at those positions. Lerp between scattered random positions and surface positions using a morph factor to animate assembly/dissolution. Add subtle noise to prevent perfect stillness at the formed state - Text on 3D paths: position characters along a CatmullRomCurve3 (or any Curve3) using
getPointAt()for position andgetTangentAt()for orientation. Each character rotates to face along the curve tangent using quaternion math. Works with helices, spirals, knots -- any 3D curve - Flat text via CanvasTexture: render text onto a canvas with the 2D API, create a texture from it, apply to a Sprite (always faces camera) or PlaneGeometry. Orders of magnitude cheaper than TextGeometry but no 3D depth, lighting, or shadows. Use for labels, HUD, captions
- SDF text: signed distance field textures give resolution-independent crisp text at any scale with glow, outline, and shadow effects built into the fragment shader. The technique most game engines use. More complex pipeline than CanvasTexture but much higher quality at varying distances
- Performance: TextGeometry produces 2,000-4,000 triangles per character. Reduce with lower curveSegments/bevelSegments, simpler fonts, InstancedMesh for repeated characters (ep070 pattern), or switch to flat CanvasTexture for non-sculptural text
Sallukes! Thanks for reading.
X