I want to read the actions from strips in the NLA editor, and get their combined results, for manual usage. Such as, combining strips, or setting the object’s pose to those values.
In 2.8, the “COMBINE” blend type goes out of it’s way to run differently from the other Blend types, which all do straightforward math to give an output.
I tried porting the code used around the areas that the other NLA blend types get their results, and even tried to keep the code functions unaltered from the C/C++ original but I just can not grasp how it actually works and which parts of the code would actually be needed, for my purposes.
I can get the numbers added together “mostly” close but it’s still a very noticeable difference because the math is wrong. The numbers are probably just close because of a limit in Quaternions
Python script
"""
function: Get.value_from_nla_strips(data, path, index=-1)
Returns the combined float value of an item's stacked NLA animation
Example usage:
bone = bpy.context.active_pose_bone
for i in range(3):
bone.location[i] = Get.value_from_nla_strips(bone, 'location', index=i):
bone.scale[i] = Get.value_from_nla_strips(bone, 'scale', index=i):
for i in range(4):
bone.rotation_quaternion[i] = Get.value_from_nla_strips(bone, 'rotation_quaternion', index=i):
"""
import bpy
from math import *
from mathutils import *
################################################
# Blender functions to Python
################################################
def nla_blend_value(blendmode, old_value, value, inf):
"accumulate the old and new values of a channel according to mode and influence"
# optimisation: no need to try applying if there is no influence
if (inf == 0.0):
return old_value
# perform blending
if blendmode == 'REPLACE':
# default
# : /* TODO: do we really want to blend by default? it seems more uses might prefer add... */
# /* do linear interpolation
# * - the influence of the accumulated data (elsewhere, that is called dstweight)
# * is 1 - influence, since the strip's influence is srcweight
# */
return old_value * (1.0 - inf) + (value * inf)
elif blendmode == 'ADD':
# /* simply add the scaled value on to the stack */
return old_value + (value * inf)
elif blendmode == 'SUBTRACT':
# /* simply subtract the scaled value from the stack */
return old_value - (value * inf)
elif blendmode == 'MULTIPLY':
# /* multiply the scaled value with the stack */
# /* Formula Used:
# * result = fac * (a * b) + (1 - fac) * a
# */
return inf * (old_value * value) + (1 - inf) * old_value
elif blendmode == 'COMBINE':
# BLI_assert(!"combine mode")
# ATTR_FALLTHROUGH
pass
def nla_combine_value(mix_mode, base_value, old_value, value, inf):
# /* optimisation: no need to try applying if there is no influence */
if (inf == 0.0):
return old_value
# /* perform blending */
if (mix_mode == 'QUATERNION'):
# default
# BLI_assert(!"invalid mix mode")
return old_value
elif (mix_mode == 'ADD'):
pass
return value
elif (mix_mode == 'AXIS_ANGLE'):
return old_value + (value - base_value) * inf
elif (mix_mode == 'MULTIPLY'):
if (base_value == 0.0):
base_value = 1.0
return old_value * pow(value / base_value, inf)
def nla_combine_quaternion(old_values, values, influence, result):
"accumulate quaternion channels for Combine mode according to influence"
tmp_old = [float()] * 4
tmp_new = [float()] * 4
normalize_qt_qt(tmp_old, old_values)
normalize_qt_qt(tmp_new, values)
pow_qt_fl_normalized(tmp_new, influence)
mul_qt_qtqt(result, tmp_old, tmp_new)
def nla_invert_combine_quaternion(old_values, values, influence, result):
tmp_old = [float()] * 4
tmp_new = [float()] * 4
normalize_qt_qt(tmp_old, old_values)
normalize_qt_qt(tmp_new, values)
invert_qt_normalized(tmp_old)
mul_qt_qtqt(result, tmp_old, tmp_new)
pow_qt_fl_normalized(result, 1.0 / influence)
def normalize_qt_qt(r, q):
copy_qt_qt(r, q)
return normalize_qt(r)
def copy_qt_qt(q1, q2):
q1[0] = q2[0]
q1[1] = q2[1]
q1[2] = q2[2]
q1[3] = q2[3]
def normalize_qt(q):
len = sqrt(dot_qtqt(q, q))
if (len != 0.0):
mul_qt_fl(q, 1.0 / len)
else:
q[1] = 1.0
q[0] = q[2] = q[3] = 0.0
return len
def dot_qtqt(q1, q2):
return q1[0] * q2[0] + q1[1] * q2[1] + q1[2] * q2[2] + q1[3] * q2[3]
def invert_qt_normalized(q):
"""
* This is just conjugate_qt for cases we know \a q is unit-length.
* we could use #conjugate_qt directly, but use this function to show intent,
* and assert if its ever becomes non-unit-length.
"""
if BLI_ASSERT_UNIT_QUAT(q):
conjugate_qt(q)
def BLI_ASSERT_UNIT_QUAT(q):
BLI_ASSERT_UNIT_EPSILON = 0.0002
_test_unit = dot_qtqt(q, q)
return (not (fabs(_test_unit - 1.0) >= BLI_ASSERT_UNIT_EPSILON * 10) or
not (fabs(_test_unit) >= BLI_ASSERT_UNIT_EPSILON * 10))
def conjugate_qt(q):
q[1] = -q[1]
q[2] = -q[2]
q[3] = -q[3]
def mul_qt_fl(q, f):
"Simple multiplier"
q[0] *= f
q[1] *= f
q[2] *= f
q[3] *= f
def mul_qt_qtqt(q, q1, q2):
t0 = q1[0] * q2[0] - q1[1] * q2[1] - q1[2] * q2[2] - q1[3] * q2[3]
t1 = q1[0] * q2[1] + q1[1] * q2[0] + q1[2] * q2[3] - q1[3] * q2[2]
t2 = q1[0] * q2[2] + q1[2] * q2[0] + q1[3] * q2[1] - q1[1] * q2[3]
q[3] = q1[0] * q2[3] + q1[3] * q2[0] + q1[1] * q2[2] - q1[2] * q2[1]
q[0] = t0
q[1] = t1
q[2] = t2
def pow_qt_fl_normalized(q, fac):
"raise a unit quaternion to the specified power"
if BLI_ASSERT_UNIT_QUAT(q):
# angle = fac * saacos(q[0]) # quat[0] = cos(0.5 * angle),
# # but now the 0.5 and 2.0 rule out
angle = fac * (q[0]) # quat[0] = cos(0.5 * angle),
# but now the 0.5 and 2.0 rule out
# nothing called "saacos", and cos doesn't make a difference
co = cos(angle)
si = sin(angle)
q[0] = co
# normalize_v3_length(q + 1, si) # can't add a single digit to a Quaternion
normalize_v3_length(q, si)
def normalize_v3_length(n, unit_length):
return normalize_v3_v3_length(n, n, unit_length)
def normalize_v3_v3_length(r, a, unit_length):
d = dot_v3v3(a, a)
# /* a larger value causes normalize errors in a
# * scaled down models with camera extreme close */
if (d > 1.0e-35):
d = sqrt(d)
mul_v3_v3fl(r, a, unit_length / d)
else:
zero_v3(r)
d = 0.0
return d
def dot_v3v3(a, b):
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
def mul_v3_v3fl(r, a, f):
r[0] = a[0] * f
r[1] = a[1] * f
r[2] = a[2] * f
def zero_v3(r):
r[0] = 0.0
r[1] = 0.0
r[2] = 0.0
################################################
# My functions
################################################
class Get:
def value_from_nla_strips(src, path, index=-1, frame=None, selected=False, tweak=False):
# Tries to actually read the strips
# but it doesn't work with COMBINE quaternions
anim = getattr(src.id_data, 'animation_data', None)
if not anim:
return
if frame is None:
frame = bpy.context.scene.frame_current_final
if isinstance(src, bpy.types.PoseBone) and not path.startswith('pose.bones'):
kpath = (f'pose.bones[\"{src.name}\"].' + path)
else:
kpath = path
# Establish default
if any((
'default' is 1,
path.endswith('scale'),
path.endswith('rotation_quaternion') and index == 0,
path.endswith('rotation_axis_angle') and index == 2,
)):
default = 1.0
else:
default = 0.0
value = default
default_quat = Quaternion()
value_quat = default_quat
first_strip = True
for track in anim.nla_tracks:
if tweak and anim.use_tweak_mode:
for strip in track.strips:
if strip.active:
break
else:
strip = Get.strip_in_track(track, frame=frame)
else:
strip = Get.strip_in_track(track, frame=frame)
action = getattr(strip, 'action', None)
if not action:
# Either no strip evaluated or strip is meta strips (TODO)
continue
# Try to find property in fcurve
if index == -1:
fc = action.fcurves.get(kpath)
else:
for fc in action.fcurves:
if fc.data_path == kpath and fc.array_index == index:
break
else:
fc = None
if fc is None:
if path.endswith('rotation_quaternion') and action.fcurves.get(kpath):
pass
else:
continue
if frame < strip.frame_start:
frame_strip = strip.frame_start
elif (frame > strip.frame_end):
frame_strip = strip.frame_end
else:
frame_strip = frame
if tweak and strip.active:
ff = frame
inf = 1.0
else:
ff = frame_strip
inf = Get.strip_influence(strip, frame=frame_strip)
next_quat = Quaternion()
if path.endswith('rotation_quaternion'):
for i in range(4):
# Don't research curves for the already found curve
if fc and i == index:
next = fc.evaluate(Get.frame_to_strip(strip, ff))
next_quat[i] = next
continue
for afc in action.fcurves:
if afc.data_path == kpath and afc.array_index == i:
next = afc.evaluate(Get.frame_to_strip(strip, ff))
next_quat[i] = next
break
else:
next_quat[i] = default_quat[i]
if fc:
next = fc.evaluate(Get.frame_to_strip(strip, ff))
else:
next = None
if (first_strip):
value = next
value_quat = next_quat
first_strip = False
elif (strip.blend_type == 'COMBINE'):
"Quaternion blending is deferred until all sub-channel values are known."
# next_val = abs(next - default) * (-1 if next < default else 1)
# value += lerp(0.0, next_val, inf)
if path.endswith('rotation_quaternion'):
mix_mode = 'QUATERNION' # return old value
mix_mode = 'AXIS_ANGLE' # return old_value + (value - base_value) * inf
# value = nla_combine_value(mix_mode, default, value, next, inf)
# nla_invert_combine_quaternion(value_quat, next_quat, inf, value_quat)
nla_combine_quaternion(value_quat, next_quat, inf, value_quat)
value = value_quat[index]
else:
# mix_mode = 'ADD' # pass
mix_mode = 'AXIS_ANGLE' # return old_value + (value - base_value) * inf
# mix_mode = 'MULTIPLY' # return old_value * pow(value / base_value, inf)
value = nla_combine_value(mix_mode, default, value, next, inf)
else:
value = nla_blend_value(strip.blend_type, value, next, inf)
# nla_combine_quaternion(value_quat, next_quat, inf, value_quat)
return value
def strip_in_track(track, frame=None):
"Find the currently evaluated strip in the track"
if frame is None:
frame = bpy.context.scene.frame_current_final
right_of_strips = None
active_strip = None
for strip in track.strips:
if (frame < strip.frame_start):
if right_of_strips in {'HOLD', 'HOLD_FORWARD'}:
# Previous strip extends forward, so use that
strip = active_strip
elif right_of_strips in {'NOTHING', None}:
if (strip.extrapolation == 'HOLD'):
# This strip extends backwards
pass
elif (strip.extrapolation in {'NOTHING', 'HOLD_FORWARD'}):
# Outside and between two strips. No effect.
strip = None
break
elif (strip.frame_start <= frame <= strip.frame_end):
# Inside strip's range
break
elif (strip.frame_end < frame):
# Set strip as active to compare to next strip
right_of_strips = strip.extrapolation
active_strip = strip
else:
if not active_strip:
# Reached end of track, and no strip can be even considered
return
return strip
def strip_influence(strip, frame=None):
"Return influence of NLA Strip at a given frame (or current frame)"
if frame is None:
frame = bpy.context.scene.frame_current_final
(start, end) = (strip.frame_start, strip.frame_end)
start_in = (start + strip.blend_in)
end_out = (end - strip.blend_out)
if (strip.use_animated_influence):
# Animated Influence
influence = strip.fcurves.find('influence').evaluate(frame)
elif (start_in < frame < end_out):
# Regular Influence
influence = 1.0
elif (frame < start):
# Before strip
if (strip.extrapolation == 'HOLD') and (start == start_in):
influence = 1.0
else:
influence = 0.0
elif (end < frame):
# After strip
if (strip.extrapolation.startswith('HOLD')) and (end_out == end):
influence = 1.0
else:
influence = 0.0
elif (start <= frame <= start_in):
# At Start or Blend In
if start != start_in:
influence = scale_range(frame, start, start_in, 0, 1)
else:
influence = 1.0
elif (end_out <= frame <= end):
# At End or Blend Out
if end_out != end:
influence = scale_range(frame, end_out, end, 1, 0)
else:
influence = 1.0
else:
# Unknown
influence = None
return influence
def scale_range(OldValue, OldMin, OldMax, NewMin, NewMax):
"Convert a number in a range, relatively to a different range"
OldRange = (OldMax - OldMin)
NewRange = (NewMax - NewMin)
if (OldRange == 0):
NewValue = NewMin
else:
NewValue = (((OldValue - OldMin) * NewRange) / OldRange) + NewMin
return NewValue
def strip_co_frame(strip, co=None, frame=None):
"Try to convert the frame number between scene and action keyframes"
action_start = strip.action_frame_start
action_end = strip.action_frame_end
strip_start = strip.frame_start
strip_end = strip.frame_end
# Repeat: store repeat then reset it to get the actual strip range
# repeat = strip.repeat; scale = strip.scale; strip.repeat = 1; strip.scale = 1; strip_end = strip.frame_end
end = Get.scale_range(strip_end, strip_start, strip_end, 0, abs(strip_start - strip_end))
end /= strip.repeat
end /= strip.scale
end *= strip.scale
end += strip_start
# Restore repeat value
# strip.repeat = repeat; strip.scale = scale
if (co is not None): # target = relative strip
value = Get.scale_range(co, action_start, action_end, strip_start, end)
if (frame is not None): # target = absolute frame
value = Get.scale_range(frame, strip_start, end, action_start, action_end)
# # for looping curve keyframes
# while (value > end):
# value -= abs(strip_start - end)
return value
def frame_to_strip(strip, frame=None):
'''Convert the keyframe.co/frame to correct frame inside nla strip'''
if frame is None:
frame = bpy.context.scene.frame_current_final
anim = strip.id_data.animation_data
if anim.use_tweak_mode and bool(bpy.app.version >= (2, 80, 0)):
return anim.nla_tweak_strip_time_to_scene(frame, invert=False)
else:
return Get.strip_co_frame(strip, frame=frame)