WebGL+shader實現素描效果渲染
實現一個這樣的渲染效果,主要的步驟包括:
- 準備模型和場景
- 通過 WebGL (Three.js) 匯入場景
- 實現 Shader 以表現接近素描的效果
在最重要的第 3 步中,我們要實現的主要有兩個效果:
- 模型邊緣的描邊 (不同於單純的線框)
- 模型表面類似於素描的線條效果
為了實現這樣的效果,我們實際並不能直接在單一的 3D 的空間上完成的,而需要另外準備一個二維場景用於合成。總體的渲染與合成流程如下:
其中的 3D 場景,就是我們想要處理成素描效果的場景。這裡使用了一個小技巧,那就是我們並非直接將 3D 場景中的渲染效果輸出到螢幕,而是先將三種不同型別的渲染結果輸出到位於視訊記憶體中的 Buffer(Three.js 中的WebGLRenderTarget
這個 2D 場景非常簡單,裡面只有一個恰好和視口大小一樣的矩形平面和一個非透視型別的 Camera,將我們從 3D 場景得到的不同型別的渲染圖作為矩形平面的貼圖,這樣我們就可以編寫 Shader來高效地處理合成效果了。最終輸出的結果其實是 2D 場景的渲染結果,但是觀看的人不會感覺到任何差異。
使用這樣一個簡單的 2D 場景進行後期合成可以說是一個非常常用的技巧,因為這樣可以通過 OpenGL 充分利用顯示卡的渲染效能。
準備場景
首先要做的工作是準備用來渲染的場景,選用的建模軟體當然是我最喜歡的
Blender。我參考BlenderNation 上刊登的一副
選用這個場景的主要原因是場景的主體結構都非常簡單,大多數物體都可以通過簡單的立方體變換和修改而成。大量的平面也方便表現素描的效果。
建模的細節不再贅述。在這一階段還有一個主要的工序需要完成,那就是 UV 展開和陰影明暗的烘焙 (Bake)。
模型的 UV 展開實質上就是確定模型的貼圖座標與模型座標的對映關係。一個好的 UV 對映決定了模型渲染時貼圖的顯示效果。因為模型表面的素描效果實際是通過貼圖實現的,因此如果沒有一個好的 UV 對映,顯示出來的筆觸可能會出現扭曲、變形、粗細不一等各種問題。UV 展開可以說是一個非常繁瑣耗時的工序。最後為了減少工作量,我不得不刪除了一些比較複雜的模型。
我將場景中的所有模型合併為一個物體,並完成 UV 展開後的結果如下:
完成 UV 展開之後將會進行烘焙。所謂的烘焙 (Bake) 就是將模型在場景環境下的明暗變化、陰影等事先渲染並對映到模型的貼圖上。這個技術常用於靜態場景中。在這種靜態場景裡,燈光的位置和角度不會變化,只有攝像機的方向會改變。因此實際上物體的明暗陰影都是固定的,將其固定在貼圖中之後,使用 OpenGL 渲染時不再進行明暗處理和陰影生成。這樣可以節約大量的計算時間。而且使用 CPU 渲染的陰影往往可以使用更為複雜的演算法以獲得真實的效果。
Blender 的烘焙選項在 Render 選項卡的最下方,這裡選擇 Full Render 來將一切光源產生的明暗陰影都固定下來。
對照之前的 UV 展開,我烘焙出來的光影貼圖如下:
最後,使用 Three.js 提供的輸出外掛,將我們的場景輸出成 Three.js 可以識別的.json
檔案。我輸出的模型檔案和相關貼圖都已經上傳到 GitHub 的倉庫裡。
這裡再為有興趣的同學推薦一個來自臺灣同胞的 Blender 基礎教程 (YouTube)。個人感覺是 Blender 的中文視訊教程中比較好的一個,雖然時間錄製早了些,但是講解很清晰。而且本文製作時使用的建模、UV 展開、貼圖和烘焙技巧都有介紹。
編寫 Shader
終於到了這篇文章的重中之重了,Shader 是通過 GPU 實現圖形渲染的核心,通過 OpenGL實現的任何 2D 或 3D 效果都離不開它。
一點點基礎知識
眾所周知, WebGL 使用的 Shader 語言其實是 OpenGL 的一個嵌入式版本OpenGL ES 所定義的,這一 Shader 語言使用了類似 C 語言的語法,但是有下面幾個區別:
- Shader 語言沒有動態分配記憶體的機制,所有記憶體 (變數) 的空間都是靜態分配的
- Shader 語言是強型別的,不同型別的數不能隱式轉換 (比如整形不能隱式轉換為浮點型)
- Shader 語言提供的一些資料結構,如向量型別
vec2
、vec3
、vec4
和矩陣型別mat2
、mat2
、mat4
是直接可以使用加減乘除運算子進行操作的。
在 WebGL 中,我們可以自己編寫的 Shader 有兩種型別
- Vertex Shader: 模型的每個頂點上呼叫
- Fragment Shader: 模型三個頂點組成的面上顯示出來的每個畫素上執行
在渲染時,GPU 會先在每個頂點上執行 Vertex Shader,再在每個畫素上執行 Fragment Shader。Vertex Shader 主要用來計算每個定點投影在視平面上的位置,但是也可以用來進行一些顏色的計算並將結果傳送給 Fragment Shader。Fragment Shader 則決定了最終顯示出來的每個畫素的顏色。
接下來介紹 Shader 的變數修飾詞。Shader 的變數修飾詞可以分為 5 種:
- (無): 預設的變數修飾符,作用域只限本地
const
: 只讀常量attribute
: 用來將每個節點的資料和 Vertex Shader 聯絡起來的變數,簡單來說就是在某一個頂點上執行Vertex Shader 時,變數的值就是這個頂點對應的值。這種對應關係是在初始化 WebGL 的程式時手動指定的。不過幸好 Three.js 已經為我們完成這一任務了。uniform
: 這種型別的變數也是執行在 CPU 的主程式向 Shader 傳遞資料的一個途徑,主要用於與所處理的 Vertex 和 Fragment無關的值,比如攝像機的位置、燈光光源的位置方向等,這些引數在每一幀的渲染時都不變,因此使用uniform
傳遞進來。varying
: 用來從 Vertex Shader 向 Fragment Shader 傳遞資料的變數。在 Vertex Shader 和 Fragment Shader上定義相同變數名的varying
變數,在執行時 Fragment Shader 中變數的值將會是組成這個面的三個頂點所提供的值的線性插值。
Three.js 已經為我們預設了必要的attribute
和uniform
,預設變數列表可以參見文件。
兩種 Shader 都有一個main
函式,不過執行的引數並非通過main
函式的引數傳入程式,輸出結果也不是通過main
函式的返回值返回的。實際上,OpenGL 已經固定了每種 Shader 的預設輸入變數和輸出變數的名稱與型別,程式可以直接訪問和設定這些變數。當然,外部程式也可以通過attribute
和uniform
機制來指定額外的輸入。
一個典型的 Vertex Shader 如下面的程式碼所示:
1 2 3 |
void main(void) { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } |
其中,position
、projectionMatrix
、modelViewMatrix
這些變數都是 Three.js 預設設定好並傳遞進 Shader 的。position
是attribute
型別,它代表了每個 Vertex 在 3D 空間中的座標,另外兩個變數是uniform
,是 Three.js根據場景的屬性而設定的。gl_Position
就是 OpenGL 指定的 Vertex Shader 的輸出值。
一個典型的 Vertex Shader 是通過給出的頂點position
,以及相關的一些變換投影矩陣,計算出這個頂點做透視投影后顯示在螢幕中的 2D 座標。因此在這裡也可以實現各種透視效果,如常見的投影透視 (近大遠小)、平視透視 (遠近一樣大),甚至超現實的反投影透視 (近小遠大) 等。
Fragment Shader 的主要用處是確定某個畫素的顏色,其已經指定的輸出值為gl_FragColor
,這是一個vec4
型別的變數,代表了 RGBA 型別的顏色表示,為每一個表面輸出白色的 Fragment Shader 如下:
1 2 3 |
void main(void) { gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0); } |
除了直接計算顏色,還可以通過貼圖 (texture) 來確定某個 Fragment 的顏色。在 WebGL 中,貼圖是通過uniform
的方式傳遞進Shader 裡的,其型別是sample2D
。隨後,我們可以使用texture2D(texture, uv)
函式獲得某一個畫素的顏色,這裡的uv
是一個二維向量,可以通過 Vertex Shader 獲得。
在 Three.js 實現訪問貼圖的一個簡單的例子是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// Vertex Shader varying vUv; void main(void) { gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); vUv = uv; } // Fragment Shader uniform sample2D aTexture; varying vUv; void main(void) { gl_FragColor = texture2D(aTexture, vUv); } |
在 Vertex Shader 中使用的uv
變數,也是 Three.js 中已經提供好的attribute
。接下來就是在 Three.js 中使用 Shader 的方法了。
在 Three.js 中使用 Shader
Three.js 提供了ShaderMaterial
用於實現自定義 Shader 的Material。下面是一個來自其官方文件的例子。
1 2 3 4 5 6 7 8 9 10 11 |
var material = new THREE.ShaderMaterial( { uniforms: { time: { type: "f", value: 1.0 }, resolution: { type: "v2", value: new THREE.Vector2() } }, attributes: { vertexOpacity: { type: 'f', value: [] } }, vertexShader: document.getElementById( 'vertexShader' ).textContent, fragmentShader: document.getElementById( 'fragmentShader' ).textContent }); |
你可以通過設定uniforms
和attributes
等引數向 Shader 傳遞資料,傳遞的格式文件中都有介紹。我們也是在這裡將 Shader 需要用到的 Texture 通過uniforms
傳遞進去的。Texture 寫在 unifroms 裡的type
是t
,value
可以是一個 Three.js 的Texture
物件,也可以是WebGLRenderTarget
。
這裡只是將值傳遞了進去,你還是要在 Shader 原始碼裡自己宣告這些變數才能訪問他們,在 Shader 裡定義的名稱應該與你在 JavaScript 中給出的鍵名相同。
顯示模型的 Outline
模型的 Outline 就是在卡通風格的圖畫中圍繞在物體邊緣的線,因為卡通風格中物體的總體色調都比較平面化,所以需要這樣的線來強調物體與物體之間的區分。
實現這種 Outline 有兩種簡單直觀的方法:
- 使用深度作為特徵,將深度變化大的地方標記出來
- 使用表面法線的方向作為特徵,將發現變化大的地方標記出來
這兩種方法都各自有自己的缺點。比如深度特徵時,很容易將一個與觀察方向夾角比較小的面全部標記為黑色;而法線特徵時,又無法將前後兩個法線相近但是距離較遠的表面區分開。這裡參考另一篇相關內容的英文部落格Sketch Rendering 的方法來實現。
這種方法結合了深度和法線,假設有兩個點 A 和 B,通過計算 A 的空間位置到 B 的法線所構成的平面的距離作為衡量,判斷是否應該標記為 Outline。A 和 B 的空間位置則需要通過 A 和 B 的深度來計算出來。因此,我們需要先將我們的 3D 場景的深度和法線渲染圖輸出出來。
Three.js 已經提供了MeshDepthMaterial
和MeshNormalMaterial
分別用來輸出深度和法線渲染圖。我們直接使用這兩個類就好了。假設我們已經初始化了一個depthMaterial
和一個normalMaterial
,那麼將整個場景裡的物體都用某一個 Material 進行渲染的話,我們可以使用
1 |
objectScene.overrideMaterial = depthMaterial; // 或 normalMaterial
|
這樣的方法實現。
此外,我們不希望渲染結果直接輸出到螢幕,因此我們需要先新建一個 WebGLRenderTarget
作為一個 FrameBuffer 來存放結果。此後這個WebGLRenderTarget
可以直接作為貼圖傳入用於合成的 2D 場景。
1 2 3 4 5 6 7 8 9 |
var pars = { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBFormat, stencilBuffer: false } var depthTexture = new THREE.WebGLRenderTarget(width, height, pars) var normalTexture = new THREE.WebGLRenderTarget(width, height, pars) |
使用下面的程式碼,將渲染結果輸出到 FrameBuffer 裡:
1 2 3 4 5 6 7 8 9 10 11 |
// render depth objectScene.overrideMaterial = depthMaterial; renderer.setClearColor('#000000'); renderer.clearTarget(depthTexture, true, true); renderer.render(objectScene, objectCamera, depthTexture); // render normal objectScene.overrideMaterial = normalMaterial; renderer.setClearColor('#000000'); renderer.clearTarget(normalTexture, true, true); renderer.render(objectScene, objectCamera, normalTexture); |
在輸出之前,別忘記使用renderer
的clearTarget
函式將 Buffer 清空。如果將我們在這一步生成的貼圖顯示出來的話,大概是下面的樣子:
生成素描筆觸
接下來就是在物體的表面生成繪製的素描線條效果了。這個方面其實比想象中更簡單一點,我們的素描效果是使用的是如下一系列貼圖組成的:
接下來的問題就是找一種方法將這種不同密度的貼圖融合在一起,這種問題被稱為 Hatching。這裡使用的 Hatching 方法是 MicroSoft Research在 2001 年發表的一篇論文中給出的。
不同於原文中使用 6 張貼圖合成的方法,這裡採用了使用 3 張貼圖合成,然後將貼圖旋轉 90 度再合成一次,從而獲得交叉的筆劃。
1 2 3 4 5 6 7 |
void main() { vec2 uv = vUv * 15.0; vec2 uv2 = vUv.yx * 10.0; float shading = texture2D(bakedshadow, vUv).r + 0.1; float crossedShading = shade(shading, uv) * shade(shading, uv2) * 0.6 + 0.4; gl_FragColor = vec4(vec3(crossedShading), 1.0); } |
shade
函式就是用合成多個貼圖的函式,具體程式碼可以參見 GitHub上的這個檔案。可以注意到,我其實使用了之前 bake 出來的明暗來作為素描線條深淺的參考因素,這樣就可以表現出明暗和陰影了。
最後的合成
最後就是要在我們的二維場景裡進行最後的合成了。構造這樣一個二維場景的程式碼很簡單:
1 2 3 4 |
var composeCamera = new THREE.OrthographicCamera(-width / 2, width / 2, height / 2, -height / 2, -10, 10); var composePlaneGeometry = new THREE.PlaneBufferGeometry(width, height); composePlaneMesh = new THREE.Mesh(composePlaneGeometry, composeMaterial); composeScene.add(composePlaneMesh); |
場景的主要構造就是一個和視口一樣大小的矩形幾何體,攝像機則是一個OrthographicCamera
,這種攝像機沒有透視效果,正合適用於我們這種合成的需求。
將前幾步輸出到 FrameBuffer (也就是WebGLRenderTarget
) 的結果作為這個矩形表面的貼圖,然後我們編寫一個 Shader 來進行合成。
這一次,我們不再需要輸出到 Buffer 上,而是直接輸出到螢幕。而 Outline 的生成也是在這一步完成的。用來計算 Outline 的函式是:
1 2 3 4 5 6 |
float planeDistance(const in vec3 positionA, const in vec3 normalA, const in vec3 positionB, const in vec3 normalB) { vec3 positionDelta = positionB-positionA; float planeDistanceDelta = max(abs(dot(positionDelta, normalA)), abs(dot(positionDelta, normalB))); return planeDistanceDelta; } |
在當前座標周圍取一個十字形的取樣,對於上下和左右取出的點分別執行上面的函式,最後使用smoothstep
來獲得 Outline 的顏色:
1 2 3 4 5 |
vec2 planeDist = vec2( planeDistance(leftpos, leftnor, rightpos, rightnor), planeDistance(uppos, upnor, downpos, downnor)); float planeEdge = 2.5 * length(planeDist); planeEdge = 1.0 - 0.5 * smoothstep(0.0, depthCenter, planeEdge); |
在最後實現的版本里,我還嘗試了再混入法線方式生成的邊緣線的效果。最終生成的 Outline 效果如下:
最後,將 Hatching 過程輸出的結果混合進來:
1 2 |
vec4 hatch = texture2D(hatchtexture, vUv); gl_FragColor = vec4(vec3(hatch * edge), 1.0); |
完整的實現可以參見我放在 GitHub上的原始碼。
大功告成!最後的合成效果如圖:
各位可以訪問我使用簡單添加了一點互動之後得到的 Live Demo(請使用支援 WebGL 的現代瀏覽器進行訪問,載入模型和全部貼圖可能需要一小會,請耐心等待)。
我實現的所有程式碼以及模型都已經以 BSD 協議釋出到 GitHub上了 (這裡)。
總結一下
雖然是作為我在學校一門課程的 Final Project 的一部分完成的專案,但是在這個過程中我總算是對於 Shader 的編寫方面有所入門。此外,這次進行 Blender進行建模也感覺比以前順利了許多。
雖然對 Blender 和 WebGL 的愛好現在看起來還沒有什麼現實價值,但是能夠自己完成一個有趣的 Project還是很有成就感的!