Table of Contents
Animate()
Interpolates bone fields based on provided animation data, as well as initial states for non-animated fields.
function Animate(
armature: Armature,
anims: Animation[],
frames: Int[],
smoothFrames: Int[],
) {
for (let a = 0; a < anims.length; a++) {
for (let k = 0; k < anims[a].keyframes.length; k++) {
let kf = anims[a].keyframes[k];
// only prev keyframes are considered
if (kf.frame > frames[a]) {
break;
}
// refer keyframe to itself, if there's no next
if (kf.next_kf == -1) {
kf.next_kf = k;
}
const nextKf = anims[a].keyframes[kf.next_kf];
// skip keyframe if it's not the last, and would not be animated
const isLast = kf.next_kf == k;
const isBeforeFrame = nextKf.frame < frames[a];
if (isBeforeFrame && !isLast) {
continue;
}
const f = frames[a];
const sf = smoothFrames[a];
// animate basic fields
let bone = armature.bones[kf.bone_id];
if (kf.element == "PositionX")
interpolateKeyframes(bone.pos.x, kf, nextKf, f, sf);
if (kf.element == "PositionY")
interpolateKeyframes(bone.pos.y, kf, nextKf, f, sf);
if (kf.element == "Rotation")
interpolateKeyframes(bone.rot, kf, nextKf, f, sf);
if (kf.element == "ScaleX")
interpolateKeyframes(bone.scale.x, kf, nextKf, f, sf);
if (kf.element == "ScaleY")
interpolateKeyframes(bone.scale.y, kf, nextKf, f, sf);
if (kf.element == "Hidden") bone.hidden = kf.value == 1;
// animate visual fields
if (bone.visuals_id != -1) {
let visuals = armature.visuals[bone.visuals_id];
if (kf.element == "Texture") visuals.tex = kf.value_str;
if (kf.element == "TintR")
interpolateKeyframes(visuals.tint.r, kf, nextKf, f, sf);
if (kf.element == "TintG")
interpolateKeyframes(visuals.tint.g, kf, nextKf, f, sf);
if (kf.element == "TintB")
interpolateKeyframes(visuals.tint.b, kf, nextKf, f, sf);
if (kf.element == "TintA")
interpolateKeyframes(visuals.tint.a, kf, nextKf, f, sf);
}
// animate inverse kinematics fields
if (bone.ik_family_id != -1) {
let ik = armature.inverse_kinematics[bone.ik_family_id];
if (kf.element == "IkConstraint") ik.constraint = kf.value_str;
if (kf.element == "MimicTarget")
ik.mimic_target = kf.value == 1;
}
}
}
// bones that are not being animated should return to their initial states
resetBones(armature, anims, frames[0], smoothFrames[0]);
}
interpolateKeyframes()
With the provided animation frame, determines the keyframes to interpolate the field by.
The resulting interpolation from the keyframes is interpolated again for smoothing.
function interpolateKeyframes(
field: Float,
prevKf: Keyframe,
nextKf: Keyframe,
frame: Int,
smoothFrame: Int,
): Float {
const totalFrames = nextKf.frame - prevKf.frame;
const currentFrame = frame - prevKf.frame;
// interpolate from current to next keyframe value
const result = interpolate(
currentFrame,
totalFrames,
prevKf.value,
nextKf.value,
nextKf.start_handle,
nextKf.end_handle,
);
// using the requested smoothing frames, interpolate the current field to the target value
const z = { x: 0, y: 0 };
return interpolate(currentFrame, smoothFrame, field, result, z, z);
}
interpolate()
Interpolation uses a modified bezier spline (explanation below).
Note that 2 helper functions are included below the main function.
function interpolate(
current: Int,
max: Int,
startVal: Float,
endVal: Float,
startHandle: Vec2,
endHandle: Vec2,
): Float {
// snapping behavior for Snap transition preset
if(startHandle.y == 999.0 && endHandle.y == 999.0) {
return startVal;
}
if(max == 0 || current >= max) {
return endVal;
}
// solve for t with Newton-Raphson
const initial = current / max;
let t = initial;
for(let i = 0; i < 5; i++) {
let x = cubicBezier(t, startHandle.x, endHandle.x);
let dx = cubicBezierDerivative(t, startHandle.x, endHandle.x);
if(Math.abs(dx) < 1e-5 {
break;
}
t -= (x - initial) / dx;
t = clamp(t, 0.0, 1.0);
}
const progress = cubic_bezier(t, startHandle.y, endHandle.y)
return startVal + (endVal - startVal) * progress
}
// for both functions below, p0 and p3 are always 0 and 1 respectively
function cubicBezier(t: Float, p1: Float, p2: Float): Float {
const u = 1. - t;
return 3. * u * u * t * p1 + 3. * u * t * t * p2 + t * t * t;
}
function cubicBezierDerivative(t: Float, p1: Float, p2: Float): Float {
const u = 1. - t;
return 3. * u * u * p1 + 6. * u * t * (p2 - p1) + 3. * t * t * (1. - p2);
}
Bezier Explanation
Note: the following explanation is incomplete, as it doesn’t include Newton-Rapshon. However, understanding this is not required to implement the code above.
The bezier spline uses the following polynomial:
value =
a * (1 - t)^3 +
b * 3 * (1 - t)^2 * t +
c * 3 * (1 - t) * t^2 +
d * t^3
This can be simplified into 4 points:
| Formula | Coefficient (a, b, c, d) | |
|---|---|---|
| h00 | (1 - t)^3 | startVal |
| h01 | 3 * (1 - t)^2 * t | startHandle |
| h10 | 3 * (1 - t) * t^2 | endHandle |
| h11 | t^3 | endVal |
The above is for a generic bezier spline, however.
In interpolation, startVal and endVal should be 0 and 1 respectively to
represent 0% to 100% of the end value. This allows the algorithm to have a
persistent curve regardless of the actual values being interpolated.
Simplified points:
| Formula | Coefficient (b, c, d) | |
|---|---|---|
| h01 | 3 * (1 - t)^2 * t | startHandle |
| h10 | 3 * (1 -t) * t^2 | endHandle |
| h11 | t^3 | 1 |
Notice that h00 is now gone, as its coefficient, startVal, is always 0 and
would have no effect on the algorithm.
The actual start and end values are applied at the end:
progress = h10 * startHandle + h01 * endHandle + h11
value = start + (end - start) * progress
resetBones()
Animate bones back to their initial states, if they’re not being animated.
This makes use of each bones’ init_ fields (init_pos, init_rot, etc).
Example: animation 1 was played and rotated the arm bone, but animation 1 is not being played anymore. That arm bone must now return to its initial rotation.
function resetBones(
armature: Armature, animations: Animation[], frame: Uint, smoothFrame: Uint
) {
let elementMap = {}
// add every element that's being animated for each bone
anims.forEach(anim => {
anim.keyframes.forEach(kf => {
if(!elementMap[kf.bone_id].contains(kf.element)) {
elementMap[kf.bone_id].push(kf.element)
}
})
jj
const f = frame;
const sf = smoothFrame;
// reset every element that's not in the map back to its initial state
armature.bones.forEach(bone => {
// reset basic fields
if (!elementMap[bone.id]["PositionX"])
interpolate(f, sf, bone.pos.x, bone.init_pos.x, z, z)
if (!elementMap[bone.id]["PositionY"])
interpolate(f, sf, bone.pos.y, bone.init_pos.y, z, z)
if (!elementMap[bone.id]["Rotation"])
interpolate(f, sf, bone.rot, bone.init_rot, z, z)
if (!elementMap[bone.id]["ScaleX"])
interpolate(f, sf, bone.scale.x, bone.init_scale.x, z, z)
if (!elementMap[bone.id]["ScaleY"])
interpolate(f, sf, bone.scale.y, bone.init_scale.y, z, z)
if (!elementMap[bone.id]["Hidden"])
bone.hidden = bone.init_hidden
// reset visual fields
if bone.visuals_id != -1 {
let visuals = armature.visuals[bone.visual_id]
if (!elementMap[bone.id]["Texture"])
visuals.tex = visuals.init_tex;
if (!elementMap[bone.id]["TintR"])
interpolate(f, sf, visuals.tint.r, visuals.init_tint.r, z, z)
if (!elementMap[bone.id]["TintG"])
interpolate(f, sf, visuals.tint.g, visuals.init_tint.g, z, z)
if (!elementMap[bone.id]["TintB"])
interpolate(f, sf, visuals.tint.b, visuals.init_tint.b, z, z)
if (!elementMap[bone.id]["TintA"])
interpolate(f, sf, visuals.tint.a, visuals.init_tint.a, z, z)
}
// reset inverse kinematics fields
if (bone.ik_family_id != -1) {
let ik = armature.inverse_kinematics[bone.ik_family_id]
if (!elementMap[bone.id]["IkConstraint"])
ik.constraint = ik.init_constraint;
if (!elementMap[bone.id]["MimicTarget"])
ik.mimic_target = ik.init_mimic_target;
}]
})
}
Note: most runtimes will have this functionality at the end of Animate(). The separation of functions is purely for documentation purposes.