glTF exporter: add fallback interpolation option

Before, any sampled properties that are not keyed was exported with LINEAR interpolation
Now, user can choose the interpolation for these not keyed values (LINEAR or CONSTANT/STEP)

Example of not keyed properties: Deformation bones when animators animated other bones
This commit is contained in:
Julien Duroure 2025-01-21 21:46:25 +01:00
parent a9d72b1a14
commit 748c91ce27
11 changed files with 38 additions and 27 deletions

View file

@ -5,7 +5,7 @@
bl_info = { bl_info = {
'name': 'glTF 2.0 format', 'name': 'glTF 2.0 format',
'author': 'Julien Duroure, Scurest, Norbert Nopper, Urs Hanselmann, Moritz Becher, Benjamin Schmithüsen, Jim Eckerlein, and many external contributors', 'author': 'Julien Duroure, Scurest, Norbert Nopper, Urs Hanselmann, Moritz Becher, Benjamin Schmithüsen, Jim Eckerlein, and many external contributors',
"version": (4, 4, 33), "version": (4, 4, 34),
'blender': (4, 4, 0), 'blender': (4, 4, 0),
'location': 'File > Import-Export', 'location': 'File > Import-Export',
'description': 'Import-Export as glTF 2.0', 'description': 'Import-Export as glTF 2.0',
@ -671,6 +671,15 @@ class ExportGLTF2_Base(ConvertGLTF2_Base):
default=True default=True
) )
export_sampling_interpolation_fallback: EnumProperty(
name='Sampling Interpolation Fallback',
items=(('LINEAR', 'Linear', 'Linear interpolation between keyframes'),
('STEP', 'Step', 'No interpolation between keyframes'),
),
description='Interpolation fallback for sampled animations, when the property is not keyed',
default='LINEAR'
)
export_pointer_animation: BoolProperty( export_pointer_animation: BoolProperty(
name='Export Animation Pointer (Experimental)', name='Export Animation Pointer (Experimental)',
description='Export material, Light & Camera animation as Animation Pointer. ' description='Export material, Light & Camera animation as Animation Pointer. '
@ -1179,6 +1188,7 @@ class ExportGLTF2_Base(ConvertGLTF2_Base):
if self.export_animations: if self.export_animations:
export_settings['gltf_frame_range'] = self.export_frame_range export_settings['gltf_frame_range'] = self.export_frame_range
export_settings['gltf_force_sampling'] = self.export_force_sampling export_settings['gltf_force_sampling'] = self.export_force_sampling
export_settings['gltf_sampling_interpolation_fallback'] = self.export_sampling_interpolation_fallback
if not self.export_force_sampling: if not self.export_force_sampling:
export_settings['gltf_def_bones'] = False export_settings['gltf_def_bones'] = False
export_settings['gltf_bake_animation'] = False export_settings['gltf_bake_animation'] = False
@ -1708,6 +1718,7 @@ def export_panel_animation_sampling(layout, operator):
body.active = operator.export_animations and operator.export_force_sampling body.active = operator.export_animations and operator.export_force_sampling
body.prop(operator, 'export_frame_step') body.prop(operator, 'export_frame_step')
body.prop(operator, 'export_sampling_interpolation_fallback')
def export_panel_animation_pointer(layout, operator): def export_panel_animation_pointer(layout, operator):

View file

@ -195,12 +195,12 @@ def get_attribute(attributes, name, data_type, domain):
return None return None
def get_gltf_interpolation(interpolation): def get_gltf_interpolation(interpolation, export_settings):
return { return {
"BEZIER": "CUBICSPLINE", "BEZIER": "CUBICSPLINE",
"LINEAR": "LINEAR", "LINEAR": "LINEAR",
"CONSTANT": "STEP" "CONSTANT": "STEP"
}.get(interpolation, "LINEAR") }.get(interpolation, export_settings['gltf_sampling_interpolation_fallback']) # If unknown, default to the mode choosen by the user
def get_anisotropy_rotation_gltf_to_blender(rotation): def get_anisotropy_rotation_gltf_to_blender(rotation):

View file

@ -577,11 +577,11 @@ def gather_action_animations(obj_uuid: int,
blender_action.name, blender_action.name,
slot.slot.handle, slot.slot.handle,
True, True,
get_gltf_interpolation("LINEAR"), get_gltf_interpolation(export_settings['gltf_sampling_interpolation_fallback'], export_settings),
export_settings) export_settings)
elif type_ == "OBJECT": elif type_ == "OBJECT":
channel = gather_sampled_object_channel( channel = gather_sampled_object_channel(
obj_uuid, prop, blender_action.name, slot.slot.handle, True, get_gltf_interpolation("LINEAR"), export_settings) obj_uuid, prop, blender_action.name, slot.slot.handle, True, get_gltf_interpolation(export_settings['gltf_sampling_interpolation_fallback'], export_settings), export_settings)
elif type_ == "SK": elif type_ == "SK":
channel = gather_sampled_sk_channel(obj_uuid, blender_action.name, slot.slot.handle, export_settings) channel = gather_sampled_sk_channel(obj_uuid, blender_action.name, slot.slot.handle, export_settings)
elif type_ == "EXTRA": # TODOSLOT slot-3 elif type_ == "EXTRA": # TODOSLOT slot-3

View file

@ -228,4 +228,4 @@ def __gather_interpolation(
blender_keyframe = [c for c in channel_group if c is not None][0].keyframe_points[0] blender_keyframe = [c for c in channel_group if c is not None][0].keyframe_points[0]
# Select the interpolation method. # Select the interpolation method.
return get_gltf_interpolation(blender_keyframe.interpolation) return get_gltf_interpolation(blender_keyframe.interpolation, export_settings)

View file

@ -38,7 +38,7 @@ def gather_armature_sampled_channels(armature_uuid, blender_action_name, slot_ha
for chan in [chan for chan in channels_animated.values() if chan['bone'] is not None]: for chan in [chan for chan in channels_animated.values() if chan['bone'] is not None]:
for prop in chan['properties'].keys(): for prop in chan['properties'].keys():
list_of_animated_bone_channels[(chan['bone'], get_channel_from_target(get_target(prop)))] = get_gltf_interpolation( list_of_animated_bone_channels[(chan['bone'], get_channel_from_target(get_target(prop)))] = get_gltf_interpolation(
chan['properties'][prop][0].keyframe_points[0].interpolation) # Could be exported without sampling : keep interpolation chan['properties'][prop][0].keyframe_points[0].interpolation, export_settings) # Could be exported without sampling : keep interpolation
for _, _, chan_prop, chan_bone in [chan for chan in to_be_sampled if chan[1] == "BONE"]: for _, _, chan_prop, chan_bone in [chan for chan in to_be_sampled if chan[1] == "BONE"]:
list_of_animated_bone_channels[ list_of_animated_bone_channels[
@ -46,7 +46,7 @@ def gather_armature_sampled_channels(armature_uuid, blender_action_name, slot_ha
chan_bone, chan_bone,
chan_prop, chan_prop,
) )
] = get_gltf_interpolation("LINEAR") # if forced to be sampled, keep LINEAR interpolation ] = get_gltf_interpolation(export_settings['gltf_sampling_interpolation_fallback'], export_settings) # if forced to be sampled, keep the interpolation chosen by the user
else: else:
pass pass
# There is no animated channels (because if it was, we would have a slot_handle) # There is no animated channels (because if it was, we would have a slot_handle)
@ -61,12 +61,12 @@ def gather_armature_sampled_channels(armature_uuid, blender_action_name, slot_ha
blender_action_name, blender_action_name,
slot_handle, slot_handle,
(bone, p) in list_of_animated_bone_channels.keys(), (bone, p) in list_of_animated_bone_channels.keys(),
list_of_animated_bone_channels[(bone, p)] if (bone, p) in list_of_animated_bone_channels.keys() else get_gltf_interpolation("LINEAR"), list_of_animated_bone_channels[(bone, p)] if (bone, p) in list_of_animated_bone_channels.keys() else get_gltf_interpolation(export_settings['gltf_sampling_interpolation_fallback'], export_settings),
export_settings) export_settings)
if channel is not None: if channel is not None:
channels.append(channel) channels.append(channel)
bake_interpolation = get_gltf_interpolation("LINEAR") bake_interpolation = get_gltf_interpolation(export_settings['gltf_sampling_interpolation_fallback'], export_settings)
# Retrieve animation on armature object itself, if any # Retrieve animation on armature object itself, if any
if blender_action_name == armature_uuid or export_settings['gltf_animation_mode'] in ["SCENE", "NLA_TRACKS"]: if blender_action_name == armature_uuid or export_settings['gltf_animation_mode'] in ["SCENE", "NLA_TRACKS"]:
# If armature is baked (no animation of armature), need to use all channels # If armature is baked (no animation of armature), need to use all channels
@ -211,7 +211,7 @@ def __gather_armature_object_channel(obj_uuid: str, blender_action, slot_handle,
"delta_rotation_euler": "rotation_quaternion", "delta_rotation_euler": "rotation_quaternion",
"delta_rotation_quaternion": "rotation_quaternion" "delta_rotation_quaternion": "rotation_quaternion"
}.get(c), }.get(c),
get_gltf_interpolation(inter) get_gltf_interpolation(inter, export_settings)
) )
) )
@ -228,7 +228,7 @@ def __gather_armature_object_channel(obj_uuid: str, blender_action, slot_handle,
"delta_rotation_euler": "rotation_quaternion", "delta_rotation_euler": "rotation_quaternion",
"delta_rotation_quaternion": "rotation_quaternion" "delta_rotation_quaternion": "rotation_quaternion"
}.get(c[2]), }.get(c[2]),
get_gltf_interpolation("LINEAR") # Forced to be sampled, so use LINEAR get_gltf_interpolation(export_settings['gltf_sampling_interpolation_fallback'], export_settings) # Forced to be sampled, so use the interpolation chosen by the user
) )
) )

View file

@ -212,15 +212,15 @@ def __convert_keyframes(armature_uuid, bone_name, channel, keyframes, action_nam
def __gather_interpolation(node_channel_is_animated, node_channel_interpolation, keyframes, export_settings): def __gather_interpolation(node_channel_is_animated, node_channel_interpolation, keyframes, export_settings):
if len(keyframes) > 2: if len(keyframes) > 2:
# keep STEP as STEP, other become LINEAR # keep STEP as STEP, other become the interpolation choosen by the user
return { return {
"STEP": "STEP" "STEP": "STEP"
}.get(node_channel_interpolation, "LINEAR") }.get(node_channel_interpolation, export_settings['gltf_sampling_interpolation_fallback'])
elif len(keyframes) == 1: elif len(keyframes) == 1:
if node_channel_is_animated is False: if node_channel_is_animated is False:
return "STEP" return "STEP"
elif node_channel_interpolation == "CUBICSPLINE": elif node_channel_interpolation == "CUBICSPLINE":
return "LINEAR" # We can't have a single keyframe with CUBICSPLINE return export_settings['gltf_sampling_interpolation_fallback'] # We can't have a single keyframe with CUBICSPLINE
else: else:
return node_channel_interpolation return node_channel_interpolation
else: else:
@ -232,4 +232,4 @@ def __gather_interpolation(node_channel_is_animated, node_channel_interpolation,
if keyframes[0].value == keyframes[1].value: if keyframes[0].value == keyframes[1].value:
return "STEP" return "STEP"
else: else:
return "LINEAR" return export_settings['gltf_sampling_interpolation_fallback']

View file

@ -31,7 +31,7 @@ def gather_data_sampled_channels(blender_type_data, blender_id, blender_action_n
blender_action_name, blender_action_name,
slot_handle, slot_handle,
path in list_of_animated_data_channels.keys(), path in list_of_animated_data_channels.keys(),
list_of_animated_data_channels[path] if path in list_of_animated_data_channels.keys() else get_gltf_interpolation("LINEAR"), list_of_animated_data_channels[path] if path in list_of_animated_data_channels.keys() else get_gltf_interpolation(export_settings['gltf_sampling_interpolation_fallback'], export_settings),
additional_key, additional_key,
export_settings) export_settings)
if channel is not None: if channel is not None:

View file

@ -133,7 +133,7 @@ def __gather_interpolation(
keyframes, keyframes,
export_settings): export_settings):
# TODOPointer # TODOPointer
return 'LINEAR' return export_settings['gltf_sampling_interpolation_fallback']
def __convert_to_gltf(value): def __convert_to_gltf(value):

View file

@ -30,11 +30,11 @@ def gather_object_sampled_channels(object_uuid: str, blender_action_name: str, s
for chan in [chan for chan in channels_animated.values() if chan['bone'] is None]: for chan in [chan for chan in channels_animated.values() if chan['bone'] is None]:
for prop in chan['properties'].keys(): for prop in chan['properties'].keys():
list_of_animated_channels[get_channel_from_target(get_target(prop))] = get_gltf_interpolation( list_of_animated_channels[get_channel_from_target(get_target(prop))] = get_gltf_interpolation(
chan['properties'][prop][0].keyframe_points[0].interpolation) # Could be exported without sampling : keep interpolation chan['properties'][prop][0].keyframe_points[0].interpolation, export_settings) # Could be exported without sampling : keep interpolation
for _, _, chan_prop, _ in [chan for chan in to_be_sampled if chan[1] == "OBJECT"]: for _, _, chan_prop, _ in [chan for chan in to_be_sampled if chan[1] == "OBJECT"]:
list_of_animated_channels[chan_prop] = get_gltf_interpolation( list_of_animated_channels[chan_prop] = get_gltf_interpolation(
"LINEAR") # if forced to be sampled, keep LINEAR interpolation export_settings['gltf_sampling_interpolation_fallback'], export_settings) # if forced to be sampled, keep the interpolation choosen by the user
else: else:
pass pass
# There is no animated channels (because if it was, we would have a slot_handle) # There is no animated channels (because if it was, we would have a slot_handle)
@ -47,7 +47,7 @@ def gather_object_sampled_channels(object_uuid: str, blender_action_name: str, s
blender_action_name, blender_action_name,
slot_handle, slot_handle,
p in list_of_animated_channels.keys(), p in list_of_animated_channels.keys(),
list_of_animated_channels[p] if p in list_of_animated_channels.keys() else get_gltf_interpolation("LINEAR"), list_of_animated_channels[p] if p in list_of_animated_channels.keys() else get_gltf_interpolation(export_settings['gltf_sampling_interpolation_fallback'], export_settings),
export_settings export_settings
) )
if channel is not None: if channel is not None:

View file

@ -119,7 +119,7 @@ def __convert_keyframes(obj_uuid: str, channel: str, keyframes, action_name: str
value = gltf2_blender_math.swizzle_yup(value, channel) value = gltf2_blender_math.swizzle_yup(value, channel)
keyframe_value = gltf2_blender_math.mathutils_to_gltf(value) keyframe_value = gltf2_blender_math.mathutils_to_gltf(value)
# No tangents when baking, we are using LINEAR interpolation # No tangents when baking, we are using LINEAR or STEP interpolation
values += keyframe_value values += keyframe_value
@ -152,15 +152,15 @@ def __gather_interpolation(
export_settings): export_settings):
if len(keyframes) > 2: if len(keyframes) > 2:
# keep STEP as STEP, other become LINEAR # keep STEP as STEP, other become the interpolation choosen by the user
return { return {
"STEP": "STEP" "STEP": "STEP"
}.get(node_channel_interpolation, "LINEAR") }.get(node_channel_interpolation, export_settings['gltf_sampling_interpolation_fallback'])
elif len(keyframes) == 1: elif len(keyframes) == 1:
if node_channel_is_animated is False: if node_channel_is_animated is False:
return "STEP" return "STEP"
elif node_channel_interpolation == "CUBICSPLINE": elif node_channel_interpolation == "CUBICSPLINE":
return "LINEAR" # We can't have a single keyframe with CUBICSPLINE return export_settings['gltf_sampling_interpolation_fallback'] # We can't have a single keyframe with CUBICSPLINE
else: else:
return node_channel_interpolation return node_channel_interpolation
else: else:
@ -172,4 +172,4 @@ def __gather_interpolation(
if keyframes[0].value == keyframes[1].value: if keyframes[0].value == keyframes[1].value:
return "STEP" return "STEP"
else: else:
return "LINEAR" return export_settings['gltf_sampling_interpolation_fallback']

View file

@ -113,4 +113,4 @@ def __convert_keyframes(obj_uuid, keyframes, action_name: str, export_settings):
def __gather_interpolation(export_settings): def __gather_interpolation(export_settings):
# TODO: check if the SK was animated with CONSTANT # TODO: check if the SK was animated with CONSTANT
return 'LINEAR' return export_settings['gltf_sampling_interpolation_fallback']