NLA >> How to Evaluate the COMBINE blend type?

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 :stuck_out_tongue:

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)