THREE.js開發的應用運行在iphone5下發現有些時候會崩潰,跟了幾天發現是因為Sprite太多頻繁更新紋理占用顯存導致的。通常解決紋理頻繁更新問題就要用到one draw all方法,放到紋理上就是把所有紋理圖片生成一張大圖片的方式
一、阻止紋理重復上傳
我們需要一張大紋理,先將所有的內容繪制在大紋理上,需要顯示局部紋理的時候通過紋理坐標控制去大紋理上取圖像。那么這個時候問題來了,THREE.js內部實現方式是將Texture與圖片、紋理坐標綁定,即使為所有的Texture對象設置同一張圖片,THREE.js仍然會將每個Texture中的圖片上傳給GPU。每次上傳一張大紋理嚴重阻塞UI渲染進程。
首先要解決的是讓這張大紋理值上傳一次。
這個問題需要我們對THREE.js源碼進行深入了解,可以看到setTexture2D函數中有一個properties變量,這個變量是一個WebGLProperties類型的變量,而該類型存儲各種東西:Texture、Material、RenderTarget、Object的buffers等。我們繼續深入該類的源碼,發現get方法會根據對象的uuid來獲取相關WebGL屬性,比如gl.createTexture、gl.createBuffer創建的各種緩沖區。
對應Texture得到的webgl屬性如下,其中__webglTexture就是對應的紋理圖片創建的緩沖區對象。
那么我們可以來一個取巧的方法,將所有紋理的的uuid都設置唯一,那么THREE.js只會對第一個Texture的紋理進行上傳,后面的texture對象取到的都是第一個的properties,這樣就能避免紋理重復上傳。
二、建立紋理索引
我們需要自己維護一套索引關系,通過這套索引關系得到每個貼圖在大紋理中紋理坐標。這里要為每一個poi記錄它的起始位置和區域范圍,其中要用到canvasContext.measureText來測量文本的寬度,文本高度可以直接根據fontSize取得。
同時索引建立完畢后,需要計算每個poi區域在全局紋理中的紋理坐標范圍:
要注意的是,這里紋理坐標的原點在左下方,有時候原點在左上方。建立索引代碼如下
三、局部更新
上述方案雖然能夠避免頻繁上傳紋理,但是需要每次將需要繪制的內容准備好,當有內容需要更新時,還是需要重新上傳整個全局紋理,反而使得性能下降巨大。經過查閱資料后發現webgl中有一種局部紋理更新技術,簡單來說先在內存中開辟一塊的紋理區域,將所有內容繪制在這張全局紋理中,每次有更新時,只需要更新它的一個局部區域即可。
但是這里要解決的問題是THREE.js並沒有提供局部紋理更新的方式,也沒有相應的自定義接口,那么這時候就需要我們自己來處理了。
這里自定義一個Texture的子類
開辟一塊內存區域
在需要的時候動態更新局部紋理,其中src這里是ImageData對象
具體代碼可以參考這里,我這里也是基於它來定制的。
https://github.com/spite/THREE.UpdatableTexture
原文作者通過更改THREE.js源碼的方式實現,而我是直接把下面這個函數拷貝到這個子類中
四、高清屏的大坑
現在我們的方案是,先在gpu中開辟一塊全局紋理區域,然后繪制時將poi繪制到一張與全局紋理同樣大小的canvas上,然后從canvas中調用createImageData來獲取像素,將像素局部更新到gpu中。那么在pc上我們得到的結果很完美。
然而放到移動端上后,我們得到的結果是:
TMMD中間那塊哪去了!找了大半天發現問題出現在高清屏上,擋在高清屏上繪制canvas上時,我們通常會做一些高清處理,比如四像素繪制一像素。
我們做高清處理的方式是利用radio*radio設備像素繪制一css像素,看起來是css像素的大小,但實際在瀏覽器內部,看起來css上一像素實際在canvas里的像素是radio * radio(radio代表window.devicePixelRatio)
但實際上在瀏覽器內部繪制canvas圖像的單位是設備像素。那么如果我們還以上面的rectW、rectH來獲取像素的話,我們得到的這部分像素並不是這個poi真正占有的像素數目。
所以,問題就來了我們需要在gpu開辟的全局紋理的單位跟canvas中獲取像素的單位要保持一致,我們統一使用設備像素。
我們對canvas也不用使用style來設置樣式寬高了。
那么獲取poi圖像的真正像素范圍時:
所以利用getImageData取像素時候,就要小心取到真正的像素區域,(startX * radio,startY * radio)- (poiRectW * radio, poiRectH * radio);否則某些像素就會被丟棄掉,這部分像素才是瀏覽器真正使用的設備像素。
現在在移動設備上能夠獲取正確的高清label啦!
五、局部更新引起的新問題
當全局紋理被占滿時候,在繼續繪制poi,這時候新的poi區域需要更新到gpu中,那么也就帶來了新的問題,在gpu中的紋理還保持着之前的像素,而新的poi會覆蓋這部分區域,但有時候往往會與之前的文字疊加起來,效果如下:
可以看到新更新的poi,在計算紋理坐標時候,有一部分像素包含了其他poi的像素。這個問題是因為新poi的區域剛好疊在了先前poi的邊界上,那么我們只要給新的poi加一點buffer,這個buffer是白素透明區域,buffer會把之前的poi像素覆蓋掉,而我們計算紋理坐標時,只取poi的邊界,那么就可以解決這個問題。
那么首先繪制的時候就要保留buffer
上傳的時候使用buffer
計算紋理坐標時,排除buffer
六、局部更新帶來的性能問題
根據目前的結果,局部更新能后解決crash的問題,但是帶來了嚴重的性能開銷,與同事應用局部更新提升性能的結果相反。這個問題還要繼續跟蹤。
目前發現問題是因為使用了getImageData來獲取數據,然后傳遞到gpu中,非ios設備用這種方式有時候getImageData的開銷特別大,而ios設備相對好一些。
測試發現非ios設備直接上傳一張大紋理的效果反而比getImageData這種方式更好。但是依然不如之前上傳多個canvas的性能。而在iphone5的測試機和iphone6的機器上性能比之前直接上傳多個canvas的方式好一些,且沒有崩潰問題。但是在岳陽的iphone6 plus 16g內存的手機上發現用具局部紋理更新性能很差,而且經常崩潰。
后來發現原因是因為,雖然getImageData在IOS上性能好過非IOS設備,但性能開銷仍然比較大,所以當場景中POI很多時,仍然會引起主線程卡頓,甚至計算太密集引起瀏覽器崩潰。其中層嘗試使用cesium方式,每個poi創建新的canvas,將canvas進行局部上傳,本以為這種方式不需要getImageData會更快一些,然而實踐發現每次創建canvas設置參數的過程更耗時。
最終的方案是仍然使用getImageData,但是將getImageData的過程分塊處理,每50ms處理一次,分塊放到場景中,這樣就解決密集計算引起的崩潰問題,雖然增加了控制成本,但是能夠有效解決IOS崩潰問題。有趣的是在安卓上getImageData方式開銷很大,即使分塊也不適合,而且安卓用一張大紋理的方式來處理,會發現很多POI繪制效果不好。
最終方案是,IOS使用getImageData局部紋理+分塊加載方式繪制POI。安卓使用POI獨立創建canvas+全量加載方式。(安卓不適用分塊加載,是為了盡快把所有POI呈現給用戶)
七、文字黑色描邊問題
這個問題自始至終困擾我好久一直沒找到黑邊的原因;
將原始的canvas導出后發現這是因為原始的canvas就有一層邊界
曾經懷疑是minFilter的設置不對在pc端紋理使用NEARESTFilter方式取值發現的確能夠消除黑邊,然而移動端仍然會出現黑邊,最后使用顏色混合公式解決問題。
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
在Three.js中需要設置SpriteMaterial的blending為CustomBlending
八、顏色混合新問題
但是使用上述方式同樣引來新問題,設計反映poi的icon四周被裁切掉,
看着沒問題是吧,設計同學截了圖之后放大了20倍。。。。。
剛開始我確實以為這是webgl渲染問題,后來仔細考慮了下這外圈白色的由來(遇到問題還是得靜下心分析)。
原因是設置了blendFunc(SrcAlphaFactor,OneMinusSrcAlphaFactor)導致有些icon周圍的像素alpha比較低
顏色混合后增加了target的顏色分量,導致最終這些區域的顏色范圍接近255,所以泛白。從而把原來圖片四周有切邊的問題充分暴露出
解決方法是設置alphaTest,如果原始紋理的alpha小於這個值則直接discard。最終得到的效果是:
九、TextureAlta問題
前面因為sprite的旋轉中心只能放在sprite紋理區域的中心所以,上面做了很多冗余紋理,有很多空白區域,目前改造了Sprite加了pivot可以動態改變選中中心點,改變后IOS下紋理的使用率提升了60%,安卓下因為是單個紋理上傳所以,需要保證紋理的大小是2的n次方,紋理的浪費率降低了50%
上述問題雖然解決了崩潰問題,但是實際使用中每個poi都要getImageData和texSubImage2D這個方法,造成單個poi耗時基本在25ms(iphone5 8.4.4);雖然上面使用setTimeout 50ms分塊方式上傳,但是如果poi過多比如1000多的停車場,這樣會導致停車場數據需要50s才能完全顯示出來。這次優化的方案是等待所有poi圖片拿到后,繪制所有的poi把畫布調用一次getImageData和一次texSubImage2D上傳到gpu,同時下次更新時,只會增量一次性上傳更新。
十、Frstrum增量更新
原來是在每一級別縮放時把所有的poi都生成好,現在的做法是只生成視錐體中能看得到的poi,然后在每次OrbitControl出發change事件時根據視錐體判斷poi,做去重后增量更新
目前還是有些問題,有時候會碰到視錐體中的poi很少,可能是判斷問題,后續會加入空間索引,根據索引和視錐體結合起來做增量更新
后續使用發現在停車場這種大數據的poi全部加載到地圖下,使用這種方式每次都要做去重處理,性能開銷很大,處理方式是使用{}做hash代替數組includes方法,結果發現性能提示很大,原來3600個節點每次去重處理在iphone 16g 10.3.3上性能基本在28幀每秒,經過優化后數據幀率達到50+(主流iPhone7fps60);iphone5 16g 8.4.1 性能在24左右優化后幀率在44+,安卓華為榮耀9優化前25幀,優化后 40+
安卓之所以不適用IOS的繪制方式,是因為這種在安卓上的繪制效果不理想,被設計挑戰
安卓后面也做了一些優化,之前安卓是每次都會重新創建canvas並上傳至gpu紋理中,導致使用視景體增量更新poi時,性能有所下降,后來每一層中的poi都根據icon、文字組成key緩存起來,並且緩存紋理,不但阻止canvas的重復創建,還阻止canvas重復上傳至gpu紋理(three中使用同一uuid),使用該方案榮耀9的fps達到50+
十一、text glyphs
該方式還有待嘗試
https://webglfundamentals.org/webgl/lessons/webgl-text-glyphs.html
十二、真正解決POI文字黑邊問題
由於要做poi漸變出現效果,但是因為之前處理黑邊問題用的是顏色混合的方式,所以當動態改變透明度時,受顏色混合影響往往是文字顏色先消失,剩下透明度部分還存在顯示先過很差。所以要實現漸變效果,不能使用顏色混合方法,但不適用顏色混合就會有黑邊問題,所以要從源頭上解決黑邊問題。(看到最后會發現有殘影)
那么思考黑邊到底是怎么產生的,這與webgl中紋理插值的顏色有關,有的設備像素取紋理時有不同的方案,但一般情況下紋理像素和設備像素都不是一一對應,所以有插值取值問題。
這是正常情況下利用canvas繪圖時背景顏色不設置,那么可以看到我們繪制出來的canvas的確有一層奇怪的黑邊。當設備取到紋理中這些邊界時就會產生黑邊。那么就要思考怎么不讓它取到這層黑邊,這個問題想了好久曾經試過用opacity過濾,發現不能解決問題。
有一天突然想到如果canvas背景為有顏色,每個設備像素都能取到顏色,那么就不會有這個問題。所以我們能否通過改一下canvas的背景顏色同時有通過透明度過濾掉不合格的像素?最終發現這個問題還真可以。
首先在繪制時將canvas背景設置為白色,但是有很低的透明度
這時候canvas繪制出來的效果是
可以看到已經沒有黑邊了,那么這時候設備像素永遠不會取到黑色邊界,也就徹底解決了黑邊問題。
那么就可以利用tween來做動畫了