three.js裡的許多物件都有一個needsUpdate屬性,文檔中很少有寫(不過three.js的文檔本來就沒多少,很多問題還得靠github上的issues),網上各式各樣的教程中也不太會寫這個,因為對於簡單的入門程式而言,是用不到這個屬性的。
那麼這個屬性到底是用來幹嘛的,一言以敝之就是告訴renderer這一幀我該更新緩存了,儘管作為一個標誌位用途很簡單,但是因為要知道為什麼要更新緩存,要更新哪些緩存,所以還是有必要好好了解下的。
為什麼需要needsUpdate
首先還是來看下為什麼需要緩存,緩存的存在一般都是為了減少數據傳輸的次數,從而減少程序在數據傳輸上消耗的時間,這裡也是,一般一個物體(Mesh)要最後能夠成功顯示到螢幕前是很不容易的,需要轉三次戰場
首先是透過程式將所有的頂點資料和紋理資料從本地磁碟讀取到記憶體當中。
然後程式在記憶體中做了適當的處理之後就要將那些需要繪製到螢幕前的物體的頂點資料和紋理資料傳輸到顯存當中。
最後在每一幀渲染的時候將顯存中的頂點資料和紋理資料flush到GPU中進行組裝,繪製。
根據那個金字塔式的資料傳輸模型,第一步顯然是最慢的,如果是在WebGL這樣的環境中透過網路來傳輸,那就更加慢了,其次是從記憶體傳輸到顯存的時間,這個後面會做一個簡單的數據測試。
然後是這三步驟操作的使用頻率,對於小場景來說,第一步是一次性的,就是每次初始化程序的時候就會將一個場景的所有資料都加載到內存中了,對於大場景來說,可能會做一些非同步加載,但是目前暫時不在我們考慮的問題當中。 對於第二步的頻率,應該是這次要講的最主要的,先寫個簡單的程序測試一下做這一步傳輸所帶來的消耗
var canvas = document.createElement('canvas');
var _gl = canvas.getContext('experimental -webgl');
var vertices = [];
for(var i = 0; i vertices.push(i * Math.random() );
}
var buffer = _gl.createBuffer();
console.profile('buffer_test');
bindBuffer();
console.profileEnd('buffer_test'); bindBuffer(){
for(var i = 0; i _gl.bindBuffer(_gl.ARRAY_BUFFER, buffer);
_gl.bufferData(_gl.ARRAY_BUFFER, new Float32 ), _gl.STATIC_DRAW);
}
}
先簡單解釋下這個程序,vertices是一個保存頂點的數組,這裡是隨機生成了1000個頂點,因為每個頂點都有x,y,z三個坐標,所以需要一個3000大小的數組, _gl.createBuffer指令在顯存中開闢了一塊用來存放頂點資料的緩存,然後使用_gl.bufferData將產生的頂點資料從記憶體傳輸一份copy到顯存中。 這裡假設了一個場景中有1000個1000個頂點的物體,每個頂點是3個32位元4個位元組的float數據,計算一下就是差不多1000 x 1000 x 12 = 11M的數據,profile一下差不多消耗了15ms的時間,這裡可能看看15ms才這麼點時間,但是對於一個實時的程序來說,如果要保證30fps的幀率,每一幀所需要的時間要控制在30ms左右,僅僅是做一次數據的傳輸就花去了一半的時間怎麼成,要知道大頭應該是GPU中的繪製操作和在CPU中的各種各樣的處理啊,應該吝嗇整個渲染過程中的每一步操作。
所以應該盡量減少這一步的傳輸次數,其實可以做到剛加載的時候就把所有的頂點數據和紋理數據從內存一併傳輸到顯存當中,這就是現在three.js做的,第一次就把需要繪製的物體(Geometry)的頂點資料傳送到顯存中,並且快取這個buffer到geometry.__webglVertexBuffer,之後每次繪製的時候都會判斷Geometry的verticesNeedUpdate屬性,如果不需要更新就直接使用現在的快取,如果看到verticesNeedUpate為true, 就會重新將Geometry中的頂點資料傳輸到geometry.__webglVertexBuffer中,一般對於靜態物體我們是不需要這一步操作的,但是如果遇到頂點會頻繁改變的物體,例如用頂點來做粒子的粒子系統,還有使用了骨骼動畫的Mesh, 這些物體每一幀都會改變自己的頂點,所以需要每一幀都需要將其verticesNeedUpdate屬性設為true來告訴renderer我需要重新傳輸數據了!
其實在WebGL程式中,更多的會在vertex shader中去改變頂點的位置來完成粒子效果和骨骼動畫,儘管如果放在cpu端計算更容易擴展,但是因為javascript的計算能力的限制,更多的還是會把這些計算量大的操作放到gpu端操作。 這種情況下並不需要重新傳輸一次頂點數據,所以上面那種case在實際程式中其實用到的不多,更多的還是會去更新紋理和材質的快取。
上面那個case主要描述的是一個傳輸頂點數據的場景,除了頂點數據,還有一個大頭就是紋理,一張1024*1024大小的R8G8B8A8格式的紋理所要佔用的內存大小也要高達4M,於是看下面這個例子
var canvas = document. createElement('canvas');
var _gl = canvas.getContext('experimental-webgl');
var texture = _gl.createTexture();
var img = new Image;
img. onload = function(){
console.profile('texture test');
bindTexture();
console.profileEnd('texture test');
}
img.src = 'test_tex.jpg';
function bindTexture(){
_gl.bindTexture(_gl.TEXTURE_2D, texture);
_gl.texImage2D(_gl.TEXTURE_2D, 0, _glRG, 0, _glRG, _glglY .UNSIGNED_BYTE, img);
}
這裡就不需要變態的重複1000次了,一次傳輸10241024的紋理就已經花了30ms,一張256256的差不多是2ms,所以three.js中對於紋理也是盡量只在最開始的時候傳輸一次,之後如果texture.needsUpdate屬性不手動設為true的話就會一直直接使用已經傳輸到顯存中的紋理。
需要更新哪些快取 上面透過兩個case描述了為什麼three.js要加這麼一個needsUpdate屬性,接下來列舉一下幾個場景來知道在什麼情況下需要手動的更新這些快取。
紋理的非同步載入 這算是一個小坑吧,因為前端的圖片是異步加載的,如果在創建好img後直接寫texture.needsUpdate=true的話,three.js的renderer中會這一幀中就使用_gl.texImage2D將空的紋理資料傳送到顯存中,然後就將這個標誌位設為false, 之後真正等到圖片載入完成的時候確不再更新顯存資料了,所以必須在onload事件中等整張圖片加載完成後再寫texture.needsUpdate = true
視頻紋理 大部分紋理都是像上面那個case直接加載和傳輸一次圖片就行了,但對於視訊紋理來說並不是,因為影片是一個圖片流,每一幀要顯示的畫面都不一樣,所以每一幀都需要將needsUpdate設為true來更新顯示卡中的紋理資料。
使用render buffer render buffer是比較特殊的對象,一般的程式在整個場景繪製出來後都是直接flush到螢幕了,但是如果多了post processing或這screen based xxx(例如screen based ambient occlusion)的話,就需要將場景先繪製到一個render buffer上,這個buffer其實就是一張紋理,只不過是上一步繪製生成的,而不是從磁碟加載的。 three.js中有一個專門的texture物件WebGLRenderTarget來初始化和保存renderbuffer, 這種紋理也需要在每一幀設定一下needsUpdate為true
Material的needsUpdate 材質在three. js中是透過THREE.Material來描述的,其實材質並沒有什麼資料要傳輸,但是為什麼還要搞一個needsUpdate呢,這裡還要說一下shader這個東西,shader直譯過來是著色器,提供了在gpu中程式處理頂點和像素的可能性,在繪畫中有個shading的術語來表示繪畫的明暗法,GPU中的shading也類似,透過程式計算光照的明暗來表現物體的材質,ok, 既然shader是一段跑在GPU上的程序,那麼像所有程序一樣都需要進行一次編譯鏈接的操作, WebGL中是在運行時對shader程序進行編譯的,這當然需要消耗時間,因此也是最好能夠一次編譯就運行到程序結束。所以three.js中就在material初始化的時候就編譯連結了shader程式並且快取了編譯連結後得到的program物件。一般一個material是不需要再去重新編譯整個shader了,材質的調整隻需要修改shader的uniform參數就行了。但如果是替換了整個材質,例如將原來phong的shader替換成了一個lambert的shader,就需要將material.needsUpdate設定成true去重新做一次編譯。不過這種情況不多見,比較常見的是下面提到的一種情況。
添加和刪除燈光 這個應該還是在場景中比較常見了的吧,可能很多剛開始用three.js的人都會掉進這個坑里,在給場景動態添加了一個燈光後發現這個燈光怎麼不起作用,不過這是在用three.js內置的shader的情況下,例如phong, lambert,看renderer裡的源代碼就會發現three.js在內置的shader代碼中使用#define來設定場景中燈光的個數,而這個#define的值是在每次更新材質的時候透過字串拼接shader得到,程式碼如下
"#define MAX_DIR_LIGHTS " parameters.maxDirLights,
"#define MAX_POINT_L. "#define MAX_SPOT_LIGHTS " parameters.maxSpotLights,
"#define MAX_HEMI_LIGHTS " parameters.maxHemiLights,
確實這種寫法能夠有效的減少了gpu寄存器的使用,如果只有一盞燈光就可以只聲明一個一盞燈光所需要的uniform變量,但是在每次燈光數量改變,特別是添加的時候就需要重新拼接編譯連結一次shader,這時候也需要將所有材質的material.needsUpdate設為true;
改變紋理 這裡的改變紋理指的並不是更新紋理數據,而是原來材質使用了紋理,後來不使用了,或者原來材質不使用紋理後來又加上去了,如果不手動強制更新材質都會導致最後出來的效果跟自己想的不一樣,產生這種問題的原因跟上面加上燈光差不多,也是因為shader加了一個宏來判斷是否使用了紋理,
parameters.map ? "#define USE_MAP" : "",
parameters.envMap ? "#define USE_ENVMAP" : "",
參數USE_LIGHTMAP" : "",
parameters.bumpMap ? "#define USE_BUMPMAP" : "",
parameters.normalMap ? "#define USE_NORMALMAP" : "",
parameters.specular : "",
所以每次map, 或envMap或lightMap等改變真值的時候都需要更新材質
其它頂點資料的改變 其實上面紋理的改變還會產生一個問題,主要是在初始化的時候沒有紋理,但是後來動態添加上去這種環境下,光是將material.needsUpdate設為true還不夠,還需要將geometry.uvsNeedsUpdate設為true , 為什麼會有這種問題呢,還是因為three.js對程式的優化,在renderer中第一次初始化geometry, material的時候,如果判斷為沒有紋理,儘管內存中的數據中有每個頂點uv數據,但three.js 還是不會將這些資料copy到顯存中,初衷應該還是為了節省點寶貴的顯存空間,但是在添加紋理後geometry並不會很智能的重新去傳輸這些uv資料以供紋理使用,必須要我們手動的將設置uvsNeedsUpdate來告知它該更新uv了, 這個問題真是開始的時候坑了我很長時間。
關於幾個頂點資料的needUpdate屬性可以看這條issue
https://github.com/mrdoob/three.js/wiki/Updates
最後 three .js的優化做的是不錯,但是在各種優化下帶來的是各種可能踩到的坑,這種情況最好的辦法也只能是看源代碼了,或者去github上提issues