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, SetTracks 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, SetOnly 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 SetThis 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. * *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