Any chance you could also opensource the Python scripts, so we can import the fonts we want?
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).
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).
Ah, I see. Well, thank you for this! Looking forward to the full release.
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.
I already set the resolution_u
of the text to 6 before exporting!
I picked 6 because resolutions below that would appear jagged when blown up (left: 4, right: 6).
This is imperfect, though. W generates a very poor mesh:
I’ll look into running Limited Dissolve
+ Triangulate Faces
on each mesh before exporting, which cleans the mesh up by a lot.
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).
Seems like a lot more work than its worth to do it like that; I’ve personally always just done the following:
- Extrude as Text Object
- Convert to Mesh
- Remove Doubles
- Limited Dissolve
- 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.
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.
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.
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)
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: !!!
Nice layout! Looks perfect. Loving the innovation, keep it up!
Damn, nice layout I love it! That’s awesome
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.
it would be nice to collaborate on a module and multiple contributors keep adding fonts
Also you know you couldve done this with photoshop and its 3d components just by converting a rasterized 2d letter to a 3d one and keep it at the thinnest possible then export it as wavefont(obj) then boom done