Three.js案例
Rainbox

一、功能概述

这个示例最大的特点是以“水面”为灵感,模拟水滴落下后的波纹扩散效果。不同于只对平面做简单的顶点扰动,这里采用了 InstancedMesh 来批量创建多个网格方块,并通过设置它们的 position.y 高度,展示起伏变化。与此同时,代码还会定期生成“水滴”,水滴落下时在平面上产生新的波纹,引起更多方块的上下波动。

二、工具函数

在开始主逻辑之前,代码里提供了一组常用工具函数,例如将角度转换为弧度、计算两点距离、数值映射以及十六进制颜色转换。这些函数在动画或数学计算过程中都非常常见,并且能简化后续的实现逻辑。

  1. 先来看 radians 函数,它能将给定的角度转换为弧度。因为在 Three.js 中,大多数需要角度的地方都必须以弧度形式输入。这样就可以避免每次都手动写 Math.PI / 180
  2. distance 函数用于计算平面上两点之间的距离,返回值是通过勾股定理(sqrt((x1 - x2)^2 + (y1 - y2)^2))求得,后面会用来判断每个方块与波心之间的距离,从而决定它的起伏高度。
  3. map 函数常被用在动画或可视化中,用来把一个区间的数值映射到另一个区间,能让输入值更好地对接到我们需要的输出范围。
  4. hexToRgbTreeJs 用于将 Hex 格式的颜色(如#ffffff)解析为 Three.js 中 r, g, b 范围在 0~1 的对象。这样就可以把 GUI 中选择的十六进制颜色正确应用到材质里。
  5. 这些函数彼此独立,但都为后面核心的水波逻辑或交互提供了基础数学支持和颜色转换支持。
// 将角度转换为弧度
const radians = (degrees) => {
  return (degrees * Math.PI) / 180;
};
 
// 计算二维平面上两点之间的距离
const distance = (x1, y1, x2, y2) => {
  return Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2));
};
 
// 将 value 从 [istart, istop] 区间映射到 [ostart, ostop] 区间
const map = (value, istart, istop, ostart, ostop) => {
  return ostart + (ostop - ostart) * ((value - istart) / (istop - istart));
};
 
// 将 Hex 颜色转换为 Three.js 可用的 RGB 对象,范围在 0~1
const hexToRgbTreeJs = (hex) => {
  const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  return result
    ? {
        r: parseInt(result[1], 16) / 255,
        g: parseInt(result[2], 16) / 255,
        b: parseInt(result[3], 16) / 255,
      }
    : null;
};

三、主类结构与初始化

在这部分,我们会创建一个 App 类,里面包含了场景、相机、光源等通用 Three.js 元素的初始化流程。它还会设置一些交互式的界面(GUI),方便我们在运行时调整背景颜色、波浪颜色等参数。

  1. setup() 方法中主要做了几件事:初始化 Stats(帧率监控)、dat.GUI(可视化参数调节)以及一些用于波纹动画的变量。Stats 可以帮助我们在页面上看到实时的 FPS,dat.GUI 能让我们动态修改颜色、波纹大小等数值。
  2. 我们还监听了浏览器的 resize 事件,每当窗口尺寸变化时,就会调用 onResize 方法来更新相机和渲染器的大小,以保持画面正常显示。
  3. 通过 visibilitychange 来监听页面是否切换至后台,如果页面不可见,则暂停水滴的动画,否则水滴会一直落下,浪费性能或引发不必要的计算。
  4. init() 方法是最终的入口,它调用了本类中的各个创建场景、添加物体、动画循环等方法,让整个 Three.js 程序得以完成完整的生命周期。
  5. 这样分层设计,可以让代码结构更清晰,也方便日后扩展或修改功能。
class App {
  // 初始化属性、监听事件等
  setup() {
    // Stats: 用于监控每帧渲染时间和帧率
    this.stats = new Stats();
    this.stats.showPanel(0);
    document.body.querySelector(".stats").appendChild(this.stats.domElement);
 
    // dat.GUI: 图形化的参数调节面板
    this.gui = new dat.GUI();
 
    // 一些常用属性
    this.backgroundColor = "#faff06";
    this.gutter = { size: 0 };
    this.meshes = [];
    this.grid = { cols: 30, rows: 30 };
    this.width = window.innerWidth;
    this.height = window.innerHeight;
    this.velocity = -0.1; // 波纹传播速度
    this.angle = 0;
    this.waveLength = 200; // 波长
    this.ripple = {};
    this.interval = 0;
    this.waterDropPositions = [];
    this.ripples = [];
 
    // GUI 分组: 更改背景色
    const gui = this.gui.addFolder("Background");
    gui.addColor(this, "backgroundColor").onChange((color) => {
      document.body.style.backgroundColor = color;
    });
 
    // 当窗口尺寸改变时,执行 onResize
    window.addEventListener("resize", this.onResize.bind(this), { passive: true });
 
    // 监听页面可见性, 如果隐藏则停止动画
    window.addEventListener(
      "visibilitychange",
      (evt) => {
        this.pause = evt.target.hidden;
      },
      false
    );
  }
 
  // 整个程序的入口
  init() {
    this.setup();
 
    this.createScene();
    this.createCamera();
    this.addAmbientLight();
    this.addDirectionalLight();
    this.createGrid();
    this.addCameraControls();
    this.addFloor();
 
    // 启动循环动画
    this.animate();
    this.draw();
 
    // 开始水滴的下落
    this.animateWaterDrops();
  }
}

四、创建场景、渲染器与相机

在 Three.js 中,场景(Scene)、渲染器(Renderer)、相机(Camera)是最基本的三要素。下面的 createScenecreateCamera 方法分别做了如下事情:

  1. createScene()

    • 新建一个 THREE.Scene() 作为所有物体的容器。
    • 使用 WebGLRenderer 来进行 GPU 加速渲染,并设置 antialias: true 以平滑边缘。
    • 打开 shadowMap 以便渲染阴影,并将渲染器生成的 <canvas> 添加到页面中。
  2. createCamera()

    • 采用 PerspectiveCamera,并设置 fov (10 度视野)、width/height 的宽高比,以及裁剪面的近远范围。
    • 调整了相机的位置 (x = -180, y = 180, z = 180),使我们在运行后能够从一个稍微俯视的角度观察场景。
    • 最后把相机添加到场景中,以便后续渲染时能正常拍摄到物体。
  3. 这种将相机放置在远处、且有一定高度的设计,可以让我们在 OrbitControls 的帮助下更容易看到整体波纹状况。

  4. 之所以选择 PerspectiveCamera 而不是 OrthographicCamera,是因为水波这种场景在透视视角下更具纵深感。

  5. 通过设置 shadowMap.typeTHREE.PCFSoftShadowMap,可以渲染出更柔和的阴影过渡,这在水波场景中可以带来更加自然的光影效果。

createScene() {
  // 创建 Three.js 场景
  this.scene = new THREE.Scene();
 
  // 创建渲染器,并设置大小、阴影等
  this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  this.renderer.setSize(window.innerWidth, window.innerHeight);
  this.renderer.shadowMap.enabled = true;
  this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
 
  // 将渲染器的 canvas 加到 body
  document.body.appendChild(this.renderer.domElement);
}
 
createCamera() {
  // 获取画布大小,用来设置相机宽高比
  const width = window.innerWidth;
  const height = window.innerHeight;
 
  // 创建透视相机,fov = 10度,适配画面
  this.camera = new THREE.PerspectiveCamera(10, width / height, 1, 1000);
  // 将相机放远一点,并往上抬,看到更大范围
  this.camera.position.set(-180, 180, 180);
 
  // 将相机加入场景
  this.scene.add(this.camera);
}

五、光源:环境光与定向光

为了让场景具有基础的亮度和阴影,我们需要在场景中添加两种光源:环境光(AmbientLight)定向光(DirectionalLight)。它们在水波模拟中各有分工。

  1. 环境光会均匀地照亮整个场景,不管物体朝向如何,都能获得一些基本的光照填充,因此往往用来防止物体处于完全黑暗中。
  2. 定向光类似于太阳光,能产生清晰的阴影效果。这里将其位置设置为 (0, 1, 0) 并打开 castShadow,让它可以在场景里投下阴影。
  3. directionalLight.shadow.camera 中的各种参数(far、near、left、right、top、bottom 等)用来控制光源相机在生成阴影贴图时的可视范围。设置合理的阴影范围可以保证阴影既不会被切掉,也不会浪费过多性能。
  4. 借助 targetObject 可以将定向光对准某个特定目标位置,让光照方向更加可控。
  5. 在实际场景中,如果想要更多拟真细节,还可以通过调整 intensity、添加更多类型的光源等方式来丰富光影。
addAmbientLight() {
  const obj = { color: '#fff' };
  // 环境光,让整体场景有均匀的照明
  const light = new THREE.AmbientLight(obj.color, 1);
  this.scene.add(light);
}
 
addDirectionalLight() {
  // 创建定向光,并启用阴影
  this.directionalLight = new THREE.DirectionalLight(0xffffff, 1);
  this.directionalLight.castShadow = true;
  this.directionalLight.position.set(0, 1, 0);
 
  // 设置阴影相机的可视范围
  this.directionalLight.shadow.camera.far = 1000;
  this.directionalLight.shadow.camera.near = -100;
  this.directionalLight.shadow.camera.left = -40;
  this.directionalLight.shadow.camera.right = 40;
  this.directionalLight.shadow.camera.top = 20;
  this.directionalLight.shadow.camera.bottom = -20;
 
  // 指定一个对象来作为光源照射的目标
  const targetObject = new THREE.Object3D();
  targetObject.position.set(-50, -82, 40);
  this.directionalLight.target = targetObject;
 
  this.scene.add(this.directionalLight);
  this.scene.add(this.directionalLight.target);
}

六、创建波纹网格(InstancedMesh)

接下来是本示例最关键的部分之一:通过 InstancedMesh 创建大量的网格方块,并在后续对它们的 position.y 做动画修改,形成波面起伏。

  1. createGrid() 中首先创建一个 THREE.Object3D() 作为容器,然后定义了材质参数(初始颜色为深蓝色),最后使用 InstancedMesh 来实例化网格。这样做相比单独创建上百或上千个 Mesh 性能更好。
  2. this.mesh = this.getMesh(geometry, material, this.grid.rows * this.grid.cols); 这里我们先创建了一块 BoxBufferGeometry(1,1,1),并在 getMesh 函数里通过 new THREE.InstancedMesh(...) 生成指定数量的实例。
  3. 每个方块被存储在一个二维数组 this.meshes[row][col] 中,以便我们能轻松定位它们在网格中的行列位置。
  4. this.centerXthis.centerZ 用于计算网格中心,使得我们渲染出来的“波面”能够处于相机的正前方。否则,网格可能会偏离可见区域。
  5. waterDropPositions 会记录网格中每个方块的 (x,z) 坐标,用来随机选择水滴落下的位置,让后续的波纹传播更灵活。
createGrid() {
  // 用于整体存放网格的容器
  this.groupMesh = new THREE.Object3D();
 
  // 基础方块材质
  const meshParams = {
    color: '#00229a',
  };
  const material = new THREE.MeshLambertMaterial(meshParams);
 
  // 把颜色调节的选项加进 GUI
  const gui = this.gui.addFolder('Water');
  gui.addColor(meshParams, 'color').onChange((color) => {
    material.color = hexToRgbTreeJs(color);
  });
 
  // 每个实例是一个 1x1x1 的方块
  const geometry = new THREE.BoxBufferGeometry(1, 1, 1);
 
  // 生成 rows * cols 个实例
  this.mesh = this.getMesh(geometry, material, this.grid.rows * this.grid.cols);
  this.scene.add(this.mesh);
 
  // 计算网格的中心值,用于让网格居中
  this.centerX = ((this.grid.cols) + ((this.grid.cols) * this.gutter.size)) * .4;
  this.centerZ = ((this.grid.rows) + ((this.grid.rows) * this.gutter.size)) * .6;
 
  // 遍历行列,给每一个方块实例设置初始位置
  let ii = 0;
  for (let row = 0; row < this.grid.rows; row++) {
    this.meshes[row] = [];
 
    for (let col = 0; col < this.grid.cols; col++) {
      const pivot = new THREE.Object3D();
      const x = col + (col * this.gutter.size);
      const z = row + (row * this.gutter.size);
 
      // 将方块的中心位置做偏移,让画面更居中
      pivot.position.set(x - this.centerX, 0, z - this.centerZ);
 
      this.meshes[row][col] = pivot;
      pivot.updateMatrix();
      // 为 InstancedMesh 中的第 ii 个实例设置变换矩阵
      this.mesh.setMatrixAt(ii++, pivot.matrix);
    }
  }
 
  // 通知 InstancedMesh 更新
  this.mesh.instanceMatrix.needsUpdate = true;
 
  // 水滴可能落下的所有位置,也就是每个方块的 (x, z)
  for (let row = 0; row < this.grid.rows; row++) {
    for (let col = 0; col < this.grid.cols; col++) {
      const x = col + (col * this.gutter.size);
      const z = row + (row * this.gutter.size);
      this.waterDropPositions.push({ x: x - this.centerX, z: z - this.centerZ });
    }
  }
}
 
getMesh(geometry, material, count) {
  // 通过 InstancedMesh 可以一次性创建多个网格实例
  const mesh = new THREE.InstancedMesh(geometry, material, count);
  mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
  mesh.castShadow = true;
  mesh.receiveShadow = true;
 
  return mesh;
}

七、控制器与地面

这一部分添加了 OrbitControls,用于鼠标拖动与缩放,以及一个阴影承载的地面(PlaneGeometry),让投下的阴影更具真实感。

  1. addCameraControls():通过 OrbitControls 来对相机进行平移、旋转、缩放的交互式操作,并且设置了 enableDampingdampingFactor,让拖拽时拥有一定的“惯性”效果。
  2. 为了让鼠标操作时有对应的提示,比如按下拖拽时切换光标样式,这里监听了 startend 事件,设置不同的光标。
  3. addFloor():创建一个足够大的平面 PlaneGeometry(100,100),并在其上使用 ShadowMaterial 作为材质,让掉落的阴影看起来更柔和半透明。
  4. 将地面稍微下沉到 y = -1,并旋转成水平面,再启用 receiveShadow,就能让物体投射的阴影出现在地面上。
  5. 之所以用 ShadowMaterial 而不是纯色材质,是因为这样可以让地面只接收阴影,而不会对物体的外观产生干扰(地面本身几乎是透明的,但阴影会在上面显现)。
addCameraControls() {
  // 绑定 OrbitControls,让我们用鼠标操控相机
  this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
  this.controls.enableDamping = true;
  this.controls.dampingFactor = 0.04;
 
  // 设置鼠标样式
  document.body.style.cursor = "-moz-grabg";
  document.body.style.cursor = "-webkit-grab";
 
  this.controls.addEventListener("start", () => {
    requestAnimationFrame(() => {
      document.body.style.cursor = "-moz-grabbing";
      document.body.style.cursor = "-webkit-grabbing";
    });
  });
 
  this.controls.addEventListener("end", () => {
    requestAnimationFrame(() => {
      document.body.style.cursor = "-moz-grab";
      document.body.style.cursor = "-webkit-grab";
    });
  });
}
 
addFloor() {
  // 地面,用于接收阴影
  const geometry = new THREE.PlaneGeometry(100, 100);
  const material = new THREE.ShadowMaterial({ opacity: .3 });
  this.floor = new THREE.Mesh(geometry, material);
 
  // 让地面处于 y=-1,水平放置
  this.floor.name = 'floor';
  this.floor.position.y = -1;
  this.floor.rotateX(- Math.PI / 2);
  this.floor.receiveShadow = true;
 
  this.scene.add(this.floor);
}

八、水滴生成与波纹扩散核心

这一段逻辑决定了水波如何产生、以及波纹怎样向外传播。它包括随机水滴的生成、动画下落,以及对波纹属性的维护与更新。

8.1 水滴生成

  1. animateWaterDrops() 会定时(每隔 100ms)创建一个新的水滴,让水滴从高处落到网格表面。
  2. 在创建水滴时,我们随机选取 this.waterDropPositions 里的坐标,并将水滴放在 y=50 的高度。
  3. 借助 GSAP 的 TweenMax.to 来实现水滴的下落动画。下落过程中,当 waterDrop.position.y < 1 时,我们往 this.ripples 数组里添加一个波纹信息,包括 (x,z) 位置、velocityangleamplitude 等,这些数据将决定波纹如何传播。
  4. 如果页面不可见(this.pause),则移除水滴并停止动画,避免浪费资源。
  5. 最终的动画结束时,将水滴从场景中移除,避免过多水滴堆叠浪费内存或影响性能。
animateWaterDrops() {
  const meshParams = {
    color: '#6ad2ff',
  };
 
  // 水滴的形状和材质
  const geometry = new THREE.BoxBufferGeometry(.5, 2, .5);
  const material = new THREE.MeshLambertMaterial(meshParams);
 
  // 把水滴颜色也加到 GUI 中
  const gui = this.gui.addFolder('Drop');
  gui.addColor(meshParams, 'color').onChange((color) => {
    material.color = hexToRgbTreeJs(color);
  });
 
  // 定时生成水滴
  this.interval = setInterval(() => {
    // 随机选择一个网格位置作为落点
    const waterDrop = this.addWaterDrop(geometry, material);
    const { x, z } = this.getRandomWaterDropPosition();
 
    // 将水滴放在高处
    waterDrop.position.set(x, 50, z);
    this.scene.add(waterDrop);
 
    // 如果页面不可见,则停止动画
    if (this.pause) {
      this.scene.remove(waterDrop);
      TweenMax.killAll(true);
    } else {
      // 下落动画
      TweenMax.to(waterDrop.position, .5, {
        ease: Sine.easeIn,
        y: -2,
        onUpdate: () => {
          // 当水滴接近地面时,生成新的波纹
          if (waterDrop.position.y < 1 ) {
            this.ripples.push({
              x,
              z,
              velocity: -1,
              angle: 0,
              amplitude: .1,
              radius: 1,
              motion: -.7
            });
          }
        },
        onComplete: () => {
          // 动画结束后,把水滴移除
          waterDrop.position.set(0, 50, 0);
          this.scene.remove(waterDrop);
        }
      });
    }
  }, 100);
}
 
// 创建并返回一个水滴Mesh
addWaterDrop(geometry, material) {
  const waterDrop = new THREE.Mesh(geometry, material);
  return waterDrop;
}
 
// 随机获取一个水滴落点
getRandomWaterDropPosition() {
  return this.waterDropPositions[Math.floor(Math.random() * Math.floor(this.waterDropPositions.length))];
}

8.2 波纹传播与网格更新

  1. this.ripples 数组中的每个波纹在 draw() 方法里被处理。我们会遍历所有网格方块,并计算它们到波纹中心的距离 dist
  2. 如果某个方块与波心的距离小于 ripple.radius,说明它会被波纹影响,则根据正弦函数 Math.sin(angle) 来计算其高度偏移。
  3. map 函数在这里将正弦值映射到一个合适的高度范围,并且根据波纹的 motion 属性来决定是向上还是向下偏移。
  4. 波纹的属性 angleradiusmotion 都会在每帧更新,从而实现波纹扩散(扩大半径)、高度变化等动态效果。如果波的 radius 超过一定值,就移除它,防止数组膨胀。
  5. 调整完每个实例的位置后,我们要调用 this.mesh.setMatrixAt(ii++, pivot.matrix) 并给 this.mesh.instanceMatrix.needsUpdate = true;,这样 InstancedMesh 才能重新渲染。
draw() {
  let ii = 0;
 
  // 遍历每个方块,根据波纹的范围调整它们的 Y 坐标
  for (let row = 0; row < this.grid.rows; row++) {
    for (let col = 0; col < this.grid.cols; col++) {
      const pivot = this.meshes[row][col];
 
      // 考虑所有存在的波纹
      for (let r = 0; r < this.ripples.length; r++) {
        const ripple = this.ripples[r];
        // 计算当前方块到波心的距离
        const dist = distance(col, row, ripple.x + this.centerX, ripple.z + this.centerZ);
 
        // 如果在波纹半径内,则计算高度
        if (dist < ripple.radius) {
          const offset = map(dist, 0, -this.waveLength, -100, 100);
          const angle = ripple.angle + offset;
          // 用正弦函数来模拟上下波动
          const y = map(Math.sin(angle), -1, 0, ripple.motion > 0 ? 0 : ripple.motion, 0);
 
          pivot.position.y = y;
        }
      }
 
      pivot.updateMatrix();
      this.mesh.setMatrixAt(ii++, pivot.matrix);
    }
  }
 
  // 更新波纹参数
  for (let ripple = 0; ripple < this.ripples.length; ripple++) {
    const r = this.ripples[ripple];
    // 调整波纹的角度、半径、运动值
    r.angle -= this.velocity * 2;
    r.radius -= this.velocity * 3;
    r.motion -= this.velocity / 5;
 
    // 当波纹半径过大时,移除它
    if (r.radius > 50) {
      this.ripples.shift();
    }
  }
 
  // 告诉 InstancedMesh 需要更新矩阵
  this.mesh.instanceMatrix.needsUpdate = true;
}

九、动画循环与尺寸响应

最后,为了让这整个场景随时间不断刷新并渲染,我们必须在每帧调用 animate()。同时,通过 onResize() 来适应窗口尺寸变化。

  1. animate() 中先调用 this.stats.begin()this.stats.end(),让帧率监控知道开始和结束渲染的时间。中间插入 this.controls.update() 来更新相机位置,以及 this.draw() 来执行波纹计算。
  2. renderer.render(scene, camera) 则将场景绘制到屏幕上,并通过 requestAnimationFrame 再次回调,实现无限循环。
  3. onResize() 通过更新 camera.aspectrenderer.setSize 来保证在不同窗口或设备尺寸下,都能正常显示,而不变形。
  4. 这两段逻辑合在一起,可以使得水波动画持续进行,并且在浏览器尺寸变动时依旧保持正确的渲染比例。
animate() {
  this.stats.begin();
 
  // 控制器刷新,处理鼠标旋转等
  this.controls.update();
 
  // 计算波纹,更新 InstancedMesh
  this.draw();
 
  // 渲染当前帧
  this.renderer.render(this.scene, this.camera);
 
  this.stats.end();
 
  requestAnimationFrame(this.animate.bind(this));
}
 
// 浏览器尺寸变化时的回调
onResize() {
  this.width = window.innerWidth;
  this.height = window.innerHeight;
 
  // 相机的宽高比要和浏览器同步
  this.camera.aspect = this.width / this.height;
  this.camera.updateProjectionMatrix();
 
  // 同时调整渲染器大小
  this.renderer.setSize(this.width, this.height);
}

代码

https://codepen.io/iondrimba/pen/EMwvgE (opens in a new tab)