Repository navigation
Conversation
|
🤖 Draft PR — automatic CI is skipped to save runner minutes.
|
2026-10-12.07-40-39.mp4 |
ecd396c to
103ece0
Compare
Excludes the sample and tests. Counts include comments and XML docs. |
103ece0 to
305cb24
Compare
There was a problem hiding this comment.
-
Tests:
TestMorphImportandTestModelComponentMorphscover 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.ymlrenders 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:
ModelRenderProcessordeforms 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.slnfails thefile-guard-gatecheck: the repo only allows.slnx(the other sample solutions are.slnx, and samples likeAnimatedModelhave none). Please remove it or convert it withdotnet sln migrate.
|
Now compute path does culling to, added related tests. But needs review. |
|
Thanks. |
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.
6832ac9 to
f1b6380
Compare
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.
98e2fb7 to
ae871fc
Compare
|
Updated the PR with cleaned-up commits, shared shader helpers, and corrected normal/tangent handling. Vertex and compute skining not exactly matches, due to order of matrix muls:
Public API intruded by this pr so far:Runtime morph controls — ModelComponentGetMorphTargetName(int) FindMorphTarget(string) stride/sources/engine/Stride.Engine/Engine/ModelComponent.Morphs.cs Lines 178 to 182 in ae871fc FindMorphTarget(ReadOnlySpan) stride/sources/engine/Stride.Engine/Engine/ModelComponent.Morphs.cs Lines 188 to 194 in ae871fc GetMorphWeight(int) stride/sources/engine/Stride.Engine/Engine/ModelComponent.Morphs.cs Lines 200 to 206 in ae871fc SetMorphWeight(int, float) stride/sources/engine/Stride.Engine/Engine/ModelComponent.Morphs.cs Lines 218 to 229 in ae871fc SetMorphWeight(string, float) stride/sources/engine/Stride.Engine/Engine/ModelComponent.Morphs.cs Lines 238 to 242 in ae871fc SetMorphWeight(ReadOnlySpan, float) stride/sources/engine/Stride.Engine/Engine/ModelComponent.Morphs.cs Lines 251 to 257 in ae871fc SetAllMorphWeights(float) stride/sources/engine/Stride.Engine/Engine/ModelComponent.Morphs.cs Lines 262 to 268 in ae871fc Mesh data — MeshMorphData and MeshMorphEntryMeshMorphData.Create(...) stride/sources/engine/Stride.Rendering/Rendering/MeshMorphData.cs Lines 60 to 61 in ae871fc MeshMorphData.Validate() MeshMorphData.Remap(ReadOnlySpan) MeshMorphData.Transform(Matrix, ReadOnlySpan, ReadOnlySpan) MeshMorphEntry.Create(uint, ushort, Vector3, Vector3, Vector3) Editor integration — template providersMorphWeightsTemplateProvider.MatchNode(NodeViewModel) MorphWeightTemplateProvider.MatchNode(NodeViewModel) |
|
|
||
| // Editor metadata only; packed runtime targets are built from the source mesh. | ||
| [DataMember(60), Display(Browsable = false)] | ||
| public List<string> MorphTargetNames { get; } = new(); |
There was a problem hiding this comment.
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 turnsImport Morph Targetson: 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.
Morph targets from import to rendering, plus an optional compute path for morphs and skinning.
Features
ModelComponentmorph 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.
MorphTargetssample.Related Issue
#339
Types of changes
Checklist
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:
[DECISION] Sparse vs dense storage of the morph data: sparse chosen
[DECISION] Delta accumulation, gather vs scatter: gather chosen
accumulation buffer and a second pass.
reading it. A list of active targets could skip those reads too.
Clanker visualization:

[DECISION] fp32 vs fp16 deltas: Half-float deltas chosen
[DECISION] Thread groups: X32Y1 below 4 instances, X8Y16 from 4
X32 and X64 were 1.6–2.5× slower with many instances.
[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
uint2pairs: three 8-byte loads instead of six 4-byte loads (up to 19% faster).Limitations
Meshes built in code with other layouts throw
NotSupportedException; use the vertex shader mode for them.mesh.weights) are not applied; models load in their base shape.ModelComponent.STEPandCUBICSPLINEare not kept.STEP/CUBICSPLINEcurves would play back differently; baking them on export fixes it.Not done / out of scope
Testing
TestMorphImport,TestModelComponentMorphs.Some thoughts