diff --git a/public/models/motions/weapons-mocap.glb b/public/models/motions/weapons-mocap.glb new file mode 100644 index 0000000..1335503 Binary files /dev/null and b/public/models/motions/weapons-mocap.glb differ diff --git a/tests/motions.test.js b/tests/motions.test.js index e9a09dd..e8c4105 100644 --- a/tests/motions.test.js +++ b/tests/motions.test.js @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; -import { MathUtils, Quaternion, Vector3 } from "three"; -import { BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward, - headingOf, removeDrift, rigWorld, sampleTimes, seamAngle, SOURCE_HIPS } from "../tools/assets/motions.mjs"; +import { AnimationClip, Bone, Group, MathUtils, Quaternion, QuaternionKeyframeTrack, Vector3, + VectorKeyframeTrack } from "three"; +import { Document } from "@gltf-transform/core"; +import { addClip, BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward, + flattenPoses, headingOf, removeDrift, rigFromScene, rigWorld, sampleFbx, sampleTimes, seamAngle, + SOURCE_HIPS, stripToSkeleton } from "../tools/assets/motions.mjs"; const turn = (x, y, z, degrees) => new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), MathUtils.degToRad(degrees)); @@ -211,3 +214,121 @@ describe("closeLoop", () => { expect(closed[3].hips.x).toBeCloseTo(0.75, 9); }); }); + +describe("sampleFbx", () => { + /** Nachbau eines MVN-FBX: Reference -> Hips -> Spine, Frame 0 in T-Pose, danach Bewegung. */ + function fakeFbx() { + const root = new Group(); + const reference = new Bone(); + reference.name = "Reference"; + const hips = new Bone(); + hips.name = "Hips"; + hips.position.set(0, 90, 0); + const spine = new Bone(); + spine.name = "Spine"; + spine.position.set(0, 10, 0); + root.add(reference); + reference.add(hips); + hips.add(spine); + const quarter = turn(0, 1, 0, 90).toArray(); + root.animations = [new AnimationClip("take", 1.008, [ + new VectorKeyframeTrack("Hips.position", [0, 0.008, 1.008], [0, 90, 0, 5, 90, 0, 5, 90, 30]), + new QuaternionKeyframeTrack("Hips.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, ...quarter]), + new QuaternionKeyframeTrack("Spine.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]), + ])]; + return root; + } + + // Bruch: Frame 0 mitgenommen — jede Schleife begänne mit einem Zucken in die T-Pose. + it("lässt Frame 0 weg, tastet bis zum Ende ab und liest die Hüfthöhe der Ruhepose", () => { + const { times, frames, hipsRestHeight } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" }); + expect(hipsRestHeight).toBe(90); + expect(times).toHaveLength(31); + expect(times[0]).toBe(0); + expect(times.at(-1)).toBeCloseTo(1, 9); + expect(frames[0].hips.x).toBeCloseTo(5, 6); + expect(frames.at(-1).hips.z).toBeCloseTo(30, 6); + }); + + it("liest Weltdrehungen, nicht lokale", () => { + const { frames } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" }); + expectSameRotation(frames.at(-1).rotations.get("Spine"), turn(0, 1, 0, 90)); + }); +}); + +describe("rigFromScene und stripToSkeleton", () => { + function skeletonDocument() { + const document = new Document(); + const root = document.createNode("root").setRotation(turn(1, 0, 0, -90).toArray()); + const pelvis = document.createNode("pelvis").setTranslation([0, 0.05, 0.917]); + const leaf = document.createNode("Head"); + root.addChild(pelvis); + pelvis.addChild(leaf); + document.getRoot().setDefaultScene(document.createScene("Armature").addChild(root)); + return document; + } + + it("liest die Knoten in Eltern-vor-Kind-Reihenfolge", () => { + const document = skeletonDocument(); + const rig = rigFromScene(document.getRoot().listScenes()[0]); + expect(rig.map((bone) => [bone.name, bone.parent])) + .toEqual([["root", null], ["pelvis", "root"], ["Head", "pelvis"]]); + expect(rig[1].position).toEqual([0, 0.05, 0.917]); + }); + + // Bruch: Blattknoten weggeprunt — Head und die Fingerknochen fehlten als Ziel der Clips. + it("räumt Clips und Geometrie weg, behält aber jeden Knoten", async () => { + const document = skeletonDocument(); + const mesh = document.createMesh("Mannequin"); + document.getRoot().listNodes()[1].setMesh(mesh); + document.createAnimation("Idle_Loop"); + await stripToSkeleton(document); + expect(document.getRoot().listAnimations()).toHaveLength(0); + expect(document.getRoot().listMeshes()).toHaveLength(0); + expect(document.getRoot().listNodes().map((node) => node.getName())).toEqual(["root", "pelvis", "Head"]); + }); +}); + +describe("addClip", () => { + it("legt einen Zeit-Accessor, Drehungen je Knochen und die Hüfte als Translation an", () => { + const document = new Document(); + const pelvis = document.createNode("pelvis"); + const spine = document.createNode("spine_01"); + document.getRoot().setDefaultScene(document.createScene().addChild(pelvis.addChild(spine))); + const poses = [0, 1].map((i) => ({ + local: new Map([["pelvis", new Quaternion()], ["spine_01", turn(0, 0, 1, 10 * i)]]), + hips: new Vector3(0, 0.9 + i, 0), + })); + const animation = addClip(document, "test_tk01", [0, 0.5], poses, "pelvis"); + + expect(animation.getName()).toBe("test_tk01"); + expect(animation.listChannels()).toHaveLength(3); + expect(new Set(animation.listSamplers().map((sampler) => sampler.getInput())).size).toBe(1); + const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation"); + expect(translation.getTargetNode().getName()).toBe("pelvis"); + // Float32Array-Speicher: 0.9 und 1.9 runden beim Schreiben, darum hier durch Math.fround vergleichen statt + // mit den float64-Literalen (Abweichung vom Brief, siehe task-5-report.md). + expect([...translation.getSampler().getOutput().getArray()]) + .toEqual([0, Math.fround(0.9), 0, 0, Math.fround(1.9), 0]); + }); + + it("meldet einen fehlenden Knoten", () => { + const document = new Document(); + document.getRoot().setDefaultScene(document.createScene().addChild(document.createNode("pelvis"))); + const poses = [{ local: new Map([["fehlt", new Quaternion()]]), hips: new Vector3() }]; + expect(() => addClip(document, "test", [0], poses, "pelvis")).toThrow(/Skelett ohne Knoten fehlt/); + }); +}); + +describe("flattenPoses", () => { + it("reiht Drehungen als xyzw und die Hüfte als xyz je Bild auf", () => { + const poses = [ + { local: new Map([["pelvis", new Quaternion(0, 0, 0, 1)]]), hips: new Vector3(1, 2, 3) }, + { local: new Map([["pelvis", new Quaternion(0.5, 0, 0, 0.8660254)]]), hips: new Vector3(4, 5, 6) }, + ]; + const { rotations, hips } = flattenPoses(poses); + expect([...hips]).toEqual([1, 2, 3, 4, 5, 6]); + expect([...rotations.get("pelvis")].slice(0, 4)).toEqual([0, 0, 0, 1]); + expect([...rotations.get("pelvis")][4]).toBeCloseTo(0.5, 6); + }); +}); diff --git a/tests/showroom.test.js b/tests/showroom.test.js index c1723c2..ea15845 100644 --- a/tests/showroom.test.js +++ b/tests/showroom.test.js @@ -1,3 +1,4 @@ +import { statSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; import { NodeIO } from "@gltf-transform/core"; @@ -44,3 +45,79 @@ describe.each(WEAPON_SOURCES.map((source) => [source.id, source]))("%s.glb", (id expect(length).toBeLessThanOrEqual(source.length[1]); }); }); + +/** Die elf Clips, benannt nach den Dateien im Pack. */ +const CLIP_IDS = [ + "hunter_bowArrow_tk01", + "idle_loadGun-Hand_tk01", "idle_loadGun-Hand_tk02", + "idle_loadGun-Rifle_tk04", "idle_loadGun-Rifle_tk05", + "leftHandThrow_grenade_tk01", "leftHandThrow_grenade_tk02", + "stabTwist_Knife_tk01", "stabTwist_Knife_tk02", + "taunt_shootSelfInHead_tk01", "taunt_shootSelfInHead_tk02", +]; + +describe("weapons-mocap.glb", () => { + const file = fileURLToPath(new URL("../public/models/motions/weapons-mocap.glb", import.meta.url)); + let animations; + beforeAll(async () => { + const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(file); + animations = document.getRoot().listAnimations(); + }, 30000); + + it("ist kleiner als 3 MB", () => { + expect(statSync(file).size).toBeLessThan(3e6); + }); + + it("enthält die elf Clips", () => { + expect(animations.map((animation) => animation.getName()).sort()).toEqual([...CLIP_IDS].sort()); + }); + + // Bruch: ein Knochen ohne Spur — die Figur bliebe dort in der Ruhepose stehen. + it("hat je Clip 22 Drehungen und die Beckenposition", () => { + for (const animation of animations) { + const channels = animation.listChannels(); + expect(channels.filter((channel) => channel.getTargetPath() === "rotation")).toHaveLength(22); + expect(channels.filter((channel) => channel.getTargetPath() === "translation") + .map((channel) => channel.getTargetNode().getName())).toEqual(["pelvis"]); + } + }); + + // Bruch: Schleife nicht geschlossen — bei jedem Rücksprung ein sichtbarer Ruck. Bei Drehungen zählt dafür nur + // die Drehung selbst, nicht ihr Vorzeichen: q und −q stellen dieselbe Rotation dar, und three.js' slerp + // liefert bei Gewicht 1 die dem Ausgangswert näherliegende der beiden Varianten (Quaternion.slerp kennt + // keinen Sonderfall für t=1). Darum wird das letzte Element vor dem Vergleich gespiegelt, wenn sein + // Skalarprodukt mit dem ersten negativ ist — eine echte Winkelabweichung am Schleifenende bleibt davon + // unberührt und schlägt weiterhin an. Bei Positionen (kein Vorzeichen-Zwilling) bleibt der Vergleich exakt. + it("endet jeder Kanal dort, wo er beginnt", () => { + for (const animation of animations) { + for (const channel of animation.listChannels()) { + const output = channel.getSampler().getOutput(); + const first = output.getElement(0, []); + const last = output.getElement(output.getCount() - 1, []); + const dot = first.reduce((sum, value, i) => sum + value * last[i], 0); + const sign = channel.getTargetPath() === "rotation" && dot < 0 ? -1 : 1; + for (let i = 0; i < first.length; i++) expect(sign * last[i]).toBeCloseTo(first[i], 5); + } + } + }); + + // Bruch: Drift nicht abgezogen — der Dummy wanderte von seinem Platz. + it("beginnt jede Beckenbahn waagrecht bei 0", () => { + for (const animation of animations) { + const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation"); + const [x, y] = translation.getSampler().getOutput().getElement(0, []); + expect(x).toBeCloseTo(0, 5); + expect(y).toBeCloseTo(0, 5); + } + }); + + it("tastet mit 30 Bildern je Sekunde ab", () => { + for (const animation of animations) { + const times = animation.listSamplers()[0].getInput(); + const values = [...times.getArray()]; + const duration = values.at(-1); + expect(values).toHaveLength(Math.round(duration * 30) + 1); + expect(values[0]).toBe(0); + } + }); +}); diff --git a/tools/assets/motions.mjs b/tools/assets/motions.mjs index 9701033..df00507 100644 --- a/tools/assets/motions.mjs +++ b/tools/assets/motions.mjs @@ -6,6 +6,12 @@ // // Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main() // liest nur Dateien und schreibt das Ergebnis. +import { mkdir, readdir, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { NodeIO } from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { prune } from "@gltf-transform/functions"; import { MathUtils, Matrix4, Quaternion, Vector3 } from "three"; /** @@ -184,3 +190,141 @@ export function closeLoop(times, poses, blend) { }; }); } + +/** + * Liest den ersten Clip eines FBX-Objekts ab: n gleichmäßige Zeitpunkte von Frame 1 bis zum Ende (Frame 0 ist die + * T-Pose, 8 ms davor). Je Zeitpunkt die Weltdrehungen der zugeordneten MVN-Knochen und die Hüft-Weltposition in cm. + * times beginnt bei 0. hipsRestHeight ist die Hüfthöhe der Ruhepose in cm, abgelesen bevor ein Bild gesetzt wird — + * die beiden Aufnahmetage liegen dort 0,9 cm auseinander. + */ +export function sampleFbx(root, rate = SAMPLE_RATE, boneMap = BONE_MAP) { + const clip = root.animations?.[0]; + if (!clip) throw new Error("FBX ohne Animation"); + const hips = root.getObjectByName(SOURCE_HIPS); + if (!hips) throw new Error(`FBX ohne Knochen ${SOURCE_HIPS}`); + root.updateMatrixWorld(true); + const hipsRestHeight = hips.getWorldPosition(new Vector3()).y; + + const bindings = clip.tracks.map((track) => { + const dot = track.name.lastIndexOf("."); + const object = root.getObjectByName(track.name.slice(0, dot)); + if (!object) throw new Error(`Spur ohne Knochen: ${track.name}`); + return { target: object[track.name.slice(dot + 1)], interpolant: track.createInterpolant() }; + }); + const hipsTimes = clip.tracks.find((track) => track.name === `${SOURCE_HIPS}.quaternion`)?.times; + if (!hipsTimes || hipsTimes.length < 3) throw new Error("Hüftspur fehlt oder ist zu kurz"); + const bones = Object.keys(boneMap).map((name) => { + const bone = root.getObjectByName(name); + if (!bone) throw new Error(`FBX ohne Knochen ${name}`); + return bone; + }); + + const start = hipsTimes[1]; + const absolute = sampleTimes(start, clip.duration, rate); + const frames = absolute.map((time) => { + for (const { target, interpolant } of bindings) target.fromArray(interpolant.evaluate(time)); + root.updateMatrixWorld(true); + return { + rotations: new Map(bones.map((bone) => [bone.name, bone.getWorldQuaternion(new Quaternion())])), + hips: hips.getWorldPosition(new Vector3()), + }; + }); + return { times: absolute.map((time) => time - start), frames, hipsRestHeight }; +} + +/** Knotenhierarchie einer gltf-transform-Szene als rig (siehe rigWorld), Eltern vor Kindern. */ +export function rigFromScene(scene) { + const rig = []; + const visit = (node, parent) => { + rig.push({ + name: node.getName(), + parent, + position: node.getTranslation(), + quaternion: node.getRotation(), + scale: node.getScale(), + }); + for (const child of node.listChildren()) visit(child, node.getName()); + }; + for (const node of scene.listChildren()) visit(node, null); + return rig; +} + +/** Entfernt aus einem gelesenen ual.glb alles außer der Knotenhierarchie: Clips, Meshes, Skins, Materialien, Texturen. */ +export async function stripToSkeleton(document) { + const root = document.getRoot(); + for (const node of root.listNodes()) node.setMesh(null).setSkin(null); + // Die Accessors müssen ausdrücklich mit weg: animation.dispose() löst seine Sampler nicht auf, prune() hält + // deren Accessors darum weiter für benutzt. Ohne diese Zeile blieben die 53 Clips von ual.glb in der Datei + // (gemessen: 8745 Accessors und 3,41 MB statt 264 und 1,66 MB). + for (const property of [...root.listAnimations(), ...root.listMeshes(), ...root.listSkins(), + ...root.listMaterials(), ...root.listTextures(), ...root.listAccessors()]) { + property.dispose(); + } + // keepLeaves: Knochen ohne Kinder (Head, Blattknochen) sind leere Blattknoten und müssen als Ziele bleiben. + await document.transform(prune({ keepLeaves: true })); +} + +/** Legt Posen als Float32-Reihen ab: je Knochen x, y, z, w pro Bild, dazu die Hüfte als x, y, z pro Bild. */ +export function flattenPoses(poses) { + const rotations = new Map([...poses[0].local.keys()].map((bone) => [bone, new Float32Array(poses.length * 4)])); + const hips = new Float32Array(poses.length * 3); + poses.forEach((pose, i) => { + for (const [bone, values] of rotations) pose.local.get(bone).toArray(values, i * 4); + pose.hips.toArray(hips, i * 3); + }); + return { rotations, hips }; +} + +/** Hängt einen Clip an das Dokument: ein Zeit-Accessor, je Knochen ein Drehungskanal, die Hüfte als Translation. */ +export function addClip(document, id, times, poses, hipsBone = BONE_MAP[SOURCE_HIPS]) { + const root = document.getRoot(); + const buffer = root.listBuffers()[0] ?? document.createBuffer(); + const nodes = new Map(root.listNodes().map((node) => [node.getName(), node])); + const accessor = (array, type) => document.createAccessor().setArray(array).setType(type).setBuffer(buffer); + const animation = document.createAnimation(id); + const input = accessor(Float32Array.from(times), "SCALAR"); + const channel = (bone, path, values, type) => { + const node = nodes.get(bone); + if (!node) throw new Error(`Skelett ohne Knoten ${bone}`); + const sampler = document.createAnimationSampler() + .setInput(input).setOutput(accessor(values, type)).setInterpolation("LINEAR"); + animation.addSampler(sampler).addChannel( + document.createAnimationChannel().setTargetNode(node).setTargetPath(path).setSampler(sampler)); + }; + const { rotations, hips } = flattenPoses(poses); + for (const [bone, values] of rotations) channel(bone, "rotation", values, "VEC4"); + channel(hipsBone, "translation", hips, "VEC3"); + return animation; +} + +async function main() { + const [sourceDir] = process.argv.slice(2); + if (!sourceDir) { + console.error("Aufruf: node tools/assets/motions.mjs "); + process.exit(1); + } + const { loadFbx } = await import("./fbx.mjs"); + const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); + const document = await io.read(fileURLToPath(new URL("../../public/models/ual.glb", import.meta.url))); + const rig = rigFromScene(document.getRoot().listScenes()[0]); + await stripToSkeleton(document); + const pelvisHeight = new Vector3().setFromMatrixPosition(rigWorld(rig).get(BONE_MAP[SOURCE_HIPS]).matrix).y; + + const files = (await readdir(sourceDir)).filter((name) => name.endsWith(".fbx")).sort(); + for (const file of files) { + const id = clipId(file); + const { times, frames, hipsRestHeight } = sampleFbx(await loadFbx(join(sourceDir, file))); + const cleaned = removeDrift(faceForward(frames)); + const blend = blendDuration(seamAngle(cleaned)); + const retarget = createRetargeter(rig, { hipsScale: pelvisHeight / (hipsRestHeight / 100) }); + addClip(document, id, times, closeLoop(times, cleaned.map(retarget), blend)); + console.log(`${id}: ${times.at(-1).toFixed(2)} s, ${times.length} Bilder, Überblendung ${blend.toFixed(2)} s`); + } + + const target = fileURLToPath(new URL("../../public/models/motions/weapons-mocap.glb", import.meta.url)); + await mkdir(dirname(target), { recursive: true }); + await io.write(target, document); + console.log(`weapons-mocap.glb ${((await stat(target)).size / 1e6).toFixed(2)} MB`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();