Three.js 案例 - 实现动态波动水面效果
在这个示例中,我们将创建一个模拟水面波动的动态平面。
一、功能概述
外观类似水波,上下起伏并随时间变化。关键思路是利用 正弦函数(sine) 和 余弦函数(cosine) 分别处理网格顶点在 x、y 方向上的位移,再在动画循环中不断刷新它们的位置坐标,从而形成逼真的水波动态。
同时,我们也会使用一些水纹理资源,并借助着色器中的置换贴图(DisplacementMap)来让光照和表面细节更加真实。通过合理地设置光照、阴影和相机参数,我们可以让这个场景更具沉浸感。
二、场景与渲染器基础设置
下面的代码块主要完成了三件事:创建场景(Scene)、设置相机(Camera)、以及初始化渲染器(Renderer)。我们用 PerspectiveCamera
给出一个带有透视效果的视图,让物体远小近大,增强真实感。初始化渲染器时,我们启用抗锯齿,并设置阴影以呈现更好的画面效果。
在正式展示代码之前,需要强调几点:
THREE.Scene()
是 Three.js 中的核心容器,所有的物体、光源都要添加到这个场景才能被渲染。THREE.PerspectiveCamera
的几个参数分别是:视角(FOV)、宽高比、近截面和远截面。合理的近、远截面可以保证性能和可视范围的平衡。WebGLRenderer
提供了 GPU 加速的渲染,在调用renderer.setSize
时,需要设置画布大小以适配浏览器窗口。- 我们将
renderer.shadowMap.enabled
设置为true
,表示允许场景中的物体产生并接收阴影。 - 在文档最后,我们会使用
document.body.appendChild(renderer.domElement)
,将渲染器所绘制的画布元素插入到页面中,才能在浏览器中看到实际效果。
// SCENE
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xa8def0); // 设置场景背景色,给人一种天空的氛围
// CAMERA
const camera = new THREE.PerspectiveCamera(
45, // 视角FOV,数值越大,视野越广,越容易产生透视变形
window.innerWidth / window.innerHeight, // 摄像机宽高比
0.1, // 近截面(能够渲染的最近距离)
1000 // 远截面(能够渲染的最远距离)
);
camera.position.y = 5; // 相机在y轴上抬高,让我们能从上方看向波浪平面
// RENDERER
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染画布大小
renderer.setPixelRatio(window.devicePixelRatio); // 设置像素比,保证高分辨率屏幕清晰度
renderer.shadowMap.enabled = true; // 启用阴影
在这里,我们把相机的 y
值设置为 5,也就是说我们默认让相机居于“水平面上方”的位置。这样在后面看波浪时,会有一个俯视的角度。同时,通过 scene.background
来设定天空的背景色,最终渲染画面看上去会更具层次和真实感。
三、OrbitControls 控制器
使用 OrbitControls 可以在鼠标拖拽或滚轮缩放时,对相机进行平移、旋转、缩放等操作。
- 引用
OrbitControls
需要从three/examples/jsm/controls/OrbitControls
引入。 - 创建时传入相机和渲染器的
domElement
,表示要控制哪个相机,以及在何处监听鼠标事件。 - 通过修改
controls.target
可以调整环绕的中心点,这个示例里,我们设置了target.z = -40
,让镜头默认对准后方区域。 - 调用
controls.update()
用来根据设置的target
刷新相机状态,否则相机的位置不会立刻改变。 - 这部分控制器可以帮助我们在浏览器中随意查看波浪平面的不同角度,更方便调试和观赏。
// CONTROLS
const controls = new OrbitControls(camera, renderer.domElement);
controls.target = new THREE.Vector3(0, 0, -40); // 设置相机默认观察的目标点
controls.update(); // 刷新相机,使其面向指定的目标点
我们设定的 target
是 z = -40
,再配合后续的几何体放置位置,就能让镜头默认对准那个平面。当用户滚动鼠标或拖拽时,即可更自由地观察整个场景。
四、添加光源
下面的代码块主要添加了两种光源:环境光(AmbientLight) 和 平行光(DirectionalLight)。
- 环境光会均匀照亮场景中的所有物体,一般用于填充阴影,避免物体完全黑暗。
- 平行光可模拟太阳光效果,从指定方向照射,能产生明显的阴影效果。
- 对平行光设置
castShadow = true
,并配置它的shadow.camera
参数,可控制阴影的可视区域大小和清晰度。 - 平行光设置目标物体,这里把
dirLight.target
指向一个Object3D
,以便精准控制照射的方向。 - 经过上述配置,可以让平面上出现随波浪起伏而产生的阴影变换。
// AMBIENT LIGHT
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
// DIRECTIONAL LIGHT
const dirLight = new THREE.DirectionalLight(0xffffff, 1.0);
dirLight.position.x += 20;
dirLight.position.y += 20;
dirLight.position.z += 20;
dirLight.castShadow = true; // 启用阴影
dirLight.shadow.mapSize.width = 4096; // 阴影贴图尺寸,越大阴影越清晰,但性能开销越高
dirLight.shadow.mapSize.height = 4096;
const d = 25;
dirLight.shadow.camera.left = -d;
dirLight.shadow.camera.right = d;
dirLight.shadow.camera.top = d;
dirLight.shadow.camera.bottom = -d;
dirLight.position.z = -30;
let target = new THREE.Object3D();
target.position.z = -20; // 指定光源照射的中心位置
dirLight.target = target;
dirLight.target.updateMatrixWorld();
dirLight.shadow.camera.lookAt(0, 0, -30);
scene.add(dirLight);
以上做法让我们可以看到随着波浪上下起伏而产生的微妙阴影变换,这也使得“水面”更接近真实环境。尤其是设置了比较大的 shadow.mapSize
,能让阴影的过渡更平滑。
五、加载贴图资源
在这个示例中,我们使用了水面材质相关的几张贴图,包括颜色贴图(Color)、法线贴图(Normal)、置换贴图(Displacement)、粗糙度贴图(Roughness)以及环境光遮蔽贴图(AO)。
TextureLoader
是 Three.js 中用于加载纹理贴图的常用类,通过load
方法加载本地或远程的图片资源。map
、normalMap
、displacementMap
、roughnessMap
、aoMap
等材质参数,在 Three.js 的MeshStandardMaterial
中都有相应的用途。- 法线贴图能够模拟精细的凹凸细节,让光照更逼真;置换贴图(在这里叫
displacementMap
)会真实改变网格顶点的高度;粗糙度贴图则决定表面反射的程度。 - 加载多个贴图可以极大丰富表面细节,但也要注意性能影响。
- 这些纹理文件都在
./textures/water/
下,用来模拟水面效果。
// TEXTURES
const textureLoader = new THREE.TextureLoader();
const waterBaseColor = textureLoader.load("./textures/water/Water_002_COLOR.jpg");
const waterNormalMap = textureLoader.load("./textures/water/Water_002_NORM.jpg");
const waterHeightMap = textureLoader.load("./textures/water/Water_002_DISP.png");
const waterRoughness = textureLoader.load("./textures/water/Water_002_ROUGH.jpg");
const waterAmbientOcclusion = textureLoader.load("./textures/water/Water_002_OCC.jpg");
将这些贴图加载进来之后,我们就可以把它们应用到后面的 MeshStandardMaterial
,让“水面”在视觉上拥有波纹、水光反射等生动效果。
六、创建波浪平面
这一部分是本示例的核心:创建一个足够细分的网格平面,并将所有与水波相关的贴图和材质信息绑定到网格上。
- 我们使用
PlaneBufferGeometry
并设置了 200 × 200 的细分段数,这样在后续改变顶点位置时,平面能表现出更平滑的波动。 - 在材质
MeshStandardMaterial
中设置了前面加载的各种贴图(map
,normalMap
,displacementMap
等),并将displacementScale
设为0.01
来控制顶点偏移强度。 plane.receiveShadow = true
和plane.castShadow = true
表明该平面既能接收阴影,也能投射阴影。plane.rotation.x = -Math.PI / 2
将平面从默认的 XY 平面变成水平面。- 在创建好后,我们把它添加到场景中,并在
z = -30
的位置上呈现。这样在配合摄像机视角时,就能正好看到整个波浪区域。
// PLANE
const WIDTH = 30;
const HEIGHT = 30;
const geometry = new THREE.PlaneBufferGeometry(WIDTH, HEIGHT, 200, 200);
const plane = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
map: waterBaseColor, // 基础颜色贴图
normalMap: waterNormalMap, // 法线贴图,让光照更逼真
displacementMap: waterHeightMap, // 置换贴图,用于真正改变顶点高度
displacementScale: 0.01, // 置换强度
roughnessMap: waterRoughness, // 粗糙度贴图
roughness: 0, // 默认粗糙度,数值越小越光滑
aoMap: waterAmbientOcclusion, // 环境遮蔽贴图,增强阴影细节
})
);
plane.receiveShadow = true;
plane.castShadow = true;
plane.rotation.x = -Math.PI / 2;
plane.position.z = -30;
scene.add(plane);
这里最重要的点在于细分数与置换贴图的结合:如果网格的细分不够,就算你设置了置换贴图,也无法看出明显的波浪起伏。而高细分数再配合较小的 displacementScale
,可以呈现出细腻且真实的波纹效果。
七、实现波动动画
这个部分的动画逻辑放在一个名为 animate
的函数里,会通过 requestAnimationFrame(animate)
实现循环调用。其核心是在 每一帧 中,通过正弦函数和余弦函数动态改变网格中每个顶点的 z
坐标,从而模拟波动效果。
geometry.attributes.position.count
用来获取平面顶点数量,以便后面遍历。Date.now()
获取当前的时间戳,通过除以 400 等手段让时间流逝的速率变慢,看起来平滑和自然。- 对每个顶点
(x, y, z)
分别计算Math.sin(x + 时间偏移) * damping
和Math.cos(y + 时间偏移) * damping
这两个值,并将结果加到该顶点的z
上。 - 波的公式可以简单理解为:波动量 = A·sin(ωx + φ) + B·cos(ωy + φ),这里 A、B 都是我们自定义的衰减系数。
- 在改变完顶点后,调用
geometry.computeVertexNormals()
重新计算法线,用于光照正确显示,然后把needsUpdate
标记为true
。最后执行renderer.render(scene, camera)
完成本帧渲染。
const count = geometry.attributes.position.count;
const damping = 0.25;
// ANIMATE
function animate() {
// SINE WAVE
const now_slow = Date.now() / 400; // 时间因子,控制波动速度
for (let i = 0; i < count; i++) {
const x = geometry.attributes.position.getX(i);
const y = geometry.attributes.position.getY(i);
const xangle = x + now_slow; // x方向相位
const xsin = Math.sin(xangle) * damping;
const yangle = y + now_slow; // y方向相位
const ycos = Math.cos(yangle) * damping;
geometry.attributes.position.setZ(i, xsin + ycos); // 将正弦和余弦相加,赋值到z坐标
}
geometry.computeVertexNormals(); // 重新计算法线
geometry.attributes.position.needsUpdate = true; // 通知Three.js需要更新顶点数据
renderer.render(scene, camera); // 执行渲染
requestAnimationFrame(animate); // 循环调用
}
通过这段逻辑,每个顶点都会在时间维度上进行正弦和余弦计算,从而产生纵向波动。由于我们是对每个顶点的 Z 值进行微小的偏移,那么平面就会出现“起伏”效果,看上去就像水面一样。geometry.computeVertexNormals()
对光照非常重要,否则波谷与波峰处无法正确地接收和散射光照。
八、页面事件和启动
最后,我们需要把渲染器的 DOM 元素添加到页面中,并监听浏览器的窗口大小变化事件。这样可以保证在调节浏览器窗口大小时,画布的渲染区域也会随之调整,防止出现图像拉伸或扭曲。
document.body.appendChild(renderer.domElement)
可以把 WebGLRenderer 创建的<canvas>
插到网页里。- 在
window.addEventListener('resize', onWindowResize)
中,每次尺寸变化都更新相机的宽高比和渲染器大小。 - 一旦所有内容设置完毕,就调用
animate()
,开启循环渲染。
document.body.appendChild(renderer.domElement);
animate();
// RESIZE HANDLER
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener("resize", onWindowResize);
这样一来,当用户进入页面时,就可以看到一个带有“水面波动”效果的三维场景,而且可以通过鼠标拖动来观察不同角度,浏览器尺寸改变时也不会出现 UI 错乱或画面变形。
九、总结
- 正弦 + 余弦的波动核心
通过对平面几何体每个顶点的z
坐标进行正弦和余弦叠加,我们就能模拟简单而自然的波浪效果。关键在于定时更新这些坐标,并控制时间因子的步进速度。 - 高细分网格的重要性
为了让置换贴图和正弦波计算都能有更平滑的效果,需要足够多的网格顶点。如果平面只有几段或十几段分割,那么波浪的变化会非常生硬,甚至看不出明显的起伏。 - 正常光照与阴影渲染
使用computeVertexNormals()
后,就能让每个顶点都根据新的位置更新法线,使光照在波峰和波谷处更真实,同时配合平行光产生的阴影也能加强立体感。 - 材质与纹理贴图
通过给平面赋予多张贴图,进一步提升了视觉效果。包括颜色、法线、置换、粗糙度和 AO 等贴图组合在一起,能很好地模拟水面的各种物理特性。
代码
github
https://github.com/calmound/threejs-demo/tree/main/wave (opens in a new tab)
gitee
https://gitee.com/calmound/threejs-demo/tree/main/wave (opens in a new tab)