Merge branch 'animationblending' into 'master'

Animation blending implementation. Flexible and moddable through .yaml blending config files.

See merge request OpenMW/openmw!3497
pull/3236/head
Alexei Kotov 5 months ago
commit 1f4ab3b668

@ -61,6 +61,7 @@ Programmers
Cory F. Cohen (cfcohen)
Cris Mihalache (Mirceam)
crussell187
Sam Hellawell (cykoder)
Dan Vukelich (sanchezman)
darkf
Dave Corley (S3ctor)
@ -144,6 +145,7 @@ Programmers
Łukasz Gołębiewski (lukago)
Lukasz Gromanowski (lgro)
Mads Sandvei (Foal)
Maksim Eremenko (Max Yari)
Marc Bouvier (CramitDeFrog)
Marcin Hulist (Gohan)
Mark Siewert (mark76)

@ -193,6 +193,7 @@
Feature #5492: Let rain and snow collide with statics
Feature #5926: Refraction based on water depth
Feature #5944: Option to use camera as sound listener
Feature #6009: Animation blending - smooth animation transitions with modding support
Feature #6152: Playing music via lua scripts
Feature #6188: Specular lighting from point light sources
Feature #6411: Support translations in openmw-launcher

@ -189,6 +189,7 @@ bool Launcher::SettingsPage::loadSettings()
loadSettingBool(Settings::game().mWeaponSheathing, *weaponSheathingCheckBox);
loadSettingBool(Settings::game().mShieldSheathing, *shieldSheathingCheckBox);
}
loadSettingBool(Settings::game().mSmoothAnimTransitions, *smoothAnimTransitionsCheckBox);
loadSettingBool(Settings::game().mTurnToMovementDirection, *turnToMovementDirectionCheckBox);
loadSettingBool(Settings::game().mSmoothMovement, *smoothMovementCheckBox);
loadSettingBool(Settings::game().mPlayerMovementIgnoresAnimation, *playerMovementIgnoresAnimationCheckBox);
@ -394,6 +395,7 @@ void Launcher::SettingsPage::saveSettings()
saveSettingBool(*weaponSheathingCheckBox, Settings::game().mWeaponSheathing);
saveSettingBool(*shieldSheathingCheckBox, Settings::game().mShieldSheathing);
saveSettingBool(*turnToMovementDirectionCheckBox, Settings::game().mTurnToMovementDirection);
saveSettingBool(*smoothAnimTransitionsCheckBox, Settings::game().mSmoothAnimTransitions);
saveSettingBool(*smoothMovementCheckBox, Settings::game().mSmoothMovement);
saveSettingBool(*playerMovementIgnoresAnimationCheckBox, Settings::game().mPlayerMovementIgnoresAnimation);

@ -426,6 +426,16 @@
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QCheckBox" name="smoothAnimTransitionsCheckBox">
<property name="toolTip">
<string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;If enabled - makes transitions between different animations/poses much smoother. Also allows to load animation blending config YAML files that can be bundled with animations in order to customise blending styles.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
</property>
<property name="text">
<string>Smooth Animation Transitions</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>

@ -27,7 +27,7 @@ add_openmw_dir (mwrender
bulletdebugdraw globalmap characterpreview camera localmap water terrainstorage ripplesimulation
renderbin actoranimation landmanager navmesh actorspaths recastmesh fogmanager objectpaging groundcover
postprocessor pingpongcull luminancecalculator pingpongcanvas transparentpass precipitationocclusion ripples
actorutil distortion animationpriority bonegroup blendmask
actorutil distortion animationpriority bonegroup blendmask animblendcontroller
)
add_openmw_dir (mwinput

@ -13,8 +13,12 @@
#include <osgParticle/ParticleProcessor>
#include <osgParticle/ParticleSystem>
#include <osgAnimation/Bone>
#include <osgAnimation/UpdateBone>
#include <components/debug/debuglog.hpp>
#include <components/resource/animblendrulesmanager.hpp>
#include <components/resource/keyframemanager.hpp>
#include <components/resource/scenemanager.hpp>
@ -396,6 +400,60 @@ namespace
return lightModel;
}
void assignBoneBlendCallbackRecursive(MWRender::BoneAnimBlendController* controller, osg::Node* parent, bool isRoot)
{
// Attempt to cast node to an osgAnimation::Bone
if (!isRoot && dynamic_cast<osgAnimation::Bone*>(parent))
{
// Wrapping in a custom callback object allows for nested callback chaining, otherwise it has link to self
// issues we need to share the base BoneAnimBlendController as that contains blending information and is
// guaranteed to update before
osgAnimation::Bone* bone = static_cast<osgAnimation::Bone*>(parent);
osg::ref_ptr<osg::Callback> cb = new MWRender::BoneAnimBlendControllerWrapper(controller, bone);
// Ensure there is no other AnimBlendController - this can happen when using
// multiple animations with different roots, such as NPC animation
osg::Callback* updateCb = bone->getUpdateCallback();
while (updateCb)
{
if (dynamic_cast<MWRender::BoneAnimBlendController*>(updateCb))
{
osg::ref_ptr<osg::Callback> nextCb = updateCb->getNestedCallback();
bone->removeUpdateCallback(updateCb);
updateCb = nextCb;
}
else
{
updateCb = updateCb->getNestedCallback();
}
}
// Find UpdateBone callback and bind to just after that (order is important)
// NOTE: if it doesn't have an UpdateBone callback, we shouldn't be doing blending!
updateCb = bone->getUpdateCallback();
while (updateCb)
{
if (dynamic_cast<osgAnimation::UpdateBone*>(updateCb))
{
// Override the immediate callback after the UpdateBone
osg::ref_ptr<osg::Callback> lastCb = updateCb->getNestedCallback();
updateCb->setNestedCallback(cb);
if (lastCb)
cb->setNestedCallback(lastCb);
break;
}
updateCb = updateCb->getNestedCallback();
}
}
// Traverse child bones if this is a group
osg::Group* group = parent->asGroup();
if (group)
for (unsigned int i = 0; i < group->getNumChildren(); ++i)
assignBoneBlendCallbackRecursive(controller, group->getChild(i), false);
}
}
namespace MWRender
@ -449,6 +507,8 @@ namespace MWRender
ControllerMap mControllerMap[sNumBlendMasks];
const SceneUtil::TextKeyMap& getTextKeys() const;
osg::ref_ptr<const SceneUtil::AnimBlendRules> mAnimBlendRules;
};
void UpdateVfxCallback::operator()(osg::Node* node, osg::NodeVisitor* nv)
@ -606,7 +666,9 @@ namespace MWRender
for (const auto& name : mResourceSystem->getVFS()->getRecursiveDirectoryIterator(animationPath))
{
if (Misc::getFileExtension(name) == "kf")
{
addSingleAnimSource(name, baseModel);
}
}
}
@ -623,17 +685,18 @@ namespace MWRender
loadAllAnimationsInFolder(kfname, baseModel);
}
void Animation::addSingleAnimSource(const std::string& kfname, const std::string& baseModel)
std::shared_ptr<Animation::AnimSource> Animation::addSingleAnimSource(
const std::string& kfname, const std::string& baseModel)
{
if (!mResourceSystem->getVFS()->exists(kfname))
return;
return nullptr;
auto animsrc = std::make_shared<AnimSource>();
animsrc->mKeyframes = mResourceSystem->getKeyframeManager()->get(kfname);
if (!animsrc->mKeyframes || animsrc->mKeyframes->mTextKeys.empty()
|| animsrc->mKeyframes->mKeyframeControllers.empty())
return;
return nullptr;
const NodeMap& nodeMap = getNodeMap();
const auto& controllerMap = animsrc->mKeyframes->mKeyframeControllers;
@ -661,7 +724,7 @@ namespace MWRender
animsrc->mControllerMap[blendMask].insert(std::make_pair(bonename, cloned));
}
mAnimSources.push_back(std::move(animsrc));
mAnimSources.push_back(animsrc);
for (const std::string& group : mAnimSources.back()->getTextKeys().getGroups())
mSupportedAnimations.insert(group);
@ -693,6 +756,37 @@ namespace MWRender
break;
}
}
// Get the blending rules
if (Settings::game().mSmoothAnimTransitions)
{
// Note, even if the actual config is .json - we should send a .yaml path to AnimBlendRulesManager, the
// manager will check for .json if it will not find a specified .yaml file.
VFS::Path::Normalized blendConfigPath(kfname);
blendConfigPath.changeExtension("yaml");
// globalBlendConfigPath is only used with actors! Objects have no default blending.
constexpr VFS::Path::NormalizedView globalBlendConfigPath("animations/animation-config.yaml");
osg::ref_ptr<const SceneUtil::AnimBlendRules> blendRules;
if (mPtr.getClass().isActor())
{
blendRules
= mResourceSystem->getAnimBlendRulesManager()->getRules(globalBlendConfigPath, blendConfigPath);
if (blendRules == nullptr)
Log(Debug::Warning) << "Unable to find animation blending rules: '" << blendConfigPath << "' or '"
<< globalBlendConfigPath << "'";
}
else
{
blendRules = mResourceSystem->getAnimBlendRulesManager()->getRules(blendConfigPath, blendConfigPath);
}
// At this point blendRules will either be nullptr or an AnimBlendRules instance with > 0 rules inside.
animsrc->mAnimBlendRules = blendRules;
}
return animsrc;
}
void Animation::clearAnimSources()
@ -817,19 +911,23 @@ namespace MWRender
return;
}
AnimStateMap::iterator foundstateiter = mStates.find(groupname);
if (foundstateiter != mStates.end())
{
foundstateiter->second.mPriority = priority;
}
AnimStateMap::iterator stateiter = mStates.begin();
while (stateiter != mStates.end())
{
if (stateiter->second.mPriority == priority)
if (stateiter->second.mPriority == priority && stateiter->first != groupname)
mStates.erase(stateiter++);
else
++stateiter;
}
stateiter = mStates.find(groupname);
if (stateiter != mStates.end())
if (foundstateiter != mStates.end())
{
stateiter->second.mPriority = priority;
resetActiveGroups();
return;
}
@ -849,6 +947,8 @@ namespace MWRender
state.mPriority = priority;
state.mBlendMask = blendMask;
state.mAutoDisable = autodisable;
state.mGroupname = groupname;
state.mStartKey = start;
mStates[std::string{ groupname }] = state;
if (state.mPlaying)
@ -981,6 +1081,48 @@ namespace MWRender
return mNodeMap;
}
template <typename ControllerType>
inline osg::Callback* Animation::handleBlendTransform(const osg::ref_ptr<osg::Node>& node,
osg::ref_ptr<SceneUtil::KeyframeController> keyframeController,
std::map<osg::ref_ptr<osg::Node>, osg::ref_ptr<ControllerType>>& blendControllers,
const AnimBlendStateData& stateData, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules,
const AnimState& active)
{
osg::ref_ptr<ControllerType> animController;
if (blendControllers.contains(node))
{
animController = blendControllers.at(node);
animController->setKeyframeTrack(keyframeController, stateData, blendRules);
}
else
{
animController = new ControllerType(keyframeController, stateData, blendRules);
blendControllers.emplace(node, animController);
if constexpr (std::is_same_v<ControllerType, BoneAnimBlendController>)
assignBoneBlendCallbackRecursive(animController, node, true);
}
keyframeController->mTime = active.mTime;
osg::Callback* asCallback = animController->getAsCallback();
if constexpr (std::is_same_v<ControllerType, BoneAnimBlendController>)
{
// IMPORTANT: we must gather all transforms at point of change before next update
// instead of at the root update callback because the root bone may require blending.
if (animController->getBlendTrigger())
animController->gatherRecursiveBoneTransforms(static_cast<osgAnimation::Bone*>(node.get()));
// Register blend callback after the initial animation callback
node->addUpdateCallback(asCallback);
mActiveControllers.emplace_back(node, asCallback);
return keyframeController->getAsCallback();
}
return asCallback;
}
void Animation::resetActiveGroups()
{
// remove all previous external controllers from the scene graph
@ -1004,7 +1146,7 @@ namespace MWRender
AnimStateMap::const_iterator state = mStates.begin();
for (; state != mStates.end(); ++state)
{
if (!(state->second.mBlendMask & (1 << blendMask)))
if (!state->second.blendMaskContains(blendMask))
continue;
if (active == mStates.end()
@ -1019,6 +1161,8 @@ namespace MWRender
if (active != mStates.end())
{
std::shared_ptr<AnimSource> animsrc = active->second.mSource;
const AnimBlendStateData stateData
= { .mGroupname = active->second.mGroupname, .mStartKey = active->second.mStartKey };
for (AnimSource::ControllerMap::iterator it = animsrc->mControllerMap[blendMask].begin();
it != animsrc->mControllerMap[blendMask].end(); ++it)
@ -1026,7 +1170,23 @@ namespace MWRender
osg::ref_ptr<osg::Node> node = getNodeMap().at(
it->first); // this should not throw, we already checked for the node existing in addAnimSource
const bool useSmoothAnims = Settings::game().mSmoothAnimTransitions;
osg::Callback* callback = it->second->getAsCallback();
if (useSmoothAnims)
{
if (dynamic_cast<NifOsg::MatrixTransform*>(node.get()))
{
callback = handleBlendTransform<NifAnimBlendController>(node, it->second,
mAnimBlendControllers, stateData, animsrc->mAnimBlendRules, active->second);
}
else if (dynamic_cast<osgAnimation::Bone*>(node.get()))
{
callback = handleBlendTransform<BoneAnimBlendController>(node, it->second,
mBoneAnimBlendControllers, stateData, animsrc->mAnimBlendRules, active->second);
}
}
node->addUpdateCallback(callback);
mActiveControllers.emplace_back(node, callback);
@ -1046,6 +1206,7 @@ namespace MWRender
}
}
}
addControllers();
}
@ -1790,13 +1951,15 @@ namespace MWRender
osg::Callback* cb = node->getUpdateCallback();
while (cb)
{
if (dynamic_cast<SceneUtil::KeyframeController*>(cb))
if (dynamic_cast<NifAnimBlendController*>(cb) || dynamic_cast<BoneAnimBlendController*>(cb)
|| dynamic_cast<SceneUtil::KeyframeController*>(cb))
{
foundKeyframeCtrl = true;
break;
}
cb = cb->getNestedCallback();
}
// Note: AnimBlendController also does the reset so if one is present - we should add the rotation node
// Without KeyframeController the orientation will not be reseted each frame, so
// RotateController shouldn't be used for such nodes.
if (!foundKeyframeCtrl)

@ -2,6 +2,7 @@
#define GAME_RENDER_ANIMATION_H
#include "animationpriority.hpp"
#include "animblendcontroller.hpp"
#include "blendmask.hpp"
#include "bonegroup.hpp"
@ -9,12 +10,15 @@
#include "../mwworld/ptr.hpp"
#include <components/misc/strings/algorithm.hpp>
#include <components/sceneutil/animblendrules.hpp>
#include <components/sceneutil/controller.hpp>
#include <components/sceneutil/nodecallback.hpp>
#include <components/sceneutil/textkeymap.hpp>
#include <components/sceneutil/util.hpp>
#include <map>
#include <span>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
@ -47,6 +51,8 @@ namespace MWRender
class RotateController;
class TransparencyUpdater;
using ActiveControllersVector = std::vector<std::pair<osg::ref_ptr<osg::Node>, osg::ref_ptr<osg::Callback>>>;
class EffectAnimationTime : public SceneUtil::ControllerSource
{
private:
@ -158,9 +164,12 @@ namespace MWRender
int mBlendMask = 0;
bool mAutoDisable = true;
std::string mGroupname;
std::string mStartKey;
float getTime() const { return *mTime; }
void setTime(float time) { *mTime = time; }
bool blendMaskContains(size_t blendMask) const { return (mBlendMask & (1 << blendMask)); }
bool shouldLoop() const { return getTime() >= mLoopStopTime && mLoopingEnabled && mLoopCount > 0; }
};
@ -189,7 +198,11 @@ namespace MWRender
// Keep track of controllers that we added to our scene graph.
// We may need to rebuild these controllers when the active animation groups / sources change.
std::vector<std::pair<osg::ref_ptr<osg::Node>, osg::ref_ptr<osg::Callback>>> mActiveControllers;
ActiveControllersVector mActiveControllers;
// Keep track of the animation controllers for easy access
std::map<osg::ref_ptr<osg::Node>, osg::ref_ptr<NifAnimBlendController>> mAnimBlendControllers;
std::map<osg::ref_ptr<osg::Node>, osg::ref_ptr<BoneAnimBlendController>> mBoneAnimBlendControllers;
std::shared_ptr<AnimationTime> mAnimationTimePtr[sNumBlendMasks];
@ -233,7 +246,9 @@ namespace MWRender
const NodeMap& getNodeMap() const;
/* Sets the appropriate animations on the bone groups based on priority.
/* Sets the appropriate animations on the bone groups based on priority by finding
* the highest priority AnimationStates and linking the appropriate controllers stored
* in the AnimationState to the corresponding nodes.
*/
void resetActiveGroups();
@ -275,7 +290,7 @@ namespace MWRender
* @param baseModel The filename of the mObjectRoot, only used for error messages.
*/
void addAnimSource(std::string_view model, const std::string& baseModel);
void addSingleAnimSource(const std::string& model, const std::string& baseModel);
std::shared_ptr<AnimSource> addSingleAnimSource(const std::string& model, const std::string& baseModel);
/** Adds an additional light to the given node using the specified ESM record. */
void addExtraLight(osg::ref_ptr<osg::Group> parent, const SceneUtil::LightCommon& light);
@ -291,6 +306,13 @@ namespace MWRender
void removeFromSceneImpl();
template <typename ControllerType>
inline osg::Callback* handleBlendTransform(const osg::ref_ptr<osg::Node>& node,
osg::ref_ptr<SceneUtil::KeyframeController> keyframeController,
std::map<osg::ref_ptr<osg::Node>, osg::ref_ptr<ControllerType>>& blendControllers,
const AnimBlendStateData& stateData, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules,
const AnimState& active);
public:
Animation(
const MWWorld::Ptr& ptr, osg::ref_ptr<osg::Group> parentNode, Resource::ResourceSystem* resourceSystem);
@ -343,6 +365,7 @@ namespace MWRender
void setAccumulation(const osg::Vec3f& accum);
/** Plays an animation.
* Creates or updates AnimationStates to represent and manage animation playback.
* \param groupname Name of the animation group to play.
* \param priority Priority of the animation. The animation will play on
* bone groups that don't have another animation set of a
@ -491,6 +514,5 @@ namespace MWRender
private:
double mStartingTime;
};
}
#endif

@ -0,0 +1,388 @@
#include "animblendcontroller.hpp"
#include "rotatecontroller.hpp"
#include <components/debug/debuglog.hpp>
#include <osgAnimation/Bone>
#include <cassert>
#include <string>
#include <vector>
namespace MWRender
{
namespace
{
// Animation Easing/Blending functions
namespace Easings
{
float linear(float x)
{
return x;
}
float sineOut(float x)
{
return std::sin((x * osg::PIf) / 2.f);
}
float sineIn(float x)
{
return 1.f - std::cos((x * osg::PIf) / 2.f);
}
float sineInOut(float x)
{
return -(std::cos(osg::PIf * x) - 1.f) / 2.f;
}
float cubicOut(float t)
{
float t1 = 1.f - t;
return 1.f - (t1 * t1 * t1); // (1-t)^3
}
float cubicIn(float x)
{
return x * x * x; // x^3
}
float cubicInOut(float x)
{
if (x < 0.5f)
{
return 4.f * x * x * x; // 4x^3
}
else
{
float x2 = -2.f * x + 2.f;
return 1.f - (x2 * x2 * x2) / 2.f; // (1 - (-2x + 2)^3)/2
}
}
float quartOut(float t)
{
float t1 = 1.f - t;
return 1.f - (t1 * t1 * t1 * t1); // (1-t)^4
}
float quartIn(float t)
{
return t * t * t * t; // t^4
}
float quartInOut(float x)
{
if (x < 0.5f)
{
return 8.f * x * x * x * x; // 8x^4
}
else
{
float x2 = -2.f * x + 2.f;
return 1.f - (x2 * x2 * x2 * x2) / 2.f; // 1 - ((-2x + 2)^4)/2
}
}
float springOutGeneric(float x, float lambda)
{
// Higher lambda = lower swing amplitude. 1 = 150% swing amplitude.
// w is the frequency of oscillation in the easing func, controls the amount of overswing
const float w = 1.5f * osg::PIf; // 4.71238
return 1.f - expf(-lambda * x) * std::cos(w * x);
}
float springOutWeak(float x)
{
return springOutGeneric(x, 4.f);
}
float springOutMed(float x)
{
return springOutGeneric(x, 3.f);
}
float springOutStrong(float x)
{
return springOutGeneric(x, 2.f);
}
float springOutTooMuch(float x)
{
return springOutGeneric(x, 1.f);
}
const std::unordered_map<std::string, EasingFn> easingsMap = {
{ "linear", Easings::linear },
{ "sineOut", Easings::sineOut },
{ "sineIn", Easings::sineIn },
{ "sineInOut", Easings::sineInOut },
{ "cubicOut", Easings::cubicOut },
{ "cubicIn", Easings::cubicIn },
{ "cubicInOut", Easings::cubicInOut },
{ "quartOut", Easings::quartOut },
{ "quartIn", Easings::quartIn },
{ "quartInOut", Easings::quartInOut },
{ "springOutWeak", Easings::springOutWeak },
{ "springOutMed", Easings::springOutMed },
{ "springOutStrong", Easings::springOutStrong },
{ "springOutTooMuch", Easings::springOutTooMuch },
};
}
osg::Vec3f vec3fLerp(float t, const osg::Vec3f& start, const osg::Vec3f& end)
{
return start + (end - start) * t;
}
}
AnimBlendController::AnimBlendController(const osg::ref_ptr<SceneUtil::KeyframeController>& keyframeTrack,
const AnimBlendStateData& newState, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules)
: mEasingFn(&Easings::sineOut)
{
setKeyframeTrack(keyframeTrack, newState, blendRules);
}
NifAnimBlendController::NifAnimBlendController(const osg::ref_ptr<SceneUtil::KeyframeController>& keyframeTrack,
const AnimBlendStateData& newState, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules)
: AnimBlendController(keyframeTrack, newState, blendRules)
{
}
BoneAnimBlendController::BoneAnimBlendController(const osg::ref_ptr<SceneUtil::KeyframeController>& keyframeTrack,
const AnimBlendStateData& newState, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules)
: AnimBlendController(keyframeTrack, newState, blendRules)
{
}
void AnimBlendController::setKeyframeTrack(const osg::ref_ptr<SceneUtil::KeyframeController>& kft,
const AnimBlendStateData& newState, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules)
{
// If animation has changed then start blending
if (newState.mGroupname != mAnimState.mGroupname || newState.mStartKey != mAnimState.mStartKey
|| kft != mKeyframeTrack)
{
// Default blend settings
mBlendDuration = 0;
mEasingFn = &Easings::sineOut;
if (blendRules)
{
// Finds a matching blend rule either in this or previous ruleset
auto blendRule = blendRules->findBlendingRule(
mAnimState.mGroupname, mAnimState.mStartKey, newState.mGroupname, newState.mStartKey);
if (blendRule)
{
if (const auto it = Easings::easingsMap.find(blendRule->mEasing); it != Easings::easingsMap.end())
{
mEasingFn = it->second;
mBlendDuration = blendRule->mDuration;
}
else
{
Log(Debug::Warning)
<< "Warning: animation blending rule contains invalid easing type: " << blendRule->mEasing;
}
}
}
mAnimBlendRules = blendRules;
mKeyframeTrack = kft;
mAnimState = newState;
mBlendTrigger = true;
}
}
void AnimBlendController::calculateInterpFactor(float time)
{
if (mBlendDuration != 0)
mTimeFactor = std::min((time - mBlendStartTime) / mBlendDuration, 1.0f);
else
mTimeFactor = 1;
mInterpActive = mTimeFactor < 1.0;
if (mInterpActive)
mInterpFactor = mEasingFn(mTimeFactor);
else
mInterpFactor = 1.0f;
}
void BoneAnimBlendController::gatherRecursiveBoneTransforms(osgAnimation::Bone* bone, bool isRoot)
{
// Incase group traversal encountered something that isnt a bone
if (!bone)
return;
mBlendBoneTransforms[bone] = bone->getMatrix();
osg::Group* group = bone->asGroup();
if (group)
{
for (unsigned int i = 0; i < group->getNumChildren(); ++i)
gatherRecursiveBoneTransforms(dynamic_cast<osgAnimation::Bone*>(group->getChild(i)), false);
}
}
void BoneAnimBlendController::applyBoneBlend(osgAnimation::Bone* bone)
{
// If we are done with interpolation then we can safely skip this as the bones are correct
if (!mInterpActive)
return;
// Shouldn't happen, but potentially an edge case where a new bone was added
// between gatherRecursiveBoneTransforms and this update
// currently OpenMW will never do this
assert(mBlendBoneTransforms.find(bone) != mBlendBoneTransforms.end());
// Every frame the osgAnimation controller updates this
// so it is ok that we update it directly below
const osg::Matrixf& currentSampledMatrix = bone->getMatrix();
const osg::Matrixf& lastSampledMatrix = mBlendBoneTransforms.at(bone);
const osg::Vec3f scale = currentSampledMatrix.getScale();
const osg::Quat rotation = currentSampledMatrix.getRotate();
const osg::Vec3f translation = currentSampledMatrix.getTrans();
const osg::Quat blendRotation = lastSampledMatrix.getRotate();
const osg::Vec3f blendTrans = lastSampledMatrix.getTrans();
osg::Quat lerpedRot;
lerpedRot.slerp(mInterpFactor, blendRotation, rotation);
osg::Matrixf lerpedMatrix;
lerpedMatrix.makeRotate(lerpedRot);
lerpedMatrix.setTrans(vec3fLerp(mInterpFactor, blendTrans, translation));
// Scale is not lerped based on the idea that it is much more likely that scale animation will be used to
// instantly hide/show objects in which case the scale interpolation is undesirable.
lerpedMatrix = osg::Matrixd::scale(scale) * lerpedMatrix;
// Apply new blended matrix
osgAnimation::Bone* boneParent = bone->getBoneParent();
bone->setMatrix(lerpedMatrix);
if (boneParent)
bone->setMatrixInSkeletonSpace(lerpedMatrix * boneParent->getMatrixInSkeletonSpace());
else
bone->setMatrixInSkeletonSpace(lerpedMatrix);
}
void BoneAnimBlendController::operator()(osgAnimation::Bone* node, osg::NodeVisitor* nv)
{
// HOW THIS WORKS: This callback method is called only for bones with attached keyframe controllers
// such as bip01, bip01 spine1 etc. The child bones of these controllers have their own callback wrapper
// which will call this instance's applyBoneBlend for each child bone. The order of update is important
// as the blending calculations expect the bone's skeleton matrix to be at the sample point
float time = nv->getFrameStamp()->getSimulationTime();
assert(node != nullptr);
if (mBlendTrigger)
{
mBlendTrigger = false;
mBlendStartTime = time;
}
calculateInterpFactor(time);
if (mInterpActive)
applyBoneBlend(node);
SceneUtil::NodeCallback<BoneAnimBlendController, osgAnimation::Bone*>::traverse(node, nv);
}
void NifAnimBlendController::operator()(NifOsg::MatrixTransform* node, osg::NodeVisitor* nv)
{
// HOW THIS WORKS: The actual retrieval of the bone transformation based on animation is done by the
// KeyframeController (mKeyframeTrack). The KeyframeController retreives time data (playback position) every
// frame from controller's input (getInputValue(nv)) which is bound to an appropriate AnimationState time value
// in Animation.cpp. Animation.cpp ultimately manages animation playback via updating AnimationState objects and
// determines when and what should be playing.
// This controller exploits KeyframeController to get transformations and upon animation change blends from
// the last known position to the new animated one.
auto [translation, rotation, scale] = mKeyframeTrack->getCurrentTransformation(nv);
float time = nv->getFrameStamp()->getSimulationTime();
if (mBlendTrigger)
{
mBlendTrigger = false;
mBlendStartTime = time;
// Nif mRotationScale is used here because it's unaffected by the side-effects of RotationController
mBlendStartRot = node->mRotationScale.toOsgMatrix().getRotate();
mBlendStartTrans = node->getMatrix().getTrans();
mBlendStartScale = node->mScale;
// Subtract any rotate controller's offset from start transform (if it appears after this callback)
// this is required otherwise the blend start will be with an offset, then offset could be applied again
// fixes an issue with camera jumping during first person sneak jumping camera
osg::Callback* updateCb = node->getUpdateCallback()->getNestedCallback();
while (updateCb)
{
MWRender::RotateController* rotateController = dynamic_cast<MWRender::RotateController*>(updateCb);
if (rotateController)
{
const osg::Quat& rotate = rotateController->getRotate();
const osg::Vec3f& offset = rotateController->getOffset();
osg::NodePathList nodepaths = node->getParentalNodePaths(rotateController->getRelativeTo());
osg::Quat worldOrient;
if (!nodepaths.empty())
{
osg::Matrixf worldMat = osg::computeLocalToWorld(nodepaths[0]);
worldOrient = worldMat.getRotate();
}
worldOrient = worldOrient * rotate.inverse();
const osg::Quat worldOrientInverse = worldOrient.inverse();
mBlendStartTrans -= worldOrientInverse * offset;
}
updateCb = updateCb->getNestedCallback();
}
}
calculateInterpFactor(time);
if (mInterpActive)
{
if (rotation)
{
osg::Quat lerpedRot;
lerpedRot.slerp(mInterpFactor, mBlendStartRot, *rotation);
node->setRotation(lerpedRot);
}
else
{
// This is necessary to prevent first person animation glitching out
node->setRotation(node->mRotationScale);
}
if (translation)
{
osg::Vec3f lerpedTrans = vec3fLerp(mInterpFactor, mBlendStartTrans, *translation);
node->setTranslation(lerpedTrans);
}
}
else
{
if (translation)
node->setTranslation(*translation);
if (rotation)
node->setRotation(*rotation);
else
node->setRotation(node->mRotationScale);
}
if (scale)
// Scale is not lerped based on the idea that it is much more likely that scale animation will be used to
// instantly hide/show objects in which case the scale interpolation is undesirable.
node->setScale(*scale);
SceneUtil::NodeCallback<NifAnimBlendController, NifOsg::MatrixTransform*>::traverse(node, nv);
}
}

@ -0,0 +1,142 @@
#ifndef OPENMW_MWRENDER_ANIMBLENDCONTROLLER_H
#define OPENMW_MWRENDER_ANIMBLENDCONTROLLER_H
#include <map>
#include <optional>
#include <string>
#include <unordered_map>
#include <osgAnimation/Bone>
#include <components/nifosg/matrixtransform.hpp>
#include <components/sceneutil/animblendrules.hpp>
#include <components/sceneutil/controller.hpp>
#include <components/sceneutil/keyframe.hpp>
#include <components/sceneutil/nodecallback.hpp>
namespace MWRender
{
typedef float (*EasingFn)(float);
struct AnimBlendStateData
{
std::string mGroupname;
std::string mStartKey;
};
class AnimBlendController : public SceneUtil::Controller
{
public:
AnimBlendController(const osg::ref_ptr<SceneUtil::KeyframeController>& keyframeTrack,
const AnimBlendStateData& animState, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules);
AnimBlendController() {}
void setKeyframeTrack(const osg::ref_ptr<SceneUtil::KeyframeController>& kft,
const AnimBlendStateData& animState, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules);
bool getBlendTrigger() const { return mBlendTrigger; }
protected:
EasingFn mEasingFn;
float mBlendDuration = 0.0f;
float mBlendStartTime = 0.0f;
float mTimeFactor = 0.0f;
float mInterpFactor = 0.0f;
bool mBlendTrigger = false;
bool mInterpActive = false;
AnimBlendStateData mAnimState;
osg::ref_ptr<const SceneUtil::AnimBlendRules> mAnimBlendRules;
osg::ref_ptr<SceneUtil::KeyframeController> mKeyframeTrack;
std::unordered_map<osg::Node*, osg::Matrixf> mBlendBoneTransforms;
inline void calculateInterpFactor(float time);
};
class NifAnimBlendController : public SceneUtil::NodeCallback<NifAnimBlendController, NifOsg::MatrixTransform*>,
public AnimBlendController
{
public:
NifAnimBlendController(const osg::ref_ptr<SceneUtil::KeyframeController>& keyframeTrack,
const AnimBlendStateData& animState, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules);
NifAnimBlendController() {}
NifAnimBlendController(const NifAnimBlendController& other, const osg::CopyOp&)
: NifAnimBlendController(other.mKeyframeTrack, other.mAnimState, other.mAnimBlendRules)
{
}
META_Object(MWRender, NifAnimBlendController)
void operator()(NifOsg::MatrixTransform* node, osg::NodeVisitor* nv);
osg::Callback* getAsCallback() { return this; }
private:
osg::Quat mBlendStartRot;
osg::Vec3f mBlendStartTrans;
float mBlendStartScale = 0.0f;
};
class BoneAnimBlendController : public SceneUtil::NodeCallback<BoneAnimBlendController, osgAnimation::Bone*>,
public AnimBlendController
{
public:
BoneAnimBlendController(const osg::ref_ptr<SceneUtil::KeyframeController>& keyframeTrack,
const AnimBlendStateData& animState, const osg::ref_ptr<const SceneUtil::AnimBlendRules>& blendRules);
BoneAnimBlendController() {}
BoneAnimBlendController(const BoneAnimBlendController& other, const osg::CopyOp&)
: BoneAnimBlendController(other.mKeyframeTrack, other.mAnimState, other.mAnimBlendRules)
{
}
void gatherRecursiveBoneTransforms(osgAnimation::Bone* parent, bool isRoot = true);
void applyBoneBlend(osgAnimation::Bone* parent);
META_Object(MWRender, BoneAnimBlendController)
void operator()(osgAnimation::Bone* node, osg::NodeVisitor* nv);
osg::Callback* getAsCallback() { return this; }
};
// Assigned to child bones with an instance of AnimBlendController
class BoneAnimBlendControllerWrapper : public osg::Callback
{
public:
BoneAnimBlendControllerWrapper(osg::ref_ptr<BoneAnimBlendController> rootCallback, osgAnimation::Bone* node)
: mRootCallback(rootCallback)
, mNode(node)
{
}
BoneAnimBlendControllerWrapper() {}
BoneAnimBlendControllerWrapper(const BoneAnimBlendControllerWrapper& copy, const osg::CopyOp&)
: mRootCallback(copy.mRootCallback)
, mNode(copy.mNode)
{
}
META_Object(MWRender, BoneAnimBlendControllerWrapper)
bool run(osg::Object* object, osg::Object* data) override
{
mRootCallback->applyBoneBlend(mNode);
traverse(object, data);
return true;
}
private:
osg::ref_ptr<BoneAnimBlendController> mRootCallback;
osgAnimation::Bone* mNode;
};
}
#endif

@ -24,6 +24,12 @@ namespace MWRender
void setOffset(const osg::Vec3f& offset);
void setRotate(const osg::Quat& rotate);
const osg::Vec3f& getOffset() const { return mOffset; }
const osg::Quat& getRotate() const { return mRotate; }
osg::Node* getRelativeTo() const { return mRelativeTo; }
void operator()(osg::MatrixTransform* node, osg::NodeVisitor* nv);
protected:

@ -127,7 +127,7 @@ add_component_dir (vfs
)
add_component_dir (resource
scenemanager keyframemanager imagemanager bulletshapemanager bulletshape niffilemanager objectcache multiobjectcache resourcesystem
scenemanager keyframemanager imagemanager animblendrulesmanager bulletshapemanager bulletshape niffilemanager objectcache multiobjectcache resourcesystem
resourcemanager stats animation foreachbulletobject errormarker cachestats bgsmfilemanager
)
@ -138,7 +138,7 @@ add_component_dir (shader
add_component_dir (sceneutil
clone attach visitor util statesetupdater controller skeleton riggeometry morphgeometry lightcontroller
lightmanager lightutil positionattitudetransform workqueue pathgridutil waterutil writescene serialize optimizer
detourdebugdraw navmesh agentpath shadow mwshadowtechnique recastmesh shadowsbin osgacontroller rtt
detourdebugdraw navmesh agentpath animblendrules shadow mwshadowtechnique recastmesh shadowsbin osgacontroller rtt
screencapture depth color riggeometryosgaextension extradata unrefqueue lightcommon lightingmethod clearcolor
cullsafeboundsvisitor keyframe nodecallback textkeymap glextensions
)

@ -52,6 +52,17 @@ namespace Nif
return false;
return true;
}
osg::Matrixf toOsgMatrix() const
{
osg::Matrixf osgMat;
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
osgMat(i, j) = mValues[j][i]; // NB: column/row major difference
return osgMat;
}
};
struct NiTransform

@ -5,6 +5,8 @@
#include <osg/TexMat>
#include <osg/Texture2D>
#include <osgAnimation/Bone>
#include <osgParticle/Emitter>
#include <components/nif/data.hpp>
@ -175,25 +177,48 @@ namespace NifOsg
void KeyframeController::operator()(NifOsg::MatrixTransform* node, osg::NodeVisitor* nv)
{
auto [translation, rotation, scale] = getCurrentTransformation(nv);
if (rotation)
{
node->setRotation(*rotation);
}
else
{
// This is necessary to prevent first person animations glitching out due to RotationController
node->setRotation(node->mRotationScale);
}
if (translation)
node->setTranslation(*translation);
if (scale)
node->setScale(*scale);
traverse(node, nv);
}
KeyframeController::KfTransform KeyframeController::getCurrentTransformation(osg::NodeVisitor* nv)
{
KfTransform out;
if (hasInput())
{
float time = getInputValue(nv);
if (!mRotations.empty())
node->setRotation(mRotations.interpKey(time));
out.mRotation = mRotations.interpKey(time);
else if (!mXRotations.empty() || !mYRotations.empty() || !mZRotations.empty())
node->setRotation(getXYZRotation(time));
else
node->setRotation(node->mRotationScale);
out.mRotation = getXYZRotation(time);
if (!mTranslations.empty())
node->setTranslation(mTranslations.interpKey(time));
out.mTranslation = mTranslations.interpKey(time);
if (!mScales.empty())
node->setScale(mScales.interpKey(time));
out.mScale = mScales.interpKey(time);
}
traverse(node, nv);
return out;
}
GeomMorpherController::GeomMorpherController() {}

@ -238,6 +238,8 @@ namespace NifOsg
osg::Vec3f getTranslation(float time) const override;
osg::Callback* getAsCallback() override { return this; }
KfTransform getCurrentTransformation(osg::NodeVisitor* nv) override;
void operator()(NifOsg::MatrixTransform*, osg::NodeVisitor*);
private:

@ -0,0 +1,76 @@
#include "animblendrulesmanager.hpp"
#include <array>
#include <components/vfs/manager.hpp>
#include <osg/Stats>
#include <osgAnimation/Animation>
#include <osgAnimation/BasicAnimationManager>
#include <osgAnimation/Channel>
#include <components/debug/debuglog.hpp>
#include <components/misc/pathhelpers.hpp>
#include <components/sceneutil/osgacontroller.hpp>
#include <components/vfs/pathutil.hpp>
#include <components/resource/scenemanager.hpp>
#include "objectcache.hpp"
#include "scenemanager.hpp"
namespace Resource
{
using AnimBlendRules = SceneUtil::AnimBlendRules;
AnimBlendRulesManager::AnimBlendRulesManager(const VFS::Manager* vfs, double expiryDelay)
: ResourceManager(vfs, expiryDelay)
{
}
osg::ref_ptr<const AnimBlendRules> AnimBlendRulesManager::getRules(
const VFS::Path::NormalizedView path, const VFS::Path::NormalizedView overridePath)
{
// Note: Providing a non-existing path but an existing overridePath is not supported!
auto tmpl = loadRules(path);
if (!tmpl)
return nullptr;
// Create an instance based on template and store template reference inside so the template will not be removed
// from cache
osg::ref_ptr<SceneUtil::AnimBlendRules> blendRules(new AnimBlendRules(*tmpl, osg::CopyOp::SHALLOW_COPY));
blendRules->getOrCreateUserDataContainer()->addUserObject(new Resource::TemplateRef(tmpl));
if (!overridePath.value().empty())
{
auto blendRuleOverrides = loadRules(overridePath);
if (blendRuleOverrides)
{
blendRules->addOverrideRules(*blendRuleOverrides);
}
blendRules->getOrCreateUserDataContainer()->addUserObject(new Resource::TemplateRef(blendRuleOverrides));
}
return blendRules;
}
osg::ref_ptr<const AnimBlendRules> AnimBlendRulesManager::loadRules(VFS::Path::NormalizedView path)
{
std::optional<osg::ref_ptr<osg::Object>> obj = mCache->getRefFromObjectCacheOrNone(path);
if (obj.has_value())
{
return osg::ref_ptr<AnimBlendRules>(static_cast<AnimBlendRules*>(obj->get()));
}
osg::ref_ptr<AnimBlendRules> blendRules = AnimBlendRules::fromFile(mVFS, path);
mCache->addEntryToObjectCache(path.value(), blendRules);
return blendRules;
}
void AnimBlendRulesManager::reportStats(unsigned int frameNumber, osg::Stats* stats) const
{
Resource::reportStats("Blending Rules", frameNumber, mCache->getStats(), *stats);
}
}

@ -0,0 +1,34 @@
#ifndef OPENMW_COMPONENTS_ANIMBLENDRULESMANAGER_H
#define OPENMW_COMPONENTS_ANIMBLENDRULESMANAGER_H
#include <osg/ref_ptr>
#include <string>
#include <components/sceneutil/animblendrules.hpp>
#include "resourcemanager.hpp"
namespace Resource
{
/// @brief Managing of keyframe resources
/// @note May be used from any thread.
class AnimBlendRulesManager : public ResourceManager
{
public:
explicit AnimBlendRulesManager(const VFS::Manager* vfs, double expiryDelay);
~AnimBlendRulesManager() = default;
/// Retrieve a read-only keyframe resource by name (case-insensitive).
/// @note Throws an exception if the resource is not found.
osg::ref_ptr<const SceneUtil::AnimBlendRules> getRules(
const VFS::Path::NormalizedView path, const VFS::Path::NormalizedView overridePath);
void reportStats(unsigned int frameNumber, osg::Stats* stats) const override;
private:
osg::ref_ptr<const SceneUtil::AnimBlendRules> loadRules(VFS::Path::NormalizedView path);
};
}
#endif

@ -2,6 +2,7 @@
#include <algorithm>
#include "animblendrulesmanager.hpp"
#include "bgsmfilemanager.hpp"
#include "imagemanager.hpp"
#include "keyframemanager.hpp"
@ -21,6 +22,7 @@ namespace Resource
mSceneManager = std::make_unique<SceneManager>(
vfs, mImageManager.get(), mNifFileManager.get(), mBgsmFileManager.get(), expiryDelay);
mKeyframeManager = std::make_unique<KeyframeManager>(vfs, mSceneManager.get(), expiryDelay, encoder);
mAnimBlendRulesManager = std::make_unique<AnimBlendRulesManager>(vfs, expiryDelay);
addResourceManager(mNifFileManager.get());
addResourceManager(mBgsmFileManager.get());
@ -28,6 +30,7 @@ namespace Resource
// note, scene references images so add images afterwards for correct implementation of updateCache()
addResourceManager(mSceneManager.get());
addResourceManager(mImageManager.get());
addResourceManager(mAnimBlendRulesManager.get());
}
ResourceSystem::~ResourceSystem()
@ -62,6 +65,11 @@ namespace Resource
return mKeyframeManager.get();
}
AnimBlendRulesManager* ResourceSystem::getAnimBlendRulesManager()
{
return mAnimBlendRulesManager.get();
}
void ResourceSystem::setExpiryDelay(double expiryDelay)
{
for (std::vector<BaseResourceManager*>::iterator it = mResourceManagers.begin(); it != mResourceManagers.end();

@ -29,6 +29,7 @@ namespace Resource
class NifFileManager;
class KeyframeManager;
class BaseResourceManager;
class AnimBlendRulesManager;
/// @brief Wrapper class that constructs and provides access to the most commonly used resource subsystems.
/// @par Resource subsystems can be used with multiple OpenGL contexts, just like the OSG equivalents, but
@ -45,6 +46,7 @@ namespace Resource
BgsmFileManager* getBgsmFileManager();
NifFileManager* getNifFileManager();
KeyframeManager* getKeyframeManager();
AnimBlendRulesManager* getAnimBlendRulesManager();
/// Indicates to each resource manager to clear the cache, i.e. to drop cached objects that are no longer
/// referenced.
@ -79,6 +81,7 @@ namespace Resource
std::unique_ptr<BgsmFileManager> mBgsmFileManager;
std::unique_ptr<NifFileManager> mNifFileManager;
std::unique_ptr<KeyframeManager> mKeyframeManager;
std::unique_ptr<AnimBlendRulesManager> mAnimBlendRulesManager;
// Store the base classes separately to get convenient access to the common interface
// Here users can register their own resourcemanager as well

@ -93,6 +93,7 @@ namespace Resource
"Terrain Chunk",
"Terrain Texture",
"Land",
"Blending Rules",
};
constexpr std::string_view cellPreloader[] = {

@ -0,0 +1,170 @@
#include "animblendrules.hpp"
#include <iterator>
#include <map>
#include <components/misc/strings/algorithm.hpp>
#include <components/misc/strings/format.hpp>
#include <components/misc/strings/lower.hpp>
#include <components/debug/debuglog.hpp>
#include <components/files/configfileparser.hpp>
#include <components/files/conversion.hpp>
#include <components/sceneutil/controller.hpp>
#include <components/sceneutil/textkeymap.hpp>
#include <stdexcept>
#include <yaml-cpp/yaml.h>
namespace SceneUtil
{
namespace
{
std::pair<std::string, std::string> splitRuleName(std::string full)
{
std::string group;
std::string key;
size_t delimiterInd = full.find(":");
Misc::StringUtils::lowerCaseInPlace(full);
if (delimiterInd == std::string::npos)
{
group = full;
Misc::StringUtils::trim(group);
}
else
{
group = full.substr(0, delimiterInd);
key = full.substr(delimiterInd + 1);
Misc::StringUtils::trim(group);
Misc::StringUtils::trim(key);
}
return std::make_pair(group, key);
}
}
using BlendRule = AnimBlendRules::BlendRule;
AnimBlendRules::AnimBlendRules(const AnimBlendRules& copy, const osg::CopyOp& copyop)
: mRules(copy.mRules)
{
}
AnimBlendRules::AnimBlendRules(const std::vector<BlendRule>& rules)
: mRules(rules)
{
}
osg::ref_ptr<AnimBlendRules> AnimBlendRules::fromFile(const VFS::Manager* vfs, VFS::Path::NormalizedView configPath)
{
Log(Debug::Debug) << "Attempting to load animation blending config '" << configPath << "'";
if (!vfs->exists(configPath))
return nullptr;
// Retrieving and parsing animation rules
std::string rawYaml(std::istreambuf_iterator<char>(*vfs->get(configPath)), {});
std::vector<BlendRule> rules;
YAML::Node root = YAML::Load(rawYaml);
if (!root.IsDefined() || root.IsNull() || root.IsScalar())
{
Log(Debug::Error) << Misc::StringUtils::format(
"Can't parse file '%s'. Check that it's a valid YAML/JSON file.", configPath);
return nullptr;
}
if (root["blending_rules"])
{
for (const auto& it : root["blending_rules"])
{
if (it["from"] && it["to"] && it["duration"] && it["easing"])
{
auto fromNames = splitRuleName(it["from"].as<std::string>());
auto toNames = splitRuleName(it["to"].as<std::string>());
BlendRule ruleObj = {
.mFromGroup = fromNames.first,
.mFromKey = fromNames.second,
.mToGroup = toNames.first,
.mToKey = toNames.second,
.mDuration = it["duration"].as<float>(),
.mEasing = it["easing"].as<std::string>(),
};
rules.emplace_back(ruleObj);
}
else
{
Log(Debug::Warning) << "Warning: Blending rule '"
<< (it["from"] ? it["from"].as<std::string>() : "undefined") << "->"
<< (it["to"] ? it["to"].as<std::string>() : "undefined")
<< "' is missing some properties. File: '" << configPath << "'.";
}
}
}
else
{
throw std::domain_error(
Misc::StringUtils::format("'blending_rules' object not found in '%s' file!", configPath));
}
// If no rules then dont allocate any instance
if (rules.size() == 0)
return nullptr;
return new AnimBlendRules(rules);
}
void AnimBlendRules::addOverrideRules(const AnimBlendRules& overrideRules)
{
auto rules = overrideRules.getRules();
// Concat the rules together, overrides added at the end since the bottom-most rule has the highest priority.
mRules.insert(mRules.end(), rules.begin(), rules.end());
}
inline bool AnimBlendRules::fitsRuleString(const std::string_view str, const std::string_view ruleStr) const
{
// A wildcard only supported in the beginning or the end of the rule string in hopes that this will be more
// performant. And most likely this kind of support is enough.
return ruleStr == "*" || str == ruleStr || (ruleStr.starts_with("*") && str.ends_with(ruleStr.substr(1)))
|| (ruleStr.ends_with("*") && str.starts_with(ruleStr.substr(0, ruleStr.length() - 1)));
}
std::optional<BlendRule> AnimBlendRules::findBlendingRule(
std::string fromGroup, std::string fromKey, std::string toGroup, std::string toKey) const
{
Misc::StringUtils::lowerCaseInPlace(fromGroup);
Misc::StringUtils::lowerCaseInPlace(fromKey);
Misc::StringUtils::lowerCaseInPlace(toGroup);
Misc::StringUtils::lowerCaseInPlace(toKey);
for (auto rule = mRules.rbegin(); rule != mRules.rend(); ++rule)
{
bool fromMatch = false;
bool toMatch = false;
// Pseudocode:
// If not a wildcard and found a wildcard
// starts with substr(0,wildcard)
if (fitsRuleString(fromGroup, rule->mFromGroup)
&& (rule->mFromKey.empty() || fitsRuleString(fromKey, rule->mFromKey)))
{
fromMatch = true;
}
if ((fitsRuleString(toGroup, rule->mToGroup) || (rule->mToGroup == "$" && toGroup == fromGroup))
&& (rule->mToKey.empty() || fitsRuleString(toKey, rule->mToKey)))
{
toMatch = true;
}
if (fromMatch && toMatch)
return std::make_optional<BlendRule>(*rule);
}
return std::nullopt;
}
}

@ -0,0 +1,49 @@
#ifndef OPENMW_COMPONENTS_SCENEUTIL_ANIMBLENDRULES_HPP
#define OPENMW_COMPONENTS_SCENEUTIL_ANIMBLENDRULES_HPP
#include <optional>
#include <string>
#include <vector>
#include <osg/Object>
#include <components/vfs/manager.hpp>
namespace SceneUtil
{
class AnimBlendRules : public osg::Object
{
public:
struct BlendRule
{
std::string mFromGroup;
std::string mFromKey;
std::string mToGroup;
std::string mToKey;
float mDuration;
std::string mEasing;
};
AnimBlendRules() = default;
AnimBlendRules(const std::vector<BlendRule>& rules);
AnimBlendRules(const AnimBlendRules& copy, const osg::CopyOp& copyop);
META_Object(SceneUtil, AnimBlendRules)
void addOverrideRules(const AnimBlendRules& overrideRules);
std::optional<BlendRule> findBlendingRule(
std::string fromGroup, std::string fromKey, std::string toGroup, std::string toKey) const;
const std::vector<BlendRule>& getRules() const { return mRules; }
static osg::ref_ptr<AnimBlendRules> fromFile(const VFS::Manager* vfs, VFS::Path::NormalizedView yamlpath);
private:
std::vector<BlendRule> mRules;
inline bool fitsRuleString(const std::string_view str, const std::string_view ruleStr) const;
};
}
#endif

@ -2,6 +2,7 @@
#define OPENMW_COMPONENTS_SCENEUTIL_KEYFRAME_HPP
#include <map>
#include <optional>
#include <osg/Object>
@ -21,8 +22,19 @@ namespace SceneUtil
{
}
std::shared_ptr<float> mTime = std::make_shared<float>(0.0f);
struct KfTransform
{
std::optional<osg::Vec3f> mTranslation;
std::optional<osg::Quat> mRotation;
std::optional<float> mScale;
};
virtual osg::Vec3f getTranslation(float time) const { return osg::Vec3f(); }
virtual KfTransform getCurrentTransformation(osg::NodeVisitor* nv) { return KfTransform(); };
/// @note We could drop this function in favour of osg::Object::asCallback from OSG 3.6 on.
virtual osg::Callback* getAsCallback() = 0;
};

@ -187,19 +187,48 @@ namespace SceneUtil
mgr->addWrapper(new GeometrySerializer);
// ignore the below for now to avoid warning spam
const char* ignore[]
= { "Debug::DebugDrawer", "MWRender::PtrHolder", "Resource::TemplateRef", "Resource::TemplateMultiRef",
"SceneUtil::CompositeStateSetUpdater", "SceneUtil::UBOManager", "SceneUtil::LightListCallback",
"SceneUtil::LightManagerUpdateCallback", "SceneUtil::FFPLightStateAttribute",
"SceneUtil::UpdateRigBounds", "SceneUtil::UpdateRigGeometry", "SceneUtil::LightSource",
"SceneUtil::DisableLight", "SceneUtil::MWShadowTechnique", "SceneUtil::TextKeyMapHolder",
"Shader::AddedState", "Shader::RemovedAlphaFunc", "NifOsg::FlipController",
"NifOsg::KeyframeController", "NifOsg::Emitter", "NifOsg::ParticleColorAffector",
"NifOsg::ParticleSystem", "NifOsg::GravityAffector", "NifOsg::ParticleBomb",
"NifOsg::GrowFadeAffector", "NifOsg::InverseWorldMatrix", "NifOsg::StaticBoundingBoxCallback",
"NifOsg::GeomMorpherController", "NifOsg::UpdateMorphGeometry", "NifOsg::UVController",
"NifOsg::VisController", "osgMyGUI::Drawable", "osg::DrawCallback", "osg::UniformBufferObject",
"osgOQ::ClearQueriesCallback", "osgOQ::RetrieveQueriesCallback", "osg::DummyObject" };
const char* ignore[] = {
"Debug::DebugDrawer",
"MWRender::NifAnimBlendController",
"MWRender::BoneAnimBlendController",
"MWRender::BoneAnimBlendControllerWrapper",
"MWRender::PtrHolder",
"Resource::TemplateRef",
"Resource::TemplateMultiRef",
"SceneUtil::CompositeStateSetUpdater",
"SceneUtil::UBOManager",
"SceneUtil::LightListCallback",
"SceneUtil::LightManagerUpdateCallback",
"SceneUtil::FFPLightStateAttribute",
"SceneUtil::UpdateRigBounds",
"SceneUtil::UpdateRigGeometry",
"SceneUtil::LightSource",
"SceneUtil::DisableLight",
"SceneUtil::MWShadowTechnique",
"SceneUtil::TextKeyMapHolder",
"Shader::AddedState",
"Shader::RemovedAlphaFunc",
"NifOsg::FlipController",
"NifOsg::KeyframeController",
"NifOsg::Emitter",
"NifOsg::ParticleColorAffector",
"NifOsg::ParticleSystem",
"NifOsg::GravityAffector",
"NifOsg::ParticleBomb",
"NifOsg::GrowFadeAffector",
"NifOsg::InverseWorldMatrix",
"NifOsg::StaticBoundingBoxCallback",
"NifOsg::GeomMorpherController",
"NifOsg::UpdateMorphGeometry",
"NifOsg::UVController",
"NifOsg::VisController",
"osgMyGUI::Drawable",
"osg::DrawCallback",
"osg::UniformBufferObject",
"osgOQ::ClearQueriesCallback",
"osgOQ::RetrieveQueriesCallback",
"osg::DummyObject",
};
for (size_t i = 0; i < sizeof(ignore) / sizeof(ignore[0]); ++i)
{
mgr->addWrapper(makeDummySerializer(ignore[i]));

@ -39,6 +39,7 @@ namespace Settings
SettingValue<bool> mCanLootDuringDeathAnimation{ mIndex, "Game", "can loot during death animation" };
SettingValue<bool> mRebalanceSoulGemValues{ mIndex, "Game", "rebalance soul gem values" };
SettingValue<bool> mUseAdditionalAnimSources{ mIndex, "Game", "use additional anim sources" };
SettingValue<bool> mSmoothAnimTransitions{ mIndex, "Game", "smooth animation transitions" };
SettingValue<bool> mBarterDispositionChangeIsPermanent{ mIndex, "Game",
"barter disposition change is permanent" };
SettingValue<int> mStrengthInfluencesHandToHand{ mIndex, "Game", "strength influences hand to hand",

@ -0,0 +1,90 @@
Animation blending
##################
Animation blending introduces smooth animation transitions between essentially every animation in the game without affecting gameplay. Effective if ``smooth animation transitions`` setting is enabled in the launcher or the config files.
Animation developers can bundle ``.yaml``/``.json`` files together with their ``.kf`` files to specify the blending style of their animations. Those settings will only affect the corresponding animation files.
The default OpenMW animation blending config file (the global config) affects actors only, that restriction doesn't apply to other animation blending config files; they can affect animated objects too.
Do not override the global config file in your mod, instead create a ``your_modded_animation_file_name.yaml`` file and put it in the same folder as your ``.kf`` file.
For example, if your mod includes a ``newAnimations.kf`` file, you can put a ``newAnimations.yaml`` file beside it and fill it with your blending rules.
Animation config files shipped in this fashion will only affect your modded animations and will not meddle with other animations in the game.
Local (per-kf-file) animation rules will only affect transitions between animations provided in that file and transitions to those animations; they will not affect transitions from the file animation to some other animation.
Editing animation config files
------------------------------
In examples below ``.yaml`` config file will be used. You can provide ``.json`` files instead of ``.yaml`` if you adhere to the same overall structures and field names.
Animation blending config file is a list of blending rules that look like this:
::
blending_rules:
- from: "*"
to: "*"
easing: "sineOut"
duration: 0.25
- from: "*"
to: "idlesneak*"
easing: "springOutWeak"
duration: 0.4
See `files/data/animations/animation-config.yaml <https://gitlab.com/OpenMW/openmw/-/tree/master/files/data/animations/animation-config.yaml>`__ for an example of such a file.
Every blending rule should include a set of following fields:
``from`` and ``to`` are rules that will attempt to match animation names; they usually look like ``animationGroupName:keyName`` where ``keyName`` is essentially the name of a specific action within the animation group.
Examples: ``"weapononehanded: chop start"``, ``"idle1h"``, ``"jump: start"`` e.t.c.
.. note::
``keyName`` is not always present and if omitted - the rule will match any ``keyName``.
The different animation names the game uses can be inspected by opening ``.kf`` animation files in Blender.
Both ``animationGroupName`` and ``keyName`` support wildcard characters either at the beginning, the end of the name, or instead of the name:
- ``"*"`` will match any name.
- ``"*idle:sta*"`` will match an animationGroupName ending with ``idle`` and a keyName starting with ``sta``.
- ``"weapon*handed: chop*attack"`` will not work since we don't support wildcards in the middle.
``easing`` is an animation blending function, i.e., a style of transition between animations, look below to see the list of possible easings.
``duration`` is the transition duration in seconds, 0.2-0.4 are usually reasonable transition times, but this highly depends on your use case.
.. note::
The bottom-most rule takes precedence in the animation config files.
List of possible easings
------------------------
- "linear"
- "sineOut"
- "sineIn"
- "sineInOut"
- "cubicOut"
- "cubicIn"
- "cubicInOut"
- "quartOut"
- "quartIn"
- "quartInOut"
- "springOutGeneric"
- "springOutWeak"
- "springOutMed"
- "springOutStrong"
- "springOutTooMuch"
``"sineOut"`` easing is usually a safe bet. In general ``"...Out"`` easing functions will yield a transition that is fast at the beginning of the transition but slows down towards the end, that style of transitions usually looks good on organic animations e.g. humanoids and creatures.
``"...In"`` transitions begin slow but end fast, ``"...InOut"`` begin fast, slowdown in the middle, end fast.
Its hard to give an example of use cases for the latter 2 types of easing functions, they are there for developers to experiment.
The possible easings are largely ported from `easings.net <https://easings.net/>`__ and have similar names. Except for the ``springOut`` family, those are similar to ``elasticOut``, with ``springOutWeak`` being almost identical to ``elasticOut``.
Don't be afraid to experiment with different timing and easing functions!

@ -276,6 +276,14 @@ Also it is possible to add a "Bip01 Arrow" bone to actor skeletons. In this case
Such approach allows to implement better shooting animations (for example, beast races have tail, so quivers should be attached under different angle and
default arrow fetching animation does not look good).
Animation blending
------------------
Animation blending introduces smooth animation transitions between essentially every animation in the game without affecting gameplay. Effective if ``smooth animation transitions`` setting is enabled in the launcher or the config files.
Animation developers can bundle ``.yaml``/``.json`` files together with their ``.kf`` files to specify the blending style of their animations. Those settings will only affect the corresponding animation files.
For more details see :doc:`animation-blending`.
Groundcover support
-------------------

@ -31,5 +31,6 @@ about creating new content for OpenMW, please refer to
doors-and-teleports
custom-shader-effects
extended
animation-blending
paths
localisation

@ -541,3 +541,14 @@ In third person, the camera will sway along with the movement animations of the
Enabling this option disables this swaying by having the player character move independently of its animation.
This setting can be controlled in the Settings tab of the launcher.
smooth animation transitions
----------------------------
:Type: boolean
:Range: True/False
:Default: False
Enabling this option uses smooth transitions between animations making them a lot less jarring. Also allows to load modded animation blending.
This setting can be controlled in the Settings tab of the launcher.

@ -21,6 +21,9 @@ set(BUILTIN_DATA_FILES
fonts/MysticCards.omwfont
fonts/MysticCardsFontLicense.txt
# Default animation blending config
animations/animation-config.yaml
# Month names and date formatting
l10n/Calendar/de.yaml
l10n/Calendar/en.yaml

@ -0,0 +1,69 @@
# This is the default OpenMW animation blending config file (global config) , will affect NPCs and creatures but not animated objects.
# If you want to provide an animation blending config for your modded animations - DO NOT override the global config in your mod.
# For details on how to edit and create your own blending rules, see https://openmw.readthedocs.io/en/latest/reference/modding/animation-blending.html
blending_rules:
# General blending rule, any transition that will not be caught by the rules below - will use this rule
- from: "*"
to: "*"
easing: "sineOut"
duration: 0.25
# From anything to sneaking
- from: "*"
to: "idlesneak*"
easing: "springOutWeak"
duration: 0.4
- from: "*"
to: "sneakforward*"
easing: "springOutWeak"
duration: 0.4
# From any to preparing for an attack swing (e.g "weapononehanded: chop start").
# Note that Rules like *:chop* will technically match any weapon attack animation with
# an animation key beginning on "chop". This includes attack preparation, attack itself and follow-through.
# Yet since rules below this block take care of more specific transitions - most likely this block will
# only affect "any animation"->"attack swing preparation".
- from: "*"
to: "*:shoot*"
easing: "sineOut"
duration: 0.1
- from: "*"
to: "*:chop*"
easing: "sineOut"
duration: 0.1
- from: "*"
to: "*:thrust*"
easing: "sineOut"
duration: 0.1
- from: "*"
to: "*:slash*"
easing: "sineOut"
duration: 0.1
# From preparing for an attack swing (e.g "weapononehanded: chop start") to an attack swing (e.g "weapononehanded: chop max attack").
- from: "*:*start"
to: "*:*attack"
easing: "sineOut"
duration: 0.05
# From a weapon swing to the final follow-through
- from: "*"
to: "*:*follow start"
easing: "linear"
duration: 0
# Sharper out of jumping transition, so bunny-hopping looks similar to vanilla
- from: "jump:start"
to: "*"
easing: "sineOut"
duration: 0.1
# Inventory doll poses don't work with transitions, so 0 duraion.
- from: "*"
to: "inventory*"
easing: "linear"
duration: 0
- from: "inventory*"
to: "*"
easing: "linear"
duration: 0
# Transitions from a no-state are always instant
- from: ""
to: "*"
easing: "linear"
duration: 0

@ -698,6 +698,14 @@ to default Morrowind fonts. Check this box if you still prefer original fonts ov
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Use casting animations for magic items, just as for spells.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;If enabled - makes transitions between different animations/poses much smoother. Also allows to load animation blending config YAML files that can be bundled with animations in order to customise blending styles.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Smooth Animation Transitions</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Makes NPCs and player movement more smooth. Recommended to use with &quot;turn to movement direction&quot; enabled.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation type="unfinished"></translation>

@ -1427,5 +1427,13 @@ to default Morrowind fonts. Check this box if you still prefer original fonts ov
<source>Browse</source>
<translation></translation>
</message>
<message>
<source>Smooth Animation Transitions</source>
<translation></translation>
</message>
<message>
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;If enabled - makes transitions between different animations/poses much smoother. Also allows to load animation blending config YAML files that can be bundled with animations in order to customise blending styles.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation></translation>
</message>
</context>
</TS>

@ -698,6 +698,14 @@ to default Morrowind fonts. Check this box if you still prefer original fonts ov
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Use casting animations for magic items, just as for spells.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Anime l&apos;utilisation d&apos;objet magique, de façon similaire à l&apos;utilisation des sorts.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</translation>
</message>
<message>
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;If enabled - makes transitions between different animations/poses much smoother. Also allows to load animation blending config YAML files that can be bundled with animations in order to customise blending styles.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation>&lt;html&gt;&lt;body&gt;&lt;p&gt;Lorsque cette option est désactivée, le moteur de jeu n&apos;effectue aucune transition entre les différentes poses/animations.&lt;/p&gt;&lt;p&gt;Lorsque cette option est activée, le moteur de jeu adoucit la transition entre les différentes poses/animations.&lt;/p&gt;&lt;p&gt;Cette option prend en charge les fichiers de configuration YAML pour les transitions entre animations, ceux-ci peuvent être inclus avec les lots d&apos;animations afin de configurer le type de transition entre les diverses animations fournies.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</translation>
</message>
<message>
<source>Smooth Animation Transitions</source>
<translation>Adoucir la transition entre animations</translation>
</message>
<message>
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Makes NPCs and player movement more smooth. Recommended to use with &quot;turn to movement direction&quot; enabled.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Cette option rend les mouvements des PNJ et du joueur plus souple. Recommandé si l&apos;option &quot;Se tourner en direction du mouvement&quot; est activée.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</translation>

@ -772,6 +772,14 @@ to default Morrowind fonts. Check this box if you still prefer original fonts ov
<source>Use Magic Item Animation</source>
<translation>Анимации магических предметов</translation>
</message>
<message>
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;If enabled - makes transitions between different animations/poses much smoother. Also allows to load animation blending config YAML files that can be bundled with animations in order to customise blending styles.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Если настройка включена, она делает переходы между различными анимациями/позами намного глаже. Кроме того, она позволяет загружать YAML-файлы конфигураций смешивания анимаций, которые могут быть включены с анимациями, чтобы настроить стили смешивания.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</translation>
</message>
<message>
<source>Smooth Animation Transitions</source>
<translation>Плавные переходы между анимациями</translation>
</message>
<message>
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Makes NPCs and player movement more smooth. Recommended to use with &quot;turn to movement direction&quot; enabled.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Делает перемещение персонажей более плавным. Рекомендуется использовать совместно с настройкой &quot;Поворот в направлении движения&quot;.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</translation>

@ -1446,5 +1446,13 @@ de ordinarie fonterna i Morrowind. Bocka denna ruta om du ändå föredrar ordin
<source>Run Script After Startup:</source>
<translation>Kör skript efter uppstart:</translation>
</message>
<message>
<source>Smooth Animation Transitions</source>
<translation>Mjuka animationsövergångar</translation>
</message>
<message>
<source>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;If enabled - makes transitions between different animations/poses much smoother. Also allows to load animation blending config YAML files that can be bundled with animations in order to customise blending styles.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</source>
<translation>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Vid aktivering gör denna funktion att övergångarna mellan olika animationer och poser blir mycket mjukare. Funktionen gör det också möjligt att konfigurera animationsövergångarna i YAML-filer. Dessa filer kan buntas ihop tillsammans med nya animationsfiler.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</translation>
</message>
</context>
</TS>

@ -284,6 +284,10 @@ rebalance soul gem values = false
# Allow to load per-group KF-files from Animations folder
use additional anim sources = false
# Use smooth transitions between animations making them a lot less jarring. Also allows to load modded animation blending
# configs (.yaml/.json config files).
smooth animation transitions = false
# Make the disposition change of merchants caused by barter dealings permanent
barter disposition change is permanent = false

@ -62,6 +62,8 @@ def runTest(name):
"resolution x = 640\n"
"resolution y = 480\n"
"framerate limit = 60\n"
"[Game]\n"
"smooth animation transitions = true\n"
)
stdout_lines = list()
exit_ok = True

Loading…
Cancel
Save