commit 19a193eca12fd1e3c04a935038b7a39eb7d2498e Author: TimV-BREX\TimVoelker Date: Tue Sep 8 09:52:52 2026 +0200 Added Body Animation Compression Setup to Repo diff --git a/Body Animation Compression/Optimized/AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.glb b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.glb new file mode 100644 index 0000000..58cf84a Binary files /dev/null and b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.glb differ diff --git a/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.glb b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.glb new file mode 100644 index 0000000..ea8e123 Binary files /dev/null and b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.glb differ diff --git a/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.glb b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.glb new file mode 100644 index 0000000..89a5124 Binary files /dev/null and b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.glb differ diff --git a/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.glb b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.glb new file mode 100644 index 0000000..8917705 Binary files /dev/null and b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.glb differ diff --git a/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.glb b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.glb new file mode 100644 index 0000000..aab20dd Binary files /dev/null and b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.glb differ diff --git a/Body Animation Compression/Optimized/AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.glb b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.glb new file mode 100644 index 0000000..fb11b32 Binary files /dev/null and b/Body Animation Compression/Optimized/AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.glb differ diff --git a/Body Animation Compression/Original/AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.FBX b/Body Animation Compression/Original/AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.FBX new file mode 100644 index 0000000..5d4e0c4 Binary files /dev/null and b/Body Animation Compression/Original/AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.FBX differ diff --git a/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.FBX b/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.FBX new file mode 100644 index 0000000..267e807 Binary files /dev/null and b/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.FBX differ diff --git a/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.FBX b/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.FBX new file mode 100644 index 0000000..5956d69 Binary files /dev/null and b/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.FBX differ diff --git a/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.FBX b/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.FBX new file mode 100644 index 0000000..d21f5e0 Binary files /dev/null and b/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.FBX differ diff --git a/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.FBX b/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.FBX new file mode 100644 index 0000000..addfb67 Binary files /dev/null and b/Body Animation Compression/Original/AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.FBX differ diff --git a/Body Animation Compression/Original/AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.FBX b/Body Animation Compression/Original/AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.FBX new file mode 100644 index 0000000..986cf6a Binary files /dev/null and b/Body Animation Compression/Original/AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.FBX differ diff --git a/Body Animation Compression/anim_optimize.py b/Body Animation Compression/anim_optimize.py new file mode 100644 index 0000000..919126d --- /dev/null +++ b/Body Animation Compression/anim_optimize.py @@ -0,0 +1,1332 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +anim_optimize.py - Headless Blender: Unreal-FBX-Animationen entbacken -> GLB. + +Ordnerstruktur (neben dieser Datei): + Original\\ <- FBX aus Unreal hier reinlegen + Optimized\\ <- GLB landen hier, gleiche Basisnamen + optimize_log.txt <- vollstaendiges Protokoll jedes Laufs + +Aufruf ueber optimize_all.bat, oder direkt: + blender -b -noaudio --factory-startup --python anim_optimize.py -- --root "C:\\pfad" + +Pipeline pro Datei: + 1. FBX importieren + 2. Optional Bones nach Praefix / Nicht-Deform-Teilbaeume loeschen + 3. Meshes/Materialien verwerfen (--anim-only) + 4. Samplerate messen und gegen --expect-fps pruefen + 5. Konstante Kanaele entfernen (Scale, Location auf Ruhewert) + 6. Fehlerbegrenzte Dezimierung (RDP), Fehler in GRAD bzw. Blender-Einheiten + 7. Alle Keys LINEAR, Werte runden + 8. GLB exportieren mit export_force_sampling=False -> Keys bleiben erhalten + 9. Optional gltfpack: Quantisierung + EXT_meshopt_compression +""" + +import bpy +import sys +import os +import re +import math +import time +import shutil +import argparse +import contextlib +import traceback +from mathutils import Quaternion, Vector, Euler + + +# ============================================================================ +# Argumente +# ============================================================================ + +def get_args(): + argv = sys.argv + argv = argv[argv.index("--") + 1:] if "--" in argv else [] + + p = argparse.ArgumentParser(prog="anim_optimize") + + # Ordner + p.add_argument("--root", default=os.path.dirname(os.path.abspath(__file__)), + help="Basisordner mit Original/ und Optimized/") + p.add_argument("--in-dir", default="Original") + p.add_argument("--out-dir", default="Optimized") + p.add_argument("--force", action="store_true", + help="bestehende Ziele ueberschreiben statt ueberspringen") + p.add_argument("--format", default="GLB", choices=["GLB", "GLTF", "FBX"]) + + # Fehlertoleranzen + p.add_argument("--rot-tol", type=float, default=0.25, + help="max. Winkelfehler in GRAD (Default 0.25)") + p.add_argument("--loc-tol-mm", type=float, default=0.5, + help="max. Positionsfehler in MILLIMETERN (Default 0.5). " + "Wird pro Datei anhand der Armature-Scale in " + "Blender-Einheiten umgerechnet.") + p.add_argument("--loc-tol", type=float, default=None, + help="max. Positionsfehler direkt in BLENDER-EINHEITEN. " + "Ueberschreibt --loc-tol-mm. Nur nutzen, wenn man die " + "Skalierung der Datei kennt.") + p.add_argument("--loose-rot-tol", type=float, default=1.0, + help="lockere Winkeltoleranz fuer Bones aus --loose-bones") + p.add_argument("--loose-bones", default="twist", + help="Komma-Liste von Teilstrings -> bekommen --loose-rot-tol. " + "Finger sind bewusst NICHT dabei.") + p.add_argument("--scale-tol", type=float, default=0.0005, + help="max. Skalierungsfehler (Default 0.0005)") + p.add_argument("--scale-snap", type=float, default=0.001, + help="Skalierungswerte naeher als dies an 1.0 werden auf " + "exakt 1.0 gesetzt. Faengt Float-Rauschen aus dem " + "Export ab (0 = aus)") + p.add_argument("--expect-fps", type=float, default=30.0, + help="erwartete Samplerate; Abweichung wird gemeldet (0 = aus)") + + # Aufraeumen + p.add_argument("--anim-only", action="store_true", + help="nur Armature exportieren, Meshes/Materialien verwerfen") + p.add_argument("--deform-only", action="store_true", + help="Bone-Teilbaeume ohne Deform-Bones loeschen (IK/Control). " + "Wirkungslos, wenn kein geskinntes Mesh dabei ist.") + p.add_argument("--drop-bones", default="", + help="Komma-Liste: Bones mit diesem Praefix werden geloescht") + + # gltfpack + p.add_argument("--gltfpack", default="", + help="Pfad zu gltfpack.exe -> zweiter Durchlauf mit Quantisierung") + p.add_argument("--gltfpack-args", default="-cc -kn -af 30", + help="Argumente fuer gltfpack. -kn ist PFLICHT: ohne das prunt " + "gltfpack die Bone-Hierarchie weg, weil in einer " + "Anim-only-GLB nichts die Nodes referenziert.") + + # Ausgabe + p.add_argument("--round", dest="nd", type=int, default=4, + help="Nachkommastellen der Keyframe-Werte (0 = nicht runden)") + p.add_argument("--log", default="optimize_log.txt", + help="Logdatei relativ zu --root (leer = kein Log)") + p.add_argument("--report", action="store_true", + help="Key-Verteilung pro Bone ins Log schreiben") + p.add_argument("--report-top", type=int, default=20, + help="wie viele Bones im Report (Default 20)") + p.add_argument("--verbose", action="store_true", + help="Blender-Import/Export-Ausgabe nicht unterdruecken") + p.add_argument("--no-progress", action="store_true", + help="Fortschrittsbalken abschalten (z.B. fuer CI-Logs)") + p.add_argument("--no-color", action="store_true", + help="ANSI-Farben abschalten") + + return p.parse_args(argv) + + +ARGS = get_args() + + +# ============================================================================ +# Konsole, Farben, Logdatei +# ============================================================================ + +CONSOLE = sys.stdout # echte Konsole, bleibt immer erreichbar +LOGFH = None # Dateihandle des Protokolls + + +def _enable_vt(): + """Windows-Konsolen brauchen ENABLE_VIRTUAL_TERMINAL_PROCESSING explizit.""" + if os.name != "nt": + return True + try: + import ctypes + k = ctypes.windll.kernel32 + h = k.GetStdHandle(-11) + mode = ctypes.c_uint32() + if not k.GetConsoleMode(h, ctypes.byref(mode)): + return False + return bool(k.SetConsoleMode(h, mode.value | 0x0004)) + except Exception: + return False + + +USE_COLOR = (not ARGS.no_color) and _enable_vt() + +DIM, CYAN, GREEN, RED, YELLOW, BOLD = "90", "96", "92", "91", "93", "1" + +ANSI_RE = re.compile(r"\033\[[0-9;]*m") + + +def c(code, text): + return "\033[%sm%s\033[0m" % (code, text) if USE_COLOR else text + + +def strip_ansi(s): + return ANSI_RE.sub("", s) + + +def logf(text=""): + """Nur ins Protokoll.""" + if LOGFH: + LOGFH.write(strip_ansi(text) + "\n") + LOGFH.flush() + + +def log(*a): + """Konsole und Protokoll.""" + text = " ".join(str(x) for x in a) + print(text, file=CONSOLE) + CONSOLE.flush() + logf(text) + + +def open_log(path): + global LOGFH + if not path: + return + try: + LOGFH = open(path, "w", encoding="utf-8") + except Exception as e: + print("Logdatei nicht schreibbar: %s" % e, file=CONSOLE) + LOGFH = None + + +def close_log(): + global LOGFH + if LOGFH: + try: + LOGFH.close() + except Exception: + pass + LOGFH = None + + +class _NullWriter: + """Schluckt alles. Ersetzt sys.stdout waehrend Import/Export.""" + encoding = "utf-8" + errors = "replace" + + def write(self, s): + return len(s) + + def writelines(self, lines): + pass + + def flush(self): + pass + + def isatty(self): + return False + + def fileno(self): + raise OSError("kein Dateideskriptor") + + def close(self): + pass + + @property + def closed(self): + return False + + +class _TeeWriter(_NullWriter): + """Schreibt Blender-Ausgabe ins Protokoll, aber nicht auf die Konsole.""" + + def write(self, s): + if LOGFH and s.strip(): + LOGFH.write(s if s.endswith("\n") else s + "\n") + return len(s) + + +@contextlib.contextmanager +def quiet_stdout(): + """Blender-Chatter von der Konsole nehmen, damit der Fortschrittsbalken + sauber bleibt - aber ins Protokoll durchreichen. + + WICHTIG: kein os.dup2 auf fd 1. Unter Windows haengt sys.stdout an einem + _WindowsConsoleIO, das WriteConsoleW benutzt. Sobald fd 1 keine Konsole + mehr ist, scheitert jeder Schreibzugriff mit WinError 1. Stattdessen wird + sys.stdout auf Python-Ebene getauscht - Importer und Exporter sind Addons, + deren print() sys.stdout zur Laufzeit aufloest. + """ + if ARGS.verbose: + yield + return + old = sys.stdout + sys.stdout = _TeeWriter() + try: + yield + finally: + sys.stdout = old + + +# ============================================================================ +# Fortschrittsanzeige +# ============================================================================ + +def fmt_time(sec): + if sec is None or sec < 0 or sec != sec or sec == float("inf"): + return "--:--" + sec = int(round(sec)) + h, rem = divmod(sec, 3600) + m, s = divmod(rem, 60) + return "%d:%02d:%02d" % (h, m, s) if h else "%02d:%02d" % (m, s) + + +def fmt_size(n): + """Bytes lesbar: KB unterhalb von 1 MB, sonst MB.""" + if n < 1024 * 1024: + return "%.0f KB" % (n / 1024.0) + return "%.2f MB" % (n / 1e6) + + +def ellipsis(text, width): + text = str(text) + return text if len(text) <= width else text[:width - 1] + "~" + + +class Progress: + """Byte-gewichteter Fortschritt. Grosse Dateien dauern laenger als kleine, + deshalb ist die ETA ueber verarbeitete Bytes stabiler als ueber Dateizahl.""" + + BAR_W = 20 + NAME_W = 22 + + def __init__(self, total_files, total_bytes, enabled=True): + self.total_files = total_files + self.total_bytes = max(1, total_bytes) + self.done_bytes = 0 + self.done_files = 0 + self.t0 = time.time() + self.enabled = bool(enabled) + self.last_len = 0 + self.idx_w = len(str(total_files)) + + def file_done(self, size): + self.done_bytes += size + self.done_files += 1 + + def file_skipped(self, size): + """Uebersprungene Dateien kosten keine Zeit -> aus der Basis nehmen, + sonst laeuft die ETA systematisch zu hoch.""" + self.total_bytes = max(1, self.total_bytes - size) + self.done_files += 1 + + def eta(self): + elapsed = time.time() - self.t0 + if self.done_bytes > 0 and elapsed > 1.0: + rate = self.done_bytes / elapsed + return (self.total_bytes - self.done_bytes) / rate + return None + + def _bar(self): + frac = min(1.0, self.done_bytes / self.total_bytes) + n = int(round(frac * self.BAR_W)) + return "[" + "#" * n + "-" * (self.BAR_W - n) + "]", frac + + def status(self, idx, name, phase): + if not self.enabled: + return + bar, frac = self._bar() + head = " [%*d/%d]" % (self.idx_w, idx, self.total_files) + line = "%s %s %3d%% ETA %s %-*s %s" % ( + c(DIM, head), c(CYAN, bar), int(frac * 100), + fmt_time(self.eta()), self.NAME_W, ellipsis(name, self.NAME_W), + c(DIM, phase)) + self._write(line, visible=len(head) + len(bar) + 24 + self.NAME_W + len(phase)) + + def _write(self, line, visible=None): + vis = visible if visible is not None else len(line) + pad = max(0, self.last_len - vis) + CONSOLE.write("\r" + line + " " * pad) + CONSOLE.flush() + self.last_len = vis + + def clear(self): + if self.enabled and self.last_len: + CONSOLE.write("\r" + " " * self.last_len + "\r") + CONSOLE.flush() + self.last_len = 0 + + def line(self, text): + """Bleibende Zeile: Konsole und Protokoll, ohne den Balken zu zerhacken.""" + self.clear() + print(text, file=CONSOLE) + CONSOLE.flush() + logf(text) + + +# ============================================================================ +# Blender-Kompatibilitaet +# ============================================================================ + +def filter_kwargs(op, kwargs): + """Entfernt Parameter, die diese Blender-Version nicht kennt.""" + try: + valid = {p.identifier for p in op.get_rna_type().properties} + except Exception: + return kwargs + dropped = sorted(k for k in kwargs if k not in valid) + if dropped: + logf(" Exporter-Parameter in dieser Blender-Version unbekannt, " + "ignoriert: %s" % ", ".join(dropped)) + return {k: v for k, v in kwargs.items() if k in valid} + + +def action_fcurves(obj): + """FCurve-Collection der aktiven Action, auch bei slotted actions (4.4+).""" + ad = obj.animation_data + if not ad or not ad.action: + return None + act = ad.action + try: + if hasattr(act, "layers") and len(act.layers) > 0: + strip = act.layers[0].strips[0] + slot = getattr(ad, "action_slot", None) + cb = strip.channelbag(slot) if slot else None + if cb is not None: + return cb.fcurves + except Exception: + pass + return act.fcurves + + +def remove_key(kps, index): + try: + kps.remove(kps[index], do_fast=True) + except TypeError: + kps.remove(kps[index]) + + +# ============================================================================ +# Ramer-Douglas-Peucker, fehlerbegrenzt +# ============================================================================ + +def rdp(n, err_fn, tol): + if n <= 2: + return list(range(n)) + keep = {0, n - 1} + stack = [(0, n - 1)] + while stack: + a, b = stack.pop() + if b - a < 2: + continue + worst_i, worst_e = -1, -1.0 + for i in range(a + 1, b): + e = err_fn(a, b, i) + if e > worst_e: + worst_e, worst_i = e, i + if worst_e > tol: + keep.add(worst_i) + stack.append((a, worst_i)) + stack.append((worst_i, b)) + return sorted(keep) + + +def quat_error_fn(qs): + """glTF interpoliert Rotation per Slerp -> exakt diese Metrik.""" + def f(a, b, i): + t = (i - a) / (b - a) + q = qs[a].slerp(qs[b], t) + d = min(1.0, abs(q.dot(qs[i]))) + return 2.0 * math.degrees(math.acos(d)) + return f + + +def euler_error_fn(es): + qs = [e.to_quaternion() for e in es] + + def f(a, b, i): + t = (i - a) / (b - a) + e = Euler(tuple(es[a][k] + (es[b][k] - es[a][k]) * t for k in range(3)), + es[a].order) + d = min(1.0, abs(e.to_quaternion().dot(qs[i]))) + return 2.0 * math.degrees(math.acos(d)) + return f + + +def vec_error_fn(vs): + def f(a, b, i): + t = (i - a) / (b - a) + return (vs[a].lerp(vs[b], t) - vs[i]).length + return f + + +# ============================================================================ +# Kurven +# ============================================================================ + +DEFAULTS = {"location": 0.0, "rotation_quaternion": None, + "rotation_euler": 0.0, "scale": 1.0} +EPS_CONST = 1e-6 + + +def bone_name_of(data_path): + if 'pose.bones["' not in data_path: + return None + s = data_path.index('pose.bones["') + len('pose.bones["') + return data_path[s:data_path.index('"]', s)] + + +def channel_of(data_path): + return data_path.rsplit(".", 1)[-1] + + +def default_value(channel, array_index): + if channel == "rotation_quaternion": + return 1.0 if array_index == 0 else 0.0 + return DEFAULTS.get(channel, 0.0) + + +def is_constant(fc): + kps = fc.keyframe_points + if len(kps) <= 1: + return True + vals = [kp.co[1] for kp in kps] + return (max(vals) - min(vals)) < EPS_CONST + + +def group_curves(fcurves): + groups = {} + for fc in fcurves: + groups.setdefault(fc.data_path, {})[fc.array_index] = fc + return groups + + +def remove_dead_fcurves(arm, fcurves): + bones = set(arm.pose.bones.keys()) + dead = [fc for fc in fcurves + if bone_name_of(fc.data_path) is not None + and bone_name_of(fc.data_path) not in bones] + for fc in dead: + fcurves.remove(fc) + return len(dead) + + +def collapse_constants(fcurves, scale_snap=0.0): + """Konstante Kanalgruppen entfernen bzw. auf einen Key reduzieren. + + WICHTIG: das laeuft gruppenweise, nicht pro Kurve. Wuerde hier eine + einzelne Quaternion-Komponente verschwinden, haette die Gruppe nur noch + 3 statt 4 Kurven - dann greift die Dezimierung nicht mehr, und der Bone + behaelt saemtliche Keys.""" + removed = collapsed = 0 + for path, curves in group_curves(fcurves).items(): + ch = channel_of(path) + if ch not in DEFAULTS: + continue + + # Float-Rauschen aus dem Export auf exakt 1.0 ziehen + if ch == "scale" and scale_snap > 0: + for fc in curves.values(): + touched = False + for kp in fc.keyframe_points: + if kp.co[1] != 1.0 and abs(kp.co[1] - 1.0) < scale_snap: + kp.co[1] = 1.0 + touched = True + if touched: + fc.update() + + if not all(is_constant(fc) for fc in curves.values()): + continue + + def val(fc): + kps = fc.keyframe_points + return kps[0].co[1] if len(kps) else default_value(ch, fc.array_index) + + at_rest = all(abs(val(fc) - default_value(ch, fc.array_index)) < EPS_CONST + for fc in curves.values()) + if at_rest: + for fc in list(curves.values()): + fcurves.remove(fc) + removed += 1 + else: + for fc in curves.values(): + kps = fc.keyframe_points + for i in range(len(kps) - 1, 0, -1): + remove_key(kps, i) + fc.update() + collapsed += 1 + return removed, collapsed + + +def prune_group(curves, keep_idx): + keep = set(keep_idx) + for fc in curves.values(): + kps = fc.keyframe_points + for i in range(len(kps) - 1, -1, -1): + if i not in keep: + remove_key(kps, i) + fc.update() + + +def decimate(fcurves, args, loc_tol, tick=None): + loose = [s.strip().lower() for s in args.loose_bones.split(",") if s.strip()] + groups = group_curves(fcurves) + before = sum(len(fc.keyframe_points) for fc in fcurves) + n_groups = max(1, len(groups)) + per_channel = {} + + for gi, (path, curves) in enumerate(groups.items()): + if tick and gi % 8 == 0: + tick(gi / n_groups) + + ch = channel_of(path) + bone = (bone_name_of(path) or "").lower() + n = min(len(fc.keyframe_points) for fc in curves.values()) + per_channel.setdefault(ch, [0, 0]) + per_channel[ch][0] += n * len(curves) + if n < 3: + per_channel[ch][1] += n * len(curves) + continue + + rot_tol = args.loose_rot_tol if any(t in bone for t in loose) else args.rot_tol + + def col(idx, i): + return curves[idx].keyframe_points[i].co[1] + + kept = n + if ch == "rotation_quaternion" and len(curves) == 4: + qs = [Quaternion((col(0, i), col(1, i), col(2, i), col(3, i))) + for i in range(n)] + for i in range(1, n): # Vorzeichen-Kontinuitaet + if qs[i].dot(qs[i - 1]) < 0.0: + qs[i].negate() + for k in range(4): + curves[k].keyframe_points[i].co[1] = qs[i][k] + keep = rdp(n, quat_error_fn(qs), rot_tol) + prune_group(curves, keep) + kept = len(keep) + + elif ch == "rotation_euler" and len(curves) == 3: + es = [Euler((col(0, i), col(1, i), col(2, i))) for i in range(n)] + keep = rdp(n, euler_error_fn(es), rot_tol) + prune_group(curves, keep) + kept = len(keep) + + elif ch in ("location", "scale") and len(curves) == 3: + vs = [Vector((col(0, i), col(1, i), col(2, i))) for i in range(n)] + tol = loc_tol if ch == "location" else args.scale_tol + keep = rdp(n, vec_error_fn(vs), tol) + prune_group(curves, keep) + kept = len(keep) + + per_channel[ch][1] += kept * len(curves) + + after = sum(len(fc.keyframe_points) for fc in fcurves) + return before, after, per_channel + + +def align_group_times(fcurves): + """glTF braucht pro Kanal identische Key-Zeiten ueber alle Komponenten.""" + fixed = 0 + for path, curves in group_curves(fcurves).items(): + times = [tuple(round(kp.co[0], 5) for kp in fc.keyframe_points) + for fc in curves.values()] + if len(set(times)) <= 1: + continue + union = sorted(set().union(*[set(t) for t in times])) + for fc in curves.values(): + have = {round(kp.co[0], 5) for kp in fc.keyframe_points} + for t in union: + if t not in have: + fc.keyframe_points.insert(t, fc.evaluate(t), options={'FAST'}) + fc.update() + fixed += 1 + return fixed + + +def finalize_curves(fcurves, nd): + for fc in fcurves: + for kp in fc.keyframe_points: + kp.interpolation = 'LINEAR' + if nd > 0: + kp.co[1] = round(kp.co[1], nd) + fc.update() + + +def bone_key_histogram(fcurves): + """Keys pro Bone, absteigend. Zeigt, wo die Daten wirklich stecken.""" + hist = {} + for fc in fcurves: + b = bone_name_of(fc.data_path) or "" + hist[b] = hist.get(b, 0) + len(fc.keyframe_points) + return sorted(hist.items(), key=lambda kv: -kv[1]) + + +# ============================================================================ +# Samplerate +# ============================================================================ + +def effective_loc_tol(args, unit_scale, obj_scale): + """Rechnet --loc-tol-mm in Blender-Einheiten um. + + Der FBX-Import legt die Umrechnung Unreal-cm -> Meter NICHT in die + Bone-Werte, sondern in die Objekt-Skalierung des Armatures (typisch 0.01). + Ein Keyframe-Wert von 1.0 entspricht daher 1.0 * obj_scale * scale_length + Metern. Wer das uebersieht, arbeitet um Faktor 100 zu streng. + """ + if args.loc_tol is not None: + return args.loc_tol, "direkt vorgegeben" + + factor = abs(obj_scale) * abs(unit_scale) + if factor < 1e-9: + return args.loc_tol_mm / 1000.0, "Skalierung unbrauchbar, 1 BU = 1 m angenommen" + tol = (args.loc_tol_mm / 1000.0) / factor + return tol, "aus %.4g mm" % args.loc_tol_mm + + +def mean_scale(vec): + """Mittlere Achsskalierung. Nicht-uniforme Rigs sind selten, aber moeglich.""" + vals = [abs(v) for v in vec] + return sum(vals) / len(vals) if vals else 1.0 + + +def detect_rate(scene, fcurves): + """Liefert (fps, key_step, effektive Keys pro Sekunde). + + Unreal backt normalerweise einen Key pro Frame, dann ist key_step 1.0 und + die effektive Rate gleich der Szenen-FPS. Ein key_step von 2.0 bedeutet, + dass die Quelle schon halbiert wurde.""" + fps = scene.render.fps / max(1e-9, scene.render.fps_base) + step = 1.0 + best = None + for path, curves in group_curves(fcurves).items(): + if channel_of(path) not in ("rotation_quaternion", "rotation_euler"): + continue + fc = next(iter(curves.values())) + if best is None or len(fc.keyframe_points) > len(best.keyframe_points): + best = fc + if best is not None and len(best.keyframe_points) > 2: + frames = [kp.co[0] for kp in best.keyframe_points] + diffs = sorted(round(b - a, 4) for a, b in zip(frames, frames[1:])) + step = diffs[len(diffs) // 2] or 1.0 + return fps, step, fps / step + + +# ============================================================================ +# Szene / Bones +# ============================================================================ + +def wipe_scene(): + with quiet_stdout(): + bpy.ops.wm.read_factory_settings(use_empty=True) + for coll in (bpy.data.actions, bpy.data.objects, bpy.data.armatures, + bpy.data.meshes, bpy.data.materials, bpy.data.images, + bpy.data.textures, bpy.data.node_groups): + for item in list(coll): + try: + coll.remove(item) + except Exception: + pass + + +def enter_edit(arm): + bpy.context.view_layer.objects.active = arm + arm.select_set(True) + bpy.ops.object.mode_set(mode='EDIT') + + +def drop_bones_by_prefix(arm, prefixes): + prefixes = [p.strip() for p in prefixes.split(",") if p.strip()] + if not prefixes: + return 0 + enter_edit(arm) + eb = arm.data.edit_bones + victims = [b for b in eb if any(b.name.startswith(p) for p in prefixes)] + for b in victims: + eb.remove(b) + bpy.ops.object.mode_set(mode='OBJECT') + return len(victims) + + +def drop_nondeform_subtrees(arm): + """Loescht nur Bones, deren KOMPLETTER Teilbaum keinen Deform-Bone enthaelt. + Zwischenknoten bleiben stehen - sonst waeren die lokalen Transformationen + der Kinder falsch.""" + enter_edit(arm) + eb = arm.data.edit_bones + cache = {} + + def has_deform(b): + if b.name in cache: + return cache[b.name] + cache[b.name] = True # Zyklusschutz + r = bool(b.use_deform) or any(has_deform(ch) for ch in b.children) + cache[b.name] = r + return r + + victims = [b for b in eb if not has_deform(b)] + names = {b.name for b in victims} + roots = [b for b in victims if b.parent is None or b.parent.name not in names] + for b in roots: + eb.remove(b) + bpy.ops.object.mode_set(mode='OBJECT') + return len(victims) + + +def purge_non_armature(): + for ob in list(bpy.data.objects): + if ob.type != 'ARMATURE': + bpy.data.objects.remove(ob, do_unlink=True) + for coll in (bpy.data.meshes, bpy.data.materials, bpy.data.images, + bpy.data.textures, bpy.data.node_groups): + for item in list(coll): + if item.users == 0: + coll.remove(item) + + +# ============================================================================ +# Export +# ============================================================================ + +EXT_OF = {"GLB": ".glb", "GLTF": ".gltf", "FBX": ".fbx"} + + +def export_gltf(dst, args): + kw = dict( + filepath=dst, + export_format='GLB' if args.format == 'GLB' else 'GLTF_SEPARATE', + use_selection=False, + export_apply=False, + export_yup=True, + export_extras=False, + export_cameras=False, + export_lights=False, + export_materials='NONE', + export_image_format='NONE', + export_texcoords=False, + export_normals=False, + export_tangents=False, + export_colors=False, + export_morph=False, + export_skins=not args.anim_only, + export_animations=True, + export_animation_mode='ACTIVE_ACTIONS', + export_force_sampling=False, # Kernpunkt: unsere Keys bleiben + export_bake_animation=False, + export_anim_single_armature=True, + export_optimize_animation_size=True, + export_optimize_animation_keep_anim_armature=False, + export_optimize_animation_keep_anim_object=False, + export_current_frame=False, + export_nla_strips=False, + export_def_bones=False, + export_hierarchy_full_collections=False, + ) + with quiet_stdout(): + bpy.ops.export_scene.gltf(**filter_kwargs(bpy.ops.export_scene.gltf, kw)) + + +def export_fbx(dst, args): + kw = dict( + filepath=dst, + use_selection=False, + object_types={'ARMATURE'} if args.anim_only else {'ARMATURE', 'MESH'}, + add_leaf_bones=False, + use_custom_props=False, + use_mesh_modifiers=False, + path_mode='STRIP', + embed_textures=False, + bake_anim=True, + bake_anim_use_all_bones=False, + bake_anim_use_nla_strips=False, + bake_anim_use_all_actions=False, + bake_anim_force_startend_keying=False, + bake_anim_step=1.0, + bake_anim_simplify_factor=1.0, + axis_forward='-Z', + axis_up='Y', + ) + with quiet_stdout(): + bpy.ops.export_scene.fbx(**filter_kwargs(bpy.ops.export_scene.fbx, kw)) + + +def resolve_written(path, fmt): + """Blenders Exporter erzwingen ihre Endung via bpy.path.ensure_ext(). + Aus 'Name.glb.part' wird dadurch 'Name.glb.part.glb'. Hier wird ermittelt, + welche Datei wirklich auf der Platte gelandet ist.""" + if os.path.exists(path): + return path + ext = EXT_OF[fmt] + for cand in (path + ext, os.path.splitext(path)[0] + ext): + if os.path.exists(cand): + return cand + raise RuntimeError("Export hat keine Datei erzeugt (%s)" % os.path.basename(path)) + + +# ============================================================================ +# gltfpack +# ============================================================================ + +def gltfpack_help(exe): + """Hilfetext von gltfpack, um vorhandene Optionen zu erkennen.""" + import subprocess + try: + r = subprocess.run([exe, "-h"], capture_output=True, text=True, timeout=30) + except Exception: + return "" + return (r.stdout or "") + (r.stderr or "") + + +def adjust_pack_args(exe, args_str): + """Passt die gltfpack-Argumente an das an, was das Binary wirklich kann, + und erzwingt -kn. + + -kn ist bei Anim-only-GLB nicht optional: ohne Mesh und Skin referenziert + nichts die Bone-Nodes, und gltfpack wuerde die Hierarchie wegprunen. + Danach findet three.js die Tracks nicht mehr.""" + import shlex + text = gltfpack_help(exe) + toks = shlex.split(args_str) + notes = [] + + if text: + for i, t in enumerate(toks): + if t in ("-cz", "-ce") and t not in text: + toks[i] = "-cc" + notes.append("%s wird von diesem gltfpack nicht unterstuetzt, " + "ersetzt durch -cc" % t) + if "-cz" in text and not any(t in ("-c", "-cc", "-cz", "-ce") for t in toks): + notes.append("keine Kompressionsoption gesetzt - -cc oder -cz waere " + "deutlich kleiner") + + if "-kn" not in toks: + toks.append("-kn") + notes.append("-kn ergaenzt (Pflicht, sonst prunt gltfpack die Bones weg)") + + return " ".join(toks), notes + + +def gltfpack_version(exe): + import subprocess + try: + r = subprocess.run([exe, "-v"], capture_output=True, text=True, timeout=30) + out = (r.stdout or r.stderr or "").strip().splitlines() + return out[0] if out else "unbekannt" + except Exception as e: + return "nicht aufrufbar (%s)" % e + + +def run_gltfpack(path, exe, extra_args): + """Zweiter Durchlauf: Quantisierung + EXT_meshopt_compression. + Bei Fehlschlag bleibt die unkomprimierte Datei erhalten.""" + import subprocess + import shlex + tmp = path + ".pack.glb" + cmd = [exe, "-i", path, "-o", tmp] + shlex.split(extra_args) + logf(" gltfpack: %s" % " ".join(cmd)) + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + except Exception as e: + return None, "gltfpack nicht ausfuehrbar: %s" % e + if (r.stdout or "").strip(): + logf(" " + (r.stdout or "").strip().replace("\n", "\n ")) + if (r.stderr or "").strip(): + logf(" stderr: " + (r.stderr or "").strip().replace("\n", "\n ")) + if r.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0: + if os.path.exists(tmp): + try: + os.remove(tmp) + except Exception: + pass + err = (r.stderr or r.stdout or "").strip().splitlines() + return None, err[-1] if err else "gltfpack rc=%d" % r.returncode + before = os.path.getsize(path) + os.remove(path) + shutil.move(tmp, path) + return before, None + + +# ============================================================================ +# Eine Datei +# ============================================================================ + +def process_one(src, dst, args, phase=lambda s: None): + phase("Szene leeren") + wipe_scene() + + phase("Import") + with quiet_stdout(): + bpy.ops.import_scene.fbx(**filter_kwargs(bpy.ops.import_scene.fbx, dict( + filepath=src, + use_anim=True, + ignore_leaf_bones=True, + automatic_bone_orientation=False, + use_custom_props=False, + ))) + + arms = [o for o in bpy.data.objects if o.type == 'ARMATURE'] + if not arms: + raise RuntimeError("kein Armature im FBX") + arm = arms[0] + n_bones_in = len(arm.data.bones) + + phase("Bones") + dropped = 0 + if args.drop_bones: + dropped += drop_bones_by_prefix(arm, args.drop_bones) + if args.deform_only: + dropped += drop_nondeform_subtrees(arm) + if args.anim_only: + purge_non_armature() + + fcurves = action_fcurves(arm) + if fcurves is None: + raise RuntimeError("keine Action / keine Animation") + + fps, step, rate = detect_rate(bpy.context.scene, fcurves) + scn = bpy.context.scene + frame_span = (scn.frame_start, scn.frame_end) + unit_scale = scn.unit_settings.scale_length or 1.0 + obj_scale = tuple(round(v, 6) for v in arm.scale) + loc_tol, loc_tol_src = effective_loc_tol(args, unit_scale, mean_scale(arm.scale)) + nonuniform = max(obj_scale) - min(obj_scale) > 1e-6 + n_curves_in = len(fcurves) + + phase("Bereinigen") + dead = remove_dead_fcurves(arm, fcurves) + removed, collapsed = collapse_constants(action_fcurves(arm), + args.scale_snap) + + before, _after_dec, per_ch = decimate( + action_fcurves(arm), args, loc_tol, + tick=lambda f: phase("Dezimieren %d%%" % int(f * 100))) + + phase("Finalisieren") + aligned = align_group_times(action_fcurves(arm)) + # erst jetzt zaehlen: das Angleichen kann Keys hinzufuegen + after = sum(len(fc.keyframe_points) for fc in action_fcurves(arm)) + finalize_curves(action_fcurves(arm), args.nd) + + hist = bone_key_histogram(action_fcurves(arm)) if args.report else [] + + phase("Export") + if args.format == 'FBX': + export_fbx(dst, args) + else: + export_gltf(dst, args) + + return dict( + bones_in=n_bones_in, bones_dropped=dropped, bones_out=len(arm.data.bones), + curves_in=n_curves_in, curves_out=len(action_fcurves(arm)), + dead_curves=dead, const_removed=removed, const_collapsed=collapsed, + aligned=aligned, keys_before=before, keys_after=after, + per_channel=per_ch, histogram=hist, + fps=fps, key_step=step, rate=rate, frame_span=frame_span, + unit_scale=unit_scale, obj_scale=obj_scale, + loc_tol=loc_tol, loc_tol_src=loc_tol_src, nonuniform=nonuniform, + written=resolve_written(dst, args.format)) + + +# ============================================================================ +# Main +# ============================================================================ + +def sanitize_root(raw): + """Faengt den klassischen Windows-Batch-Fehler ab: '%~dp0' endet mit einem + Backslash, der in "..\\" das Anfuehrungszeichen escapet - dann landet die + halbe Kommandozeile im Pfad.""" + root = raw.strip().strip('"').rstrip("\\/") + if " --" in root or "\n" in root: + cut = root.split(" --")[0].rstrip('" \\/') + log("") + log(" ! Das --root Argument wurde von cmd.exe zerschossen:") + log(" %s" % ellipsis(raw, 70)) + log(" Ursache: ein Backslash direkt vor dem schliessenden \".") + log(" In der .bat --root \"%ROOTA%\" statt --root \"%ROOT%\" verwenden.") + log(" Ich arbeite jetzt mit: %s" % cut) + log("") + root = cut + return os.path.abspath(root) + + +def short_error(exc): + """Blender packt ganze Tracebacks in die Exception-Message. Sucht die + Zeile, die tatsaechlich die Ursache benennt.""" + lines = [l.strip() for l in str(exc).splitlines() if l.strip()] + if not lines: + return exc.__class__.__name__ + lines = [re.sub(r"[A-Za-z]:[\\/][^'\"]*[\\/]", lambda m: "...\\", l) + for l in lines] + for l in reversed(lines): + if l.startswith(("File ", "Location:", "Traceback", "Error: Python")): + continue + if "Error" in l or "error" in l or ":" not in l: + return l + return lines[0] + + +def log_environment(args, in_dir, out_dir): + logf("=" * 78) + logf(" anim_optimize Protokoll") + logf(" Zeit : %s" % time.strftime("%Y-%m-%d %H:%M:%S")) + logf(" Blender : %s" % bpy.app.version_string) + logf(" Python : %s" % sys.version.split()[0]) + logf(" Plattform : %s" % sys.platform) + logf(" Quelle : %s" % in_dir) + logf(" Ziel : %s" % out_dir) + logf("-" * 78) + logf(" Einstellungen") + for k in sorted(vars(args)): + logf(" %-18s %s" % (k, getattr(args, k))) + if args.gltfpack: + logf(" %-18s %s" % ("gltfpack-version", gltfpack_version(args.gltfpack))) + logf("=" * 78) + logf("") + + +def log_file_detail(base, src, dst, st, s_in, s_out, pack_before, secs, args): + logf("") + logf("-" * 78) + logf(" DATEI %s" % base) + logf("-" * 78) + logf(" Quelle %s (%.3f MB)" % (os.path.basename(src), s_in / 1e6)) + logf(" Ziel %s (%.3f MB)" % (os.path.basename(dst), s_out / 1e6)) + if pack_before: + logf(" gltfpack %.3f MB -> %.3f MB (%.1f%%)" + % (pack_before / 1e6, s_out / 1e6, s_out / pack_before * 100)) + logf(" Dauer %.2f s" % secs) + logf("") + logf(" Samplerate %.4g fps, Key-Step %.4g -> %.4g Keys/s" + % (st["fps"], st["key_step"], st["rate"])) + logf(" Frame-Bereich %s bis %s" % st["frame_span"]) + sc = sum(abs(v) for v in st["obj_scale"]) / 3.0 + mm = st["loc_tol"] * sc * st["unit_scale"] * 1000.0 + logf(" Einheiten scale_length %.6g, Armature-Scale %s" + % (st["unit_scale"], st["obj_scale"])) + logf(" 1 Keyframe-Einheit = %.4g mm" + % (sc * st["unit_scale"] * 1000.0)) + logf(" Positionstol. %.6g BU = %.4g mm (%s)" + % (st["loc_tol"], mm, st["loc_tol_src"])) + if st["nonuniform"]: + logf(" ! Armature-Scale ist nicht uniform - die mm-Umrechnung nutzt " + "den Mittelwert") + logf(" Bones %d importiert, %d entfernt, %d exportiert" + % (st["bones_in"], st["bones_dropped"], st["bones_out"])) + logf(" F-Curves %d -> %d (%d verwaist, %d konstant entfernt, " + "%d auf 1 Key)" % (st["curves_in"], st["curves_out"], st["dead_curves"], + st["const_removed"], st["const_collapsed"])) + if st["aligned"]: + logf(" ! Zeiten angeglichen in %d Kanalgruppen - diese Gruppen waren" + % st["aligned"]) + logf(" inkonsistent und wurden NICHT dezimiert. Sollte 0 sein.") + kb, ka = st["keys_before"], st["keys_after"] + logf(" Keys %d -> %d (%.1f%% entfernt)" + % (kb, ka, (1 - ka / max(1, kb)) * 100)) + for ch, (b, a) in sorted(st["per_channel"].items()): + logf(" %-22s %7d -> %7d (%.1f%%)" + % (ch, b, a, (1 - a / max(1, b)) * 100)) + + if st["histogram"]: + logf("") + logf(" Keys pro Bone (Top %d von %d):" + % (min(args.report_top, len(st["histogram"])), len(st["histogram"]))) + total = sum(v for _, v in st["histogram"]) or 1 + for name, cnt in st["histogram"][:args.report_top]: + logf(" %-34s %6d %5.1f%%" % (ellipsis(name, 34), cnt, + cnt / total * 100)) + + +def main(): + args = ARGS + root = sanitize_root(args.root) + in_dir = os.path.join(root, args.in_dir) + out_dir = os.path.join(root, args.out_dir) + ext = EXT_OF[args.format] + + os.makedirs(in_dir, exist_ok=True) + os.makedirs(out_dir, exist_ok=True) + + log_path = os.path.join(root, args.log) if args.log else "" + open_log(log_path) + + # Reste abgebrochener Laeufe entfernen + for f in os.listdir(out_dir): + if ".part" in f: + try: + os.remove(os.path.join(out_dir, f)) + except Exception: + pass + + if args.gltfpack: + fixed, notes = adjust_pack_args(args.gltfpack, args.gltfpack_args) + if fixed != args.gltfpack_args: + args.gltfpack_args = fixed + pack_notes = notes + else: + pack_notes = [] + + log_environment(args, in_dir, out_dir) + for n in pack_notes: + logf(" gltfpack-Hinweis: %s" % n) + + files = sorted(f for f in os.listdir(in_dir) if f.lower().endswith(".fbx")) + if not files: + log("\n Keine FBX in %s gefunden.\n" % in_dir) + close_log() + return + + sizes = {f: os.path.getsize(os.path.join(in_dir, f)) for f in files} + todo = [f for f in files + if args.force or not os.path.exists( + os.path.join(out_dir, os.path.splitext(f)[0] + ext))] + skipped_upfront = [f for f in files if f not in todo] + + log("") + log(c(CYAN, "=" * 78)) + log(" Quelle : %s" % c(DIM, in_dir)) + log(" Ziel : %s (%s)" % (c(DIM, out_dir), c(BOLD, args.format))) + log(" Dateien: %d gesamt, %d zu bauen, %d uebersprungen" + % (len(files), len(todo), len(skipped_upfront))) + loc_txt = ("%.5f BU (fest)" % args.loc_tol) if args.loc_tol is not None \ + else ("%.4g mm" % args.loc_tol_mm) + log(" Tol. : rot %.3f deg | loc %s | loose %.3f deg (%s)" + % (args.rot_tol, loc_txt, args.loose_rot_tol, args.loose_bones)) + if args.gltfpack: + log(" Packing: %s %s" % (c(GREEN, "aktiv"), c(DIM, args.gltfpack_args))) + for n in pack_notes: + log(" %s" % c(YELLOW, n)) + log(c(CYAN, "=" * 78)) + + if not todo: + log(" Nichts zu tun - alle Ziele existieren bereits.") + log(c(CYAN, "=" * 78) + "\n") + close_log() + return + + prog = Progress(len(files), sum(sizes[f] for f in files), + enabled=not args.no_progress) + for f in skipped_upfront: + prog.file_skipped(sizes[f]) + + done = failed = 0 + tot_in = tot_out = 0 + packed_saved = packed_fail = 0 + out_sizes = [] # (Basisname, Groesse) je geschriebener Datei + warnings = [] + idx = len(skipped_upfront) + + for name in todo: + idx += 1 + base = os.path.splitext(name)[0] + src = os.path.join(in_dir, name) + dst = os.path.join(out_dir, base + ext) + # Endung MUSS schon stimmen, sonst haengt ensure_ext() im Exporter + # noch ein '.glb' an und die Datei liegt woanders als erwartet. + tmp = os.path.join(out_dir, base + ".part" + ext) + + t0 = time.time() + pack_before = None + try: + st = process_one(src, tmp, args, + phase=lambda p, i=idx, b=base: prog.status(i, b, p)) + written = st["written"] + if os.path.exists(dst): + os.remove(dst) + shutil.move(written, dst) + + if args.gltfpack and args.format != 'FBX': + prog.status(idx, base, "gltfpack") + pack_before, err = run_gltfpack(dst, args.gltfpack, + args.gltfpack_args) + if err: + packed_fail += 1 + warnings.append("%s: gltfpack -> %s" % (base, ellipsis(err, 44))) + else: + packed_saved += pack_before - os.path.getsize(dst) + + if args.expect_fps > 0 and abs(st["rate"] - args.expect_fps) > 0.5: + warnings.append("%s: %.4g Keys/s statt %.4g (%.4g fps, Step %.4g)" + % (base, st["rate"], args.expect_fps, + st["fps"], st["key_step"])) + + except Exception as e: + sys.stdout = CONSOLE # falls ein Kontext haengen blieb + for leftover in (tmp, tmp + ext, os.path.splitext(tmp)[0] + ext): + if os.path.exists(leftover): + try: + os.remove(leftover) + except Exception: + pass + prog.line(" %s %-22s %s" + % (c(RED, "FAIL"), ellipsis(base, 22), + c(DIM, ellipsis(short_error(e), 50)))) + logf("") + logf("-" * 78) + logf(" FEHLER bei %s" % base) + logf("-" * 78) + logf(traceback.format_exc()) + failed += 1 + prog.file_done(sizes[name]) + continue + + s_in = sizes[name] + s_out = os.path.getsize(dst) + tot_in += s_in + tot_out += s_out + out_sizes.append((base, s_out)) + done += 1 + prog.file_done(s_in) + secs = time.time() - t0 + + if pack_before: + mid = "%6.2f ->%7s" % (pack_before / 1e6, fmt_size(s_out)) + else: + mid = "%14s" % fmt_size(s_out) + prog.line(" %s %-22s %7.2f MB ->%s %s keys %6d -> %-6d %s" + % (c(GREEN, "OK "), ellipsis(base, 22), s_in / 1e6, mid, + c(BOLD, "%5.1f%%" % ((s_out / s_in * 100) if s_in else 0.0)), + st["keys_before"], st["keys_after"], + c(DIM, "%5.1fs" % secs))) + + log_file_detail(base, src, dst, st, s_in, s_out, pack_before, secs, args) + + prog.clear() + log(c(DIM, "-" * 78)) + log(" %d gebaut, %d uebersprungen, %d fehlgeschlagen Laufzeit %s" + % (done, len(skipped_upfront), failed, fmt_time(time.time() - prog.t0))) + if tot_in: + log(" Gesamt: %.2f MB -> %.2f MB (%.1f%%, %.2f MB gespart)" + % (tot_in / 1e6, tot_out / 1e6, tot_out / tot_in * 100, + (tot_in - tot_out) / 1e6)) + if packed_saved: + log(" davon durch gltfpack: %.2f MB" % (packed_saved / 1e6)) + + if out_sizes: + vals = sorted(sz for _, sz in out_sizes) + avg = sum(vals) / len(vals) + med = vals[len(vals) // 2] if len(vals) % 2 else \ + (vals[len(vals) // 2 - 1] + vals[len(vals) // 2]) / 2 + smallest = min(out_sizes, key=lambda kv: kv[1]) + largest = max(out_sizes, key=lambda kv: kv[1]) + log("") + log(c(BOLD, " Ausgabe")) + log(" %d Datei(en) %s gesamt %s im Schnitt %s Median" + % (len(out_sizes), c(BOLD, fmt_size(sum(vals))), + c(BOLD, fmt_size(avg)), fmt_size(med))) + log(" kleinste %9s %s" % (fmt_size(smallest[1]), + c(DIM, ellipsis(smallest[0], 46)))) + log(" groesste %9s %s" % (fmt_size(largest[1]), + c(DIM, ellipsis(largest[0], 46)))) + logf("") + logf(" Alle Ergebnisdateien nach Groesse:") + for base, sz in sorted(out_sizes, key=lambda kv: -kv[1]): + logf(" %9s %s" % (fmt_size(sz), base)) + if packed_fail: + log(" gltfpack fehlgeschlagen bei %d Datei(en)" % packed_fail) + if warnings: + log("") + log(c(YELLOW, " Hinweise:")) + for w in warnings[:12]: + log(" - %s" % w) + if len(warnings) > 12: + log(" - ... und %d weitere" % (len(warnings) - 12)) + if log_path: + log("") + log(" Protokoll: %s" % c(DIM, log_path)) + log(c(CYAN, "=" * 78) + "\n") + + close_log() + if failed: + sys.exit(1) + + +if __name__ == "__main__": + try: + main() + finally: + close_log() diff --git a/Body Animation Compression/gltfpack.exe b/Body Animation Compression/gltfpack.exe new file mode 100644 index 0000000..b4544c1 Binary files /dev/null and b/Body Animation Compression/gltfpack.exe differ diff --git a/Body Animation Compression/optimize_all.bat b/Body Animation Compression/optimize_all.bat new file mode 100644 index 0000000..f2d3447 --- /dev/null +++ b/Body Animation Compression/optimize_all.bat @@ -0,0 +1,220 @@ +@echo off +setlocal enabledelayedexpansion +title Animation Optimizer - Unreal FBX to Web GLB +mode con: cols=104 lines=45 >nul 2>&1 +cls + +REM =========================================================================== +REM optimize_all.bat +REM +REM Ordnerstruktur (alles neben dieser .bat): +REM Original\ FBX aus Unreal hier reinlegen +REM Optimized\ GLB landen hier, gleiche Basisnamen +REM optimize_log.txt vollstaendiges Protokoll des letzten Laufs +REM +REM Aufruf: +REM optimize_all.bat interaktiv +REM optimize_all.bat --force ohne Nachfrage alles neu bauen +REM optimize_all.bat --report Key-Verteilung pro Bone ins Log +REM optimize_all.bat --verbose Blender-Ausgabe auf der Konsole +REM optimize_all.bat --rot-tol 0.5 Toleranz ueberschreiben +REM +REM Zusaetzliche Argumente werden an anim_optimize.py durchgereicht. +REM =========================================================================== + +set BLENDER="C:\Program Files\Blender Foundation\Blender 4.5\blender.exe" + +REM gltfpack (meshoptimizer) - optionaler zweiter Durchlauf. +REM Leer lassen zum Deaktivieren, oder Pfad zur gltfpack.exe eintragen. +REM Liegt sie neben dieser .bat oder im PATH, wird sie automatisch gefunden. +set GLTFPACK= +REM Kompression: -cc = EXT_meshopt_compression (three.js r122+ mit +REM setMeshoptDecoder). -cz = KHR_meshopt_compression, kleiner, aber neuer - +REM erst umstellen, wenn dein three.js das liest. Kennt dein gltfpack -cz +REM nicht, ersetzt das Skript es automatisch durch -cc. +set PACKOPTS=-cc -kn -af 30 + +REM --- ANSI-Farben. Faellt sauber auf farblos zurueck, wenn nicht verfuegbar. +set "ESC=" +for /f %%a in ('echo prompt $E^| cmd') do set "ESC=%%a" +if defined ESC ( + set "R=!ESC![0m" & set "B=!ESC![1m" + set "DIM=!ESC![90m" & set "CY=!ESC![96m" + set "GN=!ESC![92m" & set "YE=!ESC![93m" + set "RD=!ESC![91m" & set "WH=!ESC![97m" +) else ( + set "R=" & set "B=" & set "DIM=" & set "CY=" & set "GN=" & set "YE=" & set "RD=" & set "WH=" +) + +set "LINE=------------------------------------------------------------------------------" +set "DBL===============================================================================" + +REM ROOT = mit abschliessendem Backslash, fuer Pfad-Verkettung +REM ROOTA = OHNE Backslash, zum Uebergeben als Argument. Ein Backslash direkt +REM vor dem schliessenden " wuerde das Anfuehrungszeichen escapen und +REM die restliche Kommandozeile in ein Argument ziehen. +set ROOT=%~dp0 +set ROOTA=%ROOT:~0,-1% +set IN=%ROOT%Original +set OUT=%ROOT%Optimized + +echo. +echo %CY%%DBL%%R% +echo %B%%WH%ANIMATION OPTIMIZER%R% %DIM%Unreal FBX -^> Web GLB%R% +echo %CY%%DBL%%R% +echo. + +REM --- Blender finden -------------------------------------------------------- +if not exist %BLENDER% ( + for /d %%D in ("C:\Program Files\Blender Foundation\Blender *") do ( + if exist "%%D\blender.exe" set BLENDER="%%D\blender.exe" + ) +) +if not exist %BLENDER% ( + for %%P in (blender.exe) do if not "%%~$PATH:P"=="" set BLENDER="%%~$PATH:P" +) +if not exist %BLENDER% ( + echo %RD%[ FEHLER ]%R% Blender nicht gefunden. + echo Pfad oben in dieser .bat anpassen ^(Variable BLENDER^). + echo. + pause + exit /b 1 +) + +if not exist "%ROOT%anim_optimize.py" ( + echo %RD%[ FEHLER ]%R% anim_optimize.py fehlt. + echo Die .py muss neben dieser .bat liegen: + echo %DIM%%ROOTA%%R% + echo. + pause + exit /b 1 +) + +REM --- gltfpack suchen ------------------------------------------------------- +if not defined GLTFPACK ( + if exist "%ROOT%gltfpack.exe" set GLTFPACK=%ROOT%gltfpack.exe +) +if not defined GLTFPACK ( + for %%P in (gltfpack.exe) do if not "%%~$PATH:P"=="" set GLTFPACK=%%~$PATH:P +) +set PACKARG= +if defined GLTFPACK set PACKARG=--gltfpack "%GLTFPACK%" --gltfpack-args "%PACKOPTS%" + +if not exist "%IN%" mkdir "%IN%" +if not exist "%OUT%" mkdir "%OUT%" + +REM --- Bestand pruefen ------------------------------------------------------- +set /a TOTAL=0 +set /a EXISTING=0 +for %%F in ("%IN%\*.fbx") do ( + set /a TOTAL+=1 + if exist "%OUT%\%%~nF.glb" set /a EXISTING+=1 +) +set /a REST=TOTAL-EXISTING + +echo %DIM%Quelle %R% Original\ %B%%TOTAL%%R% FBX gefunden +echo %DIM%Ziel %R% Optimized\ %B%%EXISTING%%R% davon bereits gebaut +echo %DIM%Blender %R% %DIM%%BLENDER%%R% +if defined GLTFPACK ( + echo %DIM%gltfpack%R% %GN%aktiv%R% %DIM%%PACKOPTS%%R% +) else ( + echo %DIM%gltfpack%R% %DIM%nicht gefunden - Schritt wird uebersprungen%R% +) +echo %DIM%%LINE%%R% + +if %TOTAL%==0 ( + echo. + echo %YE%[ NICHTS ZU TUN ]%R% Keine FBX in Original\ gefunden. + echo Exporte aus Unreal dort ablegen und neu starten. + echo. + pause + exit /b 0 +) + +REM --- Wurde --force schon per Kommandozeile mitgegeben? --------------------- +set EXTRA=%* +set PREFORCE= +echo.%EXTRA%| find /i "--force" >nul && set PREFORCE=1 + +REM --- Nachfrage ------------------------------------------------------------- +if defined PREFORCE goto :run +if %EXISTING%==0 goto :run + +echo. +echo %YE%%EXISTING% von %TOTAL% Animationen liegen bereits in Optimized\%R% +echo. +echo %B%[J]%R% neu optimieren und ueberschreiben %DIM%alle %TOTAL%%R% +echo %B%[N]%R% vorhandene behalten %DIM%nur die restlichen %REST%%R% +echo %B%[A]%R% abbrechen +echo. + +where choice >nul 2>&1 +if errorlevel 1 goto :fallback + +choice /c JNA /n /m " Auswahl [J/N/A]: " +if errorlevel 3 goto :abort +if errorlevel 2 goto :run +set EXTRA=%EXTRA% --force +goto :run + +:fallback +set ANSWER= +set /p ANSWER=" Auswahl [J/N/A]: " +if /i "%ANSWER%"=="A" goto :abort +if /i "%ANSWER%"=="J" set EXTRA=%EXTRA% --force +if /i "%ANSWER%"=="Y" set EXTRA=%EXTRA% --force +goto :run + +REM --- Lauf ------------------------------------------------------------------ +:run +echo. +%BLENDER% -b -noaudio --factory-startup --python "%ROOT%anim_optimize.py" -- ^ + --root "%ROOTA%" ^ + --in-dir Original ^ + --out-dir Optimized ^ + --format GLB ^ + --rot-tol 0.25 ^ + --loc-tol-mm 0.5 ^ + --loose-rot-tol 1.0 ^ + --loose-bones twist ^ + --expect-fps 30 ^ + --scale-tol 0.0005 ^ + --scale-snap 0.001 ^ + --anim-only ^ + --round 4 ^ + --log optimize_log.txt ^ + %PACKARG% ^ + %EXTRA% + +set RC=%errorlevel% +echo %DIM%%LINE%%R% +if %RC%==0 ( + echo %GN%[ FERTIG ]%R% Alle Animationen gebaut. +) else ( + echo %RD%[ FEHLER ]%R% Mindestens eine Datei ist fehlgeschlagen. + echo Details stehen im Protokoll: + echo %DIM%%ROOT%optimize_log.txt%R% +) +echo. + +where choice >nul 2>&1 +if errorlevel 1 goto :end +choice /c LON /n /t 15 /d N /m " [L]og oeffnen [O]rdner oeffnen [N]ichts (15s) " +if errorlevel 3 goto :end +if errorlevel 2 goto :openfolder +start "" notepad "%ROOT%optimize_log.txt" +goto :end + +:openfolder +start "" explorer "%OUT%" + +:end +echo. +exit /b %RC% + +:abort +echo. +echo %DIM%Abgebrochen.%R% +echo. +timeout /t 2 >nul +exit /b 0 diff --git a/Body Animation Compression/optimize_log.txt b/Body Animation Compression/optimize_log.txt new file mode 100644 index 0000000..7f2f6a8 --- /dev/null +++ b/Body Animation Compression/optimize_log.txt @@ -0,0 +1,219 @@ +============================================================================== + anim_optimize Protokoll + Zeit : 2026-09-08 09:24:01 + Blender : 4.5.7 LTS + Python : 3.11.11 + Plattform : win32 + Quelle : C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Original + Ziel : C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized +------------------------------------------------------------------------------ + Einstellungen + anim_only True + deform_only False + drop_bones + expect_fps 30.0 + force True + format GLB + gltfpack C:\Users\TimVoelker\Desktop\Web Animation Optimierung\gltfpack.exe + gltfpack_args -cc -kn -af 30 + in_dir Original + loc_tol None + loc_tol_mm 0.5 + log optimize_log.txt + loose_bones twist + loose_rot_tol 1.0 + nd 4 + no_color False + no_progress False + out_dir Optimized + report False + report_top 20 + root C:\Users\TimVoelker\Desktop\Web Animation Optimierung + rot_tol 0.25 + scale_snap 0.001 + scale_tol 0.0005 + verbose False + gltfpack-version gltfpack 1.2 +============================================================================== + + +============================================================================== + Quelle : C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Original + Ziel : C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized (GLB) + Dateien: 6 gesamt, 6 zu bauen, 0 uebersprungen + Tol. : rot 0.250 deg | loc 0.5 mm | loose 1.000 deg (twist) + Packing: aktiv -cc -kn -af 30 +============================================================================== +FBX version: 7700 + Exporter-Parameter in dieser Blender-Version unbekannt, ignoriert: export_colors +INFO +Draco mesh compression is available, use library at C:\Program Files\Blender Foundation\Blender 4.5\4.5\scripts\addons_core\io_scene_gltf2\extern_draco.dll +09:24:04 | INFO: Starting glTF 2.0 export +09:24:05 | INFO: Finished glTF 2.0 export in 1.3714752197265625 s + gltfpack: C:\Users\TimVoelker\Desktop\Web Animation Optimierung\gltfpack.exe -i C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.glb -o C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.glb.pack.glb -cc -kn -af 30 + OK AS_MC_Nath_01_Take147~ 9.20 MB -> 0.32 -> 92 KB 1.0% keys 103163 -> 22779 4.5s + +------------------------------------------------------------------------------ + DATEI AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur +------------------------------------------------------------------------------ + Quelle AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.FBX (9.202 MB) + Ziel AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.glb (0.094 MB) + gltfpack 0.324 MB -> 0.094 MB (29.1%) + Dauer 4.53 s + + Samplerate 30 fps, Key-Step 1 -> 30 Keys/s + Frame-Bereich 1 bis 250 + Einheiten scale_length 1, Armature-Scale (0.01, 0.01, 0.01) + 1 Keyframe-Einheit = 10 mm + Positionstol. 0.05 BU = 0.5 mm (aus 0.5 mm) + Bones 322 importiert, 0 entfernt, 322 exportiert + F-Curves 3229 -> 1779 (0 verwaist, 1450 konstant entfernt, 1342 auf 1 Key) + Keys 103163 -> 22779 (77.9% entfernt) + location 8607 -> 1131 (86.9%) + rotation_quaternion 94556 -> 21648 (77.1%) +FBX version: 7700 + Exporter-Parameter in dieser Blender-Version unbekannt, ignoriert: export_colors +09:24:07 | INFO: Starting glTF 2.0 export +09:24:08 | INFO: Finished glTF 2.0 export in 0.9818012714385986 s + gltfpack: C:\Users\TimVoelker\Desktop\Web Animation Optimierung\gltfpack.exe -i C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.glb -o C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.glb.pack.glb -cc -kn -af 30 + OK AS_MC_Nath_01_Take158~ 7.43 MB -> 0.31 -> 87 KB 1.2% keys 65781 -> 19851 2.8s + +------------------------------------------------------------------------------ + DATEI AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur +------------------------------------------------------------------------------ + Quelle AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.FBX (7.434 MB) + Ziel AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur.glb (0.089 MB) + gltfpack 0.308 MB -> 0.089 MB (28.8%) + Dauer 2.85 s + + Samplerate 30 fps, Key-Step 1 -> 30 Keys/s + Frame-Bereich 1 bis 250 + Einheiten scale_length 1, Armature-Scale (0.01, 0.01, 0.01) + 1 Keyframe-Einheit = 10 mm + Positionstol. 0.05 BU = 0.5 mm (aus 0.5 mm) + Bones 322 importiert, 0 entfernt, 322 exportiert + F-Curves 3229 -> 1773 (0 verwaist, 1456 konstant entfernt, 1392 auf 1 Key) + Keys 65781 -> 19851 (69.8% entfernt) + location 6501 -> 1059 (83.7%) + rotation_quaternion 59280 -> 18792 (68.3%) +FBX version: 7700 + Exporter-Parameter in dieser Blender-Version unbekannt, ignoriert: export_colors +09:24:10 | INFO: Starting glTF 2.0 export +09:24:11 | INFO: Finished glTF 2.0 export in 0.9810314178466797 s + gltfpack: C:\Users\TimVoelker\Desktop\Web Animation Optimierung\gltfpack.exe -i C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.glb -o C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.glb.pack.glb -cc -kn -af 30 + OK AS_MC_Nath_01_Take158~ 7.43 MB -> 0.30 -> 86 KB 1.2% keys 72854 -> 17085 2.9s + +------------------------------------------------------------------------------ + DATEI AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur +------------------------------------------------------------------------------ + Quelle AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.FBX (7.434 MB) + Ziel AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur.glb (0.088 MB) + gltfpack 0.295 MB -> 0.088 MB (29.7%) + Dauer 2.88 s + + Samplerate 30 fps, Key-Step 1 -> 30 Keys/s + Frame-Bereich 1 bis 250 + Einheiten scale_length 1, Armature-Scale (0.01, 0.01, 0.01) + 1 Keyframe-Einheit = 10 mm + Positionstol. 0.05 BU = 0.5 mm (aus 0.5 mm) + Bones 322 importiert, 0 entfernt, 322 exportiert + F-Curves 3229 -> 1790 (0 verwaist, 1439 konstant entfernt, 1367 auf 1 Key) + Keys 72854 -> 17085 (76.5% entfernt) + location 5490 -> 1041 (81.0%) + rotation_quaternion 67364 -> 16044 (76.2%) +FBX version: 7700 + Exporter-Parameter in dieser Blender-Version unbekannt, ignoriert: export_colors +09:24:13 | INFO: Starting glTF 2.0 export +09:24:14 | INFO: Finished glTF 2.0 export in 1.0244910717010498 s + gltfpack: C:\Users\TimVoelker\Desktop\Web Animation Optimierung\gltfpack.exe -i C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.glb -o C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.glb.pack.glb -cc -kn -af 30 + OK AS_MC_Nath_01_Take158~ 7.82 MB -> 0.32 -> 89 KB 1.2% keys 87693 -> 21320 3.1s + +------------------------------------------------------------------------------ + DATEI AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur +------------------------------------------------------------------------------ + Quelle AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.FBX (7.821 MB) + Ziel AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur.glb (0.091 MB) + gltfpack 0.316 MB -> 0.091 MB (28.9%) + Dauer 3.09 s + + Samplerate 30 fps, Key-Step 1 -> 30 Keys/s + Frame-Bereich 1 bis 250 + Einheiten scale_length 1, Armature-Scale (0.01, 0.01, 0.01) + 1 Keyframe-Einheit = 10 mm + Positionstol. 0.05 BU = 0.5 mm (aus 0.5 mm) + Bones 322 importiert, 0 entfernt, 322 exportiert + F-Curves 3229 -> 1789 (0 verwaist, 1440 konstant entfernt, 1317 auf 1 Key) + Keys 87693 -> 21320 (75.7% entfernt) + location 7497 -> 1068 (85.8%) + rotation_quaternion 80196 -> 20252 (74.7%) +FBX version: 7700 + Exporter-Parameter in dieser Blender-Version unbekannt, ignoriert: export_colors +09:24:16 | INFO: Starting glTF 2.0 export +09:24:16 | INFO: Finished glTF 2.0 export in 0.8787093162536621 s + gltfpack: C:\Users\TimVoelker\Desktop\Web Animation Optimierung\gltfpack.exe -i C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.glb -o C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.glb.pack.glb -cc -kn -af 30 + OK AS_MC_Nath_01_Take158~ 6.94 MB -> 0.30 -> 87 KB 1.3% keys 68083 -> 17660 2.5s + +------------------------------------------------------------------------------ + DATEI AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur +------------------------------------------------------------------------------ + Quelle AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.FBX (6.937 MB) + Ziel AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur.glb (0.089 MB) + gltfpack 0.298 MB -> 0.089 MB (29.8%) + Dauer 2.53 s + + Samplerate 30 fps, Key-Step 1 -> 30 Keys/s + Frame-Bereich 1 bis 250 + Einheiten scale_length 1, Armature-Scale (0.01, 0.01, 0.01) + 1 Keyframe-Einheit = 10 mm + Positionstol. 0.05 BU = 0.5 mm (aus 0.5 mm) + Bones 322 importiert, 0 entfernt, 322 exportiert + F-Curves 3229 -> 1783 (0 verwaist, 1446 konstant entfernt, 1341 auf 1 Key) + Keys 68083 -> 17660 (74.1% entfernt) + location 5451 -> 1080 (80.2%) + rotation_quaternion 62632 -> 16580 (73.5%) +FBX version: 7700 + Exporter-Parameter in dieser Blender-Version unbekannt, ignoriert: export_colors +09:24:18 | INFO: Starting glTF 2.0 export +09:24:19 | INFO: Finished glTF 2.0 export in 0.8631854057312012 s + gltfpack: C:\Users\TimVoelker\Desktop\Web Animation Optimierung\gltfpack.exe -i C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.glb -o C:\Users\TimVoelker\Desktop\Web Animation Optimierung\Optimized\AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.glb.pack.glb -cc -kn -af 30 + OK AS_MC_Nath_01_Take160~ 7.00 MB -> 0.30 -> 87 KB 1.3% keys 95470 -> 18254 2.6s + +------------------------------------------------------------------------------ + DATEI AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur +------------------------------------------------------------------------------ + Quelle AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.FBX (6.996 MB) + Ziel AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur.glb (0.089 MB) + gltfpack 0.302 MB -> 0.089 MB (29.3%) + Dauer 2.61 s + + Samplerate 30 fps, Key-Step 1 -> 30 Keys/s + Frame-Bereich 1 bis 250 + Einheiten scale_length 1, Armature-Scale (0.01, 0.01, 0.01) + 1 Keyframe-Einheit = 10 mm + Positionstol. 0.05 BU = 0.5 mm (aus 0.5 mm) + Bones 322 importiert, 0 entfernt, 322 exportiert + F-Curves 3229 -> 1838 (0 verwaist, 1391 konstant entfernt, 1222 auf 1 Key) + Keys 95470 -> 18254 (80.9% entfernt) + location 10074 -> 1098 (89.1%) + rotation_quaternion 85396 -> 17156 (79.9%) +------------------------------------------------------------------------------ + 6 gebaut, 0 uebersprungen, 0 fehlgeschlagen Laufzeit 00:18 + Gesamt: 45.83 MB -> 0.54 MB (1.2%, 45.29 MB gespart) + davon durch gltfpack: 1.30 MB + + Ausgabe + 6 Datei(en) 527 KB gesamt 88 KB im Schnitt 87 KB Median + kleinste 86 KB AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur + groesste 92 KB AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur + + Alle Ergebnisdateien nach Groesse: + 92 KB AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur + 89 KB AS_MC_Nath_01_Take158_TalkingUp4_Cascadeur + 87 KB AS_MC_Nath_01_Take158_TalkingUp6_Cascadeur + 87 KB AS_MC_Nath_01_Take158_TalkingUp1_Cascadeur + 87 KB AS_MC_Nath_01_Take160_TalkingUp1_Cascadeur + 86 KB AS_MC_Nath_01_Take158_TalkingUp2_Cascadeur + + Protokoll: C:\Users\TimVoelker\Desktop\Web Animation Optimierung\optimize_log.txt +============================================================================== + diff --git a/Body Animation Compression/threejs-meshopt-checkliste.md b/Body Animation Compression/threejs-meshopt-checkliste.md new file mode 100644 index 0000000..ffa5408 --- /dev/null +++ b/Body Animation Compression/threejs-meshopt-checkliste.md @@ -0,0 +1,101 @@ +# three.js: meshopt-komprimierte Animations-GLB laden + +Checkliste für die Web-Animationspipeline. Die GLB-Dateien aus `Optimized/` +enthalten **nur Animation** — kein Mesh, kein Skin — und sind mit gltfpack +quantisiert und über `EXT_meshopt_compression` komprimiert. + +--- + +## 1. Decoder importieren und registrieren + +```js +import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'; +import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js'; + +await MeshoptDecoder.ready; + +const loader = new GLTFLoader(); +loader.setMeshoptDecoder(MeshoptDecoder); +``` + +Ohne `setMeshoptDecoder` schlägt das Laden fehl. three.js unterstützt +`EXT_meshopt_compression` ab **r122**. + +## 2. Loader einmal bauen und wiederverwenden + +Der Decoder ist ein WASM-Modul. Einmal instanziieren, nicht pro Datei. + +## 3. Charakter separat laden + +Mesh mit Skelett einmal laden, danach nur noch die Animations-GLBs. + +## 4. Clip auf den Charakter anwenden + +```js +const anim = await loader.loadAsync( + '/Optimized/AS_MC_Nath_01_Take147_TalkingUp2_Cascadeur.glb' +); + +const mixer = new THREE.AnimationMixer(characterRoot); +mixer.clipAction(anim.animations[0], characterRoot).play(); +``` + +Im Render-Loop nicht vergessen: + +```js +mixer.update(clock.getDelta()); +``` + +## 5. Namen prüfen — wahrscheinlichster Stolperstein + +```js +console.log(anim.animations[0].tracks.slice(0, 3).map(t => t.name)); +``` + +Erwartet: `"spine_01.quaternion"` und ähnlich. + +**Wichtig:** Findet der Mixer einen Bone-Namen nicht, spielt der Clip stumm ab +— ohne Fehlermeldung. Einmal aktiv gegenprüfen. + +Falls Namen nicht passen (three.js schreibt Punkte und Doppelpunkte um): +`SkeletonUtils.retarget` oder eine Umbenennungstabelle verwenden. + +## 6. Kein DRACOLoader nötig + +Draco komprimiert nur Geometrie. Die Animationsdateien haben keine. + +## 7. Brotli auf dem Server aktivieren + +Die meshopt-Codecs sind darauf ausgelegt, dass zusätzlich mit gzip oder Brotli +komprimiert wird — das steht ausdrücklich in der Doku. Ohne das bleibt ein Teil +des Gewinns liegen. + +## 8. Bei `-cz` aufpassen + +Falls später auf `KHR_meshopt_compression` umgestellt wird: erst prüfen, ob die +eingesetzte three.js-Version das liest. `EXT_meshopt_compression` (Flag `-cc`) +ist die abgesicherte Variante. + +--- + +## Debugging + +| Symptom | Ursache | +|---|---| +| Fehler beim Laden, Extension nicht unterstützt | `setMeshoptDecoder` fehlt | +| Clip lädt, aber nichts bewegt sich | Bone-Namen passen nicht — Punkt 5 | +| Nur Teile des Körpers animiert | Skelett-Hierarchie weicht ab | +| Datei viel größer als erwartet | Brotli auf dem Server nicht aktiv | + +## Hinweise zur Pipeline + +- Die FBX in `Original/` sind die Wahrheit. Die GLB sind Auslieferungsformat, + kein Arbeitsformat. +- Blenders eingebauter Importer kann meshopt-GLB **nicht** öffnen. Dafür gibt es + das Add-on *Compressed GLB Importer* (extensions.blender.org), oder man + dekomprimiert vorher mit glTF-Transform: + `npx @gltf-transform/cli cp packed.glb plain.glb` +- Gesichtsanimationen liegen in separaten Dateien. Dort sind die + Bone-Bewegungsumfänge klein, deshalb dort mit deutlich strengerer + `--rot-tol` starten (z. B. 0.05 statt 0.25) und erst nach Sichtprüfung + hochgehen.