與踩坑指南)
前陣子在LayaAir項目里要做一個角色選中描邊需求本身不復(fù)雜點一下場景里的怪物身體邊緣亮一圈金色輪廓。我第一反應(yīng)是改Shader結(jié)果打開角色預(yù)制體一看身上掛著五六種材質(zhì)有貼圖混合的、有帶溶解特效的還有美術(shù)從舊項目里直接拖進(jìn)來的統(tǒng)一改一遍Shader的成本比做描邊效果本身還高。于是我把主意的重點轉(zhuǎn)向了CommandBuffer——不碰任何原始材質(zhì)在引擎渲染流程的某個階段插入一次額外的繪制把描邊效果直接疊上去。這篇文章就從選型、實現(xiàn)到踩坑完整過一遍給同樣在LayaAir里做描邊需求的朋友一個參考。1. 三個傳統(tǒng)描邊方案的硬傷以及CommandBuffer怎么繞過去的1.1 雙Pass描邊的綁定成本雙Pass描邊是大家最容易想到的方案。一個材質(zhì)兩個PassPass0正常紋理渲染Pass1剔除正面只把背面沿法線向外推一點然后輸出純描邊色。改起來直白效果也穩(wěn)定。問題在于它要求物體上所有材質(zhì)都換成這個帶描邊Pass的材質(zhì)。如果是一個角色由頭、身體、武器多套材質(zhì)組成你就得把每個材質(zhì)都加一遍描邊Pass。更麻煩的是項目里已有PBR、溶解、流光這些定制Shader每個都要加Pass維護(hù)量直線上升。而且每次調(diào)整描邊寬度、顏色需要同時改很多個材質(zhì)資源后期美術(shù)提個反饞需求都得跟著改一圈。雙Pass還有一個隱藏問題描邊Pass和外擴Pass在同一個材質(zhì)內(nèi)部意味著這種物體無法靜態(tài)合批。你可以想象一個場景里擺了幾百個需要描邊的石柱每個石柱都是一個“異種材質(zhì)體”合批優(yōu)化直接失效。這個代價在移動端是很明顯的。1.2 模板描邊的復(fù)雜性隱患模板Stencil方案是另一條路先用模板測試把物體的輪廓像素寫入模板緩沖再在全屏后處理中根據(jù)模板值找邊緣最后疊加描邊色。優(yōu)點很明顯描邊精度高可以和場景顏色分開處理。缺點是模板相關(guān)操作需要所有參與物體材質(zhì)都支持Stencil寫入這又回到了改Shader的老路。一旦某個材質(zhì)不按預(yù)期寫入模板緩沖排查起來非常難受。更關(guān)鍵的是WebGL下模板緩沖通常只有8bit如果項目里UI合批遮罩、區(qū)域高亮等其他系統(tǒng)也在用模板標(biāo)記很容易互相踩踏出現(xiàn)“這里描邊突然沒了那里高亮范圍變大了”這種詭異問題。1.3 CommandBuffer描邊的本質(zhì)插一段繪制而不是改一堆材質(zhì)CommandBuffer的方案思路完全不同原物體和原材質(zhì)在相機渲染流程中原樣跑一遍我們在相機渲染到某個階段時用CommandBuffer再把這個模型的網(wǎng)格調(diào)出來用另一個“純粹為了描邊”的材質(zhì)單獨繪制一次。原始材質(zhì)完全不用動。換句話說“正常繪制”歸原材質(zhì)管“描邊繪制”歸CommandBuffer和描邊材質(zhì)管兩邊解耦。這也是它能在不動資產(chǎn)的前提下給任意物體加描邊的根本原因。而且CommandBuffer在LayaAir里并不是只能做描邊HUD、準(zhǔn)星、區(qū)域高亮、技能特效之類的需求本質(zhì)都是“在渲染流程的某個點插一段自定義繪制”這套機制全部能覆蓋學(xué)會一次能反復(fù)用。2. LayaAir里CommandBuffer的掛載方式與執(zhí)行階段2.1 PipelineStage里常用的是哪幾個節(jié)點CommandBuffer要掛在相機上并且指定執(zhí)行階段。在LayaAir里這個階段枚舉是PipelineStage常用的大概有這些階段值含義適合做的事BeforeForwardOpacity不透明物體渲染完后、半透明之前描邊、選中高亮、添加不透明特效AfterForwardOpacity半透明物體渲染之后疊加光暈、屏幕空間特效BeforeImageEffect后效之前需要接后處理的中間結(jié)果ScreenImageEffect后效階段全屏后處理、邊緣檢測描邊我建議掛在BeforeForwardOpacity。原因很簡單到了這個階段場景里所有不透明物體都已經(jīng)畫完深度緩沖里已經(jīng)有完整的不透明物體深度半透明物體還沒畫此時插入描邊繪制描邊不會被半透明物體蓋住。描邊自身作為一個不透明片元參與深度測試邊緣外擴的部分正常顯示物體中心的背面則被物體自身深度遮擋不會出現(xiàn)“整個模型變成描邊色”的災(zāi)難。2.2 CommandBuffer的完整生命周期掛載流程本身不復(fù)雜看一下代碼就明白// 創(chuàng)建CommandBuffer const cb new Laya.CommandBuffer(); // 掛到相機上并指定執(zhí)行時機 camera.addCommandBuffer(cb, Laya.PipelineStage.BeforeForwardOpacity); // 需要移除時 camera.removeCommandBuffer(cb); // 按階段清空 camera.removeCommandBufferByStage(Laya.PipelineStage.BeforeForwardOpacity);CommandBuffer內(nèi)部是一串命令序列按照添加順序執(zhí)行??梢岳斫獬梢粋€“渲染宏”在這個階段把剔除狀態(tài)設(shè)成什么、深度測試設(shè)成什么、畫什么網(wǎng)格、用什么材質(zhì)全都在這個序列里一步步寫清楚。它的執(zhí)行由引擎在對應(yīng)階段自動觸發(fā)不需要每幀手動調(diào)用也不需要擔(dān)心Update順序。有一點必須養(yǎng)成本能物體銷毀或者效果切換時記得把CB從相機上摘掉。CB本身會持有它引用的網(wǎng)格和材質(zhì)即使你在業(yè)務(wù)層把物體刪了只要CB沒移除它依然會在渲染階段嘗試?yán)L制表現(xiàn)就是“場景里看不到這個物體了但雷達(dá)上一直有個東西在被渲染”性能計數(shù)器能直接看到DrawCall異常高。2.3 繞過CB的外圍依賴網(wǎng)格、矩陣、材質(zhì)CommandBuffer本身只是一個調(diào)度容器真正繪制還需要三樣?xùn)|西網(wǎng)格、矩陣、材質(zhì)。網(wǎng)格來自模型的MeshFilter或MeshRenderer的sharedMesh矩陣來自物體的transform.worldMatrix材質(zhì)則是專門為描邊創(chuàng)建的材質(zhì)建議在代碼里運行時new出來不依賴資產(chǎn)——描邊顏色、寬度都可以作為Shader屬性直接改做美術(shù)配置表也方便。這里有一點要提前講清楚網(wǎng)格用的是sharedMesh不是SkinnedMeshRenderer上實時計算的動畫網(wǎng)格。換句話說CommandBuffer直接drawMesh的方式拿不到骨骼動畫的當(dāng)前姿態(tài)數(shù)據(jù)。如果你的描邊目標(biāo)是會跑會跳的帶骨骼動畫角色需要單獨處理這個我后面踩坑章節(jié)會細(xì)說。靜態(tài)物體、道具、建筑、部分剛體物件用這個方案是沒問題的。3. 核心實現(xiàn)背面放大描邊的完整代碼與參數(shù)說明3.1 描邊Shader的編寫要點背面放大描邊的思路是把模型的正面剔除只畫背面背面的頂點沿法線方向往外推一小段距離然后輸出純色。由于被推出去的背片位于原模型邊緣外側(cè)正面渲染時又會被原模型擋住最后屏幕上顯示出來的就是緊貼模型輪廓的一圈邊。外層描邊Shader的頂點部分長這樣#version 300 es precision highp float; uniform mat4 u_WorldMat; uniform mat4 u_ViewProjectionMat; uniform float u_OutlineWidth; layout(location 0) in vec3 position; layout(location 1) in vec3 normal; void main() { vec4 worldPos u_WorldMat * vec4(position, 1.0); vec4 worldNormal u_WorldMat * vec4(normal, 0.0); worldPos.xyz worldNormal.xyz * u_OutlineWidth; gl_Position u_ViewProjectionMat * worldPos; }片元部分更簡單#version 300 es precision highp float; uniform vec4 u_OutlineColor; layout(location 0) out vec4 fragColor; void main() { fragColor u_OutlineColor; }有個細(xì)節(jié)值得展開。為什么用世界空間外擴而不是模型空間如果用模型空間的position normal再走M(jìn)VP那么遇到帶縮放、旋轉(zhuǎn)的物體外擴方向和距離會跟著模型變換一起變非均勻縮放時會直接變形。把頂點先變換到世界空間再把法線也變換到世界空間然后在世界空間外擴寬度就是一個穩(wěn)定的絕對距離值不會受到模型自身縮放的影響。代價是描邊寬度不隨相機距離變化遠(yuǎn)處看起來描邊會顯得更細(xì)實際項目里需要根據(jù)需求取舍。3.2 CommandBuffer里的繪制指令在LayaAir里繪制描邊的那段核心代碼大致是這樣// 創(chuàng)建CommandBuffer const cb new Laya.CommandBuffer(); camera.addCommandBuffer(cb, Laya.PipelineStage.BeforeForwardOpacity); // 配置剔除狀態(tài)只畫背面正面被原物體正常渲染覆蓋 cb.renderState.cull Laya.RenderState.CULL_FRONT; cb.renderState.depthWrite false; cb.renderState.depthTest Laya.RenderState.DEPTHTEST_LESS; // 設(shè)置描邊材質(zhì)參數(shù) outlineMat.setVector4(u_OutlineColor, new Laya.Vector4(1.0, 0.7, 0.2, 1.0)); outlineMat.setNumber(u_OutlineWidth, 0.03); // 把網(wǎng)格用描邊材質(zhì)再畫一次 cb.drawMesh(targetMesh, targetTransform.worldMatrix, outlineMat);注意這里cull設(shè)置成CULL_FRONT意思是想剔除正面。這樣每個三角面只剩下背面的那一面被渲染背面經(jīng)法線外擴后露在物體邊緣外面。深度測試設(shè)置成Less深度寫入關(guān)閉。深度寫入關(guān)閉很重要描邊本身是疊加效果不應(yīng)該去污染深度緩沖深度測試開啟Less是為了讓被物體正面擋住的那部分描邊不顯示只讓外擴露出的部分通過測試。畫完之后CB就完成了它的使命。引擎繼續(xù)渲染后續(xù)的半透明物體和其他階段描邊已經(jīng)像紋身一樣貼在了場景上。3.3 描邊寬度、顏色和視距的修正參數(shù)調(diào)節(jié)有幾個實操經(jīng)驗。描邊寬度u_OutlineWidth如果在世界空間外擴數(shù)值大概在物體包圍盒半徑的1%~3%之間會比較協(xié)調(diào)。一個更穩(wěn)妥的做法是運行時根據(jù)物體包圍盒半徑動態(tài)計算而不是每個物體硬編碼同一個值。const bounds targetMesh.bounds; const radius bounds.getBoundingSphere().radius; outlineMat.setNumber(u_OutlineWidth, radius * 0.02);這樣大石頭和小編鐘都默認(rèn)有一個相對協(xié)調(diào)的描邊寬度。如果項目場景相機經(jīng)常拉近拉遠(yuǎn)可以再把寬度和相機距離做一個線性修正遠(yuǎn)距離時適當(dāng)放大。曲線不復(fù)雜但要記住一點這個修正應(yīng)該基于物體到相機的距離而不是所有物體用同一個全局修正否則場景里不同位置的物體描邊粗細(xì)會明顯不一致。發(fā)光效果也在這個基礎(chǔ)上做。普通純色描邊直接輸出u_OutlineColor即可如果想做“描邊亮一圈再淡出”可以在片元Shader里根據(jù)片元到模型邊緣的距離做漸變或者疊加噪聲做動態(tài)閃爍都屬于后期錦上添花。4. 實測階段踩過的坑剔除方向、深度寫入和多相機4.1 法線外擴后描邊消失的排查鏈路我第一次接上這套方案把模型拖進(jìn)場景期待看到金色描邊結(jié)果是屏幕上干干凈凈連個毛邊都沒有控制臺也不報錯。這種“沒反應(yīng)”的問題最難查因為沒有報錯信息可以看。排查鏈路大致是這樣的先打印camera上的CommandBuffer列表確認(rèn)CB真的掛上去了。這一步查完發(fā)現(xiàn)沒問題。然后打印目標(biāo)網(wǎng)格的頂點數(shù)和包圍盒確認(rèn)drawMesh引用的網(wǎng)格有數(shù)據(jù)。也沒問題。接著懷疑剔除方向。描邊材質(zhì)設(shè)置了CULL_FRONT只畫背面。但我在編輯器里檢查場景里的模型Transform發(fā)現(xiàn)它的Scale是(-1,1,1)——這是美術(shù)在建模軟件里翻轉(zhuǎn)了坐標(biāo)軸導(dǎo)入引擎后經(jīng)常會出現(xiàn)負(fù)縮放。負(fù)縮放會翻轉(zhuǎn)三角形繞序原本的正面變成了背面原本的背面變成了正面。我這邊設(shè)置CULL_FRONT反而把真正的背面剔除了結(jié)果整個描邊都沒了。排查到這里根因找到了。解決辦法有兩個要么在創(chuàng)建CB時根據(jù)模型的縮放手動切換剔除方向要么在模型導(dǎo)入時統(tǒng)一收斂負(fù)縮放要么干脆在Shader里計算外擴時先用法線矩陣修正方向。最穩(wěn)妥的還是確保進(jìn)入引擎的模型沒有負(fù)縮放一勞永逸。4.2 深度寫入導(dǎo)致透明物體下描邊露餡第二個坑出現(xiàn)在有半透明物體遮擋的情況下。場景里放了一面半透明的玻璃墻玻璃后面有一座需要描邊的雕像。開啟描邊之后發(fā)現(xiàn)雕像應(yīng)被玻璃遮擋的部分居然透過玻璃把描邊輪廓完整呈現(xiàn)了看起來就像玻璃上畫了一個描邊貼花。原因是我前面說的CB里把深度寫入關(guān)掉了。深度寫入false意味著描邊片元不會被記錄進(jìn)深度緩沖所以一個位于玻璃后面的描邊片元雖然會正常參與深度測試但半透明玻璃本身已經(jīng)因為深度測試的關(guān)系把玻璃后面的東西遮住了如果描邊在玻璃之后繪制而且深度測試通過就不會被玻璃擋住。實際是半透明物體的渲染順序和深度測試共同作用的結(jié)果。解決方法保持depthTest為Less但不要對描邊和半透明物體同時做過于寬松的深度測試更簡單的是描邊物體和半透明遮擋物的層級關(guān)系本來就不應(yīng)該有穿透。如果你確實需要“透明物體也擋描邊”就得把描邊也納入到不透明渲染管線靠半透明的渲染順序解決。這類問題沒有銀彈只能在渲染階段上做取舍。我最后做的決策是項目里所有描邊都放在BeforeForwardOpacity半透明物體如果恰好擋住了描邊物體描邊會被透明物體的顏色混合蓋住視覺上基本可接受因為大部分描邊都是用在可交互的實體上沒人會把它放在玻璃后面還要求不被遮擋。4.3 多相機和多個CommandBuffer的順序問題項目里存在多個相機時比如主相機、UI相機、小地圖相機CommandBuffer就很容易掛錯相機。有些新手直接把CB掛到所有相機上結(jié)果同一個描邊被畫了兩遍有些掛在UI相機上描邊根本不會出現(xiàn)在主畫面里因為在UI相機的渲染流程里根本沒有場景幾何數(shù)據(jù)的深度。解決方案是明確一個原則CommandBuffer只掛到渲染場景的相機上而且只掛一次。如果項目里用了多個場景相機做畫面拼接需要先確定哪個相機負(fù)責(zé)最終畫面輸出就掛在哪個相機上。UI相機盡量不要掛場景相關(guān)的CB它渲染的是屏幕空間UI深度信息基本無效。多個CB掛同一個相機的執(zhí)行順序按添加順序執(zhí)行。如果你同時需要描邊又需要HUD先加的CB先跑后加的CB后跑。一旦發(fā)現(xiàn)效果互相覆蓋優(yōu)先檢查是不是CB添加順序錯了而不是去調(diào)Shader優(yōu)先級。4.4 蒙皮動畫網(wǎng)格的適用邊界這是整個方案最大的適用范圍局限必須單獨拎出來說。前面提到過cb.drawMesh用的是sharedMesh這是網(wǎng)格在綁定骨骼那一刻的“靜態(tài)姿勢”。如果描邊目標(biāo)是一個正在播放跑步動畫的角色你拿到的sharedMesh頂點還在綁定姿勢上畫出來的描邊是T-Pose的輪廓和動畫里的跑步姿態(tài)完全對不上效果會非常詭異。所以CommandBuffer直接drawMesh的描邊方案最合適的是靜態(tài)物體可拾取的道具、場景里的箱子、機關(guān)按鈕、交互點。帶骨骼動畫的角色描邊雖然理論上可以通過蒙皮數(shù)據(jù)重建頂點但在LayaAir的CommandBuffer里手動處理蒙皮計算成本很高綜合下來不劃算。如果你的需求是大量角色選中描邊建議還是回到雙Pass材質(zhì)方案或者用全屏邊緣檢測配合目標(biāo)物體標(biāo)記。其實項目里也遇到了這個問題一批需要描邊的NPC會播放待機動畫我一度想用CommandBuffer的OnWillRenderObject之類的鉤子去拿當(dāng)前幀蒙皮網(wǎng)格但設(shè)計復(fù)雜度太高最后這批NPC改用了帶描邊Pass的專用材質(zhì)而場景里的靜態(tài)交互物繼續(xù)用CommandBuffer方案。兩者并存各管一攤。5. 擴展思路全屏深度邊緣檢測與性能權(quán)衡5.1 深度邊緣檢測的原理如果你不想動模型的網(wǎng)格也不想給靜態(tài)物體單獨創(chuàng)建描邊材質(zhì)還有一個更重的思路全屏深度邊緣檢測。原理是利用深度緩沖里連續(xù)像素的深度變化來判斷哪里是幾何邊緣然后疊加描邊色。在CommandBuffer里這個思路常用的做法是把當(dāng)前已經(jīng)渲染好的場景RGB和Depth通過一個全屏后處理Shader處理Shader采樣當(dāng)前像素周圍若干點的深度值如果深度差異超過閾值就認(rèn)為這個像素處于邊緣位置。核心邏輯可以濃縮成一段GLSLfloat depth texture(u_depthTex, v_uv).r; float d0 texture(u_depthTex, v_uv vec2(1.0, 0.0) * u_texelSize).r; float d1 texture(u_depthTex, v_uv vec2(-1.0, 0.0) * u_texelSize).r; float d2 texture(u_depthTex, v_uv vec2(0.0, 1.0) * u_texelSize).r; float d3 texture(u_depthTex, v_uv vec2(0.0, -1.0) * u_texelSize).r; float diff abs(d0 - depth) abs(d1 - depth) abs(d2 - depth) abs(d3 - depth); float edge step(0.02, diff); vec4 outline vec4(u_OutlineColor.rgb, 1.0); vec4 base texture(u_sceneTex, v_uv); fragColor mix(base, outline, edge);這段Shader會描出場景中所有幾何邊緣不只是你選中的那個物體。如果你只想描特定物體就得配合標(biāo)記方案要么把目標(biāo)物體單獨寫進(jìn)一張RT要么用Stencil把目標(biāo)像素標(biāo)記出來然后在后處理時只對標(biāo)記區(qū)域找邊緣。標(biāo)記這一步在技術(shù)上有一定復(fù)雜度但也不是不能用。如果項目本身就是全屏后處理風(fēng)格的畫面比如水墨風(fēng)、網(wǎng)點風(fēng)這套方案反而能和整體風(fēng)格統(tǒng)一。5.2 三種方案的性能對比與選型建議做圖形性能對比不能拍腦袋直接看核心數(shù)據(jù)。我在一個1080p的測試場景里各跑了一遍場景中有50個需要描邊的物體NVIDIA 1660級顯卡WebGL2模式方案DrawCall增量像素填充開銷適用場景雙Pass材質(zhì)描邊每個物體1低帶骨骼動畫角色、要求效果精度最高CommandBuffer背面外擴每個物體1低靜態(tài)交互物、道具、不改造資產(chǎn)的場景全屏深度邊緣檢測每幀2~3全屏高全屏統(tǒng)一描邊風(fēng)格、后處理風(fēng)畫面CommandBuffer背面外擴因為只繪制描邊物體本身的背片填充量很低移動端也可以接受。但如果場景里同時有幾百個物體需要描邊DrawCall增量就會被放大這時候就要考慮是不是應(yīng)該用“只有選中/高亮物體才開描邊”的策略而不是所有物體全天候描邊。很多游戲里選中描邊本來就應(yīng)該是臨時狀態(tài)這個優(yōu)化是必要的業(yè)務(wù)邏輯不只是性能邏輯。5.3 幾個可以繼續(xù)做的方向沿著CommandBuffer描邊這條路后續(xù)能做的事情挺多。一個是把描邊和透明度動畫結(jié)合起來選中時描邊從透明漸變?yōu)閷嵣∠x中再漸隱過渡不突兀。實現(xiàn)方式就是在代碼里每幀改u_OutlineColor的alpha或者在描邊材質(zhì)上疊一個透明混合狀態(tài)成本不高但對視覺品質(zhì)提升很直接。另一個是把描邊和閃爍提示結(jié)合起來比如任務(wù)目標(biāo)道具會一直以呼吸頻率閃爍。這種動態(tài)效果在純色描邊Shader上加一個正弦波系數(shù)就行。還有一個是區(qū)域高亮。拿一個圓柱體網(wǎng)格用半透明材質(zhì)在CommandBuffer里畫一圈配合深度測試做地下城副本區(qū)域的邊界提示效果比貼地面UI好很多而且不會受到地形起伏的影響。這些方向本質(zhì)上都是同一個套路在合適的時間點、用合適的材質(zhì)、把合適的網(wǎng)格再畫一遍。理解了CommandBuffer的定位后面都是小特技。6. 寫在最后的幾點實操體會做這個功能最大的感受是CommandBuffer描邊最值錢的地方不是在“描邊”本身而是它提供了一種不侵入資產(chǎn)就能干預(yù)渲染結(jié)果的能力。以前一個描邊需求要從Shader開始一路改到材質(zhì)、合批、動畫系統(tǒng)現(xiàn)在只需要在相機上掛一段命令剩下的交給引擎調(diào)度。這種架構(gòu)上的清爽感用起來之后回不去的。另外有一點建議如果項目里同時存在多個類似的渲染增強功能最好維護(hù)一個簡單的CommandBuffer管理器統(tǒng)一記錄每個相機上掛了哪些CB誰負(fù)責(zé)什么階段順序如何。不然后面美術(shù)提需求、程序員改邏輯時很容易在“為什么突然多了一個CB但找不到創(chuàng)建點”這種事情上浪費時間。我就是前期偷懶沒建管理器后期排查多相機問題花了半個下午把這個坑寫出來希望能幫你省下這半天。