diff --git a/plugins/blender/README.md b/plugins/blender/README.md index 7d9d2955..e719c366 100644 --- a/plugins/blender/README.md +++ b/plugins/blender/README.md @@ -9,6 +9,7 @@ later): | No One Lives Forever 2, Contract J.A.C.K., Tron 2.0 | `.ltb`, model file versions 18 to 25 | DTX version -5 | | No One Lives Forever (PS2) | `.ltb`, file type 2, version 16 | DTX version -5, 32P (palette alpha as the PS2 draws it) | | Die Hard: Nakatomi Plaza | wrapped `.ltb`, ABC or D3D body, import and conversion | DTX version -5 | +| Shogo, Blood 2, KISS Psycho Circus (LithTech 1) | `.abc`, version 6, vertex animation as shape keys (`lithtech/abc6.py`, `export_abc6.py`; `tests/blender_abc6.py`, `tests/blender_abc6_edit.py`) | (DTX -2 and -3: `lithtech/dtx.py` as for the others) | meant to grow to every LithTech-era game. @@ -25,11 +26,13 @@ meant to grow to every LithTech-era game. `roundtrip_dtx.py` read and write back every shipped model and texture (`extract_abc.py` and `extract_ltb.py` fill the asset cache first; the PS2 files come from the discs' `LITH_PS2.REZ`, out/assets/ps2), and - `roundtrip_ps2_dtx.py` every PS2 texture. `blender_roundtrip.py`, + `roundtrip_ps2_dtx.py` every PS2 texture. `roundtrip_abc6.py` does the + same for the LithTech 1 `.abc` (v6) under the folders it's given. `blender_roundtrip.py`, `blender_roundtrip_ltb.py`, `blender_roundtrip_ps2ltb.py` and `blender_roundtrip_ps2_dtx.py` (run inside Blender) import and export them through the extension, and `blender_rebuild_ps2ltb.py` checks the PS2 - exporter's rebuilding of edited meshes. `build_ps2ltb_names.py` makes the + exporter's rebuilding of edited meshes. `blender_abc6.py` and + `blender_abc6_edit.py` do the same for LithTech 1's `.abc` v6. `build_ps2ltb_names.py` makes the PS2 models' name list. Game data is never committed; tests read it from the installed games diff --git a/plugins/blender/docs/BLENDER.md b/plugins/blender/docs/BLENDER.md index c931240d..70737bec 100644 --- a/plugins/blender/docs/BLENDER.md +++ b/plugins/blender/docs/BLENDER.md @@ -1,9 +1,9 @@ # The Blender extension `io_scene_lithtech` imports and exports LithTech models (`.abc` versions 9 -to 12: NOLF, AvP2; `.ltb` model file versions 18 to 25: NOLF2, Contract -J.A.C.K., Tron 2.0) and textures (`.dtx`, version -5) in Blender 5.2 LTS -and later. +to 12: NOLF, AvP2; `.abc` version 6: Shogo, Blood 2, KISS Psycho Circus; +`.ltb` model file versions 18 to 25: NOLF2, Contract J.A.C.K., Tron 2.0) +and textures (`.dtx`, version -5) in Blender 5.2 LTS and later. Die Hard: Nakatomi Plaza's wrapped `.ltb` files can also be imported through the LTB importer (wrapper version 3, model versions 9–13, ABC or D3D body). @@ -224,9 +224,44 @@ the same triangles and weights). Nothing exported here has been run on a PS2. Skins are found as for `.abc` (SKINS beside MODELS, SKINS_PV beside MODELS_PV, in any letter case); PS2 skins are 8-bit palette DTX files. +## LithTech 1 models (Shogo, Blood 2, KISS Psycho Circus) + +**File > Import / Export > LithTech Model (.abc)** reads and writes `.abc` +version 6 too; *Version* "As Imported" writes a v6 model back as v6, and +"12" converts it. `lithtech/abc6.py` has the format. The layout in Blender +is the `.abc` one. What v6 adds: + +| v6 | Blender | +|---|---| +| one mesh, each vertex on one node | one mesh object, each vertex in one vertex group | +| no bind pose: the first key of the first animation | the rest pose | +| vertex animation: a deformation node's vertices keyed per keyframe as three bytes each | shape keys `\|` (as for `.ltb`), at the bind pose, keyed in the action's KEY slot so they play with the bones | +| quaternions as stored, some not unit length | the action's `lt_v6_keys` | +| bounds, the reduced-detail vertices, the vertex bytes, the raw vertex and face values | `lt_v6_*` properties and attributes | +| chunks v6 doesn't define (KISS's `TransformInfo`, in every one of its models) | `lt_v6_extra_chunks_json`, written back as stored | + +**Export.** Each value the import kept is written back while what Blender +holds still matches it, so an unchanged model exports byte for byte: +`tests/blender_abc6.py` checks every v6 model of Shogo's and Blood 2's +demos and the LithTech 2.42 SDK's samples, 414. Outside Blender, +`tests/roundtrip_abc6.py` reads and writes back the shipped games' v6 +models: all 1,645 of Blood 2's (with The Nightmare Levels) and KISS's come +back byte-identical. An edit changes what it +touches: `tests/blender_abc6_edit.py` moves an animated vertex in one frame +(three bytes change), a bone at one key (that key changes, written unit +length), a vertex, a face, and makes vertex animation on a model that had +none, and checks each file against what Blender shows. v6 holds one node per +vertex (the heaviest of several is taken), triangles only, and no skins; a +node with a vertex that moves in a frame lists every vertex on it, as every +such node in the shipped files does. The first key of the first animation +is LithTech 1's bind, so keep it the rest pose (export warns when it is +not). The shape keys are found by their action's name, so rename an action +and its keys together. Blender interpolates normalized quaternions where LithTech 1 +interpolates the stored ones, so a few Blood 2 animations with very long +keys play differently between keys in Blender; the file is unaffected. + ## Not yet -- Shogo/Blood 2-era `.abc` (its own module beside `lithtech/abc.py`). - Matrix-palette skeletal meshes are read and written but no shipped model uses them, so they're untested. - World files (`.dat`; the PS2's world `.ltb` files). diff --git a/plugins/blender/io_scene_lithtech/__init__.py b/plugins/blender/io_scene_lithtech/__init__.py index 4ce63e1c..7082e8df 100644 --- a/plugins/blender/io_scene_lithtech/__init__.py +++ b/plugins/blender/io_scene_lithtech/__init__.py @@ -13,7 +13,7 @@ # --------------------------------------------------------------------- # class IMPORT_SCENE_OT_lithtech_abc(bpy.types.Operator, ImportHelper): - """Import LithTech models (.abc): NOLF, AvP2 and AvP2 Primal Hunt""" + """Import LithTech models (.abc): NOLF, AvP2 and AvP2 Primal Hunt, and LithTech 1's v6 (Shogo, Blood 2, KISS)""" bl_idname = "import_scene.lithtech_abc" bl_label = "Import LithTech Model" bl_options = {'REGISTER', 'UNDO', 'PRESET'} @@ -94,7 +94,7 @@ def draw(self, context): class EXPORT_SCENE_OT_lithtech_abc(bpy.types.Operator, ExportHelper): - """Export an armature and its meshes as a LithTech model (.abc, version 12)""" + """Export an armature and its meshes as a LithTech model (.abc: version 12, or LithTech 1's version 6)""" bl_idname = "export_scene.lithtech_abc" bl_label = "Export LithTech Model" bl_options = {'PRESET'} @@ -102,6 +102,14 @@ class EXPORT_SCENE_OT_lithtech_abc(bpy.types.Operator, ExportHelper): filename_ext = ".abc" filter_glob: StringProperty(default="*.abc", options={'HIDDEN'}) + abc_version: EnumProperty( + name="Version", + items=[('AUTO', "As Imported", "Version 6 for a model imported from LithTech 1's .abc v6, " + "else version 12"), + ('12', "12", "NOLF, AvP2 and AvP2 Primal Hunt"), + ('6', "6 (LithTech 1)", "Shogo, Blood 2, KISS Psycho Circus: one mesh, one node per " + "vertex, and vertex animation from the shape keys \"|\"")], + default='AUTO') scale: FloatProperty( name="Scale", default=1.0, min=1e-6, soft_min=0.001, soft_max=1000.0, description="LithTech units per Blender unit (set from the scale the model was " @@ -138,8 +146,26 @@ def invoke(self, context, event): self.filepath = bpy.path.ensure_ext(arm.name, self.filename_ext) return super().invoke(context, event) + def _version(self, context): + if self.abc_version != 'AUTO': + return int(self.abc_version) + from . import export_abc + arm = export_abc.find_armature(context) + return 6 if arm is not None and arm.get("lt_source_version") == 6 else 12 + def execute(self, context): from . import export_abc, textures + if self._version(context) == 6: + from . import export_abc6 + try: + _, written, _notes = export_abc6.save( + context, self.filepath, scale=self.scale, use_selection=self.use_selection, + export_animations=self.export_animations, report=self.report) + except export_abc.ExportError as e: + self.report({'ERROR'}, str(e)) + return {'CANCELLED'} + self.report({'INFO'}, "Wrote " + ", ".join(os.path.basename(p) for p in written) + " (v6)") + return {'FINISHED'} try: _, written = export_abc.save( context, self.filepath, scale=self.scale, use_selection=self.use_selection, @@ -157,13 +183,19 @@ def execute(self, context): def draw(self, context): layout = self.layout layout.use_property_split = True + layout.prop(self, "abc_version") + v12 = self._version(context) != 6 layout.prop(self, "scale") layout.prop(self, "use_selection") layout.prop(self, "export_animations") - layout.prop(self, "normalize_weights") - layout.prop(self, "export_textures") + # v6 holds one node per vertex, and LithTech 1's skins are a DTX + # version the texture writer doesn't write. + sub = layout.column() + sub.active = v12 + sub.prop(self, "normalize_weights") + sub.prop(self, "export_textures") sub = layout.column() - sub.active = self.export_textures + sub.active = v12 and self.export_textures sub.prop(self, "texture_format") diff --git a/plugins/blender/io_scene_lithtech/animation.py b/plugins/blender/io_scene_lithtech/animation.py index 1c040952..dd6d9879 100644 --- a/plugins/blender/io_scene_lithtech/animation.py +++ b/plugins/blender/io_scene_lithtech/animation.py @@ -31,6 +31,41 @@ def assign(arm, action): slot = action.slots.new(id_type='OBJECT', name=arm.name) ad.action = action ad.action_slot = slot + assign_shape_keys(arm, action) + + +def vertex_animated_keys(arm): + """The shape keys (Key datablocks) of the meshes this armature deforms + whose vertex animation the importer keyed (LithTech 1's .abc v6; see + import_abc._add_v6_vertex_animation).""" + out = [] + for o in bpy.data.objects: + if o.type != 'MESH' or o.get("lt_vertex_animation") != "abc6" or o.data.shape_keys is None: + continue + if o.parent == arm or any(m.type == 'ARMATURE' and m.object == arm for m in o.modifiers): + out.append(o.data.shape_keys) + return out + + +def assign_shape_keys(arm, action): + """Give each vertex-animated mesh's shape keys the action's KEY slot, so + the frames play with the bones; with none (or no action), take the + action off them and set every frame's key to 0, the first pose.""" + for key in vertex_animated_keys(arm): + slots = [] if action is None else [s for s in action.slots if s.target_id_type == 'KEY'] + slot = next((s for s in slots if s.name_display == key.name), None) + if slot is None and len(slots) == 1: + slot = slots[0] + if slot is None: + if key.animation_data: + key.animation_data.action = None + for kb in key.key_blocks: + if "|" in kb.name: + kb.value = 0.0 + continue + ad = key.animation_data or key.animation_data_create() + ad.action = action + ad.action_slot = slot def rest_pose(arm): @@ -40,6 +75,7 @@ def rest_pose(arm): track.mute = True for bone in arm.pose.bones: bone.matrix_basis = Matrix.Identity(4) + assign_shape_keys(arm, None) def select_action(context, arm, action): diff --git a/plugins/blender/io_scene_lithtech/export_abc.py b/plugins/blender/io_scene_lithtech/export_abc.py index 6fe0d26d..bdbbae49 100644 --- a/plugins/blender/io_scene_lithtech/export_abc.py +++ b/plugins/blender/io_scene_lithtech/export_abc.py @@ -117,12 +117,19 @@ def _mesh_objects(arm_obj, objects): def _evaluated_mesh(obj, depsgraph): - """The object's mesh with its modifiers except Armature ones.""" + """The object's mesh with its modifiers except Armature ones, and + without its vertex-animation frames (shape keys "|"): + playing an animation sets their values, and the mesh is written at + rest.""" toggled = [] for m in obj.modifiers: if m.type == 'ARMATURE' and m.show_viewport: m.show_viewport = False toggled.append(m) + keys = obj.data.shape_keys if obj.type == 'MESH' else None + muted = [kb for kb in keys.key_blocks if "|" in kb.name and not kb.mute] if keys else [] + for kb in muted: + kb.mute = True try: depsgraph.update() ev = obj.evaluated_get(depsgraph) @@ -132,6 +139,8 @@ def _evaluated_mesh(obj, depsgraph): finally: for m in toggled: m.show_viewport = True + for kb in muted: + kb.mute = False return me diff --git a/plugins/blender/io_scene_lithtech/export_abc6.py b/plugins/blender/io_scene_lithtech/export_abc6.py new file mode 100644 index 00000000..a14f571e --- /dev/null +++ b/plugins/blender/io_scene_lithtech/export_abc6.py @@ -0,0 +1,621 @@ +"""Export an armature and its meshes as LithTech 1's .abc version 6 (Shogo, +Blood 2, KISS Psycho Circus), vertex animation included. + +The inverse of import_abc for a v6 file. Every value is worked out from the +scene; where it still agrees with what the importer kept (the lt_v6_* +properties and attributes, see import_abc._keep_v6), the kept value is +written instead, exactly, as export_abc does for lt_location and the key +strings. So a v6 model imported and not edited is written back byte for +byte, and an edit changes only what it touches. + +What v6 holds, and where it comes from: + * ONE mesh, each vertex on ONE node, in that node's space at the first + pose. The meshes the armature deforms (export_abc's rule) are written + as one, in order. A vertex weighted to several bones goes to the + heaviest; one with none to the bone its object is parented to, else + the root. Triangles only. + * the bones, numbered as export_abc numbers them. + * the actions, found and keyed as export_abc finds and keys them. LithTech + 1 has no separate bind pose: it is the FIRST key of the FIRST + animation, so that key should be the armature's rest pose (the + importer makes it so) or the mesh comes out bent. + * VERTEX ANIMATION: the mesh's shape keys "|" + (import_abc.shape_key_name), at the bind pose, for each node's + "animated vertices". A node keeps the list it was imported with while + the mesh's faces are unchanged; a node with a vertex that moves in any + frame lists every vertex on it, as every such node in the shipped files + does. Each frame is stored as three bytes + per vertex, byte * scale + translation in the node's space: the kept + scale and translation while every byte still fits, else new ones that + span the frames. + * bounds (the model's, each node's, each animation's and keyframe's): the + kept ones while what they bound is unchanged, else worked out by the + rule most shipped files follow (measured on Shogo's and Blood 2's + demos: the posed vertices' box for a keyframe, 637 of 749; the first + pose's for the model, 185 of 200; the keyframes' union for an + animation, 139 of 215; a node's own vertices for a node, 1,317 of + 1,320). + * the reduced-detail vertices, which Blender doesn't show: kept while the + mesh's faces and vertex count are unchanged (they refer to its vertices + by number), else dropped (NumLODs 0, as 326 of the 414 v6 models held + ship), and the vertices' LOD replacements with them. + +Skins are not written: LithTech 1's textures are not the DTX version +textures.save_image writes. +""" + +import json +import math + +import bpy +from mathutils import Matrix, Vector + +from .lithtech import abc, abc6 +from . import convert +from . import export_abc +from . import import_abc + +ExportError = export_abc.ExportError + +POS_TOL = 1e-4 # LithTech units, plus POS_REL of the value +POS_REL = 1e-6 +QUAT_TOL = 1e-4 # per component, between unit quaternions +CO_TOL = 1e-6 # Blender units: a vertex not moved since import +MOVE_TOL = 1e-5 # a shape key's vertex that differs from the Basis + + +def _unmirror(p): + return (p[0], p[1], -p[2]) + + +def _v6_quat(q): + """From the shared model's drawing space back to the file's (abc6._quat + is its own inverse).""" + return (q[0], q[1], -q[2], q[3]) + + +def _f32(x): + import struct + return struct.unpack(" weight: + best, weight = ni, g.weight + self.node.append(best) + self.shared += used > 1 + + # What the importer kept, valid while the object sits where it was. + # Blender's attributes follow their vertices, corners and faces + # through edits; one an edit made gets zeros, which match nothing. + kept = self.identity + self.kept_pos = _point_attr(me, "lt_v6_position", 'FLOAT_VECTOR', 3) if kept else None + self.kept_normal = _point_attr(me, "lt_v6_normal", 'FLOAT_VECTOR', 3) if kept else None + self.kept_node = _point_attr(me, "lt_v6_node", 'INT', 1) if kept else None + self.kept_co = _point_attr(me, "lt_v6_import_co", 'FLOAT_VECTOR', 3) if kept else None + reps = [_point_attr(me, "lt_v6_replacement%d" % k, 'INT', 1) for k in range(2)] if kept else [None, None] + self.kept_uv = _point_attr(me, "lt_v6_uv", 'FLOAT2', 2, 'CORNER') if kept else None + self.kept_face = _point_attr(me, "lt_v6_face_normal", 'FLOAT_VECTOR', 3, 'FACE') if kept else None + self.face_imported = _point_attr(me, "lt_v6_face", 'INT', 1, 'FACE') if kept else None + + # Triangles, back in LithTech's winding (see import_abc). + tris = [] + loops = [0] * len(me.loops) + me.loops.foreach_get("vertex_index", loops) + for p in me.polygons: + ls = list(range(p.loop_start, p.loop_start + 3)) + tris.append((ls[0], ls[2], ls[1])) + self.tri_loops = tris + self.tri_indices = [tuple(loops[li] for li in t) for t in tris] + # The faces and the vertex numbering as imported: what the + # reduced-detail vertices and the replacements refer to. + self.faces_same = kept and int(obj.get("lt_v6_vertex_count", -1)) == nv and \ + obj.get("lt_v6_triangle_crc") == import_abc.triangle_crc([i for t in self.tri_indices for i in t]) + + # A vertex is unchanged while it is on the node it was imported on, + # that node's bind is unchanged, and it sits where it was: for most, + # where its stored floats put it; for an animated vertex, whose + # floats are not a position, where the importer put it. + self.pos_same = [False] * nv + self.co_same = [False] * nv + if self.kept_pos is not None and self.kept_node is not None and self.kept_co is not None: + for vi in range(nv): + if self.kept_node[vi] != self.node[vi] or not bind_same[self.node[vi]]: + continue + kp = self.kept_pos[3 * vi:3 * vi + 3] + self.pos_same[vi] = _close_pos(self.local(vi), kp) + self.co_same[vi] = all(abs(self.co[3 * vi + c] - self.kept_co[3 * vi + c]) <= + CO_TOL * max(1.0, abs(self.kept_co[3 * vi + c])) for c in range(3)) + self.unchanged = list(self.pos_same) + self.reps = [(reps[0][vi], reps[1][vi]) if self.faces_same and reps[0] and reps[1] else (0, 0) + for vi in range(nv)] + self.corner_same = None + + # Normals: a vertex all of whose corners keep the normal the importer + # gave them keeps its stored bytes. + me_n = [Vector((0.0, 0.0, 0.0)) for _ in range(nv)] + cn = me.corner_normals + corner_same = [True] * nv + self.corner_same = corner_same + imp = _point_attr(me, "lt_import_normal", 'FLOAT_VECTOR', 3, 'CORNER') + for li, vi in enumerate(loops): + n = cn[li].vector + me_n[vi] += n + if imp is None or max(abs(imp[3 * li + c] - n[c]) for c in range(3)) >= 1e-4: + corner_same[vi] = False + normal_mat = self.to_arm.to_3x3().inverted_safe().transposed() + self.blender_normal = [] + for vi in range(nv): + n = me_n[vi] if me_n[vi].length > 0 else me.vertices[vi].normal + self.blender_normal.append(convert.normal_from_blender((normal_mat @ n).normalized())) + + def mark_animated(self, local_vertices): + """The vertices a node animates are unchanged where they sit as + imported (see __init__).""" + for vi in local_vertices: + self.unchanged[vi] = self.co_same[vi] + + def normal_kept(self, vi): + return self.kept_normal is not None and self.corner_same[vi] and self.unchanged[vi] + + def lt_point(self, co3): + """A Blender-space point of this mesh in the armature's LithTech space.""" + return convert.vec_from_blender(self.to_arm @ Vector(co3), self.scale) + + def local(self, vi, co3=None, node=None): + """A vertex (or co3 for it) in its node's space as v6 stores it.""" + node = self.node[vi] if node is None else node + p = self.lt_point(self.co[3 * vi:3 * vi + 3] if co3 is None else co3) + return _unmirror(tuple(self.node_binv[node] @ Vector(p))) + + def shape_frames(self, action_name, nkeys): + """{key: flat co} for this action's frames that exist.""" + keys = self.me.shape_keys + out = {} + if keys is None: + return out + for k in range(nkeys): + kb = keys.key_blocks.get(import_abc.shape_key_name(action_name, k)) + if kb is None or len(kb.data) != self.count: + continue + flat = [0.0] * (3 * self.count) + kb.data.foreach_get("co", flat) + out[k] = flat + return out + + +def _box(points): + if not points: + return (0.0, 0.0, 0.0), (0.0, 0.0, 0.0) + return (tuple(_f32(min(p[c] for p in points)) for c in range(3)), + tuple(_f32(max(p[c] for p in points)) for c in range(3))) + + +def _posed_box(sm, ai, k): + a = sm.animations[ai] + lod = sm.pieces[0].lods[0] + M = abc.pose_matrices(sm, a, k) + return _box([_unmirror(abc.skinned_position(sm, v, M)) for v in abc.animated_vertices(sm, a, k, lod)]) + + +def _quantise(points, scale, translation): + """Bytes for points with this scale and translation, or None if one + doesn't fit.""" + out = bytearray() + for p in points: + for c in range(3): + q = round((p[c] - translation[c]) / scale[c]) if scale[c] else 0 + if not 0 <= q <= 255: + return None + out.append(q) + return bytes(out) + + +def _span(points): + lo = [min(p[c] for p in points) for c in range(3)] + hi = [max(p[c] for p in points) for c in range(3)] + scale = tuple(_f32((hi[c] - lo[c]) / 255.0) or 1.0 for c in range(3)) + return scale, tuple(_f32(x) for x in lo) + + +def _vertex_bytes(lst, node, nkeys, kept, bone, frames, where): + """A node's frames as v6 stores them: three bytes per entry of its list + per keyframe, and the scale and translation that decode them: (bytes, + scale, translation), the kept scale and translation while every byte + fits them. + + An entry stays as kept where Blender can't show it (one shadowed by a + later entry for the same vertex, or a frame with no shape key) while + the list is the one imported.""" + kept_bytes = bytes.fromhex(kept[2]) if kept is not None else b"" + kept_list = list(bone.get("lt_v6_animated_vertices", [])) if bone is not None else [] + same_list = lst == kept_list and len(kept_bytes) == 3 * len(lst) * nkeys + last = {v: j for j, v in enumerate(lst)} + pts = [] + for k in range(nkeys): + for j, v in enumerate(lst): + m, mi, vi = where(v) + flat = frames[mi].get(k) + if same_list and (last[v] != j or flat is None): + pts.append(None) + elif flat is not None: + pts.append(m.local(vi, flat[3 * vi:3 * vi + 3], node)) + else: + pts.append(m.local(vi, None, node)) + + def fill(points, scale, translation): + out = bytearray() + for e, p in enumerate(points): + if p is None: + out += kept_bytes[3 * e:3 * e + 3] + else: + b = _quantise([p], scale, translation) + if b is None: + return None + out += b + return bytes(out) + + if kept is not None: + data = fill(pts, tuple(kept[0]), tuple(kept[1])) + if data is not None: + return data, tuple(kept[0]), tuple(kept[1]) + # New ones, spanning every frame (a kept byte decoded with the kept ones). + if kept is not None and same_list: + s0, t0 = kept[0], kept[1] + pts = [p if p is not None else + tuple(kept_bytes[3 * e + c] * s0[c] + t0[c] for c in range(3)) for e, p in enumerate(pts)] + scale, translation = _span(pts) if pts else ((1.0, 1.0, 1.0), (0.0, 0.0, 0.0)) + return fill(pts, scale, translation), scale, translation + + +def save(context, filepath, *, armature=None, scale=1.0, use_selection=False, + export_animations=True, report=None): + arm_obj = armature or export_abc.find_armature(context) + if arm_obj is None: + raise ExportError(export_abc.nothing_to_export(context)) + if context.object and context.object.mode != 'OBJECT': + bpy.ops.object.mode_set(mode='OBJECT') + notes = [] + fps = context.scene.render.fps / context.scene.render.fps_base + + nodes, node_bones, root = export_abc._build_skeleton(arm_obj, scale) + nn = len(nodes) + node_index = {b.name: i for i, b in enumerate(node_bones) if b is not None} + node_bind = [Matrix((n.bind_matrix[0:4], n.bind_matrix[4:8], n.bind_matrix[8:12], n.bind_matrix[12:16])) + for n in nodes] + node_binv = [m.inverted_safe() for m in node_bind] + bind_same = [] + for b in node_bones: + base = b.get("lt_bind_baseline") if b is not None else None + bind_same.append(base is not None and all(abs(b.matrix_local[r][c] - base[r * 4 + c]) <= 1e-6 + for r in range(4) for c in range(4))) + parents = abc.Model(root=root).node_parents() + + # The mesh. + candidates = context.selected_objects if use_selection else arm_obj.children_recursive + if not use_selection: + candidates = set(candidates) | {o for o in context.scene.objects + if any(md.type == 'ARMATURE' and md.object == arm_obj + for md in getattr(o, "modifiers", ()))} + objs = sorted((o for o in export_abc._mesh_objects(arm_obj, candidates) if int(o.get("lt_lod", 0)) == 0), + key=lambda o: (o.get("lt_piece_index", 1 << 30), o.name)) + arm_inv = arm_obj.matrix_world.inverted() + meshes = [_Mesh(o, arm_obj, arm_inv, scale, node_index, node_binv, bind_same) for o in objs] + if not meshes: + raise ExportError("no meshes found for armature %r" % arm_obj.name) + if len(meshes) > 1: + notes.append("%d meshes written as v6's one" % len(meshes)) + shared = sum(m.shared for m in meshes) + if shared: + notes.append("%d vertices weighted to several bones went to the heaviest (v6 holds one)" % shared) + one = meshes[0] if len(meshes) == 1 else None + + raw = abc6.Model(command_string=arm_obj.get("lt_command_string", "")) + offsets = [] + total = 0 + for m in meshes: + offsets.append(total) + total += m.count + + # Actions first: the animated vertices come from their frames. + rest_locals = [] + for b in node_bones: + if b is None: + rest_locals.append(Matrix.Identity(4)) + elif b.parent is None: + rest_locals.append(b.matrix_local.copy()) + else: + rest_locals.append(b.parent.matrix_local.inverted() @ b.matrix_local) + actions = export_abc._actions_for(arm_obj, node_bones) if export_animations else [] + anims = [export_abc._animation_from_action(a, arm_obj, nodes, node_bones, rest_locals, scale, fps)[0] + for a in actions] + if not anims: + anims = [export_abc._bind_animation(nodes, "base", (0.0, 0.0, 0.0))] + actions = [None] + + # Each action's frames, and the vertices that move in any of them. + frames = [] # per action: per mesh: {key: flat co} + moving = set() # global vertex indices + for act, a in zip(actions, anims): + per = [] + for mi, m in enumerate(meshes): + fr = m.shape_frames(act.name, len(a.keyframes)) if act is not None else {} + per.append(fr) + for flat in fr.values(): + for vi in range(m.count): + if any(abs(flat[3 * vi + c] - m.co[3 * vi + c]) > MOVE_TOL for c in range(3)): + moving.add(offsets[mi] + vi) + frames.append(per) + + def where(g): + mi = max(i for i, o in enumerate(offsets) if o <= g) + return meshes[mi], mi, g - offsets[mi] + + node_of = [m.node[vi] for m in meshes for vi in range(m.count)] + md = [] + for i, b in enumerate(node_bones): + kept = list(b.get("lt_v6_animated_vertices", [])) if b is not None and one is not None and one.faces_same else [] + lst = [v for v in kept if v < total and node_of[v] == i] + if lst or any(node_of[v] == i for v in moving): + # A deformation node lists every vertex on it, as all 21 in the + # shipped files held do (14 of them listing vertices that never + # move), and as the Lithtech_Disassembly port's converter needs to build + # its piece. + have = set(lst) + lst += [v for v in range(total) if node_of[v] == i and v not in have] + md.append(lst) + md_all = {v for lst in md for v in lst} + for mi, m in enumerate(meshes): + m.mark_animated([g - offsets[mi] for g in md_all if offsets[mi] <= g < offsets[mi] + m.count]) + whole_same = one is not None and one.faces_same and all(one.unchanged) and all(bind_same) + + # Geometry. + for mi, m in enumerate(meshes): + off = offsets[mi] + for vi in range(m.count): + g = off + vi + if m.unchanged[vi]: + # (An animated vertex's floats are not its position, and + # LithTech 1 reads its frames instead, so they stay as kept + # while it does.) + pos = tuple(m.kept_pos[3 * vi:3 * vi + 3]) + else: + pos = tuple(_f32(c) for c in m.local(vi)) + if m.normal_kept(vi): + nb = tuple(int(c) for c in m.kept_normal[3 * vi:3 * vi + 3]) + else: + R = node_binv[m.node[vi]].to_3x3() + n = _unmirror(tuple(R @ Vector(m.blender_normal[vi]))) + nb = tuple(max(-127, min(127, int(round(c * 127.0)))) for c in n) + raw.vertices.append(abc6.Vertex(pos, nb, m.node[vi], m.reps[vi])) + uv_layer = m.me.uv_layers.active + for fi, (tl, ti) in enumerate(zip(m.tri_loops, m.tri_indices)): + uvs = [] + for li in tl: + if uv_layer is not None: + u, v = uv_layer.uv[li].vector + uv = (u, 1.0 - v) + else: + uv = (0.0, 0.0) + if m.kept_uv is not None and all(abs(uv[c] - m.kept_uv[2 * li + c]) <= 1e-6 for c in range(2)): + uv = (m.kept_uv[2 * li], m.kept_uv[2 * li + 1]) + uvs.append(uv) + if m.kept_face is not None and m.face_imported is not None and m.face_imported[fi] and \ + all(m.unchanged[v] for v in ti): + fn = tuple(int(c) for c in m.kept_face[3 * fi:3 * fi + 3]) + else: + ps = [Vector(_unmirror(m.lt_point(m.co[3 * v:3 * v + 3]))) for v in ti] + n = (ps[1] - ps[0]).cross(ps[2] - ps[0]) + # The sign most shipped faces have against their first pose + # (7,124 of the 16,599 measured; 835 have the other, the + # rest neither, and what LithTech 1 uses them for is not + # known here). + n = -n.normalized() if n.length > 0 else n + fn = tuple(max(-127, min(127, int(round(c * 127.0)))) for c in n) + raw.triangles.append(abc6.Triangle(tuple(uvs), tuple(off + v for v in ti), fn)) + raw.normal_vertex_count = total + if one is not None and one.faces_same: + # The reduced-detail vertices refer to the mesh's by number, so they + # hold while its vertices and faces do. + raw.vertex_start = list(one.obj.get("lt_v6_vertex_start", [0])) + raw.normal_vertex_count = int(one.obj.get("lt_v6_normal_vertex_count", total)) + for r in json.loads(one.obj.get("lt_v6_reduced_json", "[]")): + raw.vertices.append(abc6.Vertex(tuple(r[0:3]), tuple(int(c) for c in r[3:6]), int(r[6]), + (int(r[7]), int(r[8])))) + else: + raw.vertex_start = [0] + if one is not None and json.loads(one.obj.get("lt_v6_reduced_json", "[]")): + notes.append("the reduced-detail vertices were dropped: the mesh's faces have changed") + + # Nodes. + for i, (n, b) in enumerate(zip(nodes, node_bones)): + flags = int(b.get("lt_flags", n.flags)) if b is not None else n.flags + if md[i]: + flags |= abc6.NODE_DEFORMATION + bounds = list(b.get("lt_v6_bounds", [])) if b is not None and whole_same else [] + bmin, bmax = (tuple(bounds[0:3]), tuple(bounds[3:6])) if len(bounds) == 6 else (None, None) + raw.nodes.append(abc6.Node(bmin, bmax, n.name, int(b.get("lt_v6_index", i)) if b is not None else i, + flags, md[i], len(n.children))) + + # Animations. + file_names = json.loads(arm_obj.get("lt_v6_node_names_json", "[]")) + # Each node's place in the file it was imported from, by name; a name + # some models use twice (Shogo's ORDOG has two "left_armu_spike_lower") + # goes to its occurrences in order. + by_name = {} + for fi, nm in enumerate(file_names): + by_name.setdefault(nm, []).append(fi) + file_of = [by_name[n.name].pop(0) if by_name.get(n.name) else None for n in nodes] + anim_bounds = [] # animations whose bounds are worked out below + for ai, (act, a) in enumerate(zip(actions, anims)): + nk = len(a.keyframes) + times = [k.time for k in a.keyframes] + get = act.get if act is not None else {}.get + same_times = list(get("lt_v6_key_times", [])) == times + stored = list(get("lt_v6_keys", [])) if same_times else [] + if len(stored) != 7 * len(file_names) * nk: + stored = [] + kept_tracks = json.loads(get("lt_v6_tracks_json", "{}")) + anim_same = whole_same and same_times + tracks = [] + for i, n in enumerate(nodes): + keys = [] + fi = file_of[i] + for k, (p, q) in enumerate(a.node_transforms[i]): + p6, q6 = _unmirror(p), _v6_quat(q) + if stored and fi is not None: + base = 7 * (fi * nk + k) + sp, sq = tuple(stored[base:base + 3]), tuple(stored[base + 3:base + 7]) + if _close_pos(p6, sp) and _close_quat(sq, q6): + keys.append((sp, sq)) + continue + anim_same = False + keys.append((tuple(_f32(c) for c in p6), tuple(_f32(c) for c in q6))) + tr = abc6.Track(keys) + kt = kept_tracks.get(str(fi)) if fi is not None else None + if kt is not None and not md[i]: + tr.scale, tr.translation = tuple(kt[0]), tuple(kt[1]) + if md[i]: + tr.vertex_bytes, tr.scale, tr.translation = _vertex_bytes( + md[i], i, nk, kt, node_bones[i], frames[ai], where) + if kt is None or tr.vertex_bytes != bytes.fromhex(kt[2]) or \ + (tr.scale, tr.translation) != (tuple(kt[0]), tuple(kt[1])): + anim_same = False + tracks.append(tr) + kb = list(get("lt_v6_key_bounds", [])) + b6 = list(get("lt_v6_bounds", [])) + kept_bounds = anim_same and len(kb) == 6 * nk and len(b6) == 6 + if kept_bounds: + kfs = [abc6.Keyframe(k.time, tuple(kb[6 * j:6 * j + 3]), tuple(kb[6 * j + 3:6 * j + 6]), k.command) + for j, k in enumerate(a.keyframes)] + bmin, bmax = tuple(b6[0:3]), tuple(b6[3:6]) + else: + zero = (0.0, 0.0, 0.0) + kfs = [abc6.Keyframe(k.time, zero, zero, k.command) for k in a.keyframes] + bmin = bmax = zero + anim_bounds.append(ai) + length = int(get("lt_v6_length", times[-1] if times else 0)) if same_times else (times[-1] if times else 0) + raw.animations.append(abc6.Animation(a.name, length, bmin, bmax, kfs, tracks)) + + has_dims = all(int(act.get("lt_v6_has_dims", 1)) for act in actions if act is not None) + raw.anim_dims = [tuple(_f32(c) for c in a.dims) for a in anims] if has_dims else None + chunks = json.loads(arm_obj.get("lt_v6_chunks_json", "null")) or list(abc6.CHUNKS) + # The chunks v6 doesn't define (KISS's TransformInfo), written back as + # imported. + raw.extra_chunks = {k: bytes.fromhex(v) for k, v in + json.loads(arm_obj.get("lt_v6_extra_chunks_json", "{}")).items()} + chunks = [c for c in chunks if c in abc6.CHUNKS or c in raw.extra_chunks] + if not has_dims: + chunks = [c for c in chunks if c != "AnimDims"] + elif "AnimDims" not in chunks: + chunks.insert(chunks.index("Animation") + 1 if "Animation" in chunks else len(chunks), "AnimDims") + raw.chunks = chunks + + # Bounds that weren't kept, by the rule (see the module notes). + gb = list(arm_obj.get("lt_v6_bounds", [])) + geo = not (whole_same and len(gb) == 6) + if not geo: + raw.bounds_min, raw.bounds_max = tuple(gb[0:3]), tuple(gb[3:6]) + holes = [i for i, n in enumerate(raw.nodes) if n.bounds_min is None] + for i in holes: + raw.nodes[i].bounds_min = raw.nodes[i].bounds_max = (0.0, 0.0, 0.0) + if geo or holes or anim_bounds: + sm = abc6.to_abc(raw) + lod = sm.pieces[0].lods[0] + if geo: + raw.bounds_min, raw.bounds_max = _posed_box(sm, 0, 0) if raw.animations[0].keyframes else _box([]) + for i in holes: + raw.nodes[i].bounds_min, raw.nodes[i].bounds_max = _box( + [_unmirror(v.weights[0].location) for v in lod.vertices if v.weights[0].node == i]) + for ai in anim_bounds: + a = raw.animations[ai] + for k, kf in enumerate(a.keyframes): + kf.bounds_min, kf.bounds_max = _posed_box(sm, ai, k) + boxes = [(k.bounds_min, k.bounds_max) for k in a.keyframes] or [_box([])] + a.bounds_min = tuple(min(b[0][c] for b in boxes) for c in range(3)) + a.bounds_max = tuple(max(b[1][c] for b in boxes) for c in range(3)) + + # The first key of the first animation is v6's bind: say when it isn't + # the rest pose the mesh was written against. + if anims and anims[0].keyframes: + a = anims[0] + worst = 0.0 + for i in range(nn): + p, q = a.node_transforms[i][0] + local = abc.quat_to_matrix(p, q) + M = Matrix((local[0:4], local[4:8], local[8:12], local[12:16])) + rest = node_bind[i] if parents[i] < 0 else node_binv[parents[i]] @ node_bind[i] + worst = max(worst, max(abs(M[r][c] - rest[r][c]) for r in range(3) for c in range(4))) + if worst > 1e-3: + notes.append("the first key of %r is not the rest pose (off by %.3g): LithTech 1 binds the mesh " + "to that key, so set it to the rest pose" % (a.name, worst)) + + abc6.write_file(raw, filepath) + if report is not None: + for n in notes: + report({'WARNING'}, n) + return raw, [filepath], notes diff --git a/plugins/blender/io_scene_lithtech/import_abc.py b/plugins/blender/io_scene_lithtech/import_abc.py index 055bdf7c..2c6c5367 100644 --- a/plugins/blender/io_scene_lithtech/import_abc.py +++ b/plugins/blender/io_scene_lithtech/import_abc.py @@ -15,16 +15,29 @@ * the rest -> custom properties on the armature object: lt_version, lt_command_string, lt_internal_radius, lt_lod_distances, lt_scale and JSON for weight sets and child models + +LithTech 1's .abc v6 also gets: + * vertex animation -> shape keys on the mesh, "|", one per + keyframe of each animation (as import_ltb names an + LTB's), keyed in the action's KEY slot so they play + with the bones + * what v6 stores and Blender doesn't (bounds, the reduced-detail vertices, + the keys as stored, the vertices' bytes) -> lt_v6_* + properties and attributes, for export_abc6 to write + back while the model is unchanged """ import json import math import os +import struct +import zlib import bpy from mathutils import Matrix, Vector from .lithtech import abc +from .lithtech import abc6 from .lithtech import dhnp from . import convert from . import textures @@ -50,10 +63,163 @@ def _find_file_nocase(directory, name): return cur -def _read_model(path): +def _read(path): + """(the shared model, the v6 file as stored or None).""" with open(path, "rb") as f: data = f.read() - return dhnp.read(data) if dhnp.is_model(data) else abc.read(data) + if abc6.is_model(data): + # LithTech 1's .abc (Shogo, Blood 2, KISS), converted as DHNP's is; + # load() also keeps what export_abc6 needs to write it back. + raw = abc6.read(data) + return abc6.to_abc(raw), raw + return (dhnp.read(data) if dhnp.is_model(data) else abc.read(data)), None + + +def _read_model(path): + return _read(path)[0] + + +def shape_key_name(action_name, key): + """The shape key holding a vertex-animation frame: "|", the + action's name cut so that the whole fits Blender's 63 bytes.""" + tail = "|%d" % key + head = action_name.encode("utf-8")[:63 - len(tail)].decode("utf-8", "ignore") + return head + tail + + +def triangle_crc(indices): + """A checksum of a v6 triangle list's indices, to tell whether the + mesh's faces are still the file's.""" + return "%08x" % zlib.crc32(struct.pack("<%dI" % len(indices), *indices)) + + +def _keep_v6(arm_obj, bone_names, obj, raw, lod_count): + """What a v6 file holds that the scene doesn't: export_abc6 writes these + back while the part of the model they belong to is unchanged.""" + arm_obj["lt_v6_bounds"] = list(raw.bounds_min) + list(raw.bounds_max) + arm_obj["lt_v6_chunks_json"] = json.dumps(raw.chunks) + if raw.extra_chunks: + arm_obj["lt_v6_extra_chunks_json"] = json.dumps({k: v.hex() for k, v in raw.extra_chunks.items()}) + arm_obj["lt_v6_node_names_json"] = json.dumps([n.name for n in raw.nodes]) + bones = arm_obj.data.bones + for i, n in enumerate(raw.nodes): + b = bones[bone_names[i]] + b["lt_v6_index"] = n.index + b["lt_v6_bounds"] = list(n.bounds_min) + list(n.bounds_max) + if n.animated_vertices: + b["lt_v6_animated_vertices"] = list(n.animated_vertices) + + me = obj.data + full = raw.vertices[:lod_count] + obj["lt_v6_vertex_count"] = lod_count + obj["lt_v6_triangle_crc"] = triangle_crc([i for t in raw.triangles for i in t.indices]) + obj["lt_v6_vertex_start"] = list(raw.vertex_start) + obj["lt_v6_normal_vertex_count"] = raw.normal_vertex_count + # The reduced-detail vertices, which the mesh doesn't have. + obj["lt_v6_reduced_json"] = json.dumps( + [list(v.position) + list(v.normal) + [v.node] + list(v.replacements) + for v in raw.vertices[lod_count:]]) + # Every Geometry float as stored: an animated vertex's are not its + # position (abc6's notes), and an unedited one's would come back from + # Blender within float error only. + me.attributes.new("lt_v6_position", 'FLOAT_VECTOR', 'POINT').data.foreach_set( + "vector", [c for v in full for c in v.position]) + # Where each vertex was, to tell which ones have been moved since. + co = [0.0] * (3 * len(me.vertices)) + me.vertices.foreach_get("co", co) + me.attributes.new("lt_v6_import_co", 'FLOAT_VECTOR', 'POINT').data.foreach_set("vector", co) + me.attributes.new("lt_v6_normal", 'FLOAT_VECTOR', 'POINT').data.foreach_set( + "vector", [float(c) for v in full for c in v.normal]) + me.attributes.new("lt_v6_node", 'INT', 'POINT').data.foreach_set("value", [v.node for v in full]) + for k in range(2): + me.attributes.new("lt_v6_replacement%d" % k, 'INT', 'POINT').data.foreach_set( + "value", [v.replacements[k] for v in full]) + # Corners go in as 0, 2, 1 (see _create_mesh_object). + me.attributes.new("lt_v6_uv", 'FLOAT2', 'CORNER').data.foreach_set( + "vector", [c for t in raw.triangles for k in (0, 2, 1) for c in t.uvs[k]]) + me.attributes.new("lt_v6_face_normal", 'FLOAT_VECTOR', 'FACE').data.foreach_set( + "vector", [float(c) for t in raw.triangles for c in t.normal]) + # 1 on every face imported; a face made since gets 0. (Two of Blood 2's + # weapons store a face normal of zero, so that can't be the mark.) + me.attributes.new("lt_v6_face", 'INT', 'FACE').data.foreach_set("value", [1] * len(raw.triangles)) + + +def _keep_v6_animation(act, raw, ai): + a = raw.animations[ai] + act["lt_v6_length"] = a.length + act["lt_v6_bounds"] = list(a.bounds_min) + list(a.bounds_max) + act["lt_v6_key_times"] = [k.time for k in a.keyframes] + act["lt_v6_key_bounds"] = [c for k in a.keyframes for c in list(k.bounds_min) + list(k.bounds_max)] + # The keys as stored, by node in the file's order: lengths and all, which + # Blender's quaternions don't keep (abc6's notes). + act["lt_v6_keys"] = [c for tr in a.tracks for p, q in tr.keys for c in tuple(p) + tuple(q)] + tracks = {} + for fi, tr in enumerate(a.tracks): + # By the node's place in the file (lt_v6_node_names_json): names repeat. + if tr.vertex_bytes or tuple(tr.scale) != (1.0, 1.0, 1.0) or tuple(tr.translation) != (0.0, 0.0, 0.0): + tracks[str(fi)] = [list(tr.scale), list(tr.translation), tr.vertex_bytes.hex()] + if tracks: + act["lt_v6_tracks_json"] = json.dumps(tracks) + act["lt_v6_has_dims"] = int(raw.anim_dims is not None) + + +def _add_v6_vertex_animation(obj, m, actions, scale, fps): + """LithTech 1's vertex animation as shape keys: a Basis, the first pose, + and for each action and keyframe one key "|" holding every + vertex where that keyframe puts it at the bind pose. The armature then + carries them as it carries the rest of the mesh, and the keys' values, + keyed LINEAR in the action's KEY slot (1 on their own keyframe, 0 on + the ones either side), blend two frames between keys as LithTech 1 + lerps them. actions: (action, abc.Animation) pairs.""" + va = [(i, n) for i, n in enumerate(m.nodes) if n.animated_vertices] + if not va or not actions: + return + binds = [Matrix((n.bind_matrix[0:4], n.bind_matrix[4:8], n.bind_matrix[8:12], n.bind_matrix[12:16])) + for n in m.nodes] + me = obj.data + obj.shape_key_add(name="Basis", from_mix=False) + basis = [0.0] * (3 * len(me.vertices)) + me.vertices.foreach_get("co", basis) + key = me.shape_keys + for act, a in actions: + if not a.vertex_frames or not a.keyframes: + continue + names = [] + for k in range(len(a.keyframes)): + co = list(basis) + for i, n in va: + frames = a.vertex_frames[i] + if not frames: + continue + for j, vi in enumerate(n.animated_vertices): + # In a list that names a vertex twice the later wins, as + # LithTech 1's loader overwrites it. + co[3 * vi:3 * vi + 3] = convert.vec_to_blender(binds[i] @ Vector(frames[k][j]), scale) + sk = obj.shape_key_add(name=shape_key_name(act.name, k), from_mix=False) + sk.value = 0.0 + sk.data.foreach_set("co", co) + names.append(sk.name) + + slot = act.slots.new(id_type='KEY', name=key.name) + ad = key.animation_data or key.animation_data_create() + ad.action, ad.action_slot = act, slot + frames = [kf.time * fps / 1000.0 for kf in a.keyframes] + for k, name in enumerate(names): + pts = [] + if k > 0 and frames[k - 1] < frames[k]: + pts += (frames[k - 1], 0.0) + pts += (frames[k], 1.0) + if k + 1 < len(frames) and frames[k + 1] > frames[k]: + pts += (frames[k + 1], 0.0) + fc = act.fcurve_ensure_for_datablock( + key, 'key_blocks["%s"].value' % bpy.utils.escape_identifier(name), index=0, + group_name="Vertex animation") + fc.keyframe_points.add(len(pts) // 2) + fc.keyframe_points.foreach_set("co", pts) + fc.keyframe_points.foreach_set("interpolation", [1] * (len(pts) // 2)) # LINEAR + fc.update() + ad.action = None + obj["lt_vertex_animation"] = "abc6" def _bone_lengths(binds, parents, default): @@ -353,7 +519,7 @@ def _create_action(arm_obj, action_name, anim, node_map, rest_inv_locals, scale, def load(context, filepath, *, scale=1.0, face_front=True, import_animations=True, child_animations=True, import_lods=True, import_sockets=True, import_textures=True, texture_dirs=(), report=None): - m = _read_model(filepath) + m, raw = _read(filepath) name = os.path.splitext(os.path.basename(filepath))[0] fps = context.scene.render.fps / context.scene.render.fps_base @@ -378,8 +544,12 @@ def load(context, filepath, *, scale=1.0, face_front=True, import_animations=Tru arm_obj["lt_source_format"] = m.source_format arm_obj["lt_source_version"] = m.source_version if report: - report({'INFO'}, "%s: imported %s version %d; export converts to ABC or PC LTB" % - (name, m.source_format, m.source_version)) + if raw is not None: + report({'INFO'}, "%s: imported %s; File > Export > LithTech Model writes it back as v6, " + "or converts it to v12 or PC LTB" % (name, m.source_format)) + else: + report({'INFO'}, "%s: imported %s version %d; export converts to ABC or PC LTB" % + (name, m.source_format, m.source_version)) arm_obj["lt_command_string"] = m.command_string arm_obj["lt_internal_radius"] = m.internal_radius if m.lod_distances: @@ -412,6 +582,7 @@ def relation(c): images = {} materials = {} lod_collection = None + base_obj = None slots = {p.texture_index if lod.texture_index is None else lod.texture_index for p in m.pieces for lod in p.lods} for pi, p in enumerate(m.pieces): @@ -430,6 +601,7 @@ def relation(c): obj.data.materials.append(mat) if li == 0: collection.objects.link(obj) + base_obj = base_obj or obj else: if lod_collection is None: lod_collection = bpy.data.collections.new(name + " LODs") @@ -454,6 +626,9 @@ def relation(c): if p.reserved: obj["lt_reserved"] = list(p.reserved) + if raw is not None and base_obj is not None: + _keep_v6(arm_obj, bone_names, base_obj, raw, len(m.pieces[0].lods[0].vertices)) + if import_sockets: for si, s in enumerate(m.sockets): _create_socket(collection, arm_obj, bone_names, s, si, scale) @@ -462,8 +637,16 @@ def relation(c): rest_inv = _rest_locals(arm_obj, bone_names, parents) own_map = [(bn, i) for i, bn in enumerate(bone_names)] first = None + made = [] for ai, a in enumerate(m.animations): act = _create_action(arm_obj, a.name, a, own_map, rest_inv, scale, fps) + made.append((act, a)) + if raw is not None: + _keep_v6_animation(act, raw, ai) + if act.name != a.name: + # Blender renamed it (an empty name, as some Shogo .abc v6 + # gibs have, or a duplicate): keep the file's, as for bones. + act["lt_name"] = a.name act["lt_model"] = name act["lt_index"] = ai act["lt_dims"] = list(a.dims) @@ -472,6 +655,8 @@ def relation(c): act["lt_interpolation_ms"] = a.interpolation_time act["lt_weight_set"] = a.modeledit_weight_set first = first or act + if raw is not None and base_obj is not None: + _add_v6_vertex_animation(base_obj, m, made, scale, fps) if child_animations: for ci, c in enumerate(m.child_models[1:], 1): diff --git a/plugins/blender/io_scene_lithtech/lithtech/__init__.py b/plugins/blender/io_scene_lithtech/lithtech/__init__.py index 4052ef6c..ee68f11f 100644 --- a/plugins/blender/io_scene_lithtech/lithtech/__init__.py +++ b/plugins/blender/io_scene_lithtech/lithtech/__init__.py @@ -1,11 +1,13 @@ """LithTech file formats in plain Python (standard library only). abc .abc models, versions 9-12 (NOLF, AvP2, AvP2 Primal Hunt) +abc6 .abc models, version 6 (Shogo, Blood 2, KISS), converted to abc's model dtx .dtx textures, version -5 (NOLF to Tron 2.0) ltb .ltb models, model versions 18 to 25 (NOLF2, Contract J.A.C.K., Tron 2.0) ps2ltb .ltb models of NOLF on the PS2 (file type 2, version 16) """ from . import abc # noqa: F401 +from . import abc6 # noqa: F401 from . import dtx # noqa: F401 from . import ltb # noqa: F401 from . import ps2ltb # noqa: F401 diff --git a/plugins/blender/io_scene_lithtech/lithtech/abc.py b/plugins/blender/io_scene_lithtech/lithtech/abc.py index d1817ed9..62cbca62 100644 --- a/plugins/blender/io_scene_lithtech/lithtech/abc.py +++ b/plugins/blender/io_scene_lithtech/lithtech/abc.py @@ -106,6 +106,11 @@ class Node: flags: int # uint8; 1 = null/removable, 2 = tris bind_matrix: Mat4 # model space -> the node, at bind pose children: List["Node"] = field(default_factory=list) + # Vertex animation, which only LithTech 1's .abc v6 has (lithtech.abc6): + # the vertices of pieces[0].lods[0] whose positions this node keys per + # keyframe, in Animation.vertex_frames order. Empty in versions 9-12, + # and write() ignores it. + animated_vertices: List[int] = field(default_factory=list) @dataclass @@ -147,6 +152,9 @@ class Animation: # node_transforms[i][k] is node i's (position, rotation) at keyframe k, # relative to its parent; nodes in depth-first order as Model.nodes. node_transforms: List[List[Tuple[Vec3, Quat]]] = field(default_factory=list) + # vertex_frames[i][k][j] is the node-space position, at keyframe k, of + # node i's animated_vertices[j] (.abc v6 only; empty otherwise). + vertex_frames: List[List[List[Vec3]]] = field(default_factory=list) @dataclass @@ -215,11 +223,18 @@ def node_parents(self) -> List[int]: # --------------------------------------------------------------------- # def quat_to_matrix(position: Vec3, rotation: Quat) -> Mat4: - """A (position, (x, y, z, w)) pair as a 16-float row-major matrix.""" + """A (position, (x, y, z, w)) pair as a 16-float row-major matrix. + + Scaled by 2/|q|^2, as every LithTech engine's quat_ConvertToMatrix is + (LithTech 1, NOLF, Jupiter), so the quaternion needn't be unit: .abc v6 + stores thousands that aren't (abc6.py). For a unit one it is the plain + formula.""" x, y, z, w = rotation - return (1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), position[0], - 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), position[1], - 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y), position[2], + n = x * x + y * y + z * z + w * w + s = 2.0 / n if n > 0 else 0.0 + return (1 - s * (y * y + z * z), s * (x * y - z * w), s * (x * z + y * w), position[0], + s * (x * y + z * w), 1 - s * (x * x + z * z), s * (y * z - x * w), position[1], + s * (x * z - y * w), s * (y * z + x * w), 1 - s * (x * x + y * y), position[2], 0.0, 0.0, 0.0, 1.0) @@ -255,6 +270,25 @@ def skinned_position(m: Model, v: Vertex, matrices: Optional[List[Mat4]] = None) return (s[0], s[1], s[2]) +def animated_vertices(m: Model, anim: Animation, keyframe: int, lod: LOD) -> List[Vertex]: + """pieces[0].lods[0]'s vertices at a keyframe, for a model whose nodes + animate vertices (.abc v6): each animated vertex's one weight moved to + its keyed node-space position, the rest as stored. skinned_position + then places them as usual. Without vertex animation, lod.vertices.""" + if not anim.vertex_frames: + return lod.vertices + out = list(lod.vertices) + for i, node in enumerate(m.nodes): + if not node.animated_vertices or i >= len(anim.vertex_frames) or not anim.vertex_frames[i]: + continue + frame = anim.vertex_frames[i][keyframe] + for j, vi in enumerate(node.animated_vertices): + v = out[vi] + out[vi] = Vertex([Weight(w.node, frame[j], w.bias) for w in v.weights], + v.replacement, v.location, v.normal) + return out + + # --------------------------------------------------------------------- # # Header counts # --------------------------------------------------------------------- # diff --git a/plugins/blender/io_scene_lithtech/lithtech/abc6.py b/plugins/blender/io_scene_lithtech/lithtech/abc6.py new file mode 100644 index 00000000..f61cef7c --- /dev/null +++ b/plugins/blender/io_scene_lithtech/lithtech/abc6.py @@ -0,0 +1,482 @@ +"""LithTech 1's .abc models, version 6 (Shogo, Blood 2, KISS Psycho Circus), +read, written back byte for byte, and converted to the shared ABC model. + + from lithtech import abc6 + raw = abc6.read(data) # the file as stored + data = abc6.write(raw) # identical to the input + model = abc6.to_abc(raw) # an abc.Model, as abc.read gives + +Standard library only. The file is a chunk list like the later versions', +but its chunks are different: Header (the token "MonolithExport Model File +v6" and the command string), Geometry, Nodes, Animation and AnimDims. Shogo +and Blood 2 ship exactly those five, in that order. KISS Psycho Circus adds +a sixth after them in every model, TransformInfo: 8 bytes, two uint32s, +(0, 0) in 445 of its 447 models and (1, 0) in the other two. Nothing here +needs it; a chunk that isn't one of the five is kept as stored +(Model.extra_chunks) and written back in its place. + +What v6 stores, and how to_abc turns it into what abc.read gives: + + * One mesh for the whole model, each vertex on ONE node, its position in + that node's space at the model's first pose: frame 0 of animation 0. + So to_abc gives each vertex a single weight of bias 1 and binds each + node to its frame-0 transform. + * The mesh's vertex table has the reduced-detail vertices after the full + ones: Geometry's "NumLODs" is their count, NormalVerts the full count, + and the triangles index only the full ones. to_abc keeps LOD 0. + * No node count: nodes are depth first, each giving its number of + children. + * Keyframe rotations are stored conjugated, and LithTech 1 draws every + model mirrored in z (its renderer negates the third column of the + model-to-world matrix). The shared model is in the space the engine + draws, as abc.read's models are, so to_abc negates z of every position + and quaternion, which is the conjugate and the mirror together. + * Quaternions are kept as stored, length and all: 211 keys in 23 of + Shogo's demo models and 3,331 in 11 of Blood 2's are not unit (up to + length 313). LithTech 1 normalizes only in quat_ConvertToMatrix (2/|q|^2, + which abc.quat_to_matrix now does too) and interpolates the raw values, + so a long key pulls a midpoint toward itself; normalizing here would + lose that. + * VERTEX ANIMATION. A "deformation" node lists vertices whose positions it + keys per keyframe as three bytes each, decompressed as byte * Scale + + Transform in the node's space (the track's two trailing vectors). Their + Geometry floats are those bytes, not positions: the first pose is frame + 0 of animation 0's. to_abc keys them in Node.animated_vertices and + Animation.vertex_frames, and abc.animated_vertices applies them. Where a + node lists one vertex twice the later entry wins, as LithTech 1's own + loader overwrites it. + +The evidence for each of these is in the LithTech 1 port this was written +beside (github.com/branung/Lithtech_Disassembly): its loader +runtime/model/src/model_abc.cpp, its DECOMP_SHOGO.md F15 (the mirror) and +SHOGO_PORT.md 10.3 and 11.9 (node space, the conjugate), and the +pose check that holds its engine to the same rule. +""" + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from . import abc +from .binio import Reader, Writer + +TOKEN = "MonolithExport Model File v6" +CHUNKS = ("Header", "Geometry", "Nodes", "Animation", "AnimDims") +END_OF_CHUNKS = 0xFFFFFFFF + +NODE_NULL = 1 +NODE_TRIS = 2 +NODE_DEFORMATION = 4 + +Vec2 = Tuple[float, float] +Vec3 = Tuple[float, float, float] + + +class ABC6Error(abc.ABCError): + pass + + +# --------------------------------------------------------------------- # +# Data: the file as stored +# --------------------------------------------------------------------- # + +@dataclass +class Triangle: + uvs: Tuple[Vec2, Vec2, Vec2] # per corner, v down as in DTX + indices: Tuple[int, int, int] # into Model.vertices + normal: Tuple[int, int, int] # face normal, signed bytes over 127 + + +@dataclass +class Vertex: + position: Vec3 # in its node's space; see the module notes + normal: Tuple[int, int, int] # signed bytes over 127 + node: int # index into Model.nodes + replacements: Tuple[int, int] # LOD collapse targets + + +@dataclass +class Node: + bounds_min: Vec3 + bounds_max: Vec3 + name: str + index: int + flags: int # NODE_NULL, NODE_TRIS, NODE_DEFORMATION + animated_vertices: List[int] # the "MD" list: vertex indices it keys + child_count: int + + +@dataclass +class Keyframe: + time: int # ms + bounds_min: Vec3 + bounds_max: Vec3 + command: str + + +@dataclass +class Track: + """One node's channel in one animation.""" + keys: List[Tuple[Vec3, Tuple[float, float, float, float]]] # (position, quaternion) as stored + vertex_bytes: bytes = b"" # 3 * len(animated_vertices) * keyframes + scale: Vec3 = (1.0, 1.0, 1.0) # vertex decompression + translation: Vec3 = (0.0, 0.0, 0.0) + + +@dataclass +class Animation: + name: str + length: int + bounds_min: Vec3 + bounds_max: Vec3 + keyframes: List[Keyframe] + tracks: List[Track] # one per node, in node order + + +@dataclass +class Model: + command_string: str = "" + bounds_min: Vec3 = (0.0, 0.0, 0.0) + bounds_max: Vec3 = (0.0, 0.0, 0.0) + vertex_start: List[int] = field(default_factory=lambda: [0]) # VertexStartNum + triangles: List[Triangle] = field(default_factory=list) + normal_vertex_count: int = 0 # the full-detail vertices, first in `vertices` + vertices: List[Vertex] = field(default_factory=list) + nodes: List[Node] = field(default_factory=list) # depth first + animations: List[Animation] = field(default_factory=list) + anim_dims: Optional[List[Vec3]] = None # None: no AnimDims chunk + chunks: List[str] = field(default_factory=lambda: list(CHUNKS)) + # The chunks that aren't v6's own five (KISS's TransformInfo), by name, + # their bytes as stored. + extra_chunks: Dict[str, bytes] = field(default_factory=dict) + + def node_parents(self) -> List[int]: + parents = [-1] * len(self.nodes) + stack = [] + for i, n in enumerate(self.nodes): + parents[i] = stack[-1][0] if stack else -1 + if stack: + stack[-1][1] -= 1 + while stack and stack[-1][1] == 0: + stack.pop() + if n.child_count: + stack.append([i, n.child_count]) + return parents + + +# --------------------------------------------------------------------- # +# Reading and writing +# --------------------------------------------------------------------- # + +def _chunks(data) -> List[Tuple[str, int, int]]: + out = [] + pos = 0 + while True: + r = Reader(data, pos) + name = r.string() + nxt = r.u32() + end = len(data) if nxt == END_OF_CHUNKS else nxt + if end > len(data) or end < r.pos: + raise ABC6Error("bad .abc chunk offset") + out.append((name, r.pos, end)) + if nxt == END_OF_CHUNKS: + return out + if nxt <= pos: + raise ABC6Error("bad .abc chunk list") + pos = nxt + + +def is_model(data) -> bool: + """A v6 .abc: its first chunk is Header and starts with the v6 token.""" + try: + r = Reader(data, 0) + if r.string() != "Header": + return False + r.u32() + return r.string() == TOKEN + except (EOFError, UnicodeDecodeError, Exception): + return False + + +def read(data) -> Model: + try: + chunks = _chunks(data) + except EOFError: + raise ABC6Error("truncated .abc chunk list") + where = {c[0]: c for c in chunks} + m = Model(chunks=[c[0] for c in chunks]) + for name, start, end in chunks: + if name not in CHUNKS: + m.extra_chunks[name] = bytes(data[start:end]) + for need in ("Header", "Geometry", "Nodes", "Animation"): + if need not in where: + raise ABC6Error("no %s chunk; not a v6 .abc?" % need) + try: + r = Reader(data, where["Header"][1]) + if r.string() != TOKEN: + raise ABC6Error("not a v6 .abc (no %r)" % TOKEN) + m.command_string = r.string() + + r = Reader(data, where["Geometry"][1]) + m.bounds_min, m.bounds_max = r.vec3(), r.vec3() + nlod = r.u32() + m.vertex_start = list(r.unpack("%dH" % (nlod + 1))) + for _ in range(r.u32()): + f = r.unpack("6f3H3b") + m.triangles.append(Triangle(((f[0], f[1]), (f[2], f[3]), (f[4], f[5])), f[6:9], f[9:12])) + nverts = r.u32() + m.normal_vertex_count = r.u32() + for _ in range(nverts): + f = r.unpack("3f3bB2H") + m.vertices.append(Vertex(f[0:3], f[3:6], f[6], f[7:9])) + + r = Reader(data, where["Nodes"][1]) + remaining = 1 + while remaining: + if len(m.nodes) > 4096: + raise ABC6Error(".abc node tree too large") + bmin, bmax = r.vec3(), r.vec3() + name = r.string() + index = r.u16() + flags = r.u8() + nmd = r.u32() + md = list(r.unpack("%dH" % nmd)) if nmd else [] + nkids = r.u32() + m.nodes.append(Node(bmin, bmax, name, index, flags, md, nkids)) + remaining += nkids - 1 + + r = Reader(data, where["Animation"][1]) + for _ in range(r.u32()): + name = r.string() + length = r.u32() + bmin, bmax = r.vec3(), r.vec3() + keyframes = [] + for _k in range(r.u32()): + t = r.u32() + k0, k1 = r.vec3(), r.vec3() + keyframes.append(Keyframe(t, k0, k1, r.string())) + tracks = [] + for n in m.nodes: + keys = [] + for _k in keyframes: + f = r.unpack("7f") + keys.append((f[0:3], f[3:7])) + vb = r.raw(3 * len(n.animated_vertices) * len(keyframes)) if n.animated_vertices else b"" + tracks.append(Track(keys, vb, r.vec3(), r.vec3())) + m.animations.append(Animation(name, length, bmin, bmax, keyframes, tracks)) + + if "AnimDims" in where: + r = Reader(data, where["AnimDims"][1]) + m.anim_dims = [r.vec3() for _ in m.animations] + except EOFError: + raise ABC6Error("truncated v6 .abc") + for t in m.triangles: + if max(t.indices) >= len(m.vertices): + raise ABC6Error("a triangle indexes past the vertices") + for n in m.nodes: + if any(v >= len(m.vertices) for v in n.animated_vertices): + raise ABC6Error("node %r animates a vertex past the vertices" % n.name) + return m + + +def read_file(path) -> Model: + with open(path, "rb") as f: + return read(f.read()) + + +def _payload(m: Model, name: str) -> bytes: + w = Writer() + if name == "Header": + w.string(TOKEN) + w.string(m.command_string) + elif name == "Geometry": + w.vec3(m.bounds_min) + w.vec3(m.bounds_max) + w.u32(len(m.vertex_start) - 1) + w.pack("%dH" % len(m.vertex_start), *m.vertex_start) + w.u32(len(m.triangles)) + for t in m.triangles: + w.pack("6f3H3b", t.uvs[0][0], t.uvs[0][1], t.uvs[1][0], t.uvs[1][1], t.uvs[2][0], t.uvs[2][1], + *t.indices, *t.normal) + w.u32(len(m.vertices)) + w.u32(m.normal_vertex_count) + for v in m.vertices: + w.pack("3f3bB2H", *v.position, *v.normal, v.node, *v.replacements) + elif name == "Nodes": + for n in m.nodes: + w.vec3(n.bounds_min) + w.vec3(n.bounds_max) + w.string(n.name) + w.pack("HBI", n.index, n.flags, len(n.animated_vertices)) + if n.animated_vertices: + w.pack("%dH" % len(n.animated_vertices), *n.animated_vertices) + w.u32(n.child_count) + elif name == "Animation": + w.u32(len(m.animations)) + for a in m.animations: + w.string(a.name) + w.u32(a.length) + w.vec3(a.bounds_min) + w.vec3(a.bounds_max) + w.u32(len(a.keyframes)) + for k in a.keyframes: + w.u32(k.time) + w.vec3(k.bounds_min) + w.vec3(k.bounds_max) + w.string(k.command) + if len(a.tracks) != len(m.nodes): + raise ABC6Error("%s: %d tracks for %d nodes" % (a.name, len(a.tracks), len(m.nodes))) + for n, tr in zip(m.nodes, a.tracks): + if len(tr.keys) != len(a.keyframes): + raise ABC6Error("%s/%s: %d keys for %d keyframes" % (a.name, n.name, len(tr.keys), len(a.keyframes))) + for p, q in tr.keys: + w.pack("7f", *p, *q) + if n.animated_vertices: + if len(tr.vertex_bytes) != 3 * len(n.animated_vertices) * len(a.keyframes): + raise ABC6Error("%s/%s: vertex animation is the wrong size" % (a.name, n.name)) + w.raw(tr.vertex_bytes) + w.vec3(tr.scale) + w.vec3(tr.translation) + elif name == "AnimDims": + dims = m.anim_dims or [] + if len(dims) != len(m.animations): + raise ABC6Error("%d AnimDims for %d animations" % (len(dims), len(m.animations))) + for d in dims: + w.vec3(d) + elif name in m.extra_chunks: + return m.extra_chunks[name] + else: + raise ABC6Error("no bytes for chunk %r" % name) + return w.getvalue() + + +def write(m: Model) -> bytes: + w = Writer() + for i, name in enumerate(m.chunks): + body = _payload(m, name) + w.string(name) + last = i + 1 == len(m.chunks) + w.u32(END_OF_CHUNKS if last else w.tell() + 4 + len(body)) + w.raw(body) + return w.getvalue() + + +def write_file(m: Model, path): + with open(path, "wb") as f: + f.write(write(m)) + + +# --------------------------------------------------------------------- # +# The shared model +# --------------------------------------------------------------------- # + +def _mirror(p) -> Vec3: + return (p[0], p[1], -p[2]) + + +def _quat(q): + """A stored v6 quaternion in the engine's drawing space: z negated (the + conjugate and the mirror together), its length kept. LithTech 1 + interpolates the raw values (quat_Slerp, which takes a plain lerp when + the dot passes 1, as it does for a long one) and normalizes only when it + builds the matrix, as abc.quat_to_matrix does.""" + return (q[0], q[1], -q[2], q[3]) + + +def vertex_frame(track: Track, count: int, key: int) -> List[Vec3]: + """A track's animated vertex positions at a keyframe, decompressed, in + the node's space and the engine's drawing space.""" + sx, sy, sz = track.scale + tx, ty, tz = track.translation + b = track.vertex_bytes + base = key * count * 3 + return [_mirror((b[base + 3 * j] * sx + tx, b[base + 3 * j + 1] * sy + ty, b[base + 3 * j + 2] * sz + tz)) + for j in range(count)] + + +def _apply(M, p) -> Vec3: + return tuple(M[4 * i] * p[0] + M[4 * i + 1] * p[1] + M[4 * i + 2] * p[2] + M[4 * i + 3] for i in range(3)) + + +def _rotate(M, n) -> Vec3: + return tuple(M[4 * i] * n[0] + M[4 * i + 1] * n[1] + M[4 * i + 2] * n[2] for i in range(3)) + + +def to_abc(m: Model) -> abc.Model: + """The shared model (abc.Model), as abc.read gives a v9-12 file: one piece, + one LOD, nodes bound at frame 0 of animation 0, in the engine's space.""" + parents = m.node_parents() + nn = len(m.nodes) + first = m.animations[0] if m.animations else None + + # Each node's frame-0 transform: the bind. + identity = abc.quat_to_matrix((0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0)) + binds = [] + for i in range(nn): + if first is None or not first.keyframes: + local = identity + else: + p, q = first.tracks[i].keys[0] + local = abc.quat_to_matrix(_mirror(p), _quat(q)) + binds.append(local if parents[i] < 0 else abc.matrix_multiply(binds[parents[i]], local)) + + # Node-space positions: the stored ones, mirrored, and for an animated + # vertex frame 0 of animation 0's (the later of two entries wins). + local_pos = [_mirror(v.position) for v in m.vertices] + if first is not None and first.keyframes: + for i, n in enumerate(m.nodes): + if n.animated_vertices: + for j, p in enumerate(vertex_frame(first.tracks[i], len(n.animated_vertices), 0)): + local_pos[n.animated_vertices[j]] = p + + # LOD 0: the full-detail vertices, which are all the triangles use. + count = m.normal_vertex_count + if not 0 < count <= len(m.vertices) or any(max(t.indices) >= count for t in m.triangles): + count = len(m.vertices) + lod = abc.LOD() + for vi in range(count): + v = m.vertices[vi] + node = v.node if v.node < nn else 0 + M = binds[node] if nn else identity + n = _rotate(M, _mirror(tuple(c / 127.0 for c in v.normal))) + lod.vertices.append(abc.Vertex([abc.Weight(node, local_pos[vi], 1.0)], 0, _apply(M, local_pos[vi]), n)) + for t in m.triangles: + lod.triangles.append(abc.Triangle(tuple(t.indices), tuple(t.uvs))) + + out = abc.Model(version=12, command_string=m.command_string) + out.source_format = "ABC v6" + out.source_version = 6 + out.pieces.append(abc.Piece(name="Piece", lods=[lod])) + + nodes = [abc.Node(n.name, n.index, n.flags, binds[i], + animated_vertices=[v for v in n.animated_vertices if v < count]) + for i, n in enumerate(m.nodes)] + for i, p in enumerate(parents): + if p >= 0: + nodes[p].children.append(nodes[i]) + out.root = nodes[0] if nodes else None + # An animated vertex past LOD 0 (none is, in the files held) is dropped + # from the node's list; keep the frames in step with what is left. + keep = [[j for j, v in enumerate(n.animated_vertices) if v < count] for n in m.nodes] + + for ai, a in enumerate(m.animations): + dims = m.anim_dims[ai] if m.anim_dims and ai < len(m.anim_dims) else \ + tuple((a.bounds_max[c] - a.bounds_min[c]) / 2.0 for c in range(3)) + oa = abc.Animation(a.name, dims, keyframes=[abc.Keyframe(k.time, k.command) for k in a.keyframes]) + oa.node_transforms = [[(_mirror(p), _quat(q)) for p, q in tr.keys] for tr in a.tracks] + if any(n.animated_vertices for n in m.nodes): + oa.vertex_frames = [] + for i, n in enumerate(m.nodes): + if not n.animated_vertices: + oa.vertex_frames.append([]) + continue + frames = [vertex_frame(a.tracks[i], len(n.animated_vertices), k) for k in range(len(a.keyframes))] + oa.vertex_frames.append([[f[j] for j in keep[i]] for f in frames]) + out.animations.append(oa) + + # What abc.read gives for a v9-12 file with no child models: the model + # itself as ChildModels' first entry (save index 1, no relation) and its + # animations' bindings, which v6 has no use for and export_abc writes. + ident = ((0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0)) + out.child_models = [abc.ChildModel("", 1, [ident] * nn)] + out.anim_bindings = [[abc.AnimBinding(a.name, a.dims, (0.0, 0.0, 0.0)) for a in out.animations]] + return out diff --git a/plugins/blender/tests/blender_abc6.py b/plugins/blender/tests/blender_abc6.py new file mode 100644 index 00000000..857f5e63 --- /dev/null +++ b/plugins/blender/tests/blender_abc6.py @@ -0,0 +1,259 @@ +"""Imports each .abc v6 (Shogo, Blood 2, KISS) into Blender, checks the pose +the armature and the shape keys give against the shared model's, and +exports it again, as v12 and as v6. + + blender -b --factory-startup --python tests/blender_abc6.py -- \ + [--limit N] [--every K] [--keys N] + +For each model: + + * POSE: at every keyframe (up to --keys per animation, spread out), the + mesh Blender evaluates through the armature, brought back to LithTech + space, against abc.skinned_position on the shared model abc6.to_abc + gives (abc.pose_matrices, and abc.animated_vertices for the vertices a + node animates), every vertex. Within TOL_POS. The vertices a node + animates are also reported apart: they get there through the shape + keys import_abc makes of their frames. + * MIDPOINTS: the same halfway between keys, MEASURED and not asserted: + Blender lerps quaternion components where the engine slerps, which is + the same at keys and close between them. + * EXPORT: export_abc.save writes the armature as an .abc v12, which must + compare with the shared model as tests/blender_roundtrip.py compares a + v12 file with its export (v12 has no vertex animation, so the frames + are not in it). + * EXPORT v6: export_abc6.save writes it as LithTech 1's .abc v6, which + must be the file imported, byte for byte. + +Exits non-zero if any model fails. +""" + +import math +import os +import sys +import tempfile +import time +import traceback +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) +sys.path.insert(0, str(HERE)) + +import bpy # noqa: E402 +from mathutils import Vector # noqa: E402 +import io_scene_lithtech # noqa: E402 +from io_scene_lithtech import animation, convert, export_abc, export_abc6, import_abc # noqa: E402 +from io_scene_lithtech.lithtech import abc, abc6 # noqa: E402 +import blender_roundtrip # noqa: E402 + +TOL_POS = 2e-3 + + +def purge(): + blender_roundtrip.purge() + + +def posed_reference(m, anim, k, lod): + M = abc.pose_matrices(m, anim, k) + return [abc.skinned_position(m, v, M) for v in abc.animated_vertices(m, anim, k, lod)] + + +def mid_reference(m, anim, k, lod): + """Halfway between keys k and k+1: each node's pose lerped and slerped, + as the engine does, then each vertex skinned; an animated vertex's + position lerped.""" + parents = m.node_parents() + out = [] + for i, per in enumerate(anim.node_transforms): + (p0, q0), (p1, q1) = per[k], per[k + 1] + p = tuple(a + (b - a) * 0.5 for a, b in zip(p0, p1)) + d = sum(a * b for a, b in zip(q0, q1)) + if d < 0: + q1, d = tuple(-c for c in q1), -d + if d < 0.9995: + th = math.acos(min(1.0, d)) + s0, s1 = math.sin(0.5 * th) / math.sin(th), math.sin(0.5 * th) / math.sin(th) + else: + s0 = s1 = 0.5 + q = tuple(s0 * a + s1 * b for a, b in zip(q0, q1)) + local = abc.quat_to_matrix(p, q) + out.append(local if parents[i] < 0 else abc.matrix_multiply(out[parents[i]], local)) + verts = list(lod.vertices) + if anim.vertex_frames: + for i, node in enumerate(m.nodes): + if node.animated_vertices and anim.vertex_frames[i]: + f0, f1 = anim.vertex_frames[i][k], anim.vertex_frames[i][k + 1] + for j, vi in enumerate(node.animated_vertices): + p = tuple(a + (b - a) * 0.5 for a, b in zip(f0[j], f1[j])) + v = verts[vi] + verts[vi] = abc.Vertex([abc.Weight(w.node, p, w.bias) for w in v.weights], + v.replacement, v.location, v.normal) + return [abc.skinned_position(m, v, out) for v in verts] + + +def blender_positions(arm, obj, frame): + bpy.context.scene.frame_set(math.floor(frame), subframe=frame % 1.0) + bpy.context.view_layer.update() + dg = bpy.context.evaluated_depsgraph_get() + ev = obj.evaluated_get(dg) + me = ev.to_mesh() + to_arm = arm.matrix_world.inverted() @ obj.matrix_world + scale = arm.get("lt_scale", 1.0) or 1.0 + out = [convert.vec_from_blender(to_arm @ v.co, 1.0 / scale) for v in me.vertices] + ev.to_mesh_clear() + return out + + +def first_difference(a, b): + """Where two v6 models first differ, for the report.""" + for field in ("chunks", "command_string", "bounds_min", "bounds_max", "vertex_start", + "normal_vertex_count", "anim_dims"): + if getattr(a, field) != getattr(b, field): + return field + for name in ("triangles", "vertices", "nodes"): + la, lb = getattr(a, name), getattr(b, name) + if len(la) != len(lb): + return "%s: %d for %d" % (name, len(la), len(lb)) + for i, (x, y) in enumerate(zip(la, lb)): + if x != y: + return "%s %d: %r for %r" % (name, i, x, y) + for ai, (x, y) in enumerate(zip(a.animations, b.animations)): + for field in ("name", "length", "bounds_min", "bounds_max", "keyframes"): + if getattr(x, field) != getattr(y, field): + return "animation %d %s" % (ai, field) + for ni, (tx, ty) in enumerate(zip(x.tracks, y.tracks)): + for k, (kx, ky) in enumerate(zip(tx.keys, ty.keys)): + if kx != ky: + return "animation %d node %d key %d: %r for %r" % (ai, ni, k, kx, ky) + if tx != ty: + return "animation %d node %d vertex frames" % (ai, ni) + return "animations: %d for %d" % (len(a.animations), len(b.animations)) + + +def worst(got, want, skip): + return max((max(abs(g[c] - w[c]) for c in range(3)) + for i, (g, w) in enumerate(zip(got, want)) if i not in skip), default=0.0) + + +def main(argv): + args = argv[argv.index("--") + 1:] if "--" in argv else [] + limit = every = None + keys_per = 4 + paths = [] + i = 0 + while i < len(args): + if args[i] == "--limit": + limit = int(args[i + 1]); i += 2 + elif args[i] == "--every": + every = int(args[i + 1]); i += 2 + elif args[i] == "--keys": + keys_per = int(args[i + 1]); i += 2 + else: + paths.append(Path(args[i])); i += 1 + files = [] + for p in paths: + files += sorted(f for f in p.rglob("*") if f.suffix.lower() == ".abc") if p.is_dir() else [p] + files = [f for f in files if abc6.is_model(f.read_bytes())] + if every: + files = files[::every] + if limit: + files = files[:limit] + + io_scene_lithtech.register() + fps = bpy.context.scene.render.fps / bpy.context.scene.render.fps_base + tmp = Path(tempfile.mkdtemp(prefix="lt_abc6_")) + failed = poses = mids = v6_same = 0 + worst_key = worst_mid = worst_va = worst_va_mid = 0.0 + where_mid = "" + unit_mid = 0.0 # the worst midpoint between two unit-length keys + va_models = 0 + export_worst = {} + t0 = time.time() + for n, f in enumerate(files, 1): + purge() + try: + m = abc6.to_abc(abc6.read(f.read_bytes())) + lod = m.pieces[0].lods[0] + arm = import_abc.load(bpy.context, str(f), import_textures=False, child_animations=False, + import_lods=False, import_sockets=False) + obj = next(o for o in arm.children if o.type == 'MESH') + if len(obj.data.vertices) != len(lod.vertices): + raise RuntimeError("%d mesh vertices for %d" % (len(obj.data.vertices), len(lod.vertices))) + va = {vi for node in m.nodes for vi in node.animated_vertices} + va_models += bool(va) + actions = {a.get("lt_index"): a for a in bpy.data.actions if a.get("lt_model") == arm.name} + problems = [] + for ai, anim in enumerate(m.animations): + act = actions.get(ai) + if act is None or not anim.keyframes: + problems.append("no action for %s" % anim.name) + continue + animation.select_action(bpy.context, arm, act) + nk = len(anim.keyframes) + ks = sorted({round(j * (nk - 1) / max(1, keys_per - 1)) for j in range(min(nk, keys_per))}) + for k in ks: + got = blender_positions(arm, obj, anim.keyframes[k].time * fps / 1000.0) + want = posed_reference(m, anim, k, lod) + d = worst(got, want, ()) + poses += 1 + worst_key = max(worst_key, d) + if d > TOL_POS: + problems.append("%s key %d: off by %g" % (anim.name, k, d)) + if va: + worst_va = max(worst_va, max(max(abs(got[i][c] - want[i][c]) for c in range(3)) for i in va)) + if k + 1 < nk and anim.keyframes[k + 1].time > anim.keyframes[k].time: + t = (anim.keyframes[k].time + anim.keyframes[k + 1].time) / 2.0 + got = blender_positions(arm, obj, t * fps / 1000.0) + mref = mid_reference(m, anim, k, lod) + dm = worst(got, mref, va) + if va: + worst_va_mid = max(worst_va_mid, max(max(abs(got[i][c] - mref[i][c]) for c in range(3)) + for i in va)) + if dm > worst_mid: + worst_mid, where_mid = dm, "%s %s after key %d" % (f.name, anim.name, k) + unit = all(abs(math.sqrt(sum(c * c for c in per[j][1])) - 1.0) < 1e-3 + for per in anim.node_transforms for j in (k, k + 1)) + if unit: + unit_mid = max(unit_mid, dm) + mids += 1 + animation.select_action(bpy.context, arm, None) + out = tmp / "out.abc" + export_abc.save(bpy.context, str(out), armature=arm, normalize_weights=False) + d = blender_roundtrip.compare(m, abc.read_file(out)) + for key, v in d.max.items(): + if v[0] > export_worst.get(key, (0.0,))[0]: + export_worst[key] = (v[0], "%s: %s" % (f.name, v[1])) + problems += ["export: " + e for e in d.errors] + out6 = tmp / "out6.abc" + export_abc6.save(bpy.context, str(out6), armature=arm) + if out6.read_bytes() == f.read_bytes(): + v6_same += 1 + else: + problems.append("v6 export differs: " + first_difference(abc6.read(out6.read_bytes()), + abc6.read(f.read_bytes()))) + except Exception: + failed += 1 + print("FAIL %s: exception" % f) + traceback.print_exc() + continue + if problems: + failed += 1 + print("FAIL %s: %d problems; first: %s" % (f, len(problems), "; ".join(problems[:3]))) + if n % 25 == 0: + print("... %d/%d, %d failed, %.0fs" % (n, len(files), failed, time.time() - t0)) + print() + print("export, worst differences:") + for key in sorted(export_worst): + print(" %-11s %-10.3g %s" % (key, export_worst[key][0], export_worst[key][1])) + print("%d v6 models, %d failed: %d keyframe poses (worst %.3g), %d midpoints measured (worst %.3g, %s; " + "%.3g where both keys are unit length); %d models with vertex animation (those vertices worst %.3g " + "at keys, %.3g halfway); v6 export identical for %d of %d; %.0fs" + % (len(files), failed, poses, worst_key, mids, worst_mid, where_mid or "-", unit_mid, va_models, + worst_va, worst_va_mid, v6_same, len(files), time.time() - t0)) + return 1 if failed or not files else 0 + + +if __name__ == "__main__": + code = main(sys.argv) + sys.stdout.flush() + os._exit(code) diff --git a/plugins/blender/tests/blender_abc6_edit.py b/plugins/blender/tests/blender_abc6_edit.py new file mode 100644 index 00000000..ce17991b --- /dev/null +++ b/plugins/blender/tests/blender_abc6_edit.py @@ -0,0 +1,407 @@ +"""Edits .abc v6 models in Blender and checks what export_abc6 writes. + + blender -b --factory-startup --python tests/blender_abc6_edit.py -- \\ + --va --plain [--out DIR] + +Each case imports a model, makes one edit, exports v6, and checks two +things: + + * WHAT CHANGED: the file read back against the original, field by field. + Only what the edit touches may differ (and bounds, which are worked out + again when what they bound has changed). + * WHAT BLENDER SHOWS: at every keyframe of the edited animation, the mesh + Blender evaluates (armature and shape keys) against the exported file + posed by the shared model's own functions (abc6.to_abc, + abc.pose_matrices, abc.animated_vertices), every vertex: within TOL_POS, + or within a vertex-animation frame's quantization for the vertices a + node animates. + +The cases: + + operator File > Import, then File > Export with its default, "As + Imported": the v6 file back byte for byte; and Version 12 + still writes a v12 file. + va-nudge one animated vertex moved 1 unit in one frame's shape key: + that frame's three bytes change, nothing else in the tracks. + va-far the same vertex moved 1000 units: no longer fits the stored + scale, so the node's frames get a new scale and translation. + bone a bone turned 10 degrees at one key of an animation that is + not the first: that key alone changes, written unit length. + vertex a vertex of the plain model moved: its floats change, the + reduced-detail vertices stay (they refer to it by number). + face a face of the plain model deleted: the reduced-detail + vertices are dropped, NumLODs 0, replacements 0. + new-va vertex animation made in Blender on the plain model: shape + keys moving one vertex in one animation; its node gains flag + 4 and lists every vertex on it, as shipped deformation nodes + do. + +With --out the edited files are kept there, for the engine +(Lithtech_Disassembly's tools/rezview/pose_check.py) to pose. +Exits non-zero if a case fails. +""" + +import math +import os +import shutil +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) +sys.path.insert(0, str(HERE)) + +import bpy # noqa: E402 +from mathutils import Quaternion, Vector # noqa: E402 +import io_scene_lithtech # noqa: E402 +from io_scene_lithtech import animation, convert, export_abc6, import_abc # noqa: E402 +from io_scene_lithtech.lithtech import abc, abc6 # noqa: E402 +import blender_roundtrip # noqa: E402 +from blender_abc6 import blender_positions # noqa: E402 + +TOL_POS = 2e-3 + + +def load(path): + blender_roundtrip.purge() + arm = import_abc.load(bpy.context, str(path), import_textures=False, child_animations=False, + import_lods=False, import_sockets=False) + obj = next(o for o in arm.children if o.type == 'MESH') + return arm, obj + + +def actions_of(arm): + return sorted((a for a in bpy.data.actions if a.get("lt_model") == arm.name), key=lambda a: a["lt_index"]) + + +def export(arm, out): + animation.select_action(bpy.context, arm, None) + _, _, notes = export_abc6.save(bpy.context, str(out), armature=arm) + return abc6.read(Path(out).read_bytes()), notes + + +def pose_against_file(arm, obj, act, path, shape_values=None): + """Worst distance, at every keyframe of act, between Blender's mesh and + the exported file posed by the shared model: (skeletal, animated).""" + sm = abc6.to_abc(abc6.read(Path(path).read_bytes())) + lod = sm.pieces[0].lods[0] + ai = act["lt_index"] + anim = sm.animations[ai] + va = {v for n in sm.nodes for v in n.animated_vertices} + fps = bpy.context.scene.render.fps / bpy.context.scene.render.fps_base + animation.select_action(bpy.context, arm, act) + worst_s = worst_v = 0.0 + for k, kf in enumerate(anim.keyframes): + if shape_values is not None: + shape_values(k) + got = blender_positions(arm, obj, kf.time * fps / 1000.0) + M = abc.pose_matrices(sm, anim, k) + want = [abc.skinned_position(sm, v, M) for v in abc.animated_vertices(sm, anim, k, lod)] + for i, (g, w) in enumerate(zip(got, want)): + d = max(abs(g[c] - w[c]) for c in range(3)) + if i in va: + worst_v = max(worst_v, d) + else: + worst_s = max(worst_s, d) + animation.select_action(bpy.context, arm, None) + return worst_s, worst_v + + +def diff_fields(a, b): + """The parts of two v6 models that differ, as names.""" + out = [] + for field in ("chunks", "command_string", "vertex_start", "normal_vertex_count", "anim_dims"): + if getattr(a, field) != getattr(b, field): + out.append(field) + if (a.bounds_min, a.bounds_max) != (b.bounds_min, b.bounds_max): + out.append("bounds") + for name in ("vertices", "triangles"): + la, lb = getattr(a, name), getattr(b, name) + if len(la) != len(lb): + out.append("%s count" % name) + else: + out += ["%s %d" % ({"vertices": "vertex"}.get(name, name[:-1]), i) + for i, (x, y) in enumerate(zip(la, lb)) if x != y] + for i, (x, y) in enumerate(zip(a.nodes, b.nodes)): + if (x.bounds_min, x.bounds_max) != (y.bounds_min, y.bounds_max): + out.append("node %d bounds" % i) + for field in ("name", "index", "flags", "animated_vertices", "child_count"): + if getattr(x, field) != getattr(y, field): + out.append("node %d %s" % (i, field)) + for ai, (x, y) in enumerate(zip(a.animations, b.animations)): + if (x.name, x.length) != (y.name, y.length): + out.append("animation %d header" % ai) + if (x.bounds_min, x.bounds_max) != (y.bounds_min, y.bounds_max) or \ + [(k.bounds_min, k.bounds_max) for k in x.keyframes] != [(k.bounds_min, k.bounds_max) for k in y.keyframes]: + out.append("animation %d bounds" % ai) + if [(k.time, k.command) for k in x.keyframes] != [(k.time, k.command) for k in y.keyframes]: + out.append("animation %d keyframes" % ai) + for ni, (tx, ty) in enumerate(zip(x.tracks, y.tracks)): + for k, (kx, ky) in enumerate(zip(tx.keys, ty.keys)): + if kx != ky: + out.append("animation %d node %d key %d" % (ai, ni, k)) + if (tx.scale, tx.translation) != (ty.scale, ty.translation): + out.append("animation %d node %d scale" % (ai, ni)) + if tx.vertex_bytes != ty.vertex_bytes: + nb = sum(p != q for p, q in zip(tx.vertex_bytes, ty.vertex_bytes)) + \ + abs(len(tx.vertex_bytes) - len(ty.vertex_bytes)) + out.append("animation %d node %d bytes (%d)" % (ai, ni, nb)) + if len(a.animations) != len(b.animations): + out.append("animation count") + return out + + +def only(changed, allowed): + """The changes not in allowed (prefixes).""" + return [c for c in changed if not any(c == p or c.startswith(p + " ") for p in allowed)] + + +def va_vertex(orig): + """A node with animated vertices, its first vertex that no other entry + repeats, and an action that has frames for it.""" + for i, n in enumerate(orig.nodes): + for j, v in enumerate(n.animated_vertices): + if n.animated_vertices.count(v) == 1: + return i, j, v + raise RuntimeError("no animated vertex") + + +def main(argv): + args = argv[argv.index("--") + 1:] if "--" in argv else [] + va_path = plain_path = out_dir = None + i = 0 + while i < len(args): + if args[i] == "--va": + va_path = Path(args[i + 1]); i += 2 + elif args[i] == "--plain": + plain_path = Path(args[i + 1]); i += 2 + elif args[i] == "--out": + out_dir = Path(args[i + 1]); i += 2 + else: + raise SystemExit("unknown argument %r" % args[i]) + io_scene_lithtech.register() + tmp = Path(tempfile.mkdtemp(prefix="lt_abc6_edit_")) + if out_dir: + out_dir.mkdir(parents=True, exist_ok=True) + results = [] + + def report(case, problems, detail): + results.append((case, problems)) + print("%-9s %s %s" % (case, "FAIL" if problems else "ok", detail)) + for p in problems[:6]: + print(" " + p) + + def keep(path, case): + if out_dir: + shutil.copy(path, out_dir / ("%s_%s" % (case, path.name))) + + if va_path: + orig = abc6.read(va_path.read_bytes()) + ni, j, v = va_vertex(orig) + for case, dist in (("va-nudge", 1.0), ("va-far", 1000.0)): + arm, obj = load(va_path) + acts = actions_of(arm) + ai = 1 if len(acts) > 1 else 0 + act, k = acts[ai], min(1, len(orig.animations[ai].keyframes) - 1) + kb = obj.data.shape_keys.key_blocks[import_abc.shape_key_name(act.name, k)] + kb.data[v].co = kb.data[v].co + convert.vec_to_blender((dist, 0.0, 0.0)) + out = tmp / va_path.name + got, notes = export(arm, out) + keep(out, case) + changed = diff_fields(got, orig) + allowed = ["animation %d node %d bytes" % (ai, ni), "animation %d bounds" % ai] + if case == "va-far": + allowed.append("animation %d node %d scale" % (ai, ni)) + problems = ["unexpected: " + c for c in only(changed, allowed)] + if not any(c.startswith("animation %d node %d bytes" % (ai, ni)) for c in changed): + problems.append("the frame did not change") + sk, svv = pose_against_file(arm, obj, act, out) + tr = got.animations[ai].tracks[ni] + quant = max(tr.scale) * 0.87 + 1e-3 # half a step on each axis, rotated + if sk > TOL_POS: + problems.append("skeletal vertices off by %g" % sk) + if svv > quant: + problems.append("animated vertices off by %g (quantisation %g)" % (svv, quant)) + if case == "va-nudge": + nb = [c for c in changed if c.startswith("animation %d node %d bytes" % (ai, ni))] + if nb != ["animation %d node %d bytes (%d)" % (ai, ni, int(nb[0].split("(")[1][:-1]))] or \ + int(nb[0].split("(")[1][:-1]) > 3: + problems.append("more than one entry's bytes changed: %s" % nb) + report(case, problems, "%s, animation %d key %d, vertex %d on node %d: %s; Blender against the file " + "%.2g skeletal, %.2g animated (step %.3g)" + % (va_path.name, ai, k, v, ni, ", ".join(changed) or "nothing changed", sk, svv, max(tr.scale))) + + if va_path: + # The operators, as the File menu runs them: "As Imported" is v6. + blender_roundtrip.purge() + bpy.ops.import_scene.lithtech_abc(filepath=str(va_path), import_textures=False) + out = tmp / ("op_" + va_path.name) + res = bpy.ops.export_scene.lithtech_abc(filepath=str(out)) + same = out.exists() and out.read_bytes() == va_path.read_bytes() + res12 = bpy.ops.export_scene.lithtech_abc(filepath=str(tmp / "op12.abc"), abc_version='12') + v12 = (tmp / "op12.abc").exists() and not abc6.is_model((tmp / "op12.abc").read_bytes()) + report("operator", [] if same and v12 and res == {'FINISHED'} and res12 == {'FINISHED'} else + ["export %s, identical %s; version 12 %s, written as v12 %s" % (res, same, res12, v12)], + "File > Import then File > Export of %s: As Imported writes it back byte for byte (%s); " + "Version 12 still writes v12 (%s)" % (va_path.name, same, v12)) + + if plain_path: + orig = abc6.read(plain_path.read_bytes()) + # bone + arm, obj = load(plain_path) + acts = actions_of(arm) + ai = 1 if len(acts) > 1 else 0 + act = acts[ai] + k = min(1, len(orig.animations[ai].keyframes) - 1) + bone = arm.data.bones[min(3, len(arm.data.bones) - 1)] + node = bone["lt_index"] + fps = bpy.context.scene.render.fps / bpy.context.scene.render.fps_base + frame = orig.animations[ai].keyframes[k].time * fps / 1000.0 + path = 'pose.bones["%s"].rotation_quaternion' % bpy.utils.escape_identifier(bone.name) + fcs = {fc.array_index: fc for fc in export_abc6.export_abc._action_fcurves(act, arm) if fc.data_path == path} + kps = [min(fcs[c].keyframe_points, key=lambda p: abs(p.co[0] - frame)) for c in range(4)] + q = Quaternion([p.co[1] for p in kps]) + q = q @ Quaternion((1.0, 0.0, 0.0), math.radians(10.0)) + for c in range(4): + kps[c].co[1] = q[c] + fcs[c].update() + out = tmp / plain_path.name + got, notes = export(arm, out) + keep(out, "bone") + changed = diff_fields(got, orig) + problems = ["unexpected: " + c for c in only(changed, ["animation %d node %d key %d" % (ai, node, k), + "animation %d bounds" % ai])] + if "animation %d node %d key %d" % (ai, node, k) not in changed: + problems.append("the key did not change") + qn = math.sqrt(sum(c * c for c in got.animations[ai].tracks[node].keys[k][1])) + if abs(qn - 1.0) > 1e-5: + problems.append("the new key is not unit length (%g)" % qn) + sk, _ = pose_against_file(arm, obj, act, out) + if sk > TOL_POS: + problems.append("vertices off by %g" % sk) + report("bone", problems, "%s, animation %d key %d, node %d: %s; Blender against the file %.2g" + % (plain_path.name, ai, k, node, ", ".join(changed), sk)) + + # vertex + arm, obj = load(plain_path) + vi = 0 + obj.data.vertices[vi].co = obj.data.vertices[vi].co + Vector((0.0, 0.0, 1.0)) + got, notes = export(arm, out) + keep(out, "vertex") + changed = diff_fields(got, orig) + # Blender keeps custom normals relative to their faces, so moving a + # vertex turns its neighbors' corner normals too, and those + # vertices take Blender's normals: allowed, position unchanged. The + # faces around it get new face normals. + near = {i for t in orig.triangles if vi in t.indices for i in t.indices} + problems = ["unexpected: " + c for c in only(changed, ["vertex %d" % i for i in near] + + ["triangle %d" % i for i, t in enumerate(orig.triangles) + if vi in t.indices] + ["bounds", "node", "animation"])] + if any(c.startswith("animation") and "bounds" not in c for c in changed): + problems.append("animations changed: %s" % [c for c in changed if c.startswith("animation")][:3]) + if any(c.startswith("node") and "bounds" not in c for c in changed): + problems.append("nodes changed") + if "vertex %d" % vi not in changed: + problems.append("the vertex did not change") + for i in near - {vi}: + if got.vertices[i].position != orig.vertices[i].position: + problems.append("neighbour %d moved" % i) + for i, t in enumerate(orig.triangles): + if vi in t.indices and (got.triangles[i].uvs, got.triangles[i].indices) != (t.uvs, t.indices): + problems.append("triangle %d changed beyond its normal" % i) + if got.vertex_start != orig.vertex_start or len(got.vertices) != len(orig.vertices): + problems.append("the reduced-detail vertices were not kept") + sk, _ = pose_against_file(arm, obj, actions_of(arm)[0], out) + if sk > TOL_POS: + problems.append("vertices off by %g" % sk) + report("vertex", problems, "%s, vertex %d up 1: %s; reduced-detail vertices %d of %d kept; " + "Blender against the file %.2g" + % (plain_path.name, vi, ", ".join(c for c in changed if "bounds" not in c) + ", bounds", + len(got.vertices) - got.normal_vertex_count, len(orig.vertices) - orig.normal_vertex_count, sk)) + + # face + arm, obj = load(plain_path) + import bmesh + bm = bmesh.new() + bm.from_mesh(obj.data) + bm.faces.ensure_lookup_table() + bmesh.ops.delete(bm, geom=[bm.faces[0]], context='FACES_ONLY') + bm.to_mesh(obj.data) + bm.free() + got, notes = export(arm, out) + keep(out, "face") + problems = [] + if len(got.triangles) != len(orig.triangles) - 1: + problems.append("%d triangles for %d" % (len(got.triangles), len(orig.triangles) - 1)) + if got.triangles != orig.triangles[1:]: + problems.append("the other triangles changed") + if got.vertex_start != [0] or len(got.vertices) != got.normal_vertex_count: + problems.append("the reduced-detail vertices were kept") + if any(v.replacements != (0, 0) for v in got.vertices): + problems.append("replacements kept") + if [v.position for v in got.vertices] != [v.position for v in orig.vertices[:orig.normal_vertex_count]]: + problems.append("vertex positions changed") + sk, _ = pose_against_file(arm, obj, actions_of(arm)[0], out) + if sk > TOL_POS: + problems.append("vertices off by %g" % sk) + report("face", problems, "%s, face 0 deleted: %d triangles, NumLODs %d, notes: %s; Blender against " + "the file %.2g" % (plain_path.name, len(got.triangles), len(got.vertex_start) - 1, + "; ".join(notes) or "-", sk)) + + # new-va + arm, obj = load(plain_path) + acts = actions_of(arm) + ai = 1 if len(acts) > 1 else 0 + act = acts[ai] + nk = len(orig.animations[ai].keyframes) + obj.shape_key_add(name="Basis", from_mix=False) + vi = 5 + names = [] + for k in range(nk): + kb = obj.shape_key_add(name=import_abc.shape_key_name(act.name, k), from_mix=False) + kb.value = 0.0 + # Rising to 4 units at the last keyframe. + lift = 4.0 * k / max(1, nk - 1) + kb.data[vi].co = kb.data[vi].co + convert.vec_to_blender((0.0, lift, 0.0)) + names.append(kb.name) + node_of = orig.vertices[vi].node + got, notes = export(arm, out) + keep(out, "new-va") + changed = diff_fields(got, orig) + problems = ["unexpected: " + c for c in only(changed, ["node %d flags" % node_of, + "node %d animated_vertices" % node_of, + "animation"])] + own = [v for v in range(orig.normal_vertex_count) if orig.vertices[v].node == node_of] + if got.nodes[node_of].animated_vertices != own: + problems.append("node %d animates %r, not its %d vertices" % (node_of, got.nodes[node_of].animated_vertices[:8], len(own))) + if not got.nodes[node_of].flags & abc6.NODE_DEFORMATION: + problems.append("no deformation flag") + keys_changed = [c for c in changed if " key " in c] + if keys_changed: + problems.append("bone keys changed: %s" % keys_changed[:3]) + + def values(k): + for i, n in enumerate(names): + obj.data.shape_keys.key_blocks[n].value = 1.0 if i == k else 0.0 + sk, svv = pose_against_file(arm, obj, act, out, values) + tr = got.animations[ai].tracks[node_of] + quant = max(tr.scale) * 0.87 + 1e-3 + if sk > TOL_POS: + problems.append("skeletal vertices off by %g" % sk) + if svv > quant: + problems.append("the animated vertex off by %g (quantisation %g)" % (svv, quant)) + report("new-va", problems, "%s, vertex %d lifted in animation %d: node %d flags %d, animates its %d " + "vertices; Blender against the file %.2g skeletal, %.2g animated (step %.3g)" + % (plain_path.name, vi, ai, node_of, got.nodes[node_of].flags, + len(got.nodes[node_of].animated_vertices), sk, svv, max(tr.scale))) + + failed = sum(1 for _, p in results if p) + print("%d cases, %d failed" % (len(results), failed)) + return 1 if failed or not results else 0 + + +if __name__ == "__main__": + code = main(sys.argv) + sys.stdout.flush() + os._exit(code) diff --git a/plugins/blender/tests/roundtrip_abc6.py b/plugins/blender/tests/roundtrip_abc6.py new file mode 100755 index 00000000..619c10c7 --- /dev/null +++ b/plugins/blender/tests/roundtrip_abc6.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Round-trip every LithTech 1 .abc (v6) under some folders: read it, +convert it to the shared model, write it back, compare bytes. No Blender. + + roundtrip_abc6.py ... (folders of .abc files extracted from + Shogo's, Blood 2's or KISS's .rez) + +Reports how many came back byte-identical, how many carry chunks v6 doesn't +define (KISS's TransformInfo, kept as stored), and lists every failure; +.abc files of other versions are counted and skipped. +""" +import sys +import traceback +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from lithtech import abc6 # noqa: E402 + + +def main(): + dirs = [Path(a) for a in sys.argv[1:]] + if not dirs: + sys.exit(__doc__) + files = sorted(p for d in dirs for p in d.rglob("*") if p.suffix.lower() == ".abc") + same = extra = other = 0 + failures = [] + for p in files: + data = p.read_bytes() + if not abc6.is_model(data): + other += 1 + continue + try: + m = abc6.read(data) + abc6.to_abc(m) + out = abc6.write(m) + except Exception as e: + failures.append((p, "%s: %s" % (type(e).__name__, e))) + if len(failures) <= 3: + traceback.print_exc() + continue + extra += bool(m.extra_chunks) + if out == data: + same += 1 + else: + failures.append((p, "differs (%d vs %d bytes)" % (len(out), len(data)))) + total = len(files) - other + print("%d of %d v6 models byte-identical (%d with chunks v6 doesn't define); %d other .abc skipped" + % (same, total, extra, other)) + for p, why in failures: + print("FAIL %s: %s" % (p, why)) + sys.exit(1 if failures or not total else 0) + + +if __name__ == "__main__": + main() diff --git a/tools/lithrez/CMakeLists.txt b/tools/lithrez/CMakeLists.txt index ae5c874e..bb65283f 100644 --- a/tools/lithrez/CMakeLists.txt +++ b/tools/lithrez/CMakeLists.txt @@ -32,6 +32,7 @@ add_library(lithtech STATIC core/src/tga.cpp core/src/bmp.cpp core/src/abc.cpp + core/src/abc6.cpp core/src/ltb.cpp core/src/ps2ltb.cpp ${CMAKE_CURRENT_BINARY_DIR}/ps2ltb_names.cpp diff --git a/tools/lithrez/README.md b/tools/lithrez/README.md index 6109f8cf..ef42f832 100644 --- a/tools/lithrez/README.md +++ b/tools/lithrez/README.md @@ -12,7 +12,8 @@ they build with clang, gcc or MSVC on Linux, macOS and Windows. (PCX, TGA and BMP images preview and thumbnail too), animated sprites, lip-sync curves played with their voice lines, texture effects run as the engine runs them, render styles, fonts, a - model preview for `.abc` (NOLF, AvP2) and `.ltb` (NOLF2, Contract + model preview for `.abc` (NOLF, AvP2, and LithTech 1's v6: Shogo, Blood 2, + KISS) and `.ltb` (NOLF2, Contract J.A.C.K., Tron 2.0, and NOLF on the PS2) models, a sound player, and a string table editor for `CRes.dll`. - **`core/`**: the shared library (`lithtech::rez`, `lithtech::dtx`, @@ -76,6 +77,15 @@ These read the game archives (never committed), so they aren't part of (docs/RENDERSTYLE.md; all read but the version 2 files the games refuse too). `renderstyle_test` and `shader_test` (in ctest) check files made in the test. +- `abc6_verify [--dump ]`: every `.abc` v6 under a folder (the + Shogo and Blood 2 demos' `SHOGODEM.REZ` and `B2Demo.rez`: 401 models, all + read; the games, Blood 2 with The Nightmare Levels and KISS Psycho + Circus: 1,645, all read) through `lithtech::abc6`, checking that each vertex skins back to + its stored position and the first key is the bind pose; `--dump` writes + every vertex posed at every keyframe and halfway between, which matched an + independent LithTech 1 reference on all 20,677 poses (worst 0.00012). + `abc6_test` (in ctest) checks a hand-made v6 file value by value, + including a vertex-animated node and a non-unit quaternion key. - `dtx_decode_all`, `abc_verify`, `ltb_verify`, `ps2ltb_verify`: textures and models (`ps2ltb_verify --dump` against plugins/blender's `tests/ps2ltb_dump.py` compares the C++ and Python PS2 readers). diff --git a/tools/lithrez/core/include/lithtech/abc.hpp b/tools/lithrez/core/include/lithtech/abc.hpp index f3ceb852..6542a960 100644 --- a/tools/lithrez/core/include/lithtech/abc.hpp +++ b/tools/lithrez/core/include/lithtech/abc.hpp @@ -1,6 +1,7 @@ // LithTech .abc models, versions 9-12 (NOLF, AvP2, AvP2 Primal Hunt): // reading, for previews. The C++ twin of plugins/blender's lithtech/abc.py; -// plugins/blender/docs/ABC.md has the format and the evidence. +// plugins/blender/docs/ABC.md has the format and the evidence. Version 6 +// (Shogo, Blood 2, KISS) is lithtech::abc6, converted to this Model. #pragma once @@ -39,6 +40,10 @@ struct Node { uint8_t flags = 0; Mat4 bind{}; // node -> model space at the bind pose int parent = -1; // index into Model::nodes + // Vertex animation, which only .abc v6 has (lithtech::abc6): the vertices + // of pieces[0].lods[0] whose positions this node keys per keyframe, in + // Animation::vertex_frames order. Empty in versions 9-12. + std::vector animated_vertices; }; struct Keyframe { uint32_t time; std::string command; }; @@ -51,6 +56,9 @@ struct Animation { std::vector keyframes; // transforms[node][key], relative to the parent node std::vector>> transforms; + // vertex_frames[node][key][i]: the node-space position of the node's + // animated_vertices[i] at that key (.abc v6 only; empty otherwise). + std::vector>> vertex_frames; uint32_t length_ms() const { return keyframes.empty() ? 0 : keyframes.back().time; } }; @@ -78,5 +86,12 @@ std::vector pose(const Model& m, const Animation& a, float t_ms); std::vector bind_pose(const Model& m); // A vertex placed by its weights: sum of matrix @ (x, y, z, bias). Vec3 skin(const Vertex& v, const std::vector& matrices); +// For a model whose nodes animate vertices (.abc v6): moves each animated +// vertex of `lod` (a copy of pieces[0].lods[0]) to its keyed node-space +// position at time t, interpolated linearly between keys as pose() is, by +// setting its one weight's location; skin() then places it as usual. A +// later entry for the same vertex wins. Does nothing for other models. +void animate(const Model& m, const Animation& a, float t_ms, LOD& lod); +bool has_vertex_animation(const Model& m); } // namespace lithtech::abc diff --git a/tools/lithrez/core/include/lithtech/abc6.hpp b/tools/lithrez/core/include/lithtech/abc6.hpp new file mode 100644 index 00000000..c7700812 --- /dev/null +++ b/tools/lithrez/core/include/lithtech/abc6.hpp @@ -0,0 +1,92 @@ +// LithTech 1's .abc models, version 6 (Shogo, Blood 2, KISS Psycho +// Circus): reading, for previews, and conversion to abc::Model, the shared +// structure the preview draws. The C++ twin of plugins/blender's +// lithtech/abc6.py, whose module notes have the format and the evidence. +// +// In brief: one mesh, each vertex on one node, its position in that node's +// space at frame 0 of animation 0; keyframe rotations stored conjugated; +// every model drawn mirrored in z. to_abc gives each vertex one weight of +// bias 1, binds each node at frame 0, and negates z of positions and +// quaternions (the conjugate and the mirror together). Quaternions keep +// their stored length: thousands in the shipped models are not unit, and +// LithTech 1 interpolates the raw values and normalizes only building the +// matrix, as abc::quat_to_matrix and abc::pose do. A "deformation" node +// also keys its listed vertices per keyframe, as bytes decompressed by the +// track's scale and translation; to_abc carries them in +// Node::animated_vertices and Animation::vertex_frames, which abc::animate +// applies. +#pragma once + +#include "lithtech/abc.hpp" + +#include +#include +#include +#include + +namespace lithtech::abc6 { + +using Error = abc::Error; +using Vec3 = abc::Vec3; +using Quat = abc::Quat; + +enum NodeFlags : uint8_t { NodeNull = 1, NodeTris = 2, NodeDeformation = 4 }; + +struct Triangle { + float uv[3][2]; // per corner, v down as in DTX + uint16_t indices[3]; // into Model::vertices + std::array normal; // face normal, over 127 +}; + +struct Vertex { + Vec3 position; // in its node's space (see above) + std::array normal; // over 127 + uint8_t node = 0; + uint16_t replacements[2] = {0, 0}; +}; + +struct Node { + Vec3 bounds_min, bounds_max; + std::string name; + uint16_t index = 0; + uint8_t flags = 0; + std::vector animated_vertices; // the "MD" list + uint32_t child_count = 0; + int parent = -1; // index into Model::nodes +}; + +struct Track { + std::vector> keys; // as stored + std::vector vertex_bytes; // 3 * animated vertices * keyframes + Vec3 scale{1, 1, 1}, translation{0, 0, 0}; +}; + +struct Animation { + std::string name; + uint32_t length = 0; + Vec3 bounds_min, bounds_max; + std::vector keyframes; + std::vector tracks; // one per node +}; + +struct Model { + std::string command_string; + Vec3 bounds_min, bounds_max; + std::vector vertex_start; + std::vector triangles; + uint32_t normal_vertex_count = 0; // the full-detail vertices, first in `vertices` + std::vector vertices; + std::vector nodes; // depth first, parent first + std::vector animations; + std::vector anim_dims; // empty with no AnimDims chunk +}; + +// A v6 file: its first chunk is Header and starts with +// "MonolithExport Model File v6". +bool is_model(const std::vector& data); +Model read(const std::vector& data); +// The shared model, as abc::read gives a v9-12 file: one piece, one LOD, +// in the engine's drawing space. +abc::Model to_abc(const Model& m); + +} // namespace lithtech::abc6 diff --git a/tools/lithrez/core/src/abc.cpp b/tools/lithrez/core/src/abc.cpp index 2797e33c..5a10e468 100644 --- a/tools/lithrez/core/src/abc.cpp +++ b/tools/lithrez/core/src/abc.cpp @@ -187,11 +187,17 @@ Model read(const std::vector& d) { return m; } +// Scaled by 2/|q|^2, as every LithTech engine's quat_ConvertToMatrix is +// (LithTech 1's quaternion.h, NOLF's sharedcommon.cpp, Jupiter's +// ltquatbase.cpp), so a quaternion needn't be unit: .abc v6 stores +// thousands that aren't (abc6.hpp). For a unit one it is the plain formula. Mat4 quat_to_matrix(const Vec3& p, const Quat& q) { float x = q[0], y = q[1], z = q[2], w = q[3]; - return {1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w), p[0], - 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w), p[1], - 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y), p[2], + float n = x * x + y * y + z * z + w * w; + float s = n > 0 ? 2 / n : 0; + return {1 - s * (y * y + z * z), s * (x * y - z * w), s * (x * z + y * w), p[0], + s * (x * y + z * w), 1 - s * (x * x + z * z), s * (y * z - x * w), p[1], + s * (x * z - y * w), s * (y * z + x * w), 1 - s * (x * x + y * y), p[2], 0, 0, 0, 1}; } @@ -232,10 +238,12 @@ Quat slerp(Quat a, Quat b, float t) { } } // namespace -std::vector pose(const Model& m, const Animation& a, float t) { +namespace { +// The two keys around time t and how far between them. +void key_span(const Animation& a, float t, std::size_t& k0, std::size_t& k1, float& f) { std::size_t nk = a.keyframes.size(); - std::size_t k0 = 0, k1 = 0; - float f = 0; + k0 = k1 = 0; + f = 0; if (nk > 1) { if (t <= a.keyframes.front().time) { k0 = k1 = 0; @@ -248,6 +256,14 @@ std::vector pose(const Model& m, const Animation& a, float t) { f = span > 0 ? (t - a.keyframes[k0].time) / span : 0; } } +} +} // namespace + +std::vector pose(const Model& m, const Animation& a, float t) { + std::size_t nk = a.keyframes.size(); + std::size_t k0, k1; + float f; + key_span(a, t, k0, k1, f); std::vector out(m.nodes.size()); for (std::size_t i = 0; i < m.nodes.size(); ++i) { Mat4 local; @@ -267,6 +283,32 @@ std::vector pose(const Model& m, const Animation& a, float t) { return out; } +bool has_vertex_animation(const Model& m) { + for (auto& n : m.nodes) + if (!n.animated_vertices.empty()) return true; + return false; +} + +void animate(const Model& m, const Animation& a, float t, LOD& lod) { + if (a.vertex_frames.empty() || a.keyframes.empty()) return; + std::size_t k0, k1; + float f; + key_span(a, t, k0, k1, f); + for (std::size_t n = 0; n < m.nodes.size() && n < a.vertex_frames.size(); ++n) { + const auto& frames = a.vertex_frames[n]; + const auto& list = m.nodes[n].animated_vertices; + if (frames.size() <= k1 || list.empty()) continue; + for (std::size_t i = 0; i < list.size() && i < frames[k0].size() && i < frames[k1].size(); ++i) { + if (list[i] >= lod.vertices.size()) continue; + Vertex& v = lod.vertices[list[i]]; + if (v.weights.size() != 1) continue; + const Vec3& p0 = frames[k0][i]; + const Vec3& p1 = frames[k1][i]; + for (int c = 0; c < 3; ++c) v.weights[0].location[std::size_t(c)] = p0[std::size_t(c)] + (p1[std::size_t(c)] - p0[std::size_t(c)]) * f; + } + } +} + std::vector bind_pose(const Model& m) { std::vector out; for (auto& n : m.nodes) out.push_back(n.bind); diff --git a/tools/lithrez/core/src/abc6.cpp b/tools/lithrez/core/src/abc6.cpp new file mode 100644 index 00000000..61cab9be --- /dev/null +++ b/tools/lithrez/core/src/abc6.cpp @@ -0,0 +1,347 @@ +#include "lithtech/abc6.hpp" + +#include +#include +#include + +namespace lithtech::abc6 { + +namespace { + +const char kToken[] = "MonolithExport Model File v6"; + +struct Reader { + const std::vector* dp; + std::size_t pos; + std::size_t end; + const std::vector& d() const { return *dp; } + void need(std::size_t n) { + if (pos + n > end) throw Error("truncated v6 .abc chunk"); + } + uint8_t u8() { need(1); return d()[pos++]; } + int8_t i8() { return int8_t(u8()); } + uint16_t u16() { need(2); uint16_t v = uint16_t(d()[pos] | d()[pos + 1] << 8); pos += 2; return v; } + uint32_t u32() { + need(4); + uint32_t v = uint32_t(d()[pos]) | uint32_t(d()[pos + 1]) << 8 | uint32_t(d()[pos + 2]) << 16 | uint32_t(d()[pos + 3]) << 24; + pos += 4; + return v; + } + float f32() { uint32_t u = u32(); float f; std::memcpy(&f, &u, 4); return f; } + Vec3 vec3() { return {f32(), f32(), f32()}; } + Quat quat() { return {f32(), f32(), f32(), f32()}; } + std::string str() { + uint16_t n = u16(); + need(n); + std::string s(reinterpret_cast(&d()[pos]), n); + pos += n; + return s; + } + uint32_t count(std::size_t each) { + uint32_t n = u32(); + if (each && std::size_t(n) * each > end - pos) throw Error("bad count in v6 .abc"); + return n; + } +}; + +using Chunks = std::map>; + +Chunks chunk_list(const std::vector& d) { + Chunks chunks; + std::size_t pos = 0; + for (int i = 0; i < 0x200; ++i) { + Reader r{&d, pos, d.size()}; + std::string name = r.str(); + uint32_t next = r.u32(); + std::size_t end = next == 0xFFFFFFFF ? d.size() : next; + if (end > d.size() || end < r.pos) throw Error("bad .abc chunk offset"); + chunks[name] = {r.pos, end}; + if (next == 0xFFFFFFFF) break; + if (next <= pos) throw Error("bad .abc chunk list"); + pos = next; + } + return chunks; +} + +Vec3 mirror(const Vec3& p) { return {p[0], p[1], -p[2]}; } + +// A stored quaternion in the drawing space: z negated (the conjugate and +// the mirror together), its length kept. LithTech 1 interpolates the raw +// values (quat_Slerp, which takes a plain lerp when the dot passes 1, as it +// does for a long one) and only normalizes building the matrix, as +// abc::quat_to_matrix and abc::pose do. +Quat drawn(const Quat& q) { return {q[0], q[1], -q[2], q[3]}; } + +std::vector vertex_frame(const Track& t, std::size_t count, std::size_t key) { + std::vector out(count); + std::size_t base = key * count * 3; + for (std::size_t j = 0; j < count; ++j) { + if (base + 3 * j + 2 >= t.vertex_bytes.size()) break; + Vec3 p; + for (int c = 0; c < 3; ++c) + p[std::size_t(c)] = float(t.vertex_bytes[base + 3 * j + std::size_t(c)]) * t.scale[std::size_t(c)] + + t.translation[std::size_t(c)]; + out[j] = mirror(p); + } + return out; +} + +Vec3 transform_point(const abc::Mat4& M, const Vec3& p) { + Vec3 o; + for (int i = 0; i < 3; ++i) + o[std::size_t(i)] = M[std::size_t(4 * i)] * p[0] + M[std::size_t(4 * i + 1)] * p[1] + + M[std::size_t(4 * i + 2)] * p[2] + M[std::size_t(4 * i + 3)]; + return o; +} + +Vec3 transform_vector(const abc::Mat4& M, const Vec3& n) { + Vec3 o; + for (int i = 0; i < 3; ++i) + o[std::size_t(i)] = M[std::size_t(4 * i)] * n[0] + M[std::size_t(4 * i + 1)] * n[1] + M[std::size_t(4 * i + 2)] * n[2]; + return o; +} + +} // namespace + +bool is_model(const std::vector& d) { + try { + Reader r{&d, 0, d.size()}; + if (r.str() != "Header") return false; + r.u32(); + return r.str() == kToken; + } catch (const Error&) { + return false; + } +} + +Model read(const std::vector& d) { + Chunks chunks = chunk_list(d); + auto section = [&](const char* name) { + auto it = chunks.find(name); + if (it == chunks.end()) throw Error(std::string("no ") + name + " chunk; not a v6 .abc?"); + return Reader{&d, it->second.first, it->second.second}; + }; + + Model m; + Reader r = section("Header"); + if (r.str() != kToken) throw Error("not a v6 .abc"); + m.command_string = r.str(); + + r = section("Geometry"); + m.bounds_min = r.vec3(); + m.bounds_max = r.vec3(); + uint32_t nlod = r.count(2); + m.vertex_start.resize(std::size_t(nlod) + 1); + for (auto& v : m.vertex_start) v = r.u16(); + uint32_t nt = r.count(33); + m.triangles.resize(nt); + for (auto& t : m.triangles) { + for (int c = 0; c < 3; ++c) { + t.uv[c][0] = r.f32(); + t.uv[c][1] = r.f32(); + } + for (auto& i : t.indices) i = r.u16(); + for (auto& n : t.normal) n = r.i8(); + } + uint32_t nv = r.count(20); + m.normal_vertex_count = r.u32(); + m.vertices.resize(nv); + for (auto& v : m.vertices) { + v.position = r.vec3(); + for (auto& n : v.normal) n = r.i8(); + v.node = r.u8(); + v.replacements[0] = r.u16(); + v.replacements[1] = r.u16(); + } + + // Nodes, depth first, each with its number of children. + r = section("Nodes"); + std::vector> stack; // (node, children still to come) + uint32_t remaining = 1; + while (remaining) { + if (m.nodes.size() > 4096) throw Error("v6 .abc node tree too large"); + Node n; + n.bounds_min = r.vec3(); + n.bounds_max = r.vec3(); + n.name = r.str(); + n.index = r.u16(); + n.flags = r.u8(); + uint32_t nmd = r.count(2); + n.animated_vertices.resize(nmd); + for (auto& v : n.animated_vertices) v = r.u16(); + n.child_count = r.u32(); + n.parent = stack.empty() ? -1 : stack.back().first; + if (!stack.empty() && --stack.back().second == 0) stack.pop_back(); + while (!stack.empty() && stack.back().second == 0) stack.pop_back(); + int me = int(m.nodes.size()); + m.nodes.push_back(std::move(n)); + if (m.nodes.back().child_count) stack.push_back({me, m.nodes.back().child_count}); + remaining += m.nodes.back().child_count - 1; + } + + r = section("Animation"); + uint32_t na = r.count(4); + for (uint32_t i = 0; i < na; ++i) { + Animation a; + a.name = r.str(); + a.length = r.u32(); + a.bounds_min = r.vec3(); + a.bounds_max = r.vec3(); + uint32_t nk = r.count(30); + for (uint32_t k = 0; k < nk; ++k) { + uint32_t t = r.u32(); + r.vec3(); + r.vec3(); // the keyframe's box + a.keyframes.push_back({t, r.str()}); + } + a.tracks.resize(m.nodes.size()); + for (std::size_t n = 0; n < m.nodes.size(); ++n) { + Track& tr = a.tracks[n]; + r.need(std::size_t(nk) * 28); + tr.keys.resize(nk); + for (auto& key : tr.keys) { + key.first = r.vec3(); + key.second = r.quat(); + } + std::size_t nb = std::size_t(3) * m.nodes[n].animated_vertices.size() * nk; + r.need(nb); + tr.vertex_bytes.assign(d.begin() + std::ptrdiff_t(r.pos), d.begin() + std::ptrdiff_t(r.pos + nb)); + r.pos += nb; + tr.scale = r.vec3(); + tr.translation = r.vec3(); + } + m.animations.push_back(std::move(a)); + } + + if (chunks.count("AnimDims")) { + r = section("AnimDims"); + for (std::size_t i = 0; i < m.animations.size(); ++i) m.anim_dims.push_back(r.vec3()); + } + + for (auto& t : m.triangles) + for (auto i : t.indices) + if (i >= m.vertices.size()) throw Error("a v6 triangle indexes past the vertices"); + for (auto& n : m.nodes) + for (auto v : n.animated_vertices) + if (v >= m.vertices.size()) throw Error("a v6 node animates a vertex past the vertices"); + return m; +} + +abc::Model to_abc(const Model& m) { + const std::size_t nn = m.nodes.size(); + const Animation* first = m.animations.empty() ? nullptr : &m.animations[0]; + const bool posed = first && !first->keyframes.empty(); + const abc::Mat4 identity = abc::quat_to_matrix({0, 0, 0}, {0, 0, 0, 1}); + + // Each node bound at frame 0 of animation 0. + std::vector binds(nn); + for (std::size_t i = 0; i < nn; ++i) { + abc::Mat4 local = identity; + if (posed) { + auto& key = first->tracks[i].keys[0]; + local = abc::quat_to_matrix(mirror(key.first), drawn(key.second)); + } + int p = m.nodes[i].parent; + binds[i] = p < 0 ? local : abc::multiply(binds[std::size_t(p)], local); + } + + // Node-space positions: as stored, mirrored; an animated vertex's from + // frame 0 of animation 0 (the later entry for a vertex wins). + std::vector local(m.vertices.size()); + for (std::size_t vi = 0; vi < m.vertices.size(); ++vi) local[vi] = mirror(m.vertices[vi].position); + if (posed) + for (std::size_t i = 0; i < nn; ++i) { + auto& list = m.nodes[i].animated_vertices; + if (list.empty()) continue; + auto frame = vertex_frame(first->tracks[i], list.size(), 0); + for (std::size_t j = 0; j < list.size(); ++j) local[list[j]] = frame[j]; + } + + // LOD 0: the full-detail vertices, which are all the triangles use. + std::size_t count = m.normal_vertex_count; + bool fits = count > 0 && count <= m.vertices.size(); + for (auto& t : m.triangles) + for (auto i : t.indices) + if (i >= count) fits = false; + if (!fits) count = m.vertices.size(); + + abc::LOD lod; + lod.vertices.resize(count); + for (std::size_t vi = 0; vi < count; ++vi) { + const Vertex& v = m.vertices[vi]; + uint32_t node = v.node < nn ? v.node : 0; + const abc::Mat4& M = nn ? binds[node] : identity; + abc::Vertex& o = lod.vertices[vi]; + o.weights.push_back({node, local[vi], 1.0f}); + o.replacement = 0; + o.location = transform_point(M, local[vi]); + o.normal = transform_vector(M, mirror({v.normal[0] / 127.f, v.normal[1] / 127.f, v.normal[2] / 127.f})); + } + for (auto& t : m.triangles) { + abc::Triangle o{}; + for (int c = 0; c < 3; ++c) { + o.indices[c] = t.indices[c]; + o.uv[c][0] = t.uv[c][0]; + o.uv[c][1] = t.uv[c][1]; + } + lod.triangles.push_back(o); + } + + abc::Model o; + o.version = 6; + o.command_string = m.command_string; + abc::Piece piece; + piece.name = "Piece"; + piece.lods.push_back(std::move(lod)); + o.pieces.push_back(std::move(piece)); + + bool any_va = false; + std::vector> keep(nn); // entries of each list that land in LOD 0 + for (std::size_t i = 0; i < nn; ++i) { + const Node& n = m.nodes[i]; + abc::Node an; + an.name = n.name; + an.index = n.index; + an.flags = n.flags; + an.bind = binds[i]; + an.parent = n.parent; + for (std::size_t j = 0; j < n.animated_vertices.size(); ++j) + if (n.animated_vertices[j] < count) { + an.animated_vertices.push_back(n.animated_vertices[j]); + keep[i].push_back(j); + } + any_va = any_va || !an.animated_vertices.empty(); + o.nodes.push_back(std::move(an)); + } + + for (std::size_t ai = 0; ai < m.animations.size(); ++ai) { + const Animation& a = m.animations[ai]; + abc::Animation oa; + oa.name = a.name; + if (ai < m.anim_dims.size()) oa.dims = m.anim_dims[ai]; + else + for (int c = 0; c < 3; ++c) + oa.dims[std::size_t(c)] = (a.bounds_max[std::size_t(c)] - a.bounds_min[std::size_t(c)]) / 2; + oa.keyframes = a.keyframes; + oa.transforms.resize(nn); + for (std::size_t n = 0; n < nn; ++n) + for (auto& key : a.tracks[n].keys) oa.transforms[n].push_back({mirror(key.first), drawn(key.second)}); + if (any_va) { + oa.vertex_frames.resize(nn); + for (std::size_t n = 0; n < nn; ++n) { + auto& list = m.nodes[n].animated_vertices; + if (keep[n].empty()) continue; + for (std::size_t k = 0; k < a.keyframes.size(); ++k) { + auto frame = vertex_frame(a.tracks[n], list.size(), k); + std::vector kept; + for (auto j : keep[n]) kept.push_back(frame[j]); + oa.vertex_frames[n].push_back(std::move(kept)); + } + } + } + o.animations.push_back(std::move(oa)); + } + return o; +} + +} // namespace lithtech::abc6 diff --git a/tools/lithrez/gui/modelview.cpp b/tools/lithrez/gui/modelview.cpp index efbed52b..b3ebda5a 100644 --- a/tools/lithrez/gui/modelview.cpp +++ b/tools/lithrez/gui/modelview.cpp @@ -12,6 +12,7 @@ #include "modelview.h" #include +#include #include #include #include @@ -50,6 +51,7 @@ #include namespace abc = lithtech::abc; +namespace abc6 = lithtech::abc6; namespace dtx = lithtech::dtx; namespace ltb = lithtech::ltb; namespace ps2ltb = lithtech::ps2ltb; @@ -127,6 +129,9 @@ class GLView : public QOpenGLWidget { int lod = 0; bool wireframe = false, skeleton = false, textured = true, transparency = true; std::vector matrices; // current pose, model space + // The pose's animation and time, for vertex animation (.abc v6). + const abc::Animation* animation = nullptr; + float animTime = 0; QVector3D center; float radius = 1; @@ -139,6 +144,8 @@ class GLView : public QOpenGLWidget { texturesDirty = true; geometryDirty = true; lod = 0; + animation = nullptr; + animTime = 0; if (m) { matrices = abc::bind_pose(*m); frame(); @@ -439,7 +446,15 @@ class GLView : public QOpenGLWidget { g.corners = 0; g.triangleCenters.clear(); if (piece.lods.empty()) continue; - const abc::LOD& L = piece.lods[std::size_t(std::clamp(lod, 0, int(piece.lods.size()) - 1))]; + const abc::LOD* shown = &piece.lods[std::size_t(std::clamp(lod, 0, int(piece.lods.size()) - 1))]; + // A v6 model's vertex animation keys vertices of pieces[0]'s LOD 0. + abc::LOD animated; + if (animation && pi == 0 && shown == &piece.lods[0] && abc::has_vertex_animation(*model)) { + animated = *shown; + abc::animate(*model, *animation, animTime, animated); + shown = &animated; + } + const abc::LOD& L = *shown; std::vector pos(L.vertices.size()), nor(L.vertices.size()); for (std::size_t vi = 0; vi < L.vertices.size(); ++vi) { auto& v = L.vertices[vi]; @@ -554,6 +569,8 @@ struct ModelView::Impl { if (!model) return; const abc::Animation* a = anim(); gl->matrices = a ? abc::pose(*model, *a, t) : abc::bind_pose(*model); + gl->animation = a; + gl->animTime = t; gl->poseChanged(); time->setText(QStringLiteral("%1 / %2 ms").arg(int(t)).arg(a ? int(a->length_ms()) : 0)); QSignalBlocker b(slider); @@ -726,6 +743,10 @@ bool ModelView::loadAbc(const QByteArray& data, const QString& name, SkinLookup ltb::Model lm = ltb::read(bytes); format = QStringLiteral("LTB, model file version %1").arg(lm.version); m = std::make_unique(ltb::to_abc(lm)); + } else if (abc6::is_model(bytes)) { + // LithTech 1's .abc (Shogo, Blood 2, KISS), drawn the same way. + m = std::make_unique(abc6::to_abc(abc6::read(bytes))); + format = QStringLiteral("ABC version 6 (LithTech 1)"); } else { m = std::make_unique(abc::read(bytes)); format = QStringLiteral("ABC version %1").arg(m->version); diff --git a/tools/lithrez/tests/CMakeLists.txt b/tools/lithrez/tests/CMakeLists.txt index 919f8576..1f958cef 100644 --- a/tools/lithrez/tests/CMakeLists.txt +++ b/tools/lithrez/tests/CMakeLists.txt @@ -7,6 +7,13 @@ target_link_libraries(rez_progress_test PRIVATE lithtech) add_test(NAME rez_progress_test COMMAND rez_progress_test ${CMAKE_CURRENT_BINARY_DIR}/progress-work) add_executable(abc_verify abc_verify.cpp) target_link_libraries(abc_verify PRIVATE lithtech) +# .abc v6 (Shogo, Blood 2, KISS): a file made here, checked value by value; +# and every v6 model under a directory, read and converted (abc6_verify). +add_executable(abc6_test abc6_test.cpp) +target_link_libraries(abc6_test PRIVATE lithtech) +add_test(NAME abc6_test COMMAND abc6_test) +add_executable(abc6_verify abc6_verify.cpp) +target_link_libraries(abc6_verify PRIVATE lithtech) # DTX decoding over whole archives, and texture viewer screenshots. add_executable(dtx_decode_all dtx_decode_all.cpp) diff --git a/tools/lithrez/tests/abc6_test.cpp b/tools/lithrez/tests/abc6_test.cpp new file mode 100644 index 00000000..b9365525 --- /dev/null +++ b/tools/lithrez/tests/abc6_test.cpp @@ -0,0 +1,161 @@ +// lithtech::abc6 on a .abc v6 made here, checked value by value against +// numbers worked out by hand from the rules in abc6.hpp: a root and a +// "deformation" child with one vertex animated, two keys (the second +// moving the root 10 along z and turning the child 90 degrees about z, +// stored conjugated and three times too long, as thousands of shipped keys +// are) and three vertices, one on the root, one on the child, one +// animated. Then a file that is not v6, and a truncated one. +// abc6_test +#include + +#include +#include +#include +#include +#include + +using namespace lithtech; + +static int failures = 0; +#define CHECK(c) \ + do { \ + if (!(c)) { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); \ + ++failures; \ + } \ + } while (0) + +struct W { + std::vector b; + void u8(unsigned v) { b.push_back(uint8_t(v)); } + void u16(unsigned v) { u8(v & 0xff), u8(v >> 8); } + void u32(uint32_t v) { for (int i = 0; i < 4; ++i) u8(v >> (8 * i)); } + void f32(float f) { uint32_t u; std::memcpy(&u, &f, 4); u32(u); } + void vec(float x, float y, float z) { f32(x), f32(y), f32(z); } + void str(const std::string& s) { u16(unsigned(s.size())); b.insert(b.end(), s.begin(), s.end()); } +}; + +static std::vector chunks(const std::vector>>& cs) { + W w; + for (std::size_t i = 0; i < cs.size(); ++i) { + w.str(cs[i].first); + bool last = i + 1 == cs.size(); + w.u32(last ? 0xFFFFFFFFu : uint32_t(w.b.size() + 4 + cs[i].second.size())); + w.b.insert(w.b.end(), cs[i].second.begin(), cs[i].second.end()); + } + return w.b; +} + +static std::vector model() { + const float s = std::sqrt(0.5f); + W head, geo, nodes, anim, dims; + head.str("MonolithExport Model File v6"); + head.str(""); + geo.vec(-1, -1, -1); + geo.vec(1, 1, 1); + geo.u32(0); // no reduced-detail vertices + geo.u16(0); // VertexStartNum + geo.u32(1); // one triangle + float uv[6] = {0, 0, 1, 0, 0, 1}; + for (float f : uv) geo.f32(f); + geo.u16(0), geo.u16(1), geo.u16(2); + geo.u8(0), geo.u8(0), geo.u8(127); + geo.u32(3), geo.u32(3); // vertices, full-detail vertices + struct V { float x, y, z; unsigned node; } vs[3] = {{1, 2, 3, 1}, {0, 0, 0, 1}, {0, 1, 0, 0}}; + for (auto& v : vs) { + geo.vec(v.x, v.y, v.z); + geo.u8(0), geo.u8(0), geo.u8(127); + geo.u8(v.node); + geo.u16(0), geo.u16(0); + } + // root (one child), then arm: a deformation node animating vertex 1. + nodes.vec(0, 0, 0), nodes.vec(0, 0, 0), nodes.str("root"), nodes.u16(0), nodes.u8(1), nodes.u32(0), nodes.u32(1); + nodes.vec(0, 0, 0), nodes.vec(0, 0, 0), nodes.str("arm"), nodes.u16(1), nodes.u8(4 | 2), nodes.u32(1), nodes.u16(1), + nodes.u32(0); + anim.u32(1); + anim.str("base"); + anim.u32(100); + anim.vec(0, 0, 0), anim.vec(0, 0, 0); + anim.u32(2); + anim.u32(0), anim.vec(0, 0, 0), anim.vec(0, 0, 0), anim.str(""); + anim.u32(100), anim.vec(0, 0, 0), anim.vec(0, 0, 0), anim.str("fire"); + // root: (0,0,0) then (0,0,10), no turn. + anim.vec(0, 0, 0), anim.f32(0), anim.f32(0), anim.f32(0), anim.f32(1); + anim.vec(0, 0, 10), anim.f32(0), anim.f32(0), anim.f32(0), anim.f32(1); + anim.vec(1, 1, 1), anim.vec(0, 0, 0); + // arm: at (5,0,0), then turned 90 degrees about z, stored conjugated, + // at length 3. + anim.vec(5, 0, 0), anim.f32(0), anim.f32(0), anim.f32(0), anim.f32(1); + anim.vec(5, 0, 0), anim.f32(0), anim.f32(0), anim.f32(3 * s), anim.f32(3 * s); + anim.u8(10), anim.u8(20), anim.u8(30); // vertex 1 at key 0 + anim.u8(20), anim.u8(20), anim.u8(30); // and key 1 + anim.vec(0.5f, 0.5f, 0.5f), anim.vec(1, 1, 1); // byte * 0.5 + 1 + dims.vec(1, 2, 3); + return chunks({{"Header", head.b}, {"Geometry", geo.b}, {"Nodes", nodes.b}, {"Animation", anim.b}, {"AnimDims", dims.b}}); +} + +static bool near(const abc::Vec3& a, float x, float y, float z, float tol = 1e-4f) { + return std::fabs(a[0] - x) < tol && std::fabs(a[1] - y) < tol && std::fabs(a[2] - z) < tol; +} + +static std::vector posed(const abc::Model& m, float t) { + auto M = abc::pose(m, m.animations[0], t); + abc::LOD lod = m.pieces[0].lods[0]; + abc::animate(m, m.animations[0], t, lod); + std::vector out; + for (auto& v : lod.vertices) out.push_back(abc::skin(v, M)); + return out; +} + +int main() { + auto file = model(); + CHECK(abc6::is_model(file)); + abc6::Model raw = abc6::read(file); + CHECK(raw.nodes.size() == 2 && raw.nodes[1].parent == 0 && raw.nodes[1].animated_vertices.size() == 1); + abc::Model m = abc6::to_abc(raw); + CHECK(m.pieces.size() == 1 && m.pieces[0].lods.size() == 1 && m.pieces[0].lods[0].vertices.size() == 3); + CHECK(m.nodes.size() == 2 && m.nodes[1].parent == 0 && m.nodes[1].animated_vertices == std::vector{1}); + CHECK(abc::has_vertex_animation(m)); + CHECK(m.animations.size() == 1 && m.animations[0].keyframes.size() == 2 && + m.animations[0].keyframes[1].command == "fire" && near(m.animations[0].dims, 1, 2, 3)); + const auto& L = m.pieces[0].lods[0]; + // The bind, frame 0: z mirrored; the animated vertex at its frame-0 bytes. + CHECK(near(L.vertices[0].location, 6, 2, -3)); + CHECK(near(L.vertices[1].location, 11, 11, -16)); + CHECK(near(L.vertices[2].location, 0, 1, 0)); + CHECK(near(L.vertices[2].normal, 0, 0, -1)); + CHECK(L.triangles.size() == 1 && L.triangles[0].uv[1][0] == 1.0f && L.triangles[0].uv[2][1] == 1.0f); + auto at0 = posed(m, 0); + CHECK(near(at0[0], 6, 2, -3) && near(at0[1], 11, 11, -16) && near(at0[2], 0, 1, 0)); + // Key 1: the root at (0,0,-10); the arm turned -90 about z in the drawing + // space, (x,y) -> (y,-x); the animated vertex at its key-1 bytes. + auto at1 = posed(m, 100); + CHECK(near(at1[0], 7, -1, -13)); + CHECK(near(at1[1], 16, -11, -26)); + CHECK(near(at1[2], 0, 1, -10)); + // Halfway: the root at -5 and the vertex's bytes lerped. The arm's turn + // is LithTech 1's quat_Slerp on the raw values: their dot, 3 * sqrt(1/2), + // passes 1, so it lerps them to (0, 0, -1.5s, 0.5 + 1.5s), and the + // matrix normalizes them: about -68.4 degrees, not the -45 a unit key gives. + const float s = std::sqrt(0.5f); + const float th = 2 * std::atan2(-1.5f * s, 0.5f + 1.5f * s); + const float px = 8.5f, py = 11; + auto mid = posed(m, 50); + CHECK(std::fabs(th * 180 / 3.14159265f + 68.42f) < 0.05f); + CHECK(near(mid[1], 5 + px * std::cos(th) - py * std::sin(th), px * std::sin(th) + py * std::cos(th), -21)); + CHECK(near(mid[2], 0, 1, -5)); + // Not v6, and truncated. + std::vector v12 = chunks({{"Header", std::vector{12, 0, 0, 0}}}); + CHECK(!abc6::is_model(v12)); + std::vector cut(file.begin(), file.begin() + std::ptrdiff_t(file.size() - 20)); + bool threw = false; + try { + abc6::read(cut); + } catch (const abc::Error&) { + threw = true; + } + CHECK(threw); + if (failures) std::printf("%d failed\n", failures); + else std::printf("abc6_test: all passed\n"); + return failures ? 1 : 0; +} diff --git a/tools/lithrez/tests/abc6_verify.cpp b/tools/lithrez/tests/abc6_verify.cpp new file mode 100644 index 00000000..d305d11e --- /dev/null +++ b/tools/lithrez/tests/abc6_verify.cpp @@ -0,0 +1,112 @@ +// Reads every .abc v6 under a directory (Shogo, Blood 2, KISS) with +// lithtech::abc6 and checks what abc6::to_abc makes of it: skin(bind) gives +// back every vertex's stored position, the first key of the first +// animation is the bind pose, and abc::animate at that key leaves every +// vertex where the bind put it. +// abc6_verify [--dump ] +// --dump writes every LOD 0 vertex of every model posed at every keyframe +// and halfway between each pair, for a comparison with another reader +// ("model ", "pose ", then " " lines). +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace lithtech; + +static std::vector posed(const abc::Model& m, const abc::Animation& a, float t) { + auto M = abc::pose(m, a, t); + abc::LOD lod = m.pieces[0].lods[0]; + abc::animate(m, a, t, lod); + std::vector out; + for (auto& v : lod.vertices) out.push_back(abc::skin(v, M)); + return out; +} + +int main(int argc, char** argv) { + if (argc < 2) { std::printf("usage: abc6_verify [--dump ]\n"); return 2; } + FILE* dump = nullptr; + for (int i = 2; i + 1 < argc; ++i) + if (!std::strcmp(argv[i], "--dump")) dump = std::fopen(argv[i + 1], "w"); + std::vector files; + for (auto& e : fs::recursive_directory_iterator(argv[1])) { + if (!e.is_regular_file()) continue; + auto ext = e.path().extension().string(); + for (auto& c : ext) c = char(std::tolower(c)); + if (ext == ".abc") files.push_back(e.path()); + } + std::sort(files.begin(), files.end()); + int v6 = 0, other = 0, failed = 0, pose_match = 0, rest_match = 0, animated_models = 0; + long verts = 0, skin_ok = 0, poses = 0; + double worst_skin = 0, worst_pose = 0, worst_rest = 0; + for (auto& path : files) { + std::ifstream f(path, std::ios::binary); + std::vector d((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (!abc6::is_model(d)) { ++other; continue; } + ++v6; + abc::Model m; + try { + m = abc6::to_abc(abc6::read(d)); + } catch (const std::exception& ex) { + std::printf("FAIL %s: %s\n", path.u8string().c_str(), ex.what()); + ++failed; + continue; + } + auto bind = abc::bind_pose(m); + for (auto& v : m.pieces[0].lods[0].vertices) { + ++verts; + auto s = abc::skin(v, bind); + float dd = 0; + for (int i = 0; i < 3; ++i) dd = std::max(dd, std::fabs(s[i] - v.location[i])); + if (dd < 1e-3f) ++skin_ok; + worst_skin = std::max(worst_skin, double(dd)); + } + if (!m.animations.empty() && !m.animations[0].keyframes.empty()) { + const abc::Animation& a = m.animations[0]; + float t0 = float(a.keyframes[0].time); + auto P = abc::pose(m, a, t0); + double mw = 0; + for (std::size_t n = 0; n < P.size(); ++n) + for (int i = 0; i < 12; ++i) mw = std::max(mw, double(std::fabs(P[n][i] - m.nodes[n].bind[i]))); + worst_pose = std::max(worst_pose, mw); + if (mw < 1e-4) ++pose_match; + auto at0 = posed(m, a, t0); + double rw = 0; + for (std::size_t vi = 0; vi < at0.size(); ++vi) + for (int i = 0; i < 3; ++i) + rw = std::max(rw, double(std::fabs(at0[vi][i] - m.pieces[0].lods[0].vertices[vi].location[i]))); + worst_rest = std::max(worst_rest, rw); + if (rw < 1e-3) ++rest_match; + } + if (abc::has_vertex_animation(m)) ++animated_models; + if (dump) { + std::fprintf(dump, "model %s\n", fs::relative(path, argv[1]).generic_u8string().c_str()); + for (std::size_t ai = 0; ai < m.animations.size(); ++ai) { + const auto& a = m.animations[ai]; + for (std::size_t k = 0; k < a.keyframes.size(); ++k) { + std::vector> times = {{0, float(a.keyframes[k].time)}}; + if (k + 1 < a.keyframes.size() && a.keyframes[k + 1].time > a.keyframes[k].time + 1) + times.push_back({1, (a.keyframes[k].time + a.keyframes[k + 1].time) / 2.0f}); + for (auto [mid, t] : times) { + std::fprintf(dump, "pose %zu %zu%s %.9g\n", ai, k, mid ? "+" : "", t); + for (auto& p : posed(m, a, t)) std::fprintf(dump, "%.9g %.9g %.9g\n", p[0], p[1], p[2]); + ++poses; + } + } + } + } + } + if (dump) std::fclose(dump); + std::printf("%d v6 files (%d other .abc skipped), %d failed; %ld vertices: skin(bind) gives back %ld (worst %.3g); " + "first key of the first animation is the bind pose in %d of %d (worst %.3g), and leaves every " + "vertex at its bind position in %d (worst %.3g); %d models with vertex animation; %ld poses dumped\n", + v6, other, failed, verts, skin_ok, worst_skin, pose_match, v6 - failed, worst_pose, rest_match, + worst_rest, animated_models, poses); + return failed || !v6 || skin_ok != verts ? 1 : 0; +}