Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(skinned-mesh): add support for 'Child Of' constraint on bones #224

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
146 changes: 107 additions & 39 deletions addon/i3dio/node_classes/skinned_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,42 +16,70 @@

import math


class SkinnedMeshBoneNode(TransformGroupNode):
def __init__(self, id_: int, bone_object: bpy.types.Bone,
i3d: I3D, parent: SceneGraphNode):
super().__init__(id_=id_, empty_object=bone_object, i3d=i3d, parent=parent)

@property
def _transform_for_conversion(self) -> mathutils.Matrix:
def _convert_to_i3d_space(self, matrix: mathutils.Matrix) -> mathutils.Matrix:
conversion_matrix: mathutils.Matrix = self.i3d.conversion_matrix
return conversion_matrix @ matrix @ conversion_matrix.inverted()

if self.blender_object.parent is None:
# The bone is parented to the armature directly, and therefore should just use the matrix_local which is in
# relation to the armature anyway.
bone_transform = conversion_matrix @ self.blender_object.matrix_local @ conversion_matrix.inverted()

# Blender bones are visually pointing along the Z-axis, but internally they use the Y-axis. This creates a
# discrepancy when converting to GE's expected orientation. To resolve this, apply a -90-degree rotation
# around the X-axis. The translation is extracted first to avoid altering the
# bone's position during rotation.
rot_fix = mathutils.Matrix.Rotation(math.radians(-90.0), 4, 'X')
translation = bone_transform.to_translation()
bone_transform = rot_fix @ bone_transform.to_3x3().to_4x4()
bone_transform.translation = translation

if self.i3d.settings['collapse_armatures']:
# collapse_armatures deletes the armature object in the I3D,
# so we need to mutliply the armature matrix into the root bone
armature_obj = self.parent.blender_object
armature_matrix = conversion_matrix @ armature_obj.matrix_local @ conversion_matrix.inverted()

bone_transform = armature_matrix @ bone_transform
else:
# To find the transform of child bone, we take the inverse of its parents transform in armature space and
# multiply that with the bones transform in armature space. The new 4x4 matrix gives the position and
# rotation in relation to the parent bone (of the head, that is)
bone_transform = self.blender_object.parent.matrix_local.inverted() @ self.blender_object.matrix_local

@property
def _transform_for_conversion(self) -> mathutils.Matrix:
if self.blender_object.parent and isinstance(self.blender_object.parent, bpy.types.Bone):
# When a bone is parented to another bone, the bone's matrix_local is in relation to the parent bone.
# No need for the conversion matrix here since the bones are already in the correct orientation.
parent_bone_transform = self.blender_object.parent.matrix_local
return parent_bone_transform.inverted() @ self.blender_object.matrix_local

# Initialize default identity matrix for armature transform
armature_transform = mathutils.Matrix.Identity(4)

custom_parent = None

# Check if the bone has a 'Child Of' constraint and retrieve its target as the custom parent, if it exists
if isinstance(self.parent.blender_object, bpy.types.Object):
custom_parent = SkinnedMeshRootNode._get_new_bone_parent(self.parent.blender_object, self.blender_object)

# Get the armature object only if the bone is not parented to another bone
if custom_parent or (not self.blender_object.parent and self.i3d.settings['collapse_armatures']):
armature_obj = self.parent.blender_object
self.logger.debug(f"the armature object: {armature_obj.name}")
armature_transform = self._convert_to_i3d_space(armature_obj.matrix_local)

# Get the bone's transformation in armature space
bone_transform = self._convert_to_i3d_space(self.blender_object.matrix_local)

# Giants Engine expects bones to point along the Z-axis, just like how Blender shows them visually.
# But when a bone is directly connected to the armature (a root bone), Blender internally treats it as pointing
# along the Y-axis. This difference needs to be fixed by rotating the bone -90 degrees around the X-axis.
# Bones connected to other bones already work correctly and don’t need this adjustment.
rot_fix = mathutils.Matrix.Rotation(math.radians(-90.0), 4, 'X')
translation = bone_transform.to_translation()
bone_transform = rot_fix @ bone_transform.to_3x3().to_4x4()
bone_transform.translation = translation

# If a custom parent exists, calculate bone transformation relative to the parent
if custom_parent:
self.logger.debug(f"has a custom parent: {custom_parent.name} from child of constraint")
# For a 'Child Of' constraint targeting a regular object rather than another bone, the bone’s
# final transformation depends on that object’s global position in the scene. Since this object
# may lie deep within another hierarchy, `matrix_local` alone isn’t sufficient. Instead, we use
# `matrix_world` to get the object’s fully resolved global transform before converting it into I3D space.
custom_parent_transform = self._convert_to_i3d_space(custom_parent.matrix_world)
return custom_parent_transform.inverted() @ armature_transform @ bone_transform

# Default behavior for bones with no custom parent
# For bones parented directly to the armature, matrix_local already represents their transform
# relative to the armature, so no additional adjustments are needed.
if self.i3d.settings['collapse_armatures']:
# collapse_armatures deletes the armature object in the I3D, so we need to mutliply the armature matrix
# into the root bone since the bone essentially replaces the armature object
return armature_transform @ bone_transform
# If there's no custom parent, and we're not collapsing armatures, the bone's local transform
# is already correct relative to the armature. Just return it as-is.
return bone_transform


Expand Down Expand Up @@ -86,19 +114,59 @@ def _add_bone(self, bone_object: bpy.types.Bone, parent: Union[SkinnedMeshBoneNo
for child_bone in bone_object.children:
self._add_bone(child_bone, current_bone)

@staticmethod
def _find_node_by_blender_object(root_nodes, target_object):
"""Recursively find a node in the scene graph by its Blender object."""
for root_node in root_nodes:
if root_node.blender_object == target_object:
return root_node
if child_result := SkinnedMeshRootNode._find_node_by_blender_object(root_node.children, target_object):
return child_result
return None

@staticmethod
def _get_new_bone_parent(armature_object: bpy.types.Armature,
bone: bpy.types.Bone) -> Optional[Union[bpy.types.Object, bpy.types.Bone]]:
"""Return the target object or bone of the first 'Child Of' constraint for a bone."""
pose_bone = armature_object.pose.bones.get(bone.name)
if not pose_bone:
return None

child_of_constraint = next((constraint for constraint in pose_bone.constraints
if constraint.type == 'CHILD_OF'), None)
if child_of_constraint and child_of_constraint.target:
return child_of_constraint.target
return None

def update_bone_parent(self, parent):
"""Update the parent of each bone based on constraints or fallback to default behavior."""
for bone in self.bones:
if bone.parent == self:
self.element.remove(bone.element)
new_parent_target = self._get_new_bone_parent(self.blender_object, bone)
effective_parent = self._find_node_by_blender_object(self.i3d.scene_root_nodes, new_parent_target) \
if new_parent_target else parent

# Skip reparenting if the bone's parent is another bone in the same armature
if bone.parent != self:
continue

self.logger.debug(f"Bone {bone.name} set parent to: "
f"{effective_parent.name if effective_parent else 'Root'}")

# Remove bone from current parent
if bone in self.children:
self.children.remove(bone)
if parent is not None:
bone.parent = parent
parent.add_child(bone)
parent.element.append(bone.element)
else:
bone.parent = None
self.i3d.scene_root_nodes.append(bone)
self.i3d.xml_elements['Scene'].append(bone.element)
self.element.remove(bone.element)

# Add bone to new parent if found
if effective_parent:
bone.parent = parent
effective_parent.add_child(bone)
effective_parent.element.append(bone.element)
else:
# If no valid parent is found, add to the root
bone.parent = None
self.i3d.scene_root_nodes.append(bone)
self.i3d.xml_elements['Scene'].append(bone.element)


class SkinnedMeshShapeNode(ShapeNode):
Expand Down