Option B: RootDriftCompensatorControl verhindert Mesh-Drift ohne Animation zu verändern

Neues Control auf dem Modell-Spatial (nach AnimComposer hinzugefügt):
- Läuft nach AnimComposer aber vor SkinningControl → liest aktuellen Frame
- Findet den flachsten Knochen mit nicht-nullem Model-Space XZ (= Hüft-Bone)
- Verschiebt den Modell-Spatial in die entgegengesetzte Richtung
- Mesh bleibt immer über der Physik-Kapsel; Y (sit_down/Jump) wird nicht berührt
- Animations-Daten, J3Os und AnimationLibrary bleiben unverändert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 11:35:12 +02:00
parent 7dcf16fddf
commit 197a820ef9
3 changed files with 107 additions and 1 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")) {

View File

@@ -0,0 +1,103 @@
package de.blight.game.animation;
import com.jme3.anim.Armature;
import com.jme3.anim.Joint;
import com.jme3.anim.SkinningControl;
import com.jme3.math.Vector3f;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.scene.control.AbstractControl;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Verhindert visuellen Root-Motion-Drift, ohne Animations-Daten zu verändern.
*
* Dieses Control muss auf dem selben Spatial wie AnimComposer liegen und NACH
* AnimComposer hinzugefügt werden. Die Update-Reihenfolge ist dann:
* 1. AnimComposer setzt Joint-Locals und propagiert Model-Transforms
* 2. RootDriftCompensator liest aktuellen Frame-Knochen-XZ, korrigiert Spatial-XZ
* 3. SkinningControl (Kind) skinnt mit korrekten Model-Transforms
*
* Mechanismus: Wenn der "Hüft-Knochen" (tiefster sinnvoller Root) im Model-Space
* in XZ driftet, verschiebt dieses Control den Spatial in die entgegengesetzte
* Richtung. Netto bleibt das Mesh immer über der Physik-Kapsel.
* Y (Höhenachse für sit_down/Jump) wird NICHT angefasst applyBoneAnchorOffset
* in PlayerInputControl ist weiterhin allein zuständig.
*/
public class RootDriftCompensatorControl extends AbstractControl {
private static final Logger log = LoggerFactory.getLogger(RootDriftCompensatorControl.class);
private final float offsetY; // Y-Versatz des Modells (Füße auf Kapsel-Unterkante)
private Armature armature;
private Joint locomotionRoot;
/** @param offsetY Wert aus {@code loaded.setLocalTranslation(0, offsetY, 0)} */
public RootDriftCompensatorControl(float offsetY) {
this.offsetY = offsetY;
}
@Override
protected void controlUpdate(float tpf) {
if (!ensureArmature()) return;
Joint root = getOrFindLocomotionRoot();
if (root == null) return;
// Model-Transform enthält die akkumulierte Position des Knochens im Model-Space.
// AnimComposer hat diesen bereits für den aktuellen Frame gesetzt (läuft vor uns).
Vector3f modelXZ = root.getModelTransform().getTranslation();
float scale = spatial.getLocalScale().x; // uniform scale
// Spatial (= das geladene Modell) in die entgegengesetzte Richtung verschieben:
// mesh_world_X = capsule_X + spatial_local_X + bone_model_X * scale
// = capsule_X + (bone_model_X*scale) + bone_model_X*scale = capsule_X ✓
spatial.setLocalTranslation(-modelXZ.x * scale, offsetY, -modelXZ.z * scale);
}
@Override
protected void controlRender(RenderManager rm, ViewPort vp) {}
private boolean ensureArmature() {
if (armature != null) return true;
SkinningControl sc = RetargetingSystem.findSkinningControl(spatial);
if (sc == null) return false;
armature = sc.getArmature();
return armature != null;
}
/**
* Sucht den flachsten Joint, dessen Model-Transform-XZ von 0 abweicht.
* Dieser Joint ist der "Hüft-Knochen", der den Gesamtdrift verursacht.
* Wird gecacht sobald gefunden; bis dahin jeden Frame erneut versucht.
*/
private Joint getOrFindLocomotionRoot() {
if (locomotionRoot != null) return locomotionRoot;
int minDepth = Integer.MAX_VALUE;
for (int i = 0; i < armature.getJointCount(); i++) {
Joint j = armature.getJoint(i);
Vector3f mt = j.getModelTransform().getTranslation();
if (Math.abs(mt.x) > 0.005f || Math.abs(mt.z) > 0.005f) {
int d = depth(j);
if (d < minDepth) {
minDepth = d;
locomotionRoot = j;
}
}
}
if (locomotionRoot != null) {
log.info("[RootDrift] Locomotion-Root: '{}' Tiefe={}",
locomotionRoot.getName(), minDepth);
}
return locomotionRoot;
}
private static int depth(Joint j) {
int d = 0;
Joint p = j.getParent();
while (p != null) { d++; p = p.getParent(); }
return d;
}
}

View File

@@ -338,6 +338,10 @@ public class WorldScene extends BaseAppState {
log.info("[WorldScene] Kein Scale möglich (height={}), Fallback-Offset", modelHeight);
}
// RootDriftCompensator nach dem Laden hinzufügen: läuft nach AnimComposer
// (gleicher Spatial, später hinzugefügt) und korrigiert XZ-Drift des Meshes.
loaded.addControl(new de.blight.game.animation.RootDriftCompensatorControl(offsetY));
// rotationNode als Drehpunkt (CharacterControl überschreibt wrapper-Rotation jeden Frame)
Node rotNode = new Node("charRot");
loaded.setLocalTranslation(0, offsetY, 0);