最近在工作中碰到不少棘手的BUG,其中的一個是Spine骨骼的渲染花屏,在戰鬥中派發出大量士兵之後有機率出現花屏閃爍(如下圖所示),這種莫名奇妙且難以重制的BUG最為蛋疼。
前段時間為了提高Spine骨骼動畫的加載速度,将Spine庫進行了更新,新的Spine庫支援skel二進制格式,二進制格式的加載速度比json格式要快5倍以上。
這是一個大工程,遊戲中所有的骨骼動畫都需要使用更高版本的Spine編輯器重新導出,由于部分美術沒有對源檔案進行版本管理,丢失了源檔案,導緻部分骨骼動畫要重新制作,浪費了不少時間。我們對代碼進行了嚴格的版本管理,并且大受裨益,但美術的源檔案管理确實很容易被忽視,是以在這裡吃了一個大虧。更新版本之後,部分使用了翻轉的骨骼出現了一些問題,需要美術逐個檢查,重新設定翻轉之後再導出。
使用了新版本的Spine庫,除了二進制格式的支援外,渲染方面也進行了一個優化,使用TriangleCommand替換了原先的CustomCommand,這使得多個骨骼動畫的渲染可以被合并,原來的版本每個骨骼至少占用一個drawcall。另外新Spine使用的頂點Shader也發生了變化,導緻之前使用的舊Shader也需要跟着調整頂點Shader。
接下來,讓我們開始Debug,首先排查一下骨骼動畫的問題,同一個關卡,我讓測試人員幫忙以很高的頻率出兵,但是隻出一種兵,看看花屏是不是某種兵的渲染導緻的。結果是每種兵出到一定的數量之後都會出現這個問題,但是不同的兵種出問題的時間不同,其中的大樹人兵種在派出了6個之後就會出現花屏的問題,而其他兵種則比較難出現。
那麼大樹的骨骼和其他幾個骨骼有什麼不同呢?詢問美術人員之後,得知大樹這個骨骼動畫使用了較多的Mesh,也就是Spine中的網格功能,這個功能可以讓2D的圖檔實作柔順的扭曲效果,例如毛發、衣物的飄揚效果。
既然是Spine的網格出問題,那麼是否因為Spine的版本問題導緻?編輯器導出的版本與Spine運作庫的版本不比對導緻的,根據文檔讓美術使用了3.3.07,3.5.35和3.5.51版本的Spine編輯器導出骨骼,并使用了3.5.35和3.5.51的運作庫進行測試,都存在這個問題。
接下來我開始對比Spine的渲染代碼,對比上一版本(更新前的Spine,也就是Cocos2d-x3.13.1之前的Spine庫),上一版本使用的是自己的批渲染,而最新版本是TriangleCommand,嘗試改回去,但代碼和資料結構已經發生了較大的改動,強制改回去之後發現渲染效果更加糟糕了。
閱讀了Spine的渲染代碼之後,嘗試跳過spine的網格渲染,我添加了一個測試用的靜态變量,然後在運作中打斷點,之後動态修改這個變量的值,來控制程式的運作流程,逐個跳過Spine的渲染類型,最後定位到隻要把網格渲染跳掉,出再多的大樹人也不會導緻花屏。我想或許有些沒有程式員精神的程式員到這裡就會結案,然後通知美術人員去除所有網格,重新導出資源。但我決定認真分析下為什麼這個網格渲染會導緻花屏。
1 static int skiptype = 0;
2
3 void SkeletonRenderer::draw (Renderer* renderer, const Mat4& transform, uint32_t transformFlags) {
4 SkeletonBatch* batch = SkeletonBatch::getInstance();
5
6 for (auto t : _curTriangles)
7 {
8 TrianglesMgr::getInstance()->freeTriangles(t);
9 }
10 _curTriangles.clear();
11 _triCmds.clear();
12
13 Color3B nodeColor = getColor();
14 _skeleton->r = nodeColor.r / (float)255;
15 _skeleton->g = nodeColor.g / (float)255;
16 _skeleton->b = nodeColor.b / (float)255;
17 _skeleton->a = getDisplayedOpacity() / (float)255;
18
19 Color4F color;
20 AttachmentVertices* attachmentVertices = nullptr;
21 for (int i = 0, n = _skeleton->slotsCount; i < n; ++i) {
22 spSlot* slot = _skeleton->drawOrder[i];
23 if (!slot->attachment) continue;
24 if (slot->attachment->type == skiptype) continue;
25
26 switch (slot->attachment->type) {
27 case SP_ATTACHMENT_REGION: {
28 spRegionAttachment* attachment = (spRegionAttachment*)slot->attachment;
29 spRegionAttachment_computeWorldVertices(attachment, slot->bone, _worldVertices);
30 attachmentVertices = getAttachmentVertices(attachment);
31 color.r = attachment->r;
32 color.g = attachment->g;
33 color.b = attachment->b;
34 color.a = attachment->a;
35 break;
36 }
37 case SP_ATTACHMENT_MESH: {
38 spMeshAttachment* attachment = (spMeshAttachment*)slot->attachment;
39 spMeshAttachment_computeWorldVertices(attachment, slot, _worldVertices);
40 attachmentVertices = getAttachmentVertices(attachment);
41 color.r = attachment->r;
42 color.g = attachment->g;
43 color.b = attachment->b;
44 color.a = attachment->a;
45 break;
46 }
47 default:
兩種渲染最後的處理都一樣,不同的地方就在于上面這個switch中的頂點計算部分,閱讀了一下舊版本Spine的Mesh頂點計算代碼,再看看新的Mesh頂點計算,直接吐血,原本的幾行代碼,新版本使用了幾百行代碼,都是各種複雜的計算,可讀性很糟糕...,嘗試把舊的Mesh頂點計算代碼應用到新的Spine,結果也是非常糟糕。
接下來我決定換一個簡單點的環境來定位問題,這樣可以排除其他的幹擾!我修改了一下Cocos2d-x3.13版本的TestCpp中的SpineTest進行簡單的測試,結果發現了一個有意思的現象,當我添加到第十二個樹人時渲染出現了一些奇怪的現象(美術給我的是小樹人,頂點較少,是以到第十二個才出問題)
再次檢查了一下渲染的代碼後突然注意到左下角的頂點數,當我添加第12個樹人的時候,頂點數突破了65535!記得在Cocos2d-x底層渲染中,65535是VBO頂點緩存區的最大值,接下來把目标鎖定在Cocos2d-x的渲染中。再次閱讀了一下Render的代碼,特别是TriangleCommand的渲染,調試了一下,發現渲染的頂點是2W多個,而Index索引是7W多個,難道是index的限制不能超過65535?于是把代碼中的INDEX_VBO_SIZE替換為VBO_SIZE,這樣一次渲染中Index和Vertex都不能超過65535,改完之後,問題果然解決了。那這就結案了嗎?我覺得還得再深入探讨一下,把問題的根源徹底确定。
1 void Renderer::processRenderCommand(RenderCommand* command)
2 {
3 auto commandType = command->getType();
4 if( RenderCommand::Type::TRIANGLES_COMMAND == commandType)
5 {
6 // flush other queues
7 flush3D();
8
9 auto cmd = static_cast<TrianglesCommand*>(command);
10
11 // flush own queue when buffer is full
12 if(_filledVertex + cmd->getVertexCount() > VBO_SIZE || _filledIndex + cmd->getIndexCount() > INDEX_VBO_SIZE)
13 {
14 CCASSERT(cmd->getVertexCount()>= 0 && cmd->getVertexCount() < VBO_SIZE, "VBO for vertex is not big enough, please break the data down or use customized render command");
15 CCASSERT(cmd->getIndexCount()>= 0 && cmd->getIndexCount() < INDEX_VBO_SIZE, "VBO for index is not big enough, please break the data down or use customized render command");
16 drawBatchedTriangles();
17 }
18
19 // queue it
20 _queuedTriangleCommands.push_back(cmd);
21 _filledIndex += cmd->getIndexCount();
22 _filledVertex += cmd->getVertexCount();
23 }
24
難道IndexCount真的不能超過65535嗎?google查閱了不少資料,glGet擷取GL_MAX_ELEMENTS_INDICES,發現其值是10W+,仔細閱讀了OpenGL超級寶典關于緩存區部分的介紹,也沒有說Index不能超過65535。Cocos2d-x底層的VBO也配置設定了足夠的空間。難道是頂點或者索引錯位了之類的問題導緻的,于是我把動畫停止,把所有的樹人都限定在同一個位置,然後在Render的最底層,列印出每個樹人渲染時的所有頂點和索引資訊,然後對比一下隻有一個樹人、11個樹人以及12個樹人渲染的頂點和索引資訊有何不同。
1 // 增加一些調試用的靜态變量
2 static bool __dbg = false;
3 static bool __deepDbg = false;
4 static int __cmdCount = 68;
5 static int __curCmdCount = 0;
6 static int __idxCount = 0;
7 static int __vexCount = 0;
8 static int __maxidx = 0;
9
10 void Renderer::fillVerticesAndIndices(const TrianglesCommand* cmd)
11 {
12 memcpy(&_verts[_filledVertex], cmd->getVertices(), sizeof(V3F_C4B_T2F) * cmd->getVertexCount());
13
14 // fill vertex, and convert them to world coordinates
15 const Mat4& modelView = cmd->getModelView();
16 for(ssize_t i=0; i < cmd->getVertexCount(); ++i)
17 {
18 modelView.transformPoint(&(_verts[i + _filledVertex].vertices));
19 // 列印所有頂點的xyz和紋理uv
20 if(__dbg && __deepDbg)
21 {
22 CCLOG("vertex %d is xyz %.2f,%.2f,%.2f uv %.2f,%.2f", i + _filledVertex - __vexCount,_verts[i + _filledVertex].vertices.x,
23 _verts[i + _filledVertex].vertices.y, _verts[i + _filledVertex].vertices.z,
24 _verts[i + _filledVertex].texCoords.u, _verts[i + _filledVertex].texCoords.v);
25 }
26 }
27
28 // fill index
29 const unsigned short* indices = cmd->getIndices();
30 for(ssize_t i=0; i< cmd->getIndexCount(); ++i)
31 {
32 _indices[_filledIndex + i] = _filledVertex + indices[i];
33 if (__dbg)
34 {
35 if (__maxidx < _indices[_filledIndex + i])
36 {
37 __maxidx = _indices[_filledIndex + i];
38 }
39 if (__deepDbg)
40 {
41 CCLOG("index %d is %d", _filledIndex + i - __idxCount, _indices[_filledIndex + i] - __vexCount);
42 }
43 }
44 }
45
46 _filledVertex += cmd->getVertexCount();
47 _filledIndex += cmd->getIndexCount();
48 }
49
50 void Renderer::drawBatchedTriangles()
51 {
52 if(_queuedTriangleCommands.empty())
53 return;
54
55 CCGL_DEBUG_INSERT_EVENT_MARKER("RENDERER_BATCH_TRIANGLES");
56
57 if (__dbg)
58 {
59 __vexCount = 0;
60 __idxCount = 0;
61 __curCmdCount = 0;
62 }
63
64 _filledVertex = 0;
65 _filledIndex = 0;
66
67 /************** 1: Setup up vertices/indices *************/
68
69 _triBatchesToDraw[0].offset = 0;
70 _triBatchesToDraw[0].indicesToDraw = 0;
71 _triBatchesToDraw[0].cmd = nullptr;
72
73 int batchesTotal = 0;
74 int prevMaterialID = -1;
75 bool firstCommand = true;
76
77 for(auto it = std::begin(_queuedTriangleCommands); it != std::end(_queuedTriangleCommands); ++it)
78 {
79 const auto& cmd = *it;
80 auto currentMaterialID = cmd->getMaterialID();
81 const bool batchable = !cmd->isSkipBatching();
82 if (__dbg)
83 {
84 if (__curCmdCount % __cmdCount == 0)
85 {
86 CCLOG("begin %d =====================================", __curCmdCount / __cmdCount);
87 __vexCount = _filledVertex;
88 __idxCount = _filledIndex;
89 }
90 ++__curCmdCount;
91 }
92
93 fillVerticesAndIndices(cmd);
94
95 // in the same batch ?
96 if (batchable && (prevMaterialID == currentMaterialID || firstCommand))
97 {
98 CC_ASSERT(firstCommand || _triBatchesToDraw[batchesTotal].cmd->getMaterialID() == cmd->getMaterialID() && "argh... error in logic");
99 _triBatchesToDraw[batchesTotal].indicesToDraw += cmd->getIndexCount();
100 _triBatchesToDraw[batchesTotal].cmd = cmd;
101 }
102 else
103 {
104 // is this the first one?
105 if (!firstCommand) {
106 batchesTotal++;
107 _triBatchesToDraw[batchesTotal].offset = _triBatchesToDraw[batchesTotal-1].offset + _triBatchesToDraw[batchesTotal-1].indicesToDraw;
108 }
109
110 _triBatchesToDraw[batchesTotal].cmd = cmd;
111 _triBatchesToDraw[batchesTotal].indicesToDraw = (int) cmd->getIndexCount();
112
113 // is this a single batch ? Prevent creating a batch group then
114 if (!batchable)
115 currentMaterialID = -1;
116 }
117
118 // capacity full ?
119 if (batchesTotal + 1 >= _triBatchesToDrawCapacity) {
120 _triBatchesToDrawCapacity *= 1.4;
121 _triBatchesToDraw = (TriBatchToDraw*) realloc(_triBatchesToDraw, sizeof(_triBatchesToDraw[0]) * _triBatchesToDrawCapacity);
122 }
123
124 prevMaterialID = currentMaterialID;
125 firstCommand = false;
126 }
127 batchesTotal++;
128 if (__dbg)
129 {
130 CCLOG("MAX IDX %d", __maxidx);
131 }
132 __dbg = false;
133
在添加第一個樹人後,打斷點,并将__dbg和__deepDbg開啟,它會列印出本次渲染的樹人詳情,添加到第十一和第十二個的時候,再各列印一次,通過Beyond Compare對比結果,發現這些資訊完全正确,每個樹人的所有頂點和索引都是完全一樣的,渲染的内容并沒有被修改或發生錯位。那正确的内容為什麼渲染不出正确的結果呢?于是繼續分析接下來的glDrawElements方法,在十二個樹人渲染的時候,斷點檢查了一下該函數的所有參數,發現了第二個參數的值出現了問題!這個值表示要渲染的頂點索引數量,在隻渲染一次的情況下, _triBatchesToDraw[i].indicesToDraw應該等同于_filledIndex才對,而斷點看到的值卻遠小于_filledIndex,查找了一下indicesToDraw的所有引用,發現這個值在每合并一個Command的時候會加上該Command的IndexCount,而這個變量的類型是GLushort!結果終于真相大白,這個變量在不斷增加的過程中溢出了,進而導緻渲染的Index出現問題,最終導緻的花屏。
1 for (int i=0; i<batchesTotal; ++i)
2 {
3 CC_ASSERT(_triBatchesToDraw[i].cmd && "Invalid batch");
4 _triBatchesToDraw[i].cmd->useMaterial();
5 glDrawElements(GL_TRIANGLES, (GLsizei) _triBatchesToDraw[i].indicesToDraw, GL_UNSIGNED_SHORT, (GLvoid*) (_triBatchesToDraw[i].offset*sizeof(_indices[0])) );
6 _drawnBatches++;
7 _drawnVertices += _triBatchesToDraw[i].indicesToDraw;
8 }
最終的改法應該是将indicesToDraw的類型修改為GLsizei,測試通過後,開開心心地打算送出一個pull request,結果卻發現,在下一個版本3.14中,該BUG已被修複...,想想還是應該多更新一下引擎啊....
最後反思一下這個Bug,有些千奇百怪的Bug,處理到最後往往是那麼一兩行代碼的事情,整個解決Bug的流程看上去雖然很繞,但實際上是先确定并重制我呢體,再從出問題的地方——Spine一點點排查,一直到最底層的渲染邏輯。如果是用逆向思維,可能一下子就定位到問題了,但一開始根本沒懷疑Cocos2d-x的渲染有問題,因為Cocos2d-x的版本已經有段時間沒有更新過了,而Spine則是最近更新的。
是以呢,就算不更新引擎,也應該多關心一下引擎的更新日志,了解修改了哪些BUG。除了程式的原因,美術過量使用了網格,也是這個BUG的一大誘因,過量使用網格,會導緻Spine骨骼動畫加載變慢,資源檔案變大,并影響性能。
在分析Spine渲染代碼的時候,發現一個可優化的點,就是每次添加一個渲染指令,都會重新配置設定一塊記憶體用于存儲頂點資訊,為什麼不直接使用傳入的頂點資訊指針呢?可能是因為後面對頂點進行了坐标轉換,這樣同一個頂點可能被轉換多次,那麼在這裡使用一個簡易的記憶體池也可以起到很好的優化作用。