OpenSceneGraph(OSG)库在核心库的基础上写了许多扩展,这其中就有一个库专门用来处理场景中的动画的库osgAnimation,这一系列的文章主要介绍一下osgAnimation库的原理和实现,记录下来方便日后查阅。本文对应的OSG版本是3.4.0
大家一定看过一个画面,将许多动作各异的一个小人的画像,通过快速翻页,就可以看到这个小人在运动。在电影中也是如此,屏幕上运动的画面就是通过拍摄大量的图片,然后以每秒24帧的频率把他们投影到屏幕上来实现的。每一帧移动到镜头的后一个位置,接着快门打开,然后这一帧便显示。在影片切换到下一帧的瞬间,快门关闭,然后又打开以显示下一帧,以此类推。尽管观众所看到的是每秒24帧切换的不同的画面,但是大脑会把他们混合成一段平滑的动画。在看奥斯卡颁奖礼的时候,提到影片的时候,使用的词是Motion Picture,非常的贴切。
按照上面的描述,为了获得动画的效果,可以在绘制的每一帧中通过不断的修改模型位置和姿态,已达到连续动画的效果。在OSG的一帧绘制中,包含以下几个过程:
void ViewerBase::frame(double simulationTime)
{
if (_done) return;
if (_firstFrame)
{
viewerInit();
if (!isRealized())
{
realize();
}
_firstFrame = false;
}
advance(simulationTime);
eventTraversal(); //事件遍历
updateTraversal(); //更新遍历
renderingTraversals(); //渲染遍历
}
在更新遍历中去修改模型的位置和姿态是一个不错的选择(其实选在哪个遍历中来修改都是可以的,但是一般来说事件遍历是用来处理和外设的交互,如鼠标和键盘等;渲染遍历是用来真正的绘制模型,比如会调用glVertex等这样的函数, 更新遍历恰好调整的位置和姿态来用的,因此一般就在更新遍历中完成位置和姿态的调整)
在OSG中每一个节点都包含一个更新回调的成员,可以使用
inline void addUpdateCallback(Callback* nc)
来添加更新回调,OSG的更新遍历会遍历整个场景中每一个节点的更新回调,并调用更新回调的operator方法,需要做的事情是在更新回调中改变姿态即可。下面的程序通过使用一个更新回调函数,让场景中的模型实现绕Z轴的旋转
#include <osgViewer/Viewer>
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osg/Callback>
class RotateCallback : public osg::NodeCallback
{
public:
RotateCallback(osg::Vec3d axis, double rotAngular) :
_axis(axis), _speed(rotAngular), _currentRotation(0.0)
{
}
virtual void operator()(osg::Node* node, osg::NodeVisitor* nv)
{
osg::MatrixTransform *mt = dynamic_cast<osg::MatrixTransform*>(node);
if (mt)
{
_currentRotation += _speed;
if (_currentRotation > (2 * osg::PI))
{
_currentRotation -= (osg::PI * 2);
}
osg::Quat rotQuat(_currentRotation, _axis);
osg::Matrix rotMatrix(rotQuat);
mt->setMatrix(rotMatrix);
}
traverse(node, nv);
}
protected:
osg::Vec3d _axis;
double _speed;
double _currentRotation;
};
int main()
{
osg::ref_ptr<osgViewer::Viewer> viewer = new osgViewer::Viewer;
osg::ref_ptr<osg::Node> cowNode = osgDB::readNodeFile("cow.osg");
osg::ref_ptr<osg::MatrixTransform> root = new osg::MatrixTransform;
root->addChild(cowNode);
root->addUpdateCallback(new RotateCallback(osg::Z_AXIS, 0.01));
viewer->setUpViewInWindow(200, 200, 800, 600);
viewer->setSceneData(root);
return viewer->run();
}
以上的代码就是实现一个动画效果的全部逻辑和原理,osgAnimation这个库围绕着这一基本的原理进行了抽象和丰富,通过Animation中的各个类,共同完成了这一看似简单的实现,下面针对具体的osgAnimation的类来改造这一程序,最终完成一个osgAnimation版本的动画程序。
在动画程序中,有一个很重要的概念是关键帧(Key Frame)。一段渐变动画的起点和终点一般都会设置为关键帧。 一般来说作为关键帧的图像应该是具有某种特征,能够反映出变化的一帧,这也是为什么称之为“关键”帧的原因。比如说在直线上的运动,突然要开始转弯,在转弯处的一帧就有必要作为一个关键帧,它反映出了运动的一个突然的变化。
关键帧的类型是多种多样的,可能是浮点数的变化、也可能是四元数的变化等,一般使用模板参数来描述各种各样的变换类型,关键帧的每一个值应该有一个与之对应的时间点,也就是说在某一个时间点t,这时候的关键帧的值是value。
//伪代码,非c++定义
TemplateKeyframe< T = osg::Quat>
{
double _time;
T _value;
}
关键帧一般会放在一个容器中统一管理,这个关键帧的容器在osgAnimation中的类是osgAnimation::TemplateKeyframeContainer,它是一个存储容器的std::vector。
引入关键帧概念之后,可以修改之前的代码。由于程序是一个简单的旋转动画,可以使用旋转角度作为关键帧,也可以使用四元数来表示旋转的关键帧,为了简单,这里使用旋转角度作为关键帧,也就是关键帧使用DoubleKeyframe,由于旋转是连续性的,可以把旋转角度0作为关键帧的起点的关键帧,旋转角度2 π 作为终点的关键帧,也就是说只需要两个关键帧即可,修改程序:
#include <osgViewer/Viewer>
#include <osg/MatrixTransform>
#include <osgDB/ReadFile>
#include <osg/Callback>
#include <osgAnimation/Keyframe>
class RotateCallback : public osg::NodeCallback
{
public:
RotateCallback(osgAnimation::DoubleKeyframeContainer *kfc)
{
_keyframes = kfc;
_startTick = osg::Timer::instance()->tick();
}
virtual void operator()(osg::Node* node, osg::NodeVisitor* nv)
{
osg::MatrixTransform *mt = dynamic_cast<osg::MatrixTransform*>(node);
if (mt)
{
double currentTick = osg::Timer::instance()->tick();
double elaspedTime = osg::Timer::instance()->delta_s(_startTick, currentTick);
//计算关键帧之间的中间帧的值
// Value = currentframe.time / keyframe_end.time - keyframe_start.time;
double frameStartTime = _keyframes->back().getTime();
double frameEndTime = _keyframes->front().getTime();
double frameTimeDuration = frameEndTime - frameStartTime;
double currentRot = (elaspedTime / frameTimeDuration) * osg::PI * 2;
osg::Quat rotQuat(currentRot, osg::Z_AXIS);
osg::Matrix rotMatrix(rotQuat);
mt->setMatrix(rotMatrix);
}
traverse(node, nv);
}
protected:
osgAnimation::DoubleKeyframeContainer *_keyframes;
double _startTick;
};
int main()
{
osg::ref_ptr<osgViewer::Viewer> viewer = new osgViewer::Viewer;
osg::ref_ptr<osg::Node> cowNode = osgDB::readNodeFile("cow.osg");
osg::ref_ptr<osg::MatrixTransform> root = new osg::MatrixTransform;
root->addChild(cowNode);
osgAnimation::DoubleKeyframe keyframe1(0.0, 0.0);
osgAnimation::DoubleKeyframe keyframe2(10.0, osg::PI*2);
osg::ref_ptr<osgAnimation::DoubleKeyframeContainer> keyframeContainer = new osgAnimation::DoubleKeyframeContainer();
keyframeContainer->push_back(keyframe1);
keyframeContainer->push_back(keyframe2);
root->addUpdateCallback(new RotateCallback(keyframeContainer));
viewer->setUpViewInWindow(200, 200, 800, 600);
viewer->setSceneData(root);
return viewer->run();
}
定义了关键帧还不能形成动画,需要知道关键帧中间帧的值,这需要插值来处理。operator()函数中有一段计算关键帧中间值的代码,使用的其实就是线性的插值。osgAnimation把插值的过程也写成了一个类,这就是下面要提到的插值器(Interpolator)
插值器的作用是利用各种插值方式计算关键帧中间帧数据。插值器进行插值的数据类型很显然必须和关键帧中保存的值类型是一致的。插值器类提供了getValue的方法,用来获取某个时间点中间帧的值,例如线性的插值器定义的getValue的实现如下:
void getValue(const TemplateKeyframeContainer& keyframes, double time, TYPE& result) const
{
//如果时间大于最后一个关键帧的时间点,那么返回最后关键帧的值
if (time >= keyframes.back().getTime())
{
result = keyframes.back().getValue();
return;
}
//如果时间小于起始关键帧的时间点,那么返回起始关键帧的值
else if (time <= keyframes.front().getTime())
{
result = keyframes.front().getValue();
return;
}
//计算到当前时间点所处在哪两个关键帧之间,i值是两个关键帧中时间点较小的哪一个关键帧,有点类似于C++标准库中的lower_bound函数返回的值
int i = this->getKeyIndexFromTime(keyframes,time);
float blend = (time - keyframes[i].getTime()) / ( keyframes[i+1].getTime() - keyframes[i].getTime());
const TYPE& v1 = keyframes[i].getValue();
const TYPE& v2 = keyframes[i+1].getValue();
//线性的插值算法
result = v1*(1-blend) + v2*blend;
}
在引入了插值器之后,再次修改上面的程序如下:
class RotateCallback : public osg::NodeCallback
{
public:
RotateCallback(osgAnimation::DoubleKeyframeContainer *kfc)
{
_keyframes = kfc;
_startTick = osg::Timer::instance()->tick();
_interpolator = new osgAnimation::DoubleLinearInterpolator();
}
virtual void operator()(osg::Node* node, osg::NodeVisitor* nv)
{
osg::MatrixTransform *mt = dynamic_cast<osg::MatrixTransform*>(node);
if (mt)
{
double currentTick = osg::Timer::instance()->tick();
double elaspedTime = osg::Timer::instance()->delta_s(_startTick, currentTick);
double currentRot;
_interpolator->getValue(*_keyframes, elaspedTime, currentRot);
osg::Quat rotQuat(currentRot, osg::Z_AXIS);
osg::Matrix rotMatrix(rotQuat);
mt->setMatrix(rotMatrix);
}
traverse(node, nv);
}
protected:
osgAnimation::DoubleKeyframeContainer *_keyframes;
osgAnimation::DoubleLinearInterpolator *_interpolator;
double _startTick;
};
观察上面修改的代码,在引入插值器之后,仅仅使用一行代码代替了之前代码的计算(这些计算由插值器在它的实现中代劳了)。需要注意的是在运行程序之后会发现场景中的模型在旋转一圈之后就停下来了【想一想为什么?】后续会作进一步处理。
这里的关键帧容器和插值器是独立开的,osgAnimation将这二者组合起来,构成一个采样器(Sampler),采样器类的实现仅仅是将二者组合在一起,并没有额外的内容,在使用采样器之后,再次修改程序:
class RotateCallback : public osg::NodeCallback
{
public:
RotateCallback(osgAnimation::DoubleLinearSampler *dls)
{
_startTick = osg::Timer::instance()->tick();
_sampler = dls;
}
virtual void operator()(osg::Node* node, osg::NodeVisitor* nv)
{
osg::MatrixTransform *mt = dynamic_cast<osg::MatrixTransform*>(node);
if (mt)
{
double currentTick = osg::Timer::instance()->tick();
double elaspedTime = osg::Timer::instance()->delta_s(_startTick, currentTick);
double currentRot;
_sampler->getValueAt(elaspedTime, currentRot);
osg::Quat rotQuat(currentRot, osg::Z_AXIS);
osg::Matrix rotMatrix(rotQuat);
mt->setMatrix(rotMatrix);
}
traverse(node, nv);
}
protected:
osgAnimation::DoubleLinearSampler* _sampler;
double _startTick;
};
int main()
{
osg::ref_ptr<osgViewer::Viewer> viewer = new osgViewer::Viewer;
osg::ref_ptr<osg::Node> cowNode = osgDB::readNodeFile("cow.osg");
osg::ref_ptr<osg::MatrixTransform> root = new osg::MatrixTransform;
root->addChild(cowNode);
osgAnimation::DoubleKeyframe keyframe1(0.0, 0.0);
osgAnimation::DoubleKeyframe keyframe2(10.0, osg::PI*2);
osg::ref_ptr<osgAnimation::DoubleKeyframeContainer> keyframeContainer = new osgAnimation::DoubleKeyframeContainer();
keyframeContainer->push_back(keyframe1);
keyframeContainer->push_back(keyframe2);
osg::ref_ptr<osgAnimation::DoubleLinearSampler> sampler = new osgAnimation::DoubleLinearSampler();
sampler->setKeyframeContainer(keyframeContainer.get());
root->addUpdateCallback(new RotateCallback(sampler.get()));
viewer->setUpViewInWindow(200, 200, 800, 600);
viewer->setSceneData(root);
return viewer->run();
}
通过上面介绍的关键帧、插值器、采样器,其实可以处理大部分的动画了。但是要实现更为集中的动画数据管理,以及为功能的集成提供良好的操作接口,依然需要一个封装了关键帧采样器,并负责关联动画效果与场景对象的工具,这就是下面将要介绍的“动画频道”(Channel)
在上面实现的简单动画代码中,使用采样器计算得到的值,直接设置给了场景节点MatrixTransform。这样做缺少了一些灵活性。比如当有两个采样器计算的结果都需要对最后的姿态产生影响时,应该使用哪一个呢?这两个效果可能有一个权重,最后得到的Matrix值是二者的加权计算的结果,然后再把这一结果设置给MatrixTransform,最终反应到模型姿态的变化上。
也就是说,有必要把采样器计算得到的结果进行某些处理,osgAnimation使用的方式是:把这一计算过程分离出来,采样器计算得到的结果,可以用一个类来管理起来,这个类决定了采样器计算结果的权重(weight)、优先级(priority),打个简单的比方:
在上学的时候大家的作业都是直接交给老师的,也就是说大家写好的作业老师直接能看到。现在老师觉得学生太多,想挑一部分来看,于是让学习委员把大家的作业收集起来,先看一遍,然后挑一些内容再上交给老师看。学习委员可以有各种权限,可以修改一些作业、可以删除一些作业等等,也就是说老师看到的最终版本是被处理过的。
这里面涉及到的学习委员这个角色,在osgAnimation里面就是osgAnimation::Target, 这个类就是用来处理各个采样器得到的结果的(类似于学生上交的作业),它可以赋予每个计算结果优先级、权重等,从而影响最终的结果。Channel类的update函数会在每一帧中被调用,它的实现如下:
virtual void update(double time, float weight, int priority)
{
// skip if weight == 0
if (weight < 1e-4)
return;
typename SamplerType::UsingType value;
//第一部和前面Sampler中的实现一样,使用Samper中的插值器计算得到中间帧的值,并保存在value中
_sampler->getValueAt(time, value);
//把这个value的值传给Target(例子中提到的学习委员,让他更加权重再处理value值,并把最终的结果保存在Target类的成员中)
_target->update(weight, value, priority);
}
也就是说,外部三维场景节点拿到的插值变换的结果是经过Target处理过的,换言之Target类保存着外部场景需要的最终想要的值,可以调用Target函数的getValue方法来获得这个值。引入频道之后,继续修改之前的代码:
class RotateCallback : public osg::NodeCallback
{
public:
RotateCallback(osgAnimation::DoubleLinearChannel *dls)
{
_startTick = osg::Timer::instance()->tick();
_channel = dls;
}
virtual void operator()(osg::Node* node, osg::NodeVisitor* nv)
{
osg::MatrixTransform *mt = dynamic_cast<osg::MatrixTransform*>(node);
if (mt)
{
double currentTick = osg::Timer::instance()->tick();
double elaspedTime = osg::Timer::instance()->delta_s(_startTick, currentTick);
if (!_channel)
return;
//每次回调需要重置前一次的权重为0
_channel->getTarget()->reset();
_channel->update(elaspedTime, 1.0, 0);
auto doubleTarget = dynamic_cast<osgAnimation::DoubleTarget*>(_channel->getTarget());
double currentRot;
if (doubleTarget)
currentRot = doubleTarget->getValue();
osg::Quat rotQuat(currentRot, osg::Z_AXIS);
osg::Matrix rotMatrix(rotQuat);
mt->setMatrix(rotMatrix);
}
traverse(node, nv);
}
protected:
osgAnimation::DoubleLinearChannel* _channel;
double _startTick;
};
int main()
{
osg::ref_ptr<osgViewer::Viewer> viewer = new osgViewer::Viewer;
osg::ref_ptr<osg::Node> cowNode = osgDB::readNodeFile("cow.osg");
osg::ref_ptr<osg::MatrixTransform> root = new osg::MatrixTransform;
root->addChild(cowNode);
osgAnimation::DoubleKeyframe keyframe1(0.0, 0.0);
osgAnimation::DoubleKeyframe keyframe2(10.0, osg::PI * 2);
osg::ref_ptr<osgAnimation::DoubleKeyframeContainer> keyframeContainer = new osgAnimation::DoubleKeyframeContainer();
keyframeContainer->push_back(keyframe1);
keyframeContainer->push_back(keyframe2);
osg::ref_ptr<osgAnimation::DoubleLinearSampler> sampler = new osgAnimation::DoubleLinearSampler();
sampler->setKeyframeContainer(keyframeContainer.get());
osg::ref_ptr<osgAnimation::DoubleLinearChannel> channel = new osgAnimation::DoubleLinearChannel();
channel->setSampler(sampler.get());
root->addUpdateCallback(new RotateCallback(channel.get()));
viewer->setUpViewInWindow(200, 200, 800, 600);
viewer->setSceneData(root);
return viewer->run();
}
通过上面的分析,可以总结上面描述各种对象之间的相互关系如下:
假设用户已经设置了多个频道,要将这些效果混合起来,最终赋予要执行动态效果的场景对象,则可以将该过程称为一场动画的实现。在osgAnimation中使用Animation类来管理Channels。Animation类提供了管理多个Channel的方式非常简单,它提供了一个保存多个Channel数组的变量,除此之外Animation多了一个播放模式,可以让动画实现一次播放、暂停播放、循环播放以及来回播放(正向与反向)。
在前面引入插值器的4.2中,当修改程序使用插值器之后,发现场景在旋转一圈之后就暂停了,这是因为当传入的时间点的值大于关键帧最后一帧后,之后返回的取值就一直保持不变了(都是最后一帧的取值)。Animation通过提供的几种播放方式,可以使用LOOP的播放方式,让动画一直来回的循环。关于Animation的处理方式的代码如下:
bool Animation::update (double time, int priority)
{
// ...
switch (_playmode)
{
case ONCE:
if (t > _originalDuration)
{
for (ChannelList::const_iterator chan = _channels.begin();
chan != _channels.end(); ++chan)
(*chan)->update(_originalDuration, _weight, priority);
return false;
}
break;
case STAY:
if (t > _originalDuration)
t = _originalDuration;
break;
case LOOP:
if (!_originalDuration)
t = _startTime;
else if (t > _originalDuration)
t = fmod(t, _originalDuration);
// std::cout << "t " << t << " duration " << _duration << std::endl;
break;
case PPONG:
if (!_originalDuration)
t = _startTime;
else
{
int tt = (int) (t / _originalDuration);
t = fmod(t, _originalDuration);
if (tt%2)
t = _originalDuration - t;
}
break;
}
ChannelList::const_iterator chan;
for( chan=_channels.begin(); chan!=_channels.end(); ++chan)
{
(*chan)->update(t, _weight, priority);
}
return true;
}
前面写代码的方式应该就是这里面所描述的ONCE,当t值大于_originalDuration时,获取的是最后一个关键帧的取值,导致场景停留在最后一个关键帧处。
STAY模式,效果和ONCE一样
LOOP模式, 当场景时间点大于起始关键帧和结束关键帧之差时,对时间点t求模,得到的结果是场景的时间点又会进入到起始关键帧和结束关键帧之中,从而插值器可以得到正确的取值
PPONG模式,是来回模式,从实现中可以看到,根据时间点对关键帧时间长度求模,同时根据得到值的奇偶性来判断是来还是回,计算正确的时间t,实现也很好理解。在了解了这些之后,可以改进之前的程序,让模型旋转可以循环起来:
virtual void operator()(osg::Node* node, osg::NodeVisitor* nv)
{
osg::MatrixTransform *mt = dynamic_cast(node);
if (mt)
{
double currentTick = osg::Timer::instance()->tick();
double elaspedTime = osg::Timer::instance()->delta_s(_startTick, currentTick);
//到时间点超过最后一个关键帧时,通过求模让时间点再次落入起始和终点关键帧时间点之内
double startKeyframeTime = _channel->getStartTime();
double endKeyframeTime = _channel->getEndTime();
double keyframeTimeLength = endKeyframeTime - startKeyframeTime;
if (elaspedTime > keyframeTimeLength)
elaspedTime = fmod(elaspedTime, keyframeTimeLength);
if (!_channel)
return;
//每次回调需要重置前一次的权重为0
_channel->getTarget()->reset();
_channel->update(elaspedTime, 1.0, 0);
auto doubleTarget = dynamic_cast(_channel->getTarget());
double currentRot;
if (doubleTarget)
currentRot = doubleTarget->getValue();
osg::Quat rotQuat(currentRot, osg::Z_AXIS);
osg::Matrix rotMatrix(rotQuat);
mt->setMatrix(rotMatrix);
}
traverse(node, nv);
}
```
同样的,在引入了Animation之后,更新程序,使用Animation来重新改造程序。
class RotateCallback : public osg::NodeCallback
{
public:
RotateCallback(osgAnimation::Animation *dls)
{
_startTick = osg::Timer::instance()->tick();
_animation = dls;
}
virtual void operator()(osg::Node* node, osg::NodeVisitor* nv)
{
osg::MatrixTransform *mt = dynamic_cast(node);
if (mt)
{
double currentTick = osg::Timer::instance()->tick();
double elaspedTime = osg::Timer::instance()->delta_s(_startTick, currentTick);
_animation->resetTargets();
_animation->setPlayMode(osgAnimation::Animation::LOOP);
_animation->update(elaspedTime);
_animation->setWeight(1.0);
osgAnimation::ChannelList cls = _animation->getChannels();
double currentRot = dynamic_cast(cls.at(0)->getTarget())->getValue();
osg::Quat rotQuat(currentRot, osg::Z_AXIS);
osg::Matrix rotMatrix(rotQuat);
mt->setMatrix(rotMatrix);
}
traverse(node, nv);
}
protected:
osgAnimation::Animation* _animation;
double _startTick;
};
int main()
{
osg::ref_ptr viewer = new osgViewer::Viewer;
osg::ref_ptr cowNode = osgDB::readNodeFile(“cow.osg”);
osg::ref_ptr root = new osg::MatrixTransform;
root->addChild(cowNode);
osgAnimation::DoubleKeyframe keyframe1(0.0, 0.0);
osgAnimation::DoubleKeyframe keyframe2(10.0, osg::PI * 2);
osg::ref_ptr keyframeContainer = new osgAnimation::DoubleKeyframeContainer();
keyframeContainer->push_back(keyframe1);
keyframeContainer->push_back(keyframe2);
osg::ref_ptr sampler = new osgAnimation::DoubleLinearSampler();
sampler->setKeyframeContainer(keyframeContainer.get());
osg::ref_ptr channel = new osgAnimation::DoubleLinearChannel();
channel->setSampler(sampler.get());
osg::ref_ptr animation = new osgAnimation::Animation();
animation->addChannel(channel.get());
root->addUpdateCallback(new RotateCallback(animation.get()));
viewer->setUpViewInWindow(200, 200, 800, 600);
viewer->setSceneData(root);
return viewer->run();
}
“`
通过上面的分析,已经写出了osgAnimation几个主要类之间的关系和它们的使用方式了。前面写的代码不是osgAnimation的最佳实践做法,甚至是非常糟糕的写法,为的仅仅是阐述osgAnimation主要类的原理以及为什么作者要这样设计。
在写上面代码过程中,特别是Channel和Animation类引入之后的写法,我个人使用获取Target的方式非常的不爽,最终计算结果存储在Target之中,我获取到Target然后设置给MatrixTransform。在Animation类中甚至都没有获取Target的函数,上面的代码强行从Animation中获取Channel然后再获取Target的方式非常的奇怪。代码写到这里也应该很清楚了,这个Target肯定和场景的回调之间有一些关联,不需要我们使用Channel强行获取这个值再去设置,应该会在内部完成整件事情。
接下来会继续讨论Target的更新以及对Animation的一些管理,请参考下一篇文章《OSG动画库Animation解析(二)》。
参考文献:
- 王锐 钱学雷 《OpenSceneGraph三维渲染引擎设计与实践》[清华大学出版社]
- osgAnimation 源码