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();
}
});
});
}