Three.js案例
使用 Simplex Noise 实现波动地形

使用 Simplex Noise 实现波动地形

昨天通过 noise 实现了一个波动动画,今天继续 noise 在进行详细讲解下。

概念解释

首先,我们来看下 noise 的实现思路。这是完整的代码:https://github.com/jwagner/simplex-noise.js/blob/main/simplex-noise.ts (opens in a new tab)

Simplex Noise 的核心思路就是把平面(或更高维空间)切成一块块小的“三角形”(多维叫单纯形),然后确定你要计算噪声的点属于哪块小三角形后,把点到那块三角形四周顶点的距离算出来,用每个顶点的随机方向向量跟这个距离做点积,最后再把这些结果加在一起,做点平滑处理,就得到一个既连续又带有随机纹理的噪声值。这样可以避免像老式网格噪声那样容易出现方格痕迹,并且计算更高维度也不会太慢。

先根据坐标确定它所在的“单纯形”(simplex)。在二维里,单纯形是个三角形;在三维里是四面体;在四维是五胞体(更抽象的概念)。

计算在该单纯形每个顶点处对应的随机梯度向量(gradient),然后根据离顶点的距离,求出每个顶点对整体噪声值的贡献;

最终将这些顶点贡献相加,获得一个介于 [-1, 1] 的噪声值。

简单来说,n 维单纯形可以视为 n+1 个点所张成的最小“凸多面体”,例如 2 维三角形是由 3 个顶点张成,3 维四面体由 4 个顶点张成,4 维的五胞体由 5 个顶点张成。

使用 Simplex Noise 好处

可能上面的概念太抽象了,你要知道在什么场景可以使用它。

Simplex Noise 在局部保留了细节上的随机性,同时在全局有较平滑的过渡,可以很好地模拟自然界的纹理(如地形、云朵、木纹等)。

通过将空间划分成等边三角形(或四面体等),减少了视觉上横平竖直的网格条纹,让噪声看起来更自然、更有机。

案例

我们使用 noise 来实现一个地面,先看下效果。

搭建基础工程

  1. 安装依赖

    • 你需要一个支持 ES Modules 的环境(比如使用 Vite、Webpack 或者原生 ES Module)。
    • 首先在你的项目文件夹下安装依赖:
      npm install three
      npm install simplex-noise

插入 WebGL 画布

// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
  • 创建了一个渲染器,并将其 <canvas> 元素添加到 body 里。
  • antialias: true 让图像看起来边缘更平滑。

配置相机与场景

// 创建场景并设置背景为黑色
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
// 创建相机并设置位置
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(50, 80, 60);
camera.lookAt(0, 0, 0);
  • 使用透视相机(PerspectiveCamera),视场角度为 75 度,可根据喜好修改。
  • 将相机放在一个稍微抬高并远离(0,0,0)的位置,以便俯瞰地形。

添加轨道控制器(OrbitControls)

import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
 
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
  • OrbitControls 允许你用鼠标旋转、缩放、平移相机,方便调试与观察场景。
  • enableDampingdampingFactor 可以让相机在鼠标操作后平滑惯性移动。

构建一个简单的渐变纹理

为了让我们的粉色系“波动地形”更炫,可以添加一个渐变纹理

const createGradientTexture = () => {
  const canvas = document.createElement("canvas");
  canvas.width = 256;
  canvas.height = 1;
 
  const context = canvas.getContext("2d");
  const gradient = context.createLinearGradient(0, 0, 256, 0);
  gradient.addColorStop(0, "#ff1493"); // 深粉
  gradient.addColorStop(0.5, "#ff69b4"); // 亮粉
  gradient.addColorStop(1, "#ff00ff"); // 紫
 
  context.fillStyle = gradient;
  context.fillRect(0, 0, 256, 1);
 
  const texture = new THREE.CanvasTexture(canvas);
  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  return texture;
};
  • 创建一个 256 像素宽、1 像素高的 canvas;
  • 在其上绘制一个左右渐变,完成后用 THREE.CanvasTexture 封装成可循环的纹理。
  • 有了它以后,我们可以把它放到地形或其他模型的材质上。

创建地形(PlaneGeometry)和材质

  1. 几何形状

    const geometry = new THREE.PlaneGeometry(200, 200, 100, 100);
    geometry.rotateX(Math.PI / 2);
    • 这是一个宽度 200、高度 200 的平面网格,分割成 100 × 100 的小片;
    • 这里用 rotateX(Math.PI / 2) 将平面立起来,让其在 XZ 平面上展开(默认的 PlaneGeometry 是在 XY 面)。
    • 注意,这里的“height”指的是 Three.js 里 plane 的纵向尺寸,但我们实际是用它作为一个地形的“XZ”平面来操作。
  2. 材质

    const material = new THREE.MeshPhongMaterial({
      color: 0xff1493,
      wireframe: true,
      emissive: 0x330033,
      shininess: 80,
      specular: 0xff00ff,
      wireframeLinewidth: 1,
      map: createGradientTexture(), // 使用我们自定义的渐变纹理
    });
    • MeshPhongMaterial 可以让我们看到高光和光照效果。
    • wireframe: true 打开线框模式,使得网格的线条可见。
    • map 参数就是我们自制的渐变纹理。
    • 颜色全部是粉紫系,符合“炫酷”“梦幻”的风格。
  3. 创建地形网格并添加到场景

    const terrain = new THREE.Mesh(geometry, material);
    scene.add(terrain);

添加光源

const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
 
const directionalLight = new THREE.DirectionalLight(0xff1493, 1);
directionalLight.position.set(1, 1, 1);
scene.add(directionalLight);
 
const pointLight = new THREE.PointLight(0xff00ff, 1, 200);
pointLight.position.set(0, 50, 0);
scene.add(pointLight);
  • 环境光(AmbientLight)可均匀照亮场景,0.5 表示强度。
  • 平行光(DirectionalLight)用来制造一些高光和阴影效果,这里设置粉色光线,与主题颜色呼应。
  • 点光源(PointLight)在场景中央上方,进一步加点局部亮度,视觉上会更生动。

导入并使用 Simplex Noise

import { createNoise2D } from "simplex-noise";
 
const noise2D = createNoise2D();
  • createNoise2D() 会返回一个二维噪声函数 noise2D(x, y),它能给你一个在 [-1,1] 左右浮动的值,用于生成“随机起伏”。
  • 如果你想要固定随机种子,可以传入自定义的 random 函数或使用 seedrandom

动态更新地形

function updateTerrain(time) {
  const positions = terrain.geometry.attributes.position;
  const vertex = new THREE.Vector3();
 
  for (let i = 0; i < positions.count; i++) {
    vertex.fromBufferAttribute(positions, i);
 
    const originalX = vertex.x;
    const originalZ = vertex.z;
 
    // 使用多层噪声
    const scale1 = 0.02;
    const scale2 = 0.04;
    const noise1 = noise2D(originalX * scale1, originalZ * scale1) * 1.0;
    const noise2 = noise2D(originalX * scale2, originalZ * scale2) * 0.5;
 
    // 波动效果(正弦)
    const timeScale = 0.0005;
    const waveHeight = Math.sin(time * timeScale + originalX * 0.05) * 5;
 
    // 合并噪声 + 波动
    const combinedNoise = (noise1 + noise2) * 15 + waveHeight;
 
    // 设置 Y 轴位移
    positions.setY(i, combinedNoise);
  }
 
  positions.needsUpdate = true;
  terrain.geometry.computeVertexNormals(); // 重新计算法线让光照更准确
}
  • 核心思路
    • 遍历地形网格的每个顶点;
    • 根据 X、Z 坐标去计算 simplex-noise;
    • 再叠加一个正弦波(waveHeight)来实现周期性变化;
    • 最终将计算结果作为该顶点的 Y 值。
  • 这里 scale1scale2 控制了噪声的粗细程度(频率),叠加起来让表面更丰富。
  • time 用于把动画帧 performance.now()Date.now() 注入,让波浪随时间变化。

渲染循环与动画

function animate() {
  requestAnimationFrame(animate);
 
  // 更新地形
  updateTerrain(performance.now());
 
  // 更新相机控制
  controls.update();
 
  // 渲染场景
  renderer.render(scene, camera);
}
 
animate();
  • 我们在每一帧都执行 updateTerrain(),通过时间维度让噪声或正弦波产生动态变化,地形就“波动”起来。
  • controls.update() 让 OrbitControls 的惯性衰减生效。
  • requestAnimationFrame() 不断递归调用自身,形成主循环。

完整效果

以上步骤完成后,你会看到一个粉色系的“波动地形”:

  • 有线框结构能清晰看到网格;
  • 在不同位置会有高低起伏;
  • 随着时间推移,正弦波让地形产生规律的上下波动,结合噪声产生更随机的抖动感;
  • OrbitControls 允许鼠标自由旋转、缩放视角来观察这个地形。

如果你想去掉线框,可以把 wireframe: false;想改变颜色或加纹理,也可以调整材质参数;想让地形更平滑,可以加大网格细分(例如分段更高),或者改变噪声函数叠加方式。

代码

https://github.com/calmound/threejs-demo/tree/main/noise (opens in a new tab)