前端 3D 渲染實戰:從零開始用 WebGL 編寫卡通風格着色器

作者|成文迪

編輯|孫瑞瑞

引言

隨着 Web 業務的拓展,我們有時也需要在網頁 /H5/ 小程序中渲染 3D 模型,WebGL 標準就有了用武之地。可以認爲 WebGL 是 OpenGL API 的 JS 版本,由瀏覽器廠商對接操作系統 api,實現瀏覽器環境裏使用 GPU 進行實時繪製。由於業務需要,筆者也對 WebGL 進行了一些學習。本文采用卡通渲染作爲例子,從零開始實現一個着色器,以加深對渲染管線的理解。

着色器是 3D 渲染流程中很重要的一環,引入了許多抽象概念,初學不易上手,而網上的資料大都是面向遊戲開發者的。因此,本文將從前端視角出發,緊密結合用例,從頭開始編寫一個卡通渲染着色器。如果你對渲染管線感興趣,但是從未讀懂過着色器代碼,面對成堆的矩陣座標和微積分公式頭暈腦脹,那麼本文可以作爲你學習着色器的入門參考。

卡通渲染(NPR)

卡通渲染又叫做非真實渲染(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 類,這是一種開放的材質,需要開發者自行傳入着色器代碼和參數。核心代碼如下:

cmCharacer.material = new THREE.ShaderMaterial({
        uniforms: {
      _MainTex: {
                value: new THREE.TextureLoader().load("./xxx_albedo.png")
            }
  },
        vertexShader: vertexShaderToon,
        fragmentShader: fragShaderToon
});

材質創建有三個入參,其中頂點着色器(vertexShader,vs)和片元着色器(fragmentShader,fs)分別傳入一個代碼片段,uniform 則傳入靜態參數。

理解着色器

頂點着色器——對模型的每個頂點調用一次。比如我們只畫一個三角形,需要創建三個頂點,那麼頂點着色器就會運行三次,運行次數和模型的複雜度有關。在頂點着色器中,可以訪問到頂點的三維座標、uv 座標、法向量等信息。這些都是我們模型的固有屬性,我們可以在頂點着色器裏,通過修改這些值,或者將其傳遞到片元着色器中。

片元着色器——對每個屏幕像素調用一次的程序。比如我們要把模型繪製在 200100 的畫布上,那麼片元着色器就會運行 200100 次,計算出每個像素最終的顏色。運行次數和模型複雜度無關,只和屏幕分辨率有關。

注意,上述調度執行策略都是渲染管線完成的,和傳統 Web 開發 “接管全程” 的理念不同,我們創建 shader 時,實際上做的是填空題。

理解 uniform

在 WebGL 標準下,着色器代碼使用 GLSL 語言編寫,這是一種專門的強類型語言,它編譯之後將直接運行在硬件裏。而 uniform 則是我們從 JS 向 GLSL 傳參的橋樑。在 JS 側傳入之後,在着色器一側,就能通過 uniform 聲明語句來取用這些參數。此外,這個模型固有的屬性(座標、uv、法向量等),也可以通過 attribute 變量取到。

注意,Threejs 的 ShaderMaterial 有一個屬性:

isRawMaterial = false

默認爲 false,Three 在組裝着色器的時候,會在開頭注入一些常用的常量和屬性,實際執行的代碼比傳入的多一些。如果設置爲 true,則不注入任何內容,完全交給開發者全權控制。這裏我們還是需要一些基礎的前置信息,所以不改變默認值。

基礎紋理採樣

我們的第一個目標是,從基礎紋理上採樣,並展示原始的顏色。這個過程相當於把貼圖素材沿着 uv 座標 “貼” 到模型上去。對應的着色器片段如下:

let vertexShaderToon = `
  varying vec2 vUv;
        void main()
        {
      vUv = uv;
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }`
let fragShaderToon = `  
  varying vec2 vUv;
        uniform sampler2D _MainTex;
        void main() {        
            // 計算基礎漫反射顏色
            vec3 albedoColor = texture2D(_MainTex, vUv).rgb;
            gl_FragColor = vec4( albedoColor, 1.0);
       }`

下面我們逐行理解這些代碼:

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 代碼如下:

cmCharacer.material = new THREE.ShaderMaterial({
        uniforms: {
    _MainTex: {
                     value: new THREE.TextureLoader().load("./xxx_albedo.png")
                 },
    light: {
                     value: new THREE.Vector3(0, 0, 100)
                },
  },
        vertexShader: vertexShaderToon,
        fragmentShader: fragShaderToon
    });
let vertexShaderToon = `
  varying vec2 vUv;
  varying vec3 viewLight;
  varying vec3 viewNormal;
  uniform vec3 light;
        void main()
        {
    // 直接傳遞給fs
    vUv = uv;
    viewLight = normalize(vec4(light, 1.0).xyz);
    // 轉換成視圖座標系,傳遞給fs
          viewNormal = normalize(normalMatrix * normal);
                gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }`;
let fragShaderToon = `  
  varying vec2 vUv;
  varying vec3 viewLight;
  varying vec3 viewNormal;
        uniform sampler2D _MainTex;
        void main() {        
            // 計算基礎漫反射顏色
            vec3 albedoColor = texture2D(_MainTex, vUv).rgb;
            // 計算卡通渲染下的階梯陰影
            float diffuse = dot(viewLight, viewNormal);
            if (diffuse > 0.7) {
                diffuse = 1.0;
            }
            else {
                diffuse = 0.5;
            }
      vec3 finalColor = albedoColor * diffuse;
            gl_FragColor = vec4( finalColor, 1.0);
  }`

首先增加一個變量 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,這樣繪製出的陰影就是硬陰影。還可以根據需要改成三值、四值。

最終的渲染結果如下:

繪製高光

接下來我們來畫高光,高光的計算方式和陰影有所不同,陰影只和光源方向有關,但高光是隨着視角變化的,可以讓模型看起來效果更豐富。

添加後的着色器代碼如下:

let vertexShaderToon = `
  varying vec2 vUv;
  varying vec3 viewLight;
  varying vec3 viewNormal;
  varying vec3 viewPosition;
  uniform vec3 light;
        void main()
        {
    // 直接傳遞給fs
    vUv = uv;
    viewLight = normalize(vec4(light, 1.0).xyz);
    // 轉換成視圖座標系,傳遞給fs
    viewNormal = normalize(normalMatrix * normal);
    viewPosition = ( modelViewMatrix * vec4(position, 1.0)).xyz;
                gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }`
let fragShaderToon = `  
  varying vec2 vUv;
  varying vec3 viewLight;
  varying vec3 viewNormal;
  varying vec3 viewPosition;
        uniform sampler2D _MainTex;
        void main() {        
            // 計算基礎漫反射顏色
            vec3 albedoColor = texture2D(_MainTex, vUv).rgb;
            // 計算卡通渲染下的階梯陰影
            float diffuse = dot(viewLight, viewNormal);
            if (diffuse > 0.7) {
                diffuse = 1.0;
            }
            else {
                diffuse = 0.5;
            }
      // 計算高光反射值(Phong模型)
            float shininessVal=1.0;
            vec3  specularColor = vec3(1.0, 1.0, 1.0);
            vec3 R = reflect(-viewLight, viewNormal);   // 計算光源沿法線反射後的方向
            vec3 V = normalize(-viewPosition); // 計算視線方向
            float specAngle = max(dot(R, V), 0.0); // 反射方向和視線方向的夾角(點積)即爲高光係數,越接近平行,高光越強烈
            float specularFactor = pow(specAngle, shininessVal);
            // 卡通渲染階梯化處理
            if (specularFactor > 0.8) {
                specularFactor = 0.5;
            }
            else {
                specularFactor = 0.0;
            }
     vec3 finalColor = albedoColor * diffuse;
     finalColor += specularColor * specularFactor;
           gl_FragColor = vec4( finalColor, 1.0);
     }`

結合註釋理解計算原理——高光由光源反射方向視線方向的夾角來決定。這個也很好理解,我們假設有一個光滑的物體(比如金屬頭盔)放在燈泡下,如果我們順着燈泡的方向看,就能看到很刺眼的亮斑,如果我們換個角度,亮斑也會隨之偏移,而且亮度變小了。這也是 Phong 氏光照模型的經典計算方式,雖然簡單粗暴,但效果還算準確。

這裏我們寫死了高光顏色爲白色,計算係數爲 1.0,夾角則是 R 和 V 兩個向量的點積。R 是光源 (viewLight) 沿法線 (viewNormal) 反射後的方向,使用內建函數 reflect 算出。V 是視線方向,是由 viewPosition 算出來的,而 viewPosition 是在 vs 裏,將頂點座標從模型座標系轉換到視圖座標系之後的結果。這裏和前文計算 gl_Position 的方式類似,但我們沒辦法直接用到內建變量,因此要額外定義一個 varing 來存儲它。

爲什麼視線方向是頂點座標取反呢?原理也很直觀,因爲視圖座標系下,我們的眼睛就是座標原點,視線方向就是從原點發射一條射線和頂點相連,它的值就是座標取反,因爲是方向向量,所以取反後還要歸一化處理。需要注意的是,R 和 V 的點積可能是負數(發生在靠近邊緣的頂點上),所以還需要對負值做一個裁減。裁減後再做階梯化,就得到了硬陰影的高光係數。

至此,我們的頂點顏色變成:

基礎色 + 陰影顏色 x 陰影係數 + 高光顏色 x 高光係數

繪製效果如圖:

(因爲免費模型精度較低,高光就顯得比較硬,感興趣的朋友可以換更細緻的模型進行嘗試。)

繪製邊緣光

雖然有陰影和高光,但整體效果看起來還是太死板了,所以下一步我們給它加上邊緣光。

Rim Lighting,也可以翻譯成輪廓光,作用是提亮模型邊緣,也是卡通渲染常用的一種手法。和視角光源、視角都有關係,但不論視角如何變化,照亮的都是模型當前的邊緣區域。我們看下給純黑色的兔子模型添加邊緣光的效果:

那如何判斷邊緣區域?方法也很直觀,前面我們已經計算出了視圖座標下的法線,我們知道越靠近邊緣的平面,法線和視線越接近垂直,夾角越大。所以我們只要計算法線方向和視線方向的點積,就能算出邊緣光係數。

代碼如下:

let vertexShaderToon = `
  varying vec2 vUv;
  varying vec3 viewLight;
  varying vec3 viewNormal;
  varying vec3 viewPosition;
  uniform vec3 light;
        void main()
        {
    // 直接傳遞給fs
    vUv = uv;
    viewLight = normalize(vec4(light, 1.0).xyz);
    // 轉換成視圖座標系,傳遞給fs
    viewNormal = normalize(normalMatrix * normal);
    viewPosition = ( modelViewMatrix * vec4(position, 1.0)).xyz;
                gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }`
let fragShaderToon = `  
  varying vec2 vUv;
  varying vec3 viewLight;
  varying vec3 viewNormal;
  varying vec3 viewPosition;
        uniform sampler2D _MainTex;
        void main() {        
            // 計算基礎漫反射顏色
            vec3 albedoColor = texture2D(_MainTex, vUv).rgb;
            // 計算卡通渲染下的階梯陰影
            float diffuse = dot(viewLight, viewNormal);
            if (diffuse > 0.7) {
                diffuse = 1.0;
            }
            else {
                diffuse = 0.5;
            }
      // 計算高光反射值(Phong模型)
            float shininessVal=1.0;
            vec3  specularColor = vec3(1.0, 1.0, 1.0);
            vec3 R = reflect(-viewLight, viewNormal);   // 計算光源沿法線反射後的方向
            vec3 V = normalize(-viewPosition); // 計算視線方向
            float specAngle = max(dot(R, V), 0.0); // 反射方向和視線方向的夾角(點積)即爲高光係數,越接近平行,高光越強烈
            float specularFactor = pow(specAngle, shininessVal);
            // 卡通渲染階梯化處理
            if (specularFactor > 0.8) {
                specularFactor = 0.5;
            }
            else {
                specularFactor = 0.0;
            }
     // 計算rim lighting
     vec3 rimColor = vec3(1.0, 0.0, 0.0);
     float rimFactor = 0.5;
     float rimWidth = 1.0;
     float rimAngle = max( dot(viewNormal, V), 0.0); // 視線方向和法線方向的夾角(點積),越接近垂直,越靠近模型邊緣
     float rimndotv =  max(0.0, rimWidth - rimAngle);
     vec3 finalColor = albedoColor * diffuse;
     finalColor += specularColor * specularFactor;
     finalColor += rimColor * rimndotv * rimFactor;
           gl_FragColor = vec4( finalColor, 1.0);
  }`

如果有需要,也可以把邊緣光進行階梯化,類似於二次元畫風裏常見的邊緣提亮。本文該例子就保持漸變的效果。

加入邊緣光後,顏色公式變成:

基礎色 + 陰影顏色 x 陰影係數 + 高光顏色 x 高光係數 + 邊緣光 x 邊緣光係數

繪製效果如下:

繪製描邊

可以看出,疊加三種光照之後,繪製效果已經初具雛形,我們繼續實踐另一種常用的技術——描邊。下面分別介紹描邊的三種方法:法線夾角法、法線膨脹法、卷積法。

法線夾角法

計算 rim lighting 時,我們通過法線可以提取出模型的邊緣,既然如此,我們把邊緣光二值化,並且改成黑色,不就完成描邊了麼?的確可以這麼做,但渲染結果可能並不令人滿意。首先我們的角色模型網格比較複雜,不同區域片元密度不一致,曲率不一致,如果直接用法線來算,得到的邊緣寬度不統一,效果就不好。

但法線夾角也有它的優勢,即計算簡單。無需引入額外步驟,只需要單次繪製流程,所以在渲染一些簡單的場景元素時,還是有用武之地的。

渲染是一門實用的技術,沒有好壞優劣之分,只有合不合適。開發者的工作,就是結合實際場景,在效率和效果之間尋找一個平衡點。從 Rim Lighting 稍作修改就能得到法線夾角法描邊,這裏就不再贅述,感興趣可以自行魔改。

法線膨脹法

首先回想一下我們在 Photoshop 等圖形編輯軟件裏,給 2D 圖像描邊的方式:首先選中模型,然後擴展選區 1px,新建一個圖層填充黑色,把它放在原圖層的下方,完成。

在 3D 場景裏,我們也可以做類似的事情,先讓模型膨脹一圈,膨脹的方向沿着頂點法線方向。然後將膨脹後的模型渲染爲全黑色,最後再渲染原始模型,蓋在黑色模型的上方,這就是法線膨脹法。

前面所有着色器裏,我們沒有修改過 vs 輸出的 gl_Position,但我們要渲染膨脹後的全黑模型,就必須修改這個值纔行,但這個值被修改之後,我們就沒辦法在 fs 裏繼續繪製原始圖像了。

怎麼辦?既然一次畫不完,那就改變渲染管線,連續畫兩次,把結果疊加在一起作爲一幀的輸出。這種技術在渲染領域被稱爲多 pass(兩次就是 2 pass)。

爲此我們需要建立一個新的 ShaderMaterial:

let cmOutline = new THREE.Mesh(new THREE.BufferGeometry().copy(cmCharacer.geometry));
cmOutline.material = new THREE.ShaderMaterial({
        uniforms: {
            offset: {
                type: 'f',
                value: 0.05  //偏移值
            },
            color: {
                value: new THREE.Color(0.0, 0.0, 0.0)
            },
        },
        vertexShader: vertexShaderOutline,
        fragmentShader: fragShaderOutline
});
let vertexShaderOutline = `
    uniform float offset;
    void main() {
      vec4 pos = modelViewMatrix * vec4( position + normal * offset, 1.0 );
      gl_Position = projectionMatrix * pos;
    }`
let fragShaderOutline = `
  uniform vec3 color;
    void main(){
      gl_FragColor = vec4( color, 1.0 );
    }`

着色器代碼比較簡單,pos 是原始頂點沿着法線方向膨脹 offset 寬度後的結果。

現在我們同時有 cmCharacer 和 cmOutline 兩個 mesh,我們要調整幀渲染流程,先畫 outline,再畫 character,並且手動將 renderer 的 autoClear 設置爲 false,避免前一次繪製的結果被清除掉。

  let scene = new THREE.Scene();
  scene.add(cmCharacer);
  let outlineScene = new THREE.Scene();
  outlineScene.add(cmOutline);
  renderer.autoClear = false;//防止渲染器在渲染每一幀之前自動清除其輸出
        renderer.render(outlineScene, camera);
        renderer.clearDepth();
        renderer.render(scene, camera);

兩次繪製之間我們插了一句 renderer.clearDepth(),用來清理深度緩存。那深度緩存又是什麼?所謂深度,就是頂點距離攝像機的距離,緩存就是用來保存這個數據的緩衝區,它可以表達出模型的互相遮擋關係。渲染器在執行着色的時候,爲了提高計算效率,會使用深度緩存進行裁切,把所有被擋住的頂點都忽略不算。

如果不清理深度緩存,那麼膨脹後的模型一定比原來的更靠前,深度數據夠小,相當於把原模型 “裹” 起來,那麼原模型的頂點都會被忽略掉,導致只能渲染一個黑影。清理之後,深度緩存被重置,原模型纔會疊加在陰影上,形成正確的結果。

這種描邊方式比前一種法線夾角法的效果好,但也存在問題,法線膨脹的結果並不能保證均勻。比如下圖法線突變的地方,膨脹後就會出現瑕疵。

卷積法

接下來,我們繼續開拓思路,描邊既然是針對每一幀渲染結果的,其實和模型的 3D 屬性已經沒有關係了,那我們能不能先把結果渲染出來,然後像處理 2D 圖像那樣,給它描個邊?答案是可以的。在渲染管線裏,這種技術叫做後處理(post process)。

引入後處理的渲染流程變得更加複雜了。我們還是分別處理描邊和本體。繪製描邊的時候,我們先用原始模型畫一張黑白二值圖,畫在輔助的離屏渲染器上。然後用卷積的方式,從二值圖裏把邊緣提取出來,再加到原始模型上。如下圖所示:

首先看提取邊緣的代碼:

let cmMask = new THREE.Mesh(new THREE.BufferGeometry().copy(cmCharacer.geometry));
cmMask.material = new THREE.ShaderMaterial({
        vertexShader: vertexShaderMask,
        fragmentShader: fragShaderMask,
        depthTest: false
});
let vertexShaderMask = `
    uniform float offset;
    void main() {
      gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    }`
let fragShaderMask = `
    void main(){
      gl_FragColor = vec4( 1.0, 1.0, 1.0, 1.0 );
    }`

關閉深度測試(depthTest)是因爲我們 fs 的邏輯非常簡單,沒有任何的矩陣操作,所以關掉深度緩存反而可以節約一些計算步驟和存儲空間。

接下來繼續修改渲染管線,創建一個 maskScene 來渲染,並且把 renderer 的渲染目標(target)設置到一個空 buffer 裏。

  let maskScene = new THREE.Scene();
  maskScene.add(cmMask);
  let maskBuffer = new THREE.WebGLRenderTarget(canvasW, canvasH, {
      minFilter: THREE.LinearFilter,
      magFilter: THREE.LinearFilter,
      format: THREE.RGBAFormat,
      antialias: true
    });
  renderer.autoClear = false;
  let oldRenderTarget = renderer.getRenderTarget();
        renderer.setRenderTarget(maskBuffer);
        renderer.clear();
        renderer.render(maskScene, camera);

執行上述代碼後,maskBuffer 就是我們想要的黑白二值圖。

我們再創建一個描邊對象,注意這裏不再使用 cmCharacter 的幾何數據,而是直接創建一個平面網格,寬高和視口 canvas 保持一致。

let cmEdge = new THREE.Mesh(new THREE.PlaneBufferGeometry(canvasW, canvasH));
cmEdge.material = new THREE.ShaderMaterial({
    vertexShader: vertexShaderEdge,
    fragmentShader: fragShaderEdge,
    depthTest: false,
    uniforms: {
        maskTexture: {
            value: maskBuffer.texture
        },
        texSize: {
            value: new THREE.Vector2(canvasW, canvasH)
        },
        color: {
            value: new THREE.Color(0.0, 0.0, 0.0)
        },
        thickness: {
            type: 'f',
            value: 1.6
        },
        transparent: true
    },
});
let vertexShaderEdge = `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
    }`;
let fragShaderEdge = `
    uniform sampler2D maskTexture;
    uniform vec2 texSize;
    uniform vec3 color;
    uniform float thickness;
    varying vec2 vUv;
    void main() {
        vec2 invSize = thickness / texSize;
    // 採用 Roberts 算子
        vec4 uvOffset = vec4(1.0, 0.0, 0.0, 1.0) * vec4(invSize, invSize);
    // 濾波器-2*2矩陣
        vec4 c1 = texture2D( maskTexture, vUv + uvOffset.xy);
        vec4 c2 = texture2D( maskTexture, vUv - uvOffset.xy);
        vec4 c3 = texture2D( maskTexture, vUv + uvOffset.yw);
        vec4 c4 = texture2D( maskTexture, vUv - uvOffset.yw);
    // r 只有 0/1 兩個值
        float diff1 = (c1.r - c2.r)*0.5;// 判斷x方向是否屬於邊緣
        float diff2 = (c3.r - c4.r)*0.5; // 判斷y方向是否屬於邊緣
        float d = length(vec2(diff1, diff2));
        gl_FragColor = d > 0.0 ? vec4(color, 1.0) : vec4(1.0, 1.0, 1.0, 0.0);
    }`;

關於卷積的基本原理,本文不再贅述,這裏採取的也是最基礎的卷積算法,感興趣的朋友可以自行查閱。

得到卷積結果後,我們繼續改造渲染管線。

  let edgeScene = new THREE.Scene();
  edgeScene.add(edgeObj);
  // edgeScene是一張平面,不應隨原始模型轉動,所以需要一個新的正交相機
  var edgeCamera = new THREE.OrthographicCamera(-canvasW / 2, canvasW / 2, canvasH / 2, -canvasH / 2, 0, 1);
  edgeCamera.position.z = 1;
  edgeCamera.lookAt(new THREE.Vector3());
  renderer.setRenderTarget(oldRenderTarget);
        renderer.render(edgeScene, edgeCamera);
        renderer.clearDepth();
        // 渲染原始圖像
        renderer.render(scene, camera);

把渲染目標(target)設置回來,然後採取 2 pass 的方式,先渲染 edgeScene,再渲染 scene,二者疊加,得到最終的結果。

這樣提取出的邊緣很清晰,而且在尖銳地方也不會產生突變瑕疵,效果圖如下:

此外它還有一個特點,不會隨着視角的遠近而改變粗細,而上一種法線膨脹法,因爲膨脹是基於模型座標系進行的,所以會有近大遠小的問題,如果想要得到遠近一致的邊緣,必須得把攝像機距離也傳入,對 offset 的取值做修正。

但卷積法也是三種方式裏步驟最複雜的,而且生成了中間紋理,因爲紋理座標要在兩次 drawCall 前後複用,就需要從 gpu 複製到顯存,再複製回去,這在實際生產環境中,會消耗顯存和帶寬,所以我們也要根據實際情況來進行取捨。

最後的繪製效果如圖:

總結

經過長長的流程,我們終於 “畫” 出了一個看上去還像那麼回事的結果。當然,上面的代碼僅僅是基本原理的簡化版本,真正的渲染技術要比這複雜得多。

舉個例子,細心的朋友可能發現了,我們的效果圖裏,面部區域並沒有疊加高光和陰影。如果我們依照和其他區域一樣的模式來疊加,會得到很糟糕的結果。

這裏並不是因爲算法 “錯” 了,而是因爲卡通風格的面部繪製本來就有特殊性,常常需要進行一些風格化處理,比如手動調整每個平面法線的值,人爲地讓表面變得更平滑。對於鼻子、眼睛、眉毛、頭髮等等的模型,也有很多特殊的處理技巧,需要開發人員和美術設計人員密切溝通,反覆調整,達到最佳的效果。

項目中的核心代碼都放在 GitHub 項目中,地址如下:

https://github.com/wendychengc/WebglToonShaderDemo

由於效果圖裏的模型加載和前處理比較複雜,爲了方便查看核心代碼,使用了一個更加精簡的模型。感興趣的朋友歡迎克隆下來研究,更歡迎與我交流討論,共同進步。

作者簡介

成文迪

騰訊科技有限公司 PCG  高級前端開發工程師

騰訊 T11 級高級前端工程師,QQ 團隊核心開發,曾負責騰訊文檔收集表、QQ 小程序、釐米秀等重要項目。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/eeLm0Rv2YIwyPOBk2mTI1A