The problem: Blender defaults everything to Bezier interpolation, which is usually what you want. But when you have a held pose (two adjacent keyframes with the same value), the Bezier handles can still introduce a tiny overshoot into what should be a completely flat hold. Depending on surrounding curve shape, you get this subtle float at the edge of the pose. On a looping idle it compounds every cycle. On a facial hold it's the thing making a character look vaguely restless when they should be completely still.
I've been manually hunting these down in the F-curve editor for years. Finally got fed up enough to automate it.
import bpy
def flatten_held_handles(threshold=0.001):
obj = bpy.context.object
if not obj or not obj.animation_data or not obj.animation_data.action:
print("No active object with animation data.")
return
action = obj.animation_data.action
changed = 0
for fcurve in action.fcurves:
kps = fcurve.keyframe_points
for i in range(len(kps)):
kf = kps[i]
if i < len(kps) - 1:
nxt = kps[i + 1]
if abs(kf.co.y - nxt.co.y) <= threshold:
kf.handle_right_type = 'VECTOR'
nxt.handle_left_type = 'VECTOR'
changed += 1
fcurve.update()
print(f"Done. Converted {changed} held-pose handle pairs.")
flatten_held_handles()
How it works: iterates over every F-curve in the active action, finds adjacent keyframe pairs where values are within a threshold, and flips both handles to VECTOR. Flat approach, flat departure. No overshoot.
A few things worth knowing before you run it:
0.001 works fine for location and scale channels, but Euler rotation data is noisier, so bumping to 0.01 or higher is usually safer there, especially on anything that came through mocap retargeting.The thing I haven't sorted out: automatically detecting which channels need a tighter vs. looser threshold. Right now I'm running it twice with different values and eyeballing, which is embarrassing. Anyone solved this more cleanly? Also curious if it's worth checking whether a keyframe pair is already using constant interpolation and skipping those in the output, because technically they don't have the problem but they still get counted in the changed total right now.