理论要点
什么是脏标记模式:将工作推迟到必要时进行以避免不必要的工作。就是用一个标志位来标记内容是否发生变化,如果没有发生变化就直接使用缓存数据,不需要重新计算。
要点
脏标识模式:当前有一组原始数据随着时间变化而改变。由这些原始数据计算出目标数据需要耗费一定的计算量。这个时候,可以用一个脏标识,来追踪目前的原始数据是否与之前的原始数据保持一致,而此脏标识会在被标记的原始数据改变时改变。那么,若这个标记没被改变,就可以使用之前缓存的目标数据,不用再重复计算。反之,若此标记已经改变,则需用新的原始数据计算目标数据。使用场合
1,原始数据转换到目标数据会消耗很多时间,都可以考虑使用脏标记模式来节省开销。2,游戏中物体局部变换到世界变换的计算,当没有变化时不需要每帧重复计算。(从根节点沿着它的父链将变换组合起来,矩阵相乘=世界变换)。还有游戏场景图中每帧渲染的对象,对于没有发生变化的对象可以不必重新渲染。再有我们的文档存档也可以用到,内存中就是我们的原始数据,存盘到磁盘就是我们的目标数据,当然不需要实时存盘。
3,若原始数据的变化速度远高于目标数据的使用速度,此时数据会因为随后的修改而失效,此时就不适合使用脏标记模式。
代码分析
1,就如上面提到的,游戏场景中,物体运动并渲染需要知道它的世界坐标,这就意味着我们需要计算场景中所有对象的世界变换。很多对象都有很深的父链,父节点运动其上子节点也跟着变化,如果每个对象都每帧重新计算世界变换,这种开销也是很恐怖的。
下面我们就来分析怎么用脏标记模式来避免这种重复计算:
首先,局部坐标到世界坐标换算的矩阵计算不在我们这里的讨论范围,我们假设它的实现在其他什么地方。
class Transform
{
public:
//原始变换,单位矩阵表示没有移动、旋转或者缩放
static Transform origin();
//组合父链中所有的局部变换得到它的世界变换
Transform combine(Transform& other);
}
再来一个世界变换换算过程的示意图帮助理解计算过程,如下:
好,现在我们有了计算世界坐标的类了,接下来,我们来定义游戏场景中的物体类。
//每个物体组成:网格(图元),坐标,它的子节点
class GraphNode
{
public:
GraphNode(Mesh* mesh):_mesh(mesh), _local(Transform::origin()) {}
private:
Transform _local;
Mesh* _mesh;
GraphNode* _children[MAX_CHILDREN];
int _numChildren;
}
这样我们游戏的场景其实可以看作是一个单一的根节点”GraphNode”对象,它的子节点(子子节点,等等)就是世界中的所有物体。
GraphNode* _graphRoot = new GraphNode(NULL);
//Add children to root graph node...
//往这个节点树中添加子节点,即就形成了我们的场景图(与cocos节点树不谋而合)
渲染整个场景,其实就是遍历节点树,从根节点开始,通过正确的世界变换为每个节点图元调用下面的方法。
void renderMesh(Mesh* mesh, Transform transform);
我们这里不实现它,目的只是了解游戏场景形成的大概流程。现在我们的主要精力是看在遍历这个节点树计算世界变换并调用renderMesh最终渲染这个过程中是怎么优化的。
老套路,先来看不优化最直接的实现方式:
void GraphNode::render(Transform parentWorld)
{
Transform world = _local.combine(parentWorld)
if(_mesh) renderMesh(_mesh, world);
for(int i = 0; i < _numChildren; i++)
{
_children[i]->render(world);
}
}
我们通过“parentWorld”将父节点的世界变换传给它。这样这个节点的世界变换就是它本身的局部变换_local与parentWorld组合了。我们不需要回溯到父节点去重新计算,因为我们沿着父链下来已经计算过了。
我们计算节点的世界变换并保存到world中,然后如果有图元的话,就渲染它。最后我们递归进入子节点中,将当前节点的世界变换传递进去。总之,这是一个紧凑、简单的递归调用。
为了绘制整个场景图,我们从空根节点开始渲染:
_graphRoot ->render(Transform::origin());
分析下,我们上面是正确的实现了场景图的渲染,但是它并不高效,它每帧都在每个节点上调用_local.combine(parentWorld)计算世界变换。
2,下面就来看看怎么用脏标记来优化这个计算。首先我们需要添加两个成员到GraphNode类中。
class GraphNode
{
public:
GraphNode(Mesh* mesh)
:_mesh(mesh),
_local(Transform::origin()),
_dirty(true)
{}
//Other methods...
private:
Transform _local;
Mesh* _mesh;
//添加的两个成员
Transform _world; //缓存上次计算的世界变换
bool _dirty; //脏标记
GraphNode* _children[MAX_CHILDREN];
int _numChildren;
}
在物体移动,发生局部变换时,我们需要设置脏标记。
//设置脏标记
void GraphNode::setTransform(Transform local)
{
_local = local;
_dirty = true;
}
这样之后我们再来看看优化后的每帧渲染接口:
void GraphNode::render(Transform parentWorld, bool dirty)
{
dirty |= _dirty;
if(dirty)
{
//清除脏标记
_world = _local.combine(parentWorld);
_dirty = false;
}
if(_mesh) renderMesh(_mesh, _world);
//父节点变化,递归子节点
for(int i = 0; i < _numChildren; i++)
{
_children[i]->render(_world, dirty);
}
}
这样修改一个节点的局部变换只是几条赋值语句,渲染世界时只计算了自上一帧以来最少的变动的世界变换。
好,结束~