相信来到这里的你和我一样好奇NGUI是如何将我们的原始输入加工成为最终呈现出来的样子的,一言以蔽之,这个过程就是生成顶点、UV、颜色等数据并将它们传入Mesh中使用MeshRenderer进行渲染,但实际过程中需要涉及到各种模块的管理、对效率的优化和对表现的提升等等。
让我们先从一张图开始吧。
为了更好地理解这张图,我们可以先从一条线索入手,那就是在渲染过程中被传递的数据,即顶点和UV等。
顶点和UV
作为图形学的基础,关于顶点和UV的介绍和探讨相信已经有很多了,本文不再赘述。这里所要关心的是,站在NGUI的视角上我们是如何看待或者是如何使用顶点和UV的呢?它们在NGUI的渲染过程中到底发挥了什么样的作用呢?
顶点
顶点是界定所要渲染区域的最基本的元素。在三维空间中,往往还要涉及到对顶点的空间转换,而在二维空间的NGUI中,情况就比较单纯了。
先上两张图,第一张是我们所见的普通视图。
第二张是实际渲染时候的网格图,我们可以看到顶点组成了大量的三角形,这些都是最基本的图元。
UV
UV即u,v纹理贴图坐标,在渲染过程中会根据UV坐标对贴图进行采样。而在NGUI中,使用的贴图主要有三种情况,一种是直接使用原始贴图,第二种是使用图集,还有一种是字体。
以图集为例,左上角四分之一部分的贴图取样对应的UV为(0.0,1.0),(0.0,0.5),(0.5,0.5),(0.5,1.0),大概是这样的。
好了,做好基本工作,我们就可以真正开始探索了。接下来,我会根据图中的各个步骤开始逐一剖析,揭秘NGUI渲染流程的大致脉络。
渲染流程
1、在继承自UIWidget的组件中生成顶点、UV、颜色等数据,并缓存到UIGeometry中
与用户最直接相关的一步,就是我们的输入被各种组件用不同的方法生成顶点、UV和颜色,从而表现出不同的效果。以Sliced类型的UISprite为例,整个Spite被分成9个部分,包括內域和外域,其中外域包含4边和4角,內域顶点所围成的面由內域贴图渲染,外域顶点所围成的面由外域贴图渲染。
下图展示了Simple(右)和Sliced(左)中顶点的区别
//由下面代码可以看出,Sliced类型的做法就是在顶点坐标和UV中根据border划分出一道内边界,从而分出内域和外域,
//内域的顶点由内域贴图渲染,外域顶点由外域贴图渲染
void SlicedFill (List<Vector3> verts, List<Vector2> uvs, List<Color> cols)
{
//border以内的贴图可视为Simple,以外无法被拉伸
Vector4 br = border * pixelSize;
//当border为0的时候,可视整张贴图为Simple
if (br.x == 0f && br.y == 0f && br.z == 0f && br.w == 0f)
{
SimpleFill(verts, uvs, cols);
return;
}
Color gc = drawingColor;
//渲染后图像4条边相对中心点的位置,xyzw分别对应左下右上
Vector4 v = drawingDimensions;
//左下的外边界坐标
mTempPos[0].x = v.x;
mTempPos[0].y = v.y;
//右上的外边界坐标
mTempPos[3].x = v.z;
mTempPos[3].y = v.w;
if (mFlip == Flip.Horizontally || mFlip == Flip.Both)
{
//左下的内边界坐标
mTempPos[1].x = mTempPos[0].x + br.z;
//右上的内边界坐标
mTempPos[2].x = mTempPos[3].x - br.x;
//mOuterUV为原始贴图,mInnerUV为被border界定的内贴图
mTempUVs[3]. x = mOuterUV. xMin;
mTempUVs[2]. x = mInnerUV. xMin;
mTempUVs[1]. x = mInnerUV. xMax;
mTempUVs[0]. x = mOuterUV. xMax;
}
//类似的,对纵向坐标进行处理...
//将计算好的顶点、UV、颜色放入缓存中,跟踪数据可知,这些数据此时被放入了UIGeometry
for (int x = 0; x < 3; ++x)
{
int x2 = x + 1;
for (int y = 0; y < 3; ++y)
{
if (centerType == AdvancedType.Invisible && x == 1 && y == 1) continue;
int y2 = y + 1;
//用4个边界坐标即mTempPos生成顶点数据,可以理解为将mTempPos的所有x和所有y两两组合为最后的顶点
verts.Add(new Vector3(mTempPos[x].x, mTempPos[y].y));
verts.Add(new Vector3(mTempPos[x].x, mTempPos[y2].y));
verts.Add(new Vector3(mTempPos[x2].x, mTempPos[y2].y));
verts.Add(new Vector3(mTempPos[x2].x, mTempPos[y].y));
//类似的,对UV和颜色进行处理...
}
}
}
2、数据从UIGeometry被写入UIDrawCall的缓冲区,准备渲染
这一步主要由UIPanel来管理,在有组件被标记上Change时(比如改变了内容、深度变化等),UIPanel会为对应的组件(继承自UIWidget)找到适应的UIDrawcall,而一旦找不到适应的UIDrawCall,就会将UIPanel内的UIDrawcall全部回收,然后重新制造适应的UIDrawcall。
关键在于,什么是所谓的“适应”呢?我们可以通过以下这段代码一探究竟。
void FillAllDrawCalls ()
{
//回收Panel内所有DrawCall
for (int i = 0; i < drawCalls.Count; ++i)
UIDrawCall.Destroy(drawCalls[i]);
drawCalls.Clear();
Material mat = null;
Texture tex = null;
Shader sdr = null;
UIDrawCall dc = null;
int count = 0;
//根据深度对组件排序
if (mSortWidgets) SortWidgets();
//生成适应的DrawCall
for (int i = 0; i < widgets.Count; ++i)
{
UIWidget w = widgets[i];
if (w.isVisible && w.hasVertices)
{
Material mt = w.material;
if (onCreateMaterial != null) mt = onCreateMaterial(w, mt);
Texture tx = w.mainTexture;
Shader sd = w.shader;
//如果此组件材质(在NGUI中一般体现为图集和字体)、贴图、Shader中有一样不同于(深度)相邻的组件的话,
//就把上一个DrawCall放入DrawCall池,至此完成一个DrawCall的真正创建
if (mat != mt || tex != tx || sdr != sd)
{
if (dc != null && dc.verts.Count != 0)
{
drawCalls.Add(dc);
dc.UpdateGeometry(count);
dc.onRender = mOnRender;
mOnRender = null;
count = 0;
dc = null;
}
mat = mt;
tex = tx;
sdr = sd;
}
//如果此组件有材质(在NGUI中一般体现为图集和字体)、贴图、Shader中的任意一种的话,
//就创建一个新的DrawCall
if (mat != null || sdr != null || tex != null)
{
if (dc == null)
{
dc = UIDrawCall.Create(this, mat, tex, sdr);
dc.depthStart = w.depth;
dc.depthEnd = dc.depthStart;
dc.panel = this;
dc.onCreateDrawCall = onCreateDrawCall;
}
else
{
int rd = w.depth;
if (rd < dc.depthStart) dc.depthStart = rd;
if (rd > dc.depthEnd) dc.depthEnd = rd;
}
w.drawCall = dc;
++count;
//将上文所提到的顶点、UV、颜色等数据从UIGeometry写到UIDrawCall中
if (generateNormals) w.WriteToBuffers(dc.verts, dc.uvs, dc.cols, dc.norms, dc.tans, generateUV2 ? dc.uv2 : null);
else w.WriteToBuffers(dc.verts, dc.uvs, dc.cols, null, null, generateUV2 ? dc.uv2 : null);
if (w.mOnRender != null)
{
if (mOnRender == null) mOnRender = w.mOnRender;
else mOnRender += w.mOnRender;
}
}
}
else w.drawCall = null;
}
//最后一个DrawCall
if (dc != null && dc.verts.Count != 0)
{
drawCalls.Add(dc);
dc.UpdateGeometry(count);
dc.onRender = mOnRender;
mOnRender = null;
}
}
像这样,通过合并减少DrawCall的数量,从而减少对CPU的开销,提升性能。这也就是为什么我们要将材质相同的组件在深度上尽量集中放置。
3、使用UIDrawCall中的数据建立网格,并通过MeshRenderer渲染
好了,原料都准备好了,但是没有机器怎么开工呢?好在Unity提供了这样的机器,NGUI可以将生成的顶点、UV、颜色、法线、切线等数据制作成网格,然后通过MeshRenderer将网格渲染出来。这一部分可以说像一座桥梁,连接了NGUI和Unity。
public void UpdateGeometry (int widgetCount)
{
this.widgetCount = widgetCount;
int vertexCount = verts.Count;
// 安全性检测,确保获得(至少是格式上)正确的数据,这里主要看的是顶点、UV和颜色对不对应
if (vertexCount > 0 && (vertexCount == uvs.Count && vertexCount == cols.Count) && (vertexCount % 4) == 0)
{
//颜色处理
if (mColorSpace == ColorSpace.Uninitialized)
mColorSpace = QualitySettings.activeColorSpace;
if (mColorSpace == ColorSpace.Linear)
{
for (int i = 0; i < vertexCount; ++i)
{
var c = cols[i];
c.r = Mathf.GammaToLinearSpace(c.r);
c.g = Mathf.GammaToLinearSpace(c.g);
c.b = Mathf.GammaToLinearSpace(c.b);
c.a = Mathf.GammaToLinearSpace(c.a);
cols[i] = c;
}
}
// 缓存下MeshFilter,主要用来获取网格
if (mFilter == null) mFilter = gameObject.GetComponent<MeshFilter>();
if (mFilter == null) mFilter = gameObject.AddComponent<MeshFilter>();
if (vertexCount < 65000)
{
// 这里是计算实际组成最后的三角面(最小的面单元)的顶点数,如果发生了变化就重新生成三角面的顶点序列
int indexCount = (vertexCount >> 1) * 3;
bool setIndices = (mIndices == null || mIndices.Length != indexCount);
// 创建网格
if (mMesh == null)
{
mMesh = new Mesh();
mMesh.hideFlags = HideFlags.DontSave;
mMesh.name = (mMaterial != null) ? "[NGUI] " + mMaterial.name : "[NGUI] Mesh";
if (dx9BugWorkaround == 0) mMesh.MarkDynamic();
setIndices = true;
}
//其他处理,主要是顶点的修整...
//放入数据,准备渲染
mMesh.SetVertices(verts);
mMesh.SetUVs(0, uvs);
mMesh.SetColors(cols);
#if UNITY_5_4 || UNITY_5_5_OR_NEWER
//放入法线、切线等数据
mMesh.SetUVs(1, (uv2.Count == vertexCount) ? uv2 : null);
mMesh.SetNormals((norms.Count == vertexCount) ? norms : null);
mMesh.SetTangents((tans.Count == vertexCount) ? tans : null);
#else
if (uv2.Count != vertexCount) uv2.Clear();
if (norms.Count != vertexCount) norms.Clear();
if (tans.Count != vertexCount) tans.Clear();
mMesh.SetUVs(1, uv2);
mMesh.SetNormals(norms);
mMesh.SetTangents(tans);
#endif
#endif
//计算三角面的顶点序列
if (setIndices)
{
mIndices = GenerateCachedIndexBuffer(vertexCount, indexCount);
mMesh.triangles = mIndices;
}
#if !UNITY_FLASH
if (trim || !alwaysOnScreen)
#endif
//使用顶点重新计算边界
mMesh.RecalculateBounds();
mFilter.mesh = mMesh;
}
//为了性能考虑,如果顶点数太多了就报错,提醒进行优化,实际开发中这种限制很有必要
else
{
mTriangles = 0;
if (mMesh != null) mMesh.Clear();
Debug.LogError("Too many vertices on one panel: " + vertexCount);
}
//设置好MeshRenderer参数后,开始渲染
if (mRenderer == null) mRenderer = gameObject.GetComponent<MeshRenderer>();
if (mRenderer == null)
{
mRenderer = gameObject.AddComponent<MeshRenderer>();
//对MeshRenderer进行一系列的设置...
}
UpdateMaterials();
}
else
{
if (mFilter.mesh != null) mFilter.mesh.Clear();
Debug.LogError("UIWidgets must fill the buffer with 4 vertices per quad. Found " + vertexCount);
}
//清空数据...
}
最后通过MeshRenderer,Unity就可以渲染出图像了,这就是从原始输入到我们之所见的大致流程。更多细节我也希望进一步地探索,并分享出来。