snapRootBoneXZ: Achsen-Fix für Mixamo-Hips (X=0, Y=0/frei, Z=frei)

In diesen Mixamo-Exporten ist Local-Y die Vorwärts-Richtung (nicht Höhe),
Local-Z die Höhe. Bisheriger Code fror Z=0 ein → Charakter 1m zu tief.
Und Y war frei → Lauf-Drift blieb.

Neue Logik:
  X → 0 (kein Seiten-Drift)
  Y → 0 für Lauf-Clips (running/walking/sprinting/running_jump), normalisiert sonst
  Z → vollständig frei (Höhe und Setz/Aufsteh-Bewegung erhalten)

Nur der flachste Bone (Hips) wird modifiziert, alle anderen unberührt.
Clips vollständig neu importiert mit korrektem Snap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 18:28:51 +02:00
parent e669e29096
commit 7a3b2b8733
21 changed files with 96 additions and 36 deletions

View File

@@ -117,7 +117,6 @@ public class AnimationLibrary extends BaseAppState {
return false;
}
target = snapRootBoneXZ(target, sc.getArmature());
ac.addAnimClip(target);
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
if (clipName.equals("sit_down")) {
@@ -129,6 +128,12 @@ public class AnimationLibrary extends BaseAppState {
/** Wendet alle geladenen Clips auf {@code model} an (nur wenn es ein Rig hat). */
public void applyAllTo(Spatial model) {
if (RetargetingSystem.findSkinningControl(model) == null) return;
AnimComposer ac = RetargetingSystem.findAnimComposer(model);
if (ac != null) {
for (AnimClip c : new java.util.ArrayList<>(ac.getAnimClips())) {
ac.removeAnimClip(c);
}
}
int applied = 0;
for (String key : clips.keySet()) {
if (applyTo(key, model)) applied++;
@@ -234,6 +239,9 @@ public class AnimationLibrary extends BaseAppState {
for (String name : ac.getAnimClipsNames()) {
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
if (armature != null) {
animClip = snapRootBoneXZ(animClip, armature);
}
clips.put(name, animClip);
if (armature != null) armatures.put(name, armature);
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
@@ -260,15 +268,21 @@ public class AnimationLibrary extends BaseAppState {
return null;
}
// Clips mit Vorwärts-Root-Motion in Local-Y (rennen, gehen, springen):
// Y wird auf 0 eingefroren → kein Drift. Alle anderen Clips: Y bleibt frei (Hinsetzen usw.)
private static final java.util.Set<String> LOCOMOTION_CLIPS = java.util.Set.of(
"running", "walking", "sprinting", "running_jump"
);
/**
* Friert X und Z des "Hüft-Knochens" auf den Wert von Frame 0 ein.
* Y (Höhenachse, JME3 Y-Up) bleibt vollständig erhalten — sit_down / Jump / Bounce laufen korrekt.
* Entfernt Root-Motion aus dem flachsten Bone mit Translation-Track (typischerweise Hips).
* Nur dieser eine Bone wird modifiziert — alle anderen Bones bleiben unverändert.
*
* Strategie: findet die kleinste Tiefe unter allen Joints die einen Translation-Track haben.
* Bei Rigs wo Root (Tiefe 0) selbst Translations hat, wird Root gesnappt.
* Bei Rigs wo Hips (Tiefe 1) die erste Ebene mit Translations ist (Root hat nur Rotation),
* wird Hips gesnappt. So passt der Snap zu beiden Rig-Strukturen.
* Erstellt einen neuen in-memory-Clip; J3O-Dateien bleiben unverändert.
* In diesen Mixamo-Exporten ist die Achsenbelegung des Hips-Bones:
* Local X → seitlich → wird auf 0 eingefroren (kein Seiten-Drift)
* Local Y → vorwärts → wird auf 0 eingefroren für Lauf-Clips (kein Vorwärts-Drift)
* bleibt frei für Sitz/Stand-Clips (leichte Neigungsbewegung)
* Local Z → Höhe → IMMER frei lassen (Charakter-Höhe und Setz-Bewegung erhalten)
*/
public static AnimClip snapRootBoneXZ(AnimClip clip, Armature armature) {
if (clip == null || armature == null) return clip;
@@ -282,7 +296,9 @@ public class AnimationLibrary extends BaseAppState {
minDepth = Math.min(minDepth, jointDepth(j));
}
}
if (minDepth == Integer.MAX_VALUE) return clip; // keine Translation-Tracks vorhanden
if (minDepth == Integer.MAX_VALUE) return clip;
boolean isLocomotion = LOCOMOTION_CLIPS.contains(clip.getName());
List<AnimTrack<?>> newTracks = new ArrayList<>();
boolean modified = false;
@@ -292,20 +308,45 @@ public class AnimationLibrary extends BaseAppState {
continue;
}
Vector3f[] translations = tt.getTranslations();
// Nur den flachsten Bone anfassen alle anderen unverändert lassen
if (translations == null || translations.length == 0 || jointDepth(j) != minDepth) {
newTracks.add(track);
continue;
}
float f0x = translations[0].x;
float f0z = translations[0].z;
// Y-Normalisierung für Nicht-Lauf-Clips: Frame-0 auf Bind-Pose-Y
float bindY = j.getInitialTransform().getTranslation().y;
float frame0Y = translations[0].y;
float yOffset = bindY - frame0Y;
// Diagnostik
float yMin = Float.MAX_VALUE, yMax = -Float.MAX_VALUE;
float xRange = 0, zRange = 0;
for (Vector3f t : translations) {
if (t.y < yMin) yMin = t.y;
if (t.y > yMax) yMax = t.y;
xRange = Math.max(xRange, Math.abs(t.x - translations[0].x));
zRange = Math.max(zRange, Math.abs(t.z - translations[0].z));
}
log.info("[AnimLib] snap '{}' root='{}' locomotion={} bindY={} frame0Y={} yOffset={} yRange={} xRange={} zRange={}",
clip.getName(), j.getName(), isLocomotion,
String.format("%.3f", bindY), String.format("%.3f", frame0Y),
String.format("%.3f", yOffset), String.format("%.3f", yMax - yMin),
String.format("%.3f", xRange), String.format("%.3f", zRange));
Vector3f[] snapped = new Vector3f[translations.length];
for (int i = 0; i < translations.length; i++) {
snapped[i] = new Vector3f(f0x, translations[i].y, f0z);
float newY = isLocomotion
? 0f // Lauf-Clips: Y einfrieren (Vorwärts-Drift weg)
: (translations[i].y + yOffset); // Rest: Y normalisiert (Neigung erhalten)
snapped[i] = new Vector3f(
0f, // X immer 0 (Seiten-Drift weg)
newY,
translations[i].z // Z immer frei (Höhe und Setz-Bewegung erhalten)
);
}
newTracks.add(new TransformTrack(j, tt.getTimes(), snapped, tt.getRotations(), tt.getScales()));
modified = true;
log.info("[AnimLib] '{}': Tiefe-{}-Joint '{}' XZ={},{} eingefroren, Y frei",
clip.getName(), minDepth, j.getName(), f0x, f0z);
}
if (!modified) return clip;
AnimClip result = new AnimClip(clip.getName());

View File

@@ -319,6 +319,7 @@ public class WorldScene extends BaseAppState {
if (mc != null && mc.getModelPath() != null) {
try {
Spatial loaded = assetManager.loadModel(mc.getModelPath());
stripEmbeddedClips(loaded, mc.getModelPath());
loaded.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
// Auf 1.8 m skalieren Höhe aus Vertex-Daten (zuverlässiger als BoundingBox
@@ -358,6 +359,34 @@ public class WorldScene extends BaseAppState {
return buildCharacter();
}
private static final String[] ASSET_SEARCH_ROOTS = {
"blight-assets/src/main/resources",
"blight-assets/build/resources/main",
"blight-assets/bin/main",
"assets",
};
private void stripEmbeddedClips(Spatial model, String modelPath) {
com.jme3.anim.AnimComposer ac = de.blight.game.animation.RetargetingSystem.findAnimComposer(model);
if (ac == null || ac.getAnimClips().isEmpty()) return;
int count = ac.getAnimClips().size();
for (com.jme3.anim.AnimClip c : new java.util.ArrayList<>(ac.getAnimClips())) {
ac.removeAnimClip(c);
}
String rel = modelPath.replace('/', java.io.File.separatorChar);
for (String base : ASSET_SEARCH_ROOTS) {
java.nio.file.Path file = java.nio.file.Paths.get(base).resolve(rel);
if (!java.nio.file.Files.exists(file)) continue;
try {
com.jme3.export.binary.BinaryExporter.getInstance().save(model, file.toFile());
log.info("[WorldScene] {} eingebettete Clips aus '{}' entfernt: {}", count, modelPath, file);
} catch (Exception e) {
log.warn("[WorldScene] Speichern fehlgeschlagen ({}): {}", file, e.getMessage());
}
}
assetManager.deleteFromCache(new com.jme3.asset.ModelKey(modelPath));
}
private MainCharacter findMainCharacter() {
java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character");
for (GameCharacter c : CharacterIO.loadAll(charDir)) {