Been burned too many times by rig breakage that only shows up in-engine. A forearm rotating past the mechanical stop on a mech rig, a spine bone going into weird territory on a fast motion. Usually one or two blown frames, invisible while scrubbing but obvious as a pop when the game plays it back at speed.
So I wrote a Blender Python script that samples the armature at every frame, checks rotation values against a per-bone limit table, and prints a report. You define limits by bone name substring, it flags anything outside the range.
import bpy
import math
# Define rotation limits per bone name substring (in degrees)
# Adjust to match your rig's intended range of motion
BONE_LIMITS = {
"spine": {"x": (-30, 30), "y": (-20, 20), "z": (-45, 45)},
"neck": {"x": (-40, 40), "y": (-30, 30), "z": (-60, 60)},
"shoulder": {"x": (-90, 180), "y": (-90, 90), "z": (-90, 90)},
"forearm": {"x": (-140, 0), "y": (-20, 20), "z": (-20, 20)},
"knee": {"x": (0, 140), "y": (-5, 5), "z": (-5, 5)},
}
def check_rotation_limits(armature_name, frame_start=None, frame_end=None):
scene = bpy.context.scene
obj = bpy.data.objects.get(armature_name)
if not obj or obj.type != 'ARMATURE':
print(f"Armature '{armature_name}' not found.")
return []
start = frame_start or scene.frame_start
end = frame_end or scene.frame_end
violations = []
for frame in range(start, end + 1):
scene.frame_set(frame)
for bone in obj.pose.bones:
if bone.rotation_mode not in ('XYZ', 'XZY', 'YXZ', 'YZX', 'ZXY', 'ZYX'):
continue # skip quaternion bones
matched_key = next(
(k for k in BONE_LIMITS if k in bone.name.lower()), None
)
if not matched_key:
continue
limits = BONE_LIMITS[matched_key]
rot = bone.rotation_euler
for axis, val in zip('xyz', [rot.x, rot.y, rot.z]):
if axis not in limits:
continue
deg = math.degrees(val)
lo, hi = limits[axis]
if not (lo <= deg <= hi):
violations.append((frame, bone.name, axis.upper(), deg, lo, hi))
if violations:
print()
print(f"=== Rotation Limit Violations ({len(violations)} found) ===")
for frame, bone_name, axis, deg, lo, hi in violations:
print(f" Frame {frame:4d} | {bone_name:22s} | {axis}: {deg:+.1f}° (limit: {lo}° to {hi}°)")
else:
print("No violations found.")
return violations
check_rotation_limits("Armature")
Only works for Euler rotation mode bones. If your rig mixes in quaternion bones you'd need to convert first, which I haven't gotten to yet. The substring matching means "forearm" catches forearm_L, forearm_R, etc., which is usually what you want.
Sample output from my current project:
=== Rotation Limit Violations (4 found) ===
Frame 23 | forearm_L | X: -147.3° (limit: -140° to 0°)
Frame 24 | forearm_L | X: -151.8° (limit: -140° to 0°)
Frame 41 | spine_02 | Z: +52.1° (limit: -45° to 45°)
Frame 108 | knee_R | X: -3.2° (limit: 0° to 140°)
The knee one on frame 108 is the interesting case. Only 3 degrees of hyperextension, but it was causing a visible pop on one specific transition that I'd been chasing for two days.
Setting up the limit table is the tedious part since you have to know your rig's intended range of motion. But once it's set it runs in seconds even on long clips, which beats manually scrubbing or waiting to see it break in-engine.
Curious how others handle this. Do you enforce limits in the rig itself with bone rotation constraints, or do you prefer something advisory like this so the animator still has full control when they need it?