package com.tiedup.remake.client.gltf; import dev.kosmx.playerAnim.core.data.AnimationFormat; import dev.kosmx.playerAnim.core.data.KeyframeAnimation; import dev.kosmx.playerAnim.core.util.Ease; import java.util.HashSet; import java.util.Set; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.api.distmarker.OnlyIn; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.Nullable; import org.joml.Quaternionf; import org.joml.Vector3f; /** * Converts glTF rest pose + animation quaternions into a PlayerAnimator KeyframeAnimation. *

* Data is expected to be already in MC coordinate space (converted by GlbParser). * For upper bones: computes delta quaternion, decomposes to Euler ZYX (pitch/yaw/roll). * For lower bones: extracts bend angle from delta quaternion. *

* The GLB model's arm pivots are expected to match MC's exactly (world Y=1.376), * so no angle scaling is needed. If the pivots don't match, fix the Blender model. *

* Produces a static looping pose (beginTick=0, endTick=1, looped). */ @OnlyIn(Dist.CLIENT) 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. * *

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.

*/ 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. * GltfData must already be in MC coordinate space. * * @param data parsed glTF data (in MC space) * @return a static looping KeyframeAnimation suitable for PlayerAnimator */ public static KeyframeAnimation convert(GltfData data) { return convertClip(data, data.rawGltfAnimation(), "gltf_pose"); } /** * Convert a specific named animation from GltfData to a KeyframeAnimation. * Falls back to the default animation if the name is not found. * * @param data parsed glTF data (in MC space) * @param animationName the name of the animation to convert (e.g. "Struggle", "Idle") * @return a static looping KeyframeAnimation suitable for PlayerAnimator */ public static KeyframeAnimation convert( GltfData data, String animationName ) { GltfData.AnimationClip rawClip = data.getRawAnimation(animationName); if (rawClip == null) { LOGGER.warn( "[GltfPipeline] Animation '{}' not found, falling back to default", animationName ); return convert(data); } return convertClip(data, rawClip, "gltf_" + animationName); } /** * Convert a GLB animation with selective part enabling and free-bone support. * *

Owned parts are always enabled in the output animation. Free parts (in * {@code enabledParts} but not in {@code ownedParts}) are only enabled if the * GLB contains actual keyframe data for them. Parts not in {@code enabledParts} * at all are always disabled (pass through to lower layers).

* * @param data parsed glTF data (in MC space) * @param animationName animation name in GLB, or null for default * @param ownedParts parts the item explicitly owns (always enabled) * @param enabledParts parts the item may animate (owned + free); free parts * are only enabled if the GLB has keyframes for them * @return KeyframeAnimation with selective parts active */ public static KeyframeAnimation convertSelective( GltfData data, @Nullable String animationName, Set ownedParts, Set enabledParts ) { GltfData.AnimationClip rawClip; String animName; if (animationName != null) { rawClip = data.getRawAnimation(animationName); animName = "gltf_" + animationName; } else { rawClip = null; animName = "gltf_pose"; } if (rawClip == null) { rawClip = data.rawGltfAnimation(); } return convertClipSelective( data, rawClip, animName, ownedParts, enabledParts ); } /** * Internal: convert a specific raw animation clip with selective part enabling * and free-bone support. * *

Tracks which PlayerAnimator parts received actual keyframe data from the GLB. * A bone has keyframes if {@code rawClip.rotations()[jointIndex] != null}. * This information is used by {@link #enableSelectiveParts} to decide whether * free parts should be enabled.

* * @param ownedParts parts the item explicitly owns (always enabled) * @param enabledParts parts the item may animate (owned + free) */ private static KeyframeAnimation convertClipSelective( GltfData data, @Nullable GltfData.AnimationClip rawClip, String animName, Set ownedParts, Set enabledParts ) { KeyframeAnimation.AnimationBuilder builder = new KeyframeAnimation.AnimationBuilder( AnimationFormat.JSON_EMOTECRAFT ); int endTick = computeEndTick(rawClip); int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1; builder.beginTick = 0; builder.endTick = endTick; builder.stopTick = endTick; builder.isLooped = true; builder.returnTick = 0; builder.name = animName; // Track which PlayerAnimator part names received actual animation data. // Joint-level; not frame-dependent — we detect once on frame 0. Set partsWithKeyframes = new HashSet<>(); // 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; for (int f = 0; f < frameCount; f++) { int tick = frameToTick(rawClip, f, baseline); if (tick == lastTick) continue; applyFrameToBuilder( builder, data, rawClip, f, tick, DEFAULT_EASE, /* ownedFilter */null, /* keyframeCollector */f == 0 ? partsWithKeyframes : null ); lastTick = tick; } // Selective: enable owned parts always, free parts only for "Full" animations // that explicitly opt into full-body control. enableSelectiveParts( builder, ownedParts, enabledParts, partsWithKeyframes, animName ); KeyframeAnimation anim = builder.build(); LOGGER.debug( "[GltfPipeline] Converted selective animation '{}' ({} frames, endTick={}, owned={}, enabled={}, withKeyframes={})", animName, frameCount, endTick, ownedParts, enabledParts, partsWithKeyframes ); 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 ownedFilter, @Nullable Set 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 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. * *

Only writes keyframes for bones that map to a part in {@code ownedParts}. * Other bones are skipped entirely. This allows multiple items to contribute * to the same animation builder without overwriting each other's keyframes.

* * @param builder the shared animation builder to add keyframes to * @param data parsed glTF data * @param rawClip the raw animation clip, or null for rest pose * @param ownedParts parts this item exclusively owns (only these get keyframes) * @return set of part names that received actual keyframe data from the GLB */ public static Set addBonesToBuilder( KeyframeAnimation.AnimationBuilder builder, GltfData data, @Nullable GltfData.AnimationClip rawClip, Set ownedParts ) { Set partsWithKeyframes = new HashSet<>(); int frameCount = rawClip != null ? Math.max(1, rawClip.frameCount()) : 1; float baseline = timelineBaseline(rawClip); int lastTick = Integer.MIN_VALUE; for (int f = 0; f < frameCount; f++) { int tick = frameToTick(rawClip, f, baseline); if (tick == lastTick) continue; applyFrameToBuilder( builder, data, rawClip, f, tick, DEFAULT_EASE, ownedParts, f == 0 ? partsWithKeyframes : null ); lastTick = tick; } return partsWithKeyframes; } /** * Convert an animation clip using skeleton data from a separate source. * *

This is useful when the animation clip is stored separately from the * skeleton (e.g., furniture seat animations where the Player_* armature's * clips are parsed into a separate map from the skeleton GltfData).

* *

The resulting animation has all parts fully enabled. Callers should * create a mutable copy and selectively disable parts as needed.

* * @param skeleton the GltfData providing rest pose, joint names, and joint count * @param clip the raw animation clip (in glTF space) to convert * @param animName debug name for the resulting animation * @return a static looping KeyframeAnimation with all parts enabled */ public static KeyframeAnimation convertWithSkeleton( GltfData skeleton, GltfData.AnimationClip clip, String animName ) { return convertClip(skeleton, clip, animName); } /** * Internal: convert a specific raw animation clip to a KeyframeAnimation. */ private static KeyframeAnimation convertClip( GltfData data, @Nullable GltfData.AnimationClip rawClip, String animName ) { KeyframeAnimation.AnimationBuilder builder = new KeyframeAnimation.AnimationBuilder( 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 = endTick; builder.stopTick = endTick; builder.isLooped = true; builder.returnTick = 0; builder.name = animName; for (int f = 0; f < frameCount; f++) { int tick = frameToTick(rawClip, f, baseline); if (tick == lastTick) continue; applyFrameToBuilder( builder, data, rawClip, f, tick, DEFAULT_EASE, /* ownedFilter */null, /* keyframeCollector */null ); lastTick = tick; } builder.fullyEnableParts(); KeyframeAnimation anim = builder.build(); LOGGER.debug( "[GltfPipeline] Converted glTF animation '{}' ({} frames, endTick={})", animName, frameCount, endTick ); return anim; } /** * 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( @Nullable GltfData.AnimationClip rawClip, Quaternionf[] rawRestRotations, int jointIndex, int frameIndex ) { if ( rawClip != null && jointIndex < rawClip.rotations().length && rawClip.rotations()[jointIndex] != null ) { Quaternionf[] channel = rawClip.rotations()[jointIndex]; if (channel.length > 0) { int safeFrame = Math.min(frameIndex, channel.length - 1); return channel[safeFrame]; } } // 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, int tick, Ease ease ) { // "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; float yaw = euler.y; float roll = euler.z; String animPart = GltfBoneMapper.getAnimPartName(boneName); if (animPart == null) return; KeyframeAnimation.StateCollection part = getPartByName( builder, animPart ); if (part == null) return; 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, int tick, Ease ease ) { // 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)); float bendDirection = 0.0f; if (qx * qx + qz * qz > 0.001f) { bendDirection = (float) Math.atan2(qz, qx); } String upperBone = GltfBoneMapper.getUpperBoneFor(boneName); if (upperBone == null) return; String animPart = GltfBoneMapper.getAnimPartName(upperBone); if (animPart == null) return; KeyframeAnimation.StateCollection part = getPartByName( builder, animPart ); if (part == null || !part.isBendable) return; part.bend.addKeyFrame(tick, angle, ease); part.bendDirection.addKeyFrame(tick, bendDirection, ease); } private static KeyframeAnimation.StateCollection getPartByName( KeyframeAnimation.AnimationBuilder builder, String name ) { return switch (name) { case "head" -> builder.head; case "body" -> builder.body; case "rightArm" -> builder.rightArm; case "leftArm" -> builder.leftArm; case "rightLeg" -> builder.rightLeg; case "leftLeg" -> builder.leftLeg; default -> null; }; } /** * Enable parts selectively based on ownership and keyframe presence. * *
    *
  • Owned parts: always enabled (the item controls these bones)
  • *
  • Free parts WITH keyframes AND "Full" animation: enabled (explicit opt-in to full-body)
  • *
  • Free parts without "Full" prefix: disabled (prevents accidental bone hijacking)
  • *
  • Other items' parts: disabled (pass through to their own layer)
  • *
* *

The "Full" prefix convention (FullIdle, FullStruggle, FullWalk) is the artist's * explicit declaration that this animation is designed to control the entire body, * not just the item's owned regions. Without this prefix, free bones are never enabled, * even if the GLB contains keyframes for them. This prevents accidental bone hijacking * when an artist keyframes all bones in Blender by default.

* * @param builder the animation builder with keyframes already added * @param ownedParts parts the item explicitly owns (always enabled) * @param enabledParts parts the item may animate (owned + free) * @param partsWithKeyframes parts that received actual animation data from the GLB * @param animName resolved animation name (checked for "Full" prefix) */ private static void enableSelectiveParts( KeyframeAnimation.AnimationBuilder builder, Set ownedParts, Set enabledParts, Set partsWithKeyframes, String animName ) { boolean isFullBodyAnimation = isFullBodyAnimName(animName); boolean allowFreeHead = isFullHeadAnimName(animName); enableSelectivePartsCore( builder, ownedParts, enabledParts, partsWithKeyframes, isFullBodyAnimation, allowFreeHead ); } /** * 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 allOwnedParts, Set 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 ALL_PARTS_SET = Set.of( "head", "body", "rightArm", "leftArm", "rightLeg", "leftLeg" ); private static void enableSelectivePartsCore( KeyframeAnimation.AnimationBuilder builder, Set ownedParts, Set enabledParts, Set partsWithKeyframes, boolean isFullBodyAnimation, boolean allowFreeHead ) { for (String partName : ALL_PARTS_SET) { KeyframeAnimation.StateCollection part = getPartByName( builder, partName ); if (part != null) { if (ownedParts.contains(partName)) { // Always enable owned parts — the item controls these bones part.fullyEnablePart(false); } else if ( isFullBodyAnimation && enabledParts.contains(partName) && partsWithKeyframes.contains(partName) && (!"head".equals(partName) || allowFreeHead) ) { // Full-body animation: free part WITH keyframes — enable. // The "Full" prefix is the artist's explicit opt-in to animate // bones outside their declared regions. // Head is protected by default (preserves vanilla head tracking). // Use "Head" in the animation name (e.g., FullHeadStruggle) to // explicitly opt-in to head control for that animation. part.fullyEnablePart(false); } else { // Non-Full animation, other item's part, or free part without keyframes. // Disabled parts pass through to the lower-priority context layer. part.setEnabled(false); } } } } }