带你走进 WebGL 的随机美学

阅读数:2228 2019 年 7 月 12 日

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

概述

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

Noise 构造地形、体积云

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

不同 Noise 生成的材质

基础噪声算法

一个基础的噪声函数的入参通常是一个点坐标(这个点坐标可以是二维的、三维的,甚至 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(st)的入参st是片元坐标,返回的噪声值映射在片元的颜色上。
目前基础噪声算法比较主流的有两类:1. 梯度噪声;2. 细胞噪声;

梯度噪声 (Gradient Noise)

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

Perlin Noise 为 Perlin 提出的噪声算法

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

算法步骤

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

Perlin Noise 随机向量代表梯度

第一步,我们将 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 生成的噪声图由很多个“晶胞”组成,每个晶胞向外扩张,晶胞之间相互抑制。这类噪声可以模拟细胞形态、皮革纹理等。

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,可以实现以下效果:

Perlin Noise 与 Worley Noise 的 2D 分形

翘曲域(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 维度,形成动态的噪声。

2D + Time 动态噪声

如下为实现 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);
}

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

Noise 制作火焰

噪声贴图应用

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

Color Mapping

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

噪声应用于 Color Mapping

Height Mapping

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

Fbm Perlin Noise→heightmap→山脉

Normal Mapping

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

Worley Noise→Normalmap→地表细节

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

噪声贴图实践

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

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

  2. 加载 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;
{1}
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);
{1}
// 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));
{1}
// Smooth Interpolation
vec3 u = smoothstep(0.,1.,s);
{1}
// 根据八个顶点进行插值
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 构造的鹅卵石地表:

Worley Noise 构造地形

本文相关的代码地址:https://github.com/YoneChen/webgl-noise-examples

参考资料

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论