隨着 Web 業務的拓展,我們有時也需要在網頁 /H5/ 小程序中渲染 3D 模型,WebGL 標準就有了用武之地。可以認為 WebGL 是 OpenGL API 的 JS 版本,由瀏覽器廠商對接操作系統 api,實現瀏覽器環境裡使用 GPU 進行實時繪製。由於業務需要,筆者也對 WebGL 進行了一些學習。本文採用卡通渲染作為例子,從零開始實現一個着色器,以加深對渲染管線的理解。
着色器是 3D 渲染流程中很重要的一環,引入了許多抽象概念,初學不易上手,而網上的資料大都是面向遊戲開發者的。因此,本文將從前端視角出發,緊密結合用例,從頭開始編寫一個卡通渲染着色器。如果你對渲染管線感興趣,但是從未讀懂過着色器代碼,面對成堆的矩陣坐標和微積分公式頭暈腦脹,那麼本文可以作為你學習着色器的入門參考。
卡通渲染又叫做非真實渲染(Non-Photorealistic Rendering,NPR),是和基於物理的渲染(Physically Based Rendering,PBR)相對的概念。
在傳統的 3D 渲染領域,採用基於物理的 PBR 渲染管線,可以依據物理公式,將材質的特性標準化,以貼近現實的風格進行繪製。但近年來手遊市場二次元風的盛行,產生了一個新的風格流派,不追求真實感,而是以貼近 2D 卡通的風格渲染繪製 3D 角色。例如塞爾達、罪惡裝備、原神等遊戲均屬於這一類。
注意,NPR 只是一系列技術的統稱,和標準化的 PBR 不同的是,它是一種高度風格化的渲染方式,可以擁有各種各樣的風格,但核心的技術有一些共通之處,包括但不限於以下幾項:
梯度漫反射(卡通風格陰影)
局部高光
邊緣光
描邊
本文將從零開始,基於 WebGL,使用 Threejs 組織數據,從零開始編寫 demo,逐個實現上述效果。
先放效果(使用的模型來自 sketchfab,在此感謝製作者的無償分享):
如果你是實時渲染領域的小白,那麼首先要對渲染管線有個簡單的認識。概括來說,從讀取模型數據到繪製的過程,可以用下面這張圖表示(摘自 OpenGL 官方文檔):
我們的 3D 幾何模型和材質貼圖,經過頂點着色,片元着色,最後轉化成二維的像素數據,繪製在屏幕中。因為處理過程是管道式的,所以稱之為渲染管線。
繪製是逐幀進行的,從模型數據準備完畢,發送一次渲染命令,到屏幕上展示出渲染結果,我們稱為一次繪製調用(drawCall)。
這條管線的大部分流程都是固定的,但頂點着色器和片元着色器是可編程的。這意味着我們可以自定義這兩部分的算法,通過「填空題」讓 3D 模型按照我們預期的方式映射成 2D 像素。
渲染引擎里的「材質」,指的就是着色器算法(包括頂點着色器和片元着色器)和數據(包括屬性數據和紋理貼圖)的集合。給同樣的幾何體應用不同的材質,就能渲染出不同的效果。
Demo 中使用模型比較簡單,只有基礎的幾何模型和基本顏色貼圖(又叫漫反射貼圖)。
首先我們需要引入角色的幾何模型(對常見模型格式,Threejs 都提供了加載器)。
讀取成功後,我們首先使用 Threejs 默認的網狀線材質進行渲染。
模型中囊括了頂點、法線、uv 等常用數據。下面的工作就是將材質替換為我們自定義的材質,也就是「着色」。場景需要光源,所以我們需要建立一個固定位置的點光源,不隨攝像機移動,後續傳進着色器使用。
下圖綠色小方塊表示光源的位置:
我們使用 Threejs 提供的 ShaderMaterial 類,這是一種開放的材質,需要開發者自行傳入着色器代碼和參數。核心代碼如下:
材質創建有三個入參,其中頂點着色器(vertexShader,vs)和片元着色器(fragmentShader,fs)分別傳入一個代碼片段,uniform 則傳入靜態參數。
頂點着色器——對模型的每個頂點調用一次。比如我們只畫一個三角形,需要創建三個頂點,那麼頂點着色器就會運行三次,運行次數和模型的複雜度有關。在頂點着色器中,可以訪問到頂點的三維坐標、uv 坐標、法向量等信息。這些都是我們模型的固有屬性,我們可以在頂點着色器里,通過修改這些值,或者將其傳遞到片元着色器中。
片元着色器——對每個屏幕像素調用一次的程序。比如我們要把模型繪製在 200*100 的畫布上,那麼片元着色器就會運行 200*100 次,計算出每個像素最終的顏色。運行次數和模型複雜度無關,只和屏幕分辨率有關。
注意,上述調度執行策略都是渲染管線完成的,和傳統 Web 開發「接管全程」的理念不同,我們創建 shader 時,實際上做的是填空題。
在 WebGL 標準下,着色器代碼使用 GLSL 語言編寫,這是一種專門的強類型語言,它編譯之後將直接運行在硬件里。而 uniform 則是我們從 JS 向 GLSL 傳參的橋樑。在 JS 側傳入之後,在着色器一側,就能通過 uniform 聲明語句來取用這些參數。此外,這個模型固有的屬性(坐標、uv、法向量等),也可以通過 attribute 變量取到。
注意,Threejs 的 ShaderMaterial 有一個屬性:
默認為 false,Three 在組裝着色器的時候,會在開頭注入一些常用的常量和屬性,實際執行的代碼比傳入的多一些。如果設置為 true,則不注入任何內容,完全交給開發者全權控制。這裡我們還是需要一些基礎的前置信息,所以不改變默認值。
我們的第一個目標是,從基礎紋理上採樣,並展示原始的顏色。這個過程相當於把貼圖素材沿着 uv 坐標「貼」到模型上去。對應的着色器片段如下:
下面我們逐行理解這些代碼:
gl_Position
gl_Position,是頂點着色器的輸出,表示每個頂點經過我們自定義的一系列計算之後,得到的結果,它是一個 vec4 類型,前三個值是 xyz 坐標浮點數,最後一個值是為了補齊矩陣行數而設置的固定值 1.0。我們寫任何一個頂點着色器,不管代碼多複雜,最後都需要設置這個值,渲染管線將在後續步驟中使用它。
projectionMatrix * modelViewMatrix
這是用來計算坐標變換的兩個矩陣。如何變換?首先我們要記住下面這個公式:
變換後的坐標 = 視口矩陣 x 投影矩陣 x 視圖矩陣 x 模型矩陣 x 模型點坐標
公式最左邊,是我們儲存的模型數據里所包含的坐標,它們是基於模型本身的坐標系的。把它放在場景里的時候,我們需要指定一個位置,可能還會指定一些縮放,旋轉等等,這樣一來,模型相對於場景的坐標系,就會有一個偏移,用模型矩陣(modelMatrix)來表示。而我們的攝像機也是有方向的,我們放置模型之後,切換觀察角度,看到的東西也不一樣,視口的偏移就用視圖矩陣(viewMatrix)來表示。
我們知道在一幀的繪製里,model 和 view 坐標是固定的,調用的瞬間,渲染管線就能計算出來,為了簡化計算,就把前面兩個矩陣合二為一,變成 modelViewMatrix。projectionMatrix 則是投影矩陣,和我們設置 camera 的屬性有關(我們知道 Threejs 可以設置正交相機和投影相機,後者會有一些近大遠小的效果),攝像機的屬性就通過投影矩陣(projectionMatrix)來表達。
視口矩陣則是從攝像機坐標繫到窗口(在 webgl 就是我們的 canvas 對象)的變換,通常只是做一些裁減的工作,所以在渲染管線里不必設置。
綜上所述,gl_Position 就是我們把模型的坐標(position)變換到視口坐標後的變換結果。前面說過頂點着色器的調用次數是頂點個數,所以每個坐標定點都被算了一遍。算出來的結果將被傳遞給片元着色器使用。
varying vec2 vUv
vs 和 fs 的前面都有這樣的聲明語句。varing 是變量類型,如果我們想在 vs 和 fs 之間傳遞信息,就需要聲明一個 varing 類型的值,在 vs 里設置而在 fs 里取用。這裡我們設置了 vUv,而它的值就是 uv,和 position 一樣是模型的固有屬性,這兩句代碼,相當於把每個頂點的 uv 坐標信息原封不動地透傳給 fs。
然後,我們再看片元着色器,它接收了 vs 傳入的 uv 坐標,除此之外還接收了一個參數 _MainTex,就是我們基礎的貼圖,是定義着色器的時候從 js 傳入的,你也可以換成喜歡的名字。
那 uniform 和 varing 的區別是什麼呢?我的理解是,uniform 在每次渲染前就傳入了,所以對於着色器來說,它是一個常量。實際運行時,渲染引擎會把我們傳入的紋理素材,從 CPU 拷貝到 GPU 的顯存里,然後再去執行代碼。而 varing 是在着色器運行過程中才算出來的,所以就是一個變量。
texture2D 是一個默認的採樣方法,通過頂點的 uv 坐標,去紋理貼圖上做採樣,取出對應的顏色值。
而 gl_FragColor,和上面的 gl_Position 類似,是片元着色器的輸出,表示經過一系列複雜計算後,每個像素最終的顏色值,它就是渲染管線最終繪製像素的依據。可以說我們所有着色器代碼,都是為了計算它。
這裡我們取了從 _Maintex 上採樣出來的顏色的 rgb 值,加上透明度 1.0,就得到了最終渲染用的顏色值,效果如圖:
可以看出,上面的渲染結果看起來還是扁平的,是因為我們還沒算光照,只是簡單採樣了紋理顏色而已。有光照才會有陰影,所以我們的下一步就是來計算最基礎的陰影。
修改後的 shader 代碼如下:
首先增加一個變量 light,把光源坐標(xyz)傳進去。
之後定義 varing viewLight,把光源坐標進行歸一化,補齊位數之後,透傳光源數據給 fs。
為什麼只進行了歸一化和補齊位數,但沒有進行坐標變換呢?因為我們希望場景里的光照是固定的,不會隨着模型、攝像機的坐標而移動。這樣轉動模型的時候,才便於觀察陰影的變化。
接下來定義 varing viewNormal,把頂點的法線坐標也傳遞給 fs,這裡就需要進行坐標變換了,把它乘以 normalMatrix,從模型坐標變換到視圖坐標。
為什麼不能繼續用 modelViewMatrix 呢?因為法向量表示的是一個方向,而頂點坐標表示的是一個位置,所以這兩個變換矩陣不一樣。
在 fs 里我們開始計算陰影,計算的依據——光照和模型法線的夾角。這裡的原理也非常直觀,我們看到物體的顏色和朝向有關係,而物體的朝向用法線表示,那麼法線和光源夾角越小,照射到表面上的光就越少,表面越暗。
通過 dot 方法進行點乘,即兩個向量的點乘表示夾角大小,結果是 0 和 1 之間的值。我們假設光照強度為 100%(點積為 0)的情況,則顯示原本的漫反射顏色,其餘情況則疊加一層陰影,將點積作為陰影權重係數 diffuse,就得到修正後的計算公式:
vec3 finalColor = albedoColor * diffuse;
這裡我們指定陰影的顏色就是黑灰色,所以用一位浮點數 diffuse 就可以表達了。但在實際情況中也可能用到美術指定的陰影色,隨漫反射的顏色變化,感興趣可以修改試試。
因為我們模型法線是光滑的,通過這種計算,得到的是一個漸變的軟陰影。但我們想要做一個硬陰影,就要把 diffuse 階梯化。小於閾值就取顏色 1,大於就取 0,這樣繪製出的陰影就是硬陰影。還可以根據需要改成三值、四值。
最終的渲染結果如下:
接下來我們來畫高光,高光的計算方式和陰影有所不同,陰影只和光源方向有關,但高光是隨着視角變化的,可以讓模型看起來效果更豐富。
添加後的着色器代碼如下:
結合注釋理解計算原理——高光由光源反射方向和視線方向的夾角來決定。這個也很好理解,我們假設有一個光滑的物體(比如金屬頭盔)放在燈泡下,如果我們順着燈泡的方向看,就能看到很刺眼的亮斑,如果我們換個角度,亮斑也會隨之偏移,而且亮度變小了。這也是 Phong 氏光照模型的經典計算方式,雖然簡單粗暴,但效果還算準確。
這裡我們寫死了高光顏色為白色,計算係數為 1.0,夾角則是 R 和 V 兩個向量的點積。R 是光源 (viewLight) 沿法線 (viewNormal) 反射後的方向,使用內建函數 reflect 算出。V 是視線方向,是由 viewPosition 算出來的,而 viewPosition 是在 vs 里,將頂點坐標從模型坐標系轉換到視圖坐標系之後的結果。這裡和前文計算 gl_Position 的方式類似,但我們沒辦法直接用到內建變量,因此要額外定義一個 varing 來存儲它。
為什麼視線方向是頂點坐標取反呢?原理也很直觀,因為視圖坐標系下,我們的眼睛就是坐標原點,視線方向就是從原點發射一條射線和頂點相連,它的值就是坐標取反,因為是方向向量,所以取反後還要歸一化處理。需要注意的是,R 和 V 的點積可能是負數(發生在靠近邊緣的頂點上),所以還需要對負值做一個裁減。裁減後再做階梯化,就得到了硬陰影的高光係數。
至此,我們的頂點顏色變成:
基礎色 + 陰影顏色 x 陰影係數 + 高光顏色 x 高光係數
繪製效果如圖:
(因為免費模型精度較低,高光就顯得比較硬,感興趣的朋友可以換更細緻的模型進行嘗試。)
雖然有陰影和高光,但整體效果看起來還是太死板了,所以下一步我們給它加上邊緣光。
Rim Lighting,也可以翻譯成輪廓光,作用是提亮模型邊緣,也是卡通渲染常用的一種手法。和視角光源、視角都有關係,但不論視角如何變化,照亮的都是模型當前的邊緣區域。我們看下給純黑色的兔子模型添加邊緣光的效果:
那如何判斷邊緣區域?方法也很直觀,前面我們已經計算出了視圖坐標下的法線,我們知道越靠近邊緣的平面,法線和視線越接近垂直,夾角越大。所以我們只要計算法線方向和視線方向的點積,就能算出邊緣光係數。
代碼如下:
如果有需要,也可以把邊緣光進行階梯化,類似於二次元畫風裡常見的邊緣提亮。本文該例子就保持漸變的效果。
加入邊緣光後,顏色公式變成:
基礎色 + 陰影顏色 x 陰影係數 + 高光顏色 x 高光係數 + 邊緣光 x 邊緣光係數
繪製效果如下:
可以看出,疊加三種光照之後,繪製效果已經初具雛形,我們繼續實踐另一種常用的技術——描邊。下面分別介紹描邊的三種方法:法線夾角法、法線膨脹法、卷積法。
計算 rim lighting 時,我們通過法線可以提取出模型的邊緣,既然如此,我們把邊緣光二值化,並且改成黑色,不就完成描邊了麼?的確可以這麼做,但渲染結果可能並不令人滿意。首先我們的角色模型網格比較複雜,不同區域片元密度不一致,曲率不一致,如果直接用法線來算,得到的邊緣寬度不統一,效果就不好。
但法線夾角也有它的優勢,即計算簡單。無需引入額外步驟,只需要單次繪製流程,所以在渲染一些簡單的場景元素時,還是有用武之地的。
渲染是一門實用的技術,沒有好壞優劣之分,只有合不合適。開發者的工作,就是結合實際場景,在效率和效果之間尋找一個平衡點。從 Rim Lighting 稍作修改就能得到法線夾角法描邊,這裡就不再贅述,感興趣可以自行魔改。
首先回想一下我們在 Photoshop 等圖形編輯軟件里,給 2D 圖像描邊的方式:首先選中模型,然後擴展選區 1px,新建一個圖層填充黑色,把它放在原圖層的下方,完成。
在 3D 場景里,我們也可以做類似的事情,先讓模型膨脹一圈,膨脹的方向沿着頂點法線方向。然後將膨脹後的模型渲染為全黑色,最後再渲染原始模型,蓋在黑色模型的上方,這就是法線膨脹法。
前面所有着色器里,我們沒有修改過 vs 輸出的 gl_Position,但我們要渲染膨脹後的全黑模型,就必須修改這個值才行,但這個值被修改之後,我們就沒辦法在 fs 里繼續繪製原始圖像了。
怎麼辦?既然一次畫不完,那就改變渲染管線,連續畫兩次,把結果疊加在一起作為一幀的輸出。這種技術在渲染領域被稱為多 pass(兩次就是 2 pass)。
為此我們需要建立一個新的 ShaderMaterial:
着色器代碼比較簡單,pos 是原始頂點沿着法線方向膨脹 offset 寬度後的結果。
現在我們同時有 cmCharacer 和 cmOutline 兩個 mesh,我們要調整幀渲染流程,先畫 outline,再畫 character,並且手動將 renderer 的 autoClear 設置為 false,避免前一次繪製的結果被清除掉。
兩次繪製之間我們插了一句 renderer.clearDepth(),用來清理深度緩存。那深度緩存又是什麼?所謂深度,就是頂點距離攝像機的距離,緩存就是用來保存這個數據的緩衝區,它可以表達出模型的互相遮擋關係。渲染器在執行着色的時候,為了提高計算效率,會使用深度緩存進行裁切,把所有被擋住的頂點都忽略不算。
如果不清理深度緩存,那麼膨脹後的模型一定比原來的更靠前,深度數據夠小,相當於把原模型「裹」起來,那麼原模型的頂點都會被忽略掉,導致只能渲染一個黑影。清理之後,深度緩存被重置,原模型才會疊加在陰影上,形成正確的結果。
這種描邊方式比前一種法線夾角法的效果好,但也存在問題,法線膨脹的結果並不能保證均勻。比如下圖法線突變的地方,膨脹後就會出現瑕疵。
接下來,我們繼續開拓思路,描邊既然是針對每一幀渲染結果的,其實和模型的 3D 屬性已經沒有關係了,那我們能不能先把結果渲染出來,然後像處理 2D 圖像那樣,給它描個邊?答案是可以的。在渲染管線里,這種技術叫做後處理(post process)。
引入後處理的渲染流程變得更加複雜了。我們還是分別處理描邊和本體。繪製描邊的時候,我們先用原始模型畫一張黑白二值圖,畫在輔助的離屏渲染器上。然後用卷積的方式,從二值圖里把邊緣提取出來,再加到原始模型上。如下圖所示:
首先看提取邊緣的代碼:
關閉深度測試(depthTest)是因為我們 fs 的邏輯非常簡單,沒有任何的矩陣操作,所以關掉深度緩存反而可以節約一些計算步驟和存儲空間。
接下來繼續修改渲染管線,創建一個 maskScene 來渲染,並且把 renderer 的渲染目標(target)設置到一個空 buffer 里。
執行上述代碼後,maskBuffer 就是我們想要的黑白二值圖。
我們再創建一個描邊對象,注意這裡不再使用 cmCharacter 的幾何數據,而是直接創建一個平面網格,寬高和視口 canvas 保持一致。
關於卷積的基本原理,本文不再贅述,這裡採取的也是最基礎的卷積算法,感興趣的朋友可以自行查閱。
得到卷積結果後,我們繼續改造渲染管線。
把渲染目標(target)設置回來,然後採取 2 pass 的方式,先渲染 edgeScene,再渲染 scene,二者疊加,得到最終的結果。
這樣提取出的邊緣很清晰,而且在尖銳地方也不會產生突變瑕疵,效果圖如下:
此外它還有一個特點,不會隨着視角的遠近而改變粗細,而上一種法線膨脹法,因為膨脹是基於模型坐標系進行的,所以會有近大遠小的問題,如果想要得到遠近一致的邊緣,必須得把攝像機距離也傳入,對 offset 的取值做修正。
但卷積法也是三種方式里步驟最複雜的,而且生成了中間紋理,因為紋理坐標要在兩次 drawCall 前後復用,就需要從 gpu 複製到顯存,再複製回去,這在實際生產環境中,會消耗顯存和帶寬,所以我們也要根據實際情況來進行取捨。
最後的繪製效果如圖:
經過長長的流程,我們終於「畫」出了一個看上去還像那麼回事的結果。當然,上面的代碼僅僅是基本原理的簡化版本,真正的渲染技術要比這複雜得多。
舉個例子,細心的朋友可能發現了,我們的效果圖裡,面部區域並沒有疊加高光和陰影。如果我們依照和其他區域一樣的模式來疊加,會得到很糟糕的結果。
這裡並不是因為算法「錯」了,而是因為卡通風格的面部繪製本來就有特殊性,常常需要進行一些風格化處理,比如手動調整每個平面法線的值,人為地讓表面變得更平滑。對於鼻子、眼睛、眉毛、頭髮等等的模型,也有很多特殊的處理技巧,需要開發人員和美術設計人員密切溝通,反覆調整,達到最佳的效果。
項目中的核心代碼都放在 GitHub 項目中,地址如下:
https://github.com/wendychengc/WebglToonShaderDemo
由於效果圖裡的模型加載和前處理比較複雜,為了方便查看核心代碼,使用了一個更加精簡的模型。感興趣的朋友歡迎克隆下來研究,更歡迎與我交流討論,共同進步。
成文迪
騰訊科技有限公司 PCG 高級前端開發工程師
騰訊 T11 級高級前端工程師,QQ 團隊核心開發,曾負責騰訊文檔收集表、QQ 小程序、厘米秀等重要項目。
今年 8 月,GMTC 全球大前端技術大會將再一次與你在線下相約。來自阿里、騰訊、字節等一線大廠的 50+ 技術專家現場分享,邀你一同探索大前端發展的最新技術趨勢與熱點方向。點擊底部【閱讀原文】查看已上線的演講議題,門票限時 9 折,搶占交流席位:+86 13269078023(同微信)。