From 3d242079711e930f9cec4ddd639c8de9eea4d468 Mon Sep 17 00:00:00 2001 From: James Hall Date: Sun, 25 Jan 2026 20:36:41 +0000 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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,