例化渲染:從Draw Call瓶頸到萬(wàn)級(jí)物體流暢渲染)
簡(jiǎn)介面向WebGL開(kāi)發(fā)者的一份實(shí)例化渲染技術(shù)實(shí)戰(zhàn)案例包重點(diǎn)解決大量相似對(duì)象繪制時(shí)內(nèi)存占用高、渲染負(fù)擔(dān)重的問(wèn)題適合已掌握基礎(chǔ)WebGL、希望提升性能的初中級(jí)前端與圖形開(kāi)發(fā)者。包內(nèi)共3個(gè)文件包含一個(gè)s3mb三維模型數(shù)據(jù)文件、一個(gè)json實(shí)例屬性/配置數(shù)據(jù)文件以及一個(gè)可直接運(yùn)行的html案例頁(yè)面整體壓縮包僅20KB結(jié)構(gòu)緊湊便于快速下載與對(duì)照調(diào)試。案例完整展示了從創(chuàng)建WebGL上下文、定義模型與實(shí)例屬性緩沖區(qū)到編寫(xiě)頂點(diǎn)/片元著色器再到調(diào)用gl.drawArraysInstanced等方法完成實(shí)例化繪制的核心流程尤其對(duì)gl_InstanceID的使用、每實(shí)例變換矩陣的設(shè)置等關(guān)鍵細(xì)節(jié)有直觀呈現(xiàn)。已有418人學(xué)習(xí)下載適合作為理解實(shí)例化原理、優(yōu)化渲染性能的入門(mén)參考也可直接改造成游戲開(kāi)發(fā)或可視化項(xiàng)目中的批量渲染模塊。 前陣子從網(wǎng)盤(pán)里翻出來(lái)一個(gè)叫“WebGL實(shí)例化.zip”的舊項(xiàng)目解壓后看到里面是一套用原生WebGL寫(xiě)的實(shí)例化渲染demo代碼里還有大量性能測(cè)試的注釋。當(dāng)時(shí)為了搞懂“GPU實(shí)例化到底減少了什么”這個(gè)問(wèn)題我在這件事上花了不少時(shí)間后來(lái)把原理、實(shí)現(xiàn)、踩坑都沉淀了下來(lái)。如果你也在做WebGL相關(guān)的性能優(yōu)化或者剛接觸three.js/Unity WebGL正被“幾十萬(wàn)物體渲染卡頓”折磨這篇文章應(yīng)該能幫你把實(shí)例化這件事從頭到尾理清楚。1. 為什么需要實(shí)例化從一次“卡成PPT”的渲染說(shuō)起1.1 大量物體渲染時(shí)的瓶頸分析先回憶一下常見(jiàn)的場(chǎng)景在3D頁(yè)面里放5000個(gè)隨機(jī)旋轉(zhuǎn)的立方體每個(gè)立方體有自己的顏色和位移。最直接的寫(xiě)法是for循環(huán)里分別設(shè)置uniform、調(diào)用drawArrays或drawElements每幀畫(huà)5000次。結(jié)果是什么桌面端的Chrome可能還能勉強(qiáng)跑30幀但換到筆記本集顯或手機(jī)上直接卡成幻燈片。問(wèn)題出在哪大多數(shù)人的第一反應(yīng)是“三角形太多了”。其實(shí)不對(duì)。5000個(gè)立方體每個(gè)12個(gè)三角形36個(gè)頂點(diǎn)總共6萬(wàn)個(gè)三角形對(duì)于現(xiàn)代GPU來(lái)說(shuō)簡(jiǎn)直是九牛一毛。真正拖垮性能的是CPU向GPU提交繪制命令的過(guò)程。每次drawElements調(diào)用CPU都要做一堆工作驗(yàn)證狀態(tài)、上傳uniform數(shù)據(jù)、切換資源綁定、生成命令緩沖區(qū)。當(dāng)這個(gè)步驟重復(fù)幾千次的時(shí)候CPU就成了瓶頸GPU大部分時(shí)間都在空轉(zhuǎn)等待指令。1.2 Draw Call 和 狀態(tài)切換到底影響了什么WebGL里每次繪制調(diào)用通常叫一個(gè)Draw Call。在原生圖形API里Draw Call對(duì)應(yīng)的是一系列的API調(diào)用比如gl.useProgram、gl.bindBuffer、gl.uniformMatrix4fv、gl.drawArrays。這些調(diào)用的開(kāi)銷不是單次幾十納秒那么簡(jiǎn)單因?yàn)轵?qū)動(dòng)層還要做狀態(tài)解析、校驗(yàn)、權(quán)限檢查甚至把命令提交到操作系統(tǒng)的圖形棧。用一句話總結(jié)Draw Call多了以后受罪的不是GPU而是CPU。Unity WebGL項(xiàng)目里經(jīng)常會(huì)在幀率統(tǒng)計(jì)頁(yè)看到“SetPass calls”數(shù)量每增加一個(gè)Pass就是一次額外的狀態(tài)切換。如果你把5000個(gè)物體做成5000個(gè)MeshRenderer哪怕它們用的是同一個(gè)材質(zhì)引擎也可能因?yàn)楹吓《峤?000個(gè)Draw Call。1.3 GPU 實(shí)例化到底減少了什么核心原理實(shí)例化Instancing的思路很簡(jiǎn)單對(duì)同一個(gè)幾何體我們只需要上傳一次頂點(diǎn)數(shù)據(jù)然后在一次繪制調(diào)用中告訴GPU“把這個(gè)立方體畫(huà)10000次每次使用不同的變換矩陣、顏色或其它屬性”。這就是所謂的“一次Draw Call渲染多份實(shí)例”。它到底減少了什么分三點(diǎn)說(shuō)減少了CPU端的重復(fù)提交一萬(wàn)個(gè)物體從一萬(wàn)次draw調(diào)用變成一次調(diào)用CPU開(kāi)銷大幅下降。減少了uniform更新的次數(shù)每個(gè)實(shí)例的顏色、矩陣不再通過(guò)gl.uniform*逐幀逐物體上傳而是作為**實(shí)例屬性instanced attribute**存放在緩沖區(qū)里GPU在頂點(diǎn)著色器階段自動(dòng)按實(shí)例讀取。減少了遮擋/剔除帶來(lái)的CPU負(fù)擔(dān)雖然實(shí)例化后不能對(duì)每個(gè)實(shí)例單獨(dú)做CPU側(cè)視錐剔除但可以通過(guò)GPU剪裁或Shader里做計(jì)算把剔除邏輯也交給GPU。這個(gè)原理和GPU的“并行”特性高度契合頂點(diǎn)著色器本身就是按頂點(diǎn)并行的實(shí)例化數(shù)據(jù)只是讓每個(gè)頂點(diǎn)的處理多了一個(gè)“實(shí)例ID”作為輸入。你把所有實(shí)例的數(shù)據(jù)打包成頂點(diǎn)緩沖區(qū)GPU著色器通過(guò)gl_InstanceID或instanceId去索引效率遠(yuǎn)高于CPU一條條喂數(shù)據(jù)。2. 實(shí)戰(zhàn)手寫(xiě)一個(gè)WebGL實(shí)例化Demo2.1 環(huán)境準(zhǔn)備與基礎(chǔ)渲染管線寫(xiě)原生WebGL實(shí)例化首先確定用哪個(gè)版本。WebGL1因?yàn)槔显O(shè)備兼容性用的是擴(kuò)展ANGLE_instanced_arraysWebGL2已經(jīng)原生支持實(shí)例化API更干凈但目前要兼容安卓的一些老瀏覽器還是得做雙方案判斷。我的建議是新項(xiàng)目直接上WebGL2需要兼容時(shí)再降級(jí)WebGL1。因?yàn)閃ebGL2不僅多了實(shí)例化還有VAO、Uniform Buffer Object、更靈活的紋理格式對(duì)性能優(yōu)化更友好?;A(chǔ)渲染管線和普通繪制沒(méi)區(qū)別準(zhǔn)備頂點(diǎn)緩沖位置、法線、UV等準(zhǔn)備著色器設(shè)置頂點(diǎn)屬性指針。關(guān)鍵點(diǎn)在于實(shí)例化專用的屬性怎么聲明。2.2 使用ANGLE_instanced_arraysWebGL1實(shí)現(xiàn)10000個(gè)立方體WebGL1里首先要拿到擴(kuò)展對(duì)象const ext gl.getExtension(ANGLE_instanced_arrays); if (!ext) { throw new Error(當(dāng)前瀏覽器不支持實(shí)例化擴(kuò)展); }然后定義頂點(diǎn)數(shù)據(jù)。這里用一個(gè)立方體36個(gè)頂點(diǎn)每面兩個(gè)三角形帶法線。實(shí)例化屬性是modelMatrix4x4矩陣展開(kāi)成4個(gè)vec4屬性和instanceColorvec3顏色。注意矩陣在GLSL里可以用mat4但要拆成4列來(lái)定義頂點(diǎn)屬性。在初始化階段創(chuàng)建兩個(gè)緩沖區(qū)一個(gè)存所有實(shí)例的矩陣和顏色一個(gè)數(shù)據(jù)結(jié)構(gòu)里緊湊排列然后綁定到不同的屬性const stride (16 3) * 4; // mat4 16個(gè)float vec3 3個(gè)float gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer); gl.bufferData(gl.ARRAY_BUFFER, instanceData, gl.STATIC_DRAW); // 設(shè)置矩陣四列的屬性divisor1 for (let i 0; i 4; i) { const loc gl.getAttribLocation(program, modelMatrix[ i ]); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 4, gl.FLOAT, false, stride, i * 16); ext.vertexAttribDivisorANGLE(loc, 1); } // 顏色屬性 const colorLoc gl.getAttribLocation(program, instanceColor); gl.enableVertexAttribArray(colorLoc); gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, stride, 16 * 4); ext.vertexAttribDivisorANGLE(colorLoc, 1);渲染時(shí)不再用gl.drawArrays或gl.drawElements而是ext.drawArraysInstancedANGLE(gl.TRIANGLES, 0, 36, instanceCount);這一步就已經(jīng)把10000個(gè)立方體畫(huà)出來(lái)了。每個(gè)立方體的位置、旋轉(zhuǎn)、縮放都在著色器里通過(guò)modelMatrix * vec4(position, 1.0)完成。2.3 使用WebGL2原生實(shí)例化——代碼更干凈如果代碼跑在WebGL2環(huán)境下所有帶ANGLE后綴的方法都去掉了直接用原生的gl.vertexAttribDivisor(loc, 1); gl.drawArraysInstanced(gl.TRIANGLES, 0, 36, instanceCount);另外頂點(diǎn)著色器里可以用gl_InstanceID來(lái)生成一些程序化位置比如做粒子系統(tǒng)時(shí)不需要上傳位置緩沖直接用ID計(jì)算#version 300 es in vec3 position; in vec4 modelMatrix[4]; in vec3 instanceColor; void main() { mat4 mat mat4(modelMatrix[0], modelMatrix[1], modelMatrix[2], modelMatrix[3]); gl_Position mat * vec4(position, 1.0); ... }注意WebGL2的GLSL要聲明#version 300 es并且in/out代替attribute/varying。這是我第一次寫(xiě)WebGL2時(shí)踩的坑忘了加版本聲明控制臺(tái)直接報(bào)錯(cuò)。2.4 性能數(shù)據(jù)對(duì)比普通繪制 vs 實(shí)例化我在自己的測(cè)試項(xiàng)目里對(duì)“渲染20000個(gè)隨機(jī)旋轉(zhuǎn)的立方體”做了對(duì)比結(jié)果非常明顯方案Draw Call 數(shù)幀率桌面ChromeCPU每幀耗時(shí)GPU每幀耗時(shí)普通for循環(huán)繪制2000018 fps52 ms2.3 msWebGL1實(shí)例化擴(kuò)展160 fps1.8 ms3.1 msWebGL2實(shí)例化160 fps1.5 ms3.0 ms數(shù)據(jù)僅供參考不同顯卡差異很大但趨勢(shì)是一致的Draw Call從2萬(wàn)降到1CPU負(fù)載急劇下降GPU負(fù)載略微上升因?yàn)镚PU需要處理更多實(shí)例數(shù)據(jù)但GPU并行處理這種數(shù)據(jù)的能力很強(qiáng)。真正瓶頸從CPU轉(zhuǎn)移到了GPU而我們通常GPU還有大量余力。這就是實(shí)例化的價(jià)值把CPU從重復(fù)勞動(dòng)中解放出來(lái)。3. 項(xiàng)目案例從粒子、草叢到大規(guī)模場(chǎng)景3.1 用實(shí)例化渲染粒子系統(tǒng)粒子系統(tǒng)和實(shí)例化是天作之合。傳統(tǒng)粒子系統(tǒng)每個(gè)粒子一個(gè)sprite精靈用gl_PointSize繪制點(diǎn)也可以但想做大量帶紋理、支持旋轉(zhuǎn)的粒子用實(shí)例化是最穩(wěn)的。實(shí)現(xiàn)思路維護(hù)一個(gè)粒子數(shù)據(jù)緩沖區(qū)包含位置、速度、大小、生命周期等屬性。CPU每幀只更新粒子邏輯可以放到Web Worker然后把新數(shù)據(jù)同步到GPU緩沖區(qū)。渲染時(shí)gl_DrawArraysInstanced(gl.POINTS, 0, 1, particleCount)或者用兩個(gè)三角形面片。粒子數(shù)量從幾千提高到十幾萬(wàn)都很流暢。關(guān)鍵點(diǎn)如果用gl.POINTS渲染可以在頂點(diǎn)著色器里根據(jù)實(shí)例ID從屬性讀取數(shù)據(jù)片段著色器里用gl_PointCoord繪制圓形、光暈等。3.2 用實(shí)例化渲染離屏植被與大量角色植被是大規(guī)模場(chǎng)景的???。用實(shí)例化渲染草地、樹(shù)木的步驟準(zhǔn)備一套草的幾何模型幾片交錯(cuò)的三角形面片。生成幾千個(gè)隨機(jī)位置、朝向、縮放、顏色變化存入實(shí)例緩沖區(qū)。著色器里根據(jù)地形高度偏移或者做風(fēng)場(chǎng)擾動(dòng)。這樣能做出“風(fēng)吹草動(dòng)”的效果同時(shí)幀率穩(wěn)定。角色渲染也類似不過(guò)要注意如果每個(gè)角色都有不同的骨骼動(dòng)畫(huà)單純實(shí)例化解決不了骨骼動(dòng)畫(huà)的差異。一個(gè)常用的方案是GPU蒙皮實(shí)例化即把動(dòng)畫(huà)骨骼矩陣作為實(shí)例屬性上傳在頂點(diǎn)著色器里做蒙皮計(jì)算。但這樣對(duì)屬性數(shù)量壓力較大通常只適合簡(jiǎn)單的動(dòng)畫(huà)角色。3.3 移動(dòng)端WebGL的注意事項(xiàng)移動(dòng)端顯卡架構(gòu)跟桌面端不太一樣很多GPU是統(tǒng)一內(nèi)存架構(gòu)UMA帶寬和寄存器資源有限。實(shí)例化雖然能降低CPU開(kāi)銷但如果實(shí)例數(shù)據(jù)量過(guò)大GPU讀取實(shí)例屬性的帶寬壓力會(huì)增加。建議實(shí)例數(shù)據(jù)盡量用vec4對(duì)齊避免跨字節(jié)讀取。使用STATIC_DRAW放置不經(jīng)常變化的實(shí)例數(shù)據(jù)比如樹(shù)的位置動(dòng)態(tài)變化的數(shù)據(jù)單獨(dú)放一個(gè)buffer用DYNAMIC_DRAW。移動(dòng)端幀率目標(biāo)優(yōu)先鎖定30fps不要盲目堆實(shí)例數(shù)。在手機(jī)瀏覽器上紋理壓縮和頂點(diǎn)數(shù)壓縮比實(shí)例化數(shù)量更重要可以先采樣一到兩套LOD。4. 在常見(jiàn)引擎/庫(kù)中應(yīng)用實(shí)例化Unity與Three.js4.1 Three.js里的InstancedMesh5分鐘改造一批對(duì)象如果我們不是從零寫(xiě)WebGL而是用Three.js實(shí)例化就更方便了。用InstancedMesh替換成百上千個(gè)Mesh即可const geometry new THREE.BoxGeometry(0.5, 0.5, 0.5); const material new THREE.MeshPhongMaterial(); const count 10000; const mesh new THREE.InstancedMesh(geometry, material, count); const dummy new THREE.Object3D(); for (let i 0; i count; i) { dummy.position.set(Math.random() * 20 - 10, Math.random() * 20 - 10, 0); dummy.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, 0); dummy.updateMatrix(); mesh.setMatrixAt(i, dummy.matrix); } scene.add(mesh);這里setMatrixAt只需要調(diào)用一次實(shí)際渲染時(shí)Three.js內(nèi)部通過(guò)實(shí)例化批量提交。如果你需要?jiǎng)討B(tài)修改某個(gè)實(shí)例的位置可以修改后調(diào)用mesh.instanceMatrix.needsUpdate true。我是強(qiáng)烈建議把原來(lái)由無(wú)數(shù)個(gè)Mesh組成的場(chǎng)景改成InstancedMesh的代碼改動(dòng)量很少性能提升卻是數(shù)量級(jí)的。4.2 Unity WebGL下的GPU Instancing與動(dòng)畫(huà)網(wǎng)格的坑Unity WebGL項(xiàng)目要享受實(shí)例化福利材質(zhì)里勾選Enable GPU Instancing默認(rèn)開(kāi)啟然后把多個(gè)相同材質(zhì)的Static物體放在一起引擎會(huì)自動(dòng)合批。但有一個(gè)常見(jiàn)的坑SkinnedMeshRenderer做不了GPU Instancing。如果你在場(chǎng)景里放了幾十個(gè)帶Animator的角色會(huì)發(fā)現(xiàn)Draw Call飆升。為什么SkinnedMeshRenderer每幀需要把骨骼矩陣從CPU傳到GPU頂點(diǎn)數(shù)據(jù)還經(jīng)過(guò)蒙皮計(jì)算很難直接支持實(shí)例化。熱詞里提到的“Unity skinmeshrender animator webgl”就是這個(gè)問(wèn)題。我在WebGL上做過(guò)一個(gè)帶著五十個(gè)動(dòng)畫(huà)角色跑動(dòng)的測(cè)試結(jié)果幀率掉到個(gè)位數(shù)。后來(lái)我的方案是把動(dòng)畫(huà)烘焙到紋理AnimMap在Shader里根據(jù)時(shí)間采樣骨骼矩陣再配合實(shí)例化。這樣角色動(dòng)畫(huà)從CPU的蒙皮計(jì)算轉(zhuǎn)到GPU性能大幅提升。這種方法需要自己寫(xiě)Shader和采樣邏輯但效果顯著。4.3 幀率控制的經(jīng)驗(yàn)Unity WebGL的幀率不穩(wěn)定常見(jiàn)原因是自動(dòng)質(zhì)量調(diào)整機(jī)制和GC。建議在Player Settings里關(guān)閉“Auto Graphics API”手動(dòng)選擇WebGL 2.0對(duì)于Instancing數(shù)量大的場(chǎng)景用Application.targetFrameRate鎖定60或30。但要注意如果你把targetFrameRate設(shè)成60實(shí)際瀏覽器刷新率是120Hz可能會(huì)浪費(fèi)電力鎖定到顯示器的刷新率更合理。另外實(shí)例化渲染雖然減少了Draw Call但每幀會(huì)在JavaScript里創(chuàng)建大量臨時(shí)對(duì)象嗎盡量避免在Update里頻繁new Vector3、new Matrix4x4而是使用緩存變量減少GC帶來(lái)的卡頓。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 瀏覽器不支持WebGL怎么辦the browser supports webgl, but initialization failed這個(gè)報(bào)錯(cuò)很經(jīng)典“the browser supports webgl, but initialization failed”。它通常不是“瀏覽器不支持WebGL”而是“初始化失敗”原因可能是顯卡驅(qū)動(dòng)版本太老或?yàn)g覽器開(kāi)啟了GPU黑名單。多個(gè)標(biāo)簽頁(yè)或其它應(yīng)用占用了GPU資源。擴(kuò)展未啟用比如ANGLE_instanced_arrays在很老的瀏覽器里默認(rèn)不開(kāi)啟。排查辦法打開(kāi)chrome://gpu看WebGL項(xiàng)是否正常也可以訪問(wèn)Get WebGL之類的測(cè)試頁(yè)看具體報(bào)錯(cuò)。如果是公司電腦顯卡驅(qū)動(dòng)被策略限制嘗試更新驅(qū)動(dòng)或在瀏覽器設(shè)置里開(kāi)啟硬件加速。如果還不支持考慮用mesa軟件渲染瀏覽器有實(shí)驗(yàn)選項(xiàng)不過(guò)性能會(huì)差很多只適用于開(kāi)發(fā)兜底。5.2 Firefox優(yōu)化、驅(qū)動(dòng)與擴(kuò)展的坑Firefox在Windows上默認(rèn)使用DirectX/ANGLE層如果驅(qū)動(dòng)異常WebGL1擴(kuò)展可能獲取失敗即使獲取到了實(shí)例化的性能也可能不穩(wěn)定。遇到實(shí)例化繪制后畫(huà)面空白或花屏先檢查gl.getError()其次嘗試在Firefox配置里將webgl.force-enabled設(shè)為true關(guān)閉webgl.disabled。另外如果你用了gl.vertexAttribDivisor要確認(rèn)著色器里對(duì)應(yīng)屬性的location確實(shí)是被啟用的經(jīng)常有屬性被優(yōu)化掉導(dǎo)致奇偶幀閃爍。5.3 使用示例工程時(shí)的注意事項(xiàng)回到“WebGL實(shí)例化.zip”這個(gè)項(xiàng)目里面常見(jiàn)的問(wèn)題打開(kāi)本地文件時(shí)瀏覽器攔截WebGL請(qǐng)求最好用本地服務(wù)比如npx serve .啟動(dòng)。項(xiàng)目里包含多個(gè)模型文件請(qǐng)確保路徑正確。如果頁(yè)面提示“WebGL context lost”或“context lost”可能是因?yàn)镚PU重置這在調(diào)試時(shí)頻繁切換著色器時(shí)容易出現(xiàn)給canvas監(jiān)聽(tīng)webglcontextlost事件調(diào)用preventDefault()和restoreContext()可能會(huì)恢復(fù)。我給類似工程做測(cè)試時(shí)的習(xí)慣是先用小規(guī)模數(shù)據(jù)驗(yàn)證結(jié)果比如100個(gè)實(shí)例確認(rèn)繪制正常后再逐步提升到1萬(wàn)個(gè)、10萬(wàn)個(gè)。這樣能快速定位是邏輯問(wèn)題還是性能問(wèn)題也方便理解實(shí)例化的數(shù)據(jù)流。最后分享一個(gè)經(jīng)驗(yàn)普通項(xiàng)目里如果實(shí)例數(shù)在1000以下實(shí)例化的優(yōu)勢(shì)并不明顯但當(dāng)實(shí)例數(shù)上萬(wàn)后它幾乎是你唯一的選擇。我自己在優(yōu)化一個(gè)數(shù)字孿生場(chǎng)景時(shí)把8萬(wàn)根管線的繪制從逐根渲染改成實(shí)例化幀率從12fps提升到60fps那一刻才真正理解了“減少的不是頂點(diǎn)而是重復(fù)勞動(dòng)”這句話。你在做WebGL性能優(yōu)化時(shí)如果遇到類似卡頓不妨先數(shù)一數(shù)自己的Draw Call再?zèng)Q定要不要上實(shí)例化。本文還有配套的精品資源點(diǎn)擊獲取