戰(zhàn):GPU實(shí)時(shí)高質(zhì)量卡通描邊與筆畫生成管線)
1. 為什么卡渲描邊是個(gè)“看起來簡單、做起來頭大”的活先聊個(gè)現(xiàn)象。我接觸過不少剛?cè)胄凶隹ㄍㄤ秩镜耐瑢W(xué)第一反應(yīng)都是“描邊嘛邊緣檢測或者反向膨脹隨便搞搞就能出效果”??烧姘秧?xiàng)目跑起來發(fā)現(xiàn)根本不是這么回事線條要么斷得一截一截要么在鏡頭快速移動(dòng)時(shí)糊成一片拖影要么模型一換就出現(xiàn)一條條莫名奇妙的噪聲線。最后被美術(shù)一句“這線有點(diǎn)臟”打回重做心態(tài)直接炸裂。這正是我想寫StrokeGen這套方案的直接原因。它的本質(zhì)是把“描邊”這件事從傳統(tǒng)的邊緣檢測思路升級成一套GPU實(shí)時(shí)、高質(zhì)量、風(fēng)格化可控的筆畫生成管線。摘開說就是靠GPU的并行能力和精心設(shè)計(jì)的多通道策略讓描邊既能實(shí)時(shí)跑又具備漫畫/動(dòng)畫質(zhì)感還允許調(diào)參控制“這筆刷到底有多粗、多硬、多連貫”。這篇內(nèi)容更適合這幾類人看正在做卡通渲染項(xiàng)目的TA技術(shù)美術(shù)、Unity/UE里寫Shader的程序員、對NPR非真實(shí)感渲染感興趣的圖形學(xué)愛好者。當(dāng)然如果你只是想知道“為什么我的描邊總拖影”“怎么提高描邊的穩(wěn)定性”這篇也會(huì)給出非常實(shí)在的排查思路。我不想講那些理論堆滿卻落不了地的概念。下面所有內(nèi)容基本都會(huì)沿著“為什么要這么設(shè)計(jì)—到底怎么寫—遇到什么坑怎么解決”這個(gè)線索來走。你可以把它當(dāng)成一篇偏實(shí)戰(zhàn)的踩坑記錄來看也可以當(dāng)作一個(gè)可以直接往自己項(xiàng)目里搬的參考實(shí)現(xiàn)。2. StrokeGen的設(shè)計(jì)思路從“描邊”到“筆畫生成”的思維轉(zhuǎn)換2.1 傳統(tǒng)邊緣檢測為何在實(shí)時(shí)渲染里不夠用先給沒怎么接觸過NPR描邊的朋友補(bǔ)個(gè)底。目前主流的卡渲描邊大體分三類幾何描邊在模型背面做反向膨脹把背面放大一圈當(dāng)描邊最早最常見優(yōu)點(diǎn)是干凈銳利缺點(diǎn)是需要額外Pass、對硬表面模型效果好但對面片、植被“不友好”。屏幕后處理描邊用深度、法線、顏色等輸入做邊緣檢測識別出邊界然后上色。優(yōu)點(diǎn)是全屏一把梭靈活性高缺點(diǎn)是線條容易“爬動(dòng)”而且相鄰像素識別不穩(wěn)定會(huì)帶來時(shí)間上的閃爍。擴(kuò)展材質(zhì)描邊把描邊信息寫進(jìn)模型頂點(diǎn)色、UV或者獨(dú)立貼圖里靠美術(shù)手工標(biāo)記質(zhì)量最高但耗時(shí)也最大。我做過幾個(gè)項(xiàng)目以后發(fā)現(xiàn)傳統(tǒng)方案最大的問題不是“畫不出邊”而是“邊不夠像一筆畫出來的”。什么叫“像一個(gè)筆畫”用人話說一條好描邊要有幾個(gè)特征連續(xù)、粗細(xì)相對穩(wěn)定、轉(zhuǎn)角處不“斷氣”、在模型和背景之間能自然過渡、運(yùn)動(dòng)時(shí)不會(huì)產(chǎn)生讓人暈眩的殘影。而深度邊緣檢測這類方案天然只關(guān)心“哪里是邊界”壓根不關(guān)心“這條邊界應(yīng)該長成什么形態(tài)”所以它的結(jié)果往往是一堆離散的、粗細(xì)不均的小段。到這一步就引出了StrokeGen的一個(gè)核心轉(zhuǎn)變我們不只做邊緣檢測而是把邊緣檢測的結(jié)果當(dāng)作輸入再走一遍“筆畫生成”的邏輯。2.2 StrokeGen到底在“生成”什么單從名字拆StrokeGen就是Stroke筆畫 Gen生成所以它不是一套簡單的描邊濾鏡。它的目的是在實(shí)時(shí)渲染里生成一條條結(jié)構(gòu)清晰、方向明確、可被參數(shù)控制的虛擬筆畫。我理解它的關(guān)鍵點(diǎn)有三層。第一層對邊緣做“方向場”的估算。我們不只是想知道某像素是不是邊緣還要知道這條邊緣在這個(gè)像素周圍是往哪個(gè)方向延伸的。方向的估算可以讓后續(xù)線條的連續(xù)性大幅提升也可以有效抑制“線條亂跳”。第二層對邊緣做“區(qū)域化”處理。傳統(tǒng)描邊是逐像素獨(dú)立判斷StrokeGen則會(huì)參考鄰域范圍內(nèi)的法線、深度、BaseColor等特征讓一條邊盡量保持整體一致。這樣是避免“同一個(gè)物體的外輪廓一半粗一半細(xì)”這個(gè)尷尬問題的關(guān)鍵。第三層把描邊結(jié)果參數(shù)化。比如線條的軟硬程度、粗細(xì)衰減、與主光源的聯(lián)動(dòng)、貼近陰影區(qū)的變色等都可以暴露成參數(shù)給美術(shù)調(diào)節(jié)。這是從“能看”到“能用”的分水嶺。你可能已經(jīng)感覺到了這套思路放到GPU上是有天然優(yōu)勢的因?yàn)檫@些計(jì)算基本都是逐像素、可并行的。而且現(xiàn)代圖形API里的Compute Shader和異步計(jì)算剛好像是為這種全屏處理準(zhǔn)備的。2.3 為什么GPU實(shí)時(shí)處理在方案里這么關(guān)鍵如果只是做離線渲染描邊想多高質(zhì)量都可以慢慢調(diào)就是了。但實(shí)時(shí)渲染不一樣。尤其是游戲、VTube、影視預(yù)演這類場景一秒鐘要輸出幾十上百幀每幀還有主場景、陰影、后處理等一大票東西搶GPU資源。StrokeGen把大量計(jì)算搬到GPU上以后就能做到幾個(gè)特別實(shí)際的效果不額外占用CPU不會(huì)拖慢游戲主線程尤其在主機(jī)和移動(dòng)端這些CPU本來就吃緊的平臺上優(yōu)勢明顯。可以實(shí)時(shí)隨著相機(jī)視角、模型變形、表情動(dòng)畫變化而更新描邊不會(huì)出現(xiàn)“線跟模型脫節(jié)”的尷尬。后處理管線天然適合全屏操作的執(zhí)行模型只需要在屏幕空間拿到深度、法線等數(shù)據(jù)就能在同一幀內(nèi)完成描邊的檢測、生成與合成基本不需要反復(fù)讀寫顯存。我自己的體會(huì)是只要條件允許描邊邏輯盡量往屏幕后處理階段平移。這樣不依賴具體模型怎么建模只要最后的法線、深度數(shù)據(jù)能合出來全屏就都能覆蓋到??扇绻阕呒儙缀蚊柽吢肪€骨骼動(dòng)畫、布料模擬和Morph Target這些一摻和進(jìn)來很容易出現(xiàn)描邊穿幫。3. 實(shí)操指南搭建一套GPU實(shí)時(shí)高質(zhì)量描邊管線3.1 管線的輸入準(zhǔn)備從G-Buffer里抓“可用特征”想實(shí)現(xiàn)StrokeGen級別的描邊第一步不是直接上邊緣檢測而是先準(zhǔn)備一份“數(shù)據(jù)足夠干凈”的緩沖區(qū)。一般情況下我們最需要的是以下幾種信息場景深度Depth用來做視野內(nèi)的幾何輪廓檢測也能判斷物體前后的遮擋關(guān)系。世界空間法線World Normal用來識別模型表面朝向的突變比如棱角、接縫。基礎(chǔ)色/材質(zhì)IDBaseColor/MaterialID用來在顏色或材質(zhì)差異很大的邊緣處補(bǔ)線。物體運(yùn)動(dòng)矢量Motion Vector雖然不是描邊必須項(xiàng)但在處理拖影問題時(shí)能幫上大忙后面我會(huì)具體講。如果你的項(xiàng)目是Unity加上URP管線那G-Buffer里其實(shí)已經(jīng)包含大部分?jǐn)?shù)據(jù)。如果是自定義管線你需要在渲染主場景的同時(shí)把這些特征單獨(dú)輸出到一張R8G8B8A8或者R16G16B16A16的RenderTexture上。一個(gè)非常實(shí)用的建議是不要把原始法線和深度直接拿來做描邊先做一個(gè)“去噪/平滑”預(yù)處理。因?yàn)楹芏嚅W爍和毛刺的表現(xiàn)都來自原始深度在物體邊緣的鋸齒、法線在凹凸細(xì)節(jié)上的高頻變化。預(yù)處理這一步可以用一個(gè)簡單的3x3或5x5空間濾波完成成本極低改觀卻很明顯。3.2 核心實(shí)現(xiàn)從邊緣檢測到“筆畫生成”的關(guān)鍵步驟接下來直接上實(shí)現(xiàn)。為了照顧不同引擎的同學(xué)我這里用類似HLSL的寫法里面只依賴最基礎(chǔ)的紋理采樣語法不依賴某個(gè)引擎的特殊函數(shù)庫。第一步重建屏幕空間坐標(biāo)和法線。如果用的是Unity內(nèi)置函數(shù)會(huì)直接給到這些值其他引擎也基本有大同小異的函數(shù)。// 從深度紋理重建世界坐標(biāo) float3 reconstructWorldPos(float2 uv, float depth) { float4 ndc float4(uv * 2.0 - 1.0, depth, 1.0); #if UNITY_UV_STARTS_AT_TOP ndc.y -ndc.y; #endif float4 worldPos mul(_InverseViewProjection, ndc); return worldPos.xyz / worldPos.w; }第二步做邊緣強(qiáng)度的計(jì)算。我這里是綜合了三種特征深度差、法線差、顏色BaseColor差。具體做法是采樣當(dāng)前像素周圍四個(gè)方向上下左右或者八個(gè)方向的鄰域計(jì)算帶權(quán)重的差異值。float4 edgeDetect(float2 uv, float2 texelSize) { float centerDepth tex2D(_CameraDepthTexture, uv).r; float3 centerNormal tex2D(_CameraNormalsTexture, uv).xyz; float3 centerColor tex2D(_MainTex, uv).rgb; float depthThreshold 0.1; // 深度差閾值視場景微調(diào) float normalThreshold 0.6; // 法線夾角閾值可暴露成參數(shù) float colorThreshold 0.15; // 顏色差閾值美術(shù)可調(diào) float edge 0.0; // 只演示上下左右四個(gè)方向?qū)嶋H可擴(kuò)展到八方向 for (int i 0; i 4; i) { float2 offset _Offset[i] * texelSize; float2 sampleUV uv offset; float sampleDepth tex2D(_CameraDepthTexture, sampleUV).r; float3 sampleNormal tex2D(_CameraNormalsTexture, sampleUV).xyz; float3 sampleColor tex2D(_MainTex, sampleUV).rgb; // 深度差異 float depthDiff abs(centerDepth - sampleDepth); // 法線差異用夾角余弦判斷 float normalDot dot(centerNormal, sampleNormal); float normalDiff 1.0 - saturate(normalDot); // 顏色差異 float colorDiff distance(centerColor, sampleColor); // 三種特征加權(quán)求和 edge smoothstep(depthThreshold, depthThreshold * 2.0, depthDiff); edge smoothstep(normalThreshold, normalThreshold * 1.4, normalDiff) * 0.8; edge smoothstep(colorThreshold, colorThreshold * 1.5, colorDiff) * 0.6; } return edge / 4.0; }這一步做完邊緣強(qiáng)度圖已經(jīng)出來了。但請注意如果你直接拿著這張圖來上色大概率會(huì)得到一條條“斷斷續(xù)續(xù)、明暗不均”的線。原因很簡單相鄰像素各自獨(dú)立做了判斷噪聲和采樣偏差被放大了。所以第三步也就是StrokeGen味道最濃的一步讓邊緣“成長”為筆畫。我們需要對邊緣強(qiáng)度圖做兩件事一是方向性模糊Directional Blur二是沿邊緣方向做連續(xù)性強(qiáng)化Upsampling / Morphological Smooth。方向性模糊的核心思路是先估算當(dāng)前邊緣的大致走向然后沿著這條走向做模糊而不是像普通高斯模糊那樣半徑均勻散開。這樣既抹平了斷裂感又不會(huì)把線條糊成一團(tuán)。方向估算有很多種算法我通常用Sobel算子先在邊緣強(qiáng)度圖上算出梯度方向然后取垂直方向作為邊緣走向。float2 sobelGradient(float2 uv, float2 texelSize) { float c tex2D(_EdgeTex, uv).r; float tl tex2D(_EdgeTex, uv float2(-texelSize.x, -texelSize.y)).r; float tr tex2D(_EdgeTex, uv float2( texelSize.x, -texelSize.y)).r; float bl tex2D(_EdgeTex, uv float2(-texelSize.x, texelSize.y)).r; float br tex2D(_EdgeTex, uv float2( texelSize.x, texelSize.y)).r; float gx (tr 2.0 * tex2D(_EdgeTex, uv float2(texelSize.x, 0)).r br) - (tl 2.0 * tex2D(_EdgeTex, uv float2(-texelSize.x, 0)).r bl); float gy (bl 2.0 * tex2D(_EdgeTex, uv float2(0, texelSize.y)).r br) - (tl 2.0 * tex2D(_EdgeTex, uv float2(0, -texelSize.y)).r tr); return normalize(float2(gx, gy)); }拿到梯度方向之后邊緣走向就是垂直方向。接著做方向性采樣把沿線方向上的邊緣強(qiáng)度累加過來讓斷線“接”上。還有一個(gè)我強(qiáng)烈建議加進(jìn)實(shí)現(xiàn)的東西亞像素精度。后處理方案最容易被人詬病的一點(diǎn)是鋸齒尤其是線條邊緣。解決辦法是在最終合成描邊顏色時(shí)對邊緣強(qiáng)度做smoothstep讓邊緣有0.5~1像素的過渡。視覺上會(huì)非常明顯地柔和下來。3.3 風(fēng)格化參數(shù)設(shè)計(jì)與調(diào)整方法StrokeGen真正比普通描邊方案靈活的地方是它把“風(fēng)格”放到了參數(shù)層。下面是我實(shí)測下來最常用的一套參數(shù)直接列成表格方便你照抄參考。參數(shù)名作用范圍推薦范圍調(diào)整說明Outline Width描邊寬度整體線寬0.5~3像素決定了線條的基礎(chǔ)粗細(xì)數(shù)值過大會(huì)讓近景看起來“黏糊糊”Edge Softness邊緣軟化線條內(nèi)外過渡0.2~1.0控制線條邊緣的羽化程度越高越柔和越低越硬朗Depth Bias深度閾值深度檢測靈敏度0.05~0.3調(diào)高則忽略微小的深度差減少地面褶皺、衣物起伏處的雜亂線條Normal Bias法線閾值法線檢測靈敏度0.4~0.8調(diào)高則只在折角明顯的結(jié)構(gòu)上畫線適合風(fēng)格化比較“干凈”的模型Color Bias顏色閾值顏色檢測靈敏度0.1~0.3當(dāng)模型貼圖色塊邊界也想出線時(shí)調(diào)低純卡通平涂可以適當(dāng)調(diào)高Stroke Smooth Iterations筆畫平滑迭代數(shù)斷線修補(bǔ)程度0~3迭代越多線越連貫但性能開銷遞增一般1~2次足夠Flow Intensity方向流動(dòng)強(qiáng)度描邊方向感0~1.0控制沿邊緣方向的采樣強(qiáng)度類似給描邊加“筆鋒”效果以我自己的項(xiàng)目為例做偏“動(dòng)漫賽璐璐”風(fēng)格時(shí)我習(xí)慣把Normal Bias打到0.7以上Color Bias壓到0.2以下這樣既能保留頭發(fā)、臉部輪廓這些關(guān)鍵結(jié)構(gòu)線又不會(huì)在鼻梁和嘴唇這種微折角上“炸出”一堆細(xì)碎線條。而做偏“歐美卡通/Graphic Novel”風(fēng)格時(shí)我會(huì)反過來把Depth Bias調(diào)低讓物體與物體交疊的邊緣更敏感同時(shí)把Edge Softness調(diào)到接近0.8得到一條粗厚但是帶有手繪暈染感的描邊。你可能會(huì)疑惑這些參數(shù)具體怎么映射到代碼里的實(shí)際就是把上一步計(jì)算的edge值與這些閾值比較再用smoothstep插值最后乘一個(gè)可調(diào)顏色或者采樣紋理作為描邊色輸出。我后面會(huì)給出一個(gè)完整的Frag函數(shù)示例。3.4 完整合成階段把描邊和主畫面疊到一起前面幾步產(chǎn)出的是一張邊緣強(qiáng)度圖這時(shí)候還差最后一步把描邊畫到主畫面上去。這里有兩種常見做法。第一種是“描邊疊加”在Post Process的最終Pass里直接用描邊顏色疊加覆蓋。這種做法適合描邊完全遮擋底色的風(fēng)格比如賽璐璐外輪廓。做法很簡單拿到描邊強(qiáng)度后用它作為alpha在描邊色和原圖之間插值。float4 frag(Varyings input) : SV_Target { float2 uv input.uv; float edge tex2D(_EdgeTex, uv).r; // 掃掉極微小的噪聲 edge smoothstep(_EdgeThreshold, _EdgeThreshold _EdgeSoftness, edge); // 描邊色和原圖疊加 float3 baseColor tex2D(_MainTex, uv).rgb; float3 outlineColor _OutlineColor.rgb; float3 finalColor lerp(baseColor, outlineColor, edge * _OutlineAlpha); return float4(finalColor, 1.0); }第二種是“描邊混合”邊緣強(qiáng)度僅僅作為一個(gè)權(quán)重疊加在原始畫面之上保留部分底色細(xì)節(jié)。這種適合描邊比較輕量的風(fēng)格。美術(shù)一般會(huì)在“描邊透明度”和“筆畫內(nèi)陰影”之間做很多微調(diào)。如果項(xiàng)目里用到了URP或HDRP你可以在Blit Renderer Feature里掛這一步。如果是舊版Built-in管線那就是往OnRenderImage里塞。無論走哪種請一定注意把描邊Pass放在后處理鏈的合適位置。我的習(xí)慣是放在色調(diào)映射之前這樣描邊既能受到后期調(diào)色的影響又不會(huì)被Bloom糊掉太多。4. 常見問題與排查技巧實(shí)錄4.1 描邊拖影問題到底怎么治“描邊拖影”這四個(gè)字我?guī)缀趺扛魩滋炀蜁?huì)在技術(shù)群里看到一次。這里的拖影分兩種表現(xiàn)一種是靜態(tài)幀里線條邊上一圈模糊殘影另一種是動(dòng)態(tài)場景里線條根部拖出長長的尾跡。先說第一種靜態(tài)拖影。排查思路其實(shí)很簡單殘影通常來自“過寬的采樣范圍”和“不恰當(dāng)?shù)臅r(shí)間累積”。你要檢查是不是在后處理時(shí)用了太大的模糊半徑尤其是方向性模糊半徑設(shè)置過高會(huì)把線條邊緣“撐”成了一片漸變。這個(gè)好解決把方向?yàn)V波的采樣半徑降下來或者把采樣次數(shù)從8降到4線條的銳度就回來了。再說第二種動(dòng)態(tài)拖影。這里十有八九是**時(shí)間性抗鋸齒TAA**的鍋。TAA會(huì)根據(jù)Motion Vector把歷史幀像素混合進(jìn)來但如果描邊像素和主場景像素的運(yùn)動(dòng)矢量不一致歷史幀里的描邊就和當(dāng)前幀的描邊錯(cuò)位形成重影。解決辦法有幾種把描邊Pass放到TAA之后或者把描邊強(qiáng)度圖也寫一份Motion Vector給TAA讓它能把描邊和畫面一起混合。把描邊輸出到單獨(dú)的RT不參與TAA累積最后再疊加到主畫面。如果描邊的運(yùn)動(dòng)矢量不好生成折中做法是降低TAA的歷史幀權(quán)重用多點(diǎn)采樣來保證穩(wěn)定。我實(shí)測下來最穩(wěn)定的做法是“描邊不參與TAA的累積只在最后合成階段和主畫面融合”。這個(gè)方案的代價(jià)是抗鋸齒后的描邊邊緣可能會(huì)出現(xiàn)一點(diǎn)階梯感所以描邊本身的亞像素平滑一定要做好。4.2 GPU狀態(tài)下畫面崩潰或D3D設(shè)備已移除熱詞里提到Unity卡渲Shader時(shí)總會(huì)有人遇到“GPU發(fā)生崩潰或D3D設(shè)備已移除”Device Removed的報(bào)錯(cuò)。這個(gè)往往不是Shader邏輯本身的鍋而是一只腳本通過后處理每幀申請了過大的RenderTexture導(dǎo)致顯存瞬間峰值或者Pass里出現(xiàn)了除以零、無限循環(huán)等讓GPU無法恢復(fù)的異常。我排查這類問題的通用路徑先用RenderDoc或PIX逐幀抓取看崩潰發(fā)生在哪個(gè)Pass、哪次DrawCall。如果抓不到就先關(guān)掉后處理和描邊再逐個(gè)Pass打開做二分定位。檢查RenderTexture的格式和尺寸確保是2的冪次或與屏幕尺寸匹配。不要每幀都新建RT盡量復(fù)用臨時(shí)RT用GetTemporaryRT也要記得Release。檢查Shader里的for循環(huán)。GPU的循環(huán)次數(shù)必須是編譯期可確定的否則會(huì)出現(xiàn)極長的循環(huán)導(dǎo)致看門狗超時(shí)。在方向性模糊里采樣次數(shù)最好用[unroll]展開或者用定長循環(huán)。檢查是否存在NaN或Inf。極端參數(shù)下normalize()一個(gè)零向量會(huì)產(chǎn)生NaN一旦NaN進(jìn)入紋理采樣結(jié)果不可控嚴(yán)重時(shí)會(huì)導(dǎo)致驅(qū)動(dòng)層崩潰。所以在Sobel方向估算那一步一定要加一個(gè)保護(hù)向量長度過小時(shí)直接返回float2(1,0)。這些都是很典型的實(shí)戰(zhàn)坑寫出來是想讓大家知道GPU崩潰不一定是你代碼寫的“不對”更多時(shí)候是“非法操作”觸發(fā)了驅(qū)動(dòng)的保護(hù)機(jī)制。穩(wěn)住心態(tài)按步驟排查就好。4.3 描邊斷裂、閃爍與“線條一會(huì)粗一會(huì)細(xì)”這類問題在動(dòng)態(tài)場景里尤其明顯。根本原因是許多邊緣檢測是“像素級獨(dú)立判斷”缺少全局一致性。StrokeGen的思路是盡量讓線條擁有“區(qū)域連續(xù)性”。排查時(shí)我建議按順序檢查下列幾點(diǎn)邊緣強(qiáng)度圖是否出現(xiàn)了大量孤立高亮點(diǎn)如果是說明當(dāng)前閾值的靈敏度太高先調(diào)Bias。方向性模糊的迭代次數(shù)是否足夠?qū)τ陂L時(shí)間持續(xù)閃爍的線條把Stroke Smooth Iterations提到2或3通常會(huì)有質(zhì)的改善。我們在屏幕空間做后處理描邊但模型在屏幕外的邊緣變化劇烈尤其是鏡頭旋轉(zhuǎn)時(shí)屏幕空間邊緣會(huì)一幀一幀地“爬”過像素。不要試圖完全消除這種爬動(dòng)更務(wù)實(shí)的是通過加入時(shí)間濾波比如用時(shí)間上的指數(shù)加權(quán)移動(dòng)平均讓線條過渡平滑。關(guān)于“線條一會(huì)粗一會(huì)細(xì)”還有一個(gè)往往被忽略的原因描邊寬度沒有根據(jù)屏幕分辨率做縮放。同一根線在1080p下占3個(gè)像素在4K下還是3個(gè)像素看起來就會(huì)“細(xì)”不少。所以建議把Width參數(shù)乘上一個(gè)分辨率歸一化系數(shù)float normalizedWidth _OutlineWidth * (1080.0 / _ScreenParams.y);這樣不同分辨率下的視覺寬度能保持基本一致。這個(gè)細(xì)節(jié)美術(shù)很少主動(dòng)提但給到項(xiàng)目里是真香。4.4 調(diào)試與性能調(diào)優(yōu)的獨(dú)家心得這部分分享幾個(gè)我實(shí)測下來很管用的調(diào)試技巧。第一描邊強(qiáng)度圖可視化。在做后處理時(shí)不要把描邊結(jié)果直接疊加到畫面里調(diào)試先在Debug模式下把邊緣強(qiáng)度圖單獨(dú)輸出為一張灰度圖。這樣你能很直觀地看到哪些線條過強(qiáng)、哪些斷裂。等到邊緣強(qiáng)度圖看起來干凈了再打開合成關(guān)卡。第二性能預(yù)算控制在1毫秒以內(nèi)。屏幕空間的描邊在1080p下我建議全屏處理控制在0.5~1ms左右中端顯卡。超過這個(gè)數(shù)就要看看是不是采樣次數(shù)太多了。一般來說四方向采樣加1次方向性模糊就夠用八方向采樣一般用于特別追求質(zhì)量的場景。在移動(dòng)端我甚至建議把方向性模糊砍掉直接用四方向采樣加上一個(gè)低成本的抖動(dòng)Dithering來模擬柔和過渡。第三異步計(jì)算有一定的優(yōu)化空間。如果描邊不依賴后處理鏈里某些中間結(jié)果可以考慮放到異步Compute Queue里跑。不過異步計(jì)算在移動(dòng)端尤其部分Adreno和Mali GPU兼容性不穩(wěn)定要用的話提前做真機(jī)驗(yàn)證。第四把描邊參數(shù)全部暴露成材質(zhì)屬性并分類做好分組。美術(shù)調(diào)參時(shí)會(huì)非常感激你。比如可以按“寬度組”“閾值組”“顏色組”“方向處理組”來劃分命名也盡量用Outline_Width這種有前綴的結(jié)構(gòu)方便在Shader里快速檢索。5. 關(guān)于GPU驅(qū)動(dòng)、平臺適配與后續(xù)擴(kuò)展的幾點(diǎn)經(jīng)驗(yàn)5.1 平臺差異PC、主機(jī)和移動(dòng)端的處理差異寫GPU描邊Shader時(shí)最怕的就是“PC上好好的一到手機(jī)上就看不清或者閃成鬼片”。原因主要有三精度、帶寬和API差異。精度方面PC桌面端通常用FP32移動(dòng)端很多GPU為了性能會(huì)把中精度FP16算得飛快。遇到深度差值很小的情況FP16的精度不夠容易導(dǎo)致邊緣檢測判斷抖動(dòng)。我的建議是在移動(dòng)端做深度差值時(shí)先對深度做一次log變換拉大近距離細(xì)節(jié)的差異再算閾值這樣能有效降低精度問題。也可以直接在Shader里用min16float來標(biāo)注允許半精度讓編譯器自己優(yōu)化。帶寬方面后處理描邊需要讀寫多張全屏貼圖這在移動(dòng)端非常吃帶寬。優(yōu)化思路是把深度、法線、顏色壓到同一張RT的各個(gè)通道里。比如用R通道存深度、G和B通道存壓縮后的法線、A通道存材質(zhì)ID這樣屏幕后處理只用綁定一張紋理帶寬開銷直接砍半。API差異方面Unity里要留意半像素偏移。在D3D平臺紋理坐標(biāo)原點(diǎn)通常在上方OpenGL/Vulkan則在下方如果后處理uv處理不當(dāng)會(huì)出現(xiàn)上下顛倒或者邊緣錯(cuò)位的問題。解決辦法是判斷UNITY_UV_STARTS_AT_TOP宏并對uv做相應(yīng)翻轉(zhuǎn)。這段代碼我在前面重建世界坐標(biāo)時(shí)已經(jīng)寫了實(shí)際項(xiàng)目里也要在采樣屏幕紋理前套用類似的兼容邏輯。5.2 與描邊相關(guān)的GPU驅(qū)動(dòng)兼容性認(rèn)知很多同學(xué)一碰到兼容性Bug就慌其實(shí)這里面有一部分是驅(qū)動(dòng)本身的毛病不是我們代碼的問題。就比如不同版本NVIDIA驅(qū)動(dòng)對同一段Demote指令的處理有差異導(dǎo)致棋盤格采樣出來的結(jié)果不同。經(jīng)驗(yàn)是盡量別用太冷門的紋理格式別依賴未定義行為。比如對同一樣本點(diǎn)做非連續(xù)的Gather或者依賴兩個(gè)像素之間的運(yùn)算順序這些都很容易踩兼容性的坑。此外在Shader里少用discard因?yàn)樗鼤?huì)打斷GPU的Early-Z和像素著色器優(yōu)化加劇描邊處渲染壓力。還有一點(diǎn)特別重要在使用任何新的圖形API特性前一定要先做目標(biāo)設(shè)備的小范圍驗(yàn)證。不是所有手機(jī)都支持Stencil后處理或Compute Shader的隨機(jī)寫入??梢韵扔煤唵蔚臏y試場景在真機(jī)上跑一遍基礎(chǔ)功能再鋪開做參數(shù)優(yōu)化。5.3 后續(xù)可以怎么擴(kuò)展StrokeGen這套思路搭好之后擴(kuò)展空間其實(shí)是很大的。比如可以在描邊圖的基礎(chǔ)上接入“筆刷紋理”通過UV映射或世界坐標(biāo)映射讓線條自帶手繪的紙紋、顆粒感也可以給描邊加入顏色漸變讓線條在靠近主光源時(shí)變暖、背光時(shí)變冷增強(qiáng)體積感和故事感還可以把它跟輪廓光、影繪等效果結(jié)合起來組成一整套日式卡渲最終表現(xiàn)方案。如果你單位里有專門的GPU算子開發(fā)角色甚至可以把方向場估算部分用Compute Shader替代Pixel Shader用更少的內(nèi)存占用和更高的緩存友好度跑出更好的性能。在移動(dòng)端追求極致性能時(shí)這通常是一步關(guān)鍵優(yōu)化。我自己就在項(xiàng)目里把描邊強(qiáng)度圖額外輸出給Bloom做深度掩碼讓發(fā)光效果只在輪廓線上出現(xiàn)。最終的效果是很“二次元”的那種發(fā)光描邊觀眾一眼就能看出風(fēng)格化但實(shí)現(xiàn)成本卻很低。類似這樣的擴(kuò)展其實(shí)還有很多值得玩的空間關(guān)鍵在于知悉管線的邊界在哪里。6. 最后再分享一點(diǎn)邊界與心得如果你要開始做自己的GPU實(shí)時(shí)卡渲描邊方案我建議你從最基礎(chǔ)的版本入手只采樣深度和法線輸出一張Untextured的純色描邊圖跑通一遍。不要一開始就上方向性模糊、Sobel、運(yùn)動(dòng)矢量那一套先把主循環(huán)跑通再逐步加料。這樣每一步出問題都能快速定位。另一個(gè)非常想強(qiáng)調(diào)的心得是描邊這個(gè)效果60%是參數(shù)調(diào)出來的30%是兼容性磨出來的只有10%是真正的算法創(chuàng)新。市場上很多看起來驚艷的卡渲效果底層用的都是很基礎(chǔ)的技術(shù)但人家在閾值、流向、模糊半徑等細(xì)節(jié)上摳得非常細(xì)。所以別小看“調(diào)參”這件事它是把技術(shù)轉(zhuǎn)化成美術(shù)表現(xiàn)最直接的一環(huán)。我踩過很多坑。印象最深的一次為了消除一個(gè)角色耳朵邊緣的描邊閃爍前后折騰了快兩天最后發(fā)現(xiàn)只是法線紋理壓縮格式的問題——高質(zhì)量法線貼圖遇到ASTC壓縮后邊緣像素出現(xiàn)了肉眼可見的偏差導(dǎo)致后處理檢測誤判。所以說碰到某些“只在高畫質(zhì)下出現(xiàn)”的詭異描邊問題時(shí)先懷疑紋理格式再懷疑Shader邏輯。這篇經(jīng)驗(yàn)如果你能用上不妨從里面挑兩三個(gè)點(diǎn)先做起來比如給描邊加分辨率歸一化比如把邊緣強(qiáng)度圖可視化成灰度圖調(diào)試。這兩個(gè)改動(dòng)很簡單但對項(xiàng)目手感和開發(fā)效率的提升立竿見影。等跑順了再慢慢往StrokeGen的方向靠。