Fast, dynamic 3D text in any TrueType font

I was looking for a way to do dynamic 3D text and found @TheLeftover’s excellent work on MeshWriter. After understanding how he built that class, I expanded on it and ended up with this.

It supports any TrueType font that opentype.js supports (in theory).

Hopefully the playground does a good job explaining how to use it:
https://playground.babylonjs.com/#IVGSG0#9

Basic usage:

  • Add the dependencies opentype.js and earcut.js to your project
  • Add the code to your project
  • Remove the hacked load of the dependencies – the section marked:
    // This is a hack to make this work inside a playground
  • Create a TextMeshFont object with your TrueType font (TTF) file
  • Create TextMesh objects with your text

Optional usage:

  • Update the text by calling TextMesh::updateText
  • Experiment with the constants at the top (MAX_BEZIER_STEPS and BEZIER_STEP_SIZE) if you want more/fewer triangles
  • If your font isn’t working, try using a two-sided material (disable backface culling) because some fonts have weird “winding orders” that cause all the triangles to be backwards

This is my first shot at creating something useful for BabylonJS so I expect issues. Feedback welcome!

Full credit to “riv,” the author of this post, for solving the hard parts.
(I moved this discussion from here: 3D text in Babylon to stop cluttering up the MeshWriter thread).

12 Likes

@pat_lvl17 that is so nice and big stuff
very well

1 Like

@pat_lvl17 it’s cool, very fast! I use MeshWriter in my game and I’m wondering how to set color and depth in your version? Nice work!

1 Like

Great milestone!

1 Like

For color, just assign whatever material you like. It’s just a regular mesh with some instances, and all instances share the same material as their parent. If you need different colors for different strings, you would have a few options:

  1. Create multiple different TextMeshFont objects and give each a different material (easiest but probably the slowest)
  2. Change vertex colors
  3. Use a custom shader that allows a different color per instance (most difficult but probably the fastest)

There’s no depth to the letters, they’re just flat shapes. You could probably add an “extrude” operation to make them 3D.

1 Like

Bravo!

@pat_lvl17 I will try, Thank you for details!

Looks awesome, thanks for sharing! Will definitely try it out!

I just realized one difficulty with materials… Each “glyph” (letter) is it’s own mesh. So you’d have to assign the material to each one:

Unless there’s some clever way to assign a material to the parent and have it apply to all child nodes.

I could add a parameter to the TextMeshFont constructor that specifies the material to be applied to every glyph.

Ok done:
https://playground.babylonjs.com/#IVGSG0#12

Now accepts a material parameter.

4 Likes

like this ?

@pat_lvl17 this is super, it would be great if you could also add thickness and visible from back side.

“Visible from back side” is easy, just set backFaceCulling to false in your material.

Giving it depth is a little harder, maybe I can figure out something clever with Babylon’s “extrude” methods.

I realized there’s no way to destroy the text once you’ve created it, so I added dispose() methods to both TextMesh and TextMeshFont:

https://playground.babylonjs.com/#IVGSG0#14

2 Likes

I’ve been using this text in my game for a while now and it seems pretty stable and fast. So for completeness, here is the final version that I’m using.

It does have some performance cost if you update the text too much, sadly, but I think that’s the case with any text system. I limit my text updates to about 10 per second (which is faster than the player can read anyway!) and everything’s cool.

const MAX_BEZIER_STEPS = 4;
const BEZIER_STEP_SIZE = 6.0;
const GLYPH_COORDS_SCALE = 0.001;

// class for converting path commands into point data
class TextMeshPolygon
{
  points = [];
  children = [];
  area = 0.0;

  distance(p1, p2)
  {
    const dx = p1.x - p2.x, dy = p1.y - p2.y;
    return Math.sqrt(dx * dx + dy * dy);
  }

  lerp(p1, p2, t)
  {
    return {x: (1 - t) * p1.x + t * p2.x, y: (1 - t) * p1.y + t * p2.y};
  }

  cross(p1, p2)
  {
    return p1.x * p2.y - p1.y * p2.x;
  }

  moveTo(p)
  {
    this.points.push(p);
  }

  lineTo(p)
  {
    this.points.push(p);
  }

  close()
  {
    let cur = this.points[this.points.length - 1];

    this.points.forEach(next =>
    {
      this.area += 0.5 * this.cross(cur, next);
      cur = next;
    });
  }

  conicTo(p, p1)
  {
    const p0 = this.points[this.points.length - 1];
    const dist = this.distance(p0, p1) + this.distance(p1, p);
    const steps = Math.max(2, Math.min(MAX_BEZIER_STEPS, dist / BEZIER_STEP_SIZE));

    for (let i = 1; i <= steps; ++i)
    {
      const t = i / steps;
      this.points.push(this.lerp(this.lerp(p0, p1, t), this.lerp(p1, p, t), t));
    }
  }

  cubicTo(p, p1, p2)
  {
    const p0 = this.points[this.points.length - 1];
    const dist = this.distance(p0, p1) + this.distance(p1, p2) + this.distance(p2, p);
    const steps = Math.max(2, Math.min(MAX_BEZIER_STEPS, dist / BEZIER_STEP_SIZE));

    for (let i = 1; i <= steps; ++i)
    {
      const t = i / steps;
      const a = this.lerp(this.lerp(p0, p1, t), this.lerp(p1, p2, t), t);
      const b = this.lerp(this.lerp(p1, p2, t), this.lerp(p2, p, t), t);
      this.points.push(this.lerp(a, b, t));
    }
  }

  inside(p)
  {
    const epsilon = 1e-6;
    let count = 0, cur = this.points[this.points.length - 1];

    this.points.forEach(next =>
    {
      const p0 = (cur.y < next.y ? cur : next);
      const p1 = (cur.y < next.y ? next : cur);

      if (p0.y < p.y + epsilon && p1.y > p.y + epsilon)
      {
        if ((p1.x - p0.x) * (p.y - p0.y) > (p.x - p0.x) * (p1.y - p0.y))
        {
          count++;
        }
      }

      cur = next;
    });
    return (count % 2) !== 0;
  }
}

class TextMeshFont
{
  constructor(fontURL, material, doneCallback)
  {
    this.doneCallback = doneCallback;
    this.material = material;
    this.glyphs = {};

    opentype.load(fontURL, (err, font) =>
    {
      if (err)
      {
        console.error(err);
        return;
      }

      if (!font)
      {
        console.error("Could not load font from", fontURL);
        return;
      }

      let fontName = "TextMeshFont";

      if (font.names && font.names.fontFamily && font.names.fontFamily.en)
      {
        fontName = font.names.fontFamily.en;
      }

      this.font = font;
      this.glyphsParent = new BABYLON.Mesh(fontName, scene);

      if (this.doneCallback)
      {
        this.doneCallback();
      }
    });
  }

  createGlyph(ch)
  {
    const glyph = this.font.charToGlyph(ch);

    if (glyph && glyph.advanceWidth)
    {
      this.glyphs[ch] =
        {
          index: glyph.index,
          advanceWidth: glyph.advanceWidth
        };

      if (glyph.path && glyph.path.commands && glyph.path.commands.length)
      {
        const polys = [];
        glyph.path.commands.forEach(({type, x, y, x1, y1, x2, y2}) =>
        {
          switch (type)
          {
            case 'M':
              polys.push(new TextMeshPolygon());
              polys[polys.length - 1].moveTo({x, y});
              break;
            case 'L':
              polys[polys.length - 1].moveTo({x, y});
              break;
            case 'C':
              polys[polys.length - 1].cubicTo({x, y}, {x: x1, y: y1}, {x: x2, y: y2});
              break;
            case 'Q':
              polys[polys.length - 1].conicTo({x, y}, {x: x1, y: y1});
              break;
            case 'Z':
              polys[polys.length - 1].close();
              break;
          }
        });

        // sort contours by descending area
        polys.sort((a, b) => Math.abs(b.area) - Math.abs(a.area));

        // classify contours to find holes and their 'parents'
        const root = [];

        for (let i = 0; i < polys.length; ++i)
        {
          let parent = null;

          for (let j = i - 1; j >= 0; --j)
          {
            // a contour is a hole if it is inside its parent and has different winding
            if (polys[j].inside(polys[i].points[0]) && polys[i].area * polys[j].area < 0)
            {
              parent = polys[j];
              break;
            }
          }

          if (parent)
          {
            parent.children.push(polys[i]);
          }
          else
          {
            root.push(polys[i]);
          }
        }

        const totalPoints = polys.reduce((sum, p) => sum + p.points.length, 0);
        const vertexData = new Float32Array(totalPoints * 2);
        let vertexCount = 0;
        const indices = [];

        function process(poly)
        {
          // construct input for earcut
          const coords = [];
          const holes = [];
          poly.points.forEach(({x, y}) => coords.push(x, y));
          poly.children.forEach(child =>
          {
            // children's children are new, separate shapes
            child.children.forEach(process);

            holes.push(coords.length / 2);
            child.points.forEach(({x, y}) => coords.push(x, y));
          });

          vertexData.set(coords, vertexCount * 2);
          earcut(coords, holes).forEach(i => indices.push(i + vertexCount));
          vertexCount += coords.length / 2;
        }

        root.forEach(process);

        const meshdata = new BABYLON.VertexData();
        const vertices = [];
        const normals = [];

        for (let i = 0; i < vertexCount; i++)
        {
          vertices.push(vertexData[i * 2] * GLYPH_COORDS_SCALE);
          vertices.push(vertexData[i * 2 + 1] * GLYPH_COORDS_SCALE);
          vertices.push(0);

          normals.push(0);
          normals.push(0);
          normals.push(-1);
        }

        meshdata.positions = vertices;
        meshdata.indices = indices;
        meshdata.normals = normals;

        this.glyphs[ch].mesh = new BABYLON.Mesh("glyph #" + this.glyphs[ch].index + ": " + ch, scene);
        this.glyphs[ch].mesh.setParent(this.glyphsParent);

        if (this.material)
        {
          this.glyphs[ch].mesh.material = this.material;
        }

        meshdata.applyToMesh(this.glyphs[ch].mesh);

        this.glyphs[ch].mesh.setEnabled(false);
      }
    }

    return this.glyphs[ch];
  }

  dispose()
  {
    for (let i in this.glyphs)
    {
      if (this.glyphs[i].mesh)
      {
        this.glyphs[i].mesh.dispose();
      }
    }

    this.glyphsParent.dispose();
  }
}

class TextMesh
{
  constructor(textMeshFont, text, scene)
  {
    this.textMeshFont = textMeshFont;
    this.clones = {};
    this.rootNode = new BABYLON.Mesh("TextMesh: " + text.substr(0, 12), scene);

    this.updateText(text);
  }

  updateText(text)
  {
    if (text === this.text)
    {
      return;
    }

    this.text = text;

    const cloneCounts = {};
    const pos = { x: 0, y: 0, z: 0 };
    const mergeArray = [];

    this.width = 0;

    for (let i = 0; i < text.length; i++)
    {
      const ch1 = text[i];

      if (ch1 === "\n")
      {
        pos.x = 0;
        pos.y -= 1.1;
      }
      else
      {
        const ch2 = text[i + 1];
        let g = this.textMeshFont.glyphs[ch1];

        if (!g)
        {
          g = this.textMeshFont.createGlyph(ch1);
        }

        if (g)
        {
          if (g.mesh)
          {
            let clone;
            cloneCounts[ch1] = cloneCounts[ch1] ? cloneCounts[ch1] + 1 : 1;

            if (!this.clones[ch1] || cloneCounts[ch1] > this.clones[ch1].length)
            {
              clone = g.mesh.clone("glyph-clone: " + ch1);

              if (this.clones[ch1])
              {
                this.clones[ch1].push(clone);
              }
              else
              {
                this.clones[ch1] = [ clone ];
              }
            }
            else
            {
              clone = this.clones[ch1][cloneCounts[ch1] - 1];
              clone.setEnabled(true);
            }

            Object.assign(clone.position, pos);
            clone.rotation = vec3();
            clone.scaling = vec3(1.0);
            clone.setEnabled(false);

            mergeArray.push(clone);
          }

          let advance = g.advanceWidth;

          if (advance)
          {
            if (ch2 && this.textMeshFont.glyphs[ch2])
            {
              const kern = this.textMeshFont.font.getKerningValue(g.index, this.textMeshFont.glyphs[ch2].index);

              if (kern)
              {
                advance += kern;
              }
            }

            pos.x += advance * GLYPH_COORDS_SCALE;

            if (pos.x > this.width)
            {
              this.width = pos.x;
            }
          }
        }
      }
    }

    if (this.meshNode)
    {
      this.meshNode.dispose();
    }

    this.meshNode = BABYLON.Mesh.MergeMeshes(mergeArray, false);
    this.meshNode.name = this.text.substr(0, 12);
    this.meshNode.parent = this.rootNode;
  }

  dispose()
  {
    this.meshNode.dispose();
    this.rootNode.dispose();
  }
}

// This is the same as just calling "new TextMeshFont" but it wraps the result in a promise
// The constructor for TextMeshFont is asynchronous
function createTextMeshFont(fontURL, material)
{
  return new Promise((resolve, reject) =>
  {
    const f = new TextMeshFont(fontURL, material, function()
    {
      if (f)
      {
        resolve(f);
      }
      else
      {
        reject();
      }
    });
  });
}
3 Likes