mirror of
https://github.com/AntonioND/nitro-engine.git
synced 2025-06-18 16:45:33 -04:00

This fixes a bug where the base animation of the model (its standing pose) would be exported with the wrong extension.
863 lines
31 KiB
Python
Executable File
863 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# SPDX-License-Identifier: MIT
|
|
#
|
|
# Copyright (c) 2022 Antonio Niño Díaz <antonio_nd@outlook.com>
|
|
|
|
import os
|
|
|
|
from collections import namedtuple
|
|
from math import sqrt
|
|
|
|
from display_list import DisplayList, float_to_f32
|
|
|
|
class MD5FormatError(Exception):
|
|
pass
|
|
|
|
VALID_TEXTURE_SIZES = [8, 16, 32, 64, 128, 256, 512, 1024]
|
|
|
|
def is_valid_texture_size(size):
|
|
return size in VALID_TEXTURE_SIZES
|
|
|
|
def assert_num_args(cmd, real, expected, tokens):
|
|
if real != expected:
|
|
raise MD5FormatError(f"Unexpected nargs for '{cmd}' ({real} != {expected}): {tokens}")
|
|
|
|
class Quaternion():
|
|
def __init__(self, w, x, y, z):
|
|
self.w = w
|
|
self.x = x
|
|
self.y = y
|
|
self.z = z
|
|
|
|
def to_v3(self):
|
|
return Vector(self.x, self.y, self.z)
|
|
|
|
def complement(self):
|
|
return Quaternion(self.w, -self.x, -self.y, -self.z)
|
|
|
|
def normalize(self):
|
|
mag = sqrt((self.w ** 2) + (self.x ** 2) + (self.y ** 2) + (self.z ** 2))
|
|
return Quaternion(self.w / mag, self.x /mag, self.y / mag, self.z / mag)
|
|
|
|
def mul(self, other):
|
|
w = (self.w * other.w) - (self.x * other.x) - (self.y * other.y) - (self.z * other.z)
|
|
x = (self.x * other.w) + (self.w * other.x) + (self.y * other.z) - (self.z * other.y)
|
|
y = (self.y * other.w) + (self.w * other.y) + (self.z * other.x) - (self.x * other.z)
|
|
z = (self.z * other.w) + (self.w * other.z) + (self.x * other.y) - (self.y * other.x)
|
|
return Quaternion(w, x, y, z)
|
|
|
|
def quaternion_fill_incomplete_w(v):
|
|
"""
|
|
This expands an incomplete quaternion, not a regular vector. This is
|
|
needed if a quaternion is stored as the components x, y and z and it is
|
|
expected that the code will fill the value of w.
|
|
"""
|
|
t = 1.0 - (v[0] * v[0]) - (v[1] * v[1]) - (v[2] * v[2])
|
|
if t < 0:
|
|
w = 0
|
|
else:
|
|
w = -sqrt(t)
|
|
return Quaternion(w, v[0], v[1], v[2])
|
|
|
|
class Vector():
|
|
def __init__(self, x, y, z):
|
|
self.x = x
|
|
self.y = y
|
|
self.z = z
|
|
|
|
def to_q(self):
|
|
return Quaternion(0, self.x, self.y, self.z)
|
|
|
|
def length(self):
|
|
return sqrt((self.x ** 2) + (self.y ** 2) + (self.z ** 2))
|
|
|
|
def normalize(self):
|
|
mag = self.length()
|
|
return Vector(self.x / mag, self.y / mag, self.z / mag)
|
|
|
|
def add(self, other):
|
|
return Vector(self.x + other.x, self.y + other.y, self.z + other.z)
|
|
|
|
def sub(self, other):
|
|
return Vector(self.x - other.x, self.y - other.y, self.z - other.z)
|
|
|
|
def cross(self, other):
|
|
x = (self.y * other.z) - (other.y * self.z)
|
|
y = (self.z * other.x) - (other.z * self.x)
|
|
z = (self.x * other.y) - (other.x * self.y)
|
|
return Vector(x, y, z)
|
|
|
|
def mul_m4x3(self, m):
|
|
x = (self.x * m[0][0]) + (self.y * m[0][1]) + (self.z * m[0][2]) + (m[0][3] * 1)
|
|
y = (self.x * m[1][0]) + (self.y * m[1][1]) + (self.z * m[1][2]) + (m[1][3] * 1)
|
|
z = (self.x * m[2][0]) + (self.y * m[2][1]) + (self.z * m[2][2]) + (m[2][3] * 1)
|
|
return Vector(x, y, z)
|
|
|
|
def joint_info_to_m4x3(q, trans):
|
|
"""
|
|
This generates a 4x3 matrix that represents a rotation and a translation.
|
|
q is a Quaternion with a orientation, trans is a Vector with a translation.
|
|
"""
|
|
wx = 2 * q.w * q.x
|
|
wy = 2 * q.w * q.y
|
|
wz = 2 * q.w * q.z
|
|
x2 = 2 * q.x * q.x
|
|
xy = 2 * q.x * q.y
|
|
xz = 2 * q.x * q.z
|
|
y2 = 2 * q.y * q.y
|
|
yz = 2 * q.y * q.z
|
|
z2 = 2 * q.z * q.z
|
|
|
|
return [[1 - y2 - z2, xy - wz, xz + wy, trans.x],
|
|
[ xy + wz, 1 - x2 - z2, yz - wx, trans.y],
|
|
[ xz - wy, yz + wx, 1 - x2 - y2, trans.z]]
|
|
|
|
def parse_md5mesh(input_file):
|
|
Joint = namedtuple("Joint", "name parent pos orient")
|
|
Vert = namedtuple("Vert", "st startWeight countWeight")
|
|
Weight = namedtuple("Weight", "joint bias pos")
|
|
Mesh = namedtuple("Mesh", "numverts verts numtris tris numweights weights")
|
|
|
|
joints = []
|
|
meshes = []
|
|
|
|
with open(input_file, 'r') as md5mesh_file:
|
|
|
|
numJoints = None
|
|
numMeshes = None
|
|
|
|
# This can have three values:
|
|
# - "root": Parsing commands in the md5mesh outside of any group
|
|
# - "joints": Inside a "joints" node.
|
|
# - "mesh": Inside a "mesh" node.
|
|
mode = "root"
|
|
|
|
# Temporary variables used to store mesh information before packing it
|
|
numverts = None
|
|
verts = None
|
|
numtris = None
|
|
tris = None
|
|
numweights = None
|
|
weights = None
|
|
|
|
for line in md5mesh_file:
|
|
# Remove comments
|
|
line = line.split('//')[0]
|
|
|
|
# Parse line
|
|
tokens = line.split()
|
|
|
|
if len(tokens) == 0: # Empty line
|
|
continue
|
|
|
|
cmd = tokens[0]
|
|
tokens = tokens[1:]
|
|
nargs = len(tokens)
|
|
|
|
if mode == "root":
|
|
if cmd == 'MD5Version':
|
|
assert_num_args('MD5Version', nargs, 1, tokens)
|
|
version = int(tokens[0])
|
|
if version != 10:
|
|
raise MD5FormatError(f"Invalid 'MD5Version': {version} != 10")
|
|
|
|
elif cmd == 'commandline':
|
|
# Ignore this
|
|
pass
|
|
|
|
elif cmd == 'numJoints':
|
|
assert_num_args('numJoints', nargs, 1, tokens)
|
|
numJoints = int(tokens[0])
|
|
if numJoints == 0:
|
|
raise MD5FormatError(f"'numJoints' is 0")
|
|
|
|
elif cmd == 'numMeshes':
|
|
assert_num_args('numMeshes', nargs, 1, tokens)
|
|
numMeshes = int(tokens[0])
|
|
if numMeshes == 0:
|
|
raise MD5FormatError(f"'numMeshes' is 0")
|
|
|
|
elif cmd == 'joints':
|
|
assert_num_args('joints', nargs, 1, tokens)
|
|
if tokens[0] != '{':
|
|
raise MD5FormatError(f"Unexpected token for 'joints': {tokens}")
|
|
if numJoints is None:
|
|
raise MD5FormatError("'joints' command before 'numJoints'")
|
|
mode = "joints"
|
|
|
|
elif cmd == 'mesh':
|
|
assert_num_args('mesh', nargs, 1, tokens)
|
|
if tokens[0] != '{':
|
|
raise MD5FormatError(f"Unexpected token for 'mesh': {tokens}")
|
|
if numMeshes is None:
|
|
raise MD5FormatError("'mesh' command before 'numMeshes'")
|
|
mode = "mesh"
|
|
|
|
else:
|
|
print(f"Ignored unsupported command: {cmd} {tokens}")
|
|
|
|
elif mode == "joints":
|
|
if cmd == '}':
|
|
if nargs > 0:
|
|
raise MD5FormatError(f"Unexpected tokens after 'joints {{}}': {tokens}")
|
|
mode = "root"
|
|
else:
|
|
_, name, line = line.split('"')
|
|
tokens = line.strip().split(" ")
|
|
nargs = len(tokens)
|
|
|
|
assert_num_args('joint entry', nargs, 11, tokens)
|
|
|
|
parent = int(tokens[0])
|
|
|
|
if tokens[1] != '(':
|
|
raise MD5FormatError(f"Unexpected token 1 for joint': {tokens}")
|
|
pos = Vector(float(tokens[2]), float(tokens[3]), float(tokens[4]))
|
|
if tokens[5] != ')':
|
|
raise MD5FormatError(f"Unexpected token 5 for joint': {tokens}")
|
|
|
|
if tokens[6] != '(':
|
|
raise MD5FormatError(f"Unexpected token 6 for joint': {tokens}")
|
|
orient = (float(tokens[7]), float(tokens[8]), float(tokens[9]))
|
|
q_orient = quaternion_fill_incomplete_w(orient)
|
|
if tokens[10] != ')':
|
|
raise MD5FormatError(f"Unexpected token 10 for joint': {tokens}")
|
|
|
|
joints.append(Joint(name, parent, pos, q_orient))
|
|
|
|
elif mode == "mesh":
|
|
if cmd == '}':
|
|
if nargs != 0:
|
|
raise MD5FormatError(f"Unexpected tokens after 'mesh {{}}': {tokens}")
|
|
mode = "root"
|
|
|
|
meshes.append(Mesh(numverts, verts, numtris, tris, numweights, weights))
|
|
|
|
numverts = None
|
|
verts = None
|
|
numtris = None
|
|
tris = None
|
|
numweights = None
|
|
weights = None
|
|
|
|
elif cmd == 'shader':
|
|
# Ignore this
|
|
pass
|
|
|
|
elif cmd == 'numverts':
|
|
assert_num_args('numverts', nargs, 1, tokens)
|
|
numverts = int(tokens[0])
|
|
verts = [None] * numverts
|
|
|
|
elif cmd == 'vert':
|
|
assert_num_args('vert', nargs, 7, tokens)
|
|
if numverts is None:
|
|
raise MD5FormatError("'vert' command before 'numverts'")
|
|
|
|
index = int(tokens[0])
|
|
|
|
if tokens[1] != '(':
|
|
raise MD5FormatError(f"Unexpected token 1 for vert': {tokens}")
|
|
st = (float(tokens[2]), float(tokens[3]))
|
|
if tokens[4] != ')':
|
|
raise MD5FormatError(f"Unexpected token 4 for vert': {tokens}")
|
|
|
|
startWeight = int(tokens[5])
|
|
countWeight = int(tokens[6])
|
|
|
|
if countWeight != 1:
|
|
raise MD5FormatError(
|
|
f"Vertex with {countWeight} weights detected, but this tool "
|
|
"only supports vertices with one weight. Ensure that all your "
|
|
"vertices are assigned exactly one weight with a bias of 1.0."
|
|
)
|
|
|
|
verts[index] = Vert(st, startWeight, countWeight)
|
|
|
|
elif cmd == 'numtris':
|
|
assert_num_args('numtris', nargs, 1, tokens)
|
|
numtris = int(tokens[0])
|
|
tris = [None] * numtris
|
|
|
|
elif cmd == 'tri':
|
|
assert_num_args('tri', nargs, 4, tokens)
|
|
if numtris is None:
|
|
raise MD5FormatError("'tri' command before 'numtris'")
|
|
|
|
index = int(tokens[0])
|
|
# Reverse order so that they face the right direction
|
|
vertIndices = (int(tokens[3]), int(tokens[2]), int(tokens[1]))
|
|
|
|
tris[index] = vertIndices
|
|
|
|
elif cmd == 'numweights':
|
|
assert_num_args('numweights', nargs, 1, tokens)
|
|
numweights = int(tokens[0])
|
|
weights = [None] * numweights
|
|
|
|
elif cmd == 'weight':
|
|
assert_num_args('weight', nargs, 8, tokens)
|
|
if numverts is None:
|
|
raise MD5FormatError("'weight' command before 'numweights'")
|
|
|
|
index = int(tokens[0])
|
|
jointIndex = int(tokens[1])
|
|
bias = float(tokens[2])
|
|
|
|
if bias != 1.0:
|
|
raise MD5FormatError(
|
|
f"Weight with bias {bias} detected, but this tool only"
|
|
"supports weights with bias equal to 1.0. Ensure that all"
|
|
"your vertices are assigned exactly one weight with a"
|
|
"bias of 1.0."
|
|
)
|
|
|
|
if tokens[3] != '(':
|
|
raise MD5FormatError(f"Unexpected token 3 for weight': {tokens}")
|
|
pos = Vector(float(tokens[4]), float(tokens[5]), float(tokens[6]))
|
|
if tokens[7] != ')':
|
|
raise MD5FormatError(f"Unexpected token 7 for weight': {tokens}")
|
|
|
|
weights[index] = Weight(jointIndex, bias, pos)
|
|
|
|
else:
|
|
print(f"Ignored unsupported command: {cmd} {tokens}")
|
|
|
|
if mode != "root":
|
|
raise MD5FormatError("Unexpected end of file (expected '}')")
|
|
|
|
realJoints = len(joints)
|
|
if numJoints != realJoints:
|
|
raise MD5FormatError(f"Incorrect number of joints: {numJoints} != {realJoints}")
|
|
|
|
realMeshes = len(meshes)
|
|
if numJoints != realJoints:
|
|
raise MD5FormatError(f"Incorrect number of joints: {numJoints} != {realJoints}")
|
|
|
|
return (joints, meshes)
|
|
|
|
def parse_md5anim(input_file):
|
|
Joint = namedtuple("Joint", "name parent pos orient")
|
|
|
|
joints = []
|
|
frames = []
|
|
|
|
with open(input_file, 'r') as md5anim_file:
|
|
|
|
numFrames = None
|
|
numJoints = None
|
|
|
|
baseframe = []
|
|
hierarchy = []
|
|
|
|
# This can have three values:
|
|
# - "root": Parsing commands in the md5mesh outside of any group
|
|
# - "hierarchy": Inside a "hierarchy" node.
|
|
# - "bounds": Inside a "bounds" node.
|
|
# - "baseframe": Inside a "baseframe" node.
|
|
# - "frame": Inside a "frame" node.
|
|
mode = "root"
|
|
|
|
frame_index = None
|
|
|
|
for line in md5anim_file:
|
|
# Remove comments
|
|
line = line.split('//')[0]
|
|
|
|
# Parse line
|
|
tokens = line.split()
|
|
|
|
if len(tokens) == 0: # Empty line
|
|
continue
|
|
|
|
cmd = tokens[0]
|
|
tokens = tokens[1:]
|
|
nargs = len(tokens)
|
|
|
|
if mode == "root":
|
|
if cmd == 'MD5Version':
|
|
assert_num_args('MD5Version', nargs, 1, tokens)
|
|
version = int(tokens[0])
|
|
if version != 10:
|
|
raise MD5FormatError(f"Invalid 'MD5Version': {version} != 10")
|
|
|
|
elif cmd == 'commandline':
|
|
# Ignore this
|
|
pass
|
|
|
|
elif cmd == 'numFrames':
|
|
assert_num_args('numFrames', nargs, 1, tokens)
|
|
numFrames = int(tokens[0])
|
|
if numFrames == 0:
|
|
raise MD5FormatError(f"'numFrames' is 0")
|
|
frames = [None] * numFrames
|
|
|
|
elif cmd == 'numJoints':
|
|
assert_num_args('numJoints', nargs, 1, tokens)
|
|
numJoints = int(tokens[0])
|
|
if numJoints == 0:
|
|
raise MD5FormatError(f"'numJoints' is 0")
|
|
|
|
elif cmd == 'frameRate':
|
|
# Ignore this
|
|
pass
|
|
|
|
elif cmd == 'numAnimatedComponents':
|
|
# Ignore this
|
|
pass
|
|
|
|
elif cmd == 'hierarchy':
|
|
assert_num_args('hierarchy', nargs, 1, tokens)
|
|
if tokens[0] != '{':
|
|
raise MD5FormatError(f"Unexpected token for 'hierarchy': {tokens}")
|
|
mode = "hierarchy"
|
|
|
|
elif cmd == 'bounds':
|
|
assert_num_args('bounds', nargs, 1, tokens)
|
|
if tokens[0] != '{':
|
|
raise MD5FormatError(f"Unexpected token for 'bounds': {tokens}")
|
|
mode = "bounds"
|
|
|
|
elif cmd == 'baseframe':
|
|
assert_num_args('baseframe', nargs, 1, tokens)
|
|
if tokens[0] != '{':
|
|
raise MD5FormatError(f"Unexpected token for 'baseframe': {tokens}")
|
|
mode = "baseframe"
|
|
|
|
elif cmd == 'frame':
|
|
assert_num_args('frame', nargs, 2, tokens)
|
|
frame_index = int(tokens[0])
|
|
|
|
if tokens[1] != '{':
|
|
raise MD5FormatError(f"Unexpected token for 'frame': {tokens}")
|
|
if numFrames is None:
|
|
raise MD5FormatError("'frame' command before 'numFrames'")
|
|
mode = "frame"
|
|
joints = []
|
|
|
|
else:
|
|
print(f"Ignored unsupported command: {cmd} {tokens}")
|
|
|
|
elif mode == "hierarchy":
|
|
if cmd == '}':
|
|
if nargs > 0:
|
|
raise MD5FormatError(f"Unexpected tokens after 'hierarchy {{}}': {tokens}")
|
|
mode = "root"
|
|
else:
|
|
_, name, line = line.split('"')
|
|
tokens = line.strip().split(" ")
|
|
nargs = len(tokens)
|
|
|
|
assert_num_args('hierarchy entry', nargs, 3, tokens)
|
|
|
|
parent_index = int(tokens[0])
|
|
flags = int(tokens[1])
|
|
if flags != 63:
|
|
raise MD5FormatError(f"Unexpected flags in hierarchy: {flags}")
|
|
frame_data_index = int(tokens[2])
|
|
|
|
hierarchy.append(parent_index)
|
|
|
|
elif mode == "bounds":
|
|
if cmd == '}':
|
|
if nargs > 0:
|
|
raise MD5FormatError(f"Unexpected tokens after 'bounds {{}}': {tokens}")
|
|
mode = "root"
|
|
else:
|
|
# Ignore everything else
|
|
pass
|
|
|
|
elif mode == "baseframe":
|
|
if cmd == '}':
|
|
if nargs > 0:
|
|
raise MD5FormatError(f"Unexpected tokens after 'baseframe {{}}': {tokens}")
|
|
mode = "root"
|
|
else:
|
|
values = line.strip().split()
|
|
assert_num_args('baseframe joint', len(values), 10, values)
|
|
|
|
if values[0] != '(':
|
|
raise MD5FormatError(f"Unexpected token 0 for baseframe': {values}")
|
|
pos = Vector(float(values[1]), float(values[2]), float(values[3]))
|
|
if values[4] != ')':
|
|
raise MD5FormatError(f"Unexpected token 4 for baseframe': {values}")
|
|
|
|
if values[5] != '(':
|
|
raise MD5FormatError(f"Unexpected token 5 for baseframe': {values}")
|
|
orient = (float(values[6]), float(values[7]), float(values[8]))
|
|
q_orient = quaternion_fill_incomplete_w(orient)
|
|
if values[9] != ')':
|
|
raise MD5FormatError(f"Unexpected token 9 for baseframe': {values}")
|
|
|
|
baseframe.append(Joint("", -1, pos, q_orient))
|
|
|
|
elif mode == "frame":
|
|
if cmd == '}':
|
|
if nargs > 0:
|
|
raise MD5FormatError(f"Unexpected tokens after 'frame {{}}': {tokens}")
|
|
mode = "root"
|
|
|
|
# Now that the frame has been read, process the real
|
|
# positions and orientations of the bones before storing
|
|
# them.
|
|
|
|
transformed_joints = []
|
|
|
|
for joint, parent_index in zip(joints, hierarchy):
|
|
if parent_index == -1:
|
|
# Root bone
|
|
transformed_joints.append(joint)
|
|
else:
|
|
parent_pos = transformed_joints[parent_index].pos
|
|
parent_orient = transformed_joints[parent_index].orient
|
|
|
|
this_pos = joint.pos
|
|
this_orient = joint.orient
|
|
|
|
q = parent_orient
|
|
qt = q.complement()
|
|
q_pos_delta = q.mul(this_pos.to_q()).mul(qt)
|
|
pos_delta = q_pos_delta.to_v3()
|
|
|
|
pos = parent_pos.add(pos_delta)
|
|
orient = parent_orient.mul(this_orient).normalize()
|
|
|
|
transformed_joints.append(Joint("", -1, pos, orient))
|
|
|
|
frames[frame_index] = transformed_joints
|
|
else:
|
|
values = line.strip().split()
|
|
assert_num_args('frame joint', len(values), 6, values)
|
|
|
|
pos = Vector(float(values[0]), float(values[1]), float(values[2]))
|
|
|
|
orient = (float(values[3]), float(values[4]), float(values[5]))
|
|
q_orient = quaternion_fill_incomplete_w(orient)
|
|
|
|
joints.append(Joint("", -1, pos, q_orient))
|
|
|
|
if mode != "root":
|
|
raise MD5FormatError("Unexpected end of file (expected '}')")
|
|
|
|
realJoints = len(joints)
|
|
if numJoints != realJoints:
|
|
raise MD5FormatError(f"Incorrect number of joints: {numJoints} != {realJoints}")
|
|
|
|
realFrames = len(frames)
|
|
if numFrames != realFrames:
|
|
raise MD5FormatError(f"Incorrect number of frames: {numFrames} != {realFrames}")
|
|
|
|
return frames
|
|
|
|
def save_animation(frames, output_file, blender_fix):
|
|
|
|
version = 1
|
|
num_frames = len(frames)
|
|
num_bones = len(frames[0])
|
|
|
|
u32_array = [version, num_frames, num_bones]
|
|
|
|
for joints in frames:
|
|
if num_bones != len(joints):
|
|
raise MD5FormatError("Different number of bones across frames")
|
|
|
|
for joint in joints:
|
|
this_pos = joint.pos
|
|
this_orient = joint.orient
|
|
|
|
if blender_fix:
|
|
# It is needed to rotate all bones because all bones have
|
|
# absolute transformations. Rotate orientation and position by
|
|
# -90 degrees on the X axis.
|
|
q_rot = Quaternion(0.7071068, -0.7071068, 0, 0)
|
|
this_orient = q_rot.mul(this_orient)
|
|
this_pos = Vector(this_pos.x, this_pos.z, -this_pos.y)
|
|
|
|
pos = [float_to_f32(this_pos.x), float_to_f32(this_pos.y),
|
|
float_to_f32(this_pos.z)]
|
|
orient = [float_to_f32(this_orient.w), float_to_f32(this_orient.x),
|
|
float_to_f32(this_orient.y), float_to_f32(this_orient.z)]
|
|
|
|
u32_array.extend(pos)
|
|
u32_array.extend(orient)
|
|
|
|
with open(output_file, "wb") as f:
|
|
for u32 in u32_array:
|
|
b = [u32 & 0xFF, \
|
|
(u32 >> 8) & 0xFF, \
|
|
(u32 >> 16) & 0xFF, \
|
|
(u32 >> 24) & 0xFF]
|
|
f.write(bytearray(b))
|
|
|
|
def convert_md5mesh(model_file, name, output_folder, texture_size,
|
|
draw_normal_polygons, extension_mesh, extension_anim,
|
|
blender_fix, export_base_pose):
|
|
|
|
print(f"Converting model: {model_file}")
|
|
|
|
# Parse md5mesh file
|
|
joints, meshes = parse_md5mesh(model_file)
|
|
|
|
print(f"Loaded {len(joints)} joint(s) and {len(meshes)} mesh(es).")
|
|
|
|
if len(meshes) > 1:
|
|
print("WARNING: More than one mesh found. All meshes will share the same "
|
|
"texture. If you want them to have different textures, you must use "
|
|
"multiple .md5mesh files.")
|
|
|
|
if export_base_pose:
|
|
print("Converting base pose...")
|
|
|
|
save_animation([joints],
|
|
os.path.join(output_folder, f"{name}{extension_anim}"),
|
|
blender_fix)
|
|
|
|
print("Converting meshes...")
|
|
|
|
# Display list shared between all meshes
|
|
dl = DisplayList()
|
|
dl.switch_vtxs("triangles")
|
|
|
|
base_matrix = 30 - len(joints) + 1
|
|
last_joint_index = None
|
|
|
|
for mesh in meshes:
|
|
print(f" Vertices: {mesh.numverts}")
|
|
print(f" Tris: {mesh.numtris}")
|
|
print(f" Weights: {mesh.numweights}")
|
|
|
|
print(" Generating per-triangle normals...")
|
|
|
|
tri_normal = []
|
|
for tri in mesh.tris:
|
|
verts = [mesh.verts[i] for i in tri]
|
|
weights = [mesh.weights[v.startWeight] for v in verts]
|
|
|
|
vtx = []
|
|
for vert, weight in zip(verts, weights):
|
|
joint = joints[weight.joint]
|
|
m = joint_info_to_m4x3(joint.orient, joint.pos)
|
|
final = weight.pos.mul_m4x3(m)
|
|
vtx.append(final)
|
|
|
|
a = vtx[0].sub(vtx[1])
|
|
b = vtx[1].sub(vtx[2])
|
|
|
|
n = a.cross(b)
|
|
|
|
if n.length() > 0:
|
|
n = n.normalize()
|
|
tri_normal.append(n)
|
|
else:
|
|
tri_normal.append(Vector(0, 0, 0))
|
|
|
|
print(" Generating display list...")
|
|
|
|
for tri, norm in zip(mesh.tris, tri_normal):
|
|
verts = [mesh.verts[i] for i in tri]
|
|
weights = [mesh.weights[v.startWeight] for v in verts]
|
|
|
|
finals = []
|
|
|
|
for vert, weight in zip(verts, weights):
|
|
|
|
# Texture
|
|
# -------
|
|
|
|
st = vert.st
|
|
# In the MD5 format (0, 0) is the top-left corner, same as what
|
|
# the GPU of the DS expects.
|
|
u = st[0] * texture_size[0]
|
|
v = st[1] * texture_size[1]
|
|
dl.texcoord(u, v)
|
|
|
|
# Vertex and normal
|
|
# -----------------
|
|
|
|
# Load joint matrix. When drawing normal polygons it has to be
|
|
# loaded every time, because drawing the normal restores the
|
|
# original matrix.
|
|
|
|
joint_index = weight.joint
|
|
if draw_normal_polygons or joint_index != last_joint_index:
|
|
dl.mtx_restore(base_matrix + joint_index)
|
|
last_joint_index = joint_index
|
|
|
|
# Calculate normal in joint space
|
|
|
|
joint = joints[joint_index]
|
|
|
|
q = joint.orient
|
|
qt = q.complement()
|
|
n = norm.to_q()
|
|
|
|
# Transform by the inverted quaternion
|
|
n = qt.mul(n).mul(q).to_v3()
|
|
if n.length() > 0:
|
|
n = n.normalize()
|
|
dl.normal(n.x, n.y, n.z)
|
|
|
|
# The vertex is already in joint space
|
|
|
|
dl.vtx(weight.pos.x, weight.pos.y, weight.pos.z)
|
|
|
|
if draw_normal_polygons:
|
|
# Calculate actual location of the vertex so that the
|
|
# vertices of the triangle can be averaged as origin of the
|
|
# normal polygon.
|
|
q = joint.orient
|
|
qt = q.complement()
|
|
v = weight.pos.to_q()
|
|
|
|
delta = q.mul(v).mul(qt).to_v3()
|
|
|
|
final = joint.pos.add(delta)
|
|
finals.append(final)
|
|
|
|
if draw_normal_polygons:
|
|
|
|
# Don't use any of the joint transformation matrices
|
|
dl.mtx_restore(1)
|
|
|
|
vert_avg = Vector(
|
|
(finals[0].x + finals[1].x + finals[2].x) / 3,
|
|
(finals[0].y + finals[1].y + finals[2].y) / 3,
|
|
(finals[0].z + finals[1].z + finals[2].z) / 3
|
|
)
|
|
|
|
vert_avg_end = vert_avg.add(norm)
|
|
|
|
dl.texcoord(0, 0)
|
|
|
|
dl.color(1, 0, 0)
|
|
dl.vtx(vert_avg.x + 0.1, vert_avg.y, vert_avg.z)
|
|
dl.vtx(vert_avg.x, vert_avg.y, vert_avg.z)
|
|
dl.color(0, 1, 0)
|
|
dl.vtx(vert_avg_end.x, vert_avg_end.y, vert_avg_end.z)
|
|
|
|
dl.color(1, 0, 0)
|
|
dl.vtx(vert_avg.x, vert_avg.y, vert_avg.z)
|
|
dl.vtx(vert_avg.x, vert_avg.y + 0.1, vert_avg.z)
|
|
dl.color(0, 1, 0)
|
|
dl.vtx(vert_avg_end.x, vert_avg_end.y, vert_avg_end.z)
|
|
|
|
dl.color(1, 0, 0)
|
|
dl.vtx(vert_avg.x, vert_avg.y, vert_avg.z)
|
|
dl.vtx(vert_avg.x, vert_avg.y, vert_avg.z + 0.1)
|
|
dl.color(0, 1, 0)
|
|
dl.vtx(vert_avg_end.x, vert_avg_end.y, vert_avg_end.z)
|
|
|
|
dl.end_vtxs()
|
|
dl.finalize()
|
|
|
|
dl.save_to_file(os.path.join(output_folder, f"{name}{extension_mesh}"))
|
|
|
|
|
|
def convert_md5anim(name, output_folder, anim_file, skip_frames, extension_anim,
|
|
blender_fix):
|
|
|
|
print(f"Converting animation: {anim_file}")
|
|
|
|
frames = parse_md5anim(anim_file)
|
|
|
|
# Create name of animation based on file name
|
|
file_basename = os.path.basename(anim_file).replace(".md5anim", "")
|
|
anim_name = file_basename.replace(".", "_").lower()
|
|
|
|
frames = frames[::skip_frames+1]
|
|
save_animation(frames, os.path.join(output_folder,
|
|
f"{name}_{anim_name}{extension_anim}"), blender_fix)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
import argparse
|
|
import sys
|
|
import traceback
|
|
|
|
print("md5_to_dsma v0.1.1")
|
|
print("Copyright (c) 2022-2024 Antonio Niño Díaz <antonio_nd@outlook.com>")
|
|
print("All rights reserved")
|
|
print("")
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description='Converts md5mesh and md5anim files into DSM and DSA files.')
|
|
|
|
# Required arguments
|
|
parser.add_argument("--name", required=True,
|
|
help="model name to be used in output files")
|
|
parser.add_argument("--output", required=True,
|
|
help="output folder")
|
|
|
|
# Optional arguments
|
|
parser.add_argument("--model", required=False, type=str, default=None,
|
|
help="input md5mesh file")
|
|
parser.add_argument("--texture", required=False, type=int, default=[],
|
|
nargs="+", action="extend",
|
|
help="texture width and height (e.g. '--texture 32 64')")
|
|
parser.add_argument("--anims", required=False, type=str, default=[],
|
|
nargs="+", action="extend",
|
|
help="list of md5anim files to convert")
|
|
parser.add_argument("--bin", required=False,
|
|
action='store_true',
|
|
help="add '.bin' to the name of the output files")
|
|
parser.add_argument("--blender-fix", required=False,
|
|
action='store_true',
|
|
help="rotate model -90 degrees on X axis to match Blender's orientation")
|
|
parser.add_argument("--export-base-pose", required=False,
|
|
action='store_true',
|
|
help="export base pose of a md5mesh as a DSA file")
|
|
parser.add_argument("--skip-frames", required=False,
|
|
default=0, type=int,
|
|
help="number of frames to skip in an animation (0 = export all, 1 = export half, 2 = export 33%, etc)")
|
|
parser.add_argument("--draw-normal-polygons", required=False,
|
|
action='store_true',
|
|
help="draw polygons with the shape of normals for debugging")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if args.model is not None:
|
|
if len(args.texture) != 2:
|
|
print("Please, provide exactly 2 values to the --texture argument")
|
|
sys.exit(1)
|
|
|
|
if not is_valid_texture_size(args.texture[0]):
|
|
print(f"Invalid texture width. Valid values: {VALID_TEXTURE_SIZES}")
|
|
sys.exit(1)
|
|
|
|
if not is_valid_texture_size(args.texture[1]):
|
|
print(f"Invalid texture height. Valid values: {VALID_TEXTURE_SIZES}")
|
|
sys.exit(1)
|
|
|
|
# Create output directory if it doesn't exist
|
|
os.makedirs(args.output, exist_ok=True)
|
|
|
|
# Add '.bin' to the name of the files if requested
|
|
extension_mesh = "_dsm.bin" if args.bin else ".dsm"
|
|
extension_anim = "_dsa.bin" if args.bin else ".dsa"
|
|
|
|
try:
|
|
if args.model is not None:
|
|
convert_md5mesh(args.model, args.name, args.output, args.texture,
|
|
args.draw_normal_polygons, extension_mesh,
|
|
extension_anim, args.blender_fix,
|
|
args.export_base_pose)
|
|
|
|
for anim_file in args.anims:
|
|
convert_md5anim(args.name, args.output, anim_file, args.skip_frames,
|
|
extension_anim, args.blender_fix)
|
|
|
|
except BaseException as e:
|
|
print("ERROR: " + str(e))
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
except MD5FormatError as e:
|
|
print("ERROR: Invalid MD5 file: " + str(e))
|
|
traceback.print_exc()
|
|
sys.exit(1)
|
|
|
|
print("Done!")
|
|
|
|
sys.exit(0)
|