一、像素世界的流体密码
想象你站在数字海岸边,看着屏幕里的波浪此起彼伏 ------ 这些看似自然的水流,其实是无数像素在遵循数学法则跳着集体舞。作为计算机科学家,我们要做的就是给这些像素编一支 "水之舞" 的舞曲。
Three.js 就像一个数字舞台,而我们要设计的流水效果,本质上是用三角形网格模拟水面,再通过顶点位移让平面 "动" 起来。这背后藏着两个核心密码:一是如何让平面看起来像水,二是如何让它产生自然的波动。
先打个比方:如果把水面比作一张蹦床,每个顶点就是蹦床上的弹簧。当我们按一定规律拉扯这些弹簧时,平面就会呈现波浪形态。而水的透明感和反光,则像是给这张蹦床蒙上了一层会反光的塑料膜。
二、搭建数字水池的基础框架
要制作流水效果,我们首先需要搭建基本的 3D 场景。就像画油画要先准备画布和颜料,我们需要创建场景、相机和渲染器这 "三驾马车"。
javascript
// 创建场景 - 相当于我们的数字水池
const scene = new THREE.Scene();
// 创建相机 - 我们观察世界的眼睛
const camera = new THREE.PerspectiveCamera(
75, // 视野角度,就像人眼的余光范围
window.innerWidth / window.innerHeight, // 宽高比,防止画面变形
0.1, // 最近能看到的距离
1000 // 最远能看到的距离
);
camera.position.z = 5; // 把相机架在水面上方5个单位
// 创建渲染器 - 像素画师,负责把3D场景画到屏幕上
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
有了这些基础,我们就有了一个能展示 3D 世界的舞台。接下来,该请出我们的 "水面" 主角了。
三、从平面到水面:给蹦床蒙上水膜
要制作水面,我们需要一个平面几何体作为基础。但这个平面不能是光秃秃的,得给它穿上 "水" 的外衣。
csharp
// 创建水面几何体 - 一张10x10的网格,分成50x50个小格子
// 格子越多,水面越细腻,但计算量也越大,就像细密的渔网比粗网更能捕捉涟漪
const waterGeometry = new THREE.PlaneGeometry(10, 10, 50, 50);
// 创建水面材质 - 这是让平面看起来像水的关键
const waterMaterial = new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 }, // 时间变量,用来控制动画
color: { value: new THREE.Color(0.1, 0.3, 0.8) } // 水的基础颜色
},
vertexShader: `
uniform float time;
void main() {
// 暂时让顶点保持原样
gl_Position = projectionMatrix * modelViewMatrix * vec3(position);
}
`,
fragmentShader: `
uniform vec3 color;
void main() {
gl_FragColor = vec4(color, 0.8); // 带透明度的蓝色
}
`,
transparent: true // 允许透明
});
// 创建水面网格并添加到场景
const water = new THREE.Mesh(waterGeometry, waterMaterial);
water.rotation.x = -Math.PI / 2; // 让平面水平放置(默认是垂直的)
scene.add(water);
现在我们有了一个蓝色的透明平面,但它还只是块 "塑料板",要让它变成水,就得让它 "动" 起来。
四、让水面跳起来:波浪的数学舞蹈
真实的水波其实是无数不同频率、不同振幅的正弦波叠加的结果。就像合唱团里有高音、中音、低音,它们的和声才构成了丰富的音效,水波的叠加也能产生复杂自然的效果。
我们来给水面的每个顶点添加位移,让它们按照正弦规律上下运动。这里有个小技巧:让不同位置的顶点有不同的运动相位,这样才能形成 "波浪推进" 的效果。
ini
// 修改顶点着色器,添加波浪效果
waterMaterial.vertexShader = `
uniform float time;
void main() {
// 保存原始位置
vec3 pos = position;
// 第一个波浪:沿X轴传播
float wave1 = sin(pos.x * 1.5 + time) * 0.05;
// 第二个波浪:沿Z轴传播,频率和振幅不同
float wave2 = sin(pos.z * 2.0 + time * 1.2) * 0.03;
// 第三个波浪:斜向传播,制造更复杂的效果
float wave3 = sin((pos.x + pos.z) * 1.0 + time * 0.8) * 0.04;
// 叠加波浪效果(Y轴是上下方向)
pos.y = wave1 + wave2 + wave3;
// 计算最终位置
gl_Position = projectionMatrix * modelViewMatrix * vec3(pos);
}
`;
这段代码给每个顶点的 Y 坐标(上下方向)添加了三个正弦波的叠加效果。想象每个顶点都在做上下运动,但有的快、有的慢,有的幅度大、有的幅度小,这样就形成了自然的波浪。
现在我们需要一个动画循环来更新时间变量,让波浪 "动" 起来:
scss
function animate() {
requestAnimationFrame(animate);
// 更新时间(每帧增加一点,让动画持续进行)
waterMaterial.uniforms.time.value += 0.01;
renderer.render(scene, camera);
}
animate();
运行这段代码,你会看到平面开始上下起伏,但还缺点 "水" 的质感 ------ 真实的水不仅会上下动,还会反光。
五、给水面照镜子:反射与折射的小把戏
水之所以看起来像水,很大程度上是因为它会反射周围环境,同时又有一定的透明度。我们可以用菲涅尔效应来模拟这种特性:当视角接近水面切线时,反射更强;正视水面时,折射(透明度)更明显。
ini
// 更新片元着色器,添加菲涅尔效果
waterMaterial.fragmentShader = `
uniform vec3 color;
uniform float time;
varying vec3 vNormal; // 法向量,用于计算反射
varying vec3 vViewPosition; // 视角方向
void main() {
// 计算菲涅尔系数:视角越倾斜,反射越强
float fresnel = dot(normalize(vViewPosition), vNormal) * 0.5 + 0.5;
fresnel = 1.0 - fresnel; // 反转效果
// 基础颜色(透明度受菲涅尔影响)
vec3 baseColor = color * (0.5 + fresnel * 0.5);
// 添加一些随机噪点,模拟水的不平整
float noise = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898,78.233))) * 43758.5453);
// 结合噪点和菲涅尔效果,让颜色更丰富
gl_FragColor = vec4(baseColor + noise * 0.1, 0.7 + fresnel * 0.3);
}
`;
// 同时修改顶点着色器,传递法向量和视角位置
waterMaterial.vertexShader = `
uniform float time;
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec3 pos = position;
// 波浪计算(和之前一样)
float wave1 = sin(pos.x * 1.5 + time) * 0.05;
float wave2 = sin(pos.z * 2.0 + time * 1.2) * 0.03;
float wave3 = sin((pos.x + pos.z) * 1.0 + time * 0.8) * 0.04;
pos.y = wave1 + wave2 + wave3;
// 计算法向量(简化版)
vNormal = normalize(vec3(-dFdx(pos.y), 1.0, -dFdy(pos.y)));
// 计算视角位置
vViewPosition = -vec3(modelViewMatrix * vec4(pos, 1.0));
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
现在水面有了更自然的透明感和反光效果,就像给 "蹦床" 蒙上了一层会反光的弹性膜。菲涅尔效应的加入,让水面在边缘处更亮(反射强),中心处更透(折射强),这完全符合我们观察真实水面的经验。
六、让水更 "水":细节优化的魔法
要让流水效果更逼真,我们还需要添加一些细节。比如水波的速度应该随时间变化,模拟风力变化;水面应该有微小的随机扰动,避免过于规律的重复。
ini
// 在uniforms中添加更多控制变量
waterMaterial.uniforms = {
time: { value: 0 },
color: { value: new THREE.Color(0.1, 0.3, 0.8) },
wind: { value: new THREE.Vector2(1.0, 0.5) }, // 风向和风速
noiseScale: { value: 0.1 } // 噪点强度
};
// 更新顶点着色器,让波浪受风向影响
waterMaterial.vertexShader = `
uniform float time;
uniform vec2 wind;
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec3 pos = position;
// 波浪方向受风向影响
float angle = atan(wind.y, wind.x);
float dist = pos.x * cos(angle) + pos.z * sin(angle);
// 更复杂的波浪叠加
float wave1 = sin(dist * 1.5 + time) * 0.05;
float wave2 = sin(dist * 2.0 + time * 1.2) * 0.03;
float wave3 = sin((pos.x * 0.8 + pos.z * 0.8) + time * 0.8) * 0.04;
pos.y = wave1 + wave2 + wave3;
// 计算法向量
vNormal = normalize(vec3(-dFdx(pos.y), 1.0, -dFdy(pos.y)));
// 计算视角位置
vViewPosition = -vec3(modelViewMatrix * vec4(pos, 1.0));
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`;
// 在动画循环中随机改变风向,模拟自然风
function animate() {
requestAnimationFrame(animate);
waterMaterial.uniforms.time.value += 0.01;
// 每100帧轻微改变风向
if (Math.floor(waterMaterial.uniforms.time.value) % 100 === 0) {
const wind = waterMaterial.uniforms.wind.value;
wind.x += (Math.random() - 0.5) * 0.1;
wind.y += (Math.random() - 0.5) * 0.1;
wind.normalize().multiplyScalar(1.0 + Math.random() * 0.5);
}
renderer.render(scene, camera);
}
现在我们的水面会随 "风向" 改变波浪方向,并且风速也会随机变化,这让水流效果更加自然。就像真实世界里没有永远不变的风,我们的数字水池也不会有一成不变的波浪。
七、最终的魔法:添加环境互动
要让流水效果更生动,我们可以添加一些互动元素。比如让鼠标位置影响水流方向,就像用手指在水面划动一样。
ini
// 添加鼠标互动
let mouse = new THREE.Vector2(0, 0);
window.addEventListener('mousemove', (event) => {
// 将鼠标位置转换为-1到1的范围
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
});
// 在动画循环中让风向跟随鼠标
function animate() {
requestAnimationFrame(animate);
waterMaterial.uniforms.time.value += 0.01;
// 鼠标位置影响风向
const wind = waterMaterial.uniforms.wind.value;
wind.x += (mouse.x - wind.x) * 0.01;
wind.y += (mouse.y - wind.y) * 0.01;
wind.normalize().multiplyScalar(1.0);
renderer.render(scene, camera);
}
现在当你移动鼠标时,水面的波浪方向会随之改变,仿佛你的鼠标变成了一只无形的手,在数字水面上拨弄出层层涟漪。
八、从代码到海洋:像素背后的哲学
回顾整个制作过程,我们从一个简单的平面开始,通过添加波浪运动、光学效果和互动机制,最终创造出了栩栩如生的流水效果。这背后是数学、物理和计算机图形学的完美结合。
每个正弦函数都是对自然现象的简化,每个像素的颜色都是对光学规律的模拟。作为开发者,我们既是数学家,计算着波浪的频率;又是物理学家,模拟着光的反射;还是艺术家,调和着水的色彩与透明度。
下次当你看到屏幕里的流水时,不妨想想那些在幕后辛勤工作的顶点和像素 ------ 它们用最简单的数学运算,编织出了最复杂的自然奇迹。而我们,正是这些数字奇迹的魔法师。