From 3d242079711e930f9cec4ddd639c8de9eea4d468 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 25 Jan 2026 20:36:41 +0000 Subject: [PATCH 1/8] perf: O(1) bone lookup instead of search every frame --- gradle.properties | 2 +- .../lib/client/bedrock/BedrockAnimation.java | 92 +++++- .../bedrock/TargetedAnimationState.java | 311 ++++++++++++++++++ 3 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 src/main/java/dev/amble/lib/client/bedrock/TargetedAnimationState.java diff --git a/gradle.properties b/gradle.properties index f2d5908..df643f9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,7 +9,7 @@ yarn_mappings=1.20.1+build.10 loader_version=0.16.10 # Mod Properties -mod_version=1.1.15 +mod_version=1.1.16 maven_group=dev.amble publication_base_name=lib archives_base_name=amblekit diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java index fbba0d1..c785969 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -58,6 +58,8 @@ public class BedrockAnimation { public static final Collection IGNORED_BONES = Set.of("camera"); public static final Collection ROOT_BONES = Set.of("root", "player"); + // Bone lookup cache: WeakHashMap allows GC of ModelPart roots when no longer referenced + private static final WeakHashMap> BONE_CACHE = new WeakHashMap<>(); public final boolean shouldLoop; public final double animationLength; @@ -80,16 +82,99 @@ public static BedrockAnimation getFor(AnimatedEntity animated) { return anim; } + /** + * Gets or builds a cached map of bone names to ModelParts for O(1) lookups. + * Uses WeakHashMap so entries are automatically cleaned up when the root ModelPart is GC'd. + */ + private static Map getBoneMap(ModelPart root) { + return BONE_CACHE.computeIfAbsent(root, r -> { + Map map = new HashMap<>(); + buildBoneMap(r, map); + return map; + }); + } + + // Cached reflection field for ModelPart.children + private static java.lang.reflect.Field CHILDREN_FIELD = null; + private static boolean CHILDREN_FIELD_ATTEMPTED = false; + + // Possible field names for ModelPart.children across different mappings + private static final String[] CHILDREN_FIELD_NAMES = {"children", "field_3683"}; + + /** + * Recursively builds a map of bone names to their ModelPart objects. + * Uses reflection to access the children map, which is more reliable than + * traversing and checking hasChild for every possible name. + */ + private static void buildBoneMap(ModelPart part, Map map) { + // Try to get the children field via reflection (cached) + if (!CHILDREN_FIELD_ATTEMPTED) { + CHILDREN_FIELD_ATTEMPTED = true; + for (String fieldName : CHILDREN_FIELD_NAMES) { + try { + CHILDREN_FIELD = ModelPart.class.getDeclaredField(fieldName); + CHILDREN_FIELD.setAccessible(true); + break; + } catch (Exception ignored) { + // Try next field name + } + } + } + + if (CHILDREN_FIELD == null) return; + + part.traverse().forEach(p -> { + try { + @SuppressWarnings("unchecked") + Map children = (Map) CHILDREN_FIELD.get(p); + map.putAll(children); + } catch (Exception ignored) { + // Skip this part + } + }); + } + + /** + * Clears the bone cache. Call this if models are reloaded. + */ + public static void clearBoneCache() { + BONE_CACHE.clear(); + } + + /** + * Gets a bone by name from the cache, falling back to slow traversal if needed. + */ + private static ModelPart getBone(ModelPart root, String boneName, Map boneMap) { + ModelPart bone = boneMap.get(boneName); + if (bone != null) return bone; + + // Cache miss - bone name wasn't in the cache, fall back to slow path and cache it + bone = root.traverse() + .filter(part -> part.hasChild(boneName)) + .findFirst() + .map(part -> part.getChild(boneName)) + .orElse(null); + + if (bone != null) { + boneMap.put(boneName, bone); + } + + return bone; + } + @Environment(EnvType.CLIENT) public void apply(ModelPart root, double runningSeconds) { this.resetBones(root, this.overrideBones); + // Get cached bone map for O(1) lookups instead of traversing every frame + Map boneMap = getBoneMap(root); + this.boneTimelines.forEach((boneName, timeline) -> { try { if (IGNORED_BONES.contains(boneName.toLowerCase())) return; - ModelPart bone = root.traverse().filter(part -> part.hasChild(boneName)).findFirst().map(part -> part.getChild(boneName)).orElse(null); + ModelPart bone = getBone(root, boneName, boneMap); if (bone == null) { if (ROOT_BONES.contains(boneName.toLowerCase())) { bone = root; @@ -222,11 +307,14 @@ public void resetBones(ModelPart root, boolean resetAll) { return; } + // Get cached bone map for O(1) lookups instead of traversing every frame + Map boneMap = getBoneMap(root); + this.boneTimelines.forEach((boneName, timeline) -> { try { if (IGNORED_BONES.contains(boneName.toLowerCase())) return; - ModelPart bone = root.traverse().filter(part -> part.hasChild(boneName)).findFirst().map(part -> part.getChild(boneName)).orElse(null); + ModelPart bone = getBone(root, boneName, boneMap); if (bone == null) { if (ROOT_BONES.contains(boneName.toLowerCase())) { bone = root; diff --git a/src/main/java/dev/amble/lib/client/bedrock/TargetedAnimationState.java b/src/main/java/dev/amble/lib/client/bedrock/TargetedAnimationState.java new file mode 100644 index 0000000..0eb9c60 --- /dev/null +++ b/src/main/java/dev/amble/lib/client/bedrock/TargetedAnimationState.java @@ -0,0 +1,311 @@ +package dev.amble.lib.client.bedrock; + +import net.fabricmc.api.EnvType; +import net.fabricmc.api.Environment; +import net.minecraft.entity.AnimationState; +import net.minecraft.util.math.MathHelper; + +/** + * An animation state that allows targeting a specific progress (0-1) and smoothly + * transitioning to that target. Supports forward and reverse playback. + * + *

Usage example: + *

{@code
+ * TargetedAnimationState state = new TargetedAnimationState();
+ * state.setAnimationLength(1000); // 1 second animation
+ *
+ * // To play animation forward to completion:
+ * state.setTargetProgress(1.0f);
+ *
+ * // To play animation in reverse:
+ * state.setTargetProgress(0.0f);
+ *
+ * // Each tick, call:
+ * state.tick(deltaTimeMs);
+ *
+ * // Get the current animation time for rendering:
+ * long animTime = state.getAnimationTimeMs();
+ * }
+ * + * Even though this implements animation state, it does not change the built-in + * animation time directly. Instead, use getAnimationTimeMs() to retrieve the + * current time based on progress. + */ +public class TargetedAnimationState extends AnimationState { + + /** + * The target progress to animate toward (0-1) + */ + private float targetProgress = 0f; + + /** + * The current progress of the animation (0-1) + */ + private float currentProgress = 0f; + + /** + * The total length of the animation in milliseconds + */ + private long animationLengthMs = 1000L; + + /** + * Speed multiplier for transitioning (1.0 = normal speed) + */ + private float transitionSpeed = 1.0f; + + /** + * Whether the animation is currently transitioning + */ + private boolean running = false; + + /** + * The last time the animation was updated (in milliseconds) + */ + private long lastUpdateTime = 0L; + + public TargetedAnimationState() { + } + + /** + * Creates a TargetedAnimationState with a specified animation length. + * + * @param animationLengthMs The total animation length in milliseconds + */ + public TargetedAnimationState(long animationLengthMs) { + this.animationLengthMs = animationLengthMs; + } + + /** + * Sets the target progress to 1.0 (fully played). + */ + public void playForward() { + setTargetProgress(1.0f); + } + + /** + * Sets the target progress to 0.0 (reversed to start). + */ + public void playReverse() { + setTargetProgress(0.0f); + } + + /** + * Gets the current target progress. + * + * @return The target progress (0-1) + */ + public float getTargetProgress() { + return targetProgress; + } + + /** + * Sets the target progress for the animation. + * The current progress will smoothly transition toward this value. + * + * @param target The target progress (0-1), will be clamped + */ + public void setTargetProgress(float target) { + this.targetProgress = MathHelper.clamp(target, 0f, 1f); + if (!this.running && this.currentProgress != this.targetProgress) { + this.running = true; + this.lastUpdateTime = System.currentTimeMillis(); + } + } + + /** + * Gets the current animation progress. + * + * @return The current progress (0-1) + */ + public float getCurrentProgress() { + return currentProgress; + } + + /** + * Sets the current progress directly without animation. + * + * @param progress The progress to set (0-1), will be clamped + */ + public void setCurrentProgress(float progress) { + this.currentProgress = MathHelper.clamp(progress, 0f, 1f); + } + + /** + * Gets the total animation length in milliseconds. + * + * @return The animation length in milliseconds + */ + public long getAnimationLength() { + return animationLengthMs; + } + + /** + * Sets the total animation length in milliseconds. + * + * @param lengthMs The animation length in milliseconds + */ + public void setAnimationLength(long lengthMs) { + this.animationLengthMs = Math.max(1L, lengthMs); + } + + @Environment(EnvType.CLIENT) + public void setAnimationLength(BedrockAnimation animation) { + setAnimationLength((long) (animation.animationLength * 1000L)); + } + + /** + * Gets the transition speed multiplier. + * + * @return The speed multiplier + */ + public float getTransitionSpeed() { + return transitionSpeed; + } + + /** + * Sets the transition speed multiplier. + * + * @param speed The speed multiplier (1.0 = normal, 2.0 = double speed, etc.) + */ + public void setTransitionSpeed(float speed) { + this.transitionSpeed = Math.max(0.001f, speed); + } + + /** + * Updates the animation state. Call this every tick or frame. + * Uses system time to calculate delta. + */ + public void tick() { + long currentTime = System.currentTimeMillis(); + if (lastUpdateTime == 0L) { + lastUpdateTime = currentTime; + } + long deltaMs = currentTime - lastUpdateTime; + lastUpdateTime = currentTime; + + tick(deltaMs); + } + + /** + * Updates the animation state with a specific delta time. + * + * @param deltaMs The time elapsed since last update in milliseconds + */ + public void tick(long deltaMs) { + if (!running || currentProgress == targetProgress) { + if (currentProgress == targetProgress) { + running = false; + } + return; + } + + // Calculate how much progress to add based on delta time + float progressDelta = (deltaMs * transitionSpeed) / (float) animationLengthMs; + + if (targetProgress > currentProgress) { + // Moving forward + currentProgress = Math.min(currentProgress + progressDelta, targetProgress); + } else { + // Moving backward (reverse) + currentProgress = Math.max(currentProgress - progressDelta, targetProgress); + } + + // Check if we've reached the target + if (currentProgress == targetProgress) { + running = false; + } + } + + /** + * Gets the current animation time in milliseconds based on current progress. + * Use this value when applying animations. + * + * @return The animation time in milliseconds + */ + public long getAnimationTimeMs() { + return (long) (currentProgress * animationLengthMs); + } + + /** + * Gets the current animation time in seconds based on current progress. + * + * @return The animation time in seconds + */ + public float getAnimationTimeSecs() { + return currentProgress * (animationLengthMs / 1000f); + } + + /** + * Returns whether the animation is currently transitioning toward the target. + * + * @return true if currently animating + */ + public boolean isRunning() { + return running; + } + + /** + * Returns whether the animation has reached its target. + * + * @return true if current progress equals target progress + */ + public boolean isAtTarget() { + return currentProgress == targetProgress; + } + + /** + * Returns whether the animation is at the start (progress = 0). + * + * @return true if at the beginning + */ + public boolean isAtStart() { + return currentProgress == 0f; + } + + /** + * Returns whether the animation is at the end (progress = 1). + * + * @return true if at the end + */ + public boolean isAtEnd() { + return currentProgress == 1f; + } + + /** + * Resets the animation to the start position without animating. + */ + public void reset() { + this.currentProgress = 0f; + this.targetProgress = 0f; + this.running = false; + this.lastUpdateTime = 0L; + } + + /** + * Stops the animation at its current position. + */ + public void stop() { + this.targetProgress = this.currentProgress; + this.running = false; + } + + /** + * Jumps directly to the target progress without animating. + */ + public void jumpToTarget() { + this.currentProgress = this.targetProgress; + this.running = false; + } + + @Override + public String toString() { + return "TargetedAnimationState{" + + "targetProgress=" + targetProgress + + ", currentProgress=" + currentProgress + + ", animationLengthMs=" + animationLengthMs + + ", transitionSpeed=" + transitionSpeed + + ", running=" + running + + ", lastUpdateTime=" + lastUpdateTime + + '}'; + } +} From ed0927026b89e7b95200195b722717feff855f6e Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 25 Jan 2026 20:37:58 +0000 Subject: [PATCH 2/8] feat: add apply method for animation state with effect support --- .../amble/lib/client/bedrock/BedrockAnimation.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java index c785969..37ea818 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -275,6 +275,17 @@ public void apply(ModelPart root, AnimationState state, float progress, float sp }); } + @Environment(EnvType.CLIENT) + public void apply(ModelPart root, TargetedAnimationState state, @Nullable EffectProvider provider) { + float previous = state.getAnimationTimeSecs() - 0.01F; + state.tick(); + float current = state.getAnimationTimeSecs() - 0.01F; + + state.setAnimationLength(this); + this.apply(root, current); + this.applyEffects(provider, current, previous, root); + } + public void apply(ModelPart root, int totalTicks, float rawDelta) { float ticks = (float) ((totalTicks / 20F) % (this.animationLength)) * 20; float delta = rawDelta / 10F; From a8923afeedfde4ca600aedddad7df8cf32848865 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 25 Jan 2026 21:53:53 +0000 Subject: [PATCH 3/8] fix?: exploded vertices --- .../lib/client/bedrock/BedrockAnimation.java | 39 ++++++++++++++++--- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java index 37ea818..e6579b8 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -141,6 +141,14 @@ public static void clearBoneCache() { BONE_CACHE.clear(); } + /** + * Checks if a Vec3d contains valid (non-NaN, non-Infinite) values. + * Invalid values can corrupt the render state and cause black screens. + */ + private static boolean isValidVec3d(Vec3d vec) { + return Double.isFinite(vec.x) && Double.isFinite(vec.y) && Double.isFinite(vec.z); + } + /** * Gets a bone by name from the cache, falling back to slow traversal if needed. */ @@ -186,6 +194,9 @@ public void apply(ModelPart root, double runningSeconds) { if (!timeline.position.isEmpty()) { Vec3d position = timeline.position.resolve(runningSeconds); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(position)) return; + // traverse includes self bone.traverse().forEach(child -> { child.pivotX += (float) position.x; @@ -197,6 +208,9 @@ public void apply(ModelPart root, double runningSeconds) { if (!timeline.rotation.isEmpty()) { Vec3d rotation = timeline.rotation.resolve(runningSeconds); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(rotation)) return; + bone.pitch += (float) Math.toRadians((float) rotation.x); bone.yaw += (float) Math.toRadians((float) rotation.y); bone.roll += (float) Math.toRadians((float) rotation.z); @@ -205,6 +219,9 @@ public void apply(ModelPart root, double runningSeconds) { if (!timeline.scale.isEmpty()) { Vec3d scale = timeline.scale.resolve(runningSeconds); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(scale)) return; + bone.traverse().forEach(child -> { child.xScale = (float) scale.x; child.yScale = (float) scale.y; @@ -277,11 +294,13 @@ public void apply(ModelPart root, AnimationState state, float progress, float sp @Environment(EnvType.CLIENT) public void apply(ModelPart root, TargetedAnimationState state, @Nullable EffectProvider provider) { + // IMPORTANT: Set animation length BEFORE calculating time values + state.setAnimationLength(this); + float previous = state.getAnimationTimeSecs() - 0.01F; state.tick(); - float current = state.getAnimationTimeSecs() - 0.01F; + float current = state.getAnimationTimeSecs(); - state.setAnimationLength(this); this.apply(root, current); this.applyEffects(provider, current, previous, root); } @@ -458,6 +477,12 @@ public Vec3d resolve(double time) { if (smoothBefore || smoothAfter) { if (before != null && after != null) { + // Guard against division by zero when keyframes have the same time + double timeDiff = after.time - before.time; + if (timeDiff == 0) { + return beforeData; + } + Integer beforePlusIndex = beforeIndex == 0 ? null : beforeIndex - 1; KeyFrame beforePlus = getAtIndex(this, beforePlusIndex); @@ -467,7 +492,7 @@ public Vec3d resolve(double time) { Vec3d beforePlusData = (beforePlus != null && beforePlus.getPost() != null) ? beforePlus.getPost().resolve(time) : beforeData; Vec3d afterPlusData = (afterPlus != null && afterPlus.getPre() != null) ? afterPlus.getPre().resolve(time) : afterData; - double t = (time - before.time) / (after.time - before.time); + double t = (time - before.time) / timeDiff; return new Vec3d( catmullRom((float) t, (float) beforePlusData.x, (float) beforeData.x, (float) afterData.x, (float) afterPlusData.x), @@ -481,9 +506,13 @@ public Vec3d resolve(double time) { } } else { if (before != null && after != null) { - double alpha = time; + // Guard against division by zero when keyframes have the same time + double timeDiff = after.time - before.time; + if (timeDiff == 0) { + return beforeData; + } - alpha = (alpha - before.time) / (after.time - before.time); + double alpha = (time - before.time) / timeDiff; return new Vec3d( beforeData.getX() + (afterData.getX() - beforeData.getX()) * alpha, From e5046c4d8471932930937d5ef8c87a5da18ae35e Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 25 Jan 2026 22:46:55 +0000 Subject: [PATCH 4/8] Revert "fix?: exploded vertices" This reverts commit a8923afeedfde4ca600aedddad7df8cf32848865. --- .../lib/client/bedrock/BedrockAnimation.java | 39 +++---------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java index e6579b8..37ea818 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -141,14 +141,6 @@ public static void clearBoneCache() { BONE_CACHE.clear(); } - /** - * Checks if a Vec3d contains valid (non-NaN, non-Infinite) values. - * Invalid values can corrupt the render state and cause black screens. - */ - private static boolean isValidVec3d(Vec3d vec) { - return Double.isFinite(vec.x) && Double.isFinite(vec.y) && Double.isFinite(vec.z); - } - /** * Gets a bone by name from the cache, falling back to slow traversal if needed. */ @@ -194,9 +186,6 @@ public void apply(ModelPart root, double runningSeconds) { if (!timeline.position.isEmpty()) { Vec3d position = timeline.position.resolve(runningSeconds); - // Guard against NaN/Infinity corrupting render state - if (!isValidVec3d(position)) return; - // traverse includes self bone.traverse().forEach(child -> { child.pivotX += (float) position.x; @@ -208,9 +197,6 @@ public void apply(ModelPart root, double runningSeconds) { if (!timeline.rotation.isEmpty()) { Vec3d rotation = timeline.rotation.resolve(runningSeconds); - // Guard against NaN/Infinity corrupting render state - if (!isValidVec3d(rotation)) return; - bone.pitch += (float) Math.toRadians((float) rotation.x); bone.yaw += (float) Math.toRadians((float) rotation.y); bone.roll += (float) Math.toRadians((float) rotation.z); @@ -219,9 +205,6 @@ public void apply(ModelPart root, double runningSeconds) { if (!timeline.scale.isEmpty()) { Vec3d scale = timeline.scale.resolve(runningSeconds); - // Guard against NaN/Infinity corrupting render state - if (!isValidVec3d(scale)) return; - bone.traverse().forEach(child -> { child.xScale = (float) scale.x; child.yScale = (float) scale.y; @@ -294,13 +277,11 @@ public void apply(ModelPart root, AnimationState state, float progress, float sp @Environment(EnvType.CLIENT) public void apply(ModelPart root, TargetedAnimationState state, @Nullable EffectProvider provider) { - // IMPORTANT: Set animation length BEFORE calculating time values - state.setAnimationLength(this); - float previous = state.getAnimationTimeSecs() - 0.01F; state.tick(); - float current = state.getAnimationTimeSecs(); + float current = state.getAnimationTimeSecs() - 0.01F; + state.setAnimationLength(this); this.apply(root, current); this.applyEffects(provider, current, previous, root); } @@ -477,12 +458,6 @@ public Vec3d resolve(double time) { if (smoothBefore || smoothAfter) { if (before != null && after != null) { - // Guard against division by zero when keyframes have the same time - double timeDiff = after.time - before.time; - if (timeDiff == 0) { - return beforeData; - } - Integer beforePlusIndex = beforeIndex == 0 ? null : beforeIndex - 1; KeyFrame beforePlus = getAtIndex(this, beforePlusIndex); @@ -492,7 +467,7 @@ public Vec3d resolve(double time) { Vec3d beforePlusData = (beforePlus != null && beforePlus.getPost() != null) ? beforePlus.getPost().resolve(time) : beforeData; Vec3d afterPlusData = (afterPlus != null && afterPlus.getPre() != null) ? afterPlus.getPre().resolve(time) : afterData; - double t = (time - before.time) / timeDiff; + double t = (time - before.time) / (after.time - before.time); return new Vec3d( catmullRom((float) t, (float) beforePlusData.x, (float) beforeData.x, (float) afterData.x, (float) afterPlusData.x), @@ -506,13 +481,9 @@ public Vec3d resolve(double time) { } } else { if (before != null && after != null) { - // Guard against division by zero when keyframes have the same time - double timeDiff = after.time - before.time; - if (timeDiff == 0) { - return beforeData; - } + double alpha = time; - double alpha = (time - before.time) / timeDiff; + alpha = (alpha - before.time) / (after.time - before.time); return new Vec3d( beforeData.getX() + (afterData.getX() - beforeData.getX()) * alpha, From add8116193f638cc22d237a4873bd967f00b73ae Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 27 Jan 2026 16:20:27 +0000 Subject: [PATCH 5/8] cumulative metadata --- .../animation/client/AnimationMetadata.java | 4 +- .../lib/client/bedrock/BedrockAnimation.java | 89 +++++++++++++++---- .../bedrock/BedrockAnimationAdapter.java | 4 + .../lib/client/bedrock/BedrockModel.java | 24 ++++- 4 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java b/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java index 59a71a7..4aefe7c 100644 --- a/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java +++ b/src/main/java/dev/amble/lib/animation/client/AnimationMetadata.java @@ -20,9 +20,9 @@ * @param excess Any excess metadata not used by AmbleKit. */ @Environment(EnvType.CLIENT) -public record AnimationMetadata(@With boolean movement, @With Perspective perspective, @With boolean fpsCamera, @With boolean hideHandItems, @With boolean hideHud, @With boolean fpsCameraCopiesHead, @With +public record AnimationMetadata(@With boolean movement, @With Perspective perspective, @With boolean fpsCamera, @With boolean hideHandItems, @With boolean hideHud, @With boolean fpsCameraCopiesHead, @With boolean cumulative, @With JsonObject excess) { - public static final AnimationMetadata DEFAULT = new AnimationMetadata(true, null, true, true, false, false, new JsonObject()); + public static final AnimationMetadata DEFAULT = new AnimationMetadata(true, null, true, true, false, false, false, new JsonObject()); @Nullable public static AnimationMetadata getFor(AnimatedEntity animated) { diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java index 37ea818..c4ff703 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -141,6 +141,14 @@ public static void clearBoneCache() { BONE_CACHE.clear(); } + /** + * Checks if a Vec3d contains valid (non-NaN, non-Infinite) values. + * Invalid values can corrupt the render state and cause black screens. + */ + private static boolean isValidVec3d(Vec3d vec) { + return Double.isFinite(vec.x) && Double.isFinite(vec.y) && Double.isFinite(vec.z); + } + /** * Gets a bone by name from the cache, falling back to slow traversal if needed. */ @@ -186,30 +194,61 @@ public void apply(ModelPart root, double runningSeconds) { if (!timeline.position.isEmpty()) { Vec3d position = timeline.position.resolve(runningSeconds); - // traverse includes self - bone.traverse().forEach(child -> { - child.pivotX += (float) position.x; - child.pivotY += (float) position.y; - child.pivotZ += (float) position.z; - }); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(position)) return; + + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.pivotX += (float) position.x; + child.pivotY += (float) position.y; + child.pivotZ += (float) position.z; + }); + } else { + bone.pivotX += (float) position.x; + bone.pivotY += (float) position.y; + bone.pivotZ += (float) position.z; + } } if (!timeline.rotation.isEmpty()) { Vec3d rotation = timeline.rotation.resolve(runningSeconds); - bone.pitch += (float) Math.toRadians((float) rotation.x); - bone.yaw += (float) Math.toRadians((float) rotation.y); - bone.roll += (float) Math.toRadians((float) rotation.z); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(rotation)) return; + + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.pitch += (float) Math.toRadians((float) rotation.x); + child.yaw += (float) Math.toRadians((float) rotation.y); + child.roll += (float) Math.toRadians((float) rotation.z); + }); + } else { + bone.pitch += (float) Math.toRadians((float) rotation.x); + bone.yaw += (float) Math.toRadians((float) rotation.y); + bone.roll += (float) Math.toRadians((float) rotation.z); + } } if (!timeline.scale.isEmpty()) { Vec3d scale = timeline.scale.resolve(runningSeconds); - bone.traverse().forEach(child -> { - child.xScale = (float) scale.x; - child.yScale = (float) scale.y; - child.zScale = (float) scale.z; - }); + // Guard against NaN/Infinity corrupting render state + if (!isValidVec3d(scale)) return; + + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.xScale = (float) scale.x; + child.yScale = (float) scale.y; + child.zScale = (float) scale.z; + }); + } else { + bone.xScale = (float) scale.x; + bone.yScale = (float) scale.y; + bone.zScale = (float) scale.z; + } } } catch (Exception e) { ///AmbleKit.LOGGER.error("Failed apply animation to {} in model. Skipping animation application for this bone.", boneName, e); @@ -277,11 +316,13 @@ public void apply(ModelPart root, AnimationState state, float progress, float sp @Environment(EnvType.CLIENT) public void apply(ModelPart root, TargetedAnimationState state, @Nullable EffectProvider provider) { + // IMPORTANT: Set animation length BEFORE calculating time values + state.setAnimationLength(this); + float previous = state.getAnimationTimeSecs() - 0.01F; state.tick(); - float current = state.getAnimationTimeSecs() - 0.01F; + float current = state.getAnimationTimeSecs(); - state.setAnimationLength(this); this.apply(root, current); this.applyEffects(provider, current, previous, root); } @@ -458,6 +499,12 @@ public Vec3d resolve(double time) { if (smoothBefore || smoothAfter) { if (before != null && after != null) { + // Guard against division by zero when keyframes have the same time + double timeDiff = after.time - before.time; + if (timeDiff == 0) { + return beforeData; + } + Integer beforePlusIndex = beforeIndex == 0 ? null : beforeIndex - 1; KeyFrame beforePlus = getAtIndex(this, beforePlusIndex); @@ -467,7 +514,7 @@ public Vec3d resolve(double time) { Vec3d beforePlusData = (beforePlus != null && beforePlus.getPost() != null) ? beforePlus.getPost().resolve(time) : beforeData; Vec3d afterPlusData = (afterPlus != null && afterPlus.getPre() != null) ? afterPlus.getPre().resolve(time) : afterData; - double t = (time - before.time) / (after.time - before.time); + double t = (time - before.time) / timeDiff; return new Vec3d( catmullRom((float) t, (float) beforePlusData.x, (float) beforeData.x, (float) afterData.x, (float) afterPlusData.x), @@ -481,9 +528,13 @@ public Vec3d resolve(double time) { } } else { if (before != null && after != null) { - double alpha = time; + // Guard against division by zero when keyframes have the same time + double timeDiff = after.time - before.time; + if (timeDiff == 0) { + return beforeData; + } - alpha = (alpha - before.time) / (after.time - before.time); + double alpha = (time - before.time) / timeDiff; return new Vec3d( beforeData.getX() + (afterData.getX() - beforeData.getX()) * alpha, diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java index ce0b9bf..3c8236b 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java @@ -119,6 +119,10 @@ public BedrockAnimation deserialize(JsonElement json, Type typeOfT, JsonDeserial jsonMetadata.remove("camera_uses_head"); } + // Whether translations are cumulative (apply to children) - default false + boolean cumulative = jsonObj.has("cumulative") && jsonObj.get("cumulative").getAsBoolean(); + metadata = metadata.withCumulative(cumulative); + metadata = metadata.withExcess(jsonMetadata); } diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java index a07b333..b7c2606 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java @@ -18,11 +18,16 @@ import net.minecraft.client.model.*; import net.minecraft.util.Identifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.lang.reflect.Type; import java.util.*; @Environment(EnvType.CLIENT) public class BedrockModel implements Identifiable { + private static final Logger LOGGER = LoggerFactory.getLogger(BedrockModel.class); + @SerializedName("format_version") public String version; @SerializedName("minecraft:geometry") @@ -101,10 +106,21 @@ public TexturedModelData create() { List pivot = (cube.pivot != null) ? cube.pivot : bone.pivot; if (cube.uv != null) { - subPart.uv( - cube.uv.get(0), - cube.uv.get(1) - ); + int uvX = cube.uv.get(0); + int uvY = cube.uv.get(1); + + // Validate and clamp UV coordinates to texture bounds + int textureWidth = geometry.description.textureWidth; + int textureHeight = geometry.description.textureHeight; + + if (uvX < 0 || uvX >= textureWidth || uvY < 0 || uvY >= textureHeight) { + LOGGER.warn("Model '{}' bone '{}' has out-of-bounds UV coordinates [{}, {}] for texture size {}x{}. Clamping to valid range.", + geometry.description.identifier, bone.name, uvX, uvY, textureWidth, textureHeight); + uvX = Math.max(0, Math.min(uvX, textureWidth - 1)); + uvY = Math.max(0, Math.min(uvY, textureHeight - 1)); + } + + subPart.uv(uvX, uvY); } if (cube.mirror) { subPart.mirrored(); From 2390d4c5cbdba1d42605f2ac4fa966f026289427 Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 27 Jan 2026 16:54:48 +0000 Subject: [PATCH 6/8] dont do that --- .../dev/amble/lib/client/bedrock/BedrockModel.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java index b7c2606..23758d8 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockModel.java @@ -109,17 +109,6 @@ public TexturedModelData create() { int uvX = cube.uv.get(0); int uvY = cube.uv.get(1); - // Validate and clamp UV coordinates to texture bounds - int textureWidth = geometry.description.textureWidth; - int textureHeight = geometry.description.textureHeight; - - if (uvX < 0 || uvX >= textureWidth || uvY < 0 || uvY >= textureHeight) { - LOGGER.warn("Model '{}' bone '{}' has out-of-bounds UV coordinates [{}, {}] for texture size {}x{}. Clamping to valid range.", - geometry.description.identifier, bone.name, uvX, uvY, textureWidth, textureHeight); - uvX = Math.max(0, Math.min(uvX, textureWidth - 1)); - uvY = Math.max(0, Math.min(uvY, textureHeight - 1)); - } - subPart.uv(uvX, uvY); } if (cube.mirror) { From 887db976f77b98d75a292177a318bd1b2e22f877 Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 27 Jan 2026 17:14:35 +0000 Subject: [PATCH 7/8] feat: loop types support --- .../lib/client/bedrock/BedrockAnimation.java | 93 +++++++++++-------- .../bedrock/BedrockAnimationAdapter.java | 26 +++++- .../bedrock/BedrockAnimationTracker.java | 8 +- 3 files changed, 84 insertions(+), 43 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java index c4ff703..f95a84c 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -61,7 +61,19 @@ public class BedrockAnimation { // Bone lookup cache: WeakHashMap allows GC of ModelPart roots when no longer referenced private static final WeakHashMap> BONE_CACHE = new WeakHashMap<>(); - public final boolean shouldLoop; + /** + * Loop mode for Bedrock animations: + * - LOOP: Animation repeats from the beginning when finished + * - HOLD_ON_LAST_FRAME: Animation holds on the last frame (still counted as playing) + * - NONE: Animation resets to starting position when finished + */ + public enum LoopMode { + LOOP, + HOLD_ON_LAST_FRAME, + NONE + } + + public final LoopMode loopMode; public final double animationLength; public final Map boneTimelines; public final boolean overrideBones; @@ -197,18 +209,12 @@ public void apply(ModelPart root, double runningSeconds) { // Guard against NaN/Infinity corrupting render state if (!isValidVec3d(position)) return; - if (metadata.cumulative()) { - // traverse includes self - bone.traverse().forEach(child -> { - child.pivotX += (float) position.x; - child.pivotY += (float) position.y; - child.pivotZ += (float) position.z; - }); - } else { - bone.pivotX += (float) position.x; - bone.pivotY += (float) position.y; - bone.pivotZ += (float) position.z; - } + // traverse includes self + bone.traverse().forEach(child -> { + child.pivotX += (float) position.x; + child.pivotY += (float) position.y; + child.pivotZ += (float) position.z; + }); } if (!timeline.rotation.isEmpty()) { @@ -217,18 +223,9 @@ public void apply(ModelPart root, double runningSeconds) { // Guard against NaN/Infinity corrupting render state if (!isValidVec3d(rotation)) return; - if (metadata.cumulative()) { - // traverse includes self - bone.traverse().forEach(child -> { - child.pitch += (float) Math.toRadians((float) rotation.x); - child.yaw += (float) Math.toRadians((float) rotation.y); - child.roll += (float) Math.toRadians((float) rotation.z); - }); - } else { - bone.pitch += (float) Math.toRadians((float) rotation.x); - bone.yaw += (float) Math.toRadians((float) rotation.y); - bone.roll += (float) Math.toRadians((float) rotation.z); - } + bone.pitch += (float) Math.toRadians((float) rotation.x); + bone.yaw += (float) Math.toRadians((float) rotation.y); + bone.roll += (float) Math.toRadians((float) rotation.z); } if (!timeline.scale.isEmpty()) { @@ -237,25 +234,22 @@ public void apply(ModelPart root, double runningSeconds) { // Guard against NaN/Infinity corrupting render state if (!isValidVec3d(scale)) return; - if (metadata.cumulative()) { - // traverse includes self - bone.traverse().forEach(child -> { - child.xScale = (float) scale.x; - child.yScale = (float) scale.y; - child.zScale = (float) scale.z; - }); - } else { - bone.xScale = (float) scale.x; - bone.yScale = (float) scale.y; - bone.zScale = (float) scale.z; - } + bone.traverse().forEach(child -> { + child.xScale = (float) scale.x; + child.yScale = (float) scale.y; + child.zScale = (float) scale.z; + }); } } catch (Exception e) { ///AmbleKit.LOGGER.error("Failed apply animation to {} in model. Skipping animation application for this bone.", boneName, e); } }); - boolean isComplete = !this.shouldLoop && runningSeconds >= this.animationLength; + // Only reset bones when the animation is complete AND has no loop mode (NONE) + // LOOP: will wrap around via getRunningSeconds + // HOLD_ON_LAST_FRAME: should stay on last frame, not reset + // NONE: should reset to starting position when finished + boolean isComplete = this.loopMode == LoopMode.NONE && runningSeconds >= this.animationLength; if (isComplete) { this.resetBones(root, true); } @@ -342,14 +336,33 @@ public double getRunningSeconds(AnimationState state, float progress, float spee public double getRunningSeconds(AnimationState state) { float f = (float)state.getTimeRunning() / 1000.0F; - double seconds = this.shouldLoop ? f % this.animationLength : f; + double seconds; + + switch (this.loopMode) { + case LOOP: + seconds = f % this.animationLength; + break; + case HOLD_ON_LAST_FRAME: + // Clamp to animation length so it stays on last frame + seconds = Math.min(f, this.animationLength); + break; + case NONE: + default: + seconds = f; + break; + } return seconds; } public boolean isFinished(AnimationState state) { - if (this.shouldLoop) return false; + // Looping animations never finish + if (this.loopMode == LoopMode.LOOP) return false; + + // Hold on last frame animations are still considered "playing" - they don't finish + if (this.loopMode == LoopMode.HOLD_ON_LAST_FRAME) return false; + // NONE mode: animation finishes when it reaches the end return getRunningSeconds(state) >= this.animationLength; } diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java index 3c8236b..081b684 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationAdapter.java @@ -39,7 +39,29 @@ public BedrockAnimation deserialize(JsonElement json, Type typeOfT, JsonDeserial JsonObject jsonObj = json.getAsJsonObject(); double animationLength = jsonObj.has("animation_length") ? jsonObj.get("animation_length").getAsDouble() : -1.0; - boolean shouldLoop = animationLength > 0 && jsonObj.has("loop") && jsonObj.get("loop").getAsBoolean(); + + // Parse loop mode: true, "hold_on_last_frame", or none/false + BedrockAnimation.LoopMode loopMode = BedrockAnimation.LoopMode.NONE; + if (animationLength > 0 && jsonObj.has("loop")) { + JsonElement loopElement = jsonObj.get("loop"); + if (loopElement.isJsonPrimitive()) { + JsonPrimitive loopPrimitive = loopElement.getAsJsonPrimitive(); + if (loopPrimitive.isBoolean()) { + // "loop": true or "loop": false + loopMode = loopPrimitive.getAsBoolean() ? BedrockAnimation.LoopMode.LOOP : BedrockAnimation.LoopMode.NONE; + } else if (loopPrimitive.isString()) { + // "loop": "hold_on_last_frame" + String loopString = loopPrimitive.getAsString(); + if ("hold_on_last_frame".equals(loopString)) { + loopMode = BedrockAnimation.LoopMode.HOLD_ON_LAST_FRAME; + } else if ("true".equalsIgnoreCase(loopString)) { + loopMode = BedrockAnimation.LoopMode.LOOP; + } + // Any other string value defaults to NONE + } + } + } + boolean overrideBones = jsonObj.has("override_previous_animation") && jsonObj.get("override_previous_animation").getAsBoolean(); Map boneTimelines = new HashMap<>(); @@ -126,7 +148,7 @@ public BedrockAnimation deserialize(JsonElement json, Type typeOfT, JsonDeserial metadata = metadata.withExcess(jsonMetadata); } - return new BedrockAnimation(shouldLoop, animationLength, boneTimelines, overrideBones, metadata, sounds); + return new BedrockAnimation(loopMode, animationLength, boneTimelines, overrideBones, metadata, sounds); } private BedrockAnimation.BoneTimeline deserializeBoneTimeline(JsonObject bone) { diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationTracker.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationTracker.java index 39f06dc..9661e2f 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationTracker.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimationTracker.java @@ -69,9 +69,15 @@ private void tick() { } public boolean isDone() { - if (animation.shouldLoop) { + // Looping animations never finish + if (animation.loopMode == BedrockAnimation.LoopMode.LOOP) { return false; } + // Hold on last frame animations are still considered "playing" - they don't finish + if (animation.loopMode == BedrockAnimation.LoopMode.HOLD_ON_LAST_FRAME) { + return false; + } + // NONE mode: animation finishes when it reaches the end return ticks >= animation.animationLength * 20; // Convert seconds to ticks } From 7a34608b154f91f09ea5a348d4345e4e12b5d2c3 Mon Sep 17 00:00:00 2001 From: James Hall Date: Tue, 27 Jan 2026 17:27:48 +0000 Subject: [PATCH 8/8] fix: accidentally removed cumulative fix --- .../lib/client/bedrock/BedrockAnimation.java | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java index f95a84c..797442b 100644 --- a/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java +++ b/src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java @@ -209,12 +209,18 @@ public void apply(ModelPart root, double runningSeconds) { // Guard against NaN/Infinity corrupting render state if (!isValidVec3d(position)) return; - // traverse includes self - bone.traverse().forEach(child -> { - child.pivotX += (float) position.x; - child.pivotY += (float) position.y; - child.pivotZ += (float) position.z; - }); + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.pivotX += (float) position.x; + child.pivotY += (float) position.y; + child.pivotZ += (float) position.z; + }); + } else { + bone.pivotX += (float) position.x; + bone.pivotY += (float) position.y; + bone.pivotZ += (float) position.z; + } } if (!timeline.rotation.isEmpty()) { @@ -223,9 +229,18 @@ public void apply(ModelPart root, double runningSeconds) { // Guard against NaN/Infinity corrupting render state if (!isValidVec3d(rotation)) return; - bone.pitch += (float) Math.toRadians((float) rotation.x); - bone.yaw += (float) Math.toRadians((float) rotation.y); - bone.roll += (float) Math.toRadians((float) rotation.z); + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.pitch += (float) Math.toRadians((float) rotation.x); + child.yaw += (float) Math.toRadians((float) rotation.y); + child.roll += (float) Math.toRadians((float) rotation.z); + }); + } else { + bone.pitch += (float) Math.toRadians((float) rotation.x); + bone.yaw += (float) Math.toRadians((float) rotation.y); + bone.roll += (float) Math.toRadians((float) rotation.z); + } } if (!timeline.scale.isEmpty()) { @@ -234,11 +249,18 @@ public void apply(ModelPart root, double runningSeconds) { // Guard against NaN/Infinity corrupting render state if (!isValidVec3d(scale)) return; - bone.traverse().forEach(child -> { - child.xScale = (float) scale.x; - child.yScale = (float) scale.y; - child.zScale = (float) scale.z; - }); + if (metadata.cumulative()) { + // traverse includes self + bone.traverse().forEach(child -> { + child.xScale = (float) scale.x; + child.yScale = (float) scale.y; + child.zScale = (float) scale.z; + }); + } else { + bone.xScale = (float) scale.x; + bone.yScale = (float) scale.y; + bone.zScale = (float) scale.z; + } } } catch (Exception e) { ///AmbleKit.LOGGER.error("Failed apply animation to {} in model. Skipping animation application for this bone.", boneName, e);