3D text: the pipeline
TextGeometrybuilds extruded 3D text, but it requires a font in the typeface JSON format, not a.ttfor.woff- Two ways to get a typeface font:
- Convert any font at facetype.js (gero3.github.io/facetype.js)
- Grab one shipped with Three.js from
/node_modules/three/examples/fonts/(copy the font plus its LICENSE into/static/fonts/)
- With Vite you can also import the JSON directly:
import typefaceFont from 'three/examples/fonts/helvetiker_regular.typeface.json' - Check the license unless it is personal use
FontLoader and TextGeometry
Both classes live in the examples folder, not on the THREE namespace, so import them explicitly:
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'
import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'
- Unlike
TextureLoader, you cannot use the result synchronously: the rest of the text code must live inside the load success callback
const fontLoader = new FontLoader()
fontLoader.load('/fonts/helvetiker_regular.typeface.json', (font) =>
{
const textGeometry = new TextGeometry('Hello Three.js', {
font,
size: 0.5,
depth: 0.2,
curveSegments: 12,
bevelEnabled: true,
bevelThickness: 0.03,
bevelSize: 0.02,
bevelOffset: 0,
bevelSegments: 5
})
const text = new THREE.Mesh(textGeometry, material)
scene.add(text)
})
- Gotcha: the docs example uses values far larger than a typical scene, scale them down
- Text geometry generation is expensive. Do it rarely, and keep
curveSegmentsandbevelSegmentsas low as the look allows. Togglewireframe: trueon the material to eyeball triangle count, then remove it
Centering text
- A geometry carries bounding information (sphere by default) that Three.js uses for frustum culling: objects outside the camera frustum are skipped entirely
- To center precisely, compute the box bounding and translate the geometry itself, not the mesh. Moving the geometry keeps the mesh at the scene origin, so later rotations pivot around the text center
textGeometry.computeBoundingBox()
textGeometry.translate(
- (textGeometry.boundingBox.max.x - 0.02) * 0.5, // minus bevelSize
- (textGeometry.boundingBox.max.y - 0.02) * 0.5, // minus bevelSize
- (textGeometry.boundingBox.max.z - 0.03) * 0.5 // minus bevelThickness
)
- Gotcha:
boundingBox.minis not at 0 because ofbevelThicknessandbevelSize, which is why the exact version subtracts them - The shortcut that does all of the above:
textGeometry.center(). Use it in real code; the manual translate only exists to understand boundings
Matcaps for text
MeshMatcapMaterialis the cheap way to make text look great: the material picks colors from a sphere-render texture based on normals, no lights needed, excellent performance- 256x256 matcap textures are plenty. Source: github.com/nidorx/matcaps (mind licenses)
- Matcap and
maptextures are sRGB encoded, so declare it or colors render washed out:
const matcapTexture = textureLoader.load('/textures/matcaps/1.png')
matcapTexture.colorSpace = THREE.SRGBColorSpace
const material = new THREE.MeshMatcapMaterial({ matcap: matcapTexture })
Many objects cheaply
- Share one geometry and one material across every mesh instead of creating them inside the loop. 100 toruses with a shared
TorusGeometryand sharedMeshMatcapMaterialcost a fraction of 100 unique ones. The text can reuse the same material too
const donutGeometry = new THREE.TorusGeometry(0.3, 0.2, 20, 45)
for(let i = 0; i < 100; i++)
{
const donut = new THREE.Mesh(donutGeometry, material)
donut.position.set((Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10, (Math.random() - 0.5) * 10)
donut.rotation.x = Math.random() * Math.PI // symmetric shape: half a turn is enough
donut.rotation.y = Math.random() * Math.PI
const scale = Math.random()
donut.scale.set(scale, scale, scale) // scale uniformly, same value on all 3 axes
scene.add(donut)
}
Model formats: pick glTF by default
- Hundreds of 3D formats exist (OBJ, FBX, STL, PLY, COLLADA, 3DS, ...). glTF (GL Transmission Format, by the Khronos Group) is the real-time standard: it carries geometries, materials, textures, cameras, lights, scene graph, animations, skeletons, morphing, multiple scenes
- If you only need a bare geometry, a simpler format like OBJ or STL can be lighter. Test per project: does the file carry the data you need, how heavy is it, how long does decompression take
- Sample models for testing: github.com/KhronosGroup/glTF-Sample-Assets
The four glTF variants
- glTF (default):
model.gltf(JSON with transforms, materials, references) +model.bin(geometry buffers) + textures as separate images. Load only the.gltf; the rest loads automatically. Pros: editable, files load in parallel - glTF-Binary: one
.glbbinary file with everything embedded. Slightly lighter, easy to distribute, but you cannot resize or compress its textures without re-exporting - glTF-Draco: like the default but geometry buffers compressed with Google's Draco algorithm, much smaller
.bin. Draco can be applied to any of the variants - glTF-Embedded: one file, but JSON. Only benefit is a single editable file; usually the worst choice
- Choosing: want to tweak assets after export or want parallel loading, use default glTF. Want one opaque file, use Binary. Draco is a separate decision (below)
- Gotcha: your OS may hide file extensions; trust your editor's file tree
GLTFLoader usage
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
const gltfLoader = new GLTFLoader()
gltfLoader.load(
'/models/Duck/glTF/Duck.gltf',
(gltf) => { /* success */ },
(progress) => {},
(error) => {}
)
- Paths are served from
/static/roots without the/staticprefix - Imported meshes usually carry
MeshStandardMaterial, which is black without lights. Add lights (or an environment map) before assuming the load failed - Always
console.log(gltf)first and study the structure. The useful part isgltf.scene(aGroupdespite the name), which may nestObject3Ds with tinyscalevalues and extra objects like aPerspectiveCamera
Adding the model to your scene
- Simplest and usually best:
scene.add(gltf.scene)adds the whole group with correct transforms - Classic trap: looping
for(const child of gltf.scene.children) scene.add(child)silently skips elements. Adding a child to another scene removes it from its original parent, so the array shrinks while you iterate. Fixes:
// Drain until empty
while(gltf.scene.children.length)
{
scene.add(gltf.scene.children[0])
}
// Or copy the array first
const children = [...gltf.scene.children]
for(const child of children) scene.add(child)
- Adding only
gltf.scene.children[0]can drop parts (multi-part models like the FlightHelmet) or lose parent transforms - Wrong size: scale the loaded scene, not inner objects. Skinned models (
Bone+SkinnedMesh) especially should be scaled at thegltf.scenelevel:gltf.scene.scale.set(0.025, 0.025, 0.025)
Draco compression
- Draco-compressed glTF fails silently-ish with the warning
No DRACOLoader instance provided. Wire the decoder in:
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js'
const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')
gltfLoader.setDRACOLoader(dracoLoader)
- The decoder is separate code (JS and WASM builds that can run in a worker). Copy
/node_modules/three/examples/jsm/libs/draco/into/static/and pointsetDecoderPathat it - A GLTFLoader with a DRACOLoader attached still loads uncompressed files fine; the decoder loads lazily only when needed
Draco tradeoffs
- Not a free win: you pay for loading the DRACOLoader class plus the decoder, and decoding costs CPU that can cause a brief freeze at startup even with worker plus WASM
- Rule of thumb: one model with a ~100kB geometry does not need Draco. Many megabytes of models where a startup hiccup is acceptable, Draco pays off
Animations: AnimationMixer
- glTF carries animations as
gltf.animations, an array ofAnimationClips. Clips are not playable directly; you need anAnimationMixer, a player bound to one object (create one mixer per animated object)
let mixer = null
gltfLoader.load('/models/Fox/glTF/Fox.gltf', (gltf) =>
{
gltf.scene.scale.set(0.025, 0.025, 0.025)
scene.add(gltf.scene)
mixer = new THREE.AnimationMixer(gltf.scene)
const action = mixer.clipAction(gltf.animations[0]) // returns AnimationAction
action.play()
})
const tick = () =>
{
// deltaTime from your clock
if(mixer)
{
mixer.update(deltaTime)
}
// render, requestAnimationFrame ...
}
- Pitfall:
action.play()alone does nothing visible. The mixer must be updated with delta time every frame - Pitfall: the mixer is created inside the async callback, so declare it as
nullin outer scope and null-check in the tick, or the first frames throw - Switch animations by picking a different index in
clipAction(gltf.animations[i])
Quick verification
- The Three.js editor (threejs.org/editor) is a fast sanity check for a model: drag and drop a single-file model (glb or embedded), add an AmbientLight and DirectionalLight from the Add menu to see it. Multi-file glTF cannot be dropped in
Common mistakes
- Passing a
.ttfto TextGeometry instead of a converted typeface JSON - Writing text-creation code outside the FontLoader success callback, so the font is undefined
- Recreating TextGeometry frequently or leaving segment counts high, burning CPU on triangles nobody sees
- Forgetting
colorSpace = THREE.SRGBColorSpaceon matcap and color textures - Creating a new geometry and material per mesh inside a loop instead of sharing them
- Non-uniform random scale (different x, y, z) distorting objects
- Iterating
gltf.scene.childrenwith a plain for-of while re-parenting, which skips every other child - Scaling an inner
Object3Dof a skinned model instead ofgltf.scene - Loading a Draco file without
setDRACOLoader, or shipping Draco for a single small model where the decoder costs more than it saves - Playing an AnimationAction without updating the mixer with delta time each frame