Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
231 changes: 208 additions & 23 deletions src/main/java/dev/amble/lib/client/bedrock/BedrockAnimation.java
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,22 @@ public class BedrockAnimation {
public static final Collection<String> IGNORED_BONES = Set.of("camera");
public static final Collection<String> ROOT_BONES = Set.of("root", "player");

// Bone lookup cache: WeakHashMap allows GC of ModelPart roots when no longer referenced
private static final WeakHashMap<ModelPart, Map<String, ModelPart>> 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<String, BoneTimeline> boneTimelines;
public final boolean overrideBones;
Expand All @@ -80,16 +94,107 @@ 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<String, ModelPart> getBoneMap(ModelPart root) {
return BONE_CACHE.computeIfAbsent(root, r -> {
Map<String, ModelPart> 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<String, ModelPart> 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<String, ModelPart> children = (Map<String, ModelPart>) 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();
}

/**
* 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.
*/
private static ModelPart getBone(ModelPart root, String boneName, Map<String, ModelPart> 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<String, ModelPart> 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;
Expand All @@ -101,37 +206,72 @@ 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);
}
});

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);
}
Expand Down Expand Up @@ -190,6 +330,19 @@ 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();

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;
Expand All @@ -205,14 +358,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;
}

Expand All @@ -222,11 +394,14 @@ public void resetBones(ModelPart root, boolean resetAll) {
return;
}

// Get cached bone map for O(1) lookups instead of traversing every frame
Map<String, ModelPart> 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;
Expand Down Expand Up @@ -359,6 +534,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);

Expand All @@ -368,7 +549,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),
Expand All @@ -382,9 +563,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, BedrockAnimation.BoneTimeline> boneTimelines = new HashMap<>();
Expand Down Expand Up @@ -119,10 +141,14 @@ 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);
}

return new BedrockAnimation(shouldLoop, animationLength, boneTimelines, overrideBones, metadata, sounds);
return new BedrockAnimation(loopMode, animationLength, boneTimelines, overrideBones, metadata, sounds);
}

private BedrockAnimation.BoneTimeline deserializeBoneTimeline(JsonObject bone) {
Expand Down
Loading
Loading