215 lines
9.1 KiB
Python
215 lines
9.1 KiB
Python
# Converts a Wavefront OBJ (+ MTL) into a self-contained binary glTF 2.0 file, which is the only model
|
|
# format Singe loads. Each OBJ object becomes a node with one mesh primitive per material; faces are
|
|
# fan-triangulated and keep their flat normals. Materials map Kd/d to the base colour and alpha,
|
|
# Pm/Pr (or a guess from Ks/Ns) to metallic and roughness; translucent ones render blended, two-sided.
|
|
#
|
|
# --pivots takes a JSON file naming, per object, where that object's own origin should sit: three
|
|
# entries picked from "min", "mid" and "max" of its bounding box, or plain numbers. The vertices
|
|
# are moved by that much and the node carries it back as a translation, so the model looks exactly
|
|
# the same but each part now turns about a joint instead of about the model's origin. Without it
|
|
# every node sits at 0,0,0 and rotating a wing swings it around the middle of the animal.
|
|
# Usage: python3 util/objToGlb.py model.obj model.glb [--root Name] [--pivots joints.json]
|
|
import argparse
|
|
import json
|
|
import os
|
|
import struct
|
|
import numpy as np
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('obj')
|
|
parser.add_argument('out')
|
|
parser.add_argument('--root', default=None, help='name of the root node (default: the file name)')
|
|
parser.add_argument('--pivots', default=None, help='JSON naming each object\'s own origin, so its node can turn about a joint')
|
|
args = parser.parse_args()
|
|
|
|
|
|
def loadMtl(path):
|
|
materials = {}
|
|
current = None
|
|
for line in open(path):
|
|
parts = line.split()
|
|
if not parts:
|
|
continue
|
|
if parts[0] == 'newmtl':
|
|
current = {'Kd': (0.8, 0.8, 0.8), 'Ks': (0.0, 0.0, 0.0), 'Ns': 10.0, 'd': 1.0, 'Pm': None, 'Pr': None}
|
|
materials[parts[1]] = current
|
|
elif current is not None and parts[0] in ('Kd', 'Ks'):
|
|
current[parts[0]] = tuple(float(v) for v in parts[1:4])
|
|
elif current is not None and parts[0] in ('Ns', 'd', 'Pm', 'Pr'):
|
|
current[parts[0]] = float(parts[1])
|
|
return materials
|
|
|
|
|
|
positions = []
|
|
uvs = []
|
|
normals = []
|
|
materials = {}
|
|
objects = {}
|
|
objectOrder = []
|
|
currentObject = 'default'
|
|
currentMaterial = None
|
|
for line in open(args.obj):
|
|
parts = line.split()
|
|
if not parts or parts[0] == '#':
|
|
continue
|
|
if parts[0] == 'mtllib':
|
|
materials = loadMtl(os.path.join(os.path.dirname(args.obj), parts[1]))
|
|
elif parts[0] == 'v':
|
|
positions.append([float(v) for v in parts[1:4]])
|
|
elif parts[0] == 'vt':
|
|
uvs.append([float(parts[1]), 1.0 - float(parts[2])])
|
|
elif parts[0] == 'vn':
|
|
normals.append([float(v) for v in parts[1:4]])
|
|
elif parts[0] == 'o':
|
|
currentObject = parts[1]
|
|
elif parts[0] == 'usemtl':
|
|
currentMaterial = parts[1]
|
|
elif parts[0] == 'f':
|
|
corners = []
|
|
for token in parts[1:]:
|
|
fields = token.split('/')
|
|
v = int(fields[0]) - 1
|
|
vt = int(fields[1]) - 1 if len(fields) > 1 and fields[1] else None
|
|
vn = int(fields[2]) - 1 if len(fields) > 2 and fields[2] else None
|
|
corners.append((v, vt, vn))
|
|
if currentObject not in objects:
|
|
objects[currentObject] = {}
|
|
objectOrder.append(currentObject)
|
|
primitive = objects[currentObject].setdefault(currentMaterial, {'lookup': {}, 'vertices': [], 'indices': []})
|
|
indices = []
|
|
for corner in corners:
|
|
if corner not in primitive['lookup']:
|
|
primitive['lookup'][corner] = len(primitive['vertices'])
|
|
primitive['vertices'].append(corner)
|
|
indices.append(primitive['lookup'][corner])
|
|
for i in range(1, len(indices) - 1):
|
|
primitive['indices'] += [indices[0], indices[i], indices[i + 1]]
|
|
|
|
positions = np.array(positions, dtype=np.float32)
|
|
uvs = np.array(uvs, dtype=np.float32) if uvs else None
|
|
normals = np.array(normals, dtype=np.float32) if normals else None
|
|
|
|
binary = bytearray()
|
|
bufferViews = []
|
|
accessors = []
|
|
|
|
|
|
def addView(data, target):
|
|
while len(binary) % 4:
|
|
binary.append(0)
|
|
bufferViews.append({'buffer': 0, 'byteOffset': len(binary), 'byteLength': len(data), 'target': target})
|
|
binary.extend(data)
|
|
return len(bufferViews) - 1
|
|
|
|
|
|
def addAccessor(array, componentType, kind, target, bounds=False):
|
|
view = addView(array.tobytes(), target)
|
|
accessor = {'bufferView': view, 'componentType': componentType, 'count': int(array.shape[0]), 'type': kind}
|
|
if bounds:
|
|
accessor['min'] = [float(v) for v in array.min(axis=0)]
|
|
accessor['max'] = [float(v) for v in array.max(axis=0)]
|
|
accessors.append(accessor)
|
|
return len(accessors) - 1
|
|
|
|
|
|
materialNames = list(materials.keys())
|
|
glMaterials = []
|
|
for name in materialNames:
|
|
m = materials[name]
|
|
metallic = m['Pm'] if m['Pm'] is not None else (1.0 if (np.mean(m['Ks']) > 0.5 and m['d'] >= 1.0) else 0.0)
|
|
roughness = m['Pr'] if m['Pr'] is not None else float(np.clip(1.0 - m['Ns'] / 400.0, 0.05, 1.0))
|
|
entry = {'name': name, 'pbrMetallicRoughness': {'baseColorFactor': [m['Kd'][0], m['Kd'][1], m['Kd'][2], m['d']], 'metallicFactor': metallic, 'roughnessFactor': roughness}}
|
|
if m['d'] < 1.0:
|
|
entry['alphaMode'] = 'BLEND'
|
|
entry['doubleSided'] = True
|
|
glMaterials.append(entry)
|
|
|
|
# Where each object's origin should sit, resolved against its own bounding box.
|
|
pivots = {}
|
|
if args.pivots:
|
|
with open(args.pivots) as handle:
|
|
pivots = {k: v for k, v in json.load(handle).items() if not k.startswith('_')}
|
|
unknown = [name for name in pivots if name not in objects]
|
|
if unknown:
|
|
# A pivot naming a part that is not in the model is a rename that was not carried across,
|
|
# and silently ignoring it would leave that part turning about the model's origin.
|
|
raise SystemExit('%s: no such object(s) in %s: %s' % (args.pivots, args.obj, ', '.join(sorted(unknown))))
|
|
|
|
|
|
def pivotFor(name, verts):
|
|
rule = pivots.get(name)
|
|
if rule is None:
|
|
return None
|
|
low = verts.min(axis=0)
|
|
high = verts.max(axis=0)
|
|
out = []
|
|
for axis in range(3):
|
|
want = rule[axis]
|
|
if isinstance(want, (int, float)):
|
|
out.append(float(want))
|
|
elif want == 'min':
|
|
out.append(float(low[axis]))
|
|
elif want == 'max':
|
|
out.append(float(high[axis]))
|
|
elif want == 'mid':
|
|
out.append(float((low[axis] + high[axis]) / 2.0))
|
|
else:
|
|
raise SystemExit('%s: %s has an unknown axis rule %r' % (args.pivots, name, want))
|
|
return out
|
|
|
|
|
|
meshes = []
|
|
nodes = []
|
|
for name in objectOrder:
|
|
primitives = []
|
|
# The pivot is measured across the whole object, not per material, or the parts of one wing
|
|
# would each turn about a different point.
|
|
whole = np.concatenate([positions[[v for v, vt, vn in primitive['vertices']]] for primitive in objects[name].values()])
|
|
pivot = pivotFor(name, whole)
|
|
for materialName, primitive in objects[name].items():
|
|
verts = primitive['vertices']
|
|
pos = positions[[v for v, vt, vn in verts]]
|
|
if pivot is not None:
|
|
pos = pos - np.array(pivot, dtype=pos.dtype)
|
|
attributes = {'POSITION': addAccessor(pos, 5126, 'VEC3', 34962, bounds=True)}
|
|
if normals is not None and all(vn is not None for v, vt, vn in verts):
|
|
attributes['NORMAL'] = addAccessor(normals[[vn for v, vt, vn in verts]], 5126, 'VEC3', 34962)
|
|
if uvs is not None and all(vt is not None for v, vt, vn in verts):
|
|
attributes['TEXCOORD_0'] = addAccessor(uvs[[vt for v, vt, vn in verts]], 5126, 'VEC2', 34962)
|
|
indexArray = np.array(primitive['indices'], dtype=np.uint32 if len(verts) > 65535 else np.uint16)
|
|
entry = {'attributes': attributes, 'indices': addAccessor(indexArray, 5125 if indexArray.dtype == np.uint32 else 5123, 'SCALAR', 34963), 'mode': 4}
|
|
if materialName in materialNames:
|
|
entry['material'] = materialNames.index(materialName)
|
|
primitives.append(entry)
|
|
meshes.append({'name': name, 'primitives': primitives})
|
|
node = {'name': name, 'mesh': len(meshes) - 1}
|
|
if pivot is not None:
|
|
node['translation'] = pivot
|
|
nodes.append(node)
|
|
|
|
rootName = args.root or os.path.splitext(os.path.basename(args.out))[0]
|
|
nodes.append({'name': rootName, 'children': list(range(len(nodes)))})
|
|
gltf = {
|
|
'asset': {'version': '2.0', 'generator': 'Singe util/objToGlb.py'},
|
|
'scene': 0,
|
|
'scenes': [{'name': rootName, 'nodes': [len(nodes) - 1]}],
|
|
'nodes': nodes,
|
|
'meshes': meshes,
|
|
'materials': glMaterials,
|
|
'accessors': accessors,
|
|
'bufferViews': bufferViews,
|
|
'buffers': [{'byteLength': len(binary)}],
|
|
}
|
|
while len(binary) % 4:
|
|
binary.append(0)
|
|
jsonBytes = json.dumps(gltf, separators=(',', ':')).encode('utf-8')
|
|
while len(jsonBytes) % 4:
|
|
jsonBytes += b' '
|
|
total = 12 + 8 + len(jsonBytes) + 8 + len(binary)
|
|
with open(args.out, 'wb') as f:
|
|
f.write(struct.pack('<4sII', b'glTF', 2, total))
|
|
f.write(struct.pack('<II', len(jsonBytes), 0x4E4F534A))
|
|
f.write(jsonBytes)
|
|
f.write(struct.pack('<II', len(binary), 0x004E4942))
|
|
f.write(binary)
|
|
print('%s: %d nodes, %d materials, %d triangles, %d bytes' % (args.out, len(objectOrder), len(glMaterials), sum(len(p['indices']) // 3 for o in objects.values() for p in o.values()), total))
|