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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -9439,25 +9439,6 @@ public class EditorApp extends Application {
|
|||||||
refreshCharAnimSetCombo();
|
refreshCharAnimSetCombo();
|
||||||
charAnimSetCombo.setOnAction(e -> updateCharActionCombosFromSet());
|
charAnimSetCombo.setOnAction(e -> updateCharActionCombosFromSet());
|
||||||
|
|
||||||
Button embedAnimBtn = new Button("Animationen einbetten");
|
|
||||||
embedAnimBtn.setMaxWidth(Double.MAX_VALUE);
|
|
||||||
embedAnimBtn.setDisable(true);
|
|
||||||
javafx.beans.value.ChangeListener<String> embedEnableListener = (obs, ov, nv) -> {
|
|
||||||
boolean ready = charModelCombo.getValue() != null && !charModelCombo.getValue().isBlank()
|
|
||||||
&& charAnimSetCombo.getValue() != null && !charAnimSetCombo.getValue().isBlank();
|
|
||||||
embedAnimBtn.setDisable(!ready);
|
|
||||||
};
|
|
||||||
charModelCombo.valueProperty().addListener(embedEnableListener);
|
|
||||||
charAnimSetCombo.valueProperty().addListener(embedEnableListener);
|
|
||||||
embedAnimBtn.setOnAction(e -> {
|
|
||||||
String modelPath = charModelCombo.getValue();
|
|
||||||
String setName = charAnimSetCombo.getValue();
|
|
||||||
if (modelPath == null || setName == null) return;
|
|
||||||
if (charEditorStatusLabel != null)
|
|
||||||
charEditorStatusLabel.setText("Bette Animationen ein…");
|
|
||||||
input.animEmbedRequest.set(new SharedInput.AnimEmbedRequest(modelPath, setName));
|
|
||||||
});
|
|
||||||
|
|
||||||
Button stripClipsBtn = new Button("Eingebettete Clips löschen");
|
Button stripClipsBtn = new Button("Eingebettete Clips löschen");
|
||||||
stripClipsBtn.setMaxWidth(Double.MAX_VALUE);
|
stripClipsBtn.setMaxWidth(Double.MAX_VALUE);
|
||||||
stripClipsBtn.setDisable(true);
|
stripClipsBtn.setDisable(true);
|
||||||
@@ -9581,7 +9562,6 @@ public class EditorApp extends Application {
|
|||||||
charEditContainer.getChildren().addAll(
|
charEditContainer.getChildren().addAll(
|
||||||
new Label("Modell:"), charModelCombo,
|
new Label("Modell:"), charModelCombo,
|
||||||
new Label("Anim-Set:"), charAnimSetCombo,
|
new Label("Anim-Set:"), charAnimSetCombo,
|
||||||
embedAnimBtn,
|
|
||||||
stripClipsBtn
|
stripClipsBtn
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -668,21 +668,31 @@ public class AnimPreviewState extends BaseAppState {
|
|||||||
for (Spatial child : n.getChildren()) collectControlTypes(child, out);
|
for (Spatial child : n.getChildren()) collectControlTypes(child, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Speichert das aktuelle Modell (inkl. aller AnimClips) zurück auf Disk. */
|
/** Speichert das aktuelle Modell zurück auf Disk – ohne eingebettete AnimClips. */
|
||||||
private void saveModel() {
|
private void saveModel() {
|
||||||
if (currentModelPath == null || currentModel == null) return;
|
if (currentModelPath == null || currentModel == null) return;
|
||||||
if (!currentModelPath.endsWith(".j3o")) {
|
if (!currentModelPath.endsWith(".j3o")) {
|
||||||
LOG.warn("[AnimPreview] Speichern übersprungen – kein .j3o: {}", currentModelPath);
|
LOG.warn("[AnimPreview] Speichern übersprungen – kein .j3o: {}", currentModelPath);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
AnimComposer ac = findControl(currentModel, AnimComposer.class);
|
||||||
|
List<AnimClip> tempClips = new java.util.ArrayList<>();
|
||||||
|
if (ac != null) {
|
||||||
|
tempClips.addAll(ac.getAnimClips());
|
||||||
|
for (AnimClip c : tempClips) ac.removeAnimClip(c);
|
||||||
|
}
|
||||||
Path file = ASSET_ROOT.resolve(currentModelPath.replace('/', java.io.File.separatorChar));
|
Path file = ASSET_ROOT.resolve(currentModelPath.replace('/', java.io.File.separatorChar));
|
||||||
try {
|
try {
|
||||||
BinaryExporter.getInstance().save(currentModel, file.toFile());
|
BinaryExporter.getInstance().save(currentModel, file.toFile());
|
||||||
LOG.info("[AnimPreview] Modell gespeichert: {}", currentModelPath);
|
LOG.info("[AnimPreview] Modell gespeichert (ohne Clips): {}", currentModelPath);
|
||||||
assets.deleteFromCache(new ModelKey(currentModelPath));
|
assets.deleteFromCache(new ModelKey(currentModelPath));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
input.animPreviewStatus += " | Speicherfehler: " + e.getMessage();
|
input.animPreviewStatus += " | Speicherfehler: " + e.getMessage();
|
||||||
LOG.error("[AnimPreview] Speicherfehler: {}", e.toString());
|
LOG.error("[AnimPreview] Speicherfehler: {}", e.toString());
|
||||||
|
} finally {
|
||||||
|
if (ac != null) {
|
||||||
|
for (AnimClip c : tempClips) ac.addAnimClip(c);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
target = snapRootBoneXZ(target, sc.getArmature());
|
|
||||||
ac.addAnimClip(target);
|
ac.addAnimClip(target);
|
||||||
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
|
log.info("[AnimLib] Clip '{}' zu AnimComposer von '{}' hinzugefügt", clipName, model.getName());
|
||||||
if (clipName.equals("sit_down")) {
|
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). */
|
/** Wendet alle geladenen Clips auf {@code model} an (nur wenn es ein Rig hat). */
|
||||||
public void applyAllTo(Spatial model) {
|
public void applyAllTo(Spatial model) {
|
||||||
if (RetargetingSystem.findSkinningControl(model) == null) return;
|
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;
|
int applied = 0;
|
||||||
for (String key : clips.keySet()) {
|
for (String key : clips.keySet()) {
|
||||||
if (applyTo(key, model)) applied++;
|
if (applyTo(key, model)) applied++;
|
||||||
@@ -234,6 +239,9 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
|
|
||||||
for (String name : ac.getAnimClipsNames()) {
|
for (String name : ac.getAnimClipsNames()) {
|
||||||
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
|
com.jme3.anim.AnimClip animClip = ac.getAnimClip(name);
|
||||||
|
if (armature != null) {
|
||||||
|
animClip = snapRootBoneXZ(animClip, armature);
|
||||||
|
}
|
||||||
clips.put(name, animClip);
|
clips.put(name, animClip);
|
||||||
if (armature != null) armatures.put(name, armature);
|
if (armature != null) armatures.put(name, armature);
|
||||||
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
|
log.info("[AnimLib] Clip geladen: '{}' aus {}", name, assetKey);
|
||||||
@@ -260,15 +268,21 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
return null;
|
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.
|
* Entfernt Root-Motion aus dem flachsten Bone mit Translation-Track (typischerweise Hips).
|
||||||
* Y (Höhenachse, JME3 Y-Up) bleibt vollständig erhalten — sit_down / Jump / Bounce laufen korrekt.
|
* 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.
|
* In diesen Mixamo-Exporten ist die Achsenbelegung des Hips-Bones:
|
||||||
* Bei Rigs wo Root (Tiefe 0) selbst Translations hat, wird Root gesnappt.
|
* Local X → seitlich → wird auf 0 eingefroren (kein Seiten-Drift)
|
||||||
* Bei Rigs wo Hips (Tiefe 1) die erste Ebene mit Translations ist (Root hat nur Rotation),
|
* Local Y → vorwärts → wird auf 0 eingefroren für Lauf-Clips (kein Vorwärts-Drift)
|
||||||
* wird Hips gesnappt. So passt der Snap zu beiden Rig-Strukturen.
|
* bleibt frei für Sitz/Stand-Clips (leichte Neigungsbewegung)
|
||||||
* Erstellt einen neuen in-memory-Clip; J3O-Dateien bleiben unverändert.
|
* Local Z → Höhe → IMMER frei lassen (Charakter-Höhe und Setz-Bewegung erhalten)
|
||||||
*/
|
*/
|
||||||
public static AnimClip snapRootBoneXZ(AnimClip clip, Armature armature) {
|
public static AnimClip snapRootBoneXZ(AnimClip clip, Armature armature) {
|
||||||
if (clip == null || armature == null) return clip;
|
if (clip == null || armature == null) return clip;
|
||||||
@@ -282,7 +296,9 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
minDepth = Math.min(minDepth, jointDepth(j));
|
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<>();
|
List<AnimTrack<?>> newTracks = new ArrayList<>();
|
||||||
boolean modified = false;
|
boolean modified = false;
|
||||||
@@ -292,20 +308,45 @@ public class AnimationLibrary extends BaseAppState {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Vector3f[] translations = tt.getTranslations();
|
Vector3f[] translations = tt.getTranslations();
|
||||||
|
// Nur den flachsten Bone anfassen – alle anderen unverändert lassen
|
||||||
if (translations == null || translations.length == 0 || jointDepth(j) != minDepth) {
|
if (translations == null || translations.length == 0 || jointDepth(j) != minDepth) {
|
||||||
newTracks.add(track);
|
newTracks.add(track);
|
||||||
continue;
|
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];
|
Vector3f[] snapped = new Vector3f[translations.length];
|
||||||
for (int i = 0; i < translations.length; i++) {
|
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()));
|
newTracks.add(new TransformTrack(j, tt.getTimes(), snapped, tt.getRotations(), tt.getScales()));
|
||||||
modified = true;
|
modified = true;
|
||||||
log.info("[AnimLib] '{}': Tiefe-{}-Joint '{}' XZ={},{} eingefroren, Y frei",
|
|
||||||
clip.getName(), minDepth, j.getName(), f0x, f0z);
|
|
||||||
}
|
}
|
||||||
if (!modified) return clip;
|
if (!modified) return clip;
|
||||||
AnimClip result = new AnimClip(clip.getName());
|
AnimClip result = new AnimClip(clip.getName());
|
||||||
|
|||||||
@@ -319,6 +319,7 @@ public class WorldScene extends BaseAppState {
|
|||||||
if (mc != null && mc.getModelPath() != null) {
|
if (mc != null && mc.getModelPath() != null) {
|
||||||
try {
|
try {
|
||||||
Spatial loaded = assetManager.loadModel(mc.getModelPath());
|
Spatial loaded = assetManager.loadModel(mc.getModelPath());
|
||||||
|
stripEmbeddedClips(loaded, mc.getModelPath());
|
||||||
loaded.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
|
loaded.setShadowMode(RenderQueue.ShadowMode.CastAndReceive);
|
||||||
|
|
||||||
// Auf 1.8 m skalieren – Höhe aus Vertex-Daten (zuverlässiger als BoundingBox
|
// 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();
|
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() {
|
private MainCharacter findMainCharacter() {
|
||||||
java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character");
|
java.nio.file.Path charDir = AnimationLibrary.findAssetRoot().resolve("character");
|
||||||
for (GameCharacter c : CharacterIO.loadAll(charDir)) {
|
for (GameCharacter c : CharacterIO.loadAll(charDir)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user