Skip to content

feat: Morph targets and compute skinning - #3569

Open
icauro wants to merge 11 commits into
stride3d:masterfrom
icauro:pr/compute-skinning
Open

icauro wants to merge 11 commits into
stride3d:masterfrom
icauro:pr/compute-skinning

Conversation

@icauro

@icauro icauro commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Morph targets from import to rendering, plus an optional compute path for morphs and skinning.

Features

  • glTF/FBX morph target import (Import Morph Targets option); normal and tangent deltas imported or generated.
  • Morphs combine with skinning and instancing.
  • Automatic deformation path: compute for morphed meshes when supported, vertex shader otherwise and for skinning-only meshes.
  • ModelComponent morph API: authored weights saved with the scene, runtime weights for scripts and animation;
    allocation-free lookups by string, span or slot; morphs can be disabled per component.
  • Morph weight animation by target name; one clip works across models.
  • Editor: per-target sliders, Min / Reset / Max All, live preview, one undo step per edit.
  • Vulkan: raw buffers get the storage-buffer usage flag.
  • MorphTargets sample.

Related Issue

#339

Types of changes

  • Docs change / refactoring / dependency upgrade
  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My change requires a change to the documentation.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have built and run the editor to try this change out.

Architecture decisions and reasons (also a devlog :D)

I did all measurements on an RX 6800, which is an RDNA2 card.
I couldn't test on my Android phone because it doesn't support Vulkan 1.3.
Used meshes:

  • Test cylinder: 61k vertices, 600 targets, skinned.
  • Character mesh: 67k vertices, 640 targets (measured in a separate Vulkan test bench, not in Stride).

[DECISION] Sparse vs dense storage of the morph data: sparse chosen

  • Dense is much simpler to use, similar to accessing any other per-vertex attribute; sparse on the other hand needs understanding of the layout.
  • Dense stores an entry for every vertex × every target; sparse stores only the vertices a target moves.
  • Morph memory per mesh, dense vs sparse: character 1 GB vs 90 MB, cylinder 878 vs 355 MB.
  • Dense ran out of room with 16 cylinder meshes: 166 ms vs 22 ms sparse.
  • Dense is at best slightly faster (character, 16 instances: 8.1 vs 8.8 ms), but not worth 10× the memory.
  • Real characters have mostly local shapes (emotions, face etc.), which favors sparse further.

[DECISION] Delta accumulation, gather vs scatter: gather chosen

  • Deltas are sorted by vertex, so one thread reads its vertex's entries, adds skinning and writes once.
  • Scatter (one thread per entry, as in Unreal) only costs the active targets, but needs atomic adds into an
    accumulation buffer and a second pass.
  • Its atomics would also conflict between the instances that share one dispatch here.
  • Zero weights skip the math, but sparse entries are still read, since the target of an entry is only known after
    reading it. A list of active targets could skip those reads too.

Clanker visualization:
image

[DECISION] fp32 vs fp16 deltas: Half-float deltas chosen

  • An entry is 24 bytes (vertex, target, nine 16-bit deltas) instead of about 40 with 32-bit floats.
  • The morph loop is limited by memory bandwidth, so smaller entries are directly faster.
  • Accuracy checked: imported shapes match Blender on every vertex within half-float rounding (reference test).
  • Only the deltas are half; base vertices, weights and output stay 32-bit float.
  • Deltas beyond ±65504 or not finite are rejected at import.
  • This is sensible since morphs are already offsets, far below the half-float limit of 65504.

[DECISION] Thread groups: X32Y1 below 4 instances, X8Y16 from 4

  • X is vertices, Y is instances of the same mesh.
  • Neighboring vertices have different entry counts, so wide groups wait on their slowest thread:
    X32 and X64 were 1.6–2.5× slower with many instances.
  • X8Y16 was best from 16 instances (2.97 ms at 1×16).
  • With one instance X8Y1 was slightly faster, but an 8-thread group uses only part of a GPU wave, so X32Y1 is used.
  • Each shape is a shader permutation, so only two are kept.
  • For skinning alone the shape barely matters (5–20%), since every vertex does the same work.

[DECISION] Vertex shader for skinning-only meshes

  • Current vertex shader skinning is simple: read the shared mesh data -> skin -> pass it on to the pixel shader directly.

  • There is a problem though: this happens whenever the mesh is rendered, so every shadow pass repeats the same unnecessary skinning.

  • The compute skinner tries to alleviate that by skinning and storing the final vertices once, before anything is rendered on the screen.

  • So vertex shaders just need to do a simple local-to-world transformation, not bone skinning, morphs, etc.

  • I was expecting the compute skinner to make the overall frame faster for meshes with morphs and with just bones, with 1 shadow map and up. I was semi-correct: for morphs it's indeed an improvement, but not for meshes skinned with just bones.

  • The shadow pass is limited by vertex/triangle throughput, not skinning math, so removing the extra ALU work didn't matter.
    But morph skinning is memory-bound, so the compute skinner starts to matter by eliminating repetitive work.

  • Many separate meshes are much slower in compute (256 meshes: 18.5 vs 51.6 ms) from per-dispatch cost, but I suspect there is an inefficiency in the engine somewhere; it needs further investigation IMHO.

  • For now: morphed meshes use compute: the vertex shader repeats the target loop in every pass and instance (3–17× slower).

[DECISION] While at it, batch or no batch: batch chosen

  • Compute opens an optimization path: batch skinning

  • I already explained how compute reduces the repetitive work, and more importantly the repetitive memory bandwidth.

  • Since the compute path isn't bound by the rules of the fixed pipeline, we can arrange the work to minimize reads of
    the data that costs the most.

  • The costly data is the shared mesh data: morph entries and source vertices. Every instance of a mesh reads the same.

  • Naive way: one dispatch per instance. Every dispatch reads all of it again from VRAM, since it's way too big for
    cache (cylinder: 16 x 355 MB = ~5.7 GB per frame).

  • Batched way: one dispatch for all instances of a mesh, X8Y16 group = 8 vertices x 16 instances. The 16 threads of a
    vertex read the same entries at the same time, so one fetch serves all 16 -> shared data read ~once per 16
    instances. Only weights, bones and output scale with instances.

  • Narrow X also helps (I assume :D): threads of the same vertex share the same loop length, so no waiting on a slower vertex.

  • Measured ~186 vs ~1,400 µs per instance; the cache part is theory, not profiled (no RGP capture yet).

  • Before batching, I fixed the frame order: upload everything first, then dispatch everything. Before, each instance
    did upload -> barrier -> dispatch, which serialized everything (29.2 -> 4.75 ms).

  • Bones: one pose per skeleton instead of a bone palette copy per sub-mesh (~16x less bone data).

  • Output: only position, normal and tangent, the rest is just read from the same shared mesh (8.8 -> 4.9 ms).

  • Then optimizing uploads: ~12,000 small uploads per frame -> 3 arenas (22.4 -> 10.3 ms). One barrier pass instead of one per dispatch.

  • Tried one dispatch for different meshes too, no gain, so I removed it.

  • But batching only helps instances that share the same mesh, so many separate meshes still lose.

Some kernel details

  • Raw buffers: one load or store per float3 instead of three.
  • Bones as three float4 rows: each output component is one dot product (with raw buffers: 0.108 → 0.050 ms).
  • Entries read as uint2 pairs: three 8-byte loads instead of six 4-byte loads (up to 19% faster).

Limitations

  • Compute deformation needs the imported vertex layout (one interleaved buffer, float position, normal and tangent).
    Meshes built in code with other layouts throw NotSupportedException; use the vertex shader mode for them.
  • Same as existing skinning:
    • Bounds: morphs don't change culling bounds; a morph moving vertices far outside them can be culled while visible.
    • Velocity: motion vectors include object motion only, not morph or skinning deformation.
  • Import: glTF default morph weights (mesh.weights) are not applied; models load in their base shape.
    • These are usually just the slider values left on in Blender at export, not an intended resting shape.
    • FBX has no reliable equivalent: Assimp sets 1.0 for ordinary targets, so FBX defaults would be wrong.
    • Initial weights are authored in Stride instead: the editor sliders save them on the ModelComponent.
  • Import: morph weight animation is always imported as linear; glTF STEP and CUBICSPLINE are not kept.
    • Assimp's morph keys carry only time and weights, so the interpolation mode is lost before Stride sees it.
    • Blender's glTF exporter samples animation on every frame by default, so linear reproduces those curves.
    • Only sparse, hand-keyed STEP/CUBICSPLINE curves would play back differently; baking them on export fixes it.

Not done / out of scope

  • Async compute: Stride uses a single Vulkan queue, and at best it would tie the vertex shader for skinning.
  • Testing all kind of gpus to min-maxing thread counts.

Testing

  • TestMorphImport, TestModelComponentMorphs.
  • Sample and editor tested by hand.

Some thoughts

  • Buying a AMD card as graphics dev was a mistake, NSight much better tool than RGA.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Draft PR — automatic CI is skipped to save runner minutes.

  • Mark the PR ready for review to run the full automatic CI — or add a ci-run-on-draft label to run it now without leaving draft.
  • Or arm a specific opt-in suite: ci-enduser, ci-editor, ci-ios, ci-android.

@icauro

icauro commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author
2026-10-12.07-40-39.mp4

@icauro
icauro force-pushed the pr/compute-skinning branch from ecd396c to 103ece0 Compare October 12, 2026 06:59
@icauro

icauro commented Oct 12, 2026

Copy link
Copy Markdown
Contributor Author
Area Added Removed
Stride.Engine (morph API, compute renderer) 1,451 1
Stride.Rendering (morph data, shaders) 702 11
Stride.Importer.3D (morph import) 583 1
Editor (Stride.Assets.Presentation.Wpf) 339 4
Stride.Assets.Models 81 0
Stride.Importer.Common 35 0
Stride.Graphics (Vulkan fix) 22 25
Total 3,213 42

Excludes the sample and tests. Counts include comments and XML docs.

@icauro
icauro force-pushed the pr/compute-skinning branch from 103ece0 to 305cb24 Compare October 12, 2026 07:48
@icauro
icauro marked this pull request as ready for review October 12, 2026 07:58

@xen2 xen2 left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Tests: TestMorphImport and TestModelComponentMorphs cover the import and the API, but nothing renders. Could you add a screenshot test (gold image) with a morphed, skinned mesh? On CI it runs on Direct3D 11, Direct3D 12 and Vulkan, and the Vulkan leg runs with synchronization validation, so it also checks the barriers of the compute pass.

    You don't need to render the gold images yourself: test-gold-gen.yml renders them on every platform (Windows D3D11/D3D12/Vulkan, Linux, macOS, Android, iOS) and commits them to the branch. For example, on your fork:

    gh workflow run test-gold-gen.yml --repo icauro/stride --ref pr/compute-skinning -f test-filter="FullyQualifiedName~MorphTargetTests" -f update-gold=commit
    
  • Cost: ModelRenderProcessor deforms every enabled model with morph targets every frame, also when it is not visible. Is visibility culling planned as a follow-up?

  • Docs: the checklist says this needs a documentation change. Is there a stride-docs PR?

  • MorphTargets.sln fails the file-guard-gate check: the repo only allows .slnx (the other sample solutions are .slnx, and samples like AnimatedModel have none). Please remove it or convert it with dotnet sln migrate.

Comment thread sources/engine/Stride.Engine/Rendering/ModelDeformationRenderer.cs Outdated
Comment thread sources/engine/Stride.Engine/Rendering/ModelDeformationRenderer.cs Outdated
Comment thread sources/engine/Stride.Engine/Rendering/ModelDeformationRenderer.cs
Comment thread sources/engine/Stride.Assets.Models/ModelAsset.cs
Comment thread sources/engine/Stride.Engine/Engine/ModelComponent.Morphs.cs Outdated
Comment thread samples/Graphics/MorphTargets/MorphTargets.Windows/MorphTargets.Windows.csproj Outdated
@icauro

icauro commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

Now compute path does culling to, added related tests. But needs review.
Also I started to write the new docs for the feature, coming soon.

@xen2

xen2 commented Oct 15, 2026

Copy link
Copy Markdown
Member

Thanks.
About the doc, it was just a question. It won't block the PR.
If you need help, feel free to ask! (i.e. on discord)

icauro added 10 commits October 20, 2026 14:26
Use storage-buffer usage for raw and structured buffers, and create texel views only for typed buffers, matching their shader descriptor types.
Store sparse position, normal and tangent deltas per vertex. Preserve morph data on mesh copies and remap it when splitting the vertex domain.
Import morph shapes and named weight curves, generate missing direction deltas, and preserve existing model assets by leaving morph import disabled during upgrade. Cover packing, remapping, serialization and source import.
… replacement

Give each model component independent runtime weights seeded from serialized authored values. Bind animation channels by target name and retain authored weights when models change. Cover the public API, persistence and animation binding.
Release compute and output-merger UAV bindings on transitions out of unordered access, allowing deformed buffers to be read without clearing the command-list state.
Use the buffer access and pipeline-stage masks when entering or leaving Common layout, so writes from compute are synchronized with vertex fetch and other usual buffer accesses.
Apply morphs through a vertex shader or batched compute deformation. Share morph decoding and basis preparation, honor mesh skinning flags, handle singular transforms, and fall back when compute is unavailable.

Defer deformation until a render or shadow view collects the model. Transition compute outputs for drawing without clearing graphics state.
Expose named morph weights and import options in the property grid. Preview slider drags directly in the scene and commit each completed drag as one undo operation.
Demonstrate scripted morph weights and animation on glTF and FBX models, using the standard sample layout, template metadata and sample package registration.
Run the morph donut scenarios independently through vertex skinning and both compute thread-group paths, with separate gold image names. Use a lit normal/tangent material to expose deformation errors.

Cover morph weights, skeletal animation, zero scale, hidden models, first visibility and shadow-only collection. Add a compute lane to AnimatedModelTests while preserving its original vertex lane. Generate screenshot baselines separately with the CI gold workflow.
@icauro
icauro force-pushed the pr/compute-skinning branch from 6832ac9 to f1b6380 Compare October 20, 2026 14:59
Generated by https://github.com/icauro/stride/actions/runs/39444678222 for Windows D3D11/D3D12/Vulkan, Linux, macOS, Android and iOS. Promoted with CompareGold using the existing tolerance and fallback rules.
@icauro
icauro force-pushed the pr/compute-skinning branch from 98e2fb7 to ae871fc Compare October 20, 2026 15:24
@icauro

icauro commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Updated the PR with cleaned-up commits, shared shader helpers, and corrected normal/tangent handling.
Added separate gold-image tests for vertex, single-compute and batched-compute deformation, including visibility and shadow cases. References were generated on CI across Windows, Linux, macOS, Android and iOS.

Vertex and compute skining not exactly matches, due to order of matrix muls:
The measured maximum position difference was approximately 0,00000038 world units:

API Maximum position difference
D3D12 0,0000003770465
Vulkan 0,00000036931138

Public API intruded by this pr so far:

Runtime morph controls — ModelComponent

GetMorphTargetName(int)

public string GetMorphTargetName(int slot) => MorphTargetNames[slot];

FindMorphTarget(string)

public int FindMorphTarget(string name)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
return FindMorphTarget(name.AsSpan());
}

FindMorphTarget(ReadOnlySpan)

public int FindMorphTarget(ReadOnlySpan<char> name)
{
if (name.IsWhiteSpace())
throw new ArgumentException("A morph target name can't be empty or white space.", nameof(name));
SynchronizeMorphTargets();
return morphLayout != null && morphLayout.SlotBySpanName.TryGetValue(name, out int slot) ? slot : -1;
}

GetMorphWeight(int)

public float GetMorphWeight(int slot)
{
Bind();
if ((uint)slot >= (uint)slotWeights.Length)
throw new ArgumentOutOfRangeException(nameof(slot));
return slotWeights[slot];
}

SetMorphWeight(int, float)

public void SetMorphWeight(int slot, float value)
{
ModelMorphSettings.CheckWeight(value);
Bind();
if ((uint)slot >= (uint)slotWeights.Length)
throw new ArgumentOutOfRangeException(nameof(slot));
if (slotWeights[slot] == value)
return;
slotWeights[slot] = value;
meshWeightsDirty = true;
}

SetMorphWeight(string, float)

public void SetMorphWeight(string name, float value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
SetMorphWeight(name.AsSpan(), value);
}

SetMorphWeight(ReadOnlySpan, float)

public void SetMorphWeight(ReadOnlySpan<char> name, float value)
{
int slot = FindMorphTarget(name);
if (slot < 0)
throw new ArgumentException($"Morph target '{name}' is not in the model.", nameof(name));
SetMorphWeight(slot, value);
}

SetAllMorphWeights(float)

public void SetAllMorphWeights(float value)
{
ModelMorphSettings.CheckWeight(value);
Bind();
Array.Fill(slotWeights, value);
meshWeightsDirty = true;
}

Mesh data — MeshMorphData and MeshMorphEntry

MeshMorphData.Create(...)

public static MeshMorphData Create(int vertexCount, ReadOnlySpan<string> names, IEnumerable<MeshMorphEntry> contributions,
bool hasNormals = false, bool hasTangents = false)

MeshMorphData.Validate()

MeshMorphData.Remap(ReadOnlySpan)

public MeshMorphData Remap(ReadOnlySpan<uint> sourceVertices)

MeshMorphData.Transform(Matrix, ReadOnlySpan, ReadOnlySpan)

public MeshMorphData Transform(Matrix transform, ReadOnlySpan<Vector3> baseNormals, ReadOnlySpan<Vector3> baseTangents)

MeshMorphEntry.Create(uint, ushort, Vector3, Vector3, Vector3)

public static MeshMorphEntry Create(uint vertex, ushort shape, Vector3 position, Vector3 normal, Vector3 tangent)

Editor integration — template providers

MorphWeightsTemplateProvider.MatchNode(NodeViewModel)

public override bool MatchNode(NodeViewModel node)
=> node.Name == "WeightsCategory" && node.Parent?.Type == typeof(ModelMorphSettings);

MorphWeightTemplateProvider.MatchNode(NodeViewModel)

public override bool MatchNode(NodeViewModel node) => node.Name.EndsWith(NodeNameSuffix, StringComparison.Ordinal) && node.Type == typeof(float);


// Editor metadata only; packed runtime targets are built from the source mesh.
[DataMember(60), Display(Browsable = false)]
public List<string> MorphTargetNames { get; } = new();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: I may be missing something, but it looks like MorphTargetNames is only filled by ModelAssetImporter, when a model is first imported. If so, I wonder about two cases:

  • a model upgraded with ImportMorphTargets: false, where the user later turns Import Morph Targets on: would the editor find no names and show no sliders, even though the compile imports the targets?
  • a source file that gets new targets after the import: would the editor miss them?

Maybe "Update from source" already refreshes the list in both cases? If it does, all good.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants