1. 程式人生 > >WebGL+shader實現素描效果渲染

WebGL+shader實現素描效果渲染

實現一個這樣的渲染效果,主要的步驟包括:

  1. 準備模型和場景
  2. 通過 WebGL (Three.js) 匯入場景
  3. 實現 Shader 以表現接近素描的效果

在最重要的第 3 步中,我們要實現的主要有兩個效果:

  1. 模型邊緣的描邊 (不同於單純的線框)
  2. 模型表面類似於素描的線條效果

為了實現這樣的效果,我們實際並不能直接在單一的 3D 的空間上完成的,而需要另外準備一個二維場景用於合成。總體的渲染與合成流程如下:

PipelinePipeline

其中的 3D 場景,就是我們想要處理成素描效果的場景。這裡使用了一個小技巧,那就是我們並非直接將 3D 場景中的渲染效果輸出到螢幕,而是先將三種不同型別的渲染結果輸出到位於視訊記憶體中的 Buffer(Three.js 中的WebGLRenderTarget

) 裡。再在 2D 場景中合成這些輸出結果。

這個 2D 場景非常簡單,裡面只有一個恰好和視口大小一樣的矩形平面和一個非透視型別的 Camera,將我們從 3D 場景得到的不同型別的渲染圖作為矩形平面的貼圖,這樣我們就可以編寫 Shader來高效地處理合成效果了。最終輸出的結果其實是 2D 場景的渲染結果,但是觀看的人不會感覺到任何差異。

使用這樣一個簡單的 2D 場景進行後期合成可以說是一個非常常用的技巧,因為這樣可以通過 OpenGL 充分利用顯示卡的渲染效能。

準備場景

首先要做的工作是準備用來渲染的場景,選用的建模軟體當然是我最喜歡的 Blender。我參考BlenderNation 上刊登的一副

室內場景作品進行了仿製。我仿製的場景渲染結果如下:

SceneScene

選用這個場景的主要原因是場景的主體結構都非常簡單,大多數物體都可以通過簡單的立方體變換和修改而成。大量的平面也方便表現素描的效果。

建模的細節不再贅述。在這一階段還有一個主要的工序需要完成,那就是 UV 展開和陰影明暗的烘焙 (Bake)。

模型的 UV 展開實質上就是確定模型的貼圖座標與模型座標的對映關係。一個好的 UV 對映決定了模型渲染時貼圖的顯示效果。因為模型表面的素描效果實際是通過貼圖實現的,因此如果沒有一個好的 UV 對映,顯示出來的筆觸可能會出現扭曲、變形、粗細不一等各種問題。UV 展開可以說是一個非常繁瑣耗時的工序。最後為了減少工作量,我不得不刪除了一些比較複雜的模型。

我將場景中的所有模型合併為一個物體,並完成 UV 展開後的結果如下:

UV MappingUV Mapping

完成 UV 展開之後將會進行烘焙。所謂的烘焙 (Bake) 就是將模型在場景環境下的明暗變化、陰影等事先渲染並對映到模型的貼圖上。這個技術常用於靜態場景中。在這種靜態場景裡,燈光的位置和角度不會變化,只有攝像機的方向會改變。因此實際上物體的明暗陰影都是固定的,將其固定在貼圖中之後,使用 OpenGL 渲染時不再進行明暗處理和陰影生成。這樣可以節約大量的計算時間。而且使用 CPU 渲染的陰影往往可以使用更為複雜的演算法以獲得真實的效果。

Blender 的烘焙選項在 Render 選項卡的最下方,這裡選擇 Full Render 來將一切光源產生的明暗陰影都固定下來。

Bake PanelBake Panel

對照之前的 UV 展開,我烘焙出來的光影貼圖如下:

Room BakedRoom Baked

最後,使用 Three.js 提供的輸出外掛,將我們的場景輸出成 Three.js 可以識別的.json檔案。我輸出的模型檔案和相關貼圖都已經上傳到 GitHub 的倉庫裡。

這裡再為有興趣的同學推薦一個來自臺灣同胞的 Blender 基礎教程 (YouTube)。個人感覺是 Blender 的中文視訊教程中比較好的一個,雖然時間錄製早了些,但是講解很清晰。而且本文製作時使用的建模、UV 展開、貼圖和烘焙技巧都有介紹。

編寫 Shader

終於到了這篇文章的重中之重了,Shader 是通過 GPU 實現圖形渲染的核心,通過 OpenGL實現的任何 2D 或 3D 效果都離不開它。

一點點基礎知識

眾所周知, WebGL 使用的 Shader 語言其實是 OpenGL 的一個嵌入式版本OpenGL ES 所定義的,這一 Shader 語言使用了類似 C 語言的語法,但是有下面幾個區別:

  1. Shader 語言沒有動態分配記憶體的機制,所有記憶體 (變數) 的空間都是靜態分配的
  2. Shader 語言是強型別的,不同型別的數不能隱式轉換 (比如整形不能隱式轉換為浮點型)
  3. Shader 語言提供的一些資料結構,如向量型別vec2vec3vec4和矩陣型別mat2mat2mat4是直接可以使用加減乘除運算子進行操作的。

在 WebGL 中,我們可以自己編寫的 Shader 有兩種型別

  1. Vertex Shader: 模型的每個頂點上呼叫
  2. Fragment Shader: 模型三個頂點組成的面上顯示出來的每個畫素上執行

在渲染時,GPU 會先在每個頂點上執行 Vertex Shader,再在每個畫素上執行 Fragment Shader。Vertex Shader 主要用來計算每個定點投影在視平面上的位置,但是也可以用來進行一些顏色的計算並將結果傳送給 Fragment Shader。Fragment Shader 則決定了最終顯示出來的每個畫素的顏色。

接下來介紹 Shader 的變數修飾詞。Shader 的變數修飾詞可以分為 5 種:

  1. (無): 預設的變數修飾符,作用域只限本地
  2. const: 只讀常量
  3. attribute: 用來將每個節點的資料和 Vertex Shader 聯絡起來的變數,簡單來說就是在某一個頂點上執行Vertex Shader 時,變數的值就是這個頂點對應的值。這種對應關係是在初始化 WebGL 的程式時手動指定的。不過幸好 Three.js 已經為我們完成這一任務了。
  4. uniform: 這種型別的變數也是執行在 CPU 的主程式向 Shader 傳遞資料的一個途徑,主要用於與所處理的 Vertex 和 Fragment無關的值,比如攝像機的位置、燈光光源的位置方向等,這些引數在每一幀的渲染時都不變,因此使用uniform傳遞進來。
  5. varying: 用來從 Vertex Shader 向 Fragment Shader 傳遞資料的變數。在 Vertex Shader 和 Fragment Shader上定義相同變數名的varying變數,在執行時 Fragment Shader 中變數的值將會是組成這個面的三個頂點所提供的值的線性插值。

Three.js 已經為我們預設了必要的attributeuniform,預設變數列表可以參見文件

兩種 Shader 都有一個main函式,不過執行的引數並非通過main函式的引數傳入程式,輸出結果也不是通過main函式的返回值返回的。實際上,OpenGL 已經固定了每種 Shader 的預設輸入變數和輸出變數的名稱與型別,程式可以直接訪問和設定這些變數。當然,外部程式也可以通過attributeuniform機制來指定額外的輸入。

一個典型的 Vertex Shader 如下面的程式碼所示:

1
2
3
void main(void) {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

其中,positionprojectionMatrixmodelViewMatrix 這些變數都是 Three.js 預設設定好並傳遞進 Shader 的。positionattribute型別,它代表了每個 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
});

你可以通過設定uniformsattributes等引數向 Shader 傳遞資料,傳遞的格式文件中都有介紹。我們也是在這裡將 Shader 需要用到的 Texture 通過uniforms傳遞進去的。Texture 寫在 unifroms 裡的typetvalue可以是一個 Three.js 的Texture物件,也可以是WebGLRenderTarget

這裡只是將值傳遞了進去,你還是要在 Shader 原始碼裡自己宣告這些變數才能訪問他們,在 Shader 裡定義的名稱應該與你在 JavaScript 中給出的鍵名相同。

顯示模型的 Outline

模型的 Outline 就是在卡通風格的圖畫中圍繞在物體邊緣的線,因為卡通風格中物體的總體色調都比較平面化,所以需要這樣的線來強調物體與物體之間的區分。

實現這種 Outline 有兩種簡單直觀的方法:

  1. 使用深度作為特徵,將深度變化大的地方標記出來
  2. 使用表面法線的方向作為特徵,將發現變化大的地方標記出來

這兩種方法都各自有自己的缺點。比如深度特徵時,很容易將一個與觀察方向夾角比較小的面全部標記為黑色;而法線特徵時,又無法將前後兩個法線相近但是距離較遠的表面區分開。這裡參考另一篇相關內容的英文部落格Sketch Rendering 的方法來實現。

這種方法結合了深度和法線,假設有兩個點 A 和 B,通過計算 A 的空間位置到 B 的法線所構成的平面的距離作為衡量,判斷是否應該標記為 Outline。A 和 B 的空間位置則需要通過 A 和 B 的深度來計算出來。因此,我們需要先將我們的 3D 場景的深度和法線渲染圖輸出出來。

Three.js 已經提供了MeshDepthMaterialMeshNormalMaterial分別用來輸出深度和法線渲染圖。我們直接使用這兩個類就好了。假設我們已經初始化了一個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);

在輸出之前,別忘記使用rendererclearTarget函式將 Buffer 清空。如果將我們在這一步生成的貼圖顯示出來的話,大概是下面的樣子:

Depth & Normal TextureDepth & Normal Texture

生成素描筆觸

接下來就是在物體的表面生成繪製的素描線條效果了。這個方面其實比想象中更簡單一點,我們的素描效果是使用的是如下一系列貼圖組成的:

Hatching MapsHatching Maps

接下來的問題就是找一種方法將這種不同密度的貼圖融合在一起,這種問題被稱為 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 效果如下:

OutlineOutline

最後,將 Hatching 過程輸出的結果混合進來:

1
2
vec4 hatch = texture2D(hatchtexture, vUv);
gl_FragColor = vec4(vec3(hatch * edge), 1.0);

完整的實現可以參見我放在 GitHub上的原始碼

大功告成!最後的合成效果如圖:

Final ResultFinal Result

各位可以訪問我使用簡單添加了一點互動之後得到的 Live Demo(請使用支援 WebGL 的現代瀏覽器進行訪問,載入模型和全部貼圖可能需要一小會,請耐心等待)。

我實現的所有程式碼以及模型都已經以 BSD 協議釋出到 GitHub上了 (這裡)。

總結一下

雖然是作為我在學校一門課程的 Final Project 的一部分完成的專案,但是在這個過程中我總算是對於 Shader 的編寫方面有所入門。此外,這次進行 Blender進行建模也感覺比以前順利了許多。

雖然對 Blender 和 WebGL 的愛好現在看起來還沒有什麼現實價值,但是能夠自己完成一個有趣的 Project還是很有成就感的!