Three.js 流水效果制作:从像素到波澜的魔法之旅

一、像素世界的流体密码

想象你站在数字海岸边,看着屏幕里的波浪此起彼伏 ------ 这些看似自然的水流,其实是无数像素在遵循数学法则跳着集体舞。作为计算机科学家,我们要做的就是给这些像素编一支 "水之舞" 的舞曲。

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);
}

现在当你移动鼠标时,水面的波浪方向会随之改变,仿佛你的鼠标变成了一只无形的手,在数字水面上拨弄出层层涟漪。

八、从代码到海洋:像素背后的哲学

回顾整个制作过程,我们从一个简单的平面开始,通过添加波浪运动、光学效果和互动机制,最终创造出了栩栩如生的流水效果。这背后是数学、物理和计算机图形学的完美结合。

每个正弦函数都是对自然现象的简化,每个像素的颜色都是对光学规律的模拟。作为开发者,我们既是数学家,计算着波浪的频率;又是物理学家,模拟着光的反射;还是艺术家,调和着水的色彩与透明度。

下次当你看到屏幕里的流水时,不妨想想那些在幕后辛勤工作的顶点和像素 ------ 它们用最简单的数学运算,编织出了最复杂的自然奇迹。而我们,正是这些数字奇迹的魔法师。

相关推荐
LaoZhangAI37 分钟前
Kiro vs Cursor:2025年AI编程IDE深度对比
前端·后端
止观止40 分钟前
CSS3 粘性定位解析:position sticky
前端·css·css3
爱编程的喵1 小时前
深入理解JavaScript单例模式:从Storage封装到Modal弹窗的实战应用
前端·javascript
lemon_sjdk1 小时前
Java飞机大战小游戏(升级版)
java·前端·python
G等你下课1 小时前
如何用 useReducer + useContext 构建全局状态管理
前端·react.js
欧阳天羲1 小时前
AI 增强大前端数据加密与隐私保护:技术实现与合规遵
前端·人工智能·状态模式
慧一居士1 小时前
Axios 和Express 区别对比
前端
I'mxx1 小时前
【html常见页面布局】
前端·css·html
万少1 小时前
云测试提前定位和解决问题 萤火故事屋 上架流程
前端·harmonyos·客户端