Files

486 lines
20 KiB
Python

#!/usr/bin/env python3
"""Convert Ogre3D .mesh.xml + .skeleton.xml to glTF 2.0 (.glb) with all animations."""
import xml.etree.ElementTree as ET
import json
import struct
import math
import os
# ── Math ────────────────────────────────────────────────────────────────────
def axis_angle_to_quat(ax, ay, az, angle):
s = math.sin(angle / 2)
return (ax * s, ay * s, az * s, math.cos(angle / 2))
def mat4_from_trs(tx, ty, tz, qx, qy, qz, qw, sx, sy, sz):
x, y, z, w = qx, qy, qz, qw
xx, yy, zz = x*x, y*y, z*z
xy, xz, yz = x*y, x*z, y*z
wx, wy, wz = w*x, w*y, w*z
return [
(1 - 2*(yy + zz))*sx, (2*(xy + wz))*sx, (2*(xz - wy))*sx, 0,
(2*(xy - wz))*sy, (1 - 2*(xx + zz))*sy, (2*(yz + wx))*sy, 0,
(2*(xz + wy))*sz, (2*(yz - wx))*sz, (1 - 2*(xx + yy))*sz, 0,
tx, ty, tz, 1,
]
def mat4_mult(a, b):
r = [0]*16
for i in range(4):
for j in range(4):
for k in range(4):
r[j*4 + i] += a[k*4 + i] * b[j*4 + k]
return r
def lerp_keyframes(kf_times, kf_vals, query_times):
"""Linearly interpolate keyframe values to query times."""
if len(kf_times) == 1:
v = kf_vals[0]
result = []
for _ in query_times:
result.extend(v)
return result
result = []
for qt in query_times:
if qt <= kf_times[0]:
result.extend(kf_vals[0])
elif qt >= kf_times[-1]:
result.extend(kf_vals[-1])
else:
for i in range(len(kf_times) - 1):
if kf_times[i] <= qt <= kf_times[i+1]:
a = (qt - kf_times[i]) / (kf_times[i+1] - kf_times[i])
v = [kf_vals[i][j] + a*(kf_vals[i+1][j] - kf_vals[i][j])
for j in range(len(kf_vals[0]))]
result.extend(v)
break
else:
result.extend(kf_vals[-1])
return result
def pad(data, alignment=4):
"""Pad binary data with null bytes to alignment (for BIN chunk)."""
while len(data) % alignment:
data += b'\x00'
return data
# ── Parsing ─────────────────────────────────────────────────────────────────
def parse_skeleton(filepath):
tree = ET.parse(filepath)
root = tree.getroot()
bones = []
for b in root.find('bones').findall('bone'):
bid = int(b.get('id', '0'))
name = b.get('name', '')
pos = b.find('position')
px, py, pz = float(pos.get('x','0')), float(pos.get('y','0')), float(pos.get('z','0'))
rot = b.find('rotation')
angle = float(rot.get('angle','0'))
axis = rot.find('axis')
ax, ay, az = float(axis.get('x','1')), float(axis.get('y','0')), float(axis.get('z','0'))
bones.append({'id': bid, 'name': name, 'pos': (px, py, pz),
'rot_axis': (ax, ay, az), 'rot_angle': angle, 'scale': (1,1,1)})
hierarchy = {}
hier = root.find('bonehierarchy')
if hier is not None:
for bp in hier.findall('boneparent'):
hierarchy[bp.get('bone')] = bp.get('parent')
animations = {}
anims = root.find('animations')
if anims is not None:
for anim in anims.findall('animation'):
name = anim.get('name')
tracks = {}
for track in anim.find('tracks').findall('track'):
bn = track.get('bone')
kfs = []
for kf in track.find('keyframes').findall('keyframe'):
t = float(kf.get('time'))
tr = kf.find('translate')
tx, ty, tz = float(tr.get('x','0')), float(tr.get('y','0')), float(tr.get('z','0'))
ro = kf.find('rotate')
ang = float(ro.get('angle','0'))
ax_e = ro.find('axis')
ax, ay, az = float(ax_e.get('x','1')), float(ax_e.get('y','0')), float(ax_e.get('z','0'))
q = axis_angle_to_quat(ax, ay, az, ang)
sc = kf.find('scale')
sx = float(sc.get('x','1')) if sc is not None else 1.0
sy = float(sc.get('y','1')) if sc is not None else 1.0
sz = float(sc.get('z','1')) if sc is not None else 1.0
kfs.append((t, tx, ty, tz, q[0], q[1], q[2], q[3], sx, sy, sz))
tracks[bn] = kfs
animations[name] = tracks
return bones, hierarchy, animations
def parse_mesh(filepath):
tree = ET.parse(filepath)
root = tree.getroot()
verts = []
sg = root.find('sharedgeometry')
if sg is not None:
for vb in sg.findall('vertexbuffer'):
for v in vb.findall('vertex'):
pos = v.find('position')
norm = v.find('normal')
vt = {'x': float(pos.get('x','0')), 'y': float(pos.get('y','0')), 'z': float(pos.get('z','0')),
'nx': 0, 'ny': 0, 'nz': 1}
if norm is not None:
vt['nx'] = float(norm.get('x','0')); vt['ny'] = float(norm.get('y','0')); vt['nz'] = float(norm.get('z','0'))
verts.append(vt)
faces = []
for sm in root.findall('.//submesh'):
for fe in sm.findall('faces'):
for f in fe.findall('face'):
faces.append((int(f.get('v1','0')), int(f.get('v2','0')), int(f.get('v3','0'))))
bw = [{} for _ in range(len(verts))]
ba = root.find('boneassignments')
if ba is not None:
for a in ba.findall('vertexboneassignment'):
vi = int(a.get('vertexindex','0'))
bi = int(a.get('boneindex','0'))
w = float(a.get('weight','1.0'))
if vi < len(bw):
bw[vi][bi] = w
return verts, faces, bw
# ── glTF builder ────────────────────────────────────────────────────────────
def build_gltf(mesh_path, skeleton_path, output_path):
verts, faces, bw = parse_mesh(mesh_path)
bones, hierarchy, animations = parse_skeleton(skeleton_path)
# Topological sort of bones
bone_by_name = {b['name']: b for b in bones}
bone_by_id = {b['id']: b for b in bones}
roots = [b for b in bones if b['name'] not in hierarchy]
order = []
visited = set()
def dfs(name):
if name in visited: return
visited.add(name)
order.append(name)
for child, parent in hierarchy.items():
if parent == name:
dfs(child)
for root in roots:
dfs(root['name'])
name_to_idx = {name: i for i, name in enumerate(order)}
# glTF nodes (bones only for now)
nodes = []
for name in order:
b = bone_by_name[name]
q = axis_angle_to_quat(*b['rot_axis'], b['rot_angle'])
nodes.append({
'name': name,
'translation': list(b['pos']),
'rotation': [q[0], q[1], q[2], q[3]],
'scale': list(b['scale']),
})
for child, parent in hierarchy.items():
if child in name_to_idx and parent in name_to_idx:
ci, pi = name_to_idx[child], name_to_idx[parent]
nodes[pi].setdefault('children', []).append(ci)
# Add a skeleton root node (identity matrix, all bones as children)
skeleton_root_idx = len(nodes)
nodes.append({'name': '__skeleton_root__', 'children': []})
# Only add bones that are NOT children of other bones in hierarchy
for i, name in enumerate(order):
is_child = any(i in nodes[idx].get('children', []) for idx in range(len(nodes)))
if not is_child:
nodes[skeleton_root_idx]['children'].append(i)
# Inverse bind matrices
def world_transform(name):
b = bone_by_name[name]
q = axis_angle_to_quat(*b['rot_axis'], b['rot_angle'])
local = mat4_from_trs(b['pos'][0], b['pos'][1], b['pos'][2],
q[0], q[1], q[2], q[3], 1, 1, 1)
parent = hierarchy.get(name)
if parent and parent in bone_by_name:
return mat4_mult(world_transform(parent), local)
return local
ibm_flat = []
for name in order:
w = world_transform(name)
# Invert 4x4 matrix
m = [w[i + j*4] for j in range(4) for i in range(4)] # transpose to row-major for inversion
inv = invert_4x4(m)
ibm_flat.extend(inv) # back in row-major? No - glTF uses column-major
# Actually glTF stores matrices column-major in the linear buffer
# The inverse bind matrix in column-major form: each 4 floats = 1 column
ibm_col_major = []
for name in order:
w = world_transform(name)
# w is column-major: [c0x, c0y, c0z, c0w, c1x, ..., c3w]
# Invert it
inv = invert_4x4_colmajor(w)
ibm_col_major.extend(inv)
# Joint indices for skin
skin_joints = [name_to_idx[name] for name in order]
# Vertex data
positions = []
nrm = []
joints0 = []
weights0 = []
for i, v in enumerate(verts):
positions.extend([v['x'], v['y'], v['z']])
nrm.extend([v['nx'], v['ny'], v['nz']])
wmap = bw[i] if i < len(bw) else {}
sw = sorted(wmap.items(), key=lambda x: x[1], reverse=True)[:4]
j = [0, 0, 0, 0]
wgt = [0.0, 0.0, 0.0, 0.0]
for k, (bid, weight) in enumerate(sw):
if bid in bone_by_id:
jname = bone_by_id[bid]['name']
j[k] = name_to_idx.get(jname, 0)
wgt[k] = weight
total = sum(wgt)
if total > 0:
wgt = [x / total for x in wgt]
joints0.extend(j)
weights0.extend(wgt)
indices = []
for f in faces:
indices.extend(list(f))
# Bounding box
all_x = [v['x'] for v in verts]
all_y = [v['y'] for v in verts]
all_z = [v['z'] for v in verts]
bbox_min = [min(all_x), min(all_y), min(all_z)]
bbox_max = [max(all_x), max(all_y), max(all_z)]
# Model node (has mesh and skin)
model_node_idx = len(nodes)
nodes.append({'name': os.path.basename(mesh_path).replace('.mesh.xml', '') + '_mesh',
'mesh': 0, 'skin': 0})
# Scene root
scene_node_idx = len(nodes)
nodes.append({'name': 'scene_root', 'children': [skeleton_root_idx, model_node_idx]})
# ── Build buffer ────────────────────────────────────────────────────────
pos_bytes = pad(struct.pack(f'<{len(positions)}f', *positions))
nrm_bytes = pad(struct.pack(f'<{len(nrm)}f', *nrm))
idx_bytes = pad(struct.pack(f'<{len(indices)}H', *indices))
jnt_bytes = pad(struct.pack(f'<{len(joints0)}H', *joints0))
wgt_bytes = pad(struct.pack(f'<{len(weights0)}f', *weights0))
ibm_bytes = pad(struct.pack(f'<{len(ibm_col_major)}f', *ibm_col_major))
# Base data end
base_end = len(pos_bytes) + len(nrm_bytes) + len(idx_bytes) + len(jnt_bytes) + len(wgt_bytes) + len(ibm_bytes)
# ── Animations ──────────────────────────────────────────────────────────
anim_data = bytearray()
gltf_animations = []
buf_views = []
accessors_list = []
# Fill static buffer views and accessors first
off = 0
bv_pos = {'buffer': 0, 'byteOffset': off, 'byteLength': len(pos_bytes), 'target': 34962}; off += len(pos_bytes)
bv_nrm = {'buffer': 0, 'byteOffset': off, 'byteLength': len(nrm_bytes), 'target': 34962}; off += len(nrm_bytes)
bv_idx = {'buffer': 0, 'byteOffset': off, 'byteLength': len(idx_bytes), 'target': 34963}; off += len(idx_bytes)
bv_jnt = {'buffer': 0, 'byteOffset': off, 'byteLength': len(jnt_bytes), 'target': 34962}; off += len(jnt_bytes)
bv_wgt = {'buffer': 0, 'byteOffset': off, 'byteLength': len(wgt_bytes), 'target': 34962}; off += len(wgt_bytes)
bv_ibm = {'buffer': 0, 'byteOffset': off, 'byteLength': len(ibm_bytes)}; off += len(ibm_bytes)
buffer_views = [bv_pos, bv_nrm, bv_idx, bv_jnt, bv_wgt, bv_ibm]
accessors = [
{'bufferView': 0, 'componentType': 5126, 'count': len(verts), 'type': 'VEC3',
'min': bbox_min, 'max': bbox_max},
{'bufferView': 1, 'componentType': 5126, 'count': len(verts), 'type': 'VEC3'},
{'bufferView': 2, 'componentType': 5123, 'count': len(indices), 'type': 'SCALAR'},
{'bufferView': 3, 'componentType': 5123, 'count': len(verts), 'type': 'VEC4'},
{'bufferView': 4, 'componentType': 5126, 'count': len(verts), 'type': 'VEC4'},
{'bufferView': 5, 'componentType': 5126, 'count': len(order), 'type': 'MAT4'},
]
anim_base = off # animations start here
for anim_name, tracks in animations.items():
all_times = set()
for bn, kfs in tracks.items():
for t, *_ in kfs:
all_times.add(t)
sorted_times = sorted(all_times)
if len(sorted_times) < 2:
continue
time_data = pad(struct.pack(f'<{len(sorted_times)}f', *sorted_times))
time_off = len(anim_data)
anim_data.extend(time_data)
# Buffer view for time
tv_idx = len(buffer_views)
buffer_views.append({'buffer': 0, 'byteOffset': anim_base + time_off, 'byteLength': len(time_data)})
# Accessor for time (shared per animation)
t_acc_idx = len(accessors)
accessors.append({'bufferView': tv_idx, 'componentType': 5126, 'count': len(sorted_times),
'type': 'SCALAR', 'min': [sorted_times[0]], 'max': [sorted_times[-1]]})
samplers = []
channels = []
for bone_name, keyframes in tracks.items():
if bone_name not in name_to_idx:
continue
node_idx = name_to_idx[bone_name]
kf_times = [t for t, *_ in keyframes]
kf_trans = [list(kf[1:4]) for kf in keyframes]
kf_rot = [list(kf[4:8]) for kf in keyframes]
kf_scale = [list(kf[8:11]) for kf in keyframes]
for data, acc_type, path in [
(lerp_keyframes(kf_times, kf_trans, sorted_times), 'VEC3', 'translation'),
(lerp_keyframes(kf_times, kf_rot, sorted_times), 'VEC4', 'rotation'),
(lerp_keyframes(kf_times, kf_scale, sorted_times), 'VEC3', 'scale'),
]:
raw = pad(struct.pack(f'<{len(data)}f', *data))
off2 = len(anim_data)
anim_data.extend(raw)
dv_idx = len(buffer_views)
buffer_views.append({'buffer': 0, 'byteOffset': anim_base + off2, 'byteLength': len(raw)})
acc_idx = len(accessors)
accessors.append({'bufferView': dv_idx, 'componentType': 5126,
'count': len(sorted_times), 'type': acc_type})
samp_idx = len(samplers)
samplers.append({'input': t_acc_idx, 'interpolation': 'LINEAR', 'output': acc_idx})
channels.append({'sampler': samp_idx, 'target': {'node': node_idx, 'path': path}})
if samplers and channels:
gltf_animations.append({'name': anim_name, 'samplers': samplers, 'channels': channels})
anim_bin = bytes(anim_data)
# Combine
all_bin = pos_bytes + nrm_bytes + idx_bytes + jnt_bytes + wgt_bytes + ibm_bytes + anim_bin
# ── Build JSON ──────────────────────────────────────────────────────────
meshes = [{'name': 'mesh', 'primitives': [{
'attributes': {'POSITION': 0, 'NORMAL': 1, 'JOINTS_0': 3, 'WEIGHTS_0': 4},
'indices': 2,
'material': 0,
}]}]
materials = [{
'name': 'default',
'pbrMetallicRoughness': {
'baseColorFactor': [1.0, 1.0, 1.0, 1.0],
'metallicFactor': 0.0,
'roughnessFactor': 0.5,
},
}]
skin = [{'inverseBindMatrices': 5, 'joints': skin_joints, 'name': 'skin'}]
gltf = {
'asset': {'version': '2.0', 'generator': 'ogre2gltf.py'},
'scene': 0,
'scenes': [{'nodes': [scene_node_idx]}],
'nodes': nodes,
'meshes': meshes,
'materials': materials,
'skins': skin,
'animations': gltf_animations,
'accessors': accessors,
'bufferViews': buffer_views,
'buffers': [{'byteLength': len(all_bin)}],
}
# ── Write GLB ───────────────────────────────────────────────────────────
gltf_json = json.dumps(gltf, separators=(',', ':'), allow_nan=False).encode('utf-8')
# GLB spec: JSON chunk padded with spaces (0x20), BIN chunk padded with nulls (0x00)
while len(gltf_json) % 4:
gltf_json += b' '
while len(all_bin) % 4:
all_bin += b'\x00'
header = struct.pack('<III', 0x46546C67, 2, 12 + 8 + len(gltf_json) + 8 + len(all_bin))
json_chunk = struct.pack('<II', len(gltf_json), 0x4E4F534A)
bin_chunk = struct.pack('<II', len(all_bin), 0x004E4942)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, 'wb') as f:
f.write(header + json_chunk + gltf_json + bin_chunk + all_bin)
print(f' -> {output_path} ({len(verts)}v {len(faces)}f {len(bones)}b {len(gltf_animations)}a)')
# ── Matrix inversion ───────────────────────────────────────────────────────
def invert_4x4(m):
"""Invert 4x4 row-major matrix, return row-major."""
a, b, c, d, e, f, g, h, i, j, k, l, m_, n, o, p = m
det = (a * (f*k*p + g*l*n + h*j*o - h*k*n - f*l*o - g*j*p) -
b * (e*k*p + g*l*m_ + h*i*o - h*k*m_ - e*l*o - g*i*p) +
c * (e*j*p + f*l*m_ + h*i*n - h*j*m_ - e*l*n - f*i*p) -
d * (e*j*o + f*k*m_ + g*i*n - g*j*m_ - e*k*n - f*i*o))
if abs(det) < 1e-12:
return [1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]
inv_det = 1.0 / det
return [
(f*k*p + g*l*n + h*j*o - h*k*n - f*l*o - g*j*p) * inv_det,
(b*k*p + c*l*n + d*j*o - d*k*n - b*l*o - c*j*p) * inv_det,
(b*g*p + c*h*n + d*f*o - d*g*n - b*h*o - c*f*p) * inv_det,
(b*g*l + c*h*j + d*f*k - d*g*j - b*h*k - c*f*l) * inv_det,
(e*k*p + g*l*m_ + h*i*o - h*k*m_ - e*l*o - g*i*p) * inv_det,
(a*k*p + c*l*m_ + d*i*o - d*k*m_ - a*l*o - c*i*p) * inv_det,
(a*g*p + c*h*m_ + d*e*o - d*g*m_ - a*h*o - c*e*p) * inv_det,
(a*g*l + c*h*i + d*e*k - d*g*i - a*h*k - c*e*l) * inv_det,
(e*j*p + f*l*m_ + h*i*n - h*j*m_ - e*l*n - f*i*p) * inv_det,
(a*j*p + b*l*m_ + d*i*n - d*j*m_ - a*l*n - b*i*p) * inv_det,
(a*f*p + b*h*m_ + d*e*n - d*f*m_ - a*h*n - b*e*p) * inv_det,
(a*f*l + b*h*i + d*e*j - d*f*i - a*h*j - b*e*l) * inv_det,
(e*j*o + f*k*m_ + g*i*n - g*j*m_ - e*k*n - f*i*o) * inv_det,
(a*j*o + b*k*m_ + c*i*n - c*j*m_ - a*k*n - b*i*o) * inv_det,
(a*f*o + b*g*m_ + c*e*n - c*f*m_ - a*g*n - b*e*o) * inv_det,
(a*f*k + b*g*i + c*e*j - c*f*i - a*g*j - b*e*k) * inv_det,
]
def invert_4x4_colmajor(m):
"""Invert 4x4 column-major matrix, return column-major."""
# Convert col-major to row-major, invert, convert back
rowm = [m[i + j*4] for j in range(4) for i in range(4)]
inv_rowm = invert_4x4(rowm)
return [inv_rowm[j + i*4] for i in range(4) for j in range(4)]
# ── Main ────────────────────────────────────────────────────────────────────
def main():
base = '/var/home/nico/Gamedev/Wackelpeter/extracted/Models'
out = '/var/home/nico/Gamedev/Wackelpeter/web/public/models'
for name, mesh, skel in [
('blob', 'blob/Blob', 'blob/Blob'),
('sword', 'sword/sword', 'sword/sword'),
('shield', 'shield/shield', 'shield/shield'),
]:
mf = os.path.join(base, f'{mesh}.mesh.xml')
sf = os.path.join(base, f'{skel}.skeleton.xml')
if os.path.exists(mf) and os.path.exists(sf):
print(f'Converting {name}...')
build_gltf(mf, sf, os.path.join(out, f'{name}.glb'))
else:
print(f'Skipping {name} (missing XML)')
if __name__ == '__main__':
main()