diff --git a/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua b/Resources/Engine/Lua/Components/SkinnedMeshRenderer.lua index 5790939df..3854e5f03 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 +--- 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 = {} @@ -8,34 +11,69 @@ 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 + +--- Appends a new animation layer and returns its index +---@return integer +function SkinnedMeshRenderer:AddLayer() end ---- Pauses animation playback -function SkinnedMeshRenderer:Pause() end +--- 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:RemoveLayer(layer) end ---- Stops playback and resets time to start -function SkinnedMeshRenderer:Stop() end +--- Starts/resumes animation playback on a layer +---@param layer? integer +function SkinnedMeshRenderer:Play(layer) end ---- Returns whether playback is active +--- Pauses animation playback on a layer +---@param layer? integer +function SkinnedMeshRenderer:Pause(layer) end + +--- 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() end +function SkinnedMeshRenderer:IsPlaying(layer) end ---- Sets looping mode +--- 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 +85,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 diff --git a/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h b/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h index 76a4dd883..3beebea11 100644 --- a/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h +++ b/Sources/OvCore/include/OvCore/ECS/Components/CSkinnedMeshRenderer.h @@ -15,15 +15,19 @@ #include #include #include -#include namespace OvCore::ECS { class Actor; } +namespace OvRendering::Animation { struct SkeletalAnimation; } namespace OvRendering::Resources { class Model; } namespace OvCore::ECS::Components { /** * Component responsible for skeletal animation playback and skinning data generation. + * 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. */ class CSkinnedMeshRenderer : public AComponent { @@ -55,46 +59,87 @@ namespace OvCore::ECS::Components bool HasSkinningData() const; /** - * Start animation playback + * Returns the number of active animation layers (always at least 1) */ - void Play(); + uint32_t GetLayerCount() const; /** - * Pause animation playback + * Appends a new animation layer, returning its index */ - void Pause(); + uint32_t 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); + void SetLooping(bool p_value, uint32_t p_layer = 0); /** - * Returns true if loop mode is enabled + * Returns true if loop mode is enabled on a layer + * @param p_layer */ - bool IsLooping() const; + bool IsLooping(uint32_t p_layer = 0) const; /** - * Set playback speed + * Set playback speed on a layer * @param p_value + * @param p_layer */ - void SetPlaybackSpeed(float p_value); + void SetPlaybackSpeed(float p_value, uint32_t p_layer = 0); /** - * Get playback speed + * Get playback speed of a layer + * @param p_layer */ - float GetPlaybackSpeed() const; + float GetPlaybackSpeed(uint32_t p_layer = 0) const; + + /** + * 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 SetLayerWeight(float p_value, uint32_t p_layer = 0); + + /** + * Returns the blend weight of a layer + * @param p_layer + */ + float GetLayerWeight(uint32_t p_layer = 0) const; /** * Returns the scale applied to mesh bounds during frustum culling @@ -110,64 +155,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 +319,66 @@ 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; + }; + + // 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 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; - 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::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; - 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..3b1db99e3 100644 --- a/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp +++ b/Sources/OvCore/src/OvCore/ECS/Components/CSkinnedMeshRenderer.cpp @@ -15,17 +15,32 @@ #include #include #include +#include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include #include -#include +#include namespace { + constexpr float kMinimumLayerWeight = 0.0001f; + + // 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 +318,7 @@ namespace OvCore::ECS::Components::CSkinnedMeshRenderer::CSkinnedMeshRenderer(ECS::Actor& p_owner) : AComponent(p_owner) { - m_animationSourceChangedEvent += [this]() - { - RebuildRuntimeData(); - }; + m_layers.emplace_back(); NotifyModelChanged(); } @@ -329,55 +341,130 @@ void OvCore::ECS::Components::CSkinnedMeshRenderer::NotifyModelChanged() bool OvCore::ECS::Components::CSkinnedMeshRenderer::HasSkinningData() const { + const bool hasAnimatedLayer = std::any_of(m_layers.begin(), m_layers.end(), [this](const AnimationLayer& p_layer) + { + return p_layer.animationIndex.has_value() && IsLayerCompatible(p_layer); + }); + return HasCompatibleModel() && !m_boneMatrices.empty() && - ((m_animationIndex.has_value() && HasCompatibleAnimationSource()) || m_manualPoseOverride); + (hasAnimatedLayer || m_manualPoseOverride); +} + +uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::GetLayerCount() const +{ + return static_cast(m_layers.size()); +} + +uint32_t OvCore::ECS::Components::CSkinnedMeshRenderer::AddLayer() +{ + m_layers.emplace_back(); + RebuildRuntimeData(); + return static_cast(m_layers.size() - 1); +} + +bool OvCore::ECS::Components::CSkinnedMeshRenderer::RemoveLayer(uint32_t p_layer) +{ + if (p_layer >= m_layers.size() || m_layers.size() <= 1) + { + return false; + } + + m_layers.erase(m_layers.begin() + p_layer); + RebuildRuntimeData(); + return true; } -void OvCore::ECS::Components::CSkinnedMeshRenderer::Play() +void OvCore::ECS::Components::CSkinnedMeshRenderer::Play(uint32_t p_layer) { - m_playing = true; + 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 +477,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& animationNames = layer->animationNames; + const auto found = std::find(animationNames.begin(), animationNames.end(), p_name); - const auto found = std::find_if(animations.begin(), animations.end(), [&p_name](const auto& p_animation) - { - return p_animation.name == 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 +773,28 @@ 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 (auto& layer : m_layers) + { + if (!layer.playing) + { + continue; + } - UpdatePlayback(p_deltaTime); + const float previousTimeTicks = layer.timeTicks; - const bool timeChanged = std::abs(m_currentTimeTicks - previousTimeTicks) > std::numeric_limits::epsilon(); - const bool playbackStateChanged = wasPlaying != m_playing; + UpdatePlayback(layer, p_deltaTime); + + timeChanged = timeChanged || std::abs(layer.timeTicks - previousTimeTicks) > std::numeric_limits::epsilon(); + playbackStateChanged = playbackStateChanged || !layer.playing; + } if (timeChanged || playbackStateChanged) { @@ -724,26 +823,62 @@ 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_layers.size(); ++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")) + { + m_layers.clear(); + + for ( + tinyxml2::XMLElement* currentLayer = layersRoot->FirstChildElement("layer"); + currentLayer; + currentLayer = currentLayer->NextSiblingElement("layer") + ) + { + 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); + 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); + } + + // The base layer always exists, even when the scene holds an empty element + if (m_layers.empty()) + { + m_layers.emplace_back(); + } + } + SetMeshBoundsScale(m_meshBoundsScale); m_poseEvaluationRate = std::max(0.0f, m_poseEvaluationRate); m_poseEvaluationAccumulator = 0.0f; @@ -757,58 +892,188 @@ 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, &modelDiagnostic] { - animationChoice.choices.clear(); - animationChoice.choices.emplace(-1, ""); + const bool ready = HasCompatibleModel(); + modelDiagnostic.color = ready ? OVUI_STYLE(Success) : OVUI_STYLE(TextDisabled); + return ready ? std::string{ "Ready" } : 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_layers.size(); ++layerIndex) { - p_root.CreateWidget("Animation source skeleton is not compatible with model"); + // 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 = layerNode.CreateWidget>(); + columns.SetID("skinned_layer_" + std::to_string(layerIndex)); + columns.widths[0] = 200 * OVUI_SCALE; + + // 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); + 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 = layerNode.CreateWidget(); + diagnostic.AddPlugin>().RegisterGatherer([this, &diagnostic, layerIndex] + { + const auto layer = FindLayer(layerIndex); + if (!layer) + { + return std::string{}; + } + + 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" }; + } + + 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); + }; + } } - else if (m_animationNames.empty()) + + auto& addLayerButton = p_container.CreateWidget("Add Layer"); + addLayerButton.ClickedEvent += [this, &p_container] { - p_root.CreateWidget(m_animationSourceModel ? "Animation source has no animation clips" : "Model has no animation clips"); - } + AddLayer(); + BuildLayerWidgets(p_container); + }; } bool OvCore::ECS::Components::CSkinnedMeshRenderer::HasCompatibleModel() const @@ -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; +} + +OvCore::ECS::Components::CSkinnedMeshRenderer::AnimationLayer* OvCore::ECS::Components::CSkinnedMeshRenderer::FindLayer(uint32_t p_layer) +{ + return p_layer < m_layers.size() ? &m_layers[p_layer] : nullptr; } -const OvRendering::Resources::Model* OvCore::ECS::Components::CSkinnedMeshRenderer::GetAnimationModel() const +const OvCore::ECS::Components::CSkinnedMeshRenderer::AnimationLayer* OvCore::ECS::Components::CSkinnedMeshRenderer::FindLayer(uint32_t p_layer) const { - return m_animationSourceModel ? m_animationSourceModel : m_model; + return p_layer < m_layers.size() ? &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 (auto& layer : m_layers) + { + RebuildLayerRuntimeData(layer, nodeMapScratch); + ResolveLayerAnimation(layer); + } + + 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 + "'."); + } - m_animationNames.reserve(animations.size()); - for (const auto& animation : animations) + return; + } + + 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); } +} - const auto& animations = GetAnimationModel()->GetAnimations(); +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; - if (!m_animationNames.empty()) + p_layer.animationIndex = std::nullopt; + p_layer.deserializedAnimationName.clear(); + + const auto& animationNames = p_layer.animationNames; + + 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,111 @@ 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; - } + m_activeLayerSamples.clear(); - const auto animationModel = GetAnimationModel(); - if (HasCompatibleAnimationSource() && m_animationIndex.has_value() && *m_animationIndex < animationModel->GetAnimations().size()) + for (const auto& layer : m_layers) { - const auto& animation = animationModel->GetAnimations().at(*m_animationIndex); - const auto& animationSkeleton = animationModel->GetSkeleton().value(); + 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) + m_activeLayerSamples.push_back({ &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 (const auto& activeLayer : m_activeLayerSamples) { - if (track.nodeIndex >= animationSkeleton.nodes.size() || track.nodeIndex >= m_animationNodeMap.size()) + 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 +1422,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..b04ad58b5 100644 --- a/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp +++ b/Sources/OvCore/src/OvCore/Scripting/Lua/Bindings/LuaComponentsBindings.cpp @@ -93,29 +93,40 @@ 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, + "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, 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();