From 6556834f35e3a86c00afa17c77448488b875e1cc Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Mon, 31 Aug 2026 01:43:26 +0200 Subject: [PATCH 01/10] Allow widgets to span every column of a Columns layout 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. --- Sources/OvUI/include/OvUI/Widgets/AWidget.h | 1 + Sources/OvUI/include/OvUI/Widgets/Layout/Columns.h | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/OvUI/include/OvUI/Widgets/AWidget.h b/Sources/OvUI/include/OvUI/Widgets/AWidget.h index 648ec4fcb..f1da194d8 100644 --- a/Sources/OvUI/include/OvUI/Widgets/AWidget.h +++ b/Sources/OvUI/include/OvUI/Widgets/AWidget.h @@ -86,6 +86,7 @@ namespace OvUI::Widgets bool enabled = true; bool disabled = false; bool lineBreak = true; + bool fullWidth = false; // ask column-based containers to draw this widget across all of their columns bool neverDisabled = false; // provide a way to bypass the "readonly" setting used by a WidgetContainer protected: diff --git a/Sources/OvUI/include/OvUI/Widgets/Layout/Columns.h b/Sources/OvUI/include/OvUI/Widgets/Layout/Columns.h index add966fcd..20bbb2d4d 100644 --- a/Sources/OvUI/include/OvUI/Widgets/Layout/Columns.h +++ b/Sources/OvUI/include/OvUI/Widgets/Layout/Columns.h @@ -122,7 +122,7 @@ namespace OvUI::Widgets::Layout for (auto it = m_widgets.begin(); it != m_widgets.end(); ++it) { - if (dynamic_cast(it->first)) + if (it->first->fullWidth || dynamic_cast(it->first)) { drawTableSegment(segmentBegin, it, segmentIndex++); it->first->Draw(); From 3da4857f019da30cb11cf7fdf8364f6de87585f4 Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Mon, 31 Aug 2026 01:43:43 +0200 Subject: [PATCH 02/10] Add animation layers to the skinned mesh renderer 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 elements. --- .../ECS/Components/CSkinnedMeshRenderer.h | 186 ++-- .../ECS/Components/CSkinnedMeshRenderer.cpp | 799 +++++++++++++----- .../Lua/Bindings/LuaComponentsBindings.cpp | 50 +- 3 files changed, 745 insertions(+), 290 deletions(-) diff --git a/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h b/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h index 76a4dd883..83e405a38 100644 --- a/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h +++ b/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h @@ -6,6 +6,7 @@ #pragma once +#include #include #include #include @@ -22,8 +23,14 @@ namespace OvRendering::Resources { class Model; } namespace OvCore::ECS::Components { + constexpr uint32_t kMaxAnimationLayers = 4; + /** * Component responsible for skeletal animation playback and skinning data generation. + * Up to kMaxAnimationLayers animations can play simultaneously, each layer owning its own + * animation source model, animation, playback time, speed, loop mode and weight. + * Bones are blended per node, and only layers holding a track for a given node contribute to it, + * their weights being normalized against each other. Nodes no layer animates keep their bind pose. */ class CSkinnedMeshRenderer : public AComponent { @@ -55,46 +62,93 @@ namespace OvCore::ECS::Components bool HasSkinningData() const; /** - * Start animation playback + * Returns the number of active animation layers (always at least 1) + */ + uint32_t GetLayerCount() const; + + /** + * Returns the maximum number of animation layers a renderer can hold */ - void Play(); + uint32_t GetMaxLayerCount() const; /** - * Pause animation playback + * Appends a new animation layer, returning its index. + * Returns std::nullopt when the maximum layer count is already reached. */ - void Pause(); + std::optional AddLayer(); /** - * Stop animation playback and reset time to 0 + * Removes an animation layer, shifting the following layers down by one. + * Fails when the index is invalid or when only one layer remains. + * @param p_layer */ - void Stop(); + bool RemoveLayer(uint32_t p_layer); /** - * Returns true if playback is active + * Start animation playback on a layer + * @param p_layer */ - bool IsPlaying() const; + void Play(uint32_t p_layer = 0); /** - * Sets loop mode + * Pause animation playback on a layer + * @param p_layer + */ + void Pause(uint32_t p_layer = 0); + + /** + * Stop animation playback on a layer and reset its time to 0 + * @param p_layer + */ + void Stop(uint32_t p_layer = 0); + + /** + * Returns true if playback is active on a layer + * @param p_layer + */ + bool IsPlaying(uint32_t p_layer = 0) const; + + /** + * Sets loop mode on a layer + * @param p_value + * @param p_layer + */ + void SetLooping(bool p_value, uint32_t p_layer = 0); + + /** + * Returns true if loop mode is enabled on a layer + * @param p_layer + */ + bool IsLooping(uint32_t p_layer = 0) const; + + /** + * Set playback speed on a layer * @param p_value + * @param p_layer */ - void SetLooping(bool p_value); + void SetPlaybackSpeed(float p_value, uint32_t p_layer = 0); /** - * Returns true if loop mode is enabled + * Get playback speed of a layer + * @param p_layer */ - bool IsLooping() const; + float GetPlaybackSpeed(uint32_t p_layer = 0) const; /** - * Set playback speed + * Sets the blend weight of a layer, clamped to the [0,1] range + * Weights are normalized per node against the other layers animating that node, so a layer + * animating a node alone contributes fully until its weight reaches 0, where the node falls + * back to its bind pose * @param p_value + * @param p_layer */ - void SetPlaybackSpeed(float p_value); + void SetLayerWeight(float p_value, uint32_t p_layer = 0); /** - * Get playback speed + * Returns the blend weight of a layer + * @param p_layer */ - float GetPlaybackSpeed() const; + float GetLayerWeight(uint32_t p_layer = 0) const; /** * Returns the scale applied to mesh bounds during frustum culling @@ -110,64 +164,76 @@ namespace OvCore::ECS::Components void SetMeshBoundsScale(float p_scale); /** - * Sets the current playback time in seconds + * Sets the current playback time of a layer in seconds * @param p_timeSeconds + * @param p_layer */ - void SetTime(float p_timeSeconds); + void SetTime(float p_timeSeconds, uint32_t p_layer = 0); /** - * Returns the current playback time in seconds + * Returns the current playback time of a layer in seconds + * @param p_layer */ - float GetTime() const; + float GetTime(uint32_t p_layer = 0) const; /** - * Sets the external model used as animation source. Pass nullptr to use the rendered model animations. + * Sets the external model used as animation source by a layer. + * Pass nullptr to use the rendered model animations. * @param p_model + * @param p_layer */ - void SetAnimationSourceModel(OvRendering::Resources::Model* p_model); + void SetAnimationSourceModel(OvRendering::Resources::Model* p_model, uint32_t p_layer = 0); /** - * Returns the external animation source model, or nullptr when the rendered model is used + * Returns the external animation source model of a layer, or nullptr when the rendered model is used + * @param p_layer */ - OvRendering::Resources::Model* GetAnimationSourceModel() const; + OvRendering::Resources::Model* GetAnimationSourceModel(uint32_t p_layer = 0) const; /** - * Returns true if the current animation source can be applied to the rendered model skeleton + * Returns true if the animation source of a layer can be applied to the rendered model skeleton + * @param p_layer */ - bool IsAnimationSourceCompatible() const; + bool IsAnimationSourceCompatible(uint32_t p_layer = 0) const; /** - * Returns the number of available animations + * Returns the number of animations available to a layer + * @param p_layer */ - uint32_t GetAnimationCount() const; + uint32_t GetAnimationCount(uint32_t p_layer = 0) const; /** - * Returns the animation name at index (std::nullopt if index is invalid) + * Returns the animation name at index for a layer (std::nullopt if index is invalid) * @param p_index + * @param p_layer */ - std::optional GetAnimationName(uint32_t p_index) const; + std::optional GetAnimationName(uint32_t p_index, uint32_t p_layer = 0) const; /** - * Sets the active animation by index. Pass std::nullopt to clear and return to T-pose. + * Sets the active animation of a layer by index. Pass std::nullopt to clear the layer. * @param p_index + * @param p_layer */ - bool SetAnimation(std::optional p_index); + bool SetAnimation(std::optional p_index, uint32_t p_layer = 0); /** - * Sets the active animation by name + * Sets the active animation of a layer by name * @param p_name + * @param p_layer */ - bool SetAnimation(const std::string& p_name); + bool SetAnimation(const std::string& p_name, uint32_t p_layer = 0); /** - * Returns the active animation index, or std::nullopt if none is set + * Returns the active animation index of a layer, or std::nullopt if none is set + * @param p_layer */ - std::optional GetActiveAnimationIndex() const; + std::optional GetActiveAnimationIndex(uint32_t p_layer = 0) const; /** - * Returns the active animation name (empty if none) + * Returns the active animation name of a layer (std::nullopt if none) + * @param p_layer */ - std::optional GetActiveAnimationName() const; + std::optional GetActiveAnimationName(uint32_t p_layer = 0) const; /** * Returns the number of available bones @@ -262,38 +328,54 @@ namespace OvCore::ECS::Components virtual void OnInspector(OvUI::Internal::WidgetContainer& p_root) override; private: + struct AnimationLayer + { + OvRendering::Resources::Model* animationSourceModel = nullptr; + std::optional animationIndex = std::nullopt; + std::string deserializedAnimationName; + float timeTicks = 0.0f; + float speed = 1.0f; + float weight = 1.0f; + bool playing = true; + bool looping = true; + + // Runtime data, rebuilt whenever the model or the layer animation source changes. + // sourceNodeByTargetNode maps a rendered skeleton node to the source node driving it + // (-1 when unanimated), and is empty when the source is incompatible. + std::vector animationNames; + std::vector sourceNodeByTargetNode; + }; + bool HasCompatibleModel() const; - bool HasCompatibleAnimationSource() const; - const OvRendering::Resources::Model* GetAnimationModel() const; + bool IsLayerCompatible(const AnimationLayer& p_layer) const; + const OvRendering::Resources::Model* GetLayerAnimationModel(const AnimationLayer& p_layer) const; + AnimationLayer* FindLayer(uint32_t p_layer); + const AnimationLayer* FindLayer(uint32_t p_layer) const; void SyncWithModel(); void RebuildRuntimeData(); + void RebuildLayerRuntimeData(AnimationLayer& p_layer, std::vector& p_nodeMapScratch); + void ResolveLayerAnimation(AnimationLayer& p_layer); void EvaluatePose(); std::optional GetNodeIndexFromBoneIndex(uint32_t p_boneIndex) const; void RecomputeBoneMatricesFromLocalPose(); - float GetAnimationDurationSeconds() const; - void UpdatePlayback(float p_deltaTime); + float GetAnimationDurationSeconds(uint32_t p_layer) const; + void UpdatePlayback(AnimationLayer& p_layer, float p_deltaTime); + void BuildLayerWidgets(OvUI::Internal::WidgetContainer& p_container); private: const OvRendering::Resources::Model* m_model = nullptr; - OvRendering::Resources::Model* m_animationSourceModel = nullptr; - OvTools::Eventing::Event<> m_animationSourceChangedEvent; + std::array, kMaxAnimationLayers> m_animationSourceChangedEvents; - bool m_playing = true; - bool m_looping = true; - float m_playbackSpeed = 1.0f; float m_meshBoundsScale = 1.5f; float m_poseEvaluationRate = 60.0f; float m_poseEvaluationAccumulator = 0.0f; - float m_currentTimeTicks = 0.0f; - std::optional m_animationIndex = std::nullopt; - std::string m_deserializedAnimationName; + std::array m_layers; + uint32_t m_layerCount = 1; uint64_t m_poseVersion = 0; bool m_manualPoseOverride = false; - std::vector m_animationNames; - std::vector m_animationNodeMap; std::vector m_localPose; std::vector m_globalPose; std::vector m_boneMatrices; diff --git a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp index db3dfcced..accbb41d9 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -5,6 +5,7 @@ */ #include +#include #include #include @@ -21,11 +22,34 @@ #include #include #include +#include +#include +#include +#include +#include #include #include namespace { + constexpr float kMinimumLayerWeight = 0.0001f; + + struct ActiveLayerSample + { + const OvRendering::Animation::SkeletalAnimation* animation = nullptr; + float sampleTime = 0.0f; + float duration = 0.0f; + float weight = 1.0f; + bool looping = true; + const std::vector* sourceNodeByTargetNode = nullptr; + }; + + // std::clamp propagates NaN, so non-finite weights are rejected before clamping + float ClampLayerWeight(float p_value) + { + return std::isfinite(p_value) ? std::clamp(p_value, 0.0f, 1.0f) : 0.0f; + } + float WrapTime(float p_value, float p_duration) { if (p_duration <= 0.0f) @@ -303,10 +327,13 @@ namespace OvCore::ECS::Components::CSkinnedMeshRenderer::CSkinnedMeshRenderer(ECS::Actor& p_owner) : AComponent(p_owner) { - m_animationSourceChangedEvent += [this]() + for (auto& animationSourceChangedEvent : m_animationSourceChangedEvents) { - RebuildRuntimeData(); - }; + animationSourceChangedEvent += [this]() + { + RebuildRuntimeData(); + }; + } NotifyModelChanged(); } @@ -329,55 +356,144 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::NotifyModelChanged() bool OvCore::ECS::Components::CSkinnedMeshRenderer::HasSkinningData() const { + bool hasAnimatedLayer = false; + for (uint32_t layerIndex = 0; layerIndex < m_layerCount && !hasAnimatedLayer; ++layerIndex) + { + const auto& layer = m_layers[layerIndex]; + hasAnimatedLayer = layer.animationIndex.has_value() && IsLayerCompatible(layer); + } + return HasCompatibleModel() && !m_boneMatrices.empty() && - ((m_animationIndex.has_value() && HasCompatibleAnimationSource()) || m_manualPoseOverride); + (hasAnimatedLayer || m_manualPoseOverride); } -void OvCore::ECS::Components::CSkinnedMeshRenderer::Play() +uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::GetLayerCount() const { - m_playing = true; + return m_layerCount; +} + +uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::GetMaxLayerCount() const +{ + return kMaxAnimationLayers; +} + +std::optional OvCore::ECS::Components::CSkinnedMeshRenderer::AddLayer() +{ + if (m_layerCount >= kMaxAnimationLayers) + { + return std::nullopt; + } + + const uint32_t addedLayer = m_layerCount++; + m_layers[addedLayer] = AnimationLayer{}; + RebuildRuntimeData(); + return addedLayer; +} + +bool OvCore::ECS::Components::CSkinnedMeshRenderer::RemoveLayer(uint32_t p_layer) +{ + if (p_layer >= m_layerCount || m_layerCount <= 1) + { + return false; + } + + std::rotate(m_layers.begin() + p_layer, m_layers.begin() + p_layer + 1, m_layers.begin() + m_layerCount); + m_layers[--m_layerCount] = AnimationLayer{}; + RebuildRuntimeData(); + return true; +} + +void OvCore::ECS::Components::CSkinnedMeshRenderer::Play(uint32_t p_layer) +{ + const auto layer = FindLayer(p_layer); + if (!layer) + { + return; + } + + layer->playing = true; m_poseEvaluationAccumulator = 0.0f; } -void OvCore::ECS::Components::CSkinnedMeshRenderer::Pause() +void OvCore::ECS::Components::CSkinnedMeshRenderer::Pause(uint32_t p_layer) { - m_playing = false; + const auto layer = FindLayer(p_layer); + if (!layer) + { + return; + } + + layer->playing = false; m_poseEvaluationAccumulator = 0.0f; } -void OvCore::ECS::Components::CSkinnedMeshRenderer::Stop() +void OvCore::ECS::Components::CSkinnedMeshRenderer::Stop(uint32_t p_layer) { - m_playing = false; - m_currentTimeTicks = 0.0f; + const auto layer = FindLayer(p_layer); + if (!layer) + { + return; + } + + layer->playing = false; + layer->timeTicks = 0.0f; m_poseEvaluationAccumulator = 0.0f; m_manualPoseOverride = false; EvaluatePose(); } -bool OvCore::ECS::Components::CSkinnedMeshRenderer::IsPlaying() const +bool OvCore::ECS::Components::CSkinnedMeshRenderer::IsPlaying(uint32_t p_layer) const { - return m_playing; + const auto layer = FindLayer(p_layer); + return layer && layer->playing; } -void OvCore::ECS::Components::CSkinnedMeshRenderer::SetLooping(bool p_value) +void OvCore::ECS::Components::CSkinnedMeshRenderer::SetLooping(bool p_value, uint32_t p_layer) { - m_looping = p_value; + if (const auto layer = FindLayer(p_layer)) + { + layer->looping = p_value; + } } -bool OvCore::ECS::Components::CSkinnedMeshRenderer::IsLooping() const +bool OvCore::ECS::Components::CSkinnedMeshRenderer::IsLooping(uint32_t p_layer) const { - return m_looping; + const auto layer = FindLayer(p_layer); + return layer && layer->looping; } -void OvCore::ECS::Components::CSkinnedMeshRenderer::SetPlaybackSpeed(float p_value) +void OvCore::ECS::Components::CSkinnedMeshRenderer::SetPlaybackSpeed(float p_value, uint32_t p_layer) { - m_playbackSpeed = p_value; + if (const auto layer = FindLayer(p_layer)) + { + layer->speed = p_value; + } } -float OvCore::ECS::Components::CSkinnedMeshRenderer::GetPlaybackSpeed() const +float OvCore::ECS::Components::CSkinnedMeshRenderer::GetPlaybackSpeed(uint32_t p_layer) const { - return m_playbackSpeed; + const auto layer = FindLayer(p_layer); + return layer ? layer->speed : 0.0f; +} + +void OvCore::ECS::Components::CSkinnedMeshRenderer::SetLayerWeight(float p_value, uint32_t p_layer) +{ + const auto layer = FindLayer(p_layer); + if (!layer) + { + return; + } + + layer->weight = ClampLayerWeight(p_value); + m_poseEvaluationAccumulator = 0.0f; + EvaluatePose(); +} + +float OvCore::ECS::Components::CSkinnedMeshRenderer::GetLayerWeight(uint32_t p_layer) const +{ + const auto layer = FindLayer(p_layer); + return layer ? layer->weight : 0.0f; } float OvCore::ECS::Components::CSkinnedMeshRenderer::GetMeshBoundsScale() const @@ -390,138 +506,140 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::SetMeshBoundsScale(float p_s m_meshBoundsScale = std::max(1.0f, p_scale); } -void OvCore::ECS::Components::CSkinnedMeshRenderer::SetTime(float p_timeSeconds) +void OvCore::ECS::Components::CSkinnedMeshRenderer::SetTime(float p_timeSeconds, uint32_t p_layer) { - if (!HasCompatibleAnimationSource() || !m_animationIndex.has_value()) + const auto layer = FindLayer(p_layer); + if (!layer || !IsLayerCompatible(*layer) || !layer->animationIndex.has_value()) { return; } - const auto& animation = GetAnimationModel()->GetAnimations().at(*m_animationIndex); + const auto& animation = GetLayerAnimationModel(*layer)->GetAnimations().at(*layer->animationIndex); const float ticksPerSecond = animation.GetEffectiveTicksPerSecond(); - m_currentTimeTicks = p_timeSeconds * ticksPerSecond; - if (m_looping) + layer->timeTicks = p_timeSeconds * ticksPerSecond; + if (layer->looping) { - m_currentTimeTicks = WrapTime(m_currentTimeTicks, animation.duration); + layer->timeTicks = WrapTime(layer->timeTicks, animation.duration); } else { - m_currentTimeTicks = std::clamp(m_currentTimeTicks, 0.0f, animation.duration); + layer->timeTicks = std::clamp(layer->timeTicks, 0.0f, animation.duration); } m_poseEvaluationAccumulator = 0.0f; EvaluatePose(); } -float OvCore::ECS::Components::CSkinnedMeshRenderer::GetTime() const +float OvCore::ECS::Components::CSkinnedMeshRenderer::GetTime(uint32_t p_layer) const { - if (!HasCompatibleAnimationSource() || !m_animationIndex.has_value()) + const auto layer = FindLayer(p_layer); + if (!layer || !IsLayerCompatible(*layer) || !layer->animationIndex.has_value()) { return 0.0f; } - const auto& animation = GetAnimationModel()->GetAnimations().at(*m_animationIndex); + const auto& animation = GetLayerAnimationModel(*layer)->GetAnimations().at(*layer->animationIndex); const float ticksPerSecond = animation.GetEffectiveTicksPerSecond(); - return m_currentTimeTicks / ticksPerSecond; + return layer->timeTicks / ticksPerSecond; } -void OvCore::ECS::Components::CSkinnedMeshRenderer::SetAnimationSourceModel(OvRendering::Resources::Model* p_model) +void OvCore::ECS::Components::CSkinnedMeshRenderer::SetAnimationSourceModel(OvRendering::Resources::Model* p_model, uint32_t p_layer) { - if (m_animationSourceModel == p_model) + const auto layer = FindLayer(p_layer); + if (!layer || layer->animationSourceModel == p_model) { return; } - m_animationSourceModel = p_model; + layer->animationSourceModel = p_model; RebuildRuntimeData(); } -OvRendering::Resources::Model* OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationSourceModel() const +OvRendering::Resources::Model* OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationSourceModel(uint32_t p_layer) const { - return m_animationSourceModel; + const auto layer = FindLayer(p_layer); + return layer ? layer->animationSourceModel : nullptr; } -bool OvCore::ECS::Components::CSkinnedMeshRenderer::IsAnimationSourceCompatible() const +bool OvCore::ECS::Components::CSkinnedMeshRenderer::IsAnimationSourceCompatible(uint32_t p_layer) const { - return HasCompatibleAnimationSource(); + const auto layer = FindLayer(p_layer); + return layer && IsLayerCompatible(*layer); } -uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationCount() const +uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationCount(uint32_t p_layer) const { - return static_cast(m_animationNames.size()); + const auto layer = FindLayer(p_layer); + return layer ? static_cast(layer->animationNames.size()) : 0; } -std::optional OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationName(uint32_t p_index) const +std::optional OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationName(uint32_t p_index, uint32_t p_layer) const { - if (p_index < m_animationNames.size()) + const auto layer = FindLayer(p_layer); + if (!layer || p_index >= layer->animationNames.size()) { - return m_animationNames[p_index]; + return std::nullopt; } - return std::nullopt; + return layer->animationNames[p_index]; } -bool OvCore::ECS::Components::CSkinnedMeshRenderer::SetAnimation(std::optional p_index) +bool OvCore::ECS::Components::CSkinnedMeshRenderer::SetAnimation(std::optional p_index, uint32_t p_layer) { - if (!p_index.has_value()) + const auto layer = FindLayer(p_layer); + if (!layer) { - m_animationIndex = std::nullopt; - m_currentTimeTicks = 0.0f; - m_poseEvaluationAccumulator = 0.0f; - m_manualPoseOverride = false; - EvaluatePose(); - return true; + return false; } - if (!HasCompatibleAnimationSource() || *p_index >= GetAnimationModel()->GetAnimations().size()) + if (p_index.has_value() && *p_index >= layer->animationNames.size()) { return false; } - m_animationIndex = p_index; - m_currentTimeTicks = 0.0f; + layer->animationIndex = p_index; + layer->timeTicks = 0.0f; m_poseEvaluationAccumulator = 0.0f; m_manualPoseOverride = false; EvaluatePose(); return true; } -bool OvCore::ECS::Components::CSkinnedMeshRenderer::SetAnimation(const std::string& p_name) +bool OvCore::ECS::Components::CSkinnedMeshRenderer::SetAnimation(const std::string& p_name, uint32_t p_layer) { - if (!HasCompatibleAnimationSource()) + const auto layer = FindLayer(p_layer); + if (!layer) { return false; } - const auto& animations = GetAnimationModel()->GetAnimations(); - - const auto found = std::find_if(animations.begin(), animations.end(), [&p_name](const auto& p_animation) - { - return p_animation.name == p_name; - }); + const auto& animationNames = layer->animationNames; + const auto found = std::find(animationNames.begin(), animationNames.end(), p_name); - if (found == animations.end()) + if (found == animationNames.end()) { return false; } - return SetAnimation(static_cast(std::distance(animations.begin(), found))); + return SetAnimation(static_cast(std::distance(animationNames.begin(), found)), p_layer); } -std::optional OvCore::ECS::Components::CSkinnedMeshRenderer::GetActiveAnimationIndex() const +std::optional OvCore::ECS::Components::CSkinnedMeshRenderer::GetActiveAnimationIndex(uint32_t p_layer) const { - return m_animationIndex; + const auto layer = FindLayer(p_layer); + return layer ? layer->animationIndex : std::nullopt; } -std::optional OvCore::ECS::Components::CSkinnedMeshRenderer::GetActiveAnimationName() const +std::optional OvCore::ECS::Components::CSkinnedMeshRenderer::GetActiveAnimationName(uint32_t p_layer) const { - if (!m_animationIndex.has_value()) + const auto animationIndex = GetActiveAnimationIndex(p_layer); + if (!animationIndex.has_value()) { return std::nullopt; } - return GetAnimationName(m_animationIndex.value()); + return GetAnimationName(*animationIndex, p_layer); } uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::GetBoneCount() const @@ -684,18 +802,29 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnUpdate(float p_deltaTime) SyncWithModel(); - if (!HasCompatibleModel() || !m_playing) + if (!HasCompatibleModel()) { return; } - const float previousTimeTicks = m_currentTimeTicks; - const bool wasPlaying = m_playing; + bool timeChanged = false; + bool playbackStateChanged = false; + + for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) + { + auto& layer = m_layers[layerIndex]; + if (!layer.playing) + { + continue; + } + + const float previousTimeTicks = layer.timeTicks; - UpdatePlayback(p_deltaTime); + UpdatePlayback(layer, p_deltaTime); - const bool timeChanged = std::abs(m_currentTimeTicks - previousTimeTicks) > std::numeric_limits::epsilon(); - const bool playbackStateChanged = wasPlaying != m_playing; + timeChanged = timeChanged || std::abs(layer.timeTicks - previousTimeTicks) > std::numeric_limits::epsilon(); + playbackStateChanged = playbackStateChanged || !layer.playing; + } if (timeChanged || playbackStateChanged) { @@ -724,26 +853,58 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnUpdate(float p_deltaTime) void OvCore::ECS::Components::CSkinnedMeshRenderer::OnSerialize(tinyxml2::XMLDocument& p_doc, tinyxml2::XMLNode* p_node) { - OvCore::Helpers::Serializer::SerializeBoolean(p_doc, p_node, "playing", m_playing); - OvCore::Helpers::Serializer::SerializeBoolean(p_doc, p_node, "looping", m_looping); - OvCore::Helpers::Serializer::SerializeFloat(p_doc, p_node, "playback_speed", m_playbackSpeed); OvCore::Helpers::Serializer::SerializeFloat(p_doc, p_node, "mesh_bounds_scale", m_meshBoundsScale); OvCore::Helpers::Serializer::SerializeFloat(p_doc, p_node, "pose_eval_rate", m_poseEvaluationRate); - OvCore::Helpers::Serializer::SerializeFloat(p_doc, p_node, "time_ticks", m_currentTimeTicks); - OvCore::Helpers::Serializer::SerializeModel(p_doc, p_node, "animation_source", m_animationSourceModel); - OvCore::Helpers::Serializer::SerializeString(p_doc, p_node, "animation", GetActiveAnimationName().value_or(std::string{})); + + tinyxml2::XMLNode* layersNode = p_doc.NewElement("layers"); + p_node->InsertEndChild(layersNode); + + for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) + { + tinyxml2::XMLNode* layerNode = p_doc.NewElement("layer"); + layersNode->InsertEndChild(layerNode); + + const auto& layer = m_layers[layerIndex]; + OvCore::Helpers::Serializer::SerializeModel(p_doc, layerNode, "animation_source", layer.animationSourceModel); + OvCore::Helpers::Serializer::SerializeString(p_doc, layerNode, "animation", GetActiveAnimationName(layerIndex).value_or(std::string{})); + OvCore::Helpers::Serializer::SerializeFloat(p_doc, layerNode, "weight", layer.weight); + OvCore::Helpers::Serializer::SerializeBoolean(p_doc, layerNode, "playing", layer.playing); + OvCore::Helpers::Serializer::SerializeBoolean(p_doc, layerNode, "looping", layer.looping); + OvCore::Helpers::Serializer::SerializeFloat(p_doc, layerNode, "playback_speed", layer.speed); + OvCore::Helpers::Serializer::SerializeFloat(p_doc, layerNode, "time_ticks", layer.timeTicks); + } } void OvCore::ECS::Components::CSkinnedMeshRenderer::OnDeserialize(tinyxml2::XMLDocument& p_doc, tinyxml2::XMLNode* p_node) { - OvCore::Helpers::Serializer::DeserializeBoolean(p_doc, p_node, "playing", m_playing); - OvCore::Helpers::Serializer::DeserializeBoolean(p_doc, p_node, "looping", m_looping); - OvCore::Helpers::Serializer::DeserializeFloat(p_doc, p_node, "playback_speed", m_playbackSpeed); OvCore::Helpers::Serializer::DeserializeFloat(p_doc, p_node, "mesh_bounds_scale", m_meshBoundsScale); OvCore::Helpers::Serializer::DeserializeFloat(p_doc, p_node, "pose_eval_rate", m_poseEvaluationRate); - OvCore::Helpers::Serializer::DeserializeFloat(p_doc, p_node, "time_ticks", m_currentTimeTicks); - OvCore::Helpers::Serializer::DeserializeModel(p_doc, p_node, "animation_source", m_animationSourceModel); - OvCore::Helpers::Serializer::DeserializeString(p_doc, p_node, "animation", m_deserializedAnimationName); + + if (tinyxml2::XMLNode* layersRoot = p_node->FirstChildElement("layers")) + { + tinyxml2::XMLElement* currentLayer = layersRoot->FirstChildElement("layer"); + uint32_t layerIndex = 0; + + while (currentLayer && layerIndex < kMaxAnimationLayers) + { + auto& layer = m_layers[layerIndex]; + OvCore::Helpers::Serializer::DeserializeModel(p_doc, currentLayer, "animation_source", layer.animationSourceModel); + OvCore::Helpers::Serializer::DeserializeString(p_doc, currentLayer, "animation", layer.deserializedAnimationName); + OvCore::Helpers::Serializer::DeserializeFloat(p_doc, currentLayer, "weight", layer.weight); + OvCore::Helpers::Serializer::DeserializeBoolean(p_doc, currentLayer, "playing", layer.playing); + OvCore::Helpers::Serializer::DeserializeBoolean(p_doc, currentLayer, "looping", layer.looping); + OvCore::Helpers::Serializer::DeserializeFloat(p_doc, currentLayer, "playback_speed", layer.speed); + OvCore::Helpers::Serializer::DeserializeFloat(p_doc, currentLayer, "time_ticks", layer.timeTicks); + + layer.weight = ClampLayerWeight(layer.weight); + + currentLayer = currentLayer->NextSiblingElement("layer"); + ++layerIndex; + } + + m_layerCount = std::max(layerIndex, 1u); + } + SetMeshBoundsScale(m_meshBoundsScale); m_poseEvaluationRate = std::max(0.0f, m_poseEvaluationRate); m_poseEvaluationAccumulator = 0.0f; @@ -757,57 +918,161 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnInspector(OvUI::Internal:: using namespace OvCore::Helpers; - GUIDrawer::DrawBoolean(p_root, "Playing", m_playing); - GUIDrawer::DrawBoolean(p_root, "Looping", m_looping); - GUIDrawer::DrawScalar(p_root, "Playback Speed", m_playbackSpeed, 0.01f, -10.0f, 10.0f); GUIDrawer::DrawScalar(p_root, "Mesh Bounds Scale", m_meshBoundsScale, 0.05f, 1.0f, 10.0f); GUIDrawer::DrawScalar(p_root, "Pose Eval Rate", m_poseEvaluationRate, 1.0f, 0.0f, 240.0f); m_poseEvaluationRate = std::max(0.0f, m_poseEvaluationRate); - GUIDrawer::DrawScalar( - p_root, - "Time (Seconds)", - [this]() { return GetTime(); }, - [this](float p_value) { SetTime(p_value); }, - 0.01f, - 0.0f, - std::max(GetAnimationDurationSeconds(), 3600.0f) - ); - - GUIDrawer::DrawMesh(p_root, "Animation Source", m_animationSourceModel, &m_animationSourceChangedEvent); - GUIDrawer::CreateTitle(p_root, "Active Animation"); - const int currentAnimIndex = GetActiveAnimationIndex().has_value() ? static_cast(*GetActiveAnimationIndex()) : -1; - auto& animationChoice = p_root.CreateWidget(currentAnimIndex); - - auto& animDispatcher = animationChoice.AddPlugin>(); - animDispatcher.RegisterGatherer([this, &animationChoice] + auto& modelDiagnostic = p_root.CreateWidget(); + modelDiagnostic.AddPlugin>().RegisterGatherer([this] { - animationChoice.choices.clear(); - animationChoice.choices.emplace(-1, ""); + return HasCompatibleModel() ? std::string{} : std::string{ "No skinned model assigned" }; + }); - for (size_t i = 0; i < m_animationNames.size(); ++i) - { - animationChoice.choices.emplace(static_cast(i), m_animationNames[i]); - } + // Layers live in their own full-width container so they can be rebuilt in place when one is + // added or removed, without disturbing the settings above + auto& layersRoot = p_root.CreateWidget(); + layersRoot.fullWidth = true; + BuildLayerWidgets(layersRoot); +} - return GetActiveAnimationIndex().has_value() ? static_cast(*GetActiveAnimationIndex()) : -1; - }); - animDispatcher.RegisterProvider([this](int p_choice) - { - SetAnimation(p_choice >= 0 ? std::make_optional(static_cast(p_choice)) : std::nullopt); - }); +void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Internal::WidgetContainer& p_container) +{ + using namespace OvCore::Helpers; - if (!HasCompatibleModel()) + // Widgets are destroyed rather than removed, so this stays safe when called from the click + // handler of one of the widgets being replaced + for (auto& widget : p_container.GetWidgets()) { - p_root.CreateWidget("No skinned model assigned"); + widget.first->Destroy(); } - else if (m_animationSourceModel && !HasCompatibleAnimationSource()) + + for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) { - p_root.CreateWidget("Animation source skeleton is not compatible with model"); + auto& layerGroup = p_container.CreateWidget("Layer " + std::to_string(layerIndex)); + layerGroup.closable = m_layerCount > 1; + layerGroup.CloseEvent += [this, &p_container, layerIndex] + { + RemoveLayer(layerIndex); + BuildLayerWidgets(p_container); + }; + + auto& columns = layerGroup.CreateWidget>(); + columns.SetID("skinned_layer_" + std::to_string(layerIndex)); + columns.widths[0] = 200 * OVUI_SCALE; + + GUIDrawer::DrawMesh(columns, "Animation Source", m_layers[layerIndex].animationSourceModel, &m_animationSourceChangedEvents[layerIndex]); + + GUIDrawer::CreateTitle(columns, "Animation"); + const auto activeAnimationIndex = GetActiveAnimationIndex(layerIndex); + auto& animationChoice = columns.CreateWidget( + activeAnimationIndex.has_value() ? static_cast(*activeAnimationIndex) : -1 + ); + + auto& animDispatcher = animationChoice.AddPlugin>(); + animDispatcher.RegisterGatherer([this, &animationChoice, layerIndex] + { + animationChoice.choices.clear(); + animationChoice.choices.emplace(-1, ""); + + for (uint32_t i = 0; i < GetAnimationCount(layerIndex); ++i) + { + animationChoice.choices.emplace(static_cast(i), GetAnimationName(i, layerIndex).value_or(std::string{})); + } + + const auto gatheredIndex = GetActiveAnimationIndex(layerIndex); + return gatheredIndex.has_value() ? static_cast(*gatheredIndex) : -1; + }); + animDispatcher.RegisterProvider([this, layerIndex](int p_choice) + { + SetAnimation(p_choice >= 0 ? std::make_optional(static_cast(p_choice)) : std::nullopt, layerIndex); + }); + + GUIDrawer::DrawScalar( + columns, + "Weight", + [this, layerIndex]() { return GetLayerWeight(layerIndex); }, + [this, layerIndex](float p_value) { SetLayerWeight(p_value, layerIndex); }, + 0.01f, + 0.0f, + 1.0f + ); + + GUIDrawer::DrawBoolean( + columns, + "Playing", + [this, layerIndex]() { return IsPlaying(layerIndex); }, + [this, layerIndex](bool p_value) + { + if (p_value) + { + Play(layerIndex); + } + else + { + Pause(layerIndex); + } + } + ); + + GUIDrawer::DrawBoolean( + columns, + "Looping", + [this, layerIndex]() { return IsLooping(layerIndex); }, + [this, layerIndex](bool p_value) { SetLooping(p_value, layerIndex); } + ); + + GUIDrawer::DrawScalar( + columns, + "Playback Speed", + [this, layerIndex]() { return GetPlaybackSpeed(layerIndex); }, + [this, layerIndex](float p_value) { SetPlaybackSpeed(p_value, layerIndex); }, + 0.01f, + -10.0f, + 10.0f + ); + + GUIDrawer::DrawScalar( + columns, + "Time (Seconds)", + [this, layerIndex]() { return GetTime(layerIndex); }, + [this, layerIndex](float p_value) { SetTime(p_value, layerIndex); }, + 0.01f, + 0.0f, + std::max(GetAnimationDurationSeconds(layerIndex), 3600.0f) + ); + + // Gathered every frame, so assigning an incompatible source reports it without a panel refresh + auto& diagnostic = layerGroup.CreateWidget(); + diagnostic.AddPlugin>().RegisterGatherer([this, layerIndex] + { + const auto layer = FindLayer(layerIndex); + if (!layer) + { + return std::string{}; + } + + if (layer->animationSourceModel && !IsLayerCompatible(*layer)) + { + return std::string{ "Animation source skeleton is not compatible with model" }; + } + + if (layer->animationNames.empty()) + { + return std::string{ layer->animationSourceModel ? "Animation source has no animation clips" : "Model has no animation clips" }; + } + + return std::string{}; + }); } - else if (m_animationNames.empty()) + + if (m_layerCount < kMaxAnimationLayers) { - p_root.CreateWidget(m_animationSourceModel ? "Animation source has no animation clips" : "Model has no animation clips"); + auto& addLayerButton = p_container.CreateWidget("Add Layer"); + addLayerButton.ClickedEvent += [this, &p_container] + { + AddLayer(); + BuildLayerWidgets(p_container); + }; } } @@ -816,20 +1081,24 @@ bool OvCore::ECS::Components::CSkinnedMeshRenderer::HasCompatibleModel() const return m_model && m_model->IsSkinned() && m_model->GetSkeleton().has_value(); } -bool OvCore::ECS::Components::CSkinnedMeshRenderer::HasCompatibleAnimationSource() const +bool OvCore::ECS::Components::CSkinnedMeshRenderer::IsLayerCompatible(const AnimationLayer& p_layer) const { - if (!HasCompatibleModel()) - { - return false; - } + return HasCompatibleModel() && !p_layer.sourceNodeByTargetNode.empty(); +} - const auto animationModel = GetAnimationModel(); - return animationModel && animationModel->GetSkeleton().has_value() && !m_animationNodeMap.empty(); +const OvRendering::Resources::Model* OvCore::ECS::Components::CSkinnedMeshRenderer::GetLayerAnimationModel(const AnimationLayer& p_layer) const +{ + return p_layer.animationSourceModel ? p_layer.animationSourceModel : m_model; } -const OvRendering::Resources::Model* OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationModel() const +OvCore::ECS::Components::CSkinnedMeshRenderer::AnimationLayer* OvCore::ECS::Components::CSkinnedMeshRenderer::FindLayer(uint32_t p_layer) { - return m_animationSourceModel ? m_animationSourceModel : m_model; + return p_layer < m_layerCount ? &m_layers[p_layer] : nullptr; +} + +const OvCore::ECS::Components::CSkinnedMeshRenderer::AnimationLayer* OvCore::ECS::Components::CSkinnedMeshRenderer::FindLayer(uint32_t p_layer) const +{ + return p_layer < m_layerCount ? &m_layers[p_layer] : nullptr; } void OvCore::ECS::Components::CSkinnedMeshRenderer::SyncWithModel() @@ -848,89 +1117,125 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::SyncWithModel() void OvCore::ECS::Components::CSkinnedMeshRenderer::RebuildRuntimeData() { - const float preservedTimeTicks = m_currentTimeTicks; - const std::optional preservedAnimationIndex = m_animationIndex; - const std::string requestedAnimationName = m_deserializedAnimationName; - - m_animationNames.clear(); - m_animationNodeMap.clear(); m_localPose.clear(); m_globalPose.clear(); m_boneMatrices.clear(); m_boneMatricesTransposed.clear(); - m_animationIndex = std::nullopt; - m_currentTimeTicks = preservedTimeTicks; m_poseEvaluationAccumulator = 0.0f; m_manualPoseOverride = false; + for (auto& layer : m_layers) + { + layer.animationNames.clear(); + layer.sourceNodeByTargetNode.clear(); + } + if (!HasCompatibleModel()) { + // Pending deserialized names are kept, so they can still be resolved once a model shows up + for (auto& layer : m_layers) + { + layer.animationIndex = std::nullopt; + } + return; } const auto& skeleton = m_model->GetSkeleton().value(); - const auto animationModel = GetAnimationModel(); m_localPose.resize(skeleton.nodes.size(), OvMaths::FMatrix4::Identity); m_globalPose.resize(skeleton.nodes.size(), OvMaths::FMatrix4::Identity); m_boneMatrices.resize(skeleton.bones.size(), OvMaths::FMatrix4::Identity); m_boneMatricesTransposed.resize(skeleton.bones.size(), OvMaths::FMatrix4::Identity); + std::vector nodeMapScratch; + + for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) + { + RebuildLayerRuntimeData(m_layers[layerIndex], nodeMapScratch); + ResolveLayerAnimation(m_layers[layerIndex]); + } + + EvaluatePose(); +} + +void OvCore::ECS::Components::CSkinnedMeshRenderer::RebuildLayerRuntimeData(AnimationLayer& p_layer, std::vector& p_nodeMapScratch) +{ + const auto& skeleton = m_model->GetSkeleton().value(); + const auto animationModel = GetLayerAnimationModel(p_layer); + if ( - animationModel && - animationModel->GetSkeleton().has_value() && - BuildAnimationNodeMap(skeleton, animationModel->GetSkeleton().value(), m_animationNodeMap) + !animationModel || + !animationModel->GetSkeleton().has_value() || + !BuildAnimationNodeMap(skeleton, animationModel->GetSkeleton().value(), p_nodeMapScratch) ) { - const auto& animations = animationModel->GetAnimations(); + if (p_layer.animationSourceModel) + { + OVLOG_WARNING("SkinnedMeshRenderer: Animation source model '" + p_layer.animationSourceModel->path + "' is not compatible with target model '" + m_model->path + "'."); + } + + return; + } - m_animationNames.reserve(animations.size()); - for (const auto& animation : animations) + p_layer.sourceNodeByTargetNode.assign(skeleton.nodes.size(), -1); + for (size_t sourceNodeIndex = 0; sourceNodeIndex < p_nodeMapScratch.size(); ++sourceNodeIndex) + { + const int32_t targetNodeIndex = p_nodeMapScratch[sourceNodeIndex]; + if (targetNodeIndex >= 0 && static_cast(targetNodeIndex) < p_layer.sourceNodeByTargetNode.size()) { - m_animationNames.push_back(animation.name); + p_layer.sourceNodeByTargetNode[targetNodeIndex] = static_cast(sourceNodeIndex); } } - else if (m_animationSourceModel) + + const auto& animations = animationModel->GetAnimations(); + + p_layer.animationNames.reserve(animations.size()); + for (const auto& animation : animations) { - OVLOG_WARNING("SkinnedMeshRenderer: Animation source model '" + m_animationSourceModel->path + "' is not compatible with target model '" + m_model->path + "'."); + p_layer.animationNames.push_back(animation.name); } +} + +void OvCore::ECS::Components::CSkinnedMeshRenderer::ResolveLayerAnimation(AnimationLayer& p_layer) +{ + const std::optional preservedAnimationIndex = p_layer.animationIndex; + const float preservedTimeTicks = p_layer.timeTicks; + const std::string requestedAnimationName = p_layer.deserializedAnimationName; + + p_layer.animationIndex = std::nullopt; + p_layer.deserializedAnimationName.clear(); - const auto& animations = GetAnimationModel()->GetAnimations(); + const auto& animationNames = p_layer.animationNames; - if (!m_animationNames.empty()) + if (!animationNames.empty()) { if (!requestedAnimationName.empty()) { - const auto found = std::find(m_animationNames.begin(), m_animationNames.end(), requestedAnimationName); - m_animationIndex = found != m_animationNames.end() ? - std::optional{ static_cast(std::distance(m_animationNames.begin(), found)) } : + const auto found = std::find(animationNames.begin(), animationNames.end(), requestedAnimationName); + p_layer.animationIndex = found != animationNames.end() ? + std::optional{ static_cast(std::distance(animationNames.begin(), found)) } : std::optional{ 0 }; } - else if (preservedAnimationIndex.has_value() && *preservedAnimationIndex < m_animationNames.size()) + else if (preservedAnimationIndex.has_value() && *preservedAnimationIndex < animationNames.size()) { - m_animationIndex = *preservedAnimationIndex; + p_layer.animationIndex = *preservedAnimationIndex; } } - if (HasCompatibleAnimationSource() && m_animationIndex.has_value() && *m_animationIndex < animations.size()) + const auto& animations = GetLayerAnimationModel(p_layer)->GetAnimations(); + + if (IsLayerCompatible(p_layer) && p_layer.animationIndex.has_value() && *p_layer.animationIndex < animations.size()) { - const auto& animation = animations.at(*m_animationIndex); - if (m_looping) - { - m_currentTimeTicks = WrapTime(m_currentTimeTicks, animation.duration); - } - else - { - m_currentTimeTicks = std::clamp(m_currentTimeTicks, 0.0f, animation.duration); - } + const auto& animation = animations.at(*p_layer.animationIndex); + p_layer.timeTicks = p_layer.looping ? + WrapTime(preservedTimeTicks, animation.duration) : + std::clamp(preservedTimeTicks, 0.0f, animation.duration); } else { - m_currentTimeTicks = 0.0f; + p_layer.timeTicks = 0.0f; } - - m_deserializedAnimationName.clear(); - EvaluatePose(); } void OvCore::ECS::Components::CSkinnedMeshRenderer::EvaluatePose() @@ -942,66 +1247,115 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::EvaluatePose() const auto& skeleton = m_model->GetSkeleton().value(); - for (size_t nodeIndex = 0; nodeIndex < skeleton.nodes.size(); ++nodeIndex) - { - m_localPose[nodeIndex] = skeleton.nodes[nodeIndex].localBindTransform; - } + std::array activeLayers; + uint32_t activeLayerCount = 0; - const auto animationModel = GetAnimationModel(); - if (HasCompatibleAnimationSource() && m_animationIndex.has_value() && *m_animationIndex < animationModel->GetAnimations().size()) + for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) { - const auto& animation = animationModel->GetAnimations().at(*m_animationIndex); - const auto& animationSkeleton = animationModel->GetSkeleton().value(); + const auto& layer = m_layers[layerIndex]; + + if (layer.sourceNodeByTargetNode.size() != skeleton.nodes.size()) + { + continue; + } + + if (!layer.animationIndex.has_value() || !(layer.weight > kMinimumLayerWeight)) + { + continue; + } + + const auto& animations = GetLayerAnimationModel(layer)->GetAnimations(); + if (*layer.animationIndex >= animations.size()) + { + continue; + } + + const auto& animation = animations.at(*layer.animationIndex); const float duration = std::max(animation.duration, 0.0f); const float sampleTime = duration > 0.0f ? - (m_looping ? WrapTime(m_currentTimeTicks, duration) : std::clamp(m_currentTimeTicks, 0.0f, duration)) : + (layer.looping ? WrapTime(layer.timeTicks, duration) : std::clamp(layer.timeTicks, 0.0f, duration)) : 0.0f; - for (const auto& track : animation.tracks) + activeLayers[activeLayerCount++] = { &animation, sampleTime, duration, layer.weight, layer.looping, &layer.sourceNodeByTargetNode }; + } + + for (size_t targetNodeIndex = 0; targetNodeIndex < skeleton.nodes.size(); ++targetNodeIndex) + { + const auto& node = skeleton.nodes[targetNodeIndex]; + + OvMaths::FVector3 blendedPosition; + OvMaths::FQuaternion blendedRotation; + OvMaths::FVector3 blendedScale; + float accumulatedWeight = 0.0f; + + for (uint32_t activeLayerIndex = 0; activeLayerIndex < activeLayerCount; ++activeLayerIndex) { - if (track.nodeIndex >= animationSkeleton.nodes.size() || track.nodeIndex >= m_animationNodeMap.size()) + const auto& activeLayer = activeLayers[activeLayerIndex]; + const int32_t sourceNodeIndex = (*activeLayer.sourceNodeByTargetNode)[targetNodeIndex]; + if (sourceNodeIndex < 0) { continue; } - const int32_t targetNodeIndex = m_animationNodeMap[track.nodeIndex]; - if (targetNodeIndex < 0 || static_cast(targetNodeIndex) >= m_localPose.size()) + const auto track = activeLayer.animation->FindTrack(static_cast(sourceNodeIndex)); + if (!track) { continue; } - const auto& node = skeleton.nodes[static_cast(targetNodeIndex)]; - const OvMaths::FVector3 sampledPosition = SampleKeys( - track.positionKeys, - sampleTime, - duration, + track->positionKeys, + activeLayer.sampleTime, + activeLayer.duration, node.bindPosition, - m_looping, + activeLayer.looping, [](const auto& p_a, const auto& p_b, float p_alpha) { return OvMaths::FVector3::Lerp(p_a, p_b, p_alpha); } ); const OvMaths::FQuaternion sampledRotation = SampleKeys( - track.rotationKeys, - sampleTime, - duration, + track->rotationKeys, + activeLayer.sampleTime, + activeLayer.duration, node.bindRotation, - m_looping, + activeLayer.looping, [](const auto& p_a, const auto& p_b, float p_alpha) { return OvMaths::FQuaternion::Slerp(p_a, p_b, p_alpha); } ); const OvMaths::FVector3 sampledScale = SampleKeys( - track.scaleKeys, - sampleTime, - duration, + track->scaleKeys, + activeLayer.sampleTime, + activeLayer.duration, node.bindScale, - m_looping, + activeLayer.looping, [](const auto& p_a, const auto& p_b, float p_alpha) { return OvMaths::FVector3::Lerp(p_a, p_b, p_alpha); } ); - const OvMaths::FTransform sampled(sampledPosition, sampledRotation, sampledScale); - m_localPose[static_cast(targetNodeIndex)] = sampled.GetLocalMatrix(); + if (accumulatedWeight <= 0.0f) + { + blendedPosition = sampledPosition; + blendedRotation = sampledRotation; + blendedScale = sampledScale; + } + else + { + const float alpha = activeLayer.weight / (accumulatedWeight + activeLayer.weight); + blendedPosition = OvMaths::FVector3::Lerp(blendedPosition, sampledPosition, alpha); + blendedRotation = OvMaths::FQuaternion::Slerp(blendedRotation, sampledRotation, alpha); + blendedScale = OvMaths::FVector3::Lerp(blendedScale, sampledScale, alpha); + } + + accumulatedWeight += activeLayer.weight; + } + + if (accumulatedWeight > 0.0f) + { + const OvMaths::FTransform blended(blendedPosition, blendedRotation, blendedScale); + m_localPose[targetNodeIndex] = blended.GetLocalMatrix(); + } + else + { + m_localPose[targetNodeIndex] = node.localBindTransform; } } @@ -1072,51 +1426,58 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::RecomputeBoneMatricesFromLoc ++m_poseVersion; } -float OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationDurationSeconds() const +float OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationDurationSeconds(uint32_t p_layer) const { - if (!HasCompatibleAnimationSource() || !m_animationIndex.has_value()) + const auto layer = FindLayer(p_layer); + if (!layer || !IsLayerCompatible(*layer) || !layer->animationIndex.has_value()) { return 0.0f; } - const auto& animation = GetAnimationModel()->GetAnimations().at(*m_animationIndex); + const auto& animation = GetLayerAnimationModel(*layer)->GetAnimations().at(*layer->animationIndex); return animation.GetDurationSeconds(); } -void OvCore::ECS::Components::CSkinnedMeshRenderer::UpdatePlayback(float p_deltaTime) +void OvCore::ECS::Components::CSkinnedMeshRenderer::UpdatePlayback(AnimationLayer& p_layer, float p_deltaTime) { - if (!HasCompatibleAnimationSource() || !m_animationIndex.has_value()) + if (!IsLayerCompatible(p_layer) || !p_layer.animationIndex.has_value()) + { + return; + } + + const auto& animations = GetLayerAnimationModel(p_layer)->GetAnimations(); + if (*p_layer.animationIndex >= animations.size()) { return; } - const auto& animation = GetAnimationModel()->GetAnimations().at(*m_animationIndex); + const auto& animation = animations.at(*p_layer.animationIndex); if (animation.duration <= 0.0f) { return; } - if (std::abs(m_playbackSpeed) <= std::numeric_limits::epsilon()) + if (std::abs(p_layer.speed) <= std::numeric_limits::epsilon()) { return; } const float ticksPerSecond = animation.GetEffectiveTicksPerSecond(); - m_currentTimeTicks += p_deltaTime * ticksPerSecond * m_playbackSpeed; + p_layer.timeTicks += p_deltaTime * ticksPerSecond * p_layer.speed; - if (m_looping) + if (p_layer.looping) { - m_currentTimeTicks = WrapTime(m_currentTimeTicks, animation.duration); + p_layer.timeTicks = WrapTime(p_layer.timeTicks, animation.duration); } else { - const float clamped = std::clamp(m_currentTimeTicks, 0.0f, animation.duration); - const bool reachedStart = clamped <= 0.0f && m_playbackSpeed < 0.0f; - const bool reachedEnd = clamped >= animation.duration && m_playbackSpeed > 0.0f; - m_currentTimeTicks = clamped; + const float clamped = std::clamp(p_layer.timeTicks, 0.0f, animation.duration); + const bool reachedStart = clamped <= 0.0f && p_layer.speed < 0.0f; + const bool reachedEnd = clamped >= animation.duration && p_layer.speed > 0.0f; + p_layer.timeTicks = clamped; if (reachedStart || reachedEnd) { - m_playing = false; + p_layer.playing = false; } } } diff --git a/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp b/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp index 816842ff6..425149e72 100644 --- a/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp +++ b/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp @@ -93,29 +93,41 @@ void BindLuaComponents(sol::state& p_luaState) p_luaState.new_usertype("SkinnedMeshRenderer", sol::base_classes, sol::bases(), - "Play", &CSkinnedMeshRenderer::Play, - "Pause", &CSkinnedMeshRenderer::Pause, - "Stop", &CSkinnedMeshRenderer::Stop, - "IsPlaying", &CSkinnedMeshRenderer::IsPlaying, - "SetLooping", &CSkinnedMeshRenderer::SetLooping, - "IsLooping", &CSkinnedMeshRenderer::IsLooping, - "SetPlaybackSpeed", &CSkinnedMeshRenderer::SetPlaybackSpeed, - "GetPlaybackSpeed", &CSkinnedMeshRenderer::GetPlaybackSpeed, + "GetLayerCount", &CSkinnedMeshRenderer::GetLayerCount, + "GetMaxLayerCount", &CSkinnedMeshRenderer::GetMaxLayerCount, + "AddLayer", &CSkinnedMeshRenderer::AddLayer, + "RemoveLayer", &CSkinnedMeshRenderer::RemoveLayer, + "Play", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { p_this.Play(p_layer.value_or(0)); }, + "Pause", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { p_this.Pause(p_layer.value_or(0)); }, + "Stop", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { p_this.Stop(p_layer.value_or(0)); }, + "IsPlaying", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.IsPlaying(p_layer.value_or(0)); }, + "SetLooping", [](CSkinnedMeshRenderer& p_this, bool p_value, std::optional p_layer) { p_this.SetLooping(p_value, p_layer.value_or(0)); }, + "IsLooping", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.IsLooping(p_layer.value_or(0)); }, + "SetPlaybackSpeed", [](CSkinnedMeshRenderer& p_this, float p_value, std::optional p_layer) { p_this.SetPlaybackSpeed(p_value, p_layer.value_or(0)); }, + "GetPlaybackSpeed", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.GetPlaybackSpeed(p_layer.value_or(0)); }, + "SetLayerWeight", [](CSkinnedMeshRenderer& p_this, float p_value, std::optional p_layer) { p_this.SetLayerWeight(p_value, p_layer.value_or(0)); }, + "GetLayerWeight", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.GetLayerWeight(p_layer.value_or(0)); }, "SetMeshBoundsScale", &CSkinnedMeshRenderer::SetMeshBoundsScale, "GetMeshBoundsScale", &CSkinnedMeshRenderer::GetMeshBoundsScale, - "SetTime", &CSkinnedMeshRenderer::SetTime, - "GetTime", &CSkinnedMeshRenderer::GetTime, - "SetAnimationSourceModel", &CSkinnedMeshRenderer::SetAnimationSourceModel, - "GetAnimationSourceModel", &CSkinnedMeshRenderer::GetAnimationSourceModel, - "IsAnimationSourceCompatible", &CSkinnedMeshRenderer::IsAnimationSourceCompatible, - "GetAnimationCount", &CSkinnedMeshRenderer::GetAnimationCount, - "GetAnimationName", &CSkinnedMeshRenderer::GetAnimationName, + "SetTime", [](CSkinnedMeshRenderer& p_this, float p_timeSeconds, std::optional p_layer) { p_this.SetTime(p_timeSeconds, p_layer.value_or(0)); }, + "GetTime", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.GetTime(p_layer.value_or(0)); }, + "SetAnimationSourceModel", [](CSkinnedMeshRenderer& p_this, OvRendering::Resources::Model* p_model, std::optional p_layer) { p_this.SetAnimationSourceModel(p_model, p_layer.value_or(0)); }, + "GetAnimationSourceModel", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.GetAnimationSourceModel(p_layer.value_or(0)); }, + "IsAnimationSourceCompatible", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.IsAnimationSourceCompatible(p_layer.value_or(0)); }, + "GetAnimationCount", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.GetAnimationCount(p_layer.value_or(0)); }, + "GetAnimationName", [](CSkinnedMeshRenderer& p_this, uint32_t p_index, std::optional p_layer) { return p_this.GetAnimationName(p_index, p_layer.value_or(0)); }, "SetAnimation", sol::overload( - sol::resolve)>(&CSkinnedMeshRenderer::SetAnimation), - sol::resolve(&CSkinnedMeshRenderer::SetAnimation) + [](CSkinnedMeshRenderer& p_this, const std::string& p_name, std::optional p_layer) + { + return p_this.SetAnimation(p_name, p_layer.value_or(0)); + }, + [](CSkinnedMeshRenderer& p_this, std::optional p_index, std::optional p_layer) + { + return p_this.SetAnimation(p_index, p_layer.value_or(0)); + } ), - "GetActiveAnimationIndex", &CSkinnedMeshRenderer::GetActiveAnimationIndex, - "GetActiveAnimationName", &CSkinnedMeshRenderer::GetActiveAnimationName, + "GetActiveAnimationIndex", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.GetActiveAnimationIndex(p_layer.value_or(0)); }, + "GetActiveAnimationName", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { return p_this.GetActiveAnimationName(p_layer.value_or(0)); }, "GetBoneCount", &CSkinnedMeshRenderer::GetBoneCount, "GetBoneName", &CSkinnedMeshRenderer::GetBoneName, "GetBoneIndex", &CSkinnedMeshRenderer::GetBoneIndex, From 49c45189f3867dc416cc92b55972837015955e00 Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Mon, 31 Aug 2026 01:43:48 +0200 Subject: [PATCH 03/10] Document the animation layer API in the Lua reference Add the layer management functions and the optional trailing layer argument carried by every layer-scoped function. --- .../Lua/Components/SkinnedMeshRenderer.lua | 125 ++++++++++++------ 1 file changed, 88 insertions(+), 37 deletions(-) diff --git a/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua b/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua index 5790939df..26bb39264 100644 --- a/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua +++ b/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua @@ -1,6 +1,9 @@ ---@meta --- A component responsible for skeletal animation playback and skinning runtime control +--- Up to GetMaxLayerCount() animations can play at once, each layer owning its own animation +--- source model, animation, time, speed, loop mode and weight. +--- Layer arguments default to 0 (the base layer), which always exists ---@class SkinnedMeshRenderer : Component SkinnedMeshRenderer = {} @@ -8,34 +11,73 @@ SkinnedMeshRenderer = {} ---@return Actor function SkinnedMeshRenderer:GetOwner() end ---- Starts/resumes animation playback -function SkinnedMeshRenderer:Play() end +--- Returns the number of active animation layers (always at least 1) +---@return integer +function SkinnedMeshRenderer:GetLayerCount() end ---- Pauses animation playback -function SkinnedMeshRenderer:Pause() end +--- Returns the maximum number of animation layers a renderer can hold +---@return integer +function SkinnedMeshRenderer:GetMaxLayerCount() end ---- Stops playback and resets time to start -function SkinnedMeshRenderer:Stop() end +--- Appends a new animation layer and returns its index, or nil when the maximum is reached +---@return integer|nil +function SkinnedMeshRenderer:AddLayer() end ---- Returns whether playback is active +--- Removes an animation layer, shifting the following layers down by one. +--- Fails when the index is invalid or when only one layer remains +---@param layer integer ---@return boolean -function SkinnedMeshRenderer:IsPlaying() end +function SkinnedMeshRenderer:RemoveLayer(layer) end + +--- Starts/resumes animation playback on a layer +---@param layer? integer +function SkinnedMeshRenderer:Play(layer) end + +--- Pauses animation playback on a layer +---@param layer? integer +function SkinnedMeshRenderer:Pause(layer) end ---- Sets looping mode +--- Stops playback on a layer and resets its time to start +---@param layer? integer +function SkinnedMeshRenderer:Stop(layer) end + +--- Returns whether playback is active on a layer +---@param layer? integer +---@return boolean +function SkinnedMeshRenderer:IsPlaying(layer) end + +--- Sets looping mode on a layer ---@param loop boolean -function SkinnedMeshRenderer:SetLooping(loop) end +---@param layer? integer +function SkinnedMeshRenderer:SetLooping(loop, layer) end ---- Returns whether looping is enabled +--- Returns whether looping is enabled on a layer +---@param layer? integer ---@return boolean -function SkinnedMeshRenderer:IsLooping() end +function SkinnedMeshRenderer:IsLooping(layer) end ---- Sets animation playback speed +--- Sets animation playback speed on a layer ---@param speed number -function SkinnedMeshRenderer:SetPlaybackSpeed(speed) end +---@param layer? integer +function SkinnedMeshRenderer:SetPlaybackSpeed(speed, layer) end ---- Returns current playback speed +--- Returns the playback speed of a layer +---@param layer? integer +---@return number +function SkinnedMeshRenderer:GetPlaybackSpeed(layer) end + +--- Sets the blend weight of a layer, clamped to the [0,1] range +--- Weights are normalized per bone against the other layers animating that bone, so a layer +--- animating a bone alone contributes fully until its weight reaches 0, where the bone falls +--- back to its bind pose +---@param weight number +---@param layer? integer +function SkinnedMeshRenderer:SetLayerWeight(weight, layer) end + +--- Returns the blend weight of a layer +---@param layer? integer ---@return number -function SkinnedMeshRenderer:GetPlaybackSpeed() end +function SkinnedMeshRenderer:GetLayerWeight(layer) end --- Sets the bounds scale used during frustum culling for skinned meshes --- Values below 1.0 are clamped to 1.0 @@ -47,48 +89,57 @@ function SkinnedMeshRenderer:SetMeshBoundsScale(scale) end ---@return number function SkinnedMeshRenderer:GetMeshBoundsScale() end ---- Sets playback time in seconds +--- Sets the playback time of a layer in seconds ---@param timeSeconds number -function SkinnedMeshRenderer:SetTime(timeSeconds) end +---@param layer? integer +function SkinnedMeshRenderer:SetTime(timeSeconds, layer) end ---- Returns playback time in seconds +--- Returns the playback time of a layer in seconds +---@param layer? integer ---@return number -function SkinnedMeshRenderer:GetTime() end +function SkinnedMeshRenderer:GetTime(layer) end ---- Sets an external model used as animation source, or nil to use the rendered model +--- Sets the external model used as animation source by a layer, or nil to use the rendered model ---@param model Model|nil -function SkinnedMeshRenderer:SetAnimationSourceModel(model) end +---@param layer? integer +function SkinnedMeshRenderer:SetAnimationSourceModel(model, layer) end ---- Returns the external animation source model, or nil when the rendered model is used +--- Returns the external animation source model of a layer, or nil when the rendered model is used +---@param layer? integer ---@return Model|nil -function SkinnedMeshRenderer:GetAnimationSourceModel() end +function SkinnedMeshRenderer:GetAnimationSourceModel(layer) end ---- Returns whether the current animation source is compatible with the rendered model skeleton +--- Returns whether the animation source of a layer is compatible with the rendered model skeleton +---@param layer? integer ---@return boolean -function SkinnedMeshRenderer:IsAnimationSourceCompatible() end +function SkinnedMeshRenderer:IsAnimationSourceCompatible(layer) end ---- Returns the number of available animation clips +--- Returns the number of animation clips available to a layer +---@param layer? integer ---@return integer -function SkinnedMeshRenderer:GetAnimationCount() end +function SkinnedMeshRenderer:GetAnimationCount(layer) end ---- Returns clip name at index or nil +--- Returns the clip name at index for a layer, or nil ---@param index integer +---@param layer? integer ---@return string|nil -function SkinnedMeshRenderer:GetAnimationName(index) end +function SkinnedMeshRenderer:GetAnimationName(index, layer) end ---- Sets the active animation clip by index or name, returns true on success ----@overload fun(self: SkinnedMeshRenderer, index: integer|nil): boolean ----@overload fun(self: SkinnedMeshRenderer, name: string): boolean +--- Sets the active animation clip of a layer by index or name, returns true on success +---@overload fun(self: SkinnedMeshRenderer, name: string, layer?: integer): boolean +---@overload fun(self: SkinnedMeshRenderer, index: integer|nil, layer?: integer): boolean ---@return boolean function SkinnedMeshRenderer:SetAnimation(...) end ---- Returns current clip index or nil +--- Returns the current clip index of a layer, or nil +---@param layer? integer ---@return integer|nil -function SkinnedMeshRenderer:GetActiveAnimationIndex() end +function SkinnedMeshRenderer:GetActiveAnimationIndex(layer) end ---- Returns current clip name or nil +--- Returns the current clip name of a layer, or nil +---@param layer? integer ---@return string|nil -function SkinnedMeshRenderer:GetActiveAnimationName() end +function SkinnedMeshRenderer:GetActiveAnimationName(layer) end --- Returns the number of available bones ---@return integer From fbd9e802b21afc3d4fd2e0822cb8895cfae7424a Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Thu, 3 Sep 2026 19:03:54 +0200 Subject: [PATCH 04/10] Bind animation source via setter --- .../ECS/Components/CSkinnedMeshRenderer.h | 2 -- .../ECS/Components/CSkinnedMeshRenderer.cpp | 30 +++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h b/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h index 83e405a38..0ad3506f8 100644 --- a/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h +++ b/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h @@ -16,7 +16,6 @@ #include #include #include -#include namespace OvCore::ECS { class Actor; } namespace OvRendering::Resources { class Model; } @@ -364,7 +363,6 @@ namespace OvCore::ECS::Components private: const OvRendering::Resources::Model* m_model = nullptr; - std::array, kMaxAnimationLayers> m_animationSourceChangedEvents; float m_meshBoundsScale = 1.5f; float m_poseEvaluationRate = 60.0f; diff --git a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp index accbb41d9..4cf2d78f3 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -16,8 +16,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -327,14 +329,6 @@ namespace OvCore::ECS::Components::CSkinnedMeshRenderer::CSkinnedMeshRenderer(ECS::Actor& p_owner) : AComponent(p_owner) { - for (auto& animationSourceChangedEvent : m_animationSourceChangedEvents) - { - animationSourceChangedEvent += [this]() - { - RebuildRuntimeData(); - }; - } - NotifyModelChanged(); } @@ -960,7 +954,25 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte columns.SetID("skinned_layer_" + std::to_string(layerIndex)); columns.widths[0] = 200 * OVUI_SCALE; - GUIDrawer::DrawMesh(columns, "Animation Source", m_layers[layerIndex].animationSourceModel, &m_animationSourceChangedEvents[layerIndex]); + // The animation source is bound through the setter, so the widget holds no reference into + // the layer storage, which moves whenever a layer is added or removed + GUIDrawer::DrawAsset( + columns, + "Animation Source", + [this, layerIndex] + { + const auto model = GetAnimationSourceModel(layerIndex); + return model ? model->path : std::string{}; + }, + [this, layerIndex](std::string p_path) + { + SetAnimationSourceModel( + p_path.empty() ? nullptr : OVSERVICE(OvCore::ResourceManagement::ModelManager).GetResource(p_path), + layerIndex + ); + }, + OvTools::Utils::PathParser::EFileType::MODEL + ); GUIDrawer::CreateTitle(columns, "Animation"); const auto activeAnimationIndex = GetActiveAnimationIndex(layerIndex); From 51056555270d51dfe38a0c6b04c11ebb67552331 Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Thu, 3 Sep 2026 19:06:06 +0200 Subject: [PATCH 05/10] Remove animation layer limit --- .../Lua/Components/SkinnedMeshRenderer.lua | 10 +- .../ECS/Components/CSkinnedMeshRenderer.h | 35 +++--- .../ECS/Components/CSkinnedMeshRenderer.cpp | 109 +++++++----------- .../Lua/Bindings/LuaComponentsBindings.cpp | 1 - 4 files changed, 64 insertions(+), 91 deletions(-) diff --git a/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua b/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua index 26bb39264..3854e5f03 100644 --- a/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua +++ b/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua @@ -1,8 +1,8 @@ ---@meta --- A component responsible for skeletal animation playback and skinning runtime control ---- Up to GetMaxLayerCount() animations can play at once, each layer owning its own animation ---- source model, animation, time, speed, loop mode and weight. +--- Any number of animations can play at once, each layer owning its own animation source model, +--- animation, time, speed, loop mode and weight. --- Layer arguments default to 0 (the base layer), which always exists ---@class SkinnedMeshRenderer : Component SkinnedMeshRenderer = {} @@ -15,12 +15,8 @@ function SkinnedMeshRenderer:GetOwner() end ---@return integer function SkinnedMeshRenderer:GetLayerCount() end ---- Returns the maximum number of animation layers a renderer can hold +--- Appends a new animation layer and returns its index ---@return integer -function SkinnedMeshRenderer:GetMaxLayerCount() end - ---- Appends a new animation layer and returns its index, or nil when the maximum is reached ----@return integer|nil function SkinnedMeshRenderer:AddLayer() end --- Removes an animation layer, shifting the following layers down by one. diff --git a/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h b/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h index 0ad3506f8..3beebea11 100644 --- a/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h +++ b/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h @@ -6,7 +6,6 @@ #pragma once -#include #include #include #include @@ -18,16 +17,15 @@ #include namespace OvCore::ECS { class Actor; } +namespace OvRendering::Animation { struct SkeletalAnimation; } namespace OvRendering::Resources { class Model; } namespace OvCore::ECS::Components { - constexpr uint32_t kMaxAnimationLayers = 4; - /** * Component responsible for skeletal animation playback and skinning data generation. - * Up to kMaxAnimationLayers animations can play simultaneously, each layer owning its own - * animation source model, animation, playback time, speed, loop mode and weight. + * Any number of animations can play simultaneously, each layer owning its own animation source + * model, animation, playback time, speed, loop mode and weight. * Bones are blended per node, and only layers holding a track for a given node contribute to it, * their weights being normalized against each other. Nodes no layer animates keep their bind pose. */ @@ -66,15 +64,9 @@ namespace OvCore::ECS::Components uint32_t GetLayerCount() const; /** - * Returns the maximum number of animation layers a renderer can hold + * Appends a new animation layer, returning its index */ - uint32_t GetMaxLayerCount() const; - - /** - * Appends a new animation layer, returning its index. - * Returns std::nullopt when the maximum layer count is already reached. - */ - std::optional AddLayer(); + uint32_t AddLayer(); /** * Removes an animation layer, shifting the following layers down by one. @@ -345,6 +337,17 @@ namespace OvCore::ECS::Components std::vector sourceNodeByTargetNode; }; + // Per-layer data sampled once per pose evaluation, before blending the skeleton node by node + struct ActiveLayerSample + { + const OvRendering::Animation::SkeletalAnimation* animation = nullptr; + float sampleTime = 0.0f; + float duration = 0.0f; + float weight = 1.0f; + bool looping = true; + const std::vector* sourceNodeByTargetNode = nullptr; + }; + bool HasCompatibleModel() const; bool IsLayerCompatible(const AnimationLayer& p_layer) const; const OvRendering::Resources::Model* GetLayerAnimationModel(const AnimationLayer& p_layer) const; @@ -368,8 +371,10 @@ namespace OvCore::ECS::Components float m_poseEvaluationRate = 60.0f; float m_poseEvaluationAccumulator = 0.0f; - std::array m_layers; - uint32_t m_layerCount = 1; + std::vector m_layers; + + // Reused across pose evaluations to keep them free of per-frame allocations + std::vector m_activeLayerSamples; uint64_t m_poseVersion = 0; bool m_manualPoseOverride = false; diff --git a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp index 4cf2d78f3..9720087f1 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -5,7 +5,6 @@ */ #include -#include #include #include @@ -36,16 +35,6 @@ namespace { constexpr float kMinimumLayerWeight = 0.0001f; - struct ActiveLayerSample - { - const OvRendering::Animation::SkeletalAnimation* animation = nullptr; - float sampleTime = 0.0f; - float duration = 0.0f; - float weight = 1.0f; - bool looping = true; - const std::vector* sourceNodeByTargetNode = nullptr; - }; - // std::clamp propagates NaN, so non-finite weights are rejected before clamping float ClampLayerWeight(float p_value) { @@ -329,6 +318,8 @@ namespace OvCore::ECS::Components::CSkinnedMeshRenderer::CSkinnedMeshRenderer(ECS::Actor& p_owner) : AComponent(p_owner) { + m_layers.emplace_back(); + NotifyModelChanged(); } @@ -350,12 +341,10 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::NotifyModelChanged() bool OvCore::ECS::Components::CSkinnedMeshRenderer::HasSkinningData() const { - bool hasAnimatedLayer = false; - for (uint32_t layerIndex = 0; layerIndex < m_layerCount && !hasAnimatedLayer; ++layerIndex) + const bool hasAnimatedLayer = std::any_of(m_layers.begin(), m_layers.end(), [this](const AnimationLayer& p_layer) { - const auto& layer = m_layers[layerIndex]; - hasAnimatedLayer = layer.animationIndex.has_value() && IsLayerCompatible(layer); - } + return p_layer.animationIndex.has_value() && IsLayerCompatible(p_layer); + }); return HasCompatibleModel() && !m_boneMatrices.empty() && @@ -364,36 +353,24 @@ bool OvCore::ECS::Components::CSkinnedMeshRenderer::HasSkinningData() const uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::GetLayerCount() const { - return m_layerCount; + return static_cast(m_layers.size()); } -uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::GetMaxLayerCount() const +uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::AddLayer() { - return kMaxAnimationLayers; -} - -std::optional OvCore::ECS::Components::CSkinnedMeshRenderer::AddLayer() -{ - if (m_layerCount >= kMaxAnimationLayers) - { - return std::nullopt; - } - - const uint32_t addedLayer = m_layerCount++; - m_layers[addedLayer] = AnimationLayer{}; + m_layers.emplace_back(); RebuildRuntimeData(); - return addedLayer; + return static_cast(m_layers.size() - 1); } bool OvCore::ECS::Components::CSkinnedMeshRenderer::RemoveLayer(uint32_t p_layer) { - if (p_layer >= m_layerCount || m_layerCount <= 1) + if (p_layer >= m_layers.size() || m_layers.size() <= 1) { return false; } - std::rotate(m_layers.begin() + p_layer, m_layers.begin() + p_layer + 1, m_layers.begin() + m_layerCount); - m_layers[--m_layerCount] = AnimationLayer{}; + m_layers.erase(m_layers.begin() + p_layer); RebuildRuntimeData(); return true; } @@ -804,9 +781,8 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnUpdate(float p_deltaTime) bool timeChanged = false; bool playbackStateChanged = false; - for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) + for (auto& layer : m_layers) { - auto& layer = m_layers[layerIndex]; if (!layer.playing) { continue; @@ -853,7 +829,7 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnSerialize(tinyxml2::XMLDoc tinyxml2::XMLNode* layersNode = p_doc.NewElement("layers"); p_node->InsertEndChild(layersNode); - for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) + for (uint32_t layerIndex = 0; layerIndex < m_layers.size(); ++layerIndex) { tinyxml2::XMLNode* layerNode = p_doc.NewElement("layer"); layersNode->InsertEndChild(layerNode); @@ -876,12 +852,15 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnDeserialize(tinyxml2::XMLD if (tinyxml2::XMLNode* layersRoot = p_node->FirstChildElement("layers")) { - tinyxml2::XMLElement* currentLayer = layersRoot->FirstChildElement("layer"); - uint32_t layerIndex = 0; + m_layers.clear(); - while (currentLayer && layerIndex < kMaxAnimationLayers) + for ( + tinyxml2::XMLElement* currentLayer = layersRoot->FirstChildElement("layer"); + currentLayer; + currentLayer = currentLayer->NextSiblingElement("layer") + ) { - auto& layer = m_layers[layerIndex]; + auto& layer = m_layers.emplace_back(); OvCore::Helpers::Serializer::DeserializeModel(p_doc, currentLayer, "animation_source", layer.animationSourceModel); OvCore::Helpers::Serializer::DeserializeString(p_doc, currentLayer, "animation", layer.deserializedAnimationName); OvCore::Helpers::Serializer::DeserializeFloat(p_doc, currentLayer, "weight", layer.weight); @@ -891,12 +870,13 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnDeserialize(tinyxml2::XMLD OvCore::Helpers::Serializer::DeserializeFloat(p_doc, currentLayer, "time_ticks", layer.timeTicks); layer.weight = ClampLayerWeight(layer.weight); - - currentLayer = currentLayer->NextSiblingElement("layer"); - ++layerIndex; } - m_layerCount = std::max(layerIndex, 1u); + // The base layer always exists, even when the scene holds an empty element + if (m_layers.empty()) + { + m_layers.emplace_back(); + } } SetMeshBoundsScale(m_meshBoundsScale); @@ -940,10 +920,10 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte widget.first->Destroy(); } - for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) + for (uint32_t layerIndex = 0; layerIndex < m_layers.size(); ++layerIndex) { auto& layerGroup = p_container.CreateWidget("Layer " + std::to_string(layerIndex)); - layerGroup.closable = m_layerCount > 1; + layerGroup.closable = m_layers.size() > 1; layerGroup.CloseEvent += [this, &p_container, layerIndex] { RemoveLayer(layerIndex); @@ -1077,15 +1057,12 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte }); } - if (m_layerCount < kMaxAnimationLayers) + auto& addLayerButton = p_container.CreateWidget("Add Layer"); + addLayerButton.ClickedEvent += [this, &p_container] { - auto& addLayerButton = p_container.CreateWidget("Add Layer"); - addLayerButton.ClickedEvent += [this, &p_container] - { - AddLayer(); - BuildLayerWidgets(p_container); - }; - } + AddLayer(); + BuildLayerWidgets(p_container); + }; } bool OvCore::ECS::Components::CSkinnedMeshRenderer::HasCompatibleModel() const @@ -1105,12 +1082,12 @@ const OvRendering::Resources::Model* OvCore::ECS::Components::CSkinnedMeshRender OvCore::ECS::Components::CSkinnedMeshRenderer::AnimationLayer* OvCore::ECS::Components::CSkinnedMeshRenderer::FindLayer(uint32_t p_layer) { - return p_layer < m_layerCount ? &m_layers[p_layer] : nullptr; + return p_layer < m_layers.size() ? &m_layers[p_layer] : nullptr; } const OvCore::ECS::Components::CSkinnedMeshRenderer::AnimationLayer* OvCore::ECS::Components::CSkinnedMeshRenderer::FindLayer(uint32_t p_layer) const { - return p_layer < m_layerCount ? &m_layers[p_layer] : nullptr; + return p_layer < m_layers.size() ? &m_layers[p_layer] : nullptr; } void OvCore::ECS::Components::CSkinnedMeshRenderer::SyncWithModel() @@ -1162,10 +1139,10 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::RebuildRuntimeData() std::vector nodeMapScratch; - for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) + for (auto& layer : m_layers) { - RebuildLayerRuntimeData(m_layers[layerIndex], nodeMapScratch); - ResolveLayerAnimation(m_layers[layerIndex]); + RebuildLayerRuntimeData(layer, nodeMapScratch); + ResolveLayerAnimation(layer); } EvaluatePose(); @@ -1259,13 +1236,10 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::EvaluatePose() const auto& skeleton = m_model->GetSkeleton().value(); - std::array activeLayers; - uint32_t activeLayerCount = 0; + m_activeLayerSamples.clear(); - for (uint32_t layerIndex = 0; layerIndex < m_layerCount; ++layerIndex) + for (const auto& layer : m_layers) { - const auto& layer = m_layers[layerIndex]; - if (layer.sourceNodeByTargetNode.size() != skeleton.nodes.size()) { continue; @@ -1289,7 +1263,7 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::EvaluatePose() (layer.looping ? WrapTime(layer.timeTicks, duration) : std::clamp(layer.timeTicks, 0.0f, duration)) : 0.0f; - activeLayers[activeLayerCount++] = { &animation, sampleTime, duration, layer.weight, layer.looping, &layer.sourceNodeByTargetNode }; + m_activeLayerSamples.push_back({ &animation, sampleTime, duration, layer.weight, layer.looping, &layer.sourceNodeByTargetNode }); } for (size_t targetNodeIndex = 0; targetNodeIndex < skeleton.nodes.size(); ++targetNodeIndex) @@ -1301,9 +1275,8 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::EvaluatePose() OvMaths::FVector3 blendedScale; float accumulatedWeight = 0.0f; - for (uint32_t activeLayerIndex = 0; activeLayerIndex < activeLayerCount; ++activeLayerIndex) + for (const auto& activeLayer : m_activeLayerSamples) { - const auto& activeLayer = activeLayers[activeLayerIndex]; const int32_t sourceNodeIndex = (*activeLayer.sourceNodeByTargetNode)[targetNodeIndex]; if (sourceNodeIndex < 0) { diff --git a/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp b/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp index 425149e72..b04ad58b5 100644 --- a/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp +++ b/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp @@ -94,7 +94,6 @@ void BindLuaComponents(sol::state& p_luaState) p_luaState.new_usertype("SkinnedMeshRenderer", sol::base_classes, sol::bases(), "GetLayerCount", &CSkinnedMeshRenderer::GetLayerCount, - "GetMaxLayerCount", &CSkinnedMeshRenderer::GetMaxLayerCount, "AddLayer", &CSkinnedMeshRenderer::AddLayer, "RemoveLayer", &CSkinnedMeshRenderer::RemoveLayer, "Play", [](CSkinnedMeshRenderer& p_this, std::optional p_layer) { p_this.Play(p_layer.value_or(0)); }, From fc94ce9f4a175067926ce8fd4c3d7ea69f3e26e1 Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Thu, 3 Sep 2026 19:07:48 +0200 Subject: [PATCH 06/10] Draw animation layers as tree nodes --- .../ECS/Components/CSkinnedMeshRenderer.cpp | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp index 9720087f1..1a763dc36 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -27,7 +27,7 @@ #include #include #include -#include +#include #include #include @@ -922,15 +922,12 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte for (uint32_t layerIndex = 0; layerIndex < m_layers.size(); ++layerIndex) { - auto& layerGroup = p_container.CreateWidget("Layer " + std::to_string(layerIndex)); - layerGroup.closable = m_layers.size() > 1; - layerGroup.CloseEvent += [this, &p_container, layerIndex] - { - RemoveLayer(layerIndex); - BuildLayerWidgets(p_container); - }; + // Layers are array elements, so they use a tree node rather than the collapsable group + // reserved for components. The identifier keeps the node folded state across rebuilds + auto& layerNode = p_container.CreateWidget("Layer " + std::to_string(layerIndex)); + layerNode.SetID("skinned_layer_node_" + std::to_string(layerIndex)); - auto& columns = layerGroup.CreateWidget>(); + auto& columns = layerNode.CreateWidget>(); columns.SetID("skinned_layer_" + std::to_string(layerIndex)); columns.widths[0] = 200 * OVUI_SCALE; @@ -1034,7 +1031,7 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte ); // Gathered every frame, so assigning an incompatible source reports it without a panel refresh - auto& diagnostic = layerGroup.CreateWidget(); + auto& diagnostic = layerNode.CreateWidget(); diagnostic.AddPlugin>().RegisterGatherer([this, layerIndex] { const auto layer = FindLayer(layerIndex); @@ -1055,6 +1052,16 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte return std::string{}; }); + + if (m_layers.size() > 1) + { + auto& removeLayerButton = layerNode.CreateWidget("Remove Layer"); + removeLayerButton.ClickedEvent += [this, &p_container, layerIndex] + { + RemoveLayer(layerIndex); + BuildLayerWidgets(p_container); + }; + } } auto& addLayerButton = p_container.CreateWidget("Add Layer"); From 5987428e518e529f42e923e30d098a13452102b0 Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Fri, 4 Sep 2026 20:52:17 +0200 Subject: [PATCH 07/10] Show ready state in diagnostics --- .../ECS/Components/CSkinnedMeshRenderer.cpp | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp index 1a763dc36..3b1db99e3 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -29,7 +29,7 @@ #include #include #include -#include +#include namespace { @@ -896,10 +896,12 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnInspector(OvUI::Internal:: GUIDrawer::DrawScalar(p_root, "Pose Eval Rate", m_poseEvaluationRate, 1.0f, 0.0f, 240.0f); m_poseEvaluationRate = std::max(0.0f, m_poseEvaluationRate); - auto& modelDiagnostic = p_root.CreateWidget(); - modelDiagnostic.AddPlugin>().RegisterGatherer([this] + auto& modelDiagnostic = p_root.CreateWidget(); + modelDiagnostic.AddPlugin>().RegisterGatherer([this, &modelDiagnostic] { - return HasCompatibleModel() ? std::string{} : std::string{ "No skinned model assigned" }; + const bool ready = HasCompatibleModel(); + modelDiagnostic.color = ready ? OVUI_STYLE(Success) : OVUI_STYLE(TextDisabled); + return ready ? std::string{ "Ready" } : std::string{ "No skinned model assigned" }; }); // Layers live in their own full-width container so they can be rebuilt in place when one is @@ -1031,8 +1033,8 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte ); // Gathered every frame, so assigning an incompatible source reports it without a panel refresh - auto& diagnostic = layerNode.CreateWidget(); - diagnostic.AddPlugin>().RegisterGatherer([this, layerIndex] + auto& diagnostic = layerNode.CreateWidget(); + diagnostic.AddPlugin>().RegisterGatherer([this, &diagnostic, layerIndex] { const auto layer = FindLayer(layerIndex); if (!layer) @@ -1042,11 +1044,13 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte if (layer->animationSourceModel && !IsLayerCompatible(*layer)) { + diagnostic.color = OVUI_STYLE(Danger); return std::string{ "Animation source skeleton is not compatible with model" }; } if (layer->animationNames.empty()) { + diagnostic.color = OVUI_STYLE(Warning); return std::string{ layer->animationSourceModel ? "Animation source has no animation clips" : "Model has no animation clips" }; } From 330d4e3d88555ab09c0042ad1e692aebfd3e7bbc Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Tue, 8 Sep 2026 23:47:49 +0200 Subject: [PATCH 08/10] Split model diagnostic into two columns --- .../ECS/Components/CSkinnedMeshRenderer.cpp | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp index 3b1db99e3..443c09eec 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -896,12 +896,20 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::OnInspector(OvUI::Internal:: GUIDrawer::DrawScalar(p_root, "Pose Eval Rate", m_poseEvaluationRate, 1.0f, 0.0f, 240.0f); m_poseEvaluationRate = std::max(0.0f, m_poseEvaluationRate); - auto& modelDiagnostic = p_root.CreateWidget(); - modelDiagnostic.AddPlugin>().RegisterGatherer([this, &modelDiagnostic] + // The inspector lays components out in two columns, so the status lands next to its details, + // the way scripts report theirs + auto& modelStatus = p_root.CreateWidget(); + modelStatus.AddPlugin>().RegisterGatherer([this, &modelStatus] { const bool ready = HasCompatibleModel(); - modelDiagnostic.color = ready ? OVUI_STYLE(Success) : OVUI_STYLE(TextDisabled); - return ready ? std::string{ "Ready" } : std::string{ "No skinned model assigned" }; + modelStatus.color = ready ? OVUI_STYLE(Success) : OVUI_STYLE(Danger); + return ready ? std::string{ "Ready" } : std::string{ "Error" }; + }); + + auto& modelDiagnostic = p_root.CreateWidget("", OVUI_STYLE(TextDisabled)); + modelDiagnostic.AddPlugin>().RegisterGatherer([this] + { + return HasCompatibleModel() ? std::string{ "Compatible skinned model found" } : std::string{ "No skinned model assigned" }; }); // Layers live in their own full-width container so they can be rebuilt in place when one is From 61b6409858f91ac812766baa03f60e49af5aeb0e Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Wed, 9 Sep 2026 01:01:23 +0200 Subject: [PATCH 09/10] Report layer diagnostics in two columns --- .../ECS/Components/CSkinnedMeshRenderer.cpp | 59 ++++++++++++++++--- 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp index 443c09eec..7647fe164 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -35,6 +35,16 @@ namespace { constexpr float kMinimumLayerWeight = 0.0001f; + // The inspector reports a layer as a status/details pair, so both cells resolve this once + enum class ELayerDiagnostic + { + STALE, + INCOMPATIBLE_SOURCE, + SOURCE_WITHOUT_CLIPS, + MODEL_WITHOUT_CLIPS, + READY + }; + // std::clamp propagates NaN, so non-finite weights are rejected before clamping float ClampLayerWeight(float p_value) { @@ -1041,28 +1051,61 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte ); // Gathered every frame, so assigning an incompatible source reports it without a panel refresh - auto& diagnostic = layerNode.CreateWidget(); - diagnostic.AddPlugin>().RegisterGatherer([this, &diagnostic, layerIndex] + const auto resolveDiagnostic = [this, layerIndex] { const auto layer = FindLayer(layerIndex); if (!layer) { - return std::string{}; + return ELayerDiagnostic::STALE; } if (layer->animationSourceModel && !IsLayerCompatible(*layer)) { - diagnostic.color = OVUI_STYLE(Danger); - return std::string{ "Animation source skeleton is not compatible with model" }; + return ELayerDiagnostic::INCOMPATIBLE_SOURCE; } if (layer->animationNames.empty()) { - diagnostic.color = OVUI_STYLE(Warning); - return std::string{ layer->animationSourceModel ? "Animation source has no animation clips" : "Model has no animation clips" }; + return layer->animationSourceModel ? ELayerDiagnostic::SOURCE_WITHOUT_CLIPS : ELayerDiagnostic::MODEL_WITHOUT_CLIPS; } - return std::string{}; + return ELayerDiagnostic::READY; + }; + + auto& layerStatus = columns.CreateWidget(); + layerStatus.AddPlugin>().RegisterGatherer([&layerStatus, resolveDiagnostic] + { + switch (resolveDiagnostic()) + { + case ELayerDiagnostic::INCOMPATIBLE_SOURCE: + layerStatus.color = OVUI_STYLE(Danger); + return std::string{ "Error" }; + + case ELayerDiagnostic::SOURCE_WITHOUT_CLIPS: + case ELayerDiagnostic::MODEL_WITHOUT_CLIPS: + layerStatus.color = OVUI_STYLE(Warning); + return std::string{ "Warning" }; + + case ELayerDiagnostic::READY: + layerStatus.color = OVUI_STYLE(Success); + return std::string{ "Ready" }; + + default: + return std::string{}; + } + }); + + auto& layerDiagnostic = columns.CreateWidget("", OVUI_STYLE(TextDisabled)); + layerDiagnostic.AddPlugin>().RegisterGatherer([resolveDiagnostic] + { + switch (resolveDiagnostic()) + { + case ELayerDiagnostic::INCOMPATIBLE_SOURCE: return std::string{ "Animation source skeleton is not compatible with model" }; + case ELayerDiagnostic::SOURCE_WITHOUT_CLIPS: return std::string{ "Animation source has no animation clips" }; + case ELayerDiagnostic::MODEL_WITHOUT_CLIPS: return std::string{ "Model has no animation clips" }; + case ELayerDiagnostic::READY: return std::string{ "Compatible animation source found" }; + default: return std::string{}; + } }); if (m_layers.size() > 1) From dc447660871e7fb517e4bc45e0878b8015236859 Mon Sep 17 00:00:00 2001 From: Gopmyc Date: Wed, 9 Sep 2026 01:01:24 +0200 Subject: [PATCH 10/10] Rename animation source field to override --- .../OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp index 7647fe164..3d710515a 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -955,7 +955,7 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::BuildLayerWidgets(OvUI::Inte // the layer storage, which moves whenever a layer is added or removed GUIDrawer::DrawAsset( columns, - "Animation Source", + "Animation Source Override", [this, layerIndex] { const auto model = GetAnimationSourceModel(layerIndex);