Cocos 实用渲染实战(一):高性价比的人物皮肤渲染
無論何等類型或規模,人物渲染都是項目中難以替代的重要組成部分。
有趣的是,對比世間萬物,我們理應對自己的身體更加熟悉,然而遠自文藝復興以來,無論在美術層面還是技術層面,人物渲染一直是一個令人撓頭的癢點——即便是從真人模特身上以尖端儀器掃描,并賦予極高貼圖精度的加持下,我們依然時常難以擺脫“恐怖谷”的困擾。
那么,有什么“奇技淫巧”,能夠讓我們在 Cocos Creator 中更容易地產出可信的人物渲染效果呢?Cocos 布道師葡萄干將從皮膚、頭發、眼睛三個方面,同大家分享一套 Cocos 「次世代」人物渲染方案。
?
今天先從皮膚說起。
確立目標
我們將在 Cocos Effect 中編寫一個次表面散射著色器,用于表現人物渲染中的皮膚材質效果。Cocos Creator 已經自帶了標準 Metal/Roughness 流程的 PBR 著色器,我們將在此基礎上增加新的 GLSL 代碼,這樣既可以使用所有 Metal/Roughness 流程的 PBR 功能和特性,同時也兼備次表面散射的效果呈現。
所謂次表面散射,最直觀的視覺觀感是:物體內部自發光由內而外把物體照亮了,因此使用我們的著色器除了可以配合環境制作寫實的次表面散射效果之外,在特定數值的配合下,還可以產生更特別、更戲劇化的效果:
?
我們的著色器將配合兩張新貼圖 Thickness 和 Curvature 使用。這兩個名詞對于美術,尤其是角色美術的同學,想必是不陌生了。同時,我們會附加上菲涅爾反射的功能,在 PBR 反射算法的基礎上賦予更靈活的調節選項。最后,我們會賦予一個 Diffuse Profile 功能,其細節會在后文詳說。目前我們只需要知道它是一個隨數值調節顏色輸出的功能。
那么,Cocos Effect 又如何使用呢?
快速上手
Cocos Effect 是 Cocos Creator 存儲著色器的一種格式,它使用 YAML 編寫。Cocos Effect 將頂點著色器、片元著色器和編輯器中的參數整合在一個文件中,并且會根據目標平臺不同轉換為不同版本的 OpenGL ES Shader。YAML 只傳輸數據,它本身不包含邏輯,畢竟 YAML 的全稱是“YAML Ain't a Markup Language” (YAML 不是一種標記語言),真正表達邏輯的還是 GLSL 頂點和片元著色器。
Cocos Effect 的詳細信息何以從官方文檔「Effect 語法」[1]中獲得,以Cocos Creator 內置的標準 PBR 著色器為例,我們可以參考以下的信息,快速開始編寫自己的著色器:
所有頂點著色器代碼需要在“CCProgram standard-vs”標簽下用 GLSL 編寫,同時也可以使用 Cocos Creator 內置的 Shader 參數,具體的列表可以在「常用 shader 內置 Uniform」[2]獲得;
?
類似的,所有的片元著色器代碼需要在“CCProgram standard-fs”標簽下用 GLSL 編寫;
?
自定義參數需要在“properties”標簽下聲明,在“properties”標簽下聲明的變量都會在 Cocos Creator 內的編輯面板中出現;
?
我們需要為新聲明的參數聲明一個相應的 uniform,這需要在“CCProgram shared-ubo”標簽下實現,聲明的 uniform 無論頂點著色器還是片元著色器都可以訪問;
?
我們也可以聲明自定義函數,但是記得:YAML 是不包含邏輯的。所以自定義函數應該放在頂點著色器(“CCProgram standard-vs”)或者片元著色器(“CCProgram standard-fs”)之內。
奠定理論
首先,我們需要解答一個問題:什么樣的效果能夠讓皮膚更真實?
我們可以從現實生活中找到一些參考:
?
觀察上圖,首先你可能會注意到的是:他的耳朵是紅色的。而且在結構越陡峭、線條越堅硬的部分,紅色顯得更加濃艷。另外,在他的鼻梁明暗交界線的部分,也可以看到一條紅色聚集的色帶。
?
在上圖中,我們同樣能夠在鼻梁的明暗交界線上觀察到紅色的色帶,她的鼻梁的線條并不是特別陡峭,因此色帶似乎也比上一個例子更寬一些。
?
在這個例子中,我們同樣可以觀察到紅色聚集的部分,不過在她的臉上,紅色聚集在鼻翼和鼻尖的位置。
?
而在這個例子當中,我們可以在他的臉頰明暗交界線的位置看到大面積的粉紅色聚集(至于為什么是粉紅色,是因為這張照片在后期調色中混入了冷色調的緣故)。而這種粉紅色在他鼻梁線條鋒利的部分同樣可以看到。
綜合起來,我們似乎可以觀察到一定的規律:
人的皮膚在明暗交接線的部分,會出現紅色聚集;
紅色聚集在人臉結構陡峭,線條鋒利的部分,會更明顯和鮮艷;
耳朵、鼻尖、鼻翼等區域是紅色聚集常見的地方。
那么,為什么會出現這種現象?這些紅色是從何而來的?
?
我們知道,當光線從一種介質 A 射入另一種介質 B 時,一部分光線被介質 B 吸收,另一部分在介質 B 中經過多次反射和折射,最終一部分光線從介質 B 折返重新射入介質 A 中。而這些在介質 B 中經過反復反射折射而重新回到介質 A 的光線被人眼所捕捉到,所以人眼可以觀察到介質 B 的顏色。這些光線雖然原理上屬于反射光線(Specular),但二手手游拍賣平臺實際表現的是散射(Diffuse)的特質,所以被稱為漫反射(Diffuse Reflectance)光線。
你大概已經注意到:當漫反射光線經過在介質 B 中的種種流程,重新射入介質 A 時,距離原來射入介質 B 的入射點已經有了一段距離。對于絕大多數材質來說,這個距離非常微小,完全可以忽略不記,所以我們可以理解為漫反射光線是由原入射點射出的。
然而對一小部分材質來說,這個距離就不能忽略了。當光線射入時,這類材質會表現一種透光的特質,仿佛物體內部自帶光源,把物體從內部照亮了。自然界中有很多有機材質會表現這種特質,比如蜂蠟、樹葉、果蔬等,當然也包括人的皮膚。
這種特別的散射特質,即被稱為次表面散射(Sub-surface Scattering)。
?
原理似乎挺簡單,但我們應該如何實現呢?
在計算機圖形學的歷史上,我們可以找到多種多樣表現次表面散射的技巧和手法。其中一個較早的例子來自與電影《黑客帝國》(The Matrix)。特效人員發現,可以簡單地對皮膚的 Diffuse 貼圖做一次模糊,再疊加到原貼圖上,就可以有效降低貼圖的人工質感,做出光線在表皮下散射的效果。
而對于明暗交界線上的紅色堆積,你大概已經想到可以輕松利用“N·L”方法,通過光照方向和物體表面法線計算得明暗交界線的位置,再疊加以一個顏色即可。這種方法也被稱為 Wrap Lighting 方法,在經典游戲《半衰期2》(Half-life 2)中廣泛應用。
而與“N·L”非常類似的“N·V”方法,可以通過攝像機方向和物體表面法線得到物體正對攝像機觀察角度的部分,這可以幫助我們輕松獲得菲涅爾(Fresnel)反射的效果。
講到這里,我們的目標已經比較明確了:
我們需要一個模糊,用于達到 Diffuse 貼圖的漫反射效果;
我們需要一張 Thickness 貼圖和一張 Curvature 貼圖,幫助我們識別物體那些部位容易出現次表面散射;
我們需要一個菲涅爾反射效果,這將幫助我們實現皮膚的 Specular 部分;
最后,我們需要一個 Diffuse Profile,這將幫助我們確定次表面散射的強度和顏色。
實現模糊效果
如何實現模糊的效果?其背后的邏輯其實很簡單:我們只要把需要被模糊的圖像的 UV 向各個方向偏移一點距離,把所有偏移的結果相加,求一個平均值即可:
vec3 boxBlur( sampler2D diffuseMap, float blurAmt ){
vec2 uv01 = vec2(v_uv.x - blurAmt * 0.01, v_uv.y - blurAmt * 0.01);
vec2 uv02 = vec2(v_uv.x + blurAmt * 0.01, v_uv.y - blurAmt * 0.01);
vec2 uv03 = vec2(v_uv.x + blurAmt * 0.01, v_uv.y + blurAmt * 0.01);
vec2 uv04 = vec2(v_uv.x - blurAmt * 0.01, v_uv.y + blurAmt * 0.01);
vec3 blurredDiffuse = (SRGBToLinear(texture(diffuseMap, uv01).rgb) + SRGBToLinear(texture(diffuseMap, uv02).rgb) + SRGBToLinear(texture(diffuseMap, uv03).rgb) + SRGBToLinear(texture(diffuseMap, uv04).rgb)) / 4.0;
return blurredDiffuse;
}
在上面的示例代碼中,“v_uv”是 Vertex Shader 傳遞的 UV 數據,我們基于 Cocos Creator 的內置 PBR 著色器編寫我們的 Shader,所以有很多準備工作已經做好了,我們直接拿來用即可。“SRGBToLinear”是 Cocos Creator 內置函數,將 sRGB 空間的顏色數據轉換為線性空間,在 PBR 流程當中,所有的顏色計算需要在線性空間中進行。
當然,僅僅做一次平均值的計算,最終效果可能不是特別好。你也可以用同樣的方法循環2-3次,以獲得更細膩的效果:
vec3 blurPass = vec3( 0.0, 0.0, 0.0 );
for( float i = 1.0; i < 4.0; i++ ){
blurPass += boxBlur(diffuseMap, blurAmt * i);
}
blurPass = blurPass / 3.0;
這種單刀直入的模糊方式,稱之為方形模糊(Box Blur),其特點就是所有像素一視同仁,被處于同樣等級的模糊處理。雖然簡潔明了效率高,但視覺上不一定是我們想要的。
如果你用過 Adobe 系列的圖像處理工具,你一定很熟悉最常用的模糊工具高斯模糊(Gaussian Blur)。高斯模糊的邏輯與方形模糊一脈相承,不同的是:像素偏移的距離越大,模糊的程度越高,反之則越小,而模糊程度的權重則呈正態分布排列。這樣我們得到的結果中間模糊程度低,四周模糊程度高,并且呈現出一種從中間向四周自然衰減的效果。
用代碼從頭實現一個正態分布函數,似乎還是太麻煩了。所幸的是,我們只需要幾個呈正態分布的數值作為我們模糊的權重,直接代入我們的方形模糊函數當中即可,網上有諸多正態分布數值生成器可供挑選。
vec3 gaussianBlur( sampler2D diffuseMap, float blurAmt ) {
float gOffset[5];
gOffset[0] = 0.0;
gOffset[1] = 1.0;
gOffset[2] = 2.0;
gOffset[3] = 3.0;
gOffset[4] = 4.0;
float gWeight[5];
gWeight[0] = 0.2270270270;
gWeight[1] = 0.1945945946;
gWeight[2] = 0.1216216216;
gWeight[3] = 0.0540540541;
gWeight[4] = 0.0162162162;
vec3 baseDiffuse = SRGBToLinear(texture(diffuseMap, v_uv).rgb);
for( int i = 0; i < 5; i++ ){
baseDiffuse += SRGBToLinear(texture(diffuseMap, v_uv + vec2(gOffset?* 0.01 * blurAmt, 0.0)).rgb) * gWeight;
baseDiffuse += SRGBToLinear(texture(diffuseMap, v_uv - vec2(gOffset?* 0.01 * blurAmt, 0.0)).rgb) * gWeight;
baseDiffuse += SRGBToLinear(texture(diffuseMap, v_uv + vec2(0.0, gOffset?* 0.01 * blurAmt)).rgb) * gWeight;
baseDiffuse += SRGBToLinear(texture(diffuseMap, v_uv - vec2(0.0, gOffset?* 0.01 * blurAmt)).rgb) * gWeight;
}
return baseDiffuse / 5.0;
}
探索 Diffuse Profile
讓我們回到之前觀察到的明暗交界線上的紅色聚集上面。我們已經想到可以用“N·L”+ 顏色疊加的方法實現這種效果,但這里有一個問題:這里的紅色是常量的紅色嗎?
讓我們來看一個實驗:有一個呈現次表面散射的球體,被單一光源照射。光源的位置和強度不變,放大和縮小球體的大小,觀察次表面散射顏色的變化。
?
結果略微出乎意料:在球體極大和極小的情況下,散射顏色較深,近乎于純黑色;隨著球體的縮放接近于極大和極小之間的一個范圍,散射的顏色逐漸變得明亮和鮮艷,直到達到一個峰值。
這似乎告訴我們:次表面散射的顏色變化也遵循一條鐘形曲線(正態分布曲線),在某個臨界點達到峰值,向兩邊遞減。
那么,這個臨界點是由什么決定的呢?這就取決于我們如何理解實驗中球體大小的變化。
首先,在光源位置不變的情況下,球體的大小變化,意味著光線到達球體表面傳播的距離變化,也就是說,光線傳播的距離是因素之一。
另外,一個半徑較大的球體,可以看作其表面的曲率(Curvature)也更大,反之則更小。一個半徑無限小的球體,表面的曲率也無限小,可以看作是一個平面。也就是說,物體表面的曲率,即彎曲的程度,也是因素之一。這也與我們之前在觀察參考圖中得到的結論相契合。
理論很美好,但是我們如何實現呢?
所幸的是,我們已經有基于現實觀測的皮膚次表面散射數據供我們使用:
?
這張表看上去有點不明覺厲,簡單地說:我們所觀察到的皮膚上的紅色,其實是皮膚的不同截層以不同的顏色和強度曲線分別進行散射,再疊加而成。這張表列舉了六層不同的顏色和曲線。而所有截層的散射強度都以正態分布排列,所以我們可以在右圖看到自然衰減的暈染。
既然數據已經給到我們了,我們就可以根據正態分布公式,計算出散射強度,再疊加以顏色,散射的最終輸出就解決了。
?
上圖即是正態分布(高斯分布)公式,簡單地說:μ 為中位數,在我們的計算中也就是散射顏色變化的峰值,我們知道它由光線傳播的距離和物體表面的曲度相關,目前可以取0;σ^2為方差,它決定了鐘形曲線的陡峭程度,這個數值已經為我們提供了。由此,我們可以直接帶入公式:
#define M_PI 3.1415926535897932384626433832795
vec3 Profile( float dis ){
return??vec3(0.233, 0.455, 0.649) * 1.0 / (abs(sqrt(0.0064)) * abs(sqrt(2.0 * M_PI))) * exp(-dis * dis / (2.0 * 0.0064)) +
vec3(0.1, 0.366, 0.344) * 1.0 / (abs(sqrt(0.0484)) * abs(sqrt(2.0 * M_PI))) * exp(-dis * dis / (2.0 * 0.0484)) +
vec3(0.118, 0.198, 0.0) * 1.0 / (abs(sqrt(0.187)) * abs(sqrt(2.0 * M_PI))) * exp(-dis * dis / (2.0 * 0.187)) +
vec3(0.113, 0.007, 0.007) * 1.0 / (abs(sqrt(0.567)) * abs(sqrt(2.0 * M_PI))) * exp(-dis * dis / (2.0 * 0.567)) +
vec3(0.358, 0.004, 0.0) * 1.0 / (abs(sqrt(1.99)) * abs(sqrt(2.0 * M_PI))) * exp(-dis * dis / (2.0 * 1.99)) +
vec3(0.078, 0.0, 0.0) * 1.0 / (abs(sqrt(7.41)) * abs(sqrt(2.0 * M_PI))) * exp(-dis * dis / (2.0 * 7.41));
}
到目前為止,我們已經得到了實現 Diffuse 散射效果的模糊,得到了解決次表面散射顏色和強度的 Diffuse Profile,現在的問題是:次表面散射應該出現在哪里?
在這里我們需要引入兩張貼圖:Thickness 和 Curvature。Thickness 通過以法線反方向發射射線的方式計算物體的厚度,Curvature 表現的是物體表面的曲率。這兩張圖,我們無需考慮如何在引擎中計算和生成,因為我們可以在第三方軟件,比如 Substance Painter 中離線渲染他們。
?
獲得烘焙完成的貼圖之后,我們把 Curvature 放在一邊,先處理 Thickness。
vec4 bDepth = gaussianBlur(thicknessMap, 0.0);
float deltaDepth = abs(SRGBToLinear(texture(thicknessMap, v_uv).rgb).x - bDepth.x);
我們先用之前的高斯模糊處理 Thickness,再用原貼圖減去被模糊后的 Thickness 貼圖。回憶一下高斯模糊的原理,我們得到的結果是:模糊后偏離較小的像素被減去了,留下的是偏離較大的像素。這些Δ值可以作為我們次表面散射的遮罩。
我們仍缺的一環是菲涅爾反射。如上所述,我們可以使用“N·V”方法實現這一效果。
vec4 v_normal_cam = normalize(cc_matView * vec4(v_normal, 0.0));
float NVdot = dot(vec3(0.0, 0.0, 1.0), normalize(v_normal_cam).xyz);
在上面的示例代碼中,“cc_matView”是Cocos Creator自帶參數,返回的是試圖矩陣。“v_normal”是從 Vertex Shader 傳遞的法線數據。
所有組件已經基本就緒了,下面是讓他們聯動起來的環節。
完成 Shader
漫反射效果,用我們的模糊處理一下 Diffuse 貼圖,疊加到 Albedo 通道即可。
vec4 bDiffuse = gaussianBlur(diffuseMap, blurAmt);
s.albedo += bDiffuse;
菲涅爾反射的效果,用我們“N·V”計算得出的權重,乘以一個自定義參數,疊加到 roughness 通道即可。
pbr.y += NVdot * roughnessGain;
s.roughness = clamp(pbr.y, 0.04, 1.0);
漫反射的顏色,我們已經得到了 Diffuse Profile 的函數,問題是:用什么參數帶入這個函數?
我們已經知道光線傳播的距離和物體表面的曲率是影響次表面散射的因素。光傳播的距離似乎比較難把控,但我們至少知道物體本身的前后關系也算在光傳播的距離當中,因此代入頂點位置數據(v_position)是順理成章的。
至于物體表面的曲率,我們并不知道曲率與次表面散射的直接數值關系,但我們通過觀察實驗得知:曲率與次表面散射強度大致呈線性關系,因此我們姑且把曲率(由 Curvature 貼圖提供)當作一般的數值權重,再輔以我們自定義的權重加以調節。
除了 Diffuse Profile 的輸出之外,我們還可以利用 Cocos Creator 內置的 cc_mainLitColor 參數,在散射中加入光源顏色和強度的影響。
最后,在顏色輸出上再疊加上物體自身的 Diffuse 顏色,就基本確立了次表面散射的顏色和強度。
vec3 curvatureMap = SRGBToLinear(texture(curvatureMap, v_uv).rgb);
vec3 sssColor = Profile(length(v_position) * curvatureMap.x) *
cc_mainLitColor.rgb *
cc_mainLitColor.w *
s.albedo.rgb;
然而,問題又出現了:次表面散射該從哪個通道輸出?
Cocos Creator 遵循標準 PBR 的 Metal/Roughness 流程,因而默認 Pipeline 中并沒有 Translucency 通道,但這并不會對我們造成太大影響:我們可以利用 Emissive 通道達到相同的效果。
下一個問題是:次表面散射應該在哪里出現?
我們知道次表面散射的成因是入射光線在物體內部反射和折射而產生內部發光現象,所以我們的第一步是計算“N·L”,利用我們已得到的 Thickness Δ值,我們可以得到一個明面為0,暗面為 Thickness 的遮罩。這也符合次表面散射的原理:光線從明面射入,所以我們可以從暗面觀察到次表面散射現象。
最后,利用遮罩將次表面散射顏色疊加到暗面上,我們的效果就已經出來了。
float sssMask = mix(deltaDepth, 0.0, NLdot);
vec3 sssGain = mix(vec3(0.0, 0.0, 0.0), sssColor, sssMask);
s.emissive += sssGain;
次表面散射確實是一個經久不衰的話題。毫無疑問,我們今天的成果還有許多可以糾正、改進和繼續發掘的地方。希望你讀到這里,已經激發了一些奇思妙想,開始動手制作屬于自己的 Cocos Creator 著色器了。
總結
以上是生活随笔為你收集整理的Cocos 实用渲染实战(一):高性价比的人物皮肤渲染的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: PS5独占游戏RETURNAL:华丽的黑
- 下一篇: CEDEC 2021 | 让巨大化角色充