带你走进WebGL的随机美学
图形噪声,是计算机图形学中一类随机算法,经常用来模拟自然界中的各种纹理材质,如下图的云、山脉等,都是通过噪声算法模拟出来的。
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. 细胞噪声;
梯度噪声产生的纹理具有连续性,所以经常用来模拟山脉、云朵等具有连续性的物质,该类噪声的典型代表是 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 生成的噪声图由很多个“晶胞”组成,每个晶胞向外扩张,晶胞之间相互抑制。这类噪声可以模拟细胞形态、皮革纹理等。
worley 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,得到类似泰森多变形的纹理,如上图最右侧。
前面介绍了两种主流的基础噪声算法,我们可以通过对多个不同频率的同类噪声进行运算,产生更为自然的效果,下图是经过分形操作后的噪声纹理。
基础噪声 / 分形 / 湍流
分形布朗运动,简称 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;
}
另外一种变种是在 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 分形
翘曲域噪声用来模拟卷曲、螺旋状的纹理,比如烟雾、大理石等,实现公式如下:
公式:
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);
}
利用时间,我们可以生成实现动态纹理,模拟如火焰、云朵的变换。
Perlin Noise 制作火焰
利用噪声算法,我们可以构造物体表面的纹理颜色和材质细节,在 3d 开发中,一般采用贴图方式应用在 3D Object 上的 Material 材质上。
彩色贴图是最常用的是方式,即直接将噪声值映射为片元颜色值,作为材质的 Texture 图案。
噪声应用于 Color Mapping
另一种是作为 Height Mapping 高度贴图,生成地形高度。高度贴图的每个像素映射到平面点的高度值,通过图形噪声生成的 Height Map 可模拟连绵起伏的山脉。
Fbm Perlin Noise→heightmap→山脉
除了通过 heightMap 生成地形,还可以通过 法线贴图 改变光照效果,实现材质表面的凹凸细节。
Worley Noise→Normalmap→地表细节
这里的噪声值被映射为法线贴图的 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 构造的鹅卵石地表:
Worley Noise 构造地形
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