Tutorial 113: Morph Targets

CNA Tutorials  ·  3D Content

What you’ll learn

  • Finding the morph data an imported model brings with it, and setting blend weights.
  • Why CNA blends morph targets on the CPU, and exactly what that costs you per change.
  • Driving weights from an imported animation track.
  • How to budget morph targets so the cost model works in your favour rather than against it.

Before you startTutorial 110: Loading glTF 2.0 Models at Runtime. Morph targets arrive attached to a loaded Model, so you need that path working first.

A morph target — a blend shape, a shape key, whichever name your DCC tool uses — is a stored set of per-vertex offsets from a base pose. Blend several of them at once with independent weights and you get facial expressions, muscle bulges, damage states and vehicle deformation without adding a single bone.

CNA imports them from glTF and blends them for you. The blending happens on the CPU, and that single fact determines everything about how you should use the feature.

Where the data lives

Morph data is per mesh part, not per model, and it rides on the part’s Tag — the same convention Tutorial 112’s SkinningData uses on Model::Tag, for the same reason: real XNA’s ModelMeshPart has no morph property to reuse.

#include "Microsoft/Xna/Framework/Graphics/MorphTargetEXT.hpp"

using namespace Microsoft::Xna::Framework::Graphics;

Model* head = Content->Load<Model>("head");

for (ModelMesh* mesh : head->getMeshesProperty()) {
    for (ModelMeshPart* part : mesh->getMeshPartsProperty()) {
        auto* morph = dynamic_cast<MorphTargetDataEXT*>(part->getTagProperty());
        if (morph == nullptr) { continue; }   // this part has no morph targets

        const std::size_t targetCount = morph->PositionDeltas.size();
        // morph->Weights holds the current weights, one per target
    }
}

Use dynamic_cast rather than static_cast here. Tag is a general-purpose System::Object*, so a null result is the honest answer for "this part has no morph targets", and a dynamic_cast gives you that answer safely.

Member of MorphTargetDataEXTMeaning
BaseVertexBytesThe part’s all-weights-zero vertex bytes, exactly as first uploaded.
StrideByte stride of one vertex — 32, 52 or 56.
PositionDeltasPositionDeltas[target][vertex]. Its outer size is the target count.
NormalDeltasSame shape; a target with no normal deltas has an empty inner vector.
WeightsCurrent blend weights, one per target.
WeightTrackOptional imported weight animation, or empty.

The initial Weights are the default weights from the source mesh, so a model loads showing whatever neutral shape the artist authored.

Setting weights

One call does the whole job — blend, then re-upload:

// Two targets: [smile, blink]. Weights are usually 0..1, one per target.
SetMorphWeightsEXT(*part, { 0.7f, 0.0f });

Two things will throw, and both are worth knowing before you meet them at runtime:

  • Calling it on a part with no MorphTargetDataEXT attached.
  • Passing a weight vector whose length does not exactly match the target count. There is no padding and no truncation — the message names both the expected and the received count.

The blend itself is the obvious formula, applied per vertex:

finalPosition = basePosition + sum(weights[t] * PositionDeltas[t][v]);
finalNormal   = normalize(baseNormal + sum(weights[t] * NormalDeltas[t][v]));

Normals are renormalised afterwards, because a weighted sum of unit vectors is not itself unit length. Every other attribute — texture coordinates, blend weights, blend indices, colour — is copied from the base pose untouched. Normal deltas are only applied for the strides that actually have a normal slot, which is all three of 32, 52 and 56.

Weights are not clamped to [0, 1]. Nothing stops you passing 1.4 to overshoot a shape, or a negative weight to push it the other way — a legitimate technique, and entirely your responsibility.

If you want the blended bytes without touching the GPU — for a collision proxy, a bounds recomputation, a unit test — the pure function is public:

std::vector<std::uint8_t> bytes = BlendMorphTargetsEXT(*morph, { 0.7f, 0.0f });
// Same size and stride as morph->BaseVertexBytes. Nothing was uploaded.

The cost model

Morph blending is a CPU operation. Every SetMorphWeightsEXT() call walks every vertex of that mesh part, sums every target’s contribution, and then re-uploads the entire vertex buffer. It is not a vertex-shader technique.

This is a deliberate simplicity-over-throughput trade, and it buys something real: morph targets work unchanged with every effect and every shader on every renderer, including the 2D-only and non-shader ones. There is no per-renderer morph shader to be missing, so there is no renderer on which the feature quietly does nothing.

What it costs is that the work scales with the product of three things:

FactorEffect on cost
Vertices in the mesh partLinear. The whole buffer is rebuilt and re-uploaded.
Number of morph targetsLinear. Every target is summed for every vertex.
Weight changes per secondLinear. Cost is paid per change, not per frame.

That third row is the lever, and it is the one most under your control. Four rules follow from it directly:

  • Do not call it every frame unconditionally. Compare against the previous weights and skip the call when nothing moved. A face holding an expression should cost nothing.
  • Keep morphed parts small. Split a character so the face is its own mesh part. Morphing a 2,000-vertex head is a fundamentally different proposition from morphing a 60,000-vertex whole body, and the split is free.
  • Batch a whole expression into one call. The weight vector covers every target at once, so setting eight blend shapes costs one traversal — not eight.
  • Prefer bones for large-amplitude motion. Skinning runs on the GPU. Reserve morph targets for what bones cannot express: the surface detail.

Used that way — a handful of low-poly morphed parts, updated only when a weight genuinely changes — the cost is negligible. Used the other way — every character’s full body mesh re-uploaded every frame — it will dominate your frame time, and it will do so quietly, because nothing errors.

Animated weights

glTF can animate morph weights over time through a weights channel. That channel targets a mesh node rather than a skeleton joint, so it has no bone index and cannot live on Tutorial 112’s bone timeline. CNA gives it its own track type, imported onto MorphTargetDataEXT::WeightTrack.

Member of MorphWeightTrackEXTMeaning
KeysKeyframes in ascending Time order; each holds a full weight vector.
StepInterpolationTrue if the source channel was STEP — a real hold-last-value.
CubicSplineTrue if the source channel was CUBICSPLINE. Each key then also carries InTangent and OutTangent.

Unlike the bone tracks, this one is evaluated at playback, so a CUBICSPLINE weight curve keeps its real Hermite shape between keys rather than being baked down to a polyline:

// Advance your own clock however you like; the track is independent of AnimationPlayer.
elapsedSeconds_ += gameTime.getElapsedGameTimeProperty().getTotalSecondsProperty();

std::vector<float> w = EvaluateMorphWeightsEXT(morph->WeightTrack, elapsedSeconds_);
if (!w.empty() && w != morph->Weights) {
    SetMorphWeightsEXT(*part, w);      // only re-upload when the weights actually changed
}

Evaluation is clamped at both ends and returns an empty vector for a track with no keys, so an unanimated part costs one comparison. Note the guard on the last line — that is the "pay per change" rule from above, in code.

Because the track is a plain data member and evaluation is a free function, nothing forces you to use the imported timeline at all. Drive the weights from gameplay instead — damage level, speech volume, a UI slider — and the same SetMorphWeightsEXT() call applies.

Morph targets through the offline converter

Both import paths carry morph data. The runtime .gltf/.glb path builds MorphTargetDataEXT in memory; the offline converter writes the same information out as a _morph.bin sidecar plus three JSON fields on the mesh entry:

{
  "vertices": "head_mesh0_verts.bin",
  "indices": "head_mesh0_idx.bin",
  "vertexStride": 32,
  "effect": "BasicEffect",
  "parentBone": 0,
  "morphTargets": "head_mesh0_morph.bin",
  "morphWeights": [0, 0],
  "morphWeightTrack": {
    "stepInterpolation": false,
    "cubicSpline": false,
    "keys": [
      { "time": 0,   "weights": [0, 0] },
      { "time": 0.5, "weights": [1, 0] }
    ]
  }
}

The loaded result is identical either way, so you can switch an asset between the two paths without touching a line of game code.

Where to go next