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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user