|
#!/usr/bin/env python3 |
|
""" |
|
mdl2gltf - Convert Quake .MDL files to glTF 2.0 (.glb) |
|
|
|
Supports: |
|
- Single and group skins (first skin used as texture) |
|
- Single and group frames (all sub-frames flattened as animation targets) |
|
- Proper UV seam handling (backface onseam offset) |
|
- Vertex normals from Quake's precalculated normal table |
|
- Morph-target animation exported as glTF morph targets |
|
- Embedded PNG texture in the .glb binary |
|
|
|
Usage: |
|
python mdl2gltf.py input.mdl [output.glb] |
|
""" |
|
|
|
import struct |
|
import sys |
|
import json |
|
import io |
|
import math |
|
from pathlib import Path |
|
|
|
ANORMS = [ |
|
(-0.525731, 0.000000, 0.850651), (-0.442863, 0.238856, 0.864188), |
|
(-0.295242, 0.000000, 0.955423), (-0.309017, 0.500000, 0.809017), |
|
(-0.162460, 0.262866, 0.951056), ( 0.000000, 0.000000, 1.000000), |
|
( 0.000000, 0.850651, 0.525731), (-0.147621, 0.716567, 0.681718), |
|
( 0.147621, 0.716567, 0.681718), ( 0.000000, 0.525731, 0.850651), |
|
( 0.309017, 0.500000, 0.809017), ( 0.525731, 0.000000, 0.850651), |
|
( 0.295242, 0.000000, 0.955423), ( 0.442863, 0.238856, 0.864188), |
|
( 0.162460, 0.262866, 0.951056), (-0.681718, 0.147621, 0.716567), |
|
(-0.809017, 0.309017, 0.500000), (-0.587785, 0.425325, 0.688191), |
|
(-0.850651, 0.525731, 0.000000), (-0.864188, 0.442863, 0.238856), |
|
(-0.716567, 0.681718, 0.147621), (-0.688191, 0.587785, 0.425325), |
|
(-0.500000, 0.809017, 0.309017), (-0.238856, 0.864188, 0.442863), |
|
(-0.425325, 0.688191, 0.587785), (-0.716567, 0.681718,-0.147621), |
|
(-0.500000, 0.809017,-0.309017), (-0.525731, 0.850651, 0.000000), |
|
( 0.000000, 0.850651,-0.525731), (-0.238856, 0.864188,-0.442863), |
|
( 0.000000, 0.955423,-0.295242), (-0.262866, 0.951056,-0.162460), |
|
( 0.000000, 1.000000, 0.000000), ( 0.000000, 0.955423, 0.295242), |
|
(-0.262866, 0.951056, 0.162460), ( 0.238856, 0.864188, 0.442863), |
|
( 0.262866, 0.951056, 0.162460), ( 0.500000, 0.809017, 0.309017), |
|
( 0.238856, 0.864188,-0.442863), ( 0.262866, 0.951056,-0.162460), |
|
( 0.500000, 0.809017,-0.309017), ( 0.850651, 0.525731, 0.000000), |
|
( 0.716567, 0.681718, 0.147621), ( 0.716567, 0.681718,-0.147621), |
|
( 0.525731, 0.850651, 0.000000), ( 0.425325, 0.688191, 0.587785), |
|
( 0.864188, 0.442863, 0.238856), ( 0.688191, 0.587785, 0.425325), |
|
( 0.809017, 0.309017, 0.500000), ( 0.681718, 0.147621, 0.716567), |
|
( 0.587785, 0.425325, 0.688191), ( 0.955423, 0.295242, 0.000000), |
|
( 1.000000, 0.000000, 0.000000), ( 0.951056, 0.162460, 0.262866), |
|
( 0.850651,-0.525731, 0.000000), ( 0.955423,-0.295242, 0.000000), |
|
( 0.864188,-0.442863, 0.238856), ( 0.951056,-0.162460, 0.262866), |
|
( 0.809017,-0.309017, 0.500000), ( 0.681718,-0.147621, 0.716567), |
|
( 0.850651, 0.000000, 0.525731), ( 0.864188, 0.442863,-0.238856), |
|
( 0.809017, 0.309017,-0.500000), ( 0.951056, 0.162460,-0.262866), |
|
( 0.525731, 0.000000,-0.850651), ( 0.681718, 0.147621,-0.716567), |
|
( 0.681718,-0.147621,-0.716567), ( 0.850651, 0.000000,-0.525731), |
|
( 0.809017,-0.309017,-0.500000), ( 0.864188,-0.442863,-0.238856), |
|
( 0.951056,-0.162460,-0.262866), ( 0.147621, 0.716567,-0.681718), |
|
( 0.309017, 0.500000,-0.809017), ( 0.425325, 0.688191,-0.587785), |
|
( 0.442863, 0.238856,-0.864188), ( 0.587785, 0.425325,-0.688191), |
|
( 0.688191, 0.587785,-0.425325), (-0.147621, 0.716567,-0.681718), |
|
(-0.309017, 0.500000,-0.809017), ( 0.000000, 0.525731,-0.850651), |
|
(-0.525731, 0.000000,-0.850651), (-0.442863, 0.238856,-0.864188), |
|
(-0.295242, 0.000000,-0.955423), (-0.162460, 0.262866,-0.951056), |
|
( 0.000000, 0.000000,-1.000000), ( 0.295242, 0.000000,-0.955423), |
|
( 0.162460, 0.262866,-0.951056), (-0.442863,-0.238856,-0.864188), |
|
(-0.309017,-0.500000,-0.809017), (-0.162460,-0.262866,-0.951056), |
|
( 0.000000,-0.850651,-0.525731), (-0.147621,-0.716567,-0.681718), |
|
( 0.147621,-0.716567,-0.681718), ( 0.000000,-0.525731,-0.850651), |
|
( 0.309017,-0.500000,-0.809017), ( 0.442863,-0.238856,-0.864188), |
|
( 0.162460,-0.262866,-0.951056), ( 0.238856,-0.864188,-0.442863), |
|
( 0.500000,-0.809017,-0.309017), ( 0.425325,-0.688191,-0.587785), |
|
( 0.716567,-0.681718,-0.147621), ( 0.688191,-0.587785,-0.425325), |
|
( 0.587785,-0.425325,-0.688191), ( 0.000000,-0.955423,-0.295242), |
|
( 0.000000,-1.000000, 0.000000), ( 0.262866,-0.951056,-0.162460), |
|
( 0.000000,-0.850651, 0.525731), ( 0.000000,-0.955423, 0.295242), |
|
( 0.238856,-0.864188, 0.442863), ( 0.262866,-0.951056, 0.162460), |
|
( 0.500000,-0.809017, 0.309017), ( 0.716567,-0.681718, 0.147621), |
|
( 0.525731,-0.850651, 0.000000), (-0.238856,-0.864188,-0.442863), |
|
(-0.500000,-0.809017,-0.309017), (-0.262866,-0.951056,-0.162460), |
|
(-0.850651,-0.525731, 0.000000), (-0.716567,-0.681718,-0.147621), |
|
(-0.716567,-0.681718, 0.147621), (-0.525731,-0.850651, 0.000000), |
|
(-0.500000,-0.809017, 0.309017), (-0.238856,-0.864188, 0.442863), |
|
(-0.262866,-0.951056, 0.162460), (-0.864188,-0.442863, 0.238856), |
|
(-0.809017,-0.309017, 0.500000), (-0.688191,-0.587785, 0.425325), |
|
(-0.681718,-0.147621, 0.716567), (-0.442863,-0.238856, 0.864188), |
|
(-0.587785,-0.425325, 0.688191), (-0.309017,-0.500000, 0.809017), |
|
(-0.147621,-0.716567, 0.681718), (-0.425325,-0.688191, 0.587785), |
|
(-0.162460,-0.262866, 0.951056), ( 0.442863,-0.238856, 0.864188), |
|
( 0.162460,-0.262866, 0.951056), ( 0.309017,-0.500000, 0.809017), |
|
( 0.147621,-0.716567, 0.681718), ( 0.000000,-0.525731, 0.850651), |
|
( 0.425325,-0.688191, 0.587785), ( 0.587785,-0.425325, 0.688191), |
|
( 0.688191,-0.587785, 0.425325), (-0.955423, 0.295242, 0.000000), |
|
(-0.951056, 0.162460, 0.262866), (-1.000000, 0.000000, 0.000000), |
|
(-0.850651, 0.000000, 0.525731), (-0.955423,-0.295242, 0.000000), |
|
(-0.951056,-0.162460, 0.262866), (-0.864188, 0.442863,-0.238856), |
|
(-0.951056, 0.162460,-0.262866), (-0.809017, 0.309017,-0.500000), |
|
(-0.864188,-0.442863,-0.238856), (-0.951056,-0.162460,-0.262866), |
|
(-0.809017,-0.309017,-0.500000), (-0.681718, 0.147621,-0.716567), |
|
(-0.681718,-0.147621,-0.716567), (-0.850651, 0.000000,-0.525731), |
|
(-0.688191, 0.587785,-0.425325), (-0.587785, 0.425325,-0.688191), |
|
(-0.425325, 0.688191,-0.587785), (-0.425325,-0.688191,-0.587785), |
|
(-0.587785,-0.425325,-0.688191), (-0.688191,-0.587785,-0.425325), |
|
] |
|
|
|
QUAKE_PALETTE = [ |
|
( 0, 0, 0), ( 15, 15, 15), ( 31, 31, 31), ( 47, 47, 47), |
|
( 63, 63, 63), ( 75, 75, 75), ( 91, 91, 91), (107, 107, 107), |
|
(123, 123, 123), (139, 139, 139), (155, 155, 155), (171, 171, 171), |
|
(187, 187, 187), (203, 203, 203), (219, 219, 219), (235, 235, 235), |
|
( 15, 11, 7), ( 23, 15, 11), ( 31, 23, 11), ( 39, 27, 15), |
|
( 47, 35, 19), ( 55, 43, 23), ( 63, 47, 23), ( 75, 55, 27), |
|
( 83, 59, 27), ( 91, 67, 31), ( 99, 75, 31), (107, 83, 31), |
|
(115, 87, 31), (123, 95, 35), (131, 103, 35), (143, 111, 35), |
|
( 11, 11, 15), ( 19, 19, 27), ( 27, 27, 39), ( 39, 39, 51), |
|
( 47, 47, 63), ( 55, 55, 75), ( 63, 63, 87), ( 71, 71, 103), |
|
( 79, 79, 115), ( 91, 91, 127), ( 99, 99, 139), (107, 107, 151), |
|
(115, 115, 163), (123, 123, 175), (131, 131, 187), (139, 139, 203), |
|
( 0, 0, 0), ( 7, 7, 0), ( 11, 11, 0), ( 19, 19, 0), |
|
( 27, 27, 0), ( 35, 35, 0), ( 43, 43, 7), ( 47, 47, 7), |
|
( 55, 55, 7), ( 63, 63, 7), ( 71, 71, 7), ( 75, 75, 11), |
|
( 83, 83, 11), ( 91, 91, 11), ( 99, 99, 11), (107, 107, 15), |
|
( 7, 0, 0), ( 15, 0, 0), ( 23, 0, 0), ( 31, 0, 0), |
|
( 39, 0, 0), ( 47, 0, 0), ( 55, 0, 0), ( 63, 0, 0), |
|
( 71, 0, 0), ( 79, 0, 0), ( 87, 0, 0), ( 95, 0, 0), |
|
(103, 0, 0), (111, 0, 0), (119, 0, 0), (127, 0, 0), |
|
( 19, 19, 0), ( 27, 27, 0), ( 35, 35, 0), ( 47, 43, 0), |
|
( 55, 47, 0), ( 67, 55, 0), ( 75, 59, 7), ( 87, 67, 7), |
|
( 95, 71, 7), (107, 75, 11), (119, 83, 15), (131, 87, 19), |
|
(139, 91, 19), (151, 95, 27), (163, 99, 31), (175, 103, 35), |
|
( 35, 19, 7), ( 47, 23, 11), ( 59, 31, 15), ( 75, 35, 19), |
|
( 87, 43, 23), ( 99, 47, 31), (115, 55, 35), (127, 59, 43), |
|
(143, 67, 51), (159, 79, 51), (175, 99, 47), (191, 119, 47), |
|
(207, 143, 43), (223, 171, 39), (239, 203, 31), (255, 243, 27), |
|
( 11, 7, 0), ( 27, 19, 0), ( 43, 35, 15), ( 55, 43, 19), |
|
( 71, 51, 27), ( 83, 55, 35), ( 99, 63, 43), (111, 71, 51), |
|
(127, 83, 63), (139, 95, 71), (155, 107, 83), (167, 123, 95), |
|
(183, 135, 107), (195, 147, 123), (211, 163, 139), (227, 179, 151), |
|
(171, 139, 163), (159, 127, 151), (147, 115, 135), (139, 103, 123), |
|
(127, 91, 111), (119, 83, 99), (107, 75, 87), ( 95, 63, 75), |
|
( 87, 55, 67), ( 75, 47, 55), ( 67, 39, 47), ( 55, 31, 35), |
|
( 43, 23, 27), ( 35, 19, 19), ( 23, 11, 11), ( 15, 7, 7), |
|
(187, 115, 159), (175, 107, 143), (163, 95, 131), (151, 87, 119), |
|
(139, 79, 107), (127, 75, 95), (115, 67, 83), (107, 59, 75), |
|
( 95, 51, 63), ( 83, 43, 55), ( 71, 35, 43), ( 59, 31, 35), |
|
( 47, 23, 27), ( 35, 19, 19), ( 23, 11, 11), ( 15, 7, 7), |
|
(219, 195, 187), (203, 179, 167), (191, 163, 155), (175, 151, 139), |
|
(163, 135, 123), (151, 123, 111), (135, 111, 95), (123, 99, 83), |
|
(107, 87, 71), ( 95, 75, 59), ( 83, 63, 51), ( 67, 51, 39), |
|
( 55, 43, 31), ( 39, 31, 23), ( 27, 19, 15), ( 15, 11, 7), |
|
(111, 131, 123), (103, 123, 111), ( 95, 115, 103), ( 87, 107, 95), |
|
( 79, 99, 87), ( 71, 91, 79), ( 63, 83, 71), ( 55, 75, 63), |
|
( 47, 67, 55), ( 43, 59, 47), ( 35, 51, 39), ( 31, 43, 31), |
|
( 23, 35, 23), ( 15, 27, 19), ( 11, 19, 11), ( 7, 11, 7), |
|
(255, 243, 27), (239, 223, 23), (219, 203, 19), (203, 183, 15), |
|
(187, 167, 15), (171, 151, 11), (155, 131, 7), (139, 115, 7), |
|
(123, 99, 7), (107, 83, 0), ( 91, 71, 0), ( 75, 55, 0), |
|
( 59, 43, 0), ( 43, 31, 0), ( 27, 15, 0), ( 11, 7, 0), |
|
( 0, 0, 255), ( 11, 11, 239), ( 19, 19, 223), ( 27, 27, 207), |
|
( 35, 35, 191), ( 43, 43, 175), ( 47, 47, 159), ( 47, 47, 143), |
|
( 47, 47, 127), ( 47, 47, 111), ( 47, 47, 95), ( 43, 43, 79), |
|
( 35, 35, 63), ( 27, 27, 47), ( 19, 19, 31), ( 11, 11, 15), |
|
( 43, 0, 0), ( 59, 0, 0), ( 75, 7, 0), ( 95, 7, 0), |
|
(111, 15, 0), (127, 23, 7), (147, 31, 7), (163, 39, 11), |
|
(183, 51, 15), (195, 75, 27), (207, 99, 43), (219, 127, 59), |
|
(227, 151, 79), (231, 171, 95), (239, 191, 119), (247, 211, 139), |
|
(167, 123, 59), (183, 155, 55), (199, 195, 55), (231, 227, 87), |
|
(127, 191, 255), (171, 231, 255), (215, 255, 255), (103, 0, 0), |
|
(139, 0, 0), (179, 0, 0), (215, 0, 0), (255, 0, 0), |
|
(255, 243, 147), (255, 247, 199), (255, 255, 255), (159, 91, 83), |
|
] |
|
|
|
|
|
class MDLReader: |
|
def __init__(self, data: bytes): |
|
self.data = data |
|
self.pos = 0 |
|
|
|
def read(self, n: int) -> bytes: |
|
result = self.data[self.pos:self.pos + n] |
|
self.pos += n |
|
return result |
|
|
|
def read_int(self) -> int: |
|
return struct.unpack('<i', self.read(4))[0] |
|
|
|
def read_float(self) -> float: |
|
return struct.unpack('<f', self.read(4))[0] |
|
|
|
def read_vec3(self) -> tuple: |
|
return struct.unpack('<3f', self.read(12)) |
|
|
|
def read_vertex(self) -> tuple: |
|
v = struct.unpack('<3B', self.read(3)) |
|
ni = struct.unpack('<B', self.read(1))[0] |
|
return v, ni |
|
|
|
|
|
def parse_mdl(filepath: str) -> dict: |
|
with open(filepath, 'rb') as f: |
|
data = f.read() |
|
|
|
r = MDLReader(data) |
|
|
|
ident = r.read_int() |
|
version = r.read_int() |
|
assert ident == 1330660425, f"Bad magic: {ident:#x}" |
|
assert version == 6, f"Bad version: {version}" |
|
|
|
scale = r.read_vec3() |
|
translate = r.read_vec3() |
|
boundingradius = r.read_float() |
|
eyeposition = r.read_vec3() |
|
|
|
num_skins = r.read_int() |
|
skinwidth = r.read_int() |
|
skinheight = r.read_int() |
|
num_verts = r.read_int() |
|
num_tris = r.read_int() |
|
num_frames = r.read_int() |
|
synctype = r.read_int() |
|
flags = r.read_int() |
|
size = r.read_float() |
|
|
|
skin_size = skinwidth * skinheight |
|
skins = [] |
|
for _ in range(num_skins): |
|
group = r.read_int() |
|
if group == 0: |
|
pixel_data = r.read(skin_size) |
|
skins.append(pixel_data) |
|
else: |
|
nb = r.read_int() |
|
for _ in range(nb): |
|
r.read_float() |
|
for j in range(nb): |
|
pixel_data = r.read(skin_size) |
|
if j == 0: |
|
skins.append(pixel_data) |
|
|
|
texcoords = [] |
|
for _ in range(num_verts): |
|
onseam = r.read_int() |
|
s = r.read_int() |
|
t = r.read_int() |
|
texcoords.append((onseam, s, t)) |
|
|
|
triangles = [] |
|
for _ in range(num_tris): |
|
facesfront = r.read_int() |
|
v0 = r.read_int() |
|
v1 = r.read_int() |
|
v2 = r.read_int() |
|
triangles.append((facesfront, (v0, v1, v2))) |
|
|
|
frames = [] |
|
for _ in range(num_frames): |
|
frame_type = r.read_int() |
|
if frame_type == 0: |
|
bboxmin = r.read_vertex() |
|
bboxmax = r.read_vertex() |
|
name = r.read(16).split(b'\x00')[0].decode('ascii', errors='replace') |
|
verts = [] |
|
for _ in range(num_verts): |
|
verts.append(r.read_vertex()) |
|
frames.append({'name': name, 'verts': verts}) |
|
else: |
|
nb = r.read_int() |
|
group_bboxmin = r.read_vertex() |
|
group_bboxmax = r.read_vertex() |
|
for _ in range(nb): |
|
r.read_float() |
|
for j in range(nb): |
|
bboxmin = r.read_vertex() |
|
bboxmax = r.read_vertex() |
|
name = r.read(16).split(b'\x00')[0].decode('ascii', errors='replace') |
|
verts = [] |
|
for _ in range(num_verts): |
|
verts.append(r.read_vertex()) |
|
frames.append({'name': name, 'verts': verts}) |
|
|
|
return { |
|
'scale': scale, |
|
'translate': translate, |
|
'skinwidth': skinwidth, |
|
'skinheight': skinheight, |
|
'num_verts': num_verts, |
|
'num_tris': num_tris, |
|
'skins': skins, |
|
'texcoords': texcoords, |
|
'triangles': triangles, |
|
'frames': frames, |
|
} |
|
|
|
|
|
def skin_to_png(pixel_data: bytes, width: int, height: int) -> bytes: |
|
try: |
|
import png |
|
rows = [] |
|
for y in range(height): |
|
row = [] |
|
for x in range(width): |
|
idx = pixel_data[y * width + x] |
|
r, g, b = QUAKE_PALETTE[idx] |
|
alpha = 0 if idx == 255 else 255 |
|
row.extend([r, g, b, alpha]) |
|
rows.append(row) |
|
buf = io.BytesIO() |
|
w = png.Writer(width=width, height=height, alpha=True, greyscale=False) |
|
w.write(buf, rows) |
|
return buf.getvalue() |
|
except ImportError: |
|
return _write_png_manual(pixel_data, width, height) |
|
|
|
|
|
def _write_png_manual(pixel_data: bytes, width: int, height: int) -> bytes: |
|
import zlib |
|
|
|
def chunk(chunk_type: bytes, data: bytes) -> bytes: |
|
c = chunk_type + data |
|
crc = struct.pack('>I', zlib.crc32(c) & 0xffffffff) |
|
return struct.pack('>I', len(data)) + c + crc |
|
|
|
raw = bytearray() |
|
for y in range(height): |
|
raw.append(0) |
|
for x in range(width): |
|
idx = pixel_data[y * width + x] |
|
r, g, b = QUAKE_PALETTE[idx] |
|
alpha = 0 if idx == 255 else 255 |
|
raw.extend([r, g, b, alpha]) |
|
|
|
compressed = zlib.compress(bytes(raw), 9) |
|
|
|
out = io.BytesIO() |
|
out.write(b'\x89PNG\r\n\x1a\n') |
|
out.write(chunk(b'IHDR', struct.pack('>IIBBBBB', width, height, 8, 6, 0, 0, 0))) |
|
out.write(chunk(b'IDAT', compressed)) |
|
out.write(chunk(b'IEND', b'')) |
|
return out.getvalue() |
|
|
|
|
|
def build_mesh_data(mdl: dict, frame_idx: int): |
|
""" |
|
Build per-triangle-vertex position/normal/uv arrays for a given frame. |
|
MDL uses indexed vertices but UVs depend on triangle face direction, |
|
so we need to "unroll" to per-face-vertex. |
|
|
|
Quake coordinate system: X = forward, Y = left, Z = up |
|
glTF coordinate system: X = right, Y = up, Z = forward (toward viewer) |
|
Transform: gltf_x = -mdl_y, gltf_y = mdl_z, gltf_z = mdl_x |
|
""" |
|
scale = mdl['scale'] |
|
translate = mdl['translate'] |
|
skinwidth = mdl['skinwidth'] |
|
skinheight = mdl['skinheight'] |
|
texcoords = mdl['texcoords'] |
|
triangles = mdl['triangles'] |
|
frame = mdl['frames'][frame_idx] |
|
|
|
positions = [] |
|
normals = [] |
|
uvs = [] |
|
|
|
for facesfront, (v0, v1, v2) in triangles: |
|
for vi in (v0, v1, v2): |
|
vert_data, normal_idx = frame['verts'][vi] |
|
px = scale[0] * vert_data[0] + translate[0] |
|
py = scale[1] * vert_data[1] + translate[1] |
|
pz = scale[2] * vert_data[2] + translate[2] |
|
|
|
gx = -py |
|
gy = pz |
|
gz = px |
|
|
|
positions.append((gx, gy, gz)) |
|
|
|
ni = min(normal_idx, 161) |
|
nx, ny, nz = ANORMS[ni] |
|
normals.append((-ny, nz, nx)) |
|
|
|
onseam, s, t = texcoords[vi] |
|
if not facesfront and onseam: |
|
s += skinwidth * 0.5 |
|
|
|
u = (s + 0.5) / skinwidth |
|
v = (t + 0.5) / skinheight |
|
uvs.append((u, v)) |
|
|
|
return positions, normals, uvs |
|
|
|
|
|
def compute_morph_deltas(mdl: dict, base_positions, frame_idx: int): |
|
"""Compute position deltas for a morph target relative to the base frame.""" |
|
scale = mdl['scale'] |
|
translate = mdl['translate'] |
|
triangles = mdl['triangles'] |
|
frame = mdl['frames'][frame_idx] |
|
|
|
deltas = [] |
|
vert_i = 0 |
|
for _, (v0, v1, v2) in triangles: |
|
for vi in (v0, v1, v2): |
|
vert_data, _ = frame['verts'][vi] |
|
px = scale[0] * vert_data[0] + translate[0] |
|
py = scale[1] * vert_data[1] + translate[1] |
|
pz = scale[2] * vert_data[2] + translate[2] |
|
|
|
gx = -py |
|
gy = pz |
|
gz = px |
|
|
|
bx, by, bz = base_positions[vert_i] |
|
deltas.append((gx - bx, gy - by, gz - bz)) |
|
vert_i += 1 |
|
|
|
return deltas |
|
|
|
|
|
def pad_to_4(data: bytes) -> bytes: |
|
remainder = len(data) % 4 |
|
if remainder: |
|
data += b'\x00' * (4 - remainder) |
|
return data |
|
|
|
|
|
def pack_floats(float_list) -> bytes: |
|
return struct.pack(f'<{len(float_list)}f', *float_list) |
|
|
|
|
|
def flatten(tuples_list): |
|
out = [] |
|
for t in tuples_list: |
|
out.extend(t) |
|
return out |
|
|
|
|
|
def minmax_vec3(tuples_list): |
|
xs = [t[0] for t in tuples_list] |
|
ys = [t[1] for t in tuples_list] |
|
zs = [t[2] for t in tuples_list] |
|
return [min(xs), min(ys), min(zs)], [max(xs), max(ys), max(zs)] |
|
|
|
|
|
def build_glb(mdl: dict, output_path: str): |
|
num_frames = len(mdl['frames']) |
|
use_morph_targets = num_frames > 1 |
|
|
|
base_positions, base_normals, base_uvs = build_mesh_data(mdl, 0) |
|
num_vertices = len(base_positions) |
|
num_triangles = mdl['num_tris'] |
|
|
|
indices = list(range(num_vertices)) |
|
|
|
morph_deltas = [] |
|
if use_morph_targets: |
|
for fi in range(1, num_frames): |
|
deltas = compute_morph_deltas(mdl, base_positions, fi) |
|
morph_deltas.append(deltas) |
|
|
|
pos_min, pos_max = minmax_vec3(base_positions) |
|
norm_min, norm_max = minmax_vec3(base_normals) |
|
|
|
bin_blobs = [] |
|
|
|
def add_blob(data: bytes) -> tuple: |
|
offset = sum(len(b) for b in bin_blobs) |
|
padded = pad_to_4(data) |
|
bin_blobs.append(padded) |
|
return offset, len(data) |
|
|
|
idx_data = struct.pack(f'<{len(indices)}H', *indices) if num_vertices <= 65535 else struct.pack(f'<{len(indices)}I', *indices) |
|
idx_component = 5123 if num_vertices <= 65535 else 5125 |
|
idx_offset, idx_len = add_blob(idx_data) |
|
|
|
pos_data = pack_floats(flatten(base_positions)) |
|
pos_offset, pos_len = add_blob(pos_data) |
|
|
|
norm_data = pack_floats(flatten(base_normals)) |
|
norm_offset, norm_len = add_blob(norm_data) |
|
|
|
uv_data = pack_floats(flatten(base_uvs)) |
|
uv_offset, uv_len = add_blob(uv_data) |
|
|
|
morph_offsets = [] |
|
for deltas in morph_deltas: |
|
d_min, d_max = minmax_vec3(deltas) |
|
d_data = pack_floats(flatten(deltas)) |
|
d_off, d_len = add_blob(d_data) |
|
morph_offsets.append((d_off, d_len, d_min, d_max)) |
|
|
|
png_data = skin_to_png(mdl['skins'][0], mdl['skinwidth'], mdl['skinheight']) |
|
img_offset, img_len = add_blob(png_data) |
|
|
|
total_bin_size = sum(len(b) for b in bin_blobs) |
|
|
|
buffer_views = [] |
|
accessors = [] |
|
|
|
def add_buffer_view(offset, length, target=None): |
|
bv = {"buffer": 0, "byteOffset": offset, "byteLength": length} |
|
if target: |
|
bv["target"] = target |
|
idx = len(buffer_views) |
|
buffer_views.append(bv) |
|
return idx |
|
|
|
def add_accessor(bv_idx, component_type, count, acc_type, min_val=None, max_val=None): |
|
acc = { |
|
"bufferView": bv_idx, |
|
"componentType": component_type, |
|
"count": count, |
|
"type": acc_type, |
|
} |
|
if min_val is not None: |
|
acc["min"] = min_val |
|
if max_val is not None: |
|
acc["max"] = max_val |
|
idx = len(accessors) |
|
accessors.append(acc) |
|
return idx |
|
|
|
idx_bv = add_buffer_view(idx_offset, idx_len, 34963) |
|
idx_acc = add_accessor(idx_bv, idx_component, num_vertices, "SCALAR", |
|
[0], [num_vertices - 1]) |
|
|
|
pos_bv = add_buffer_view(pos_offset, pos_len, 34962) |
|
pos_acc = add_accessor(pos_bv, 5126, num_vertices, "VEC3", pos_min, pos_max) |
|
|
|
norm_bv = add_buffer_view(norm_offset, norm_len, 34962) |
|
norm_acc = add_accessor(norm_bv, 5126, num_vertices, "VEC3", norm_min, norm_max) |
|
|
|
uv_bv = add_buffer_view(uv_offset, uv_len, 34962) |
|
uv_acc = add_accessor(uv_bv, 5126, num_vertices, "VEC2") |
|
|
|
morph_acc_indices = [] |
|
for d_off, d_len, d_min, d_max in morph_offsets: |
|
m_bv = add_buffer_view(d_off, d_len, 34962) |
|
m_acc = add_accessor(m_bv, 5126, num_vertices, "VEC3", d_min, d_max) |
|
morph_acc_indices.append(m_acc) |
|
|
|
img_bv = add_buffer_view(img_offset, img_len) |
|
|
|
mesh_primitive = { |
|
"attributes": { |
|
"POSITION": pos_acc, |
|
"NORMAL": norm_acc, |
|
"TEXCOORD_0": uv_acc, |
|
}, |
|
"indices": idx_acc, |
|
"material": 0, |
|
} |
|
|
|
if use_morph_targets: |
|
targets = [] |
|
for m_acc in morph_acc_indices: |
|
targets.append({"POSITION": m_acc}) |
|
mesh_primitive["targets"] = targets |
|
|
|
mesh = {"primitives": [mesh_primitive]} |
|
if use_morph_targets: |
|
mesh["weights"] = [0.0] * len(morph_deltas) |
|
|
|
gltf = { |
|
"asset": {"version": "2.0", "generator": "mdl2gltf"}, |
|
"scene": 0, |
|
"scenes": [{"nodes": [0]}], |
|
"nodes": [{"mesh": 0}], |
|
"meshes": [mesh], |
|
"accessors": accessors, |
|
"bufferViews": buffer_views, |
|
"buffers": [{"byteLength": total_bin_size}], |
|
"images": [{"bufferView": img_bv, "mimeType": "image/png"}], |
|
"textures": [{"source": 0, "sampler": 0}], |
|
"samplers": [{"magFilter": 9728, "minFilter": 9728, "wrapS": 10497, "wrapT": 10497}], |
|
"materials": [{ |
|
"pbrMetallicRoughness": { |
|
"baseColorTexture": {"index": 0}, |
|
"metallicFactor": 0.0, |
|
"roughnessFactor": 1.0, |
|
}, |
|
"alphaMode": "MASK", |
|
"alphaCutoff": 0.5, |
|
"doubleSided": True, |
|
}], |
|
} |
|
|
|
if use_morph_targets: |
|
fps = 10.0 |
|
time_step = 1.0 / fps |
|
|
|
timestamps = [] |
|
for fi in range(num_frames): |
|
timestamps.append(fi * time_step) |
|
|
|
time_data = pack_floats(timestamps) |
|
time_off, time_len = add_blob(time_data) |
|
|
|
weight_values = [] |
|
num_targets = len(morph_deltas) |
|
for fi in range(num_frames): |
|
weights = [0.0] * num_targets |
|
if fi > 0: |
|
weights[fi - 1] = 1.0 |
|
weight_values.extend(weights) |
|
|
|
weight_data = pack_floats(weight_values) |
|
weight_off, weight_len = add_blob(weight_data) |
|
|
|
total_bin_size = sum(len(b) for b in bin_blobs) |
|
gltf["buffers"][0]["byteLength"] = total_bin_size |
|
|
|
time_bv = add_buffer_view(time_off, time_len) |
|
time_acc = add_accessor(time_bv, 5126, num_frames, "SCALAR", |
|
[timestamps[0]], [timestamps[-1]]) |
|
|
|
weight_bv = add_buffer_view(weight_off, weight_len) |
|
weight_acc = add_accessor(weight_bv, 5126, num_frames * num_targets, "SCALAR") |
|
|
|
gltf["animations"] = [{ |
|
"name": "animation", |
|
"channels": [{ |
|
"sampler": 0, |
|
"target": {"node": 0, "path": "weights"}, |
|
}], |
|
"samplers": [{ |
|
"input": time_acc, |
|
"output": weight_acc, |
|
"interpolation": "STEP", |
|
}], |
|
}] |
|
|
|
json_str = json.dumps(gltf, separators=(',', ':')) |
|
json_bytes = json_str.encode('utf-8') |
|
json_padded = json_bytes + b' ' * ((4 - len(json_bytes) % 4) % 4) |
|
|
|
bin_data = b''.join(bin_blobs) |
|
bin_padded = bin_data + b'\x00' * ((4 - len(bin_data) % 4) % 4) |
|
|
|
total_length = 12 + 8 + len(json_padded) + 8 + len(bin_padded) |
|
|
|
with open(output_path, 'wb') as f: |
|
f.write(struct.pack('<I', 0x46546C67)) # glTF magic |
|
f.write(struct.pack('<I', 2)) # version |
|
f.write(struct.pack('<I', total_length)) |
|
|
|
f.write(struct.pack('<I', len(json_padded))) |
|
f.write(struct.pack('<I', 0x4E4F534A)) # JSON chunk |
|
f.write(json_padded) |
|
|
|
f.write(struct.pack('<I', len(bin_padded))) |
|
f.write(struct.pack('<I', 0x004E4942)) # BIN chunk |
|
f.write(bin_padded) |
|
|
|
|
|
def main(): |
|
if len(sys.argv) < 2: |
|
print(f"Usage: {sys.argv[0]} <input.mdl> [output.glb]") |
|
sys.exit(1) |
|
|
|
input_path = sys.argv[1] |
|
if len(sys.argv) >= 3: |
|
output_path = sys.argv[2] |
|
else: |
|
output_path = str(Path(input_path).with_suffix('.glb')) |
|
|
|
mdl = parse_mdl(input_path) |
|
|
|
print(f"MDL: {Path(input_path).name}") |
|
print(f" Skin: {mdl['skinwidth']}x{mdl['skinheight']}, {len(mdl['skins'])} skin(s)") |
|
print(f" Mesh: {mdl['num_verts']} verts, {mdl['num_tris']} tris") |
|
print(f" Frames: {len(mdl['frames'])}") |
|
|
|
build_glb(mdl, output_path) |
|
print(f" -> {output_path}") |
|
|
|
|
|
if __name__ == '__main__': |
|
main() |