Unity优化之NGUI篇
最近在进行项目的优化工作,测试发现UI的开销占到了一半以上,因此决定先从UI优化入手。
源码分析
NGUI中有几个重要的类:UIPanel、UIWidget和UIDrawCall。其中,UIPanel使用一个静态链表来保存游戏里的所有UIPanel实例,每个UIPanel在列表中的顺序由depth属性决定。需要注意的是,这里是静态链表,不存在父子关系。以下是UIPanel的depth属性的代码实现:
public int depth
{
get
{
return mDepth;
}
set
{
if (mDepth != value)
{
mDepth = value;
#if UNITY_EDITOR
NGUITools.SetDirty(this);
#endif
list.Sort(CompareFunc);
}
}
}
每个UIPanel使用一个列表来保存所有的UIWidget,排序顺序为先按照depth排序,若depth相同则再按照材质排序。以下是排序相关的代码:
public void SortWidgets ()
{
mSortWidgets = false;
widgets.Sort(UIWidget.PanelCompareFunc);
}
static public int PanelCompareFunc (UIWidget left, UIWidget right)
{
if (left.mDepth < right.mDepth) return -1;
if (left.mDepth > right.mDepth) return 1;
Material leftMat = left.material;
Material rightMat = right.material;
if (leftMat == rightMat) return 0;
if (leftMat != null) return -1;
if (rightMat != null) return 1;
return (leftMat.GetInstanceID() < rightMat.GetInstanceID()) ? -1 : 1;
}
每个UIPanel管理自己的DrawCall,其定义如下:
public List<UIDrawCall> drawCalls = new List<UIDrawCall>();
大概流程是每一帧每个UIPanel会更新所有属于它的UIWidget。判断一个UIWidget属于哪个UIPanel的方法是,该UIWidget会向其父节点一直向上查找,第一个找到的UIPanel就是它所属的UIPanel,具体代码如下:
static public UIPanel Find (Transform trans, bool createIfMissing, int layer)
{
UIPanel panel = null;
while (panel == null && trans != null)
{
panel = trans.GetComponent<UIPanel>();
if (panel != null) return panel;
if (trans.parent == null) break;
trans = trans.parent;
}
return createIfMissing ? NGUITools.CreateUI(trans, false, layer) : null;
}
更新UIWidget的过程实际上就是准备UIDrawCall的过程。首先会在现有的DrawCall中查找,如果找到材质、贴图和shader都相同的DrawCall,则认为该UIWidget可以放入这个DrawCall中;否则,就需要重建当前UIPanel的所有DrawCall。以下是查找DrawCall的代码:
public UIDrawCall FindDrawCall (UIWidget w)
{
Material mat = w.material;
Texture tex = w.mainTexture;
int depth = w.depth;
for (int i = 0; i < drawCalls.Count; ++i)
{
UIDrawCall dc = drawCalls[i];
int dcStart = (i == 0) ? int.MinValue : drawCalls[i - 1].depthEnd + 1;
int dcEnd = (i + 1 == drawCalls.Count) ? int.MaxValue : drawCalls[i + 1].depthStart - 1;
if (dcStart <= depth && dcEnd >= depth)
{
if (dc.baseMaterial == mat && dc.mainTexture == tex && w.shader == dc.shader)
{
if (w.isVisible)
{
w.drawCall = dc;
if (w.hasVertices) dc.isDirty = true;
return dc;
}
}
else mRebuild = true;
return null;
}
}
mRebuild = true;
return null;
}
UIPanel的更新方法UpdateSelf如下:
void UpdateSelf ()
{
mUpdateTime = RealTime.time;
UpdateTransformMatrix();
UpdateLayers();
UpdateWidgets();
if (mRebuild)
{
mRebuild = false;
FillAllDrawCalls();
}
else
{
// 此处代码省略
}
}
优化策略
减少UIPanel的数量
UIPanel的数量一定要尽量少。这是因为UIPanel之间的DrawCall是不会合并的,所以如果滥用UIPanel,DrawCall的数量将无法降低。
合理规划UIWidget的depth
同一个UIPanel下面的UIWidget的depth需要有一个良好的规划。由于UIWidget的排序顺序是先按照depth,然后再按照材质,因此我们要尽量把使用相同材质贴图的UIWidget放在同一个depth段里。
以游戏中的主界面为例,主界面是玩家大多数时候停留的界面,因此最值得优化。建议在主界面没有特殊需求的情况下,将所有静态的东西都放在一个UIPanel下。假设使用的shader都是半透明混合的shader,大部分的UI元素放到了一个公共的图集common中,其他的图集有atlas1、atlas2等,还有文字用到的贴图font1、font2。主界面应尽量避免出现文字在贴图下面的情况。根据源码分析,我们要尽量把使用同一个图集的UIWidget放在一起,这里主要围绕common图集进行规划,因为其他的贴图很可能会根据游戏逻辑发生变化。以下是建议的depth范围设置:
common:1 - 20- 其他
atlas:21 - 40 common:41 - 60- 其他
atlas:61 - 80 common:81 - 100
这样设置后,可以保证common图集的UIWidget都合并到一个DrawCall里。
减少DrawCall的重建
重建DrawCall主要有以下几种情况,应尽量减少重建,因为每一帧都重建是不可接受的。原则是尽量不要改变同一个UIPanel下面的UIWidget列表,增加、删除或改变顺序都会导致DrawCall的重建。
UIWidget第一次更新时,没有找到对应的DrawCall。UIPanel的OnEnable方法被调用时。UIPanel的OnInit方法(即Start方法)被调用时。- 手动调用
Refresh方法(尽量不要手动调用这个接口)。 UIWidget从UIPanel上移除,并且该UIWidget的depth是DrawCall的起始或结束depth时,代码如下:public void RemoveWidget (UIWidget w) { if (widgets.Remove(w) && w.drawCall != null) { int depth = w.depth; if (depth == w.drawCall.depthStart || depth == w.drawCall.depthEnd) mRebuild = true;
w.drawCall.isDirty = true; w.drawCall = null; } }
### 减少DrawCall的更新
`DrawCall`的更新(即`DrawCall`标记为`Dirty`)主要有以下几种情况,其中应重点减少第4种情况。只要`UIWidget`的`transform`发生变化,或者`color`发生变化,都会重新填充当前的`DrawCall`。因此,优化策略是把每帧都会发生变化的`UIWidget`放在单独的`DrawCall`里,或者单独的`UIPanel`中,即所谓的动静分离。
- 后加入的`UIWidget`找到之前的同样材质的`DrawCall`时。
- `UIWidget`从以前的`DrawCall`中移除时。
- 手动调用`SetDirty`方法时。
- `UIWidget`的`UpdateGeometry`方法被调用时。