3D MeshPart Text Module

I’m writing a text to mesh module. It’s great since it kerns+spaces characters perfectly (I had to extract the kerning table from the font + a bunch of other metrics using python). I just have to run one python script in Blender and it outputs all the meshes + a bunch of important lua files for me. Theoretically, I can port any font I want to Roblox! Here’s several example results:

image

image

You can get the module here:

Sample usage:

local SourceSansProRegular = require(workspace.SourceSansProRegular)
local model = Instance.new("Model", workspace)
SourceSansProRegular.Draw{
	text = "Hello, World!",
	parent = model,
	cframe = CFrame.new(0, 0, 0),
	thickness = 0.05,
	scale = 1
}

You can get a bit more documentation in the module itself.

Don’t expect any maintenance + extra features with this module. I’m just posting it here for anyone that wants to play with it. I’ll probably make a more official release with more fonts later (+ a plugin)

(note: really important that you parent this outside of workspace since the meshes are stored as MeshParts in the module itself)

40 Likes

This is really cool and would be perfect for buildings and storefronts. Wish the example module used a bold font though.

Any chance you could also opensource the Python scripts, so we can import the fonts we want?

1 Like

Sure. Open blender in a directory and run the following code (requires fonttools to be installed.)

Code
from __future__ import print_function
import os
import sys
from collections import OrderedDict
from fontTools import ttLib
from fontTools.ttLib.tables._c_m_a_p import CmapSubtable
import bpy
import math
from mathutils import Vector

# insert path to font here
# otf and ttf only.
font_path = "C:\\Users\\Roblox\\Desktop\\dump-py\\SourceSansPro-Regular.otf"
KernFeatureTag = "kern"
cmapTableName = "cmap"
hmtxTableName = "hmtx"
headTableName = "head"
GPOSTableName = "GPOS"
# This is a rather... obscure codepoint. The only way to get the
# .notdef glyph (box with X in most fonts) to render is by having
# it render something that isn't in the font.
notdef_code = 78008


def is_included_codepoint(cp):
    return cp >= 32 and cp <= 126


class MyLeftClass:
    def __init__(self):
        self.glyphs = []
        self.class_1_record = 0


class MyRightClass:
    def __init__(self):
        self.glyphs = []
        self.class_2_record = 0


def collect_unique_kern_lookup_list_indexes(feature_record):
    unique_kern_lookup_index_list = []
    for feat_rec_item in feature_record:
        # print(feat_rec_item.FeatureTag)
        # GPOS feature tags (e.g. kern, mark, mkmk, size) of each ScriptRecord
        if feat_rec_item.FeatureTag == KernFeatureTag:
            feature = feat_rec_item.Feature

            for feat_lookup_item in feature.LookupListIndex:
                if feat_lookup_item not in unique_kern_lookup_index_list:
                    unique_kern_lookup_index_list.append(feat_lookup_item)

    return unique_kern_lookup_index_list


class OTFReader(object):

    def __init__(self, fontPath):
        self.font = ttLib.TTFont(fontPath)
        self.kerning_pairs = {}
        self.single_pairs = {}
        self.class_pairs = {}
        self.pair_pos_list = []
        self.all_left_classes = {}
        self.all_right_classes = {}
        self.codepoint_to_glyph_map = {}

        if GPOSTableName not in self.font:
            print("The font has no %s table" % GPOSTableName, file=sys.stderr)
            self.goodbye()

        if cmapTableName not in self.font:
            print("The font has no %s table" % cmapTableName, file=sys.stderr)
            self.goodbye()

        if hmtxTableName not in self.font:
            print("The font has no %s table" % hmtxTableName, file=sys.stderr)
            self.goodbye()

        if headTableName not in self.font:
            print("The font has no %s table" % headTableName, file=sys.stderr)
            self.goodbye()

        else:
            self.analyze_font()
            self.get_glyph_map()
            self.find_kerning_lookups()
            self.get_pair_pos()
            self.get_single_pairs()
            self.get_class_pairs()

    def get_glyph_map(self):
        outtables = []
        for table in self.cmap.tables:
            if table.format in [4, 12, 13, 14]:
                outtables.append(table)
            newtable = CmapSubtable.newSubtable(4)
            newtable.platformID = table.platformID
            newtable.platEncID = table.platEncID
            newtable.language = table.language
            newtable.cmap = table.cmap
            newtable.format = table.format
            outtables.append(newtable)

        cmap_entry_to_use = None
        best_subtable_format = 0
        for table in outtables:
            if table.platformID == 0 and table.format > best_subtable_format:
                cmap_entry_to_use = table
                best_subtable_format = table.format

        for cp, glyphname in cmap_entry_to_use.cmap.items():
            self.codepoint_to_glyph_map[cp] = glyphname

    def goodbye(self):
        print("The fun ends here.", file=sys.stderr)
        return

    def analyze_font(self):
        self.cmap = self.font[cmapTableName]
        self.hmtx_metrics = self.font[hmtxTableName].metrics
        self.gpos_table = self.font[GPOSTableName].table
        self.script_list = self.gpos_table.ScriptList
        self.feature_list = self.gpos_table.FeatureList
        self.feature_count = self.feature_list.FeatureCount
        self.feature_record = self.feature_list.FeatureRecord
        self.unique_kern_lookup_index_list = collect_unique_kern_lookup_list_indexes(
            self.feature_record)
        self.head = self.font[headTableName]
        self.unitsPerEm = self.head.unitsPerEm
        self.yMin = self.head.yMin
        self.yMax = self.head.yMax

    def find_kerning_lookups(self):
        if not len(self.unique_kern_lookup_index_list):
            print("The font has no %s feature." % KernFeatureTag, file=sys.stderr)
            self.goodbye()

        "LookupList:"
        self.lookup_list = self.gpos_table.LookupList
        self.lookups = []
        for kernLookupIndex in sorted(self.unique_kern_lookup_index_list):
            lookup = self.lookup_list.Lookup[kernLookupIndex]

            # Confirm this is a GPOS LookupType 2; or
            # using an extension table (GPOS LookupType 9):

            """
            Lookup types:
            1   Single adjustment           Adjust position of a single glyph
            2   Pair adjustment             Adjust position of a pair of glyphs
            3   Cursive attachment          Attach cursive glyphs
            4   MarkToBase attachment       Attach a combining mark to a base glyph
            5   MarkToLigature attachment   Attach a combining mark to a ligature
            6   MarkToMark attachment       Attach a combining mark to another mark
            7   Context positioning         Position one or more glyphs in context
            8   Chained Context positioning Position one or more glyphs in chained context
            9   Extension positioning       Extension mechanism for other positionings
            10+ Reserved for future use
            """
            if lookup.LookupType not in [2, 9]:
                print("""
                Info: GPOS LookupType %s found.
                This type is neither a pair adjustment positioning lookup (GPOS LookupType 2),
                nor using an extension table (GPOS LookupType 9), which are the only ones supported.
                """ % lookup.LookupType, file=sys.stderr)
                continue
            self.lookups.append(lookup)

    def get_pair_pos(self):
        for lookup in self.lookups:
            for subtable_item in lookup.SubTable:

                if subtable_item.LookupType == 9:  # extension table
                    if subtable_item.ExtensionLookupType == 8:  # contextual
                        print("Contextual Kerning not (yet?) supported.", file=sys.stderr)
                        continue
                    elif subtable_item.ExtensionLookupType == 2:
                        subtable_item = subtable_item.ExtSubTable

                # if subtable_item.Coverage.Format not in [1, 2]:  # previous fontTools
                if subtable_item.Format not in [1, 2]:
                    print("WARNING: Coverage format %d is not yet supported." % subtable_item.Coverage.Format, file=sys.stderr)

                if subtable_item.ValueFormat1 not in [0, 4, 5]:
                    print("WARNING: ValueFormat1 format %d is not yet supported." % subtable_item.ValueFormat1, file=sys.stderr)

                if subtable_item.ValueFormat2 not in [0]:
                    print("WARNING: ValueFormat2 format %d is not yet supported." % subtable_item.ValueFormat2, file=sys.stderr)

                self.pair_pos_list.append(subtable_item)

                # Each glyph in this list will have a corresponding PairSet
                # which will contain all the second glyphs and the kerning
                # value in the form of PairValueRecord(s)
                # self.first_glyphsList.extend(subtable_item.Coverage.glyphs)

    def get_single_pairs(self):
        for pair_pos in self.pair_pos_list:
            if pair_pos.Format == 1:
                # single pair adjustment

                first_glyphsList = pair_pos.Coverage.glyphs

                # This iteration is done by index so we have a way
                # to reference the first_glyphsList:
                for pair_set_index, _ in enumerate(pair_pos.PairSet):
                    for pair_value_record_item in pair_pos.PairSet[pair_set_index].PairValueRecord:
                        second_glyph = pair_value_record_item.SecondGlyph
                        value_format = pair_pos.ValueFormat1

                        if value_format == 5:  # RTL kerning
                            kern_value = "<%d 0 %d 0>" % (
                                pair_value_record_item.Value1.XPlacement,
                                pair_value_record_item.Value1.XAdvance)
                        elif value_format == 0:  # RTL pair with value <0 0 0 0>
                            kern_value = "<0 0 0 0>"
                        elif value_format == 4:  # LTR kerning
                            kern_value = pair_value_record_item.Value1.XAdvance
                        else:
                            print("\tValueFormat1 = %d" % value_format, file=sys.stdout)
                            continue  # skip the rest

                        first_glyph = first_glyphsList[pair_set_index]
                        self.kerning_pairs[(
                            first_glyph, second_glyph)] = kern_value
                        self.single_pairs[(
                            first_glyph, second_glyph)] = kern_value

    def get_class_pairs(self):
        for loop, pair_pos in enumerate(self.pair_pos_list):
            if pair_pos.Format == 2:

                left_classes = {}
                right_classes = {}

                # Find left class with the Class1Record index="0".
                # This first class is mixed into the "Coverage" table
                # (e.g. all left glyphs) and has no class="X" property
                # that is why we have to find the glyphs in that way.

                lg0 = MyLeftClass()

                # list of all glyphs kerned to the left of a pair:
                all_left_glyphs = pair_pos.Coverage.glyphs
                # list of all glyphs contained within left-sided kerning classes:
                # allLeftClassGlyphs = pair_pos.ClassDef1.classDefs.keys()

                single_glyphs = []
                class_glyphs = []

                for g_name, class_id in pair_pos.ClassDef1.classDefs.items():
                    if class_id == 0:
                        single_glyphs.append(g_name)
                    else:
                        class_glyphs.append(g_name)

                # lg0.glyphs =  list(set(all_left_glyphs) - set(allLeftClassGlyphs)) # coverage glyphs minus glyphs in a class (including class 0)
                # coverage glyphs minus glyphs in real class (without class 0)
                lg0.glyphs = list(set(all_left_glyphs) - set(class_glyphs))

                lg0.glyphs.sort()
                left_classes[lg0.class_1_record] = lg0
                classname = "class_%s_%s" % (loop, lg0.class_1_record)
                self.all_left_classes[classname] = lg0.glyphs

                # Find all the remaining left classes:
                for left_glyph in pair_pos.ClassDef1.classDefs:
                    class_1_record = pair_pos.ClassDef1.classDefs[left_glyph]

                    if class_1_record != 0:  # this was the crucial line.
                        lg = MyLeftClass()
                        lg.class_1_record = class_1_record
                        left_classes.setdefault(
                            class_1_record, lg).glyphs.append(left_glyph)
                        self.all_left_classes.setdefault(
                            "class_%s_%s" % (loop, lg.class_1_record), lg.glyphs)

                # Same for the right classes:
                for right_glyph in pair_pos.ClassDef2.classDefs:
                    class_2_record = pair_pos.ClassDef2.classDefs[right_glyph]
                    rg = MyRightClass()
                    rg.class_2_record = class_2_record
                    right_classes.setdefault(
                        class_2_record, rg).glyphs.append(right_glyph)
                    self.all_right_classes.setdefault(
                        "class_%s_%s" % (loop, rg.class_2_record), rg.glyphs)

                for record_l in left_classes:
                    for record_r in right_classes:
                        if pair_pos.Class1Record[record_l].Class2Record[record_r]:
                            value_format = pair_pos.ValueFormat1

                            if value_format in [4, 5]:
                                kern_value = pair_pos.Class1Record[record_l].Class2Record[record_r].Value1.XAdvance
                            elif value_format == 0:
                                # value_format zero is caused by a value of <0 0 0 0> on a class-class pair; skip these
                                continue
                            else:
                                print("\tValueFormat1 = %d" % value_format, file=sys.stdout)
                                continue  # skip the rest

                            if kern_value != 0:
                                left_classname = "class_%s_%s" % (
                                    loop, left_classes[record_l].class_1_record)
                                right_classname = "class_%s_%s" % (
                                    loop, right_classes[record_r].class_2_record)

                                self.class_pairs[(
                                    left_classname, right_classname)] = kern_value

                                for l in left_classes[record_l].glyphs:
                                    for r in right_classes[record_r].glyphs:
                                        if (l, r) in self.kerning_pairs:
                                            # if the kerning pair has already been assigned in pair-to-pair kerning
                                            continue
                                        else:
                                            if value_format == 5:  # RTL kerning
                                                kern_value = "<%d 0 %d 0>" % (
                                                    pair_pos.Class1Record[record_l].Class2Record[record_r].Value1.XPlacement, pair_pos.Class1Record[record_l].Class2Record[record_r].Value1.XAdvance)

                                            self.kerning_pairs[(
                                                l, r)] = kern_value

                        else:
                            print("ERROR", file=sys.stderr)


def get_center(o):
    local_bbox_center = 0.125 * sum((Vector(b) for b in o.bound_box), Vector())
    global_bbox_center = o.matrix_world * local_bbox_center
    return global_bbox_center


if __name__ == "__main__":
    assumedFontPath = font_path
    if os.path.exists(assumedFontPath) and os.path.splitext(assumedFontPath)[1].lower() in [".otf", ".ttf"]:
        fontPath = assumedFontPath
        f = OTFReader(fontPath)
        kerning_pairs = f.kerning_pairs

        codepoint_to_glyph_map = f.codepoint_to_glyph_map
        glyph_to_codepoint_map = {}
        for codepoint, glyph in codepoint_to_glyph_map.items():
            glyph_to_codepoint_map[glyph] = codepoint

        # output lua files:
        # kern.lua is a dump of kerning_table, containing then kerning between each character (non-contextual)
        # cmap.lua is a dump of codepoint_to_glyph_map, containing the mapping of the codepoint to glyph (useful for naming)
        # width.lua is a dump of width_table, containing the advance width of each codepoint
        # width.lua is a dump of a few metrics. Namely, yMin and yMax.
        with open("kern.lua", "w") as kern:
            kerning_table = {}
            for pair, kerning in f.kerning_pairs.items():
                l, r = pair[0], pair[1]
                try:
                    lcp, rcp = glyph_to_codepoint_map[l], glyph_to_codepoint_map[r]
                    if is_included_codepoint(lcp) and is_included_codepoint(rcp):
                        try:
                            kerning_table[l][r] = kerning
                        except KeyError:
                            kerning_table[l] = {r: kerning}
                except KeyError:
                    pass

            kern.write("return {\n")
            for l, kernings in sorted(kerning_table.items()):
                kern.write("\t[\"{}\"] = {{\n".format(l))
                for r, kerning in sorted(kernings.items()):
                    kern.write("\t\t[\"{}\"] = {},\n".format(r, kerning))
                kern.write("\t},\n")
            kern.write("}")

        with open("cmap.lua", "w") as cmap:
            cmap.write("return {\n")
            for codepoint, glyph in sorted(codepoint_to_glyph_map.items()):
                if is_included_codepoint(codepoint):
                    cmap.write("\t[{}] = \"{}\",\n".format(codepoint, glyph))
            cmap.write("}")

        with open("width.lua", "w") as width:
            width_table = {}
            for glyph, metrics in f.hmtx_metrics.items():
                try:
                    cp = glyph_to_codepoint_map[glyph]
                    if is_included_codepoint(cp):
                        width_table[glyph] = metrics[0]
                except KeyError:
                    if glyph == ".notdef":
                        width_table[glyph] = metrics[0]

            width.write("return {\n")
            for glyph, width_n in sorted(width_table.items()):
                width.write("\t[\"{}\"] = {},\n".format(glyph, width_n))
            width.write("}")

        with open("metrics.lua", "w") as metrics:
            metrics.write("return {\n")
            metrics.write("\tyMin = {},\n".format(f.yMin))
            metrics.write("\tyMax = {},\n".format(f.yMax))
            metrics.write("}")

        offsets = {}
        x_sizes = {}
        y_sizes = {}
        fnt = bpy.data.fonts.load(font_path)
        # Dump the glyphs into blender.
        # Manually select all and export w/ a scale of 0.01 afterwards. Roblox's mesh splitting will handle the rest.
        for codepoint, _ in sorted(codepoint_to_glyph_map.items()):
            if is_included_codepoint(codepoint):
                bpy.ops.object.text_add()
                tx = bpy.context.object
                tx.data.font = fnt
                tx.data.body = chr(codepoint)
                tx.data.extrude = 0.5
                glyph = codepoint_to_glyph_map[codepoint]
                tx.name = "{}_{}".format(codepoint, glyph)
                tx.data.name = "{}_{}".format(codepoint, glyph)
                tx.data.resolution_u = 6
                tx.rotation_euler[0] = math.radians(90)
                tx.rotation_euler[2] = math.radians(180)
                bpy.ops.object.modifier_add(type="EDGE_SPLIT")
                offset_from_center = get_center(tx)
                offsets[glyph] = offset_from_center
                x_sizes[glyph] = tx.dimensions.x
                y_sizes[glyph] = tx.dimensions.y

        # Create .notdef glyph
        bpy.ops.object.text_add()
        tx = bpy.context.object
        tx.data.font = fnt
        tx.data.body = chr(notdef_code)
        tx.data.extrude = 0.5
        tx.name = ".notdef"
        tx.data.name = ".notdef"
        tx.data.resolution_u = 6
        tx.rotation_euler[0] = math.radians(90)
        tx.rotation_euler[2] = math.radians(180)
        bpy.ops.object.modifier_add(type="EDGE_SPLIT")
        offset_from_center = get_center(tx)
        offsets[".notdef"] = offset_from_center
        x_sizes[".notdef"] = tx.dimensions.x
        y_sizes[".notdef"] = tx.dimensions.y

        # dump a bunch of offset/size tables
        with open("xOffsets.lua", "w") as xOffsets:
            xOffsets.write("return {\n")
            for glyph, offset in sorted(offsets.items()):
                xOffsets.write(
                    "\t[\"{}\"] = {:.6f},\n".format(glyph, offset.x))
            xOffsets.write("}")

        with open("yOffsets.lua", "w") as yOffsets:
            yOffsets.write("return {\n")
            for glyph, offset in sorted(offsets.items()):
                yOffsets.write(
                    "\t[\"{}\"] = {:.6f},\n".format(glyph, offset.z))
            yOffsets.write("}")

        with open("xSizes.lua", "w") as xSizes:
            xSizes.write("return {\n")
            for glyph, offset in sorted(offsets.items()):
                xSizes.write("\t[\"{}\"] = {:.6f},\n".format(
                    glyph, x_sizes[glyph]))
            xSizes.write("}")

        with open("ySizes.lua", "w") as ySizes:
            ySizes.write("return {\n")
            for glyph, offset in sorted(offsets.items()):
                ySizes.write("\t[\"{}\"] = {:.6f},\n".format(
                    glyph, y_sizes[glyph]))
            ySizes.write("}")

    else:
        print("That is not a valid font.", file=sys.stderr)

A lot of the code was from this script. I replaced camelCase to snake_case and I’m starting to regret it.

This will generate cmap.lua, kern.lua, metrics.lua, width.lua, xOffsets.lua, xSizes.lua, yOffsets.lua, and ySizes.lua. This is also create a bunch of meshes in blender which will have to be exported manually (you can export them all into one fbx and let Roblox’s mesh splitting handle the rest).

2 Likes

Also, think you could release this in public resources, so non full members can use this as well?

This script is a toy for now. I’m going to be making a few changes to this script, so I don’t want it to be public yet. Also, new members can access this thread, too, not just full members (non-members can’t, though).

2 Likes

Ah, I see. Well, thank you for this! Looking forward to the full release. :slight_smile:

I would suggest for those making new fonts with Blender text that you optimize your models before uploading them. While Blender’s text is a nice way to create 3D models of fonts, the text oftentimes isn’t the cleanest of geometry.

The one on the left is what Blender generates with Source Sans Pro, and is 348 polys, while the one on the right is a recreation of that geometry which only sits at 48 polys.

There are a few ways to cut down. One simple one would be to turn down the resolution of the text object, which defaults at 12.

Here, the above text uses the default resolution and ends up at 3,344 polys once converted from text into a mesh, whereas the lower one uses a resolution of 4 and ends up at a much lower 1,264.

If your text isn’t going to be used in a spot where the backside would ever be visible, it’d be advised to remove the back from the letters as well.

Doing so cuts down our 1,264 text from before to 957.

Lastly, just go through your letters and clean them up. Simplify areas where the curves are higher poly but don’t need all that, especially in spots where that extra geometry can be done with less. I could’ve gone further but that would have started affecting the shape more, so sticking closer to the original example above cuts our text down even lower from 957 to 744.


3 Likes

I already set the resolution_u of the text to 6 before exporting!

image

I picked 6 because resolutions below that would appear jagged when blown up (left: 4, right: 6).

image

This is imperfect, though. W generates a very poor mesh:

image

I’ll look into running Limited Dissolve + Triangulate Faces on each mesh before exporting, which cleans the mesh up by a lot.

image

I want this to be a general purpose module. While removing the back of the mesh is good for some usecases, it isn’t for others (e.g. recreating the Hollywood sign).

2 Likes

Seems like a lot more work than its worth to do it like that; I’ve personally always just done the following:

  1. Extrude as Text Object
  2. Convert to Mesh
  3. Remove Doubles
  4. Limited Dissolve
  5. Decimate Modifier to visual acceptance.

Going any further like removing the back faces, while yes reduces the face count, really is picking at straws and reduces usability for the future. In that argument, it gets to a point where just using a SurfaceGUI would be more optimal. Personally I’d say the resolution you chose is too low for comfort, the methodology I did brought the text down to ~1300 tri, which is perfectly fine for the application.

If these were props that would be used one hundred times, sure tighten the belt, but I feel it is just unnecessary here.

EDIT: Looks like @XAXA just barely beat me to the punch, I’ll leave the commentary here regardless.

3 Likes

While yes, the original post is about general usage, this is just a place where a good chunk of polys could be cut. It wouldn’t be good for the default which comes with the module, but depending on the use case it could be a decent spot to cut down for those going for something more specific than the module provides.

Again it really depends on the usage. It may be good to go even lower if it’s a spot where the text isn’t very large, or go higher for cases where we are getting giant Hollywood sign style letters which could use the extra polys. Not sure where it’d get to the point of needing a SurfaceGUI though, since that all depends. Part of the benefit is getting everything that comes with being a 3D physical object. Depth, materials and so on.

Oooh, that looks a lot cleaner! Relieved to see that work is being put in place to make sure the defaults are resource friendly! Have seen a few poor cases where users have put some hi-res letters all around their game, causing some performance issues for what boils down to a few extra details.

1 Like

More fonts. Wee.


Taking suggestions for fonts, btw. I’ll probably replace BEON (since it doesn’t support small letters) with another font that looks good for neon signs.

1 Like

You should add Roboto, Railway, Comic Sans, and Product Sans.

Also, I keep on getting this error
glyphObjects is not a valid member of ModuleScript (line 9)

1 Like

You could try using Inter.

1 Like

Whoops. I said I wouldn’t update it but I did. And broke the thing as a result… I’m reverting that model to a previous version, sorry!

@Elttob Looks great! I’ll add it in.

EDIT: !!!

3 Likes

Nice layout! Looks perfect. Loving the innovation, keep it up!

Damn, nice layout I love it! That’s awesome

1 Like

Soon ™

2 Likes

Awesome, but I would really like to see a 2D version of this, where you could use it on UIs.

That’s already a solved problem.

2 Likes