卡通渲染(下)

原文鏈接:zhangwenli.com/blog/201

上一篇教程介紹了如何實現卡通渲染的著色器,收到了不少的點贊和回復,希望你們不都是「馬後讀」的~

但是,居然有讀者表示——

這渲染結果一點都不真實啊!

我就不服了,這就好比你去吐槽卡通片畫面不真實一樣……

渲染一方面的研究工作在追求渲染的真實性,旨在還原人眼在現實中看到的視覺效果。所以有了基於物理的渲染(Physically Based Rendering)、照片級真實感渲染(Photorealistic Rendering)之類的概念,本質上都是在還原現實。而另一方面,諸如卡通渲染之類的非真實感渲染(Non-photorealistic Rendering,NPR)則在尋求另一條出路。通過遠離現實的、抽象的、藝術感的形式,更有力地表達並彰顯內容的主題。而卡通渲染只是非真實感渲染中一個很小的子集。

正如任何一門藝術,在現實主義者盡態極妍地還原我們所處世界的同時,形式主義者總會適時地帶我們逃離一會兒現實,緩一口氣。

因而,即使在真實感渲染能力很強的時代,非真實感渲染也有其不可替代的作用。

哎,感覺我又回到了寫論文的時代……

好了,我們趕緊進入正題,來談談如何為我們的卡通渲染加上描邊效果。下圖是本文實現的結果:

本文實現的結果可以在 zhangwenli.com/cezanne 運行,或者在 GitHub 查看源碼。

描邊的原理

首先,我們要理解描邊的作用。

通常而言,描邊是為了增加對比,將物體與背景更強烈地隔離開。對於我們在上篇文章中實現的卡通渲染而言,物體漸變的顏色被設為幾種階梯式改變的顏色,這也是為了增加對比性。因而在這樣的渲染效果中,添加描邊可以讓對比來得更加強烈。

那麼,如何實現描邊呢?通常的演算法分為這樣幾類——

  • 判斷面片朝向,找到正反面交匯處的邊;
  • 將面片沿法向量方向放大,渲染作為描邊之後,再次渲染模型;
  • 將深度緩衝區或法向量繪製到一張臨時紋理,用圖像處理的方法,找到突變的地方作為邊緣;
  • 將法向量接近平行屏幕所在平面的點作為邊緣。

本文實現的是最後一種方法,因為它只需要渲染一次,而且只需要在著色器中做修改。

下面,我們將分別介紹這幾種演算法的原理。

面片朝向

讓我們用一個簡單的例子來說明。下圖的 (a) 顯示的是一個金字塔型的模型,它由六個三角形組成(底部的四邊形由兩個三角形組成)。

該演算法的偽代碼是:

遍歷模型中的每個三角形:n 計算三角形是正面還是反面nn遍歷模型中的每條邊:n 如果某條邊既包含在正面的三角形中,又包含在反面的三角形中:n 將這條邊作為描邊繪製n

需要注意的是,以上過程是在 CPU 中計算的(而不是著色器),該演算法可以使用非常基本的 OpenGL 操作實現。

那麼,如何「計算三角形是正面還是反面」呢?

前向面與後向面

上面我們說的「前面」和「反面」,是較為通俗易懂的說法,而它們嚴格的名稱為:前向面(front face)與後向面(back face)。檢測一個面是前向面還是後向面,是非常經典的圖形學問題。這裡,我還是用比較方便大家了解的方式介紹。

圖片來源:《計算機圖形學(第三版)》,電子工業出版社,第 109 頁

我們高中的時候學過,在笛卡爾坐標系下,任何一個平面可以描述為 Ax + By + Cz + D = 0,而向量 (A, B, C) 正好是該平面的一個法向量。

推導過程我這裡就不展開了,大家可以很方便地這樣記住:

如果將 D 設為 0 讓平面通過原點,那麼原點到平面上的任意一點 (x, y, z) 所形成的向量 (x, y, z) 必然垂直於法向量 (A, B, C)(因為如果一條直線垂直於一個平面,則它垂直於平面上任何一條直線)。這時候的平面方程 Ax + By + Cz = 0 正好是向量 (x, y, z) 與 (A, B, C) 點乘的結果。而我們知道,如果兩個向量垂直,他們的點積為 0。這是一個方便記憶的方式。

當我們要計算一個凸多邊形(在我們的例子中就是一個三角形)所在的平面時,我們只要知道其法向量就行了(這裡 D 對朝向沒有影響,所以可以不管)。

那麼,知道一個三角形的三個頂點,如何計算其法向量呢?

還是高中數學問題。答案是——

N = (V2 - V1) ? (V3 - V2)n

其中,N 是法向量,V1、V2、V3 是三角形的三個頂點的位置,? 表示叉乘。需要注意的是,叉乘是有方向的區別的,在 OpenGL 中繪製三角形輸入的三個頂點的順序決定了面片的朝向,因而寫成 (V2 - V1) ? (V2 - V3) 的話,會得到相反的方向。

而一旦我們計算出法向量之後,就很容易判斷出前後面了。

圖片來源:《計算機圖形學(第三版)》,電子工業出版社,第 432 頁

如果這張圖,我們可以很容易地理解,當法向量 N 與觀察方向 Vview 點乘大於 0(也就是說法向量在觀察方向上的投影是正的),它對觀察者來說,就是位於反面的,也就是一個後向面。

優劣分析

明白了前後向面的判定演算法之後,這一演算法是很好理解,也是很容易實現的。

該演算法的優點在於,由於是用 OpenGL 畫線的方式實現的描邊,因而線寬是可控而且等寬的。

缺點在於,這些操作都是在 CPU 中完成的,有很多點乘叉乘操作,性能並不會很好。並且,它最終的繪製結果如下圖 (d) 所示,加粗的黑線表示描邊。可以看到,只有周圍一圈的輪廓線(silhouette)被描繪了,而一些其他的邊緣則被忽視了。

在分析優劣的時候,我們要明白,優劣的區別常常取決於應用場景。比如,在有的情況下,等寬的描邊是優點,在另一些情況下,則可能不是。甚至性能也不總是越快的越好,有時候較慢的演算法已經超出了可察覺的範圍,更高的性能帶來的增益是可以忽略的。所以,理解各個演算法的特性,對在特定場景下的選擇有很大幫助。

沿法向量放大

這一演算法解釋起來就容易得多了。它的核心思想是,將每個頂點沿法線方向延伸(如圖 b),然後填充放大的模型(如圖 c,並且通常只填充後向面),在此基礎上,再繪製原始模型疊加上去(如圖 d)。

這一演算法可以在著色器中實現,但是需要兩次渲染。第一次在頂點著色器中延伸頂點,並將頂點顏色設為描邊的顏色。第二次正常渲染,並且渲染前不要清除已經繪製的結果。

優劣分析

該演算法可以在著色器中實現,效率較高,實現方式也比前一種直觀方便。

缺點是,和第一種演算法一樣,只有前後面交界處才會被描邊;每幀需要渲染兩次;描邊粗細不完全是相同的。

為什麼可能粗細不同呢?讓我們通過一個更簡單的例子來理解一下。

假設我們的原始模型是圖 (a) 的直角三角形,三個頂點沿法線方向延伸相同的長度,得到圖 (c) 的結果。可以發現,不僅圖 (b) 代表的描邊粗細是不相等的,而且放大後的模型都失去了原有的直角屬性。

所以,使用這種方法描邊,得到的邊緣可能是變形的。

圖像處理

使用圖像處理的方式,可以將法向量或深度緩衝區繪製到一張紋理,然後通過邊緣檢測演算法,得到畫面中法向量和深度突變的地方,通常這些可以作為邊緣描繪。

上圖展示了深度緩衝區的大小,對應的片元著色器是:

void main()n{n float zbuffer = gl_FragCoord.z * gl_FragCoord.w * 100.0;n gl_FragColor = vec4(zbuffer, zbuffer, zbuffer, 1.0);n}n

其中,gl_FragCoord.z 是深度信息,gl_FragCoord.w 是縮放因子,乘以 100.0 是為了將深度信息縮放到一個合適的顏色顯示,通常需要根據場景的深度進行試驗得到。

用 Canny 邊緣檢測演算法,能夠得到一個非常理想的邊緣——

這一演算法的效果優劣,主要取決於邊緣檢測演算法。而如果模型比較複雜的情況下,可能就沒有上圖這麼好的結果。

優劣分析

通過邊緣檢測獲得的邊緣,具有很大的不確定性,有可能得到噪點很多或者沒有檢測到邊緣的情況。如果邊緣檢測能夠在著色器中做的話,效率會更高些。

平行屏幕方向的法向量

下面要介紹的這個演算法,就是我們實際應用到塞尚項目中的。為什麼用這種演算法呢?因為它只需要修改著色器就能實現效果,實現起來是最方便的,所以我就偷懶只實現了這個效果。(但是前面幾種演算法的示意圖畫得超清楚有木有!)

這一演算法的思想是,在片元著色器中,根據視覺坐標系下的法向量,找到平行屏幕的片元,作為邊緣。片元著色器代碼如下:

varying vec3 vNormal;nnvoid main() {n float silhouette = length(vNormal * vec3(0.0, 0.0, 1.0));n if (silhouette < 0.5) {n silhouette = 0.0;n }n else {n silhouette = 1.0;n }nn gl_FragColor = vec4(silhouette, silhouette, silhouette, 1.0);n}n

其中,vNormal 是頂點著色器中傳遞過來的法向量(在上一篇教程中有介紹);vec3(0.0, 0.0, 1.0)是垂直於屏幕的方向,也就是視圖坐標系下的視角方向。vNormal * vec3(0.0, 0.0, 1.0) 是將法向量和視角方向進行點乘,得到法向量在視角方向上的投影。length() 得到該點乘結果的模長,如果它較小,代表法向量在視角方向上的投影較小,也就是法向量較接近於平行屏幕的方向。

得到的效果如下:

優劣分析

使用這種方法,能夠得到第一、第二種演算法所忽視的不在前後面交界處的邊,這有時是比較有用的。它的原理和實現都比較簡單,能夠在著色器中高效地計算。

缺點在於,需要指定一個閾值,然而對於不同場景,可能都需要調節這個閾值以達到更好的渲染效果,因而就有些不缺點性。並且,就像上面蘋果的渲染結果顯示的那樣,有時候平行屏幕的法向量,並不意味著邊緣,因而會在梗的根本有一些不太理想的邊緣。

至於這種演算法形成的粗細不同的描邊,和卡通渲染的效果結合起來,倒也是蠻搭的呢!

結合卡通渲染

結合的方法是,如果一個片元是邊緣,則按邊緣色渲染,否則渲染卡通渲染的結果。

頂點著色器代碼:

uniform vec3 color;nuniform vec3 light;nnvarying vec3 vColor;nvarying vec3 vNormal;nvarying vec3 vLight;nnvoid main()n{n // pass to fsn vColor = color;n vNormal = normalize(normalMatrix * normal);nn vec4 viewLight = viewMatrix * vec4(light, 1.0);n vLight = viewLight.xyz;nn gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);n}n

片元著色器代碼:

varying vec3 vColor;nvarying vec3 vNormal;nvarying vec3 vLight;nnvoid main() {n float silhouette = length(vNormal * vec3(0.0, 0.0, 1.0));n if (silhouette < 0.5) {n silhouette = 0.0;n }n else {n silhouette = 1.0;n }nn float diffuse = dot(normalize(vLight), vNormal);n if (diffuse > 0.8) {n diffuse = 1.0;n }n else if (diffuse > 0.5) {n diffuse = 0.6;n }n else if (diffuse > 0.2) {n diffuse = 0.4;n }n else {n diffuse = 0.2;n }n diffuse = diffuse * silhouette;nn gl_FragColor = vec4(vColor * diffuse, 1.0);n}n

最終得到的結果是:

小結

這篇博客介紹了如何實現描邊演算法。總體而言,通過判斷面片朝向和沿法向量放大的方法較為穩定,但是不能得到除了輪廓線之外的邊緣;而基於圖像處理和法向量方向的方法具有一些不確定性,但是能夠得到輪廓線之外的邊緣,並且通常來說,計算效率更高。

對於具體的應用場景,可以根據各自的優劣選擇合適的演算法。

另外,因為寫這一系列教程,讓我也溫故知新了很多圖形學知識。而把這個項目取名為「塞尚」,也是希望能夠堅持寫下去,把一件簡單的事做細做明白,謝謝大家的支持!

本文實現的結果可以在 zhangwenli.com/cezanne 運行,或者在 GitHub 查看源碼。

參考資料

  • 《計算機圖形學》:這是一本介紹非常底層的圖形學實現原理的書,可能很大情況下你並不需要了解這些細節,但是如果感興趣的話,這本書會幫你解答很多疑惑。
  • how to show silhouette using GLSL
  • WebGL+shader實現素描效果渲染

推薦閱讀:

Python · cv2(一)· 神經網路的可視化
Python數據分析之簡書七日熱門數據分析
Matplotlib 蠟燭圖教程
WebGL Earth颱風監測web應用
有誰可以說說「燈光遙感」?

TAG:可视化 | 计算机图形学 | WebGL |