Skip to content

Support Animation Layers for Skinned Meshes - #834

Open
Gopmyc wants to merge 7 commits into
Overload-Technologies:mainfrom
Gopmyc:833
Open

Support Animation Layers for Skinned Meshes#834
Gopmyc wants to merge 7 commits into
Overload-Technologies:mainfrom
Gopmyc:833

Conversation

@Gopmyc

@Gopmyc Gopmyc commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Description

CSkinnedMeshRenderer played a single clip. It now drives up to 4 layers, each owning its
animation source model, animation, time, speed, loop mode and weight. Layer-scoped methods take a
trailing layer index defaulting to the base layer, which always exists

Pose evaluation iterates skeleton nodes instead of animation tracks, resolving each layer's track
per node. Only layers holding a track for a node contribute to it, their weights normalized against
each other, lerp for position and scale, slerp for rotation. Nodes no layer animates keep their
bind pose

Related Issue(s)

Fixes #833

Review Guidance

  • A single layer at any weight above zero reproduces the previous output; zero active layers gives
    the bind pose.
  • Weights normalize per node, so a layer that alone drives a bone stays at full strength until its
    weight reaches zero, where the bone falls back to bind pose. Documented on SetLayerWeight.
  • Going node-major also makes duplicate tracks pointing at the same node harmless, where
    accumulating weights would have counted them twice. AssimpParser can emit those.
  • Scene format break: per-layer settings moved under a <layers> element. Existing scenes lose
    playing, looping, playback_speed, time_ticks, animation and animation_source, and
    reset to one default layer. No migration path.
  • Columns only drew Separator full width, through a hardcoded dynamic_cast. That is now an
    opt-in AWidget::fullWidth flag, required for the collapsible layer groups. Default false, no
    existing widget affected.
  • No bone masks: a layer only affects nodes its clip has tracks for. A rotation-only track still
    contributes bind translation, matching the previous single-clip behaviour.

Screenshots/GIFs

N/A

AI Usage Disclosure

Documentation + Refactored code

Checklist

  • My code follows the project's code style guidelines
  • When applicable, I have commented my code, particularly in hard-to-understand areas
  • When applicable, I have updated the documentation accordingly
  • My changes don't generate new warnings or errors
  • I have reviewed and take responsibility for all code in this PR (including any AI-assisted contributions)

Gopmyc added 3 commits August 31, 2026 01:43
Columns only drew Separator across the full width, through a hardcoded
dynamic_cast. Generalize it into an opt-in AWidget::fullWidth flag so any
widget can opt out of being placed in a single cell.
Playback state moves from a single set of scalars to up to 4 layers, each
owning its animation source model, animation, time, speed, loop mode and
weight. Layer-scoped methods take a trailing layer index defaulting to the
base layer, which always exists.

Pose evaluation now iterates skeleton nodes instead of animation tracks and
looks each layer's track up by node. Only layers holding a track for a node
contribute to it, their weights normalized against each other, blending with
lerp for position and scale and slerp for rotation. Nodes no layer animates
keep their bind pose, so a single layer at any weight above zero reproduces
the previous output. Going node-major also makes duplicate tracks pointing at
the same node harmless, where accumulating weights would have counted them
twice.

Layers are added and removed at runtime and drawn as collapsible groups in
the inspector, so only the layers actually in use are shown. Per-layer
settings are serialized as repeated <layer> elements.
Add the layer management functions and the optional trailing layer argument
carried by every layer-scoped function.
function SkinnedMeshRenderer:RemoveLayer(layer) end

--- Starts/resumes animation playback on a layer
---@param layer? integer

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

'?' marks the param as optional in LuaLS. First time it shows up in the repo, can switch to 'integer|nil' if you'd rather ?

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.

I'm fine to start using type? instead of type|nil, good catch!
We'll update other type|nil to type? as we go

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think using AI to do that could be a huge time saver

@adriengivry adriengivry Sep 3, 2026

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.

Yeah AI is great for refactoring code like that :)
Could be done in another PR

Comment thread Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h Outdated
for (auto it = m_widgets.begin(); it != m_widgets.end(); ++it)
{
if (dynamic_cast<Visual::Separator*>(it->first))
if (it->first->fullWidth || dynamic_cast<Visual::Separator*>(it->first))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Needed for the collapsible layer groups : The inspector pins column 0 at 200px, so a
GroupCollapsable dropped in a cell gets crushed. Only 'Separator' could break out of the table
before, via this hardcoded cast, the flag just generalizes it. Defaults to false, nothing else
changes

@Gopmyc Gopmyc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Just a few small changes to clarify things that I didn't find particularly explicit in the code itself

@tccountus

tccountus commented Aug 31, 2026

Copy link
Copy Markdown
Contributor
                    <component>
                        <type>class OvCore::ECS::Components::CSkinnedMeshRenderer</type>
                        <data>
                            <mesh_bounds_scale>1.5</mesh_bounds_scale>
                            <pose_eval_rate>60</pose_eval_rate>
                            <layers>
                                <layer>
                                    <animation_source>?</animation_source>
                                    <playing>true</playing>
                                    <looping>true</looping>
                                    <playback_speed>1</playback_speed>
                                    <time_ticks>0</time_ticks>
                                    <weight>1</weight>
                                    <animation>root|idle</animation>
                                </layer>
                            </layers>
                        </data>
                    </component>

We have to modify Animation.ovscene in the Showroom project - maybe we could add something to convert the legacy format to the new

@Gopmyc

Gopmyc commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author
                    <component>

                        <type>class OvCore::ECS::Components::CSkinnedMeshRenderer</type>

                        <data>

                            <mesh_bounds_scale>1.5</mesh_bounds_scale>

                            <pose_eval_rate>60</pose_eval_rate>

                            <layers>

                                <layer>

                                    <animation_source>?</animation_source>

                                    <playing>true</playing>

                                    <looping>true</looping>

                                    <playback_speed>1</playback_speed>

                                    <time_ticks>0</time_ticks>

                                    <weight>1</weight>

                                    <animation>root|idle</animation>

                                </layer>

                            </layers>

                        </data>

                    </component>

We have to modify Animation.ovscene in the Showroom project - maybe we could add something to convert the legacy format to the new

I don’t think adding backward compatibility for this case is really worth it, as the implementation and maintenance cost would be quite high for a fairly small migration issue. I’d say the same for a separate conversion tool.

Existing Scenes/Prefabs using a SkinnedMeshRenderer can simply be opened in Overload, updated to use the desired animation on Layer 0, and saved again.

The Lua API should remain backward compatible since the layer parameter defaults to 0.

I’ll also make a follow-up PR soon to fix this in Showroom.

@adriengivry adriengivry 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.

I like the feature, I think it's a great addition.
The UI is a little confusing, "Layer 0", "Layer 1", etc... look like other components.

Image

We probably want to keep collapsible groups for components, and find another way to present array elements.

@Gopmyc

Gopmyc commented Sep 3, 2026

Copy link
Copy Markdown
Contributor Author

I like the feature, I think it's a great addition. The UI is a little confusing, "Layer 0", "Layer 1", etc... look like other components.

Image We probably want to keep collapsible groups for components, and find another way to present array elements.

I decided to switch to a simpler visual style where the boundary between layers is less pronounced than the components themselves, what do you think ?
image
image

{
animationChoice.choices.clear();
animationChoice.choices.emplace(-1, "<None>");
return HasCompatibleModel() ? std::string{} : std::string{ "No skinned model assigned" };

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.

I like the idea of having a diagnostic message here, but when no issue is found, it leaves a blank spot.

Image

Either we could add a message like for scripts, or disable the text dynamically.

Image

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added a Ready at the component level like Behaviour does, and colored the layer messages. Left
the layers quiet when they're fine though, repeating "Ready" on each one gets noisy fast

Tried disabling the field first, but ExecutePlugins runs inside the if (enabled) block in
AWidget::Draw, so a widget that hides itself from its own gatherer never comes back. Probably
worth fixing on its own

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

Animation Layers - Support for simultaneous skeletal animations with blending

3 participants