技術之路

站在代碼以外看技術

應用 WebGL 完成素描後果的襯著

此次來引見一下我比來剛完成的一個小玩藝兒:經由過程 WebGL 在網頁上顯示一個素描作風的場景。

歡迎先應用支撐 WebGL 的閱讀器閱讀一下本文對應的 Live Demo。 本文的代碼和場景文件也以 BSD 的協定在 Github 上宣布了(這裏)。

開端之前先說點題外話,在曩昔的兩個月裏,我還開辟了一個用來治理矢量圖標並生成圖標字體的對象: MyIcons。這是一個 Web-based 的對象,很便利安排到 Heroku,有須要的同夥歡迎圍不雅和體驗! 我也會在以後的博文中慢慢給出這個對象有關成績的講解。

一點點綜述

此次的素描襯著 Demo 是基于 Three.js 的,跟之前的文章 Let Rocket Fly 分歧,此次要觸及到 Shader 的編寫,是以龐雜度也要比本來高許多。

完成一個如許的襯著後果,重要的步調包含:

  1. 預備模子和場景
  2. 經由過程 WebGL (Three.js) 導入場景
  3. 完成 Shader 以表示接近素描的後果

在最主要的第3步中,我們要完成的重要有兩個後果:

  1. 模子邊沿的描邊(分歧于純真的線框)
  2. 模子外面相似于素描的線條後果

爲了完成如許的後果,我們現實其實不能直接在單一的 3D 的空間上完成的,而須要別的預備一個二維場景用于分解。 整體的襯著與分解流程以下:

《應用 WebGL 完成素描後果的襯著》Pipeline

個中的 3D 場景,就是我們想要處置成素描後果的場景。這裏應用了一個小技能, 那就是我們並不是直接將 3D 場景中的襯著後果輸入到屏幕,而是先將三種分歧類型的襯著成果輸入到位于顯存中的 Buffer (Three.js 中的WebGLRenderTarget)裏。再在 2D 場景中分解這些輸入成果。

這個 2D 場景異常簡略,外面只要一個正好和視口巨細一樣的矩形立體和一個非透視類型的 Camera, 將我們從 3D 場景獲得的分歧類型的襯著圖作爲矩形立體的貼圖,如許我們就能夠編寫 Shader 來高效地處置分解後果了。終究輸入的成果實際上是 2D 場景的襯著成果,然則旁觀的人不會感到就任何差別。

應用如許一個簡略的 2D 場景停止前期分解可以說是一個異常經常使用的技能,由於如許可以經由過程 OpenGL 充足應用顯卡的襯著機能。

預備場景

起首要做的任務是預備用來襯著的場景,選用的建模軟件固然是我最愛好的 Blender。我參考 BlenderNation 上登載的一副室內場景作品停止了仿造。 我仿造的場景襯著成果以下:

《應用 WebGL 完成素描後果的襯著》Scene

選用這個場景的重要緣由是場景的主體構造都異常簡略,大多半物體都可以經由過程簡略的立方體變換和修正而成。 大批的立體也便利表示素描的後果。

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

模子的 UV 睜開本質上就是肯定模子的貼圖坐標與模子坐標的映照關系。一個好的 UV 映照決議了模子襯著時貼圖的顯示後果。 由於模子外面的素描後果現實是經由過程貼圖完成的,是以假如沒有一個好的 UV 映照,顯示出來的筆觸能夠會湧現歪曲、變形、 粗細紛歧等各類成績。UV 睜開可以說是一個異常繁瑣耗時的工序。最初爲了削減任務量,我不能不刪除一些比擬龐雜的模子。

我將場景中的壹切模子歸並爲一個物體,並完成 UV 睜開後的成果以下:

《應用 WebGL 完成素描後果的襯著》UV Mapping

完成 UV 睜開以後將會停止烘焙。所謂的烘焙(Bake)就是將模子在場景情況下的明暗變更、暗影等事前襯著並映照到模子的貼圖上。 這個技術經常使用于靜態場景中。在這類靜態場景裏,燈光的地位和角度不會變更,只要攝像機的偏向會轉變。 是以現實上物體的明暗暗影都是固定的,將其固定在貼圖中以後,應用 OpenGL 襯著時不再停止明暗處置和暗影生成。 如許可以勤儉大批的盤算時光。並且應用 CPU 襯著的暗影常常可使用更加龐雜的算法以取得真實的後果。

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

《應用 WebGL 完成素描後果的襯著》Bake Panel

對比之前的 UV 睜開,我烘焙出來的光影貼圖以下:

《應用 WebGL 完成素描後果的襯著》Room 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 裏的typet, value可所以一個 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 清空。 假如將我們在這一步生成的貼圖顯示出來的話,也許是上面的模樣:

《應用 WebGL 完成素描後果的襯著》Depth & Normal Texture

生成素描筆觸

接上去就是在物體的外面生成繪制的素描線條後果了。這個方面其實比想象中更簡略一點, 我們的素描後果是應用的是以下一系列貼圖構成的:

《應用 WebGL 完成素描後果的襯著》Hatching 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
5
6
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
7
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 後果以下:

《應用 WebGL 完成素描後果的襯著》Outline

最初,將 Hatching 進程輸入的成果混雜出去:

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

完全的完成可以拜見我放在 GitHub 上的源碼

半途而廢!最初的分解後果如圖:

《應用 WebGL 完成素描後果的襯著》Final Result

列位可以訪問我應用簡略添加了一點交互以後獲得的 Live Demo (請應用支撐 WebGL 的古代閱讀器停止訪問,加載模子和全體貼圖能夠須要一小會,請耐煩期待)。

我完成的壹切代碼和模子都曾經以 BSD 協定宣布到 GitHub 上了(這裏)。

總結一下

固然是作爲我在學校一門課程的 Final Project 的一部門完成的項目, 然則在這個過程當中我總算是關於 Shader 的編寫方面有所入門。另外,此次停止 Blender 停止建模也感到比之前順遂了很多。

固然對 Blender 和 WebGL 的喜好如今看起來還沒有甚麽實際價值,然則可以或許本身完成一個風趣的 Project 照樣很有造詣感的!

 

轉自:IO Meter 瀏覽原文

點贊

揭櫫評論

電子郵件地址不會被公開。 必填項已用*標注