【Stage3D学习笔记续】山寨Starling(八):核心优化(批处理)的实现

时间:2022-06-01 23:56:45

批处理是使GPU进行高效绘制的一种技术手段,也是整个渲染流程中最核心的技术,到目前为止我们并没有使用到这种技术手段,下面我们看看我们现在的渲染机制。

先想一想我们最开始是怎么向GPU绘制一幅图像的,可以回头查看Stage3D学习笔记(五):通过矩阵操作纹理这篇文章;

绘制流程:

  1. 我们创建了两个矩阵,一个正交矩阵一个模型矩阵;
  2. 清除3D图像;
  3. 我们创建了顶点缓冲对象和索引缓冲对象比上传数据到GPU,我们编写了四个顶点数据表示图像的四个顶点,以及使用索引数据指示了这四个顶点数据要绘制的两个三角形(Stage3D是基于三角形进行绘制的,两个三角形拼成一个矩形,即我们的图像);
  4. 我们上传了需要绘制的纹理对象到GPU;
  5. 我们对模型矩阵进行了转换;
  6. 初始化着色器和设置着色器为当前使用的着色器程序;
  7. 指定着色器如何使用我们上传的数据,把我们的正交矩阵和模型矩阵合并并作为一个常量设置到着色器;
  8. 调用drawTriangles方法绘制我们的图像;
  9. 调用present方法将后台缓冲中的图像显示到屏幕;

下面看看我们v0.2的引擎绘制100个对象会是什么样的绘制流程(简化过,参考上面的绘制流程):

  1. 程序启动先执行第1步;
  2. 每帧开始执行第2步;
  3. 执行第3步到8第步绘制第一个图像;
  4. 重复执行第3步到8第步绘制完这100个图像;
  5. 执行第9步,一帧绘制结束;

我把第3步到8第步的绘制称为一次DrawCall,那么我们按照这个逻辑一帧会执行100次DrawCall,并且DrawCall的次数和我们绘制的图像个数是一致的。

下面我们来说说批处理的思想:假如我们绘制的这100个图像除了位置(或者旋转等顶点数据)外所有的需要的条件(纹理,着色器等)都是一致的,那么我们其实可以在上传顶点和索引数据时一口气就把这100个图像的数据都上传(反正其他的数据都一致),这样进行绘制的话,其实我们只需执行一个DrawCall就把所有的图像都绘制了出来。

这就是批次绘制即批处理的核心思想,再考虑下,如果前50个图像和后50个图像的纹理不一致呢?这样就有2次DrawCall了,如果极端情况呢,100个图像都不一致?那样批处理也会执行100次DrawCall,但是这种情况也是有一种优化方法可以降低到最少1次DrawCall,不急,这个方法我们后面会谈到。

 

2014年11月25日补充:DrawCall次数的减少本质上是减轻CPU和GPU之间相互通信的开销,从而获得效率的提升。

 

理解了批处理的思想,下面我们看看Starling中是怎么用代码实现的吧:

未优化的Starling中,带有渲染逻辑(rander方法中)会进行实际渲染的类只有两个分别是:

  1. 渲染没有纹理仅显示颜色的正方形类Quad;
  2. 渲染带有纹理的图形类Image;

这两个类都带有渲染所需的所有数据,每次渲染都可以看做一次DrawCall,所以可以理解为有多少个Quad或者Image对象在舞台每帧就会有多少次DrawCall;

而基于批处理,我们优化的目的是如果状态一致(指着色器、纹理等一致的情况),多个Quad或者Image的渲染合并为一次DrawCall,那么实际渲染逻辑就需要从Quad和Image类中去掉;

 

QuadBatch:

Starling引入了一个新的类QuadBatch,从名称就可以看出该类是用来进行批处理操作的,Starling将实际的渲染逻辑从Quad和Image中移除,并移到该类中,即Starling中的所有模型渲染都是在该类中完成的。

QuadBatch类在Straling中有3个重要的用途:

1.实现自动批处理功能(核心逻辑):

RenderSupport类中保存一个QuadBatch的数组mQuadBatches,每帧循环时,每个需要渲染的对象的rander方法中会将自己的数据提交到RenderSupport当前的QuadBatch对象中,具体就是Quad类会调用QuadBatch的addQuad方法添加自身的数据,Image类会调用QuadBatch的addImage方法添加自身的数据;

每次添加之前QuadBatch对象都会判断一下状态是否改变,如果改变则立即渲染当前QuadBatch中收集的数据,并且新建一个QuadBatch对象为当前使用的新的批处理对象来使用;如果状态没有改变则说明下一个需要渲染的对象是可以提交到本次批处理中的,会添加该对象对应的数据到当前QuadBatch对象中;

2.为Sprite类的flatten方法提供支持:

QuadBatch类提供静态方法compile为Sprite类的flatten方法提供支持,Sprite类调用flatten方法后,会将其内部的所有子对象编译为多个QuadBatch对象保存到mFlattenedContents属性中,重写rander方法,如果调用过flatten方法,则以后每帧都会跳过处理其内部的所有子对象,而是直接使用编译好的mFlattenedContents直接进行渲染来提高运行效率;

当然这种技术的局限是,Sprite中的所有子项都不会发生改动,同时再也看不到子节点属性的任何变化(位置,旋转,透明度等)。 要更新这个显示对象的屏幕显示,只需要再次调用flatten一次,或者unflatten这个对象。

3.作为更加高效的容器使用:

有趣的是QuadBatch类被Starling的作者设计为DisplayObject的子类,表示其可以作为一个显示对象添加到舞台之中,但是由于QuadBatch类必须调用addQuad或addImage方法添加子项进行渲染,使其又有了类似容器的功能,但和容器不同的是,QuadBatch类并不是将子项添加到自身,而是将子项的数据拷贝到自身。

使用QuadBatch类作为容器使用好处是可以更加高效,因为避开了容器逻辑运算和事件派发,坏处是添加的子项状态必须一致,并且所有添加的子项其实都融合为一个独立的显示对象了。

 

状态是否改变的判断:

状态是否改变的判断是QuadBatch类的isStateChange方法,我们可以直接查看来确定我们设计的游戏中是否存在导致状态改变的因素从而导致DrawCall的上升:

 1 /** Indicates if a quad can be added to the batch without causing a state change. 
 2  *  A state change occurs if the quad uses a different base texture or has a different 
 3  *  'smoothing', 'repeat' or 'blendMode' setting. */
 4 public function isStateChange(quad:Quad, parentAlpha:Number, texture:Texture, 
 5                               smoothing:String, blendMode:String):Boolean
 6 {
 7     if (mNumQuads == 0) return false;
 8     else if (mNumQuads == 8192) return true; // maximum buffer size
 9     else if (mTexture == null && texture == null) return false;
10     else if (mTexture != null && texture != null)
11         return mTexture.base != texture.base ||
12                mTexture.repeat != texture.repeat ||
13                mSmoothing != smoothing ||
14                mTinted != (quad.tinted || parentAlpha != 1.0) ||
15                this.blendMode != blendMode;
16     else return true;
17 }

 

矩阵转换:

看到这里,你如果认为QuadBatch的addQuad和addImage方法就是简单的把目标对象的顶点数据添加到QuadBatch自身的顶点数据中就大错特错了,其实这一步我们又会面临曾经遇到过的两个问题:

  1. 确定添加的顶点数据(即一堆三角形)谁先绘制,需要正确的遮挡关系;
  2. 确定每个绘制的对象最终绘制到3D画布上的最终状态(位置、旋转和缩放等属性);

对于第一个问题,Starling框架的正确渲染顺序已经解决了,先添加的三角形会被后添加的三角形覆盖,即先调用rander的对象会被后调用rander的对象覆盖,即使使用批处理技术也一样;

对于第二个问题,由于Starling的状态改变不包括判断我们的对象是否位于同一父容器,所以位于不同父级容器的对象都可以作为一次性绘制的对象,导致出现需要对每个额外进行矩阵的转换,那么我们在Starling的源码中寻找答案吧:

在RenderSupport的batchQuad方法中(该方法会在Quad和Image的rander方法中调用):

 1 /** Adds a quad to the current batch of unrendered quads. If there is a state change,
 2  *  all previous quads are rendered at once, and the batch is reset. */
 3 public function batchQuad(quad:Quad, parentAlpha:Number, 
 4                           texture:Texture=null, smoothing:String=null):void
 5 {
 6     if (currentQuadBatch.isStateChange(quad, parentAlpha, texture, smoothing, mBlendMode))
 7         finishQuadBatch();
 8     
 9     currentQuadBatch.addQuad(quad, parentAlpha, texture, smoothing, mModelViewMatrix, mBlendMode);
10 }

是将当前对象的坐标转换矩阵mModelViewMatrix作为参数传入的,说明在addQuad方法中不需要考虑父级和自身的转换矩阵,直接针对mModelViewMatrix处理即可,我们看看mModelViewMatrix是何时被处理的,DisplayObjectContainer类的render方法:

 1 /** @inheritDoc */
 2 public override function render(support:RenderSupport, parentAlpha:Number):void
 3 {
 4     var alpha:Number = parentAlpha * this.alpha;
 5     var numChildren:int = mChildren.length;
 6     
 7     for (var i:int=0; i<numChildren; ++i)
 8     {
 9         var child:DisplayObject = mChildren[i];
10         if (child.alpha != 0.0 && child.visible && child.scaleX != 0.0 && child.scaleY != 0.0)
11         {
12             support.pushMatrix();
13             support.pushBlendMode();
14             
15             support.blendMode = child.blendMode;
16             support.transformMatrix(child);
17             child.render(support, alpha);
18             
19             support.popMatrix();
20             support.popBlendMode();
21         }
22     }
23 }

16行代码,将当期处理的child对象的转换矩阵数据设置为mModelViewMatrix,然后处理子项的rander方法。

我们接下来看看BatchQuad的addQuad方法:

 1 /** Adds a quad to the batch. The first quad determines the state of the batch,
 2  *  i.e. the values for texture, smoothing and blendmode. When you add additional quads,  
 3  *  make sure they share that state (e.g. with the 'isStageChange' method), or reset
 4  *  the batch. */ 
 5 public function addQuad(quad:Quad, parentAlpha:Number=1.0, texture:Texture=null, 
 6                         smoothing:String=null, modelViewMatrix:Matrix3D=null, 
 7                         blendMode:String=null):void
 8 {
 9     if (modelViewMatrix == null)
10     {
11         modelViewMatrix = sHelperMatrix3D;
12         modelViewMatrix.identity();
13         RenderSupport.transformMatrixForObject(modelViewMatrix, quad);
14     }
15     
16     var tinted:Boolean = texture ? (quad.tinted || parentAlpha != 1.0) : false;
17     var alpha:Number = parentAlpha * quad.alpha;
18     var vertexID:int = mNumQuads * 4;
19     
20     if (mNumQuads + 1 > mVertexData.numVertices / 4) expand();
21     if (mNumQuads == 0) 
22     {
23         this.blendMode = blendMode ? blendMode : quad.blendMode;
24         mTexture = texture;
25         mTinted = tinted;
26         mSmoothing = smoothing;
27         mVertexData.setPremultipliedAlpha(
28             texture ? texture.premultipliedAlpha : true, false); 
29     }
30     
31     quad.copyVertexDataTo(mVertexData, vertexID);
32     
33     if (alpha != 1.0)
34         mVertexData.scaleAlpha(vertexID, alpha, 4);
35     
36     mVertexData.transformVertex(vertexID, modelViewMatrix, 4);
37 
38     mSyncRequired = true;
39     mNumQuads++;
40 }

我们主要集中注意到第36行的代码,该行代码将新添加的顶点坐标和mModelViewMatrix矩阵进行运算,得到的结果是该对象最终会显示到3D画布的坐标;

我们在看看Starling是怎么对批处理对象进行绘制的,RenderSupport的finishQuadBatch方法:

 1 /** Renders the current quad batch and resets it. */
 2 public function finishQuadBatch():void
 3 {
 4     currentQuadBatch.renderCustom(mProjectionMatrix);
 5     currentQuadBatch.reset();
 6     
 7     ++mCurrentQuadBatchID;
 8     
 9     if (mQuadBatches.length <= mCurrentQuadBatchID)
10         mQuadBatches.push(new QuadBatch());
11 }

这个方法我们调用QuadBatch的renderCustom方法传入的是正交矩阵mProjectionMatrix,而不是和当前mModelViewMatrix运算过的mvpMatrix矩阵,因为我们的运算在合并顶点数据时已经进行了。

 

批处理渲染:

QuadBatch类的renderCustom方法是Starling中真正进行3D渲染的核心代码,没有特别需要说的,因为所有需要的数据在该代码执行前都已经正确处理完毕了。