Skeleton

SimVX provides skeletal animation support through the Bone dataclass and Skeleton class. Together they define a bone hierarchy and compute joint matrices for GPU vertex skinning.

Bone

A Bone represents a single bone in the hierarchy:

Field

Type

Description

name

str

Human-readable bone name

parent_index

int

Index of parent bone (-1 for root)

inverse_bind_matrix

np.ndarray (4x4)

Transforms from mesh space to bone-local space

local_transform

np.ndarray (4x4)

Default local-space transform relative to parent

Skeleton

Skeleton holds an ordered list of bones and computes the final joint matrices used by the GPU skinning shader.

Creating a Skeleton

Bones must be ordered so that every parent appears before its children:

import numpy as np
from simvx.core import Bone, Skeleton, translate

# Build a simple arm: root -> upper_arm -> forearm -> hand
bones = [
    Bone(name="root",      parent_index=-1),
    Bone(name="upper_arm",  parent_index=0,
         local_transform=translate((0, 2, 0))),
    Bone(name="forearm",    parent_index=1,
         local_transform=translate((0, 1.5, 0))),
    Bone(name="hand",       parent_index=2,
         local_transform=translate((0, 1.0, 0))),
]

skeleton = Skeleton(bones)
print(skeleton.bone_count)  # 4

Computing a Pose

Call compute_pose() to walk the hierarchy and produce joint matrices. Optionally override individual bone transforms:

# Default pose (uses each bone's local_transform)
skeleton.compute_pose()

# Animated pose: override forearm rotation
import math
animated = {
    2: rotate((1, 0, 0), math.radians(45)) @ translate((0, 1.5, 0)),  # forearm rotated 45 deg
}
skeleton.compute_pose(bone_transforms=animated)

Joint Matrices

After compute_pose(), the joint_matrices property returns a (bone_count, 4, 4) NumPy array. Each matrix is world_transform @ inverse_bind_matrix, ready to upload to an SSBO.

matrices = skeleton.joint_matrices  # shape (4, 4, 4) for 4 bones

The vertex shader applies skinning by blending up to 4 joint matrices per vertex using bone weights:

vec4 skinned = vec4(0.0);
for (int i = 0; i < 4; i++) {
    skinned += joint_matrices[bone_ids[i]] * vec4(position, 1.0) * bone_weights[i];
}

Skinning a Mesh

Set MeshInstance3D.skeleton to the skeleton and the renderer draws that mesh through the GPU skinning path, re-reading joint_matrices every frame:

arm = MeshInstance3D(mesh=rigged_mesh, skeleton=skeleton)

The mesh needs the matching per-vertex joint indices and weights. import_gltf attaches those and sets skeleton on every primitive of a rigged node, so an imported model is ready to pose. Setting skeleton back to None returns the mesh to the rigid path. The skeleton does not have to be the mesh’s parent, or in the tree at all: only its joint matrices are read.

Skinning needs both halves: per-vertex joints and weights on the mesh, and bones on the skeleton. Either one missing leaves nothing to drive. A skeleton on a mesh that carries no joints and weights (a primitive built by hand, or an unrigged Mesh.cube()), and a Skeleton() that was never given bones, both render rigidly, identically to the same mesh with no skeleton, and the renderer logs one warning naming the node and the missing half the first time it is submitted. .skeleton itself is left alone: the fallback is a draw decision, not an edit to the scene.

Finding Bones

Look up a bone index by name:

idx = skeleton.find_bone("forearm")  # 2
idx = skeleton.find_bone("missing")  # -1

API Reference

See simvx.core.skeleton for the complete skeleton API.