Moving a mesh without rebuilding it#
A character mesh begins in a reference pose. A skeleton supplies joints arranged in a parent hierarchy. Vertices store the joints that influence them and the weight of each influence. Moving joints deforms the mesh while keeping its topology intact. AnimationSystem owns clip players, state-machine state, and local pose buffers. A SkeletonAsset owns joint names, parents, bind transforms, and inverse bind matrices. An AnimationClip owns translation, rotation, and scale keyframes, duration, and events. MeshAsset stores joint indices and weights alongside the normal vertex data.
Two clips. One continuous pose. This illustration uses canvas. The explanation below describes the same process.
Scrub the timeline, change playback speed, and blend idle with walking. Turn bones off to inspect the skinned mesh; turn wireframe on to see how segments deform. The planar browser rig uses blended joint angles and two-weight linear skinning. Native 3D clips use quaternion interpolation and imported meshes.
Sampling and blending#
A track identifies neighboring keyframes around the playback time. Translation and scale use appropriate interpolants; rotations use normalized quaternion interpolation or spherical interpolation. Quaternions q and -q represent the same orientation, so choose the same hemisphere before blending. Linear interpolation of Euler angles is not a general 3D rotation solution. A blend tree produces a local pose from weighted clips. Clip weights sum to one for a normalized blend. Crossfades gradually exchange state weights rather than abruptly switching every joint. Clips can have different durations; normalized phase alignment keeps footsteps from sliding during locomotion blends.
Pose evaluation on the CPU#
The optional samples/dx12/pose-reference.hpp header supplies the corresponding C++20 math: typed keyframes, interval lookup, vector sampling, quaternion sampling, local-pose blending, hierarchy accumulation, and inverse-bind palette construction. It uses DirectXMath and defines every helper it calls. It is a reference extension, not part of the two minimal executable targets, and was not compiled here. Import validates finite values, nonzero quaternions, strictly increasing track times, normalized weights, and parent-before-child joint order. Empty tracks use the bind-local fallback. A clip player resolves looping time and events before calling the sampling functions; the sampler itself clamps to the first/last keys.
Complete sample source: dx12/pose-reference.hpp
#pragma once
// Standalone reference math extension; not used by the two minimal executables.
// Requires C++20 and the Windows SDK's DirectXMath. Native compile is unverified.
#include <DirectXMath.h>
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <span>
#include <stdexcept>
#include <vector>
namespace qubic_reference {
using namespace DirectX;
using std::size_t;
template<class T> struct Key { float time; T value; };
struct JointPose {
XMFLOAT3 translation{0,0,0};
XMFLOAT4 rotation{0,0,0,1};
XMFLOAT3 scale{1,1,1};
};
struct KeyInterval { size_t a, b; float alpha; };
template<class T> KeyInterval FindInterval(std::span<const Key<T>> keys, float time) {
// Import contract: finite values, strictly increasing key times.
if (keys.empty()) throw std::invalid_argument("Empty track has no interval");
if (time <= keys.front().time) return {0,0,0};
if (time >= keys.back().time) return {keys.size()-1,keys.size()-1,0};
auto upper = std::upper_bound(keys.begin(),keys.end(),time,
[](float t,const Key<T>& key){return t < key.time;});
size_t b = static_cast<size_t>(upper-keys.begin()), a = b-1;
const float duration = keys[b].time-keys[a].time;
if (duration <= 0) throw std::invalid_argument("Track times must increase");
return {a,b,std::clamp((time-keys[a].time)/duration,0.0f,1.0f)};
}
inline XMFLOAT3 SampleVector(std::span<const Key<XMFLOAT3>> keys, float time,
const XMFLOAT3& fallback) {
if (keys.empty()) return fallback;
auto interval = FindInterval(keys,time);
XMFLOAT3 out;
XMStoreFloat3(&out,XMVectorLerp(XMLoadFloat3(&keys[interval.a].value),
XMLoadFloat3(&keys[interval.b].value),interval.alpha));
return out;
}
inline XMFLOAT4 BlendRotation(const XMFLOAT4& from,const XMFLOAT4& to,float weight) {
XMVECTOR a = XMQuaternionNormalize(XMLoadFloat4(&from));
XMVECTOR b = XMQuaternionNormalize(XMLoadFloat4(&to));
// q and -q represent one orientation; choose the same hemisphere.
if (XMVectorGetX(XMQuaternionDot(a,b)) < 0) b = XMVectorNegate(b);
XMFLOAT4 out;
XMStoreFloat4(&out,XMQuaternionNormalize(XMQuaternionSlerp(a,b,weight)));
return out;
}
inline XMFLOAT4 SampleRotation(std::span<const Key<XMFLOAT4>> keys,float time,
const XMFLOAT4& fallback) {
if (keys.empty()) return fallback;
auto interval = FindInterval(keys,time);
return BlendRotation(keys[interval.a].value,keys[interval.b].value,interval.alpha);
}
inline JointPose BlendPose(const JointPose& a,const JointPose& b,float weight) {
weight = std::clamp(weight,0.0f,1.0f);
JointPose out;
XMStoreFloat3(&out.translation,XMVectorLerp(XMLoadFloat3(&a.translation),XMLoadFloat3(&b.translation),weight));
XMStoreFloat3(&out.scale,XMVectorLerp(XMLoadFloat3(&a.scale),XMLoadFloat3(&b.scale),weight));
out.rotation = BlendRotation(a.rotation,b.rotation,weight);
return out;
}
inline std::vector<XMFLOAT4X4> EvaluatePalette(std::span<const JointPose> pose,
std::span<const int32_t> parents,std::span<const XMFLOAT4X4> inverseBind) {
if (pose.size()!=parents.size() || pose.size()!=inverseBind.size())
throw std::invalid_argument("Skeleton/palette size mismatch");
std::vector<XMFLOAT4X4> globals(pose.size()),palette(pose.size());
for (size_t i=0;i<pose.size();++i) {
if (parents[i] < -1 || parents[i] >= static_cast<int64_t>(i))
throw std::invalid_argument("Skeleton must be parent-before-child");
const auto& p=pose[i];
XMMATRIX local=XMMatrixScaling(p.scale.x,p.scale.y,p.scale.z)
* XMMatrixRotationQuaternion(XMQuaternionNormalize(XMLoadFloat4(&p.rotation)))
* XMMatrixTranslation(p.translation.x,p.translation.y,p.translation.z);
XMMATRIX global=parents[i]>=0?local*XMLoadFloat4x4(&globals[parents[i]]):local;
XMStoreFloat4x4(&globals[i],global);
XMStoreFloat4x4(&palette[i],XMLoadFloat4x4(&inverseBind[i])*global);
}
return palette;
}
} // namespace qubic_reference
After sampling each joint’s translation, rotation, and scale tracks into two local poses, BlendPose combines corresponding joints. EvaluatePalette accumulates the hierarchy and produces row-major matrices for the shader’s BoneMatrix records. Upload that palette into a frame-owned structured buffer with a 64-byte element stride; the t2 descriptor and bytes remain valid through completion.
From local pose to bone palette#
With the handbook’s row-vector matrices, boneGlobal equals boneLocal multiplied by parentGlobal. A skin matrix equals inverseBind multiplied by boneGlobal. It first brings a bind-pose vertex into the joint’s bind-local coordinates, then moves it into the animated model space. The character entity’s world transform is applied afterward.
Implementation: GPU linear blend skinning
cbuffer FrameConstants : register(b0) {
row_major float4x4 worldViewProjection;
};
struct BoneMatrix { row_major float4x4 skin; };
StructuredBuffer<BoneMatrix> bonePalette : register(t2);
float4 SkinPosition(float3 position, uint4 joints, float4 weights) {
float4 p = float4(position, 1.0);
float4 result = 0.0;
for (uint i = 0; i < 4; ++i)
result += weights[i] * mul(p, bonePalette[joints[i]].skin);
return result;
}This is a reference shader excerpt. Import validates each joint index and normalizes weights, with a defined fallback for a zero total. Pose evaluation writes a frame-owned palette buffer; the descriptor at t2 references that allocation until the frame fence passes. Matrix packing and multiplication order must match the CPU convention. Normals need their own direction transformation, with inverse-transpose handling when nonuniform scaling is permitted.
State, events, and root motion#
A state machine chooses idle, locomotion, jump, or another state from gameplay parameters. Transitions define conditions and durations. Animation events are delivered when playback crosses an event time, including a loop boundary; repeatedly sampling one time must not retrigger the same event. Root motion extracts displacement from the animation root. The movement controller requests that displacement from PhysicsWorld; collision resolution chooses the final entity position. Remove the applied root movement from the visual pose so the character does not move twice. Gameplay authority determines whether a clip drives movement or follows it.
Morph targets and inverse kinematics#
A morph target stores vertex deltas and a blend weight. Apply compatible morph deltas before skinning when authored in bind space. Facial expressions often combine morphs with joint motion. Inverse kinematics solves joint parameters from a target, such as a foot on a stair. A two-bone analytic solver is a useful introduction; it still needs joint limits, stable pole-vector choices, and blending back to the authored pose.
Costs and troubleshooting#
Pose work scales with animated joints and active tracks. Share clip data, compress tracks with measured error bounds, skip far-character updates when acceptable, and preserve event semantics when changing evaluation rate. Palette uploads cost bandwidth; do not upload unchanged data automatically. Exploding vertices usually mean wrong inverse-bind matrices, joint indices, or matrix layout. Foot sliding suggests phase mismatch or root-motion disagreement. Candy-wrapper twisting is a known limitation of linear blend skinning; dual-quaternion methods have different tradeoffs. Compare the bind pose against an identity palette before debugging playback.