← Back to the library

GUIDE3D & design

Use your own font in three.js: convert it to typeface JSON

three.js 3D text needs a typeface JSON file. A short fontTools script converts only the characters you need from a TTF, OTF or WOFF2, ready for FontLoader and ExtrudeGeometry.

WHAT YOU’LL GET

A tiny, self-hosted typeface file (about 12.5 KB for 37 characters) that lets three.js extrude your real brand font.

WHO IT’S FOR

Developers using three.js TextGeometry or FontLoader with a custom font.

DIFFICULTY

Intermediate

TIME

15 minutes

WORKS WITH

Claude Codex

Get the full file

The whole resource as one Markdown file for your notes or your AI workspace.

FREE

What you will build

A small, self-hosted typeface JSON file that lets three.js draw and extrude your real brand font. You convert only the characters you need from a TTF, OTF or WOFF2 file with a short Python script built on fontTools, load it with three.js FontLoader, and turn letters into 3D shapes. For the dainer.ai hero, 37 characters of Archivo Black came to about 12.5 KB.

Before you start

  • Python 3 and pip. Install fontTools with WOFF2 support: python3 -m pip install fonttools brotli. fontTools lists brotli as the extra needed for WOFF 2.0 (fonttools[woff] installs it for you).
  • The font file you already serve on your site, for example public/assets/archivo.woff2.
  • A licence that allows embedding and conversion. The SIL Open Font License does. Keep the licence file next to the font.
  • three.js in your project (npm install three). This was tested with three 0.185.
  • About 15 minutes.

How it works

three.js does not read TTF or WOFF2 directly for 3D text. Its FontLoader reads a JSON format, often called typeface JSON, where each character stores its advance width and its outline as a string of drawing commands. The three.js docs point to an online converter, facetype.js, which can also limit the character set. This script does the same job offline, in one command you can keep in your repo and rerun.

The script uses a fontTools "pen": an object that receives drawing calls (move, line, curve, close) as fontTools walks a glyph's outline. The pen writes those calls in the exact order three.js expects.

  1. Open the font with fontTools and find each character's glyph through the font's Unicode map.
  2. Draw each glyph into a custom pen that records m, l, q, b and z commands.
  3. Save the commands, the advance width and a few font metrics as JSON.
  4. Load the JSON with FontLoader, call font.generateShapes(text, size), and extrude the shapes.

Step 1: Check the font and its licence

Open the licence that came with the font and confirm it allows embedding and modification. Converting a subset is a derived file, so the licence should cover that. Archivo Black is under the SIL Open Font License, and the site keeps archivo-OFL.txt in its assets licence folder.

Then check what kind of outlines the font has. It changes nothing in the script, but it explains the output.

PYTHON
from fontTools.ttLib import TTFont
f = TTFont("archivo.woff2")
print(f.flavor, "glyf" in f, "CFF " in f, "fvar" in f)

glyf means TrueType outlines made of quadratic curves. CFF means PostScript outlines with cubic curves. fvar means a variable font with adjustable axes such as weight. Our file printed woff2 True False False: a static TrueType font inside WOFF2.

Check

  • The licence file is in your project next to the font.
  • You know whether the font is variable. If it is, read the variable font note in Gotchas.

Step 2: Understand the typeface JSON format

The output is a single object. The parts FontLoader uses are:

  • glyphs: one entry per character, keyed by the character itself.
  • ha: the horizontal advance, which is how far the next character moves along.
  • o: the outline as a space-separated string of commands and numbers.
  • resolution: the font's units per em. three.js scales every number by size / resolution.
  • boundingBox and underlineThickness: used to compute line height for multi-line text.

The commands are m x y (move), l x y (line), q (quadratic curve), b (cubic curve) and z (close). The order of numbers inside q and b is the detail that breaks most hand-written converters. Reading three.js's FontLoader.js source, a q command takes the end point first and then the control point, and a b command takes the end point and then the two control points. The pen below writes them in that order.

Check

  • Open any three.js sample font JSON and find a q command. It has four numbers after it: end x, end y, control x, control y.

Step 3: Save the conversion script

This is the script from the site's repo. It uses BasePen, whose docs say a subclass must override _moveTo, _lineTo and _curveToOne, and may override _qCurveToOne and _closePath. For TrueType curves with several off-curve points in a row, BasePen splits them into single quadratic segments before calling _qCurveToOne, so the pen only ever handles one curve at a time.

PYTHON
"""Convert selected glyphs of a TrueType/WOFF2 font into three.js typeface JSON.
Run: python3 font-to-typeface.py <font> <out.json> <chars>
"""
import json, sys
from fontTools.ttLib import TTFont
from fontTools.pens.basePen import BasePen

class TypefacePen(BasePen):
    def __init__(self, glyphSet):
        super().__init__(glyphSet)
        self.cmds = []
    def _moveTo(self, p): self.cmds += ["m", *map(round, p)]
    def _lineTo(self, p): self.cmds += ["l", *map(round, p)]
    # three.js 'q' order: end x, end y, control x, control y
    def _qCurveToOne(self, c, p): self.cmds += ["q", *map(round, p), *map(round, c)]
    # three.js 'b' order: end, control1, control2
    def _curveToOne(self, c1, c2, p): self.cmds += ["b", *map(round, p), *map(round, c1), *map(round, c2)]
    def _closePath(self): self.cmds += ["z"]

font_path, out_path, chars = sys.argv[1], sys.argv[2], sys.argv[3]
font = TTFont(font_path)
cmap, gs = font.getBestCmap(), font.getGlyphSet()
hmtx, head, hhea, post = font["hmtx"], font["head"], font["hhea"], font["post"]
glyphs = {}
for ch in dict.fromkeys(chars):
    name = cmap.get(ord(ch))
    if not name:
        continue
    pen = TypefacePen(gs)
    gs[name].draw(pen)
    xs = [v for v in pen.cmds if isinstance(v, int)][0::2]
    glyphs[ch] = {
        "ha": hmtx[name][0],
        "x_min": min(xs) if xs else 0,
        "x_max": max(xs) if xs else 0,
        "o": " ".join(str(c) for c in pen.cmds),
    }
data = {
    "glyphs": glyphs,
    "familyName": font["name"].getDebugName(1),
    "ascender": hhea.ascent,
    "descender": hhea.descent,
    "underlinePosition": post.underlinePosition,
    "underlineThickness": post.underlineThickness,
    "boundingBox": {"xMin": head.xMin, "yMin": head.yMin, "xMax": head.xMax, "yMax": head.yMax},
    "resolution": head.unitsPerEm,
    "original_font_information": {"source": "Archivo Black (SIL Open Font License)"},
    "cssFontWeight": "normal",
    "cssFontStyle": "normal",
}
json.dump(data, open(out_path, "w"), separators=(",", ":"))
print(out_path, len(glyphs), "glyphs", head.unitsPerEm, "upm")

getBestCmap() returns the font's best Unicode map, from character code to glyph name. dict.fromkeys(chars) removes duplicate characters while keeping their order. Change the source line to name your own font and licence.

Check

  • Run the script with no arguments. An IndexError means Python found fontTools and only the arguments are missing. A ModuleNotFoundError means fontTools is not installed yet.

Step 4: Convert only the characters you need

Pass the characters as the third argument. Include ?, because three.js falls back to the ? glyph when a character is missing.

BASH
python3 font-to-typeface.py archivo.woff2 archivo-typeface.json "abcdefghijklmnopqrstuvwxyz.0123456789?"

The script prints the file name, the number of glyphs and the units per em. Our run for dainer.ai? printed 8 glyphs 1000 upm: seven unique characters from the word plus the question mark.

Inspect the result before you use it:

PYTHON
import json
d = json.load(open("archivo-typeface.json"))
print(sorted(d["glyphs"]), d["resolution"], d["familyName"], d["ascender"], d["descender"])
print(d["glyphs"]["d"]["o"][:80])

The site's own file, which was made without the question mark, shows 37 characters, a resolution of 1000, the family name Archivo Black, an ascender of 878 and a descender of -210, and an outline starting with m 444 0 l 427 75 q ....

Check

  • Every character you asked for is listed. A missing one has no entry in the font's Unicode map.
  • The file is small. Ours was 12,545 bytes for 37 characters.

Step 5: Load it and make 3D letters

Put the JSON in your public assets and load it once. FontLoader.parse(json) returns a Font, and font.generateShapes(text, size) returns shapes you can pass to ShapeGeometry or ExtrudeGeometry.

JS
import * as THREE from "three";
import { FontLoader } from "three/examples/jsm/loaders/FontLoader.js";

const json = await fetch("/assets/archivo-typeface.json").then((r) => r.json());
const font = new FontLoader().parse(json);
const shapes = font.generateShapes("d", 100);
const geo = new THREE.ExtrudeGeometry(shapes, {
  depth: 18, curveSegments: 10,
  bevelEnabled: true, bevelThickness: 2.2, bevelSize: 0.9, bevelSegments: 4,
});

We ran exactly this in Node against the site's file with three 0.185. The letter d produced one shape with one hole (the counter of the bowl), a geometry with two material groups (caps and sides), and a width of about 59 units at size 100, next to an advance of 66.7.

Check

  • Letters with counters, such as a, d, e and o, show holes, not filled blobs.
  • geo.groups lists material index 0 and 1, so you can give caps and sides different materials.

Step 6: Place letters yourself when spacing matters

generateShapes lays out a string by adding each glyph's advance (ha) to the next one's position. Reading the loader source, it does not apply kerning pairs, and it knows nothing about CSS letter-spacing. If your heading uses tight tracking, like the dainer.ai wordmark at -0.085em, a whole-word mesh will not match the HTML.

The fix is to generate one shape per character and place each one on the position the browser measured for that letter. That is what the hero does, and it is described in Brief: turn your logo text into floating 3D letters.

Check

  • Overlay the 3D letters on the CSS text at 50% opacity. Per-letter placement lines up; a single-string mesh drifts along the word.

Step 7: Serve it and cache it

Treat the JSON like any other static asset. Commit it next to the font, serve it from your public folder, and load it lazily in the component that needs it so it does not block first paint. Because it only holds the characters you converted, rerun the script when the text changes, for example if a new letter appears in your heading.

Check

  • The browser network tab shows one small JSON request, and only on pages that use 3D text.
  • The licence file ships in the same folder.

Gotchas we hit

Most of these came from reading the three.js loader source while writing the converter, before the first run, which is why the first file worked.

  • Curve order is easy to get backwards. three.js reads q and b with the end point first. A converter that writes control points first draws the wrong curves.
  • Missing glyphs are quiet. If a character is not in the JSON, three.js uses the ? glyph if it exists, and only logs an error if neither exists. Always include ?, and include every character your text can contain.
  • CSS spacing does not carry over. The site's heading uses -0.085em letter-spacing, which whole-string layout ignores. The hero places each letter on its measured CSS position instead.
  • WOFF2 needs brotli. Without it fontTools cannot decompress WOFF2 files. Install brotli or fonttools[woff].
  • Variable fonts. getGlyphSet() returns the default instance. fontTools accepts a location such as font.getGlyphSet(location={"wght": 900}) to draw a specific weight. Our font was static, so we did not need it.
  • x_min and x_max are rough. The script takes them from every point, including curve controls. FontLoader does not use them for layout, so it does not matter for three.js.

Take it further

  • Add a --chars-from option that reads the characters from your page copy, so the file always covers your heading.
  • Convert a second weight for body text in 3D, keeping each file to the characters it needs.
  • Read Brief: turn your logo text into floating 3D letters to use this file in a full animated hero.
  • Read three.js: why your black 3D text looks grey before you choose materials for the extruded letters.

Quick checklist

  • The font licence allows embedding, and the licence file is in the repo.
  • fonttools and brotli are installed.
  • The pen writes q and b with the end point first.
  • The character list includes every character you render, plus ?.
  • The output lists all expected glyphs and is a few KB, not hundreds.
  • FontLoader().parse(json) and generateShapes produce shapes with holes where letters have counters.
  • Letters that must match CSS spacing are placed one by one.
  • You rerun the script whenever the text gains a new character.