Three.js案例
用一段 Shader 打开噪声世界:基于 Three.js 的二维 Value Noise 动效实践

用一段 Shader 打开噪声世界:基于 Three.js 的二维 Value Noise 动效实践

用 Three.js 和 GLSL 实现一个动态噪声的视觉特效,我们先来看下效果:

实现思路

  1. 初始化阶段: 通过init函数设置场景、相机和着色器材质,并添加事件监听器。
  2. 动画循环: animate函数创建动画循环,每帧调用render函数更新画面。
  3. 着色器执行:
    • main函数是着色器的入口点,负责坐标变换和选择使用简单或复杂模式。
    • 根据u_complex变量选择使用pattern或pattern2函数生成图案。
    • pattern2函数比pattern函数多了几层fbm1调用,产生更复杂的视觉效果。
    • 最后计算并输出最终颜色。
  4. 辅助函数:
    • random2函数:生成随机向量。
    • noise函数:使用梯度噪声算法创建平滑的噪声。
    • fbm1函数:通过多次叠加不同频率和振幅的噪声,创建分形布朗运动效果。
  5. 交互: 鼠标移动影响动画速度,鼠标点击切换简单/复杂模式。

实现架构图

大家可以配合下面的图来理解思路

代码实现

创建基础的渲染环境

我们采用经典的 Shader 渲染方式:在一个全屏的平面上绘制 Fragment Shader 生成的内容。首先引入 Three.js,并在页面中预留容器:

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/88/three.min.js"></script>
<div id="container"></div>

然后使用 JavaScript 初始化 Three.js 的核心部分:相机、场景、渲染器和一个满屏平面。

let container;
let camera, scene, renderer;
let uniforms;
 
function init() {
  container = document.getElementById('container');
 
  camera = new THREE.Camera();
  camera.position.z = 1;
 
  scene = new THREE.Scene();
 
  const geometry = new THREE.PlaneBufferGeometry(2, 2);
 
  uniforms = {
    u_time: { type: 'f', value: 0.0 },
    u_resolution: { type: 'v2', value: new THREE.Vector2() },
    u_mouse: { type: 'v2', value: new THREE.Vector2() },
    u_complex: { type: 'b', value: false }
  };
 
  const material = new THREE.ShaderMaterial({
    uniforms: uniforms,
    vertexShader: document.getElementById('vertexShader').textContent,
    fragmentShader: document.getElementById('fragmentShader').textContent
  });
 
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);
 
  renderer = new THREE.WebGLRenderer();
  container.appendChild(renderer.domElement);
 
  onWindowResize();
  window.addEventListener('resize', onWindowResize, false);
  document.addEventListener('mouseup', onMouseUp, false);
 
  document.onmousemove = function (e) {
    uniforms.u_mouse.value.x = e.pageX;
    uniforms.u_mouse.value.y = e.pageY;
  };
}
 
function onWindowResize() {
  renderer.setSize(window.innerWidth, window.innerHeight);
  uniforms.u_resolution.value.x = renderer.domElement.width;
  uniforms.u_resolution.value.y = renderer.domElement.height;
}
 
function onMouseUp() {
  uniforms.u_complex.value = !uniforms.u_complex.value;
}

注意,这里我们注册了一个 mouseup 事件,用于在点击时切换一种更复杂的图案模式。

构建顶点与片元着色器

我们使用最简形式的顶点着色器,将二维平面上的顶点传入片元着色器:

<script id="vertexShader" type="x-shader/x-vertex">
  void main() {
    gl_Position = vec4(position, 1.0);
  }
</script>

重点的工作全部在 Fragment Shader 中进行,主要由以下几个部分组成:

引入噪声与 FBM 函数

我们使用 Inigo Quilez 提出的经典 Value Noise 实现。为了构造更有层次的变化,引入了多重噪声合成(fbm):

float noise(vec2 st, float seed) {
    vec2 i = floor(st);
    vec2 f = fract(st);
    vec2 u = f * f * (3.0 - 2.0 * f);
 
    return mix(mix(dot(random2(i, seed), f - vec2(0.0, 0.0)),
                   dot(random2(i + vec2(1.0, 0.0), seed), f - vec2(1.0, 0.0)), u.x),
               mix(dot(random2(i + vec2(0.0, 1.0), seed), f - vec2(0.0, 1.0)),
                   dot(random2(i + vec2(1.0, 1.0), seed), f - vec2(1.0, 1.0)), u.x), u.y);
}

再通过一个 fbm1 函数合成多重噪声图案:

float fbm1(in vec2 _st, float seed) {
  float v = 0.0;
  float a = 0.5;
  vec2 shift = vec2(100.0);
  mat2 rot = mat2(cos(0.5), sin(0.5), -sin(0.5), cos(0.5));
  for (int i = 0; i < 6; ++i) {
      v += a * noise(_st, seed);
      _st = rot * _st * 2.0 + shift;
      a *= 0.4;
  }
  return v;
}

构建可切换的图案函数

我们提供了 patternpattern2 两种函数,分别控制不同复杂度的噪声叠加方式。通过鼠标点击 u_complex 变量切换不同的绘制模式。

两种模式的核心差异是叠加层数和每层噪声对空间的扰动程度。

简化模式:

float pattern(vec2 uv, float seed, float time, inout vec2 q, inout vec2 r) {
  q = vec2(fbm1(uv * .1, seed), fbm1(uv + vec2(5.2, 1.3), seed));
  r = vec2(fbm1(uv * .1 + 4.0 * q + vec2(1.7 - time / 2., 9.2), seed),
           fbm1(uv + 4.0 * q + vec2(8.3 - time / 2., 2.8), seed));
  return fbm1(uv * .05 + 4.0 * r, seed);
}

复杂模式:

float pattern2(vec2 uv, float seed, float time, inout vec2 q, inout vec2 r) {
  q = vec2(fbm1(uv, seed), fbm1(uv + vec2(5.2, 1.3), seed));
  r = vec2(fbm1(uv + 4.0 * q + vec2(1.7 - time / 2., 9.2), seed),
           fbm1(uv + 4.0 * q + vec2(8.3 - time / 2., 2.8), seed));
  vec2 s = vec2(fbm1(uv + 5.0 * r + vec2(21.7 - time / 2., 90.2), seed),
                fbm1(uv + 5.0 * r + vec2(80.3 - time / 2., 20.8), seed));
  vec2 t = vec2(fbm1(uv + 4.0 * s + vec2(121.7, 190.2), seed),
                fbm1(uv + 4.0 * s + vec2(180.3, 120.8), seed));
  vec2 u = vec2(fbm1(uv + 3.0 * t + vec2(221.7, 290.2), seed),
                fbm1(uv + 3.0 * t + vec2(280.3, 220.8), seed));
  vec2 v = vec2(fbm1(uv + 2.0 * u + vec2(221.7, 290.2), seed),
                fbm1(uv + 2.0 * u + vec2(280.3, 220.8), seed));
  return fbm1(uv + 4.0 * v, seed);
}

最终片元着色器实现

main 函数中,我们将片元坐标映射为标准化空间(居中 [-1, 1]),加入旋转和缩放变化后,调用 pattern 函数获取当前颜色值:

void main() {
  vec2 uv = (gl_FragCoord.xy - 0.5 * u_resolution.xy) / u_resolution.y;
 
  float time = u_time / 10.0;
  mat2 rot = mat2(cos(time / 10.), sin(time / 10.), -sin(time / 10.), cos(time / 10.));
  uv = rot * uv;
  uv *= 0.9 * sin(u_time / 20.0) + 3.0;
  uv.x -= time / 5.0;
 
  vec2 q = vec2(0.0);
  vec2 r = vec2(0.0);
  float _pattern = u_complex ? pattern2(uv, seed, time, q, r) : pattern(uv, seed, time, q, r);
 
  vec3 colour = vec3(_pattern) * 2.0;
  colour.r -= dot(q, r) * 15.0;
  colour = mix(colour, vec3(pattern(r, seed2, time, q, r), dot(q, r) * 15.0, -0.1), 0.5);
  colour -= q.y * 1.5;
  colour = mix(colour, vec3(0.2), clamp(q.x, -1.0, 0.0) * 3.0);
 
  gl_FragColor = vec4(-colour + abs(colour) * 0.5, 1.0);
}

通过这些计算和色彩混合,你将获得一种具有梦幻感的动态图案,点击切换模式时呈现不同的图层深度。

启动渲染循环

最后,通过 requestAnimationFrame 实现帧更新,推动 u_time 和交互变化。

function animate() {
  requestAnimationFrame(animate);
  uniforms.u_time.value += 0.05 * (1 + uniforms.u_mouse.value.x / 200.0);
  renderer.render(scene, camera);
}
 
init();
animate();

代码

https://codepen.io/shubniggurath/pen/NXGbBo (opens in a new tab)