Basic b3d to glTF converter
| #!/usr/bin/env python3 | |
| """b3d (Blitz3D) -> glTF 2.0 converter. | |
| An AI-generated Python port of modlib's Lua converter (https://github.com/appgurueu/modlib, | |
| b3d.lua), with some robustness fixes: | |
| * duplicate keyframes are merged instead of rejected, | |
| * quaternions (node rotations & rotation keys) are normalized, | |
| * bind matrix chains accumulate through *all* nodes below the mesh node | |
| (modlib only multiplies transforms of bone nodes), | |
| * >4 bone influences are sorted by weight so the strongest land in JOINTS_0, | |
| * non-default texture/brush flags produce warnings instead of hard errors. | |
| Format references: | |
| * b3d: https://github.com/blitz-research/blitz3d/blob/master/blitz3d/loader_b3d.cpp | |
| * glTF 2.0: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html | |
| Coordinate systems: Blitz3D is left-handed (X+ right, Y+ up, Z+ forward), | |
| glTF is right-handed (X- right, Y+ up, Z+ forward). Conversion: negate the | |
| X component of translations/positions/normals, negate the x component of | |
| quaternions, and flip triangle winding order. | |
| Output is a self-contained .gltf (JSON with a base64 data URI buffer). | |
| Only the Python standard library is required. | |
| """ | |
| import argparse | |
| import base64 | |
| import json | |
| import math | |
| import os | |
| import struct | |
| import sys | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def warn(msg): | |
| print("warning: " + msg, file=sys.stderr) | |
| def f32(x): | |
| """Round a Python float to the nearest binary32 value (returned as float).""" | |
| return struct.unpack("<f", struct.pack("<f", x))[0] | |
| class B3DError(Exception): | |
| pass | |
| # --------------------------------------------------------------------------- | |
| # b3d parsing | |
| # --------------------------------------------------------------------------- | |
| class _Reader: | |
| """Reads primitives from `data[pos:end]` (one chunk payload).""" | |
| __slots__ = ("data", "pos", "end") | |
| def __init__(self, data, pos, end): | |
| self.data, self.pos, self.end = data, pos, end | |
| def remaining(self): | |
| return self.end - self.pos | |
| def _take(self, n): | |
| if self.pos + n > self.end: | |
| raise B3DError("unexpected end of chunk at byte %d" % self.pos) | |
| pos = self.pos | |
| self.pos += n | |
| return pos | |
| def i32(self): | |
| return struct.unpack_from("<i", self.data, self._take(4))[0] | |
| def f(self): | |
| v = struct.unpack_from("<f", self.data, self._take(4))[0] | |
| if not math.isfinite(v): | |
| raise B3DError("non-finite float at byte %d" % (self.pos - 4)) | |
| return v | |
| def floats(self, n): | |
| return [self.f() for _ in range(n)] | |
| def string(self): | |
| nul = self.data.find(b"\0", self.pos, self.end) | |
| if nul < 0: | |
| raise B3DError("unterminated string at byte %d" % self.pos) | |
| s = self.data[self.pos:nul].decode("latin-1") | |
| self.pos = nul + 1 | |
| return s | |
| def color(self): | |
| return {"r": self.f(), "g": self.f(), "b": self.f(), "a": self.f()} | |
| def quaternion(self): | |
| # b3d stores w, x, y, z; we keep (x, y, z, w) as glTF does. | |
| w, x, y, z = self.f(), self.f(), self.f(), self.f() | |
| return [x, y, z, w] | |
| def optional_id(self): | |
| i = self.i32() | |
| return None if i == -1 else i | |
| def chunks(self): | |
| """Iterate (tag, payload_reader) over the sub-chunks of this payload.""" | |
| while self.pos < self.end: | |
| if self.remaining() < 8: | |
| raise B3DError("truncated chunk header at byte %d" % self.pos) | |
| tag = self.data[self.pos:self.pos + 4].decode("latin-1") | |
| (length,) = struct.unpack_from("<i", self.data, self.pos + 4) | |
| start = self.pos + 8 | |
| if length < 0 or start + length > self.end: | |
| raise B3DError("chunk %r at byte %d overflows its parent" | |
| % (tag, self.pos)) | |
| self.pos = start + length | |
| yield tag, _Reader(self.data, start, start + length) | |
| def _parse_texs(r): | |
| textures = [] | |
| while r.remaining() > 0: | |
| textures.append({ | |
| "file": r.string(), | |
| "flags": r.i32(), | |
| "blend": r.i32(), | |
| "pos": r.floats(2), | |
| "scale": r.floats(2), | |
| "rotation": r.f(), | |
| }) | |
| return textures | |
| def _parse_brus(r): | |
| brushes = [] | |
| n_texs = r.i32() | |
| if not 0 <= n_texs <= 8: | |
| raise B3DError("invalid number of textures per brush: %d" % n_texs) | |
| while r.remaining() > 0: | |
| brushes.append({ | |
| "name": r.string(), | |
| "color": r.color(), | |
| "shininess": r.f(), | |
| "blend": r.i32(), | |
| "fx": r.i32(), | |
| "texture_ids": [r.optional_id() for _ in range(n_texs)], | |
| }) | |
| return brushes | |
| def _parse_vrts(r): | |
| flags = r.i32() | |
| tc_sets = r.i32() | |
| tc_size = r.i32() | |
| if not (0 <= tc_sets <= 8 and 0 <= tc_size <= 4): | |
| raise B3DError("invalid texture coordinate layout (%d sets of size %d)" | |
| % (tc_sets, tc_size)) | |
| has_normal = bool(flags & 1) | |
| has_color = bool(flags & 2) | |
| vertices = [] | |
| while r.remaining() > 0: | |
| vertices.append({ | |
| "pos": r.floats(3), | |
| "normal": r.floats(3) if has_normal else None, | |
| "color": r.color() if has_color else None, | |
| "tex_coords": [r.floats(tc_size) for _ in range(tc_sets)], | |
| }) | |
| return { | |
| "flags": flags, | |
| "tex_coord_sets": tc_sets, | |
| "tex_coord_set_size": tc_size, | |
| "list": vertices, | |
| } | |
| def _parse_tris(r): | |
| brush_id = r.optional_id() | |
| tris = [] | |
| while r.remaining() > 0: | |
| tris.append((r.i32(), r.i32(), r.i32())) | |
| return {"brush_id": brush_id, "tris": tris} | |
| def _parse_mesh(r): | |
| brush_id = r.optional_id() | |
| vertices = None | |
| triangle_sets = [] | |
| for tag, cr in r.chunks(): | |
| if tag == "VRTS": | |
| if vertices is not None: | |
| raise B3DError("multiple VRTS chunks in MESH") | |
| vertices = _parse_vrts(cr) | |
| elif tag == "TRIS": | |
| triangle_sets.append(_parse_tris(cr)) | |
| else: | |
| warn("skipping unknown chunk %r in MESH" % tag) | |
| if vertices is None or not triangle_sets: | |
| raise B3DError("MESH lacks VRTS or TRIS") | |
| n = len(vertices["list"]) | |
| for ts in triangle_sets: | |
| for tri in ts["tris"]: | |
| if any(not 0 <= i < n for i in tri): | |
| raise B3DError("triangle vertex index out of range") | |
| return {"brush_id": brush_id, "vertices": vertices, | |
| "triangle_sets": triangle_sets} | |
| def _parse_bone(r): | |
| bone = {} | |
| while r.remaining() > 0: | |
| vertex_id = r.i32() | |
| weight = r.f() | |
| if vertex_id in bone: | |
| raise B3DError("duplicate vertex weight for vertex %d" % vertex_id) | |
| if weight > 0: # many exporters include unneeded zero weights | |
| bone[vertex_id] = weight | |
| return bone | |
| def _parse_keys(r): | |
| flags = r.i32() | |
| has_pos, has_scale, has_rot = bool(flags & 1), bool(flags & 2), bool(flags & 4) | |
| keys = [] | |
| while r.remaining() > 0: | |
| key = {"frame": r.i32() - 1} # b3d uses 1-indexed frames | |
| if has_pos: | |
| key["position"] = r.floats(3) | |
| if has_scale: | |
| key["scale"] = r.floats(3) | |
| if has_rot: | |
| key["rotation"] = r.quaternion() | |
| keys.append(key) | |
| return keys | |
| def _parse_anim(r): | |
| return {"flags": r.i32(), "frames": r.i32(), "fps": r.f()} | |
| def _parse_node(r): | |
| node = { | |
| "name": r.string(), | |
| "position": r.floats(3), | |
| "scale": r.floats(3), | |
| "rotation": r.quaternion(), | |
| "keys": [], | |
| "children": [], | |
| "mesh": None, | |
| "bone": None, | |
| "animation": None, | |
| } | |
| for tag, cr in r.chunks(): | |
| if tag == "MESH": | |
| if node["mesh"] is not None or node["bone"] is not None: | |
| raise B3DError("node %r has more than one MESH/BONE" | |
| % node["name"]) | |
| node["mesh"] = _parse_mesh(cr) | |
| elif tag == "BONE": | |
| if node["mesh"] is not None or node["bone"] is not None: | |
| raise B3DError("node %r has more than one MESH/BONE" | |
| % node["name"]) | |
| node["bone"] = _parse_bone(cr) | |
| elif tag == "KEYS": | |
| node["keys"].extend(_parse_keys(cr)) | |
| elif tag == "ANIM": | |
| node["animation"] = _parse_anim(cr) | |
| elif tag == "NODE": | |
| node["children"].append(_parse_node(cr)) | |
| else: | |
| # Unknown chunks mark the node as a pivot in Blitz3D; skip them. | |
| pass | |
| # Sort keys ascendingly by frame; merge keys sharing a frame | |
| # (e.g. from separate KEYS chunks with different flags). | |
| node["keys"].sort(key=lambda k: k["frame"]) | |
| merged = [] | |
| for key in node["keys"]: | |
| if merged and merged[-1]["frame"] == key["frame"]: | |
| for field in ("position", "scale", "rotation"): | |
| if field in key: | |
| if field in merged[-1] and merged[-1][field] != key[field]: | |
| warn("node %r: conflicting %s keys for frame %d; " | |
| "keeping the first" % (node["name"], field, | |
| key["frame"])) | |
| else: | |
| merged[-1][field] = key[field] | |
| else: | |
| merged.append(key) | |
| node["keys"] = merged | |
| return node | |
| def parse_b3d(data): | |
| """Parse a b3d file (bytes) into a model dict.""" | |
| root = _Reader(data, 0, len(data)) | |
| model = None | |
| for tag, r in root.chunks(): | |
| if tag != "BB3D": | |
| raise B3DError("expected BB3D chunk, found %r" % tag) | |
| if model is not None: | |
| raise B3DError("multiple BB3D chunks") | |
| version = r.i32() | |
| model = { | |
| "version": (version // 100, version % 100), | |
| "textures": [], | |
| "brushes": [], | |
| "node": None, | |
| } | |
| if model["version"][0] > 2: | |
| raise B3DError("unsupported version: %d" % version) | |
| for ctag, cr in r.chunks(): | |
| if ctag == "TEXS": | |
| model["textures"].extend(_parse_texs(cr)) | |
| elif ctag == "BRUS": | |
| model["brushes"].extend(_parse_brus(cr)) | |
| elif ctag == "NODE": | |
| if model["node"] is not None: | |
| raise B3DError("multiple root nodes") | |
| model["node"] = _parse_node(cr) | |
| else: | |
| warn("skipping unknown chunk %r in BB3D" % ctag) | |
| if model is None: | |
| raise B3DError("empty file") | |
| if root.remaining() > 0: | |
| warn("%d trailing bytes after BB3D chunk" % root.remaining()) | |
| return model | |
| # --------------------------------------------------------------------------- | |
| # Minimal 4x4 matrix math (row-major) | |
| # --------------------------------------------------------------------------- | |
| def mat_identity(): | |
| return [[1.0 if i == j else 0.0 for j in range(4)] for i in range(4)] | |
| def mat_mul(a, b): | |
| return [[sum(a[i][k] * b[k][j] for k in range(4)) for j in range(4)] | |
| for i in range(4)] | |
| def mat_trs(translation, rotation, scale): | |
| """T * R * S from glTF-convention TRS (rotation: unit quaternion xyzw).""" | |
| x, y, z, w = rotation | |
| tx, ty, tz = translation | |
| sx, sy, sz = scale | |
| r = [ | |
| [1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)], | |
| [2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)], | |
| [2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)], | |
| ] | |
| s = (sx, sy, sz) | |
| return [ | |
| [r[0][0] * s[0], r[0][1] * s[1], r[0][2] * s[2], tx], | |
| [r[1][0] * s[0], r[1][1] * s[1], r[1][2] * s[2], ty], | |
| [r[2][0] * s[0], r[2][1] * s[1], r[2][2] * s[2], tz], | |
| [0.0, 0.0, 0.0, 1.0], | |
| ] | |
| def mat_inverse(m): | |
| """Gauss-Jordan inversion with partial pivoting.""" | |
| a = [row[:] for row in m] | |
| inv = mat_identity() | |
| for col in range(4): | |
| pivot = max(range(col, 4), key=lambda r: abs(a[r][col])) | |
| if abs(a[pivot][col]) < 1e-9: | |
| raise B3DError("singular bind matrix (zero scale on a bone?)") | |
| a[col], a[pivot] = a[pivot], a[col] | |
| inv[col], inv[pivot] = inv[pivot], inv[col] | |
| pv = a[col][col] | |
| a[col] = [v / pv for v in a[col]] | |
| inv[col] = [v / pv for v in inv[col]] | |
| for row in range(4): | |
| if row != col and a[row][col] != 0.0: | |
| f = a[row][col] | |
| a[row] = [v - f * p for v, p in zip(a[row], a[col])] | |
| inv[row] = [v - f * p for v, p in zip(inv[row], inv[col])] | |
| return inv | |
| # --------------------------------------------------------------------------- | |
| # glTF conversion | |
| # --------------------------------------------------------------------------- | |
| # coordinate system conversion could be done here, but does not appear to be necessary | |
| def translation_to_gltf(vec): | |
| return [vec[0], vec[1], vec[2]] | |
| def quaternion_to_gltf(quat): | |
| return [quat[0], quat[1], quat[2], quat[3]] | |
| def normalize_quaternion(quat, ctx): | |
| length = math.sqrt(sum(c * c for c in quat)) | |
| if length < 1e-9: | |
| warn("%s: zero quaternion, substituting identity" % ctx) | |
| return [0.0, 0.0, 0.0, 1.0] | |
| return [c / length for c in quat] | |
| def color_to_gltf(col): | |
| return [col["r"], col["g"], col["b"], col["a"]] | |
| def clamp01(x, ctx): | |
| if 0.0 <= x <= 1.0: | |
| return x | |
| warn("%s: value %g out of [0, 1], clamping" % (ctx, x)) | |
| return min(1.0, max(0.0, x)) | |
| ARRAY_BUFFER = 34962 | |
| ELEMENT_ARRAY_BUFFER = 34963 | |
| COMPONENT_TYPES = {"float": 5126, "unsigned_short": 5123, "unsigned_int": 5125} | |
| COMPONENT_FORMATS = {"float": "f", "unsigned_short": "H", "unsigned_int": "I"} | |
| class _GltfBuffer: | |
| """Accumulates raw data; produces buffer views & accessors.""" | |
| def __init__(self): | |
| self.data = bytearray() | |
| self.views = [] | |
| self.accessors = [] | |
| def add_accessor(self, acc_type, comp_type, target, values, | |
| amin=None, amax=None): | |
| """`values` is a flat list of numbers; floats must be f32-rounded | |
| already if min/max consistency matters. Returns the accessor index.""" | |
| n_components = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, | |
| "MAT4": 16}[acc_type] | |
| count, rem = divmod(len(values), n_components) | |
| assert rem == 0 and count > 0 | |
| self.data.extend(b"\0" * (-len(self.data) % 4)) # 4-byte alignment | |
| offset = len(self.data) | |
| fmt = "<%d%s" % (len(values), COMPONENT_FORMATS[comp_type]) | |
| if comp_type == "float": | |
| for v in values: | |
| if not math.isfinite(v): | |
| raise B3DError("non-finite value in output buffer") | |
| self.data.extend(struct.pack(fmt, *values)) | |
| self.views.append({ | |
| "buffer": 0, | |
| "byteOffset": offset, | |
| "byteLength": len(self.data) - offset, | |
| **({"target": target} if target is not None else {}), | |
| }) | |
| accessor = { | |
| "bufferView": len(self.views) - 1, | |
| "componentType": COMPONENT_TYPES[comp_type], | |
| "type": acc_type, | |
| "count": count, | |
| } | |
| if amin is not None: | |
| accessor["min"] = amin | |
| if amax is not None: | |
| accessor["max"] = amax | |
| self.accessors.append(accessor) | |
| return len(self.accessors) - 1 | |
| def to_gltf(model, generator="b3d2gltf.py"): | |
| buf = _GltfBuffer() | |
| textures = [] | |
| materials = [] | |
| meshes = [] | |
| nodes = [] | |
| skins = [] | |
| samplers = [] | |
| channels = [] | |
| def add_texture(name): | |
| textures.append({"name": name}) | |
| return len(textures) - 1 | |
| for i, tex in enumerate(model["textures"]): | |
| # Flag 1 = color texture; blend 2 = multiply (the defaults). | |
| if tex["flags"] & ~65536 != 1 or tex["blend"] != 2: | |
| warn("texture %d (%r): unsupported flags/blend (%d/%d) ignored" | |
| % (i, tex["file"], tex["flags"], tex["blend"])) | |
| if (tex["rotation"] != 0 or tex["pos"] != [0.0, 0.0] | |
| or tex["scale"] != [1.0, 1.0]): | |
| warn("texture %d (%r): texture transform ignored" % (i, tex["file"])) | |
| add_texture(tex["file"]) | |
| for i, brush in enumerate(model["brushes"]): | |
| # Blitz3D brush blend: 0 replace, 1 alpha, 2 multiply, 3 add. | |
| if brush["blend"] != 1: | |
| warn("brush %d (%r): unsupported blend %d, using alpha" | |
| % (i, brush["name"], brush["blend"])) | |
| if brush["fx"] != 0: | |
| warn("brush %d (%r): fx %d ignored" % (i, brush["name"], brush["fx"])) | |
| texture_ids = [t for t in brush["texture_ids"] if t is not None] | |
| if len(texture_ids) > 1: | |
| warn("brush %d (%r): only the first of %d textures is used" | |
| % (i, brush["name"], len(texture_ids))) | |
| if texture_ids: | |
| if texture_ids[0] >= len(textures): | |
| raise B3DError("brush %d references undefined texture %d" | |
| % (i, texture_ids[0])) | |
| index = texture_ids[0] | |
| else: | |
| # Implementations seem to implicitly assume textures for brushes. | |
| index = add_texture(brush["name"]) | |
| ctx = "brush %d (%r)" % (i, brush["name"]) | |
| materials.append({ | |
| "name": brush["name"], | |
| "alphaMode": "BLEND", | |
| "pbrMetallicRoughness": { | |
| "baseColorFactor": [clamp01(c, ctx) | |
| for c in color_to_gltf(brush["color"])], | |
| "metallicFactor": clamp01(brush["shininess"], ctx), | |
| "baseColorTexture": {"index": index}, | |
| }, | |
| }) | |
| def add_mesh(mesh, weights, add_neutral_joint): | |
| vertices = mesh["vertices"]["list"] | |
| attributes = {} | |
| positions = [] | |
| inf = math.inf | |
| min_pos, max_pos = [inf] * 3, [-inf] * 3 | |
| for vertex in vertices: | |
| pos = [f32(c) for c in translation_to_gltf(vertex["pos"])] | |
| positions.extend(pos) | |
| min_pos = [min(a, b) for a, b in zip(min_pos, pos)] | |
| max_pos = [max(a, b) for a, b in zip(max_pos, pos)] | |
| attributes["POSITION"] = buf.add_accessor( | |
| "VEC3", "float", ARRAY_BUFFER, positions, min_pos, max_pos) | |
| if mesh["vertices"]["flags"] & 1: # normals | |
| normals = [] | |
| for i, vertex in enumerate(vertices): | |
| n = translation_to_gltf(vertex["normal"]) | |
| length = math.sqrt(sum(c * c for c in n)) | |
| if length < 1e-9: | |
| # A zero normal is invalid in glTF; substitute something. | |
| warn("vertex %d has a zero normal" % i) | |
| n = [0.0, 1.0, 0.0] | |
| else: | |
| n = [c / length for c in n] | |
| normals.extend(f32(c) for c in n) | |
| attributes["NORMAL"] = buf.add_accessor( | |
| "VEC3", "float", ARRAY_BUFFER, normals) | |
| if mesh["vertices"]["flags"] & 2: # vertex colors | |
| colors = [] | |
| for i, vertex in enumerate(vertices): | |
| colors.extend(clamp01(f32(c), "vertex %d color" % i) | |
| for c in color_to_gltf(vertex["color"])) | |
| attributes["COLOR_0"] = buf.add_accessor( | |
| "VEC4", "float", ARRAY_BUFFER, colors) | |
| if mesh["vertices"]["tex_coord_sets"] >= 1: | |
| if mesh["vertices"]["tex_coord_set_size"] != 2: | |
| raise B3DError("only 2D texture coordinates are supported") | |
| for tcs in range(mesh["vertices"]["tex_coord_sets"]): | |
| uvs = [] | |
| for vertex in vertices: | |
| uvs.extend(f32(c) for c in vertex["tex_coords"][tcs]) | |
| attributes["TEXCOORD_%d" % tcs] = buf.add_accessor( | |
| "VEC2", "float", ARRAY_BUFFER, uvs) | |
| if weights: | |
| # Normalize weights; collect per-vertex influence lists sorted by | |
| # descending weight so that the strongest influences go into | |
| # JOINTS_0/WEIGHTS_0. | |
| influences = [None] * len(vertices) | |
| max_count = 0 | |
| for vertex_id, joint_weights in weights.items(): | |
| if not 0 <= vertex_id < len(vertices): | |
| raise B3DError("bone weight for out-of-range vertex %d" | |
| % vertex_id) | |
| total = sum(joint_weights.values()) | |
| if total <= 0: | |
| continue | |
| inf_list = sorted(joint_weights.items(), | |
| key=lambda jw: (-jw[1], jw[0])) | |
| influences[vertex_id] = [(j, w / total) for j, w in inf_list] | |
| max_count = max(max_count, len(inf_list)) | |
| for vertex_id in range(len(vertices)): | |
| if influences[vertex_id] is None: | |
| # Static vertex: influenced by a dummy neutral bone. | |
| # See https://github.com/KhronosGroup/glTF/issues/2269 | |
| influences[vertex_id] = [(add_neutral_joint(), 1.0)] | |
| max_count = max(max_count, 1) | |
| if max_count > 4: | |
| warn("up to %d bone influences per vertex; sets beyond " | |
| "JOINTS_0/WEIGHTS_0 may be ignored by engines" % max_count) | |
| for set_id in range((max_count + 3) // 4): | |
| joint_ids = [] | |
| joint_weights = [] | |
| for infl in influences: | |
| for i in range(4 * set_id, 4 * set_id + 4): | |
| if i < len(infl) and infl[i][1] > 0: | |
| joint_ids.append(infl[i][0]) | |
| joint_weights.append(f32(infl[i][1])) | |
| else: | |
| # Zero-weight joints must be 0 per the glTF spec. | |
| joint_ids.append(0) | |
| joint_weights.append(0.0) | |
| attributes["JOINTS_%d" % set_id] = buf.add_accessor( | |
| "VEC4", "unsigned_short", ARRAY_BUFFER, joint_ids) | |
| attributes["WEIGHTS_%d" % set_id] = buf.add_accessor( | |
| "VEC4", "float", ARRAY_BUFFER, joint_weights) | |
| primitives = [] | |
| for triangle_set in mesh["triangle_sets"]: | |
| indices = [] | |
| for tri in triangle_set["tris"]: | |
| # Flip winding order due to the handedness flip. | |
| indices.extend(reversed(tri)) | |
| index_accessor = buf.add_accessor( | |
| "SCALAR", "unsigned_int", ELEMENT_ARRAY_BUFFER, indices) | |
| brush_id = triangle_set["brush_id"] | |
| if brush_id is None: | |
| brush_id = mesh["brush_id"] | |
| if brush_id is not None and brush_id >= len(materials): | |
| raise B3DError("triangle set references undefined brush %d" | |
| % brush_id) | |
| primitive = {"attributes": attributes, "indices": index_accessor} | |
| if brush_id is not None: | |
| primitive["material"] = brush_id | |
| primitives.append(primitive) | |
| meshes.append({"primitives": primitives}) | |
| return len(meshes) - 1 | |
| def add_node(node, bind_mat, fps, anim): | |
| # Reserve a fixed index for this node. | |
| node_id = len(nodes) | |
| nodes.append(None) | |
| if node["animation"] is not None and node["animation"]["fps"] > 0: | |
| fps = node["animation"]["fps"] | |
| translation = translation_to_gltf(node["position"]) | |
| rotation = quaternion_to_gltf(normalize_quaternion( | |
| node["rotation"], "node %r rotation" % node["name"])) | |
| scale = list(node["scale"]) | |
| # Keyframes -> animation channels. | |
| if node["keys"]: | |
| targets = { | |
| "translation": ("position", translation_to_gltf), | |
| "scale": ("scale", list), | |
| "rotation": ("rotation", lambda q: quaternion_to_gltf( | |
| normalize_quaternion(q, "node %r rotation key" | |
| % node["name"]))), | |
| } | |
| for path, (field, convert) in targets.items(): | |
| keyframes = [(k["frame"], k[field]) for k in node["keys"] | |
| if field in k] | |
| if not keyframes: | |
| continue | |
| times = [f32(frame / (fps or 60.0)) for frame, _ in keyframes] | |
| input_acc = buf.add_accessor( | |
| "SCALAR", "float", None, times, [min(times)], [max(times)]) | |
| values = [] | |
| for _, value in keyframes: | |
| values.extend(f32(c) for c in convert(value)) | |
| output_acc = buf.add_accessor( | |
| "VEC4" if path == "rotation" else "VEC3", "float", None, | |
| values) | |
| samplers.append({"input": input_acc, "output": output_acc}) | |
| channels.append({ | |
| "sampler": len(samplers) - 1, | |
| "target": {"node": node_id, "path": path}, | |
| }) | |
| if node["mesh"] is not None: | |
| if anim is not None: | |
| raise B3DError("nested meshes are not supported") | |
| anim = {"weights": {}, "joints": [], "inv_bind_mats": []} | |
| # Bind matrices are relative to the mesh node's space. | |
| bind_mat = None | |
| elif bind_mat is not None or anim is not None: | |
| # Below the mesh node: accumulate *all* local transforms. | |
| local = mat_trs(translation, rotation, scale) | |
| bind_mat = mat_mul(bind_mat, local) if bind_mat is not None else local | |
| if node["bone"] is not None: | |
| if anim is None: | |
| raise B3DError("bone node %r outside of a mesh node" | |
| % node["name"]) | |
| joint_id = len(anim["joints"]) | |
| anim["joints"].append(node_id) | |
| anim["inv_bind_mats"].append(mat_inverse(bind_mat)) | |
| for vertex_id, weight in node["bone"].items(): | |
| anim["weights"].setdefault(vertex_id, {})[joint_id] = weight | |
| children = [add_node(child, bind_mat, fps, anim) | |
| for child in node["children"]] | |
| mesh_id = skin_id = neutral_node_id = None | |
| if node["mesh"] is not None: | |
| neutral_joint_id = None | |
| def add_neutral_joint(): | |
| # Lazily create a neutral bone for vertices without weights. | |
| # It must be a *parent* of the mesh node: as a sibling, the | |
| # joints would lack a common root; as a child, the mesh node's | |
| # animation would move it. | |
| nonlocal neutral_joint_id, neutral_node_id | |
| if neutral_joint_id is None: | |
| neutral_node_id = len(nodes) | |
| nodes.append({"name": "neutral_bone", | |
| "children": [node_id]}) | |
| neutral_joint_id = len(anim["joints"]) | |
| anim["joints"].append(neutral_node_id) | |
| return neutral_joint_id | |
| mesh_id = add_mesh(node["mesh"], anim["weights"], add_neutral_joint) | |
| if anim["joints"]: | |
| if neutral_joint_id is not None: | |
| # The neutral joint must leave static vertices where the | |
| # mesh node's transform would put them. | |
| anim["inv_bind_mats"].append( | |
| mat_trs(translation, rotation, scale)) | |
| ibms = [] | |
| for m in anim["inv_bind_mats"]: | |
| # glTF is column-major; only f32-rounded values may enter | |
| # the buffer. | |
| ibms.extend(f32(m[i][j]) for j in range(4) for i in range(4)) | |
| skins.append({ | |
| "inverseBindMatrices": buf.add_accessor( | |
| "MAT4", "float", None, ibms), | |
| "joints": anim["joints"], | |
| **({"skeleton": neutral_node_id} | |
| if neutral_node_id is not None else {}), | |
| }) | |
| skin_id = len(skins) - 1 | |
| gltf_node = {"name": node["name"]} | |
| if mesh_id is not None: | |
| gltf_node["mesh"] = mesh_id | |
| if skin_id is not None: | |
| gltf_node["skin"] = skin_id | |
| if children: | |
| gltf_node["children"] = children | |
| gltf_node["translation"] = translation | |
| gltf_node["rotation"] = rotation | |
| gltf_node["scale"] = scale | |
| nodes[node_id] = gltf_node | |
| # If a neutral bone was created, it replaces the node as subtree root. | |
| return neutral_node_id if neutral_node_id is not None else node_id | |
| gltf = { | |
| "asset": {"generator": generator, "version": "2.0"}, | |
| } | |
| if model["node"] is not None: | |
| root = add_node(model["node"], None, None, None) | |
| gltf["scene"] = 0 | |
| gltf["scenes"] = [{"nodes": [root]}] | |
| if textures: | |
| gltf["textures"] = textures | |
| if materials: | |
| gltf["materials"] = materials | |
| if meshes: | |
| gltf["meshes"] = meshes | |
| if nodes: | |
| gltf["nodes"] = nodes | |
| if skins: | |
| gltf["skins"] = skins | |
| if channels: | |
| gltf["animations"] = [{"channels": channels, "samplers": samplers}] | |
| if buf.data: | |
| gltf["accessors"] = buf.accessors | |
| gltf["bufferViews"] = buf.views | |
| gltf["buffers"] = [{ | |
| "byteLength": len(buf.data), | |
| "uri": "data:application/octet-stream;base64," | |
| + base64.b64encode(bytes(buf.data)).decode("ascii"), | |
| }] | |
| return gltf | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| def main(argv=None): | |
| parser = argparse.ArgumentParser( | |
| description="Convert a Blitz3D .b3d model to glTF 2.0 (.gltf).") | |
| parser.add_argument("input", help="input .b3d file") | |
| parser.add_argument("output", nargs="?", | |
| help="output .gltf file (default: input with .gltf " | |
| "extension; '-' for stdout)") | |
| parser.add_argument("--pretty", action="store_true", | |
| help="pretty-print the JSON output") | |
| args = parser.parse_args(argv) | |
| with open(args.input, "rb") as f: | |
| data = f.read() | |
| try: | |
| model = parse_b3d(data) | |
| gltf = to_gltf(model) | |
| except B3DError as e: | |
| print("error: %s" % e, file=sys.stderr) | |
| return 1 | |
| dump_args = {"indent": 4} if args.pretty else {"separators": (",", ":")} | |
| text = json.dumps(gltf, allow_nan=False, **dump_args) | |
| output = args.output | |
| if output == "-": | |
| sys.stdout.write(text + "\n") | |
| else: | |
| if output is None: | |
| output = os.path.splitext(args.input)[0] + ".gltf" | |
| with open(output, "w", encoding="utf-8") as f: | |
| f.write(text) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
评论
?
参与讨论