使用 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 来实现一个地面,先看下效果。
搭建基础工程
-
安装依赖
- 你需要一个支持 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
允许你用鼠标旋转、缩放、平移相机,方便调试与观察场景。enableDamping
与dampingFactor
可以让相机在鼠标操作后平滑惯性移动。
构建一个简单的渐变纹理
为了让我们的粉色系“波动地形”更炫,可以添加一个渐变纹理:
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)和材质
-
几何形状
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”平面来操作。
-
材质
const material = new THREE.MeshPhongMaterial({ color: 0xff1493, wireframe: true, emissive: 0x330033, shininess: 80, specular: 0xff00ff, wireframeLinewidth: 1, map: createGradientTexture(), // 使用我们自定义的渐变纹理 });
MeshPhongMaterial
可以让我们看到高光和光照效果。wireframe: true
打开线框模式,使得网格的线条可见。map
参数就是我们自制的渐变纹理。- 颜色全部是粉紫系,符合“炫酷”“梦幻”的风格。
-
创建地形网格并添加到场景
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 值。
- 这里
scale1
、scale2
控制了噪声的粗细程度(频率),叠加起来让表面更丰富。 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)