4個方面入手 TiledMap 地圖優(yōu)化!W字干貨分享
引言:如何進(jìn)行 TiledMap 地圖優(yōu)化?開發(fā)者 Bool Chen 將分享一套行之有效的 TiledMap 地圖優(yōu)化方案,其中包括了渲染、解析、尋路方面。
當(dāng)項目里的地圖越來越龐大和復(fù)雜,一些性能上的問題也開始逐漸出現(xiàn)。本文將從裁剪區(qū)域共享、Sprite 顏色數(shù)據(jù)去除、多圖集渲染合批和分幀尋路四個方面,分享關(guān)于 TiledMap 地圖的優(yōu)化以及實現(xiàn)。

測試用例
本次的測試用例是這樣的一張地圖,有6個圖層,其中4個圖塊層、2個物件層。測試的數(shù)據(jù)來源是在瀏覽器環(huán)境下,利用 console.time 和 timeEnd 函數(shù),打印對應(yīng)的邏輯耗時或渲染耗時,需要注意的是每次運行的耗時并不是一致的,但是在取均值后,可以認(rèn)為是相對可靠的。

優(yōu)化前后(注:橫軸是游戲運行的幀數(shù),縱軸是在該幀數(shù)下,對應(yīng)的耗時,單位是毫秒)
上圖是我們最后將裁剪區(qū)域共享+Sprite 顏色數(shù)據(jù)去除+多圖集渲染合批一起使用后的優(yōu)化效果,測試顯示渲染耗時大約降低了20%左右。其實這張地圖并不算復(fù)雜,如果物件數(shù)量、圖層數(shù)量增加的話,優(yōu)化效果會更加明顯。
本次的主要優(yōu)化方案參考自大城小胖的《如何重繪<江南百景圖>》,文章介紹了很多性能優(yōu)化技巧,強烈推薦大家去看看。項目基于 Cocos Cocos Creator 2.4.3,不過大部分優(yōu)化思路在 v3.x 依舊適用。限于篇幅,本文僅呈現(xiàn)部分核心代碼,完整代碼及測試項目源碼下載見文末。
裁剪區(qū)域共享
玩家操控人物在地圖上移動的時候,地圖顯示的內(nèi)容也需要跟隨人物的位置發(fā)生改變。此時,為了優(yōu)化性能,引擎會計算屏幕的可視范圍,只有在可視范圍內(nèi)的圖塊才會被渲染。
研究引擎中 TiledMap 地圖的渲染流程后我們發(fā)現(xiàn),其實 TiledMap 本身并不是渲染組件,地圖的渲染是通過圖層 TiledLayer 實現(xiàn)的,其對應(yīng)的渲染器是 TmxAssembler。渲染時,渲染流會逐個調(diào)用 TmxAssembler 的 fillBuffers 函數(shù)進(jìn)行渲染數(shù)據(jù)填充,此函數(shù)中會調(diào)用 CCTiledLayer 的 _updateCulling 函數(shù)進(jìn)行可視范圍,只有可視范圍發(fā)生改變才會進(jìn)行渲染。
但是,在計算的時候,由于每個圖層都有對應(yīng)的 Assembler,所以每個圖層都會單獨計算一次。而一般情況下我們每個圖層顯示的范圍是一致的,所以我們希望它只計算一次就好了。
接下來我們就來實現(xiàn)裁剪區(qū)域共享(Share Culling),讓不同 TiledLayer 間,共享可視區(qū)域的裁剪計算結(jié)果,以此節(jié)約性能。
實現(xiàn)過程
首先我們繼承 TiledMap,重寫創(chuàng)建圖層的 _buildLayerAndGroup 函數(shù),實現(xiàn)創(chuàng)建自定義的 ShareCullingTiledLayer。
因為相對來說記錄第一個圖層實現(xiàn)起來更方便,所以我們緩存第一個圖層,并將首個 TieldLayer 傳遞給后面的圖層,方便后面去讀取計算結(jié)果。
_buildLayerAndGroup() {
for (let i = 0, len = layerInfos.length; i < len; i++) {
if (layerInfo instanceof cc.TMXLayerInfo) {
// 創(chuàng)建自定義的ShareCullingTiledLayer
let layer = child.getComponent(ShareCullingTiledLayer);
// 傳遞、記錄首個TiledLayer
layer._init(layerInfo, firstLayer);
firstLayer = firstLayer || layer;
}
}
}
接著修改 TiledLayer 的裁剪函數(shù),一樣通過重寫的方式實現(xiàn)。
這里我們進(jìn)行判斷,如果是首個圖層,我們才讓他進(jìn)行計算,并把結(jié)果緩存起來;如果不是首個圖層,我們就直接讀取首個圖層的計算結(jié)果。
最后重寫 TiledLayer 的裁剪函數(shù),實現(xiàn)復(fù)用裁剪區(qū)域的功能。
_updateCulling() {
// this._firstLayer為空時 表示為首個layer
let firstLayer = this._firstLayer;
if (!firstLayer) {
// 進(jìn)行裁剪區(qū)域計算
this._updateViewPort();
this._cacheCullingDirty = this._cullingDirty;
} else {
// 直接復(fù)用firstLayer的結(jié)果
this._cullingRect = firstLayer._cullingRect;
this._cullingDirty = firstLayer._cacheCullingDirty;
return;
}
}
很簡單地我們就完成了這個優(yōu)化。Share Culling 實現(xiàn)起來并不麻煩,但效果是顯著的。
優(yōu)化效果

優(yōu)化前后
可以看到即使只有6個圖層的情況下,裁剪函數(shù)的平均耗時降低了35%左右,當(dāng)圖層數(shù)量增加的時候,優(yōu)化效率會更高。
講到裁剪區(qū)域,這里還有一個優(yōu)化點。在初始化圖塊圖層時,引擎會遍歷整個地圖的圖塊,將所有圖塊的信息保存起來,方便后續(xù)使用。這里可以改成區(qū)域加載,一開始只解析當(dāng)前屏幕中的圖塊,隨后在移動的時候,再動態(tài)解析行動方向上的圖塊——當(dāng)然這個方案也有缺點,就是我們需要額外的內(nèi)存空間保存對應(yīng)的坐標(biāo)是否已經(jīng)解析過。
Sprite 顏色數(shù)據(jù)去除
接下來是物件顏色去除,這里我們用在地圖物件上,但其實這個優(yōu)化在所有 Sprite 組件中都是可以適用的。

Sprite 默認(rèn)的渲染頂點數(shù)據(jù)中包含了顏色數(shù)據(jù),但大部分情況下,美術(shù)給我們的素材我們都是直接放到游戲里,不會再對顏色做修改,此時 Color 數(shù)據(jù)似乎成了一個非必要的東西,將其去除掉可以減少 CPU 和 GPU 的數(shù)據(jù)傳輸,也可以省去著色器中對顏色的計算。
簡單講一下 Sprite 渲染流程。Sprite 組件會通過 resetAssembler 取得一個默認(rèn)的 Assembler,而 Assembler 會通過 updateRenderData 函數(shù),把 Sprite 的數(shù)據(jù)填充到 RenderData 中。最后引擎會幫我們把渲染數(shù)據(jù)傳遞給材質(zhì),進(jìn)而進(jìn)行渲染。
接著我們來看看怎么實現(xiàn)這個優(yōu)化。
實現(xiàn)過程
我們從底層步驟往上看,首先是著色器。仿照內(nèi)置的 Effect 及 Material 創(chuàng)建 Effect 和 Material,因為我們不再需要顏色了,所以只要把著色器中關(guān)于顏色的輸入輸出、計算等的代碼去除即可。
// 刪除顏色相關(guān)輸入輸出處理
CCProgram vs %{
in vec3 a_position;
// in vec4 a_color;
// out vec4 v_color;
void main () {
// v_color = a_color;
gl_Position = pos;
}
}%
// 刪除顏色相關(guān)輸入、計算
CCProgram fs %{
precision highp float;
// in vec4 v_color;
void main () {
vec4 o = vec4(1, 1, 1, 1);
CCTexture(texture, v_uv0, o);
// o *= v_color;
gl_FragColor = o;
}
}%
接著我們要提供不帶顏色的渲染數(shù)據(jù)。繼承 cc.Assembler 實現(xiàn)一個新的 Assembler。在 Assembler 中,首先要新建一個頂點數(shù)據(jù)格式,將默認(rèn)的頂點格式中的顏色屬性去掉。隨后,為我們的新格式創(chuàng)建對應(yīng)的頂點數(shù)據(jù)容器。
// 自定義頂點格式,去掉默認(rèn)的顏色字段
let gfx = cc.gfx;
let vfmtNoColor = new gfx.VertexFormat([
{ name: gfx.ATTR_POSITION, type: gfx.ATTR_TYPE_FLOAT32, num: 2 },
{ name: gfx.ATTR_UV0, type: gfx.ATTR_TYPE_FLOAT32, num: 2 },
// { name: gfx.ATTR_COLOR, …},
]);/**
* 初始化this._renderData 創(chuàng)建自定義格式的renderData
*/
initData() {
let data = this._renderData = new cc.RenderData();
this._renderData.init(this);
// 按我們自己的格式創(chuàng)建renderData
data.createFlexData(0, 4, 6, vfmtNoColor);
}
最后,把渲染顏色的函數(shù)也移除掉。這樣就完成了一個不帶顏色的 Assembler。
/**
* 更新顏色 拜拜了??
*/
// updateColor () {
// }
接著我們需要使用這個 Assembler。重寫 Sprite 的 resetAssembler 函數(shù),將默認(rèn)的 Assembler 改成上面的 Assembler。
/**
* 修改默認(rèn)Assembler
*/
_resetAssembler() {
let assembler = this._assembler = new NoColorSpriteAssembler();
assembler.init(this);
this.setVertsDirty();
},
如果你要運用在其他地方,只要給 Sprite 換上前面的 material 就可以了。
那么如何運用在地圖物件中呢?我們通過繼承實現(xiàn)一個 TiledObjectGroup,并重寫 _init 函數(shù)。在里面,我們將默認(rèn)的 Sprite 組件改成我們自定義的組件,并賦予對應(yīng)的去除顏色的材質(zhì)即可。
_init(groupInfo, mapInfo, texGrids, noColorMaterial) {
let objects = groupInfo._objects;
for (let i = 0, l = objects.length; i < l; i++) {
imgNode = new cc.Node();
let sp = imgNode.addComponent("NoColorSprite");
sp.setMaterial(0, noColorMaterial);
}
}
優(yōu)化效果

優(yōu)化前后
最終優(yōu)化效果,在大約有100多個組件的情況下,渲染耗時降低了約12%。我在測試優(yōu)化效果的時候,發(fā)現(xiàn)這個數(shù)據(jù)有較大的浮動,范圍大約是5-15%。
在邏輯層面,我們減少了顏色數(shù)據(jù)的填充,本身優(yōu)化效果其實并不算大。其次,數(shù)據(jù)統(tǒng)計監(jiān)聽不到 CPU 和 GPU 數(shù)據(jù)傳輸?shù)牟糠?,也監(jiān)聽不到著色器優(yōu)化的部分。
另外,顏色數(shù)據(jù)的去除還可以為我們接下來的地圖物件多圖集渲染合批做準(zhǔn)備。
多圖集渲染合批
物件常常是地圖中不可或缺的一部分,世界觀豐富起來之后,物件來自不同的圖集也是很常見的,這個時候如果還要對物件進(jìn)行排序,圖集交錯的情況下,非常容易產(chǎn)生大量的 DC。
優(yōu)化 DC 的常見方案是打包圖集,但當(dāng)圖片來自不同圖集的時候,這個方案就無法進(jìn)行了。多圖集渲染合批是一個類似于打包圖集的方案,我們在渲染的時候,一次傳遞多張圖集,把原本的判斷圖片是否來自于同一張圖集,轉(zhuǎn)換為判斷圖片是否來自于同一批圖集。
大部分手機設(shè)備都可以支持8張圖集,所以理論上,只要使用的圖集不超過8張,就可以只要1次 DC。
實現(xiàn)過程
首先一樣需要修改著色器相關(guān)代碼。我們同樣復(fù)制一份內(nèi)置 Effect,之后在 Effect 的聲明中,增加一些 texture 參數(shù),來接收多張圖集數(shù)據(jù)。
CCEffect %{
techniques:
- passes:
- vert: vs
properties:
texture: { value: white }
texture1: { value: white }
texture2: { value: white }
texture3: { value: white }
// 4 5 6...
}%
隨后,通過頂點數(shù)據(jù)傳遞 texture_index,表示當(dāng)前使用的是哪張圖集。這里在著色器代碼中根據(jù) texture_index 從不同的圖集取值就可以了。
CCProgram fs %{
precision highp float;
in float texture_idx;
void main () {
vec4 o = vec4(1, 1, 1, 1);
#if USE_TEXTURE
if (texture_idx <= 1.0) {
CCTexture(texture, v_uv0, o);
} else if (texture_idx <= 2.0) {
CCTexture(texture1, v_uv0, o);
} else if (texture_idx <= 3.0) {
CCTexture(texture2, v_uv0, o);
}
// else ...
#endif
gl_FragColor = o;
}
}%
現(xiàn)在我們要想辦法把這些數(shù)據(jù)傳遞給材質(zhì)。
先說 texture_index。這個部分和前面的組件顏色去除有點類似,不過這次是增加數(shù)據(jù)。我們自定義新的頂點數(shù)據(jù)格式,在里面增加一個 a_texture_index 屬性,之后創(chuàng)建一個新的頂點數(shù)據(jù)容器(注意 texture_index 聲明的位置,一會兒我們會用到)。
let gfx = cc.gfx;
var vfmtPosUvColorIndex = new gfx.VertexFormat([
{ name: gfx.ATTR_POSITION, type: gfx.ATTR_TYPE_FLOAT32, num: 2 },
{ name: gfx.ATTR_UV0, type: gfx.ATTR_TYPE_FLOAT32, num: 2 },
{ name: "a_texture_idx", type: gfx.ATTR_TYPE_FLOAT32, num: 1 },
{ name: gfx.ATTR_COLOR, type: gfx.ATTR_TYPE_UINT8, num: 4, normalize: true },
]);
initData() {
let data = this._renderData = new cc.RenderData();
this._renderData.init(this);
data.createFlexData(0, 4, 6, vfmtPosUvColorIndex);
}
完事之后,我們就要往這個容器中寫值,把數(shù)據(jù)傳遞給著色器。
新建 updateTextureIdx 函數(shù),進(jìn)行數(shù)據(jù)的填充。按照我們定義的頂點格式,在每個頂點的對應(yīng)位置填充 texture_index 屬性值。
隨后找出填充頂點數(shù)據(jù)的 updateRenderData 函數(shù),在里面增加對 updateTextureIdx 函數(shù)的調(diào)用,這樣就完成了數(shù)據(jù)的填充。
// 填充textureIndex數(shù)據(jù)
updateTextureIdx(sprite) {
let textureIdx = sprite._textureIdx;
let verts = this._renderData.vDatas[0];
let verticesCount = this.verticesCount;
let floatsPerVert = this.floatsPerVert;
for (let i = 0; i < verticesCount; i++) {
let index = i * floatsPerVert + 4;
verts[index] = textureIdx;
}
}
updateRenderData(sprite) {
if (sprite._vertsDirty) {
this.updateUVs(sprite);
this.updateVerts(sprite);
this.updateTextureIdx(sprite);
sprite._vertsDirty = false;
}
}
接著是傳遞圖集,我們?yōu)?objectGroup 傳遞一個 texture 變量,來保存所有物件圖層使用到的圖集。在創(chuàng)建完 objectGroup 之后,再按順序地把圖集傳遞給材質(zhì)。
_buildLayerAndGroup: function () {
let layerInfos = mapInfo.getAllChildren ();
let textureSet = new Set();
for (let i = 0, len = layerInfos.length; i < len; i++) {
let layerInfo = layerInfos[i];
let group = child.getComponent("MutilObjectGroup");
group._init(this.objectMaterial, textureSet);
}
// 設(shè)置材質(zhì)的texture屬性
let objectTextures = Array.from(textureSet);
for (let i = 0; i < objectTextures.length; i++) {
let idx = i === 0 ? '' : i;
this.objectMaterial.setProperty(`texture${idx}`, objectTextures[i], 0);
}
}
接著看一下 objectGroup的部分。
我們實現(xiàn)新的 TiledObjectGroup,重寫 _init 函數(shù)。
除了 textureSet,我們同時維護(hù)一個 textureIndexMap,來記錄圖集在 set 中的位置。新建 Sprite 組件的時候,動態(tài)地去更新 TextureSet 和 TextureIndexMap。
然后,我們利用 map 來獲得 Sprite 的 texture_index。
需要注意的是,我們需要將材質(zhì)的哈希值寫死,否則更新圖集后,一樣會判定為不可合批。
_init(groupInfo, mapInfo, texGrids, material, textureSet) {
// texture資源 -> textureIndex
let textureIndexMap = new Map();
Array.from(textureSet).forEach((texture, idx) => textureIndexMap.set(texture, idx));
for (let i = 0, l = objects.length; i < l; i++) {
let sp = imgNode.getComponent("MutilSprite");
let spf = sp.spriteFrame;
// 收集所有圖集
let size = textureSet.size;
textureSet.add(grid.tileset.sourceImage);
// 更新Map
if (size !== textureSet.size) {
textureIndexMap.set(grid.tileset.sourceImage, size)
}
sp.setMaterial(0, material);
// 設(shè)置textureIndex
let index = textureIndexMap.get(sp.spriteFrame._texture);
// 寫死哈希值 使其可以合批
sp.getMaterial(0).updateHash(9999);
sp.setTextureIdx(index + 1);
}
}
優(yōu)化效果

優(yōu)化后,DC 從16降到了6,平均降低了13%的渲染耗時。而在復(fù)雜的環(huán)境下,不論物件產(chǎn)生了多少次 DC,最后的 DC 都會是6次,優(yōu)化效果也會提升。
因為相對默認(rèn)的渲染方式,我們額外增加了 texture_index 這個數(shù)據(jù),這會有一點性能損耗。但是如果和前面的顏色去除結(jié)合起來使用,就可以抵消這個損耗,達(dá)到更好的優(yōu)化效果。
此外,在圖塊圖層也有類似記錄圖集的操作。
初始化時,需要獲取圖層用到的所有圖集,并為他們創(chuàng)建對應(yīng)的材質(zhì),這里需要遍歷整張地圖。這里是一個優(yōu)化點,首先我們可以要求策劃拼地圖的時候每個圖層只使用一個圖集,這也可以避免多個圖集導(dǎo)致的 DC 上升。在這之后,我們可以修改對應(yīng)的代碼,只要找到一個圖集,就可以停止遍歷了,避免多次完整遍歷整張地圖。
分幀尋路

尋路是游戲中的重要部分,當(dāng)?shù)貓D面積增加時,尋路算法的損耗也會變成一個不可小視的部分。分幀的思路也是一個常見的優(yōu)化方法,我們把一件復(fù)雜的工作拆成好幾段,每幀做一段。它本身并沒有減少運算的數(shù)量,但是可以幫你壓平 CPU 的使用率曲線,避免突發(fā)的計算占用導(dǎo)致掉幀。
實現(xiàn)過程
在我們的尋路工具類里面提供一個接口,來進(jìn)行尋路任務(wù)的提交。
因為分幀處理后,代碼的執(zhí)行變成異步的了,所以我們需要緩存尋路任務(wù)的數(shù)據(jù)以及進(jìn)度,才能正確地接著上一幀的結(jié)果繼續(xù)處理。
之后,我們在游戲中每幀調(diào)用對應(yīng)的尋路函數(shù),進(jìn)行尋路的計算。
在進(jìn)行路徑計算的時候,我們每次訪問路徑點前,都先判斷已訪問的路徑點數(shù)量,如果超出了數(shù)量,就不再進(jìn)行尋路,等待下一幀的調(diào)用。
/**
* 開始一個尋路任務(wù)。此函數(shù)為外部調(diào)用入口
*/
findRoad(start, target, wall, callback, config) {
const { maxWalkPerFrame } = config;
this._maxWalkPointAmount = maxWalkPerFrame || Number.MAX_VALUE;
// ...存儲數(shù)據(jù)
// 立即執(zhí)行一次尋路
this._findPath();
}
/**
* 此函數(shù)應(yīng)由外部引用者每幀調(diào)用
*/
update() {
this._findPath();
}
/**
* 執(zhí)行一次尋路
*/
_findPath() {
let walkPointAmount = 0;
while (walkPointAmount++ < this._maxWalkPointAmount) {
// 訪問路徑點...
const point = this._waitQueue.poll();
}
}
優(yōu)化效果

優(yōu)化前后
測試用例是在游戲開始時,提交了四個尋路任務(wù)。可以看到優(yōu)化前的時間消耗接近 8ms,這對我們來說是不可接受的。在優(yōu)化后,最高的耗時也不過 1ms。相對來說是一個可以接受的數(shù)字。
除了分幀處理,我們還可以再進(jìn)一步地進(jìn)行優(yōu)化。
比如游戲世界剛啟動的時候,所有的 NPC 都需要進(jìn)行隨機移動,這個時候會有大量的 NPC 需要同時進(jìn)行尋路運算,仍然會導(dǎo)致 CPU 占用率過高。這里有兩個方案,一個是讓 NPC 在不同的時機點開始移動,另一個是對尋路任務(wù)進(jìn)行統(tǒng)一的管理。這里介紹一下后一個方案。
我們可以將提交的尋路任務(wù)保存到隊列中。只有當(dāng)尋路任務(wù)完成的時候,我們才從隊列中取出新的任務(wù)。
/**
* 添加一個尋路任務(wù)。此函數(shù)為外部調(diào)用入口
* @param {FindRoadTask} task 尋路任務(wù)
*/
addFindRoadTask(task) {
if (this._finding) {
this._taskList.push(task);
} else {
this._startFindRoadTask(task);
}
}/**
* 尋路任務(wù)結(jié)束回調(diào)。不論尋路成功或失敗都會調(diào)用本函數(shù)
*/
_onFindOver() {
if (!!this._taskList.length) {
this._startFindRoadTask(this._taskList.shift());
} else {
this._finding = false;
}
}
如此一來,就可以保證我們每幀同時只會執(zhí)行一個任務(wù),進(jìn)一步地壓平曲線。我們用同樣的測試用例進(jìn)行測試,結(jié)果如下。

優(yōu)化前后
需要注意一下,這里的藍(lán)色線就是剛剛優(yōu)化后的綠色線??梢钥吹骄G色線又進(jìn)一步地更平緩了,最高也不超過 0.5ms,我們可以不用再擔(dān)心尋路會對幀數(shù)造成影響了。
總結(jié)與資源下載
如果把本文介紹的優(yōu)化全做了是什么效果?

開頭提到,這里我們測試了裁剪區(qū)域共享+顏色去除+多圖集渲染合批,渲染耗時大約降低了20%左右。
完整代碼與測試項目歡迎移步下方論壇專貼查看與下載,如有疑問、或是其他好的優(yōu)化方案,都可以在論壇一起交流!
論壇專貼地址
裁剪區(qū)域共享(Share Culling):
https://forum.cocos.org/t/topic/134525
Sprite 顏色數(shù)據(jù)去除:
https://forum.cocos.org/t/topic/135235
多圖渲染合批:
https://forum.cocos.org/t/topic/136349
分幀尋路+尋路任務(wù)統(tǒng)一管理:
https://forum.cocos.org/t/topic/134884
往期精彩
