Skip to content

Models and 3D Text

A Three.js guide for coding agents. Also covers TextGeometry, 3d text, typeface font, FontLoader, convert font to typeface json, center text geometry, and 20 more.

Show all 26 aliases

TextGeometry, 3d text, typeface font, FontLoader, convert font to typeface json, center text geometry, bounding box, computeBoundingBox, matcap material, import a model, GLTFLoader, glTF formats, glb vs gltf, Draco compression, DRACOLoader, No DRACOLoader instance provided, model too big or too small, gltf scene children missing, loop skips children when adding to scene, play gltf animation, AnimationMixer, AnimationClip, clipAction, animation not playing, how do I load a 3d model, how do I make 3d text

3D text: the pipeline

  • TextGeometry builds extruded 3D text, but it requires a font in the typeface JSON format, not a .ttf or .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 curveSegments and bevelSegments as low as the look allows. Toggle wireframe: true on 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.min is not at 0 because of bevelThickness and bevelSize, 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

  • MeshMatcapMaterial is 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 map textures 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 TorusGeometry and shared MeshMatcapMaterial cost 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 .glb binary 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 /static prefix
  • 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 is gltf.scene (a Group despite the name), which may nest Object3Ds with tiny scale values and extra objects like a PerspectiveCamera

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 the gltf.scene level: 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 point setDecoderPath at 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 of AnimationClips. Clips are not playable directly; you need an AnimationMixer, 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 null in 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 .ttf to 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.SRGBColorSpace on 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.children with a plain for-of while re-parenting, which skips every other child
  • Scaling an inner Object3D of a skinned model instead of gltf.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

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "models-and-3d-text" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems