# 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('