fancy 3d lettering

When rendering 3d text using Three.js with any font, the result is a geometry that is the extrusion of the text as a plane. That is, the cross section of any stroke in the 3d text is a rectangular shape.

What we’d like to do is generate 3d text using a cylindrical stroke: the cross section of the stroke is a circle. This article presents one way of doing this.

Intended audience
This blog post assumes you’re familiar with Javascript, React, SVG and basic 3d terminology

First, let’s see how 3d text can be rendered in Three.js.

Rendering 3d text in Three.js

We can utilize the following steps to render 3d text in Three.js:

  1. Loading a font
  2. Generating the geometry using TextGeometry
  3. Rendering the mesh created from the geometry

We’ll use the declarative bindings from React Three Fiber to render the mesh.

The example above renders 3d text in a stroke with a rectangular cross section. This is because fonts are usually defined using outlines.

outline font

This results in the letters of the font being defined as planes instead of a paths. The planes are then extruded along their normals.

To create the fancy 3d text, we need to have the letters of the font to not be defined as outlines. This can be achieved by using Single Line Fonts.

A single line font is a unique font that uses a singular line to define a letter or glyph. Unlike a traditional typeface, single-line fonts have no thickness (thank you Modern DIY Bride for the succinct definition).

Single line fonts are also known as engraving fonts, technical lettering fonts or pen plotter fonts and are hugely useful when performing machining or drawing operations. The single line in each character of the font can serve as the cross axis of a cylindrical mesh.

single line font

For this article, we’ll be using the EMSAllure single line font from the generous collection of fonts for use with Inkscape. You’ll find a screenshot of the font in the README.

Generating a tube from a single line font

We’ll explore one method for generating the tube through the following steps:

  1. Load and parse the single line font as SVG.
  2. Parse the SVG as a record of characters as keys and their shapes as properties
  3. Generate a list of points for each svg path
  4. Render the tube geometry

Load and parse the single line font as SVG

First, we download the single line font as an SVG string from the svg-fonts repository mentioned earlier. We then use the fetch function to load the SVG string and parse it as a DOM node:

1
2
3
4
5
6
7
8
async function loadFont(): Promise<string> {
  const svgResponse = await fetch("./EMSAllure.svg");
  const EMSAllure = await svgResponse.text();
  return EMSAllure;
}

const svg = await loadFont();
const fontDOM = new DOMParser().parseFromString(svgString, "image/svg+xml");

We need the SVG as a DOM node because the library we’ll use in step 3. requires a DOM node.

Parse the SVG as a record of characters as keys and svg paths as properties

The single line font is defined as an SVG with <glyph /> nodes for each character, each glyph containing a d attribute that describes the shape of the character.

We need to extract these shapes to be used in defining the paths for the cross axes of the tubes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
type CharacterDescriptor = {
  d: string,
  unicode: string,
  width: number,
  height: number,
};

function getNumericalAttribute(
  element: Element | null,
  attrName: string,
  fallback: number
): number {
  if (!element) return fallback;
  const attr = element.getAttribute(attrName);
  if (!attr) return fallback;
  return parseInt(attr);
}

function parseFont(
  element: Document,
  size = 24
): Record<string, CharacterDescriptor> {
  const result: Record<string, CharacterDescriptor> = {};

  const svgFont = element.getElementById("EMSAllure");
  const svgFontface = element.getElementsByTagName("font-face")[0];
  const svgGlyphs = element.getElementsByTagName("glyph");

  const fontHorizAdvX = getNumericalAttribute(svgFont, "horiz-adv-x", 378);
  const fontUnitsPerEm = getNumericalAttribute(
    svgFontface,
    "units-per-em",
    1000
  );
  const scale = size / fontUnitsPerEm;

  for (var i = 0; i < svgGlyphs.length; i++) {
    const svgGlyph = svgGlyphs[i];
    const d = svgGlyph.getAttribute("d");

    const unicode = svgGlyph.getAttribute("unicode");

    if (!unicode || !d) {
      continue;
    }

    const width = getNumericalAttribute(svgGlyph, "horiz-adv-x", fontHorizAdvX);

    result[`${unicode}`] = {
      unicode: unicode,
      width: width * scale,
      height: size,
      d,
    };
  }
  return result;
}

const parsedFont = parseFont(fontDOM);

We can obtain the shape of a particular character by extracting the character definition:

1
const AcharShape = parseFont["a"].d;

Generate a list of points for each svg path

To retrieve a list of points that describe the character’s shape, we’ll utilize the flatten-svg library.

1
2
3
4
5
6
7
var pathObject = document.createElementNS("http://www.w3.org/2000/svg", "path");
pathObject.setAttribute("d", AcharShape);

const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.appendChild(pathObject);

const AcharPoints = flattenSVG(svg);

Render the tube geometry

The Three.js library provides the TubeGeometry, a 3d object that generates a cylinder along a path as a curve.

To display the character as a Tube, we generate the curve from the list of points and build the geometry from the result.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import * as THREE from "three";

const AcharCurve = new THREE.CatmullRomCurve3(
  AcharPoints.points.map((points) => new THREE.Vector3(point.x, point.y, 0)),
  false,
  "catmullrom",
  0.5
);

const generatedTube = new THREE.TubeGeometry(curve, 256, 48, 8, false);

function CharTube() {
  return (
    <mesh>
      <primitive object={generatedTube} />
      <meshStandardMaterial />
    </mesh>
  );
}

The final render for the character “a” looks a little like this:

What next?

As can be seen from the final render and the process, there’s a lot we can improve. A few of these are listed below:

  • Provide caps for the open ends of the tube
  • Smoothen the curve
  • Remove the DOM SVG requirement; generate the list of points without requiring a DOM element.