feat(assets): elf Mocap-Clips als weapons-mocap.glb für das Mannequin
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa6cde79d3
commit
4422432998
4 files changed
+345
-3
No files matched your search
Binary file not shown.
+124
-3
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 <Ordner mit den FBX aus MotionLibrary_Weapons.zip>");
|
||||
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();
|
||||
Reference in new issue
Block a user