WebGL進階——走進圖形雜訊
- 2020 年 3 月 18 日
- 筆記
導語:大自然蘊含著各式各樣的紋理,小到細胞菌落分布,大到宇宙星球表面。運用圖形雜訊,我們可以在3d場景中模擬它們,本文就帶大家一起走進萬能的圖形雜訊。
概述
圖形雜訊,是電腦圖形學中一類隨機演算法,經常用來模擬自然界中的各種紋理材質,如下圖的雲、山脈等,都是通過雜訊演算法模擬出來的。

通過不同的雜訊演算法,作用在物體紋理和材質細節,我們可以模擬不同類型的材質。

基礎雜訊演算法
一個基礎的雜訊函數的入參通常是一個點坐標(這個點坐標可以是二維的、三維的,甚至N維),返回值是一個浮點數值:noise(vec2(x,y))。 我們將這個浮點值轉成灰度顏色,形成雜訊圖,具體可以通過編寫片元著色器程式來繪製。

上圖是各類雜訊函數在片元著色器中的運行效果,程式碼如下:
// noise fragment shader varying vec2 uv; float noise(vec2 p) { // TODO } void main() { float n = noise(uv); // 通過雜訊函數計算片元坐標對應雜訊值 gl_FragColor = vec4(n, n, n, 1.0); }其中noise(uv)的入參uv是片元坐標,返回的雜訊值映射在片元的顏色上。 目前基礎雜訊演算法比較主流的有兩類:1. 梯度雜訊;2. 細胞雜訊;
梯度雜訊 (Gradient Noise)
梯度雜訊產生的紋理具有連續性,所以經常用來模擬山脈、雲朵等具有連續性的物質,該類雜訊的典型代表是Perlin Noise。

其它梯度雜訊還有Simplex Noise和Wavelet Noise,它們也是由Perlin Noise演變而來。
演算法步驟
梯度雜訊是通過多個隨機梯度相互影響計算得到,通過梯度向量的方向與片元的位置計算雜訊值。這裡以2d舉例,主要分為四步:1. 網格生成;2. 網格隨機梯度生成;3. 梯度貢獻值計算;4. 平滑插值

第一步,我們將2d平面分成m×n個大小相同的網格,具體數值取決於我們需要生成的紋理密度(下面以4×4作為例子);
#define SCALE 4. // 將平面分為 4 × 4 個正方形網格 float noise(vec2 p) { p *= SCALE; // TODO }第二步,梯度向量生成,這一步是根據第一步生成的網格的頂點來產生隨機向量,四個頂點就有四個梯度向量;

我們需要將每個網格對應的隨機向量記錄下來,確保不同片元在相同網格中獲取的隨機向量是一致的。
// 輸入網格頂點位置,輸出隨機向量 vec2 random(vec2 p){ return -1.0 + 2.0 * fract( sin( vec2( dot(p, vec2(127.1,311.7)), dot(p, vec2(269.5,183.3)) ) ) * 43758.5453 ); }如上,借用三角函數sin(θ)的來生成隨機值,入參是網格頂點的坐標,返回值是隨機向量。
第三步,梯度貢獻計算,這一步是通過計算四個梯度向量對當前片元點P的影響,主要先求出點P到四個頂點的距離向量,然後和對應的梯度向量進行點積。

如圖,網格內的片元點P的四個頂點距離向量為a1, a2, a3, a4,此時將距離向量與梯度向量g1, g2, g3, g4進行點積運算:c[i] = a[i] · g[i];
第四步,平滑插值,這一步我們對四個貢獻值進行線性疊加,使用smoothstep()方法,平滑網格邊界,最終得到當前片元的雜訊值。具體程式碼如下:
float noise_perlin (vec2 p) { vec2 i = floor(p); // 獲取當前網格索引i vec2 f = fract(p); // 獲取當前片元在網格內的相對位置 // 計算梯度貢獻值 float a = dot(random(i),f); // 梯度向量與距離向量點積運算 float b = dot(random(i + vec2(1., 0.)),f - vec2(1., 0.)); float c = dot(random(i + vec2(0., 1.)),f - vec2(0., 1.)); float d = dot(random(i + vec2(1., 1.)),f - vec2(1., 1.)); // 平滑插值 vec2 u = smoothstep(0.,1.,f); // 疊加四個梯度貢獻值 return mix(mix(a,b,u.x),mix(c,d,u.x),u.y); }細胞雜訊 (Celluar Noise)

Celluar Noise生成的雜訊圖由很多個「晶胞」組成,每個晶胞向外擴張,晶胞之間相互抑制。這類雜訊可以模擬細胞形態、皮革紋理等。

演算法步驟
細胞雜訊演算法主要通過距離場的形式實現的,以單個特徵點為中心的徑向漸變,多個特徵點共同作用而成。主要分為三步:1. 網格生成;2. 特徵點生成;3. 最近特徵點計算

第一步,網格生成:將平面劃分為m×n個網格,這一步和梯度雜訊的第一步一樣; 第二步,特徵點生成:為每個網格分配一個特徵點v[i,j],這個特徵點的位置在網格內隨機。
// 輸入網格索引,輸出網格特徵點坐標 vec2 random(vec2 st){ return fract( sin( vec2( dot(st, vec2(127.1,311.7)), dot(st, vec2(269.5,183.3)) ) ) * 43758.5453 ); }第三步,針對當前像素點p,計算出距離點p最近的特徵點v,將點p到點v的距離記為F1;
float noise(vec2 p) { vec2 i = floor(p); // 獲取當前網格索引i vec2 f = fract(p); // 獲取當前片元在網格內的相對位置 float F1 = 1.; // 遍歷當前像素點相鄰的9個網格特徵點 for (int j = -1; j <= 1; j++) { for (int k = -1; k <= 1; k++) { vec2 neighbor = vec2(float(j), float(k)); vec2 point = random(i + neighbor); float d = length(point + neighbor - f); F1 = min(F1,d); } } return F1; }求解F1,我們可以遍歷所有特徵點v,計算每個特徵點v到點p的距離,再取出最小的距離F1;但實際上,我們只需遍歷離點p最近的網格特徵點即可。在2d中,則最多遍歷包括自身相連的9個網格,如圖:

求解F1:點P的最近特徵點距離
最後一步,將F1映射為當前像素點的顏色值,可以是gl_FragColor = vec4(vec3(pow(noise(uv), 2.)), 1.0);。 不僅如此,我們還可以取特徵點v到點p第二近的距離F2,通過F2 - F1,得到類似泰森多變形的紋理,如上圖最右側。
雜訊演算法組合
前面介紹了兩種主流的基礎雜訊演算法,我們可以通過對多個不同頻率的同類雜訊進行運算,產生更為自然的效果,下圖是經過分形操作後的雜訊紋理。

分形布朗運動(Fractal Brownian Motion)
分形布朗運動,簡稱fbm,是通過將不同頻率和振幅的雜訊函數進行操作,最常用的方法是:將頻率乘2的倍數,振幅除2的倍數,線性相加。

- 公式:fbm = noise(st) + 0.5 * noise(2*st) + 0.25 * noise(4*st)
// fragment shader片元著色器 #define OCTAVE_NUM 5 // 疊加5次的分形雜訊 float fbm_noise(vec2 p) { float f = 0.0; p = p * 4.0; float a = 1.; for (int i = 0; i < OCTAVE_NUM; i++) { f += a * noise(p); p = 4.0 * p; a /= 4.; } return f; }湍流(Turbulence)
另外一種變種是在fbm中對雜訊函數取絕對值,使雜訊值等於0處發生突變,產生湍流紋理:
- 公式:fbm = |noise(st)| + 0.5 * |noise(2*st)| + 0.25 * |noise(4*st)|
// 湍流分形雜訊 float fbm_abs_noise(vec2 p) { ... for (int i = 0; i < OCTAVE_NUM; i++) { f += a * abs(noise(p)); // 對雜訊函數取絕對值 ... } return f; }現在結合上文提到的梯度雜訊和細胞雜訊分別進行fbm,可以實現以下效果:

翹曲域(Domain Wrapping)

翹曲域雜訊用來模擬捲曲、螺旋狀的紋理,比如煙霧、大理石等,實現公式如下:
- 公式:f(p) = fbm( p + fbm( p + fbm( p ) ) )
float domain_wraping( vec2 p ) { vec2 q = vec2( fbm(p), fbm(p) ); vec2 r = vec2( fbm(p + q), fbm(p + q) ); return fbm( st + r ); }具體實現可參考Inigo Quiles的文章:https://www.iquilezles.org/www/articles/warp/warp.htm
動態紋理
前面講的都是基於2d平面的靜態雜訊,我們還可以在2d基礎上加上時間t維度,形成動態的雜訊。

如下為實現3d noise的程式碼結構:
// noise fragment shader #define SPEED 20. varying vec2 uv; uniform float u_time; float noise(vec3 p) { // TODO } void main() { float n = noise(uv, u_time * SPEED); // 傳入片元坐標與時間 gl_FragColor = vec4(n, n, n, 1.0); }利用時間,我們可以生成實現動態紋理,模擬如火焰、雲朵的變換。

雜訊貼圖應用
利用雜訊演算法,我們可以構造物體表面的紋理顏色和材質細節,在3d開發中,一般採用貼圖方式應用在3D Object上的Material材質上。
Color Mapping
彩色貼圖是最常用的是方式,即直接將雜訊值映射為片元顏色值,作為材質的Texture圖案。

Height Mapping
另一種是作為Height Mapping高度貼圖,生成地形高度。高度貼圖的每個像素映射到平面點的高度值,通過圖形雜訊生成的Height Map可模擬連綿起伏的山脈。

Normal Mapping
除了通過heightMap生成地形,還可以通過法線貼圖改變光照效果,實現材質表面的凹凸細節。

這裡的雜訊值被映射為法線貼圖的color值。
雜訊貼圖實踐
在WebGL中使用雜訊貼圖通常有兩種方法:
- 讀取一張靜態noise圖片的雜訊值;
- 載入noise程式,切換著色器中運行它
前者不必多說,適用於靜態紋理材質,後者適用於動態紋理,以下主要介紹後者的實現。

這裡將通過實現如上圖球體的紋理貼圖效果,為了簡化程式碼,我使用Three.js來實現。 demo預覽:https://yonechen.github.io/webgl-noise-examples/web/index.html
首先,按往常一樣創建場景、相機、渲染器,在初始化階段創建一個球體,我們將把雜訊紋理應用在這顆球體上:
class Web3d { constructor() { ... } // 創建場景、相機、渲染器 // 渲染前初始化鉤子 start() { this.addLight(); // 添加燈光 this.addBall(); // 添加一個球體 } addBall() { const { scene } = this; this.initNoise(); const geometry = new THREE.SphereBufferGeometry(50, 32, 32); // 創建一個半徑為50的球體 // 創建材質 const material = new THREE.MeshPhongMaterial( { shininess: 5, map: this.colorMap.texture // 將雜訊紋理作為球體材質的colorMap } ); const ball = new THREE.Mesh( geometry, material ); ball.rotation.set(0,-Math.PI,0); scene.add(ball); } // 動態渲染更新鉤子 update() { } }接著,編寫Noise shader程式,我們把前面的梯度雜訊shader搬過來稍微封裝下:
const ColorMapShader = { uniforms: { "scale": { value: new THREE.Vector2( 1, 1 ) }, "offset": { value: new THREE.Vector2( 0, 0 ) }, "time": { value: 1.0 }, }, vertexShader: ` varying vec2 vUv; uniform vec2 scale; uniform vec2 offset; void main( void ) { vUv = uv * scale + offset; gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 ); } `, fragmentShader: ` varying vec2 vUv; uniform float time; vec3 random_perlin( vec3 p ) { p = vec3( dot(p,vec3(127.1,311.7,69.5)), dot(p,vec3(269.5,183.3,132.7)), dot(p,vec3(247.3,108.5,96.5)) ); return -1.0 + 2.0*fract(sin(p)*43758.5453123); } float noise_perlin (vec3 p) { vec3 i = floor(p); vec3 s = fract(p); // 3D網格有8個頂點 float a = dot(random_perlin(i),s); float b = dot(random_perlin(i + vec3(1, 0, 0)),s - vec3(1, 0, 0)); float c = dot(random_perlin(i + vec3(0, 1, 0)),s - vec3(0, 1, 0)); float d = dot(random_perlin(i + vec3(0, 0, 1)),s - vec3(0, 0, 1)); float e = dot(random_perlin(i + vec3(1, 1, 0)),s - vec3(1, 1, 0)); float f = dot(random_perlin(i + vec3(1, 0, 1)),s - vec3(1, 0, 1)); float g = dot(random_perlin(i + vec3(0, 1, 1)),s - vec3(0, 1, 1)); float h = dot(random_perlin(i + vec3(1, 1, 1)),s - vec3(1, 1, 1)); // Smooth Interpolation vec3 u = smoothstep(0.,1.,s); // 根據八個頂點進行插值 return mix(mix(mix( a, b, u.x), mix( c, e, u.x), u.y), mix(mix( d, f, u.x), mix( g, h, u.x), u.y), u.z); } float noise_turbulence(vec3 p) { float f = 0.0; float a = 1.; p = 4.0 * p; for (int i = 0; i < 5; i++) { f += a * abs(noise_perlin(p)); p = 2.0 * p; a /= 2.; } return f; } void main( void ) { float c1 = noise_turbulence(vec3(vUv, time/10.0)); vec3 color = vec3(1.5*c1, 1.5*c1*c1*c1, c1*c1*c1*c1*c1*c1); gl_FragColor = vec4( color, 1.0 ); } ` };OK,現在讓WebGL去載入這段程式,並告訴它這段程式碼是要作為球體的紋理貼圖的:
initNoise() { const { scene, renderer } = this; // 創建一個雜訊平面,作為運行雜訊shader的載體。 const plane = new THREE.PlaneBufferGeometry( window.innerWidth, window.innerHeight ); const colorMapMaterial = new THREE.ShaderMaterial( { ...ColorMapShader, // 將雜訊著色器程式碼傳入ShaderMaterial uniforms: { ...ColorMapShader.uniforms, scale: { value: new THREE.Vector2( 1, 1 ) } }, lights: false } ); const noise = new THREE.Mesh( plane, colorMapMaterial ); scene.add( noise ); // 創建雜訊紋理的渲染對象framebuffer。 const colorMap = new THREE.WebGLRenderTarget( 512, 512 ); colorMap.texture.generateMipmaps = false; colorMap.texture.wrapS = colorMap.texture.wrapT = THREE.RepeatWrapping; this.noise = noise; this.colorMap = colorMap; this.uniformsNoise = colorMapMaterial.uniforms; // 創建一個正交相機,對準雜訊平面。 this.cameraOrtho = new THREE.OrthographicCamera( window.innerWidth / - 2, window.innerWidth / 2, window.innerHeight / 2, window.innerHeight / - 2, - 10000, 10000 ); this._renderNoise(); }第四步,讓renderer動態運行雜訊shader,更新雜訊變數,可以是時間、顏色、偏移量等。
_renderNoise() { const { scene, noise, colorMap, renderer, cameraOrtho } = this; noise.visible = true; renderer.setRenderTarget( colorMap ); renderer.clear(); renderer.render( scene, cameraOrtho ); noise.visible = false; } update(delta) { this.uniformsNoise[ 'time' ].value += delta; // 更新noise的時間,生成動態紋理 this._renderNoise(); }通過同樣的方法,我們可以試著用在將高度貼圖上,比如用Worley Noise構造的鵝卵石地表:https://yonechen.github.io/webgl-noise-examples/web/heightmap.html

最後
本文相關的程式碼地址:https://github.com/YoneChen/webgl-noise-examples
