vlambda博客
学习文章列表

WebGL进阶——走进图形噪声

导语:大自然蕴含着各式各样的纹理,小到细胞菌落分布,大到宇宙星球表面。运用图形噪声,我们可以在3d场景中模拟它们,本文就带大家一起走进万能的图形噪声。

概述

图形噪声,是计算机图形学中一类随机算法,经常用来模拟自然界中的各种纹理材质,如下图的云、山脉等,都是通过噪声算法模拟出来的。

通过不同的噪声算法,作用在物体纹理和材质细节,我们可以模拟不同类型的材质。

WebGL进阶——走进图形噪声

基础噪声算法

一个基础的噪声函数的入参通常是一个点坐标(这个点坐标可以是二维的、三维的,甚至N维),返回值是一个浮点数值: noise(vec2(x,y))。 我们将这个浮点值转成灰度颜色,形成噪声图,具体可以通过编写片元着色器程序来绘制。

WebGL进阶——走进图形噪声

上图是各类噪声函数在片元着色器中的运行效果,代码如下:

 
   
   
 
  1. // noise fragment shader

  2. varying vec2 uv;

  3. float noise(vec2 p) {

  4. // TODO

  5. }

  6. void main() {

  7. float n = noise(uv); // 通过噪声函数计算片元坐标对应噪声值

  8. gl_FragColor = vec4(n, n, n, 1.0);

  9. }

其中 noise(st)的入参 st是片元坐标,返回的噪声值映射在片元的颜色上。 目前基础噪声算法比较主流的有两类:1. 梯度噪声;2. 细胞噪声;

梯度噪声 (Gradient Noise)

梯度噪声产生的纹理具有连续性,所以经常用来模拟山脉、云朵等具有连续性的物质,该类噪声的典型代表是Perlin Noise。

WebGL进阶——走进图形噪声

其它梯度噪声还有Simplex Noise和Wavelet Noise,它们也是由Perlin Noise演变而来。

算法步骤

梯度噪声是通过多个随机梯度相互影响计算得到,通过梯度向量的方向与片元的位置计算噪声值。这里以2d举例,主要分为四步:1. 网格生成;2. 网格随机梯度生成;3. 梯度贡献值计算;4. 平滑插值

WebGL进阶——走进图形噪声

第一步,我们将2d平面分成m×n个大小相同的网格,具体数值取决于我们需要生成的纹理密度(下面以4×4作为例子);

 
   
   
 
  1. #define SCALE 4. // 将平面分为 4 × 4 个正方形网格

  2. float noise(vec2 p) {

  3. p *= SCALE;

  4. // TODO

  5. }

第二步,梯度向量生成,这一步是根据第一步生成的网格的顶点来产生随机向量,四个顶点就有四个梯度向量;

WebGL进阶——走进图形噪声

我们需要将每个网格对应的随机向量记录下来,确保不同片元在相同网格中获取的随机向量是一致的。

 
   
   
 
  1. // 输入网格顶点位置,输出随机向量

  2. vec2 random(vec2 p){

  3. return -1.0 + 2.0 * fract(

  4. sin(

  5. vec2(

  6. dot(p, vec2(127.1,311.7)),

  7. dot(p, vec2(269.5,183.3))

  8. )

  9. ) * 43758.5453

  10. );

  11. }

如上,借用三角函数sin(θ)的来生成随机值,入参是网格顶点的坐标,返回值是随机向量。

第三步,梯度贡献计算,这一步是通过计算四个梯度向量对当前片元点P的影响,主要先求出点P到四个顶点的距离向量,然后和对应的梯度向量进行点积。

WebGL进阶——走进图形噪声

如图,网格内的片元点P的四个顶点距离向量为a1, a2, a3, a4,此时将距离向量与梯度向量g1, g2, g3, g4进行点积运算:c[i] = a[i] · g[i];


第四步,平滑插值,这一步我们对四个贡献值进行线性叠加,使用 smoothstep()方法,平滑网格边界,最终得到当前片元的噪声值。具体代码如下:

 
   
   
 
  1. float noise_perlin (vec2 p) {

  2. vec2 i = floor(p); // 获取当前网格索引i

  3. vec2 f = fract(p); // 获取当前片元在网格内的相对位置

  4. // 计算梯度贡献值

  5. float a = dot(random(i),f); // 梯度向量与距离向量点积运算

  6. float b = dot(random(i + vec2(1., 0.)),f - vec2(1., 0.));

  7. float c = dot(random(i + vec2(0., 1.)),f - vec2(0., 1.));

  8. float d = dot(random(i + vec2(1., 1.)),f - vec2(1., 1.));

  9. // 平滑插值

  10. vec2 u = smoothstep(0.,1.,f);

  11. // 叠加四个梯度贡献值

  12. return mix(mix(a,b,u.x),mix(c,d,u.x),u.y);

  13. }

细胞噪声 (Celluar Noise)

WebGL进阶——走进图形噪声

Celluar Noise生成的噪声图由很多个“晶胞”组成,每个晶胞向外扩张,晶胞之间相互抑制。这类噪声可以模拟细胞形态、皮革纹理等。

WebGL进阶——走进图形噪声

算法步骤

细胞噪声算法主要通过距离场的形式实现的,以单个特征点为中心的径向渐变,多个特征点共同作用而成。主要分为三步:1. 网格生成;2. 特征点生成;3. 最近特征点计算

WebGL进阶——走进图形噪声

第一步,网格生成:将平面划分为m×n个网格,这一步和梯度噪声的第一步一样; 第二步,特征点生成:为每个网格分配一个特征点 v[i,j],这个特征点的位置在网格内随机。

 
   
   
 
  1. // 输入网格索引,输出网格特征点坐标

  2. vec2 random(vec2 st){

  3. return fract(

  4. sin(

  5. vec2(

  6. dot(st, vec2(127.1,311.7)),

  7. dot(st, vec2(269.5,183.3))

  8. )

  9. ) * 43758.5453

  10. );

  11. }

第三步,针对当前像素点p,计算出距离点p最近的特征点v,将点p到点v的距离记为F1;

 
   
   
 
  1. float noise(vec2 p) {

  2. vec2 i = floor(p); // 获取当前网格索引i

  3. vec2 f = fract(p); // 获取当前片元在网格内的相对位置

  4. float F1 = 1.;

  5. // 遍历当前像素点相邻的9个网格特征点

  6. for (int j = -1; j <= 1; j++) {

  7. for (int k = -1; k <= 1; k++) {

  8. vec2 neighbor = vec2(float(j), float(k));

  9. vec2 point = random(i + neighbor);

  10. float d = length(point + neighbor - f);

  11. F1 = min(F1,d);

  12. }

  13. }

  14. return F1;

  15. }

求解F1,我们可以遍历所有特征点v,计算每个特征点v到点p的距离,再取出最小的距离F1;但实际上,我们只需遍历离点p最近的网格特征点即可。在2d中,则最多遍历包括自身相连的9个网格,如图:

WebGL进阶——走进图形噪声

最后一步,将F1映射为当前像素点的颜色值,可以是 gl_FragColor=vec4(vec3(pow(noise(uv),2.)),1.0);。 不仅如此,我们还可以取特征点v到点p第二近的距离F2,通过F2 - F1,得到类似泰森多变形的纹理,如上图最右侧。

噪声算法组合

前面介绍了两种主流的基础噪声算法,我们可以通过对多个不同频率的同类噪声进行运算,产生更为自然的效果,下图是经过分形操作后的噪声纹理。

WebGL进阶——走进图形噪声

分形布朗运动(Fractal Brownian Motion)

分形布朗运动,简称fbm,是通过将不同频率和振幅的噪声函数进行操作,最常用的方法是:将频率乘2的倍数,振幅除2的倍数,线性相加。

WebGL进阶——走进图形噪声

  • 公式: fbm=noise(st)+0.5*noise(2*st)+0.25*noise(4*st)

 
   
   
 
  1. // fragment shader片元着色器

  2. #define OCTAVE_NUM 5

  3. // 叠加5次的分形噪声

  4. float fbm_noise(vec2 p)

  5. {

  6. float f = 0.0;

  7. p = p * 4.0;

  8. float a = 1.;

  9. for (int i = 0; i < OCTAVE_NUM; i++)

  10. {

  11. f += a * noise(p);

  12. p = 4.0 * p;

  13. a /= 4.;

  14. }

  15. return f;

  16. }

湍流(Turbulence)

另外一种变种是在fbm中对噪声函数取绝对值,使噪声值等于0处发生突变,产生湍流纹理:

  • 公式: fbm=|noise(st)|+0.5*|noise(2*st)|+0.25*|noise(4*st)|

 
   
   
 
  1. // 湍流分形噪声

  2. float fbm_abs_noise(vec2 p)

  3. {

  4. ...

  5. for (int i = 0; i < OCTAVE_NUM; i++)

  6. {

  7. f += a * abs(noise(p)); // 对噪声函数取绝对值

  8. ...

  9. }

  10. return f;

  11. }

现在结合上文提到的梯度噪声和细胞噪声分别进行fbm,可以实现以下效果:

WebGL进阶——走进图形噪声

翘曲域(Domain Wrapping)

WebGL进阶——走进图形噪声

翘曲域噪声用来模拟卷曲、螺旋状的纹理,比如烟雾、大理石等,实现公式如下:

  • 公式: f(p)=fbm(p+fbm(p+fbm(p)))

 
   
   
 
  1. float domain_wraping( vec2 p )

  2. {

  3. vec2 q = vec2( fbm(p), fbm(p) );


  4. vec2 r = vec2( fbm(p + q), fbm(p + q) );


  5. return fbm( st + r );

  6. }

具体实现可参考Inigo Quiles的文章:https://www.iquilezles.org/www/articles/warp/warp.htm

动态纹理

前面讲的都是基于2d平面的静态噪声,我们还可以在2d基础上加上时间t维度,形成动态的噪声。如下为实现3d noise的代码结构:

WebGL进阶——走进图形噪声

 
   
   
 
  1. // noise fragment shader

  2. #define SPEED 20.

  3. varying vec2 uv;

  4. uniform float u_time;

  5. float noise(vec3 p) {

  6. // TODO

  7. }

  8. void main() {

  9. float n = noise(uv, u_time * SPEED); // 传入片元坐标与时间

  10. gl_FragColor = vec4(n, n, n, 1.0);

  11. }

利用时间,我们可以生成实现动态纹理,模拟如火焰、云朵的变换。

WebGL进阶——走进图形噪声

噪声贴图应用

利用噪声算法,我们可以构造物体表面的纹理颜色和材质细节,在3d开发中,一般采用贴图方式应用在3D Object上的Material材质上。

Color Mapping

彩色贴图是最常用的是方式,即直接将噪声值映射为片元颜色值,作为材质的Texture图案。

WebGL进阶——走进图形噪声

Height Mapping

另一种是作为Height Mapping高度贴图,生成地形高度。高度贴图的每个像素映射到平面点的高度值,通过图形噪声生成的Height Map可模拟连绵起伏的山脉。

WebGL进阶——走进图形噪声

Normal Mapping

除了通过heightMap生成地形,还可以通过法线贴图改变光照效果,实现材质表面的凹凸细节。

WebGL进阶——走进图形噪声

这里的噪声值被映射为法线贴图的color值。

噪声贴图实践

在WebGL中使用噪声贴图通常有两种方法:

  1. 读取一张静态noise图片的噪声值;

  2. 加载noise程序,切换着色器中运行它 前者不必多说,适用于静态纹理材质,后者适用于动态纹理,这里主要介绍后者的实现。

这里将通过实现如上图球体的纹理贴图效果,为了简化代码,我使用Three.js来实现。 demo预览:https://yonechen.github.io/webgl-noise-examples/web/index.html

首先,按往常一样创建场景、相机、渲染器,在初始化阶段创建一个球体,我们将把噪声纹理应用在这颗球体上:

 
   
   
 
  1. class Web3d {

  2. constructor() { ... } // 创建场景、相机、渲染器

  3. // 渲染前初始化钩子

  4. start() {

  5. this.addLight(); // 添加灯光

  6. this.addBall(); // 添加一个球体

  7. }

  8. addBall() {

  9. const { scene } = this;

  10. this.initNoise();

  11. const geometry = new THREE.SphereBufferGeometry(50, 32, 32); // 创建一个半径为50的球体

  12. // 创建材质

  13. const material = new THREE.MeshPhongMaterial( {

  14. shininess: 5,

  15. map: this.colorMap.texture // 将噪声纹理作为球体材质的colorMap

  16. } );

  17. const ball = new THREE.Mesh( geometry, material );

  18. ball.rotation.set(0,-Math.PI,0);

  19. scene.add(ball);

  20. }

  21. // 动态渲染更新钩子

  22. update() { }

  23. }

接着,编写Noise shader程序,我们把前面的梯度噪声shader搬过来稍微封装下:

 
   
   
 
  1. const ColorMapShader = {

  2. uniforms: {

  3. "scale": { value: new THREE.Vector2( 1, 1 ) },

  4. "offset": { value: new THREE.Vector2( 0, 0 ) },

  5. "time": { value: 1.0 },

  6. },

  7. vertexShader: `

  8. varying vec2 vUv;

  9. uniform vec2 scale;

  10. uniform vec2 offset;


  11. void main( void ) {

  12. vUv = uv * scale + offset;

  13. gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );

  14. }

  15. `,

  16. fragmentShader: `

  17. varying vec2 vUv;

  18. uniform float time;

  19. vec3 random_perlin( vec3 p ) {

  20. p = vec3(

  21. dot(p,vec3(127.1,311.7,69.5)),

  22. dot(p,vec3(269.5,183.3,132.7)),

  23. dot(p,vec3(247.3,108.5,96.5))

  24. );

  25. return -1.0 + 2.0*fract(sin(p)*43758.5453123);

  26. }

  27. float noise_perlin (vec3 p) {

  28. vec3 i = floor(p);

  29. vec3 s = fract(p);


  30. // 3D网格有8个顶点

  31. float a = dot(random_perlin(i),s);

  32. float b = dot(random_perlin(i + vec3(1, 0, 0)),s - vec3(1, 0, 0));

  33. float c = dot(random_perlin(i + vec3(0, 1, 0)),s - vec3(0, 1, 0));

  34. float d = dot(random_perlin(i + vec3(0, 0, 1)),s - vec3(0, 0, 1));

  35. float e = dot(random_perlin(i + vec3(1, 1, 0)),s - vec3(1, 1, 0));

  36. float f = dot(random_perlin(i + vec3(1, 0, 1)),s - vec3(1, 0, 1));

  37. float g = dot(random_perlin(i + vec3(0, 1, 1)),s - vec3(0, 1, 1));

  38. float h = dot(random_perlin(i + vec3(1, 1, 1)),s - vec3(1, 1, 1));


  39. // Smooth Interpolation

  40. vec3 u = smoothstep(0.,1.,s);


  41. // 根据八个顶点进行插值

  42. return mix(mix(mix( a, b, u.x),

  43. mix( c, e, u.x), u.y),

  44. mix(mix( d, f, u.x),

  45. mix( g, h, u.x), u.y), u.z);

  46. }

  47. float noise_turbulence(vec3 p)

  48. {

  49. float f = 0.0;

  50. float a = 1.;

  51. p = 4.0 * p;

  52. for (int i = 0; i < 5; i++) {

  53. f += a * abs(noise_perlin(p));

  54. p = 2.0 * p;

  55. a /= 2.;

  56. }

  57. return f;

  58. }

  59. void main( void ) {

  60. float c1 = noise_turbulence(vec3(vUv, time/10.0));

  61. vec3 color = vec3(1.5*c1, 1.5*c1*c1*c1, c1*c1*c1*c1*c1*c1);

  62. gl_FragColor = vec4( color, 1.0 );

  63. }

  64. `

  65. };

OK,现在让WebGL去加载这段程序,并告诉它这段代码是要作为球体的纹理贴图的:

 
   
   
 
  1. initNoise() {

  2. const { scene, renderer } = this;

  3. // 创建一个噪声平面,作为运行噪声shader的载体。

  4. const plane = new THREE.PlaneBufferGeometry( window.innerWidth, window.innerHeight );

  5. const colorMapMaterial = new THREE.ShaderMaterial( {

  6. ...ColorMapShader, // 将噪声着色器代码传入ShaderMaterial

  7. uniforms: {

  8. ...ColorMapShader.uniforms,

  9. scale: { value: new THREE.Vector2( 1, 1 ) }

  10. },

  11. lights: false

  12. } );

  13. const noise = new THREE.Mesh( plane, colorMapMaterial );

  14. scene.add( noise );

  15. // 创建噪声纹理的渲染对象framebuffer。

  16. const colorMap = new THREE.WebGLRenderTarget( 512, 512 );

  17. colorMap.texture.generateMipmaps = false;

  18. colorMap.texture.wrapS = colorMap.texture.wrapT = THREE.RepeatWrapping;

  19. this.noise = noise;

  20. this.colorMap = colorMap;

  21. this.uniformsNoise = colorMapMaterial.uniforms;

  22. // 创建一个正交相机,对准噪声平面。

  23. this.cameraOrtho = new THREE.OrthographicCamera( window.innerWidth / - 2, window.innerWidth / 2, window.innerHeight / 2, window.innerHeight / - 2, - 10000, 10000 );

  24. this._renderNoise();

  25. }

第四步,让renderer动态运行噪声shader,更新噪声变量,可以是时间、颜色、偏移量等。

 
   
   
 
  1. _renderNoise() {

  2. const { scene, noise, colorMap, renderer, cameraOrtho } = this;

  3. noise.visible = true;

  4. renderer.setRenderTarget( colorMap );

  5. renderer.clear();

  6. renderer.render( scene, cameraOrtho );

  7. noise.visible = false;

  8. }

  9. update(delta) {

  10. this.uniformsNoise[ 'time' ].value += delta; // 更新noise的时间,生成动态纹理

  11. this._renderNoise();

  12. }

通过同样的方法,我们可以试着用在将高度贴图上,比如用Worley Noise构造的鹅卵石地表:https://yonechen.github.io/webgl-noise-examples/web/heightmap.html

最后

  • 相关专栏《WebXR技术庄园》:https://zhuanlan.zhihu.com/webxr

参考资料

  • OpenGL复杂地形的Shader实现:https://blog.csdn.net/Mahabharata_/article/details/78168432

  • The Book of Shader - 图形噪声:https://thebookofshaders.com/11/

  • 【图形学】谈谈噪声:https://blog.csdn.net/candycat1992/article/details/50346469