Refactor V2 animation, furniture, and GLTF rendering

Broad consolidation of the V2 bondage-item, furniture-entity, and
client-side GLTF pipeline.

Parsing and rendering
  - Shared GLB parsing helpers consolidated into GlbParserUtils
    (accessor reads, weight normalization, joint-index clamping,
    coordinate-space conversion, animation parse, primitive loop).
  - Grow-on-demand Matrix4f[] scratch pool in GltfSkinningEngine and
    GltfLiveBoneReader — removes per-frame joint-matrix allocation
    from the render hot path.
  - emitVertex helper dedups three parallel loops in GltfMeshRenderer.
  - TintColorResolver.resolve has a zero-alloc path when the item
    declares no tint channels.
  - itemAnimCache bounded to 256 entries (access-order LRU) with
    atomic get-or-compute under the map's monitor.

Animation correctness
  - First-in-joint-order wins when body and torso both map to the
    same PlayerAnimator slot; duplicate writes log a single WARN.
  - Multi-item composites honor the FullX / FullHeadX opt-in that
    the single-item path already recognized.
  - Seat transforms converted to Minecraft model-def space so
    asymmetric furniture renders passengers at the correct offset.
  - GlbValidator: IBM count / type / presence, JOINTS_0 presence,
    animation channel target validation, multi-skin support.

Furniture correctness and anti-exploit
  - Seat assignment synced via SynchedEntityData (server is
    authoritative; eliminates client-server divergence on multi-seat).
  - Force-mount authorization requires same dimension and a free
    seat; cross-dimension distance checks rejected.
  - Reconnection on login checks for seat takeover before re-mount
    and force-loads the target chunk for cross-dimension cases.
  - tiedup_furniture_lockpick_ctx carries a session UUID nonce so
    stale context can't misroute a body-item lockpick.
  - tiedup_locked_furniture survives death without keepInventory
    (Forge 1.20.1 does not auto-copy persistent data on respawn).

Lifecycle and memory
  - EntityCleanupHandler fans EntityLeaveLevelEvent out to every
    per-entity state map on the client.
  - DogPoseRenderHandler re-keyed by UUID (stable across dimension
    change; entity int ids are recycled).
  - PetBedRenderHandler, PlayerArmHideEventHandler, and
    HeldItemHideHandler use receiveCanceled + sentinel sets so
    Pre-time mutations are restored even when a downstream handler
    cancels the render.

Tests
  - JUnit harness with 76+ tests across GlbParserUtils, GltfPoseConverter,
    FurnitureSeatGeometry, and FurnitureAuthPredicate.
This commit is contained in:
NotEvil
2026-04-18 17:34:03 +02:00
parent 37da2c1716
commit 11188bc621
63 changed files with 4965 additions and 2226 deletions

View File

@@ -30,8 +30,59 @@ public final class GltfPoseConverter {
private static final Logger LOGGER = LogManager.getLogger("GltfPipeline");
private static final int TICKS_PER_SECOND = 20;
private static final Ease DEFAULT_EASE = Ease.LINEAR;
private GltfPoseConverter() {}
/**
* Compute the end tick (inclusive) of a clip's keyframe timeline, relative
* to the clip's first timestamp. Returns 1 for null or empty clips (minimum
* valid builder endTick). glTF timestamps are in seconds; MC ticks are 20 Hz.
*
* <p>The baseline subtraction ensures clips authored with an NLA onset
* ({@code timestamps[0] > 0}) don't leave tick range {@code [0, firstTick)}
* undefined on each loop — the clip is always timeline-normalized to start
* at tick 0.</p>
*/
public static int computeEndTick(@Nullable GltfData.AnimationClip clip) {
if (
clip == null ||
clip.frameCount() == 0 ||
clip.timestamps().length == 0
) {
return 1;
}
float[] times = clip.timestamps();
int lastIdx = Math.min(times.length - 1, clip.frameCount() - 1);
float baseline = times[0];
return Math.max(
1,
Math.round((times[lastIdx] - baseline) * TICKS_PER_SECOND)
);
}
/**
* Convert a frame index to an MC tick based on the clip's timestamps,
* relative to {@code baselineSeconds} (typically {@code timestamps[0]}).
*/
private static int frameToTick(
@Nullable GltfData.AnimationClip clip,
int frameIndex,
float baselineSeconds
) {
if (clip == null) return 0;
float[] times = clip.timestamps();
if (frameIndex >= times.length) return 0;
return Math.round((times[frameIndex] - baselineSeconds) * TICKS_PER_SECOND);
}
/** Return the timestamp baseline for the clip, or 0 if absent. */
private static float timelineBaseline(@Nullable GltfData.AnimationClip clip) {
if (clip == null || clip.timestamps().length == 0) return 0f;
return clip.timestamps()[0];
}
/**
* Convert a GltfData's rest pose (or first animation frame) to a KeyframeAnimation.
* Uses the default (first) animation clip.
@@ -123,7 +174,7 @@ public final class GltfPoseConverter {
*/
private static KeyframeAnimation convertClipSelective(
GltfData data,
GltfData.AnimationClip rawClip,
@Nullable GltfData.AnimationClip rawClip,
String animName,
Set<String> ownedParts,
Set<String> enabledParts
@@ -133,79 +184,42 @@ public final class GltfPoseConverter {
AnimationFormat.JSON_EMOTECRAFT
);
int endTick = computeEndTick(rawClip);
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.endTick = endTick;
builder.stopTick = endTick;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
// Track which PlayerAnimator part names received actual animation data
// Track which PlayerAnimator part names received actual animation data.
// Joint-level; not frame-dependent — we detect once on frame 0.
Set<String> partsWithKeyframes = new HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Tick deduplication: MC runs at 20 Hz. Source clips authored at higher
// rates (24/30/60 FPS Blender) produce multiple frames that round to the
// same tick; emit once per unique tick (keep the first) so artists see
// deterministic behavior rather than relying on PlayerAnimator's "last
// inserted wins" semantic. ARTIST_GUIDE: author at 20 FPS for 1:1.
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
// Check if this joint has explicit animation data (not just rest pose fallback).
// A bone counts as explicitly animated if it has rotation OR translation keyframes.
boolean hasExplicitAnim =
rawClip != null &&
((j < rawClip.rotations().length &&
rawClip.rotations()[j] != null) ||
(rawClip.translations() != null &&
j < rawClip.translations().length &&
rawClip.translations()[j] != null));
Quaternionf animQ = getRawAnimQuaternion(
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
rawRestRotations,
j
f,
tick,
DEFAULT_EASE,
/* ownedFilter */null,
/* keyframeCollector */f == 0 ? partsWithKeyframes : null
);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180deg rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
// Record which PlayerAnimator part received data
if (hasExplicitAnim) {
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart != null) {
partsWithKeyframes.add(animPart);
}
// For lower bones, the keyframe data goes to the upper bone's part
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upperBone
);
if (upperPart != null) {
partsWithKeyframes.add(upperPart);
}
}
}
}
lastTick = tick;
}
// Selective: enable owned parts always, free parts only for "Full" animations
@@ -220,8 +234,10 @@ public final class GltfPoseConverter {
KeyframeAnimation anim = builder.build();
LOGGER.debug(
"[GltfPipeline] Converted selective animation '{}' (owned: {}, enabled: {}, withKeyframes: {})",
"[GltfPipeline] Converted selective animation '{}' ({} frames, endTick={}, owned={}, enabled={}, withKeyframes={})",
animName,
frameCount,
endTick,
ownedParts,
enabledParts,
partsWithKeyframes
@@ -229,6 +245,132 @@ public final class GltfPoseConverter {
return anim;
}
/**
* Apply a single frame's delta rotations for every known bone to the builder,
* writing one keyframe per bone at {@code tick}.
*
* @param ownedFilter if non-null, only bones whose animPart is in this
* set are written (shared-builder multi-item path)
* @param keyframeCollector if non-null, parts that have explicit rotation or
* translation channels are added to this set
*/
private static void applyFrameToBuilder(
KeyframeAnimation.AnimationBuilder builder,
GltfData data,
@Nullable GltfData.AnimationClip rawClip,
int frameIndex,
int tick,
Ease ease,
@Nullable Set<String> ownedFilter,
@Nullable Set<String> keyframeCollector
) {
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
// Two known bones can map to the same PlayerAnimator part (e.g.
// `body` + `torso` → "body"). Both would write to the same
// StateCollection and the second write silently wins; instead,
// first-in-array-order wins and subsequent collisions are skipped.
// Lower bones don't conflict with upper bones (separate axis).
Set<String> claimedUpperParts = new java.util.HashSet<>();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) continue;
boolean isLower = GltfBoneMapper.isLowerBone(boneName);
// Apply ownedFilter BEFORE claiming the slot: a bone that this item
// doesn't own must not reserve the upper-part slot, otherwise a
// later owned bone mapping to the same slot gets spuriously
// rejected by the collision check below.
if (ownedFilter != null) {
if (!ownedFilter.contains(animPart)) continue;
// For lower bones, also require the upper bone's part to be owned.
if (isLower) {
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
if (upper != null) {
String upperPart = GltfBoneMapper.getAnimPartName(upper);
if (
upperPart == null ||
!ownedFilter.contains(upperPart)
) continue;
}
}
}
if (!isLower && !claimedUpperParts.add(animPart)) {
// Another upper bone already claimed this PlayerAnimator part.
// Skip the duplicate write so HashMap iteration order can't
// silently flip which bone drives the pose.
if (frameIndex == 0) {
LOGGER.warn(
"[GltfPipeline] Bone '{}' maps to PlayerAnimator part '{}' already written by an earlier bone — ignoring. Use only one of them in the GLB.",
boneName,
animPart
);
}
continue;
}
Quaternionf animQ = getRawAnimQuaternion(
rawClip,
rawRestRotations,
j,
frameIndex
);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// delta_parent = rest * delta_local * inv(rest)
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// glTF parent frame → MC model-def frame: 180° around Z (negate qx, qy).
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (isLower) {
convertLowerBone(builder, boneName, deltaQ, tick, ease);
} else {
convertUpperBone(builder, boneName, deltaQ, tick, ease);
}
if (keyframeCollector != null) {
// Translation-only channels count as "explicit": a pure-
// translation animation (e.g. a rigid-body bounce) still
// feeds keyframes to PlayerAnimator, so its part must be
// claimed for composite merging.
boolean hasExplicitAnim =
rawClip != null &&
((j < rawClip.rotations().length &&
rawClip.rotations()[j] != null) ||
(rawClip.translations() != null &&
j < rawClip.translations().length &&
rawClip.translations()[j] != null));
if (hasExplicitAnim) {
keyframeCollector.add(animPart);
if (isLower) {
String upper = GltfBoneMapper.getUpperBoneFor(boneName);
if (upper != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upper
);
if (upperPart != null) keyframeCollector.add(
upperPart
);
}
}
}
}
}
}
/**
* Add keyframes for specific owned parts from a GLB animation clip to an existing builder.
*
@@ -248,76 +390,25 @@ public final class GltfPoseConverter {
@Nullable GltfData.AnimationClip rawClip,
Set<String> ownedParts
) {
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
Set<String> partsWithKeyframes = new HashSet<>();
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
// Only process bones that belong to this item's owned parts
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null || !ownedParts.contains(animPart)) continue;
// For lower bones, check if the UPPER bone's part is owned
// (lower bone keyframes go to the upper bone's StateCollection)
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upperBone
);
if (
upperPart == null || !ownedParts.contains(upperPart)
) continue;
}
}
boolean hasExplicitAnim =
rawClip != null &&
((j < rawClip.rotations().length &&
rawClip.rotations()[j] != null) ||
(rawClip.translations() != null &&
j < rawClip.translations().length &&
rawClip.translations()[j] != null));
Quaternionf animQ = getRawAnimQuaternion(
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
rawRestRotations,
j
f,
tick,
DEFAULT_EASE,
ownedParts,
f == 0 ? partsWithKeyframes : null
);
Quaternionf restQ = rawRestRotations[j];
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
if (hasExplicitAnim) {
partsWithKeyframes.add(animPart);
if (GltfBoneMapper.isLowerBone(boneName)) {
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone != null) {
String upperPart = GltfBoneMapper.getAnimPartName(
upperBone
);
if (upperPart != null) partsWithKeyframes.add(
upperPart
);
}
}
}
lastTick = tick;
}
return partsWithKeyframes;
@@ -351,7 +442,7 @@ public final class GltfPoseConverter {
*/
private static KeyframeAnimation convertClip(
GltfData data,
GltfData.AnimationClip rawClip,
@Nullable GltfData.AnimationClip rawClip,
String animName
) {
KeyframeAnimation.AnimationBuilder builder =
@@ -359,123 +450,94 @@ public final class GltfPoseConverter {
AnimationFormat.JSON_EMOTECRAFT
);
int endTick = computeEndTick(rawClip);
int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1;
float baseline = timelineBaseline(rawClip);
int lastTick = Integer.MIN_VALUE;
builder.beginTick = 0;
builder.endTick = 1;
builder.stopTick = 1;
builder.endTick = endTick;
builder.stopTick = endTick;
builder.isLooped = true;
builder.returnTick = 0;
builder.name = animName;
String[] jointNames = data.jointNames();
Quaternionf[] rawRestRotations = data.rawGltfRestRotations();
for (int j = 0; j < data.jointCount(); j++) {
String boneName = jointNames[j];
if (!GltfBoneMapper.isKnownBone(boneName)) continue;
Quaternionf animQ = getRawAnimQuaternion(
for (int f = 0; f < frameCount; f++) {
int tick = frameToTick(rawClip, f, baseline);
if (tick == lastTick) continue;
applyFrameToBuilder(
builder,
data,
rawClip,
rawRestRotations,
j
f,
tick,
DEFAULT_EASE,
/* ownedFilter */null,
/* keyframeCollector */null
);
Quaternionf restQ = rawRestRotations[j];
// delta_local = inverse(rest_q) * anim_q (in bone-local frame)
Quaternionf deltaLocal = new Quaternionf(restQ).invert().mul(animQ);
// Convert to PARENT frame: delta_parent = rest * delta_local * inv(rest)
// Simplifies algebraically to: animQ * inv(restQ)
Quaternionf deltaParent = new Quaternionf(restQ)
.mul(deltaLocal)
.mul(new Quaternionf(restQ).invert());
// Convert from glTF parent frame to MC model-def frame.
// 180° rotation around Z (X and Y differ): negate qx and qy.
Quaternionf deltaQ = new Quaternionf(deltaParent);
deltaQ.x = -deltaQ.x;
deltaQ.y = -deltaQ.y;
LOGGER.debug(
String.format(
"[GltfPipeline] Bone '%s': restQ=(%.3f,%.3f,%.3f,%.3f) animQ=(%.3f,%.3f,%.3f,%.3f) deltaQ=(%.3f,%.3f,%.3f,%.3f)",
boneName,
restQ.x,
restQ.y,
restQ.z,
restQ.w,
animQ.x,
animQ.y,
animQ.z,
animQ.w,
deltaQ.x,
deltaQ.y,
deltaQ.z,
deltaQ.w
)
);
if (GltfBoneMapper.isLowerBone(boneName)) {
convertLowerBone(builder, boneName, deltaQ);
} else {
convertUpperBone(builder, boneName, deltaQ);
}
lastTick = tick;
}
builder.fullyEnableParts();
KeyframeAnimation anim = builder.build();
LOGGER.debug(
"[GltfPipeline] Converted glTF animation '{}' to KeyframeAnimation",
animName
"[GltfPipeline] Converted glTF animation '{}' ({} frames, endTick={})",
animName,
frameCount,
endTick
);
return anim;
}
/**
* Get the raw animation quaternion for a joint from a specific clip.
* Falls back to rest rotation if the clip is null or has no data for this joint.
* Get the raw animation quaternion for a joint at a specific frame.
* Falls back to rest rotation if the clip is null, has no data for this joint,
* or has an empty channel. Clamps frameIndex to the last available frame if
* the joint's channel is shorter than the shared timestamps array.
*/
private static Quaternionf getRawAnimQuaternion(
GltfData.AnimationClip rawClip,
@Nullable GltfData.AnimationClip rawClip,
Quaternionf[] rawRestRotations,
int jointIndex
int jointIndex,
int frameIndex
) {
if (
rawClip != null &&
jointIndex < rawClip.rotations().length &&
rawClip.rotations()[jointIndex] != null
) {
return rawClip.rotations()[jointIndex][0]; // first frame
Quaternionf[] channel = rawClip.rotations()[jointIndex];
if (channel.length > 0) {
int safeFrame = Math.min(frameIndex, channel.length - 1);
return channel[safeFrame];
}
}
return rawRestRotations[jointIndex]; // fallback to rest
// Defensive: under a well-formed GLB, jointCount == restRotations.length
// (guaranteed by the parser). This guard keeps us from AIOOBE-ing if
// that invariant is ever broken by a future parser path.
if (jointIndex >= rawRestRotations.length) {
return new Quaternionf();
}
return rawRestRotations[jointIndex];
}
private static void convertUpperBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName,
Quaternionf deltaQ
Quaternionf deltaQ,
int tick,
Ease ease
) {
// Decompose delta quaternion to Euler ZYX
// JOML's getEulerAnglesZYX stores: euler.x = X rotation, euler.y = Y rotation, euler.z = Z rotation
// (the "ZYX" refers to rotation ORDER, not storage order)
// "ZYX" is rotation order, not storage: euler.{x,y,z} hold the X/Y/Z
// Euler angles for a R = Rz·Ry·Rx decomposition. Gimbal lock at the
// middle axis (euler.y = ±90°); see ARTIST_GUIDE.md Common Mistakes.
Vector3f euler = new Vector3f();
deltaQ.getEulerAnglesZYX(euler);
float pitch = euler.x; // X rotation (pitch)
float yaw = euler.y; // Y rotation (yaw)
float roll = euler.z; // Z rotation (roll)
float pitch = euler.x;
float yaw = euler.y;
float roll = euler.z;
LOGGER.debug(
String.format(
"[GltfPipeline] Upper bone '%s': pitch=%.1f° yaw=%.1f° roll=%.1f°",
boneName,
Math.toDegrees(pitch),
Math.toDegrees(yaw),
Math.toDegrees(roll)
)
);
// Get the StateCollection for this body part
String animPart = GltfBoneMapper.getAnimPartName(boneName);
if (animPart == null) return;
@@ -485,41 +547,40 @@ public final class GltfPoseConverter {
);
if (part == null) return;
part.pitch.addKeyFrame(0, pitch, Ease.CONSTANT);
part.yaw.addKeyFrame(0, yaw, Ease.CONSTANT);
part.roll.addKeyFrame(0, roll, Ease.CONSTANT);
part.pitch.addKeyFrame(tick, pitch, ease);
part.yaw.addKeyFrame(tick, yaw, ease);
part.roll.addKeyFrame(tick, roll, ease);
}
private static void convertLowerBone(
KeyframeAnimation.AnimationBuilder builder,
String boneName,
Quaternionf deltaQ
Quaternionf deltaQ,
int tick,
Ease ease
) {
// Extract bend angle and axis from the delta quaternion
float angle =
2.0f * (float) Math.acos(Math.min(1.0, Math.abs(deltaQ.w)));
// Canonicalize q: q and -q represent the same rotation. Always pick the
// hemisphere with w >= 0 so consecutive frames don't pop across the
// double-cover boundary when interpolating.
float qx = deltaQ.x;
float qy = deltaQ.y;
float qz = deltaQ.z;
float qw = deltaQ.w;
if (qw < 0) {
qx = -qx;
qy = -qy;
qz = -qz;
qw = -qw;
}
// Now qw is in [0, 1]. Rotation angle = 2 * acos(qw), in [0, π].
float angle = 2.0f * (float) Math.acos(Math.min(1.0f, qw));
// Determine bend direction from axis
float bendDirection = 0.0f;
if (deltaQ.x * deltaQ.x + deltaQ.z * deltaQ.z > 0.001f) {
bendDirection = (float) Math.atan2(deltaQ.z, deltaQ.x);
if (qx * qx + qz * qz > 0.001f) {
bendDirection = (float) Math.atan2(qz, qx);
}
// Sign: if w is negative, the angle wraps
if (deltaQ.w < 0) {
angle = -angle;
}
LOGGER.debug(
String.format(
"[GltfPipeline] Lower bone '%s': bendAngle=%.1f° bendDir=%.1f°",
boneName,
Math.toDegrees(angle),
Math.toDegrees(bendDirection)
)
);
// Apply bend to the upper bone's StateCollection
String upperBone = GltfBoneMapper.getUpperBoneFor(boneName);
if (upperBone == null) return;
@@ -532,8 +593,8 @@ public final class GltfPoseConverter {
);
if (part == null || !part.isBendable) return;
part.bend.addKeyFrame(0, angle, Ease.CONSTANT);
part.bendDirection.addKeyFrame(0, bendDirection, Ease.CONSTANT);
part.bend.addKeyFrame(tick, angle, ease);
part.bendDirection.addKeyFrame(tick, bendDirection, ease);
}
private static KeyframeAnimation.StateCollection getPartByName(
@@ -580,27 +641,86 @@ public final class GltfPoseConverter {
Set<String> partsWithKeyframes,
String animName
) {
// Free bones are only enabled for "Full" animations (FullIdle, FullStruggle, etc.)
// The "gltf_" prefix is added by convertClipSelective, so check for "gltf_Full"
boolean isFullBodyAnimation = animName != null &&
animName.startsWith("gltf_Full");
// Head is protected by default — only enabled as a free bone when the animation
// name contains "Head" (e.g., FullHeadStruggle, FullHeadIdle).
// This lets artists opt-in per animation without affecting the item's regions.
// FullHead prefix (e.g., FullHeadStruggle) opts into head as a free bone.
// Use startsWith to avoid false positives (e.g., FullOverhead, FullAhead).
boolean allowFreeHead = isFullBodyAnimation &&
animName.startsWith("gltf_FullHead");
boolean isFullBodyAnimation = isFullBodyAnimName(animName);
boolean allowFreeHead = isFullHeadAnimName(animName);
enableSelectivePartsCore(
builder,
ownedParts,
enabledParts,
partsWithKeyframes,
isFullBodyAnimation,
allowFreeHead
);
}
String[] allParts = {
"head",
"body",
"rightArm",
"leftArm",
"rightLeg",
"leftLeg",
};
for (String partName : allParts) {
/**
* Check whether a resolved-and-prefixed animation name (e.g. {@code "gltf_FullStruggle"})
* declares opt-in to full-body free-bone animation. See the "Full" prefix
* convention in {@link #enableSelectiveParts}.
*/
public static boolean isFullBodyAnimName(@Nullable String animName) {
return animName != null && animName.startsWith("gltf_Full");
}
/**
* Check whether a resolved-and-prefixed animation name opts in to head
* animation as a free bone (e.g. {@code "gltf_FullHeadStruggle"}). Head is
* protected by default to preserve vanilla head-tracking on bondage items
* that don't specifically want to animate it.
*/
public static boolean isFullHeadAnimName(@Nullable String animName) {
return isFullBodyAnimName(animName) &&
animName.startsWith("gltf_FullHead");
}
/**
* Composite variant of {@link #enableSelectiveParts} used by the multi-item
* path. Callers (e.g. {@code GltfAnimationApplier.applyMultiItemV2Animation})
* compute the three aggregates themselves: {@code allOwnedParts} is the
* union of owned regions across all items, {@code partsWithKeyframes} is
* the union of keyframe parts returned by each {@link #addBonesToBuilder}
* call, and the two Full/FullHead flags should be true if ANY item in the
* composite resolved to a {@code FullX}/{@code FullHeadX} animation name.
*/
public static void enableSelectivePartsComposite(
KeyframeAnimation.AnimationBuilder builder,
Set<String> allOwnedParts,
Set<String> partsWithKeyframes,
boolean isFullBodyAnimation,
boolean allowFreeHead
) {
// In the composite path every animation part is implicitly in
// enabledParts — if a FullX animation has keyframes for it, we want it
// enabled. Pass ALL_PARTS as the enabled set so the single-item
// opt-out path is a no-op.
enableSelectivePartsCore(
builder,
allOwnedParts,
ALL_PARTS_SET,
partsWithKeyframes,
isFullBodyAnimation,
allowFreeHead
);
}
private static final Set<String> ALL_PARTS_SET = Set.of(
"head",
"body",
"rightArm",
"leftArm",
"rightLeg",
"leftLeg"
);
private static void enableSelectivePartsCore(
KeyframeAnimation.AnimationBuilder builder,
Set<String> ownedParts,
Set<String> enabledParts,
Set<String> partsWithKeyframes,
boolean isFullBodyAnimation,
boolean allowFreeHead
) {
for (String partName : ALL_PARTS_SET) {
KeyframeAnimation.StateCollection part = getPartByName(
builder,
partName