Three.js案例
从立方体变化到球体的粒子过渡效果

从立方体变化到球体的粒子过渡效果

今天,带大家一起实现粒子过渡动画,使用了 gasp 动画库,从立方体到球体的变化。

一、场景与渲染器基础设置

我们首先要搭建一个可视化的 3D 环境,包括场景(Scene)相机(Camera)渲染器(Renderer)。这样我们才能把随后创建的几何体渲染出来,并且能够在浏览器窗口里自由查看。下面这个代码块就是完成场景、相机和渲染器初始化的关键步骤。

  1. THREE.Scene():Three.js 的场景容器,所有物体和光源都要放进场景里才能被渲染出来。
  2. THREE.PerspectiveCamera:透视摄像机,用来模拟人眼或真实镜头观察物体的方式。它的 4 个核心参数分别是视场角(FOV)、宽高比、近截面和远截面。
  3. THREE.WebGLRenderer:使用 GPU 的加速渲染器,能让 3D 效果在浏览器中高效地显示。为了避免画面锯齿,我们将 antialias 设置为 true
  4. renderer.setSize(window.innerWidth, window.innerHeight):设置渲染画布的宽和高,使之与浏览器窗口匹配。
  5. renderer.domElement 添加到 document.body,表示我们要在网页的 body 标签中插入这个 3D 画面。
import "./style.css";
import javascriptLogo from "./javascript.svg";
import viteLogo from "/vite.svg";
import { setupCounter } from "./counter.js";
import gsap from "gsap";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
 
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000); // 将场景背景设置为黑色
 
const camera = new THREE.PerspectiveCamera(
  75, // 视角FOV,数值越大,视觉范围越广,也会带来更多透视畸变
  window.innerWidth / window.innerHeight, // 摄像机宽高比
  0.1, // 摄像机可视范围的最近端
  1000 // 摄像机可视范围的最远端
);
 
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setClearColor(0x000000); // 设置渲染器清屏颜色
renderer.setSize(window.innerWidth, window.innerHeight); // 占满整个浏览器窗口
 
// 将渲染器的canvas添加到页面上
document.body.appendChild(renderer.domElement);

二、OrbitControls 控制器

接下来的代码块为场景添加了 OrbitControls 控制器,它可以让我们用鼠标来旋转、平移并缩放相机,方便在浏览器中随意观察对象。

  1. OrbitControls 来自 three/examples/jsm/controls/OrbitControls,可以监听鼠标事件并同步更新相机位置。
  2. controls.enableDamping 设置为 true,表示在拖拽停止后相机的旋转/平移会有一点“缓动”效果,更加顺滑。
  3. controls.dampingFactor 控制缓动速度,值越大,阻尼越强。
  4. controls.rotateSpeed 控制旋转速度,值越大,旋转更灵敏。
  5. 每帧都需要调用 controls.update(),才能使控制器的参数变化生效并实时更新相机。
// 添加 OrbitControls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 开启阻尼感
controls.dampingFactor = 0.05; // 阻尼系数
controls.rotateSpeed = 0.5; // 旋转速度

这一步的好处在于,我们无需自行编写繁琐的鼠标交互逻辑。只要在动画循环中让 controls.update() 生效,我们就可以通过鼠标拖拽或滚轮缩放来随意观看我们的点云。

三、浏览器窗口自适应

在实际项目中,通常需要在浏览器窗口大小发生变化时,自动更新相机和渲染器的尺寸,保证渲染画面不变形。接下来这段代码演示了自适应窗口大小的处理方式。

  1. camera.aspect = window.innerWidth / window.innerHeight 在窗口变化时同步修改相机宽高比。
  2. camera.updateProjectionMatrix() 在修改完宽高比后,需要更新摄像机投影矩阵,才能生效。
  3. renderer.setSize(window.innerWidth, window.innerHeight) 再次调整渲染区域的大小。
  4. window.addEventListener('resize', onWindowResize, false) 监听窗口大小变化事件,每当窗口变化时,调用 onWindowResize()
function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}
 
window.addEventListener("resize", onWindowResize, false);

这样做能够最大程度地适配用户不同的显示器和浏览器窗口尺寸,确保点云在各种分辨率下都能保持正确的比例。

四、构建点云的基础几何

下面这个代码块首先设定了一些关于点云的基本参数,然后在几何体(BufferGeometry)中创建好存放顶点坐标的数组,并使用随机分布的方式把它们分配到一个立方体空间内。

  1. distance 表示一个数值范围,用来控制立方体和球体的大小,比如 (Math.random() - 0.5) * 2 * distance 会产生 [-distance, distance] 之间的随机坐标。
  2. count 表示顶点数量,也就是要生成多少个点。
  3. new THREE.BufferGeometry() 创建一个可管理大量顶点数据的几何体。它可以让我们自行往里写顶点(position)等属性。
  4. 声明 vertices = [] 数组,用来存储所有点的 x、y、z 坐标。
  5. geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3)) 用于把这个顶点数组正式变为几何体的 position 属性。这里的 “3” 表示每个点有 3 个分量(x, y, z)。
// 设置基本参数
const distance = Math.min(100, window.innerWidth / 8);
const count = 2000;
const geometry = new THREE.BufferGeometry();
const vertices = [];
 
// 创建一个用于动画的状态对象
const animationState = {
  morphFactor: 0, // 0 表示立方体形态, 1 表示球体形态
};
 
// 生成初始的立方体顶点
for (let i = 0; i < count; i++) {
  // 在 -distance ~ distance 范围内随机分布
  const x = (Math.random() - 0.5) * 2 * distance;
  const y = (Math.random() - 0.5) * 2 * distance;
  const z = (Math.random() - 0.5) * 2 * distance;
  vertices.push(x, y, z);
}
 
geometry.setAttribute("position", new THREE.Float32BufferAttribute(vertices, 3));

前面先随机生成了一个立方体内的分布,这使得一开始,所有的点会散落在一个边长大约为 2 * distance 的正方体中。随后我们会用 animationState.morphFactor 来控制顶点在立方体与球体之间的插值动画。

五、创建点云并添加到场景

上面已经准备好了点云的几何体,接下来需要给它配上一种材质(Material),再用 THREE.Points 进行渲染。

  1. THREE.PointsMaterial 常用于渲染成千上万个点,它有 sizecolorsizeAttenuation 等属性可选。
  2. color: 0xff44ff 表示点的颜色,这里是带点粉紫的色调。
  3. size: 2 表示每个点的大小,数值越大,点云看上去越粗。
  4. new THREE.Points(geometry, material) 生成一个点云对象,将前面定义的几何体与材质绑定在一起。
  5. scene.add() 就是把这个点云添加到场景中,当然这里先放进了一个 renderingParent(Group 对象)再加到场景里,是为了方便后续整体缩放或移动。
const particles = new THREE.Points(
  geometry,
  new THREE.PointsMaterial({
    color: 0xff44ff,
    size: 2,
    sizeAttenuation: true,
  })
);
 
const renderingParent = new THREE.Group();
renderingParent.add(particles);
 
const resizeContainer = new THREE.Group();
resizeContainer.add(renderingParent);
 
scene.add(resizeContainer);
camera.position.z = 300;

这里的分层组合(renderingParentresizeContainer)可以让我们在后续实现整体动画或缩放时更加灵活。如果直接把 particles 丢到场景里,要做全局变换就不如给它包一层 Group 来得方便。

六、顶点插值:立方体到球体

这是整个示例的核心逻辑:在动画循环中,根据 animationState.morphFactor 对点的坐标进行插值,从而实现点云从立方体形态平滑过渡到球体形态。下面这个代码块的作用就是实时更新几何体中的 position,从而在下一帧的渲染中显示形态变化。

  1. updateVertices() 函数会在每一帧中被调用,或者在 GSAP 动画更新时被调用。
  2. 首先获取 geometry.attributes.position.array,它是一个包含所有顶点数据的数组。
  3. 为了实现立方体和球体之间的插值,需要先确定“立方体坐标”和“球体坐标”。在这里,我们重新随机生成 “立方体坐标”(cubeX, cubeY, cubeZ),又计算出 “球体坐标”(sphereX, sphereY, sphereZ)。
    • 球体坐标的算法通过 phitheta 分配在球面上,phi 通常与纬度相关,theta 通常与经度相关。
    • Math.cos()Math.sin() 等函数结合距离 distance,可以把点固定到球面上。
  4. 利用 positions[i3] = cubeX + (sphereX - cubeX) * morphFactor 这种公式把立方体坐标和球体坐标做线性插值。morphFactor 从 0 到 1,就能让点的位置从立方体渐渐挪到球体的位置。
  5. geometry.attributes.position.needsUpdate = true 表示数据已更新,需要 Three.js 重新刷新顶点。
// 创建一个函数来更新顶点位置
function updateVertices() {
  const positions = geometry.attributes.position.array;
 
  for (let i = 0; i < count; i++) {
    const i3 = i * 3;
 
    // 获取立方体坐标
    const cubeX = (Math.random() - 0.5) * 2 * distance;
    const cubeY = (Math.random() - 0.5) * 2 * distance;
    const cubeZ = (Math.random() - 0.5) * 2 * distance;
 
    // 计算球体坐标
    const phi = Math.acos(-1 + (2 * i) / count);
    const theta = Math.sqrt(count * Math.PI) * phi;
    const sphereX = distance * Math.cos(theta) * Math.sin(phi);
    const sphereY = distance * Math.sin(theta) * Math.sin(phi);
    const sphereZ = distance * Math.cos(phi);
 
    // 使用 morphFactor 进行插值
    positions[i3] = cubeX + (sphereX - cubeX) * animationState.morphFactor;
    positions[i3 + 1] = cubeY + (sphereY - cubeY) * animationState.morphFactor;
    positions[i3 + 2] = cubeZ + (sphereZ - cubeZ) * animationState.morphFactor;
  }
 
  geometry.attributes.position.needsUpdate = true;
}

为什么要在循环里重新“随机生成”立方体坐标?
这是一个巧妙但也相对独特的做法。每帧都随机生成一个立方体上的点,再插值到球体位置,会使每个点在每一帧都有新的随机起点,因此点云在立方体状态时会看起来比较“抖动”。这会带来一种比较动态的视觉效果。如果想让每个顶点固定拥有唯一的立方体坐标,那么就应该在初始化时把立方体坐标记录下来,而不是每帧都随机。
目前的实现会让点云在 0~1 之间循环时,显得更加流动和幻变,也是一种有趣的视觉呈现。

七、动画循环与渲染

下面的动画循环 animate() 函数里,每帧都会做以下事情:

  1. requestAnimationFrame(animate) 让浏览器在下一帧继续调用 animate(),形成循环。
  2. controls.update() 刷新 OrbitControls 的状态,使相机视角随着鼠标操作而更新。
  3. updateVertices() 刷新点云每个顶点的位置,从而实现形态变换的计算。
  4. renderer.render(scene, camera) 最后一步把场景渲染到屏幕上。
const animate = function () {
  requestAnimationFrame(animate);
  controls.update();
  updateVertices();
  renderer.render(scene, camera);
};
 
animate();

有了这段循环,每一帧都可以实现对点云的重新计算和更新,并把最终画面呈现出来。也正是在这个循环中,OrbitControls 才能正常工作,让我们通过鼠标查看场景的各个角度。

八、GSAP 动画控制

为了让 animationState.morphFactor 在 0 和 1 之间循环往复,我们使用了 GSAP(GreenSock Animation Platform)来驱动动画。GSAP 可以很轻松地实现各种平滑的补间动画。

  1. gsap.to(animationState, { morphFactor: 1, ... }) 表示将 animationState.morphFactor 从原值(默认 0)动画到 1。
  2. duration: 2 是动画持续时长 2 秒,ease: "power2.inOut" 指定缓动函数来控制变换的节奏,repeat: -1 指循环无限次,yoyo: true 表示每次动画结束后会反向回放。
  3. onUpdate: updateVertices 确保在 morphFactor 值发生改变的同时,即刻更新顶点位置。
  4. 通过这种方式,我们就可以看到点云在立方体和球体之间来回变化,形成一个持续的呼吸或脉动效果。
// 创建形状变换动画
gsap.to(animationState, {
  morphFactor: 1,
  duration: 2,
  ease: "power2.inOut",
  repeat: -1,
  yoyo: true,
  onUpdate: updateVertices,
});

这样的动画逻辑非常直观:morphFactor 不断从 0~1 再回到 0,点云就会先从立方体状态过渡到球体,再从球体过渡回立方体,周而复始。

九、整体缩放动画

在示例中,我们还使用了另一段 GSAP 动画来使点云整体有节奏地缩放,看起来仿佛在呼吸。

  1. 这里定义了一个 animProps = { scale: 1 },并对它做补间动画,使 scale 在 1 和 1.3 之间来回波动。
  2. onUpdate 回调中,将 renderingParent.scale.set(animProps.scale, animProps.scale, animProps.scale),就能让整个点云组合随 scale 大小变动。
  3. repeat: -1, yoyo: true 结合 ease: 'sine' 能实现平滑的往返缩放。
// 缩放动画
const animProps = { scale: 1 };
gsap.to(animProps, {
  duration: 10,
  scale: 1.3,
  repeat: -1,
  yoyo: true,
  ease: "sine",
  onUpdate: () => {
    renderingParent.scale.set(animProps.scale, animProps.scale, animProps.scale);
  },
});

这段代码让点云在 10 秒的时长里缓慢地向外扩大到 1.3 倍,再缓慢地回到原始大小,如此往复。配合形态转换动画,会带来一种相当有节奏感的视觉冲击。

十、总结与扩展

  1. 点云与插值核心
    这份示例展示了如何使用 BufferGeometry 管理数千个顶点,并通过插值公式(线性插值)在立方体和球体两种坐标之间过渡。
  2. GSAP 与 Three.js 结合
    我们充分利用了 GSAP 强大的补间动画能力,实时更新 morphFactor 和整体缩放效果,只要在 onUpdate 里调用 updateVertices() 或相关操作,就能实现平滑的视觉动画。
  3. OrbitControls 的旋转与缩放
    通过 OrbitControls,用户可以随意改变相机视角;加上我们在动画中做的缓动效果,让整个场景体验更加有趣。
  4. 可能的改进
    • 如果你想要“固定”随机到的立方体坐标,可以只在初始化时生成每个点的立方体坐标并存储下来,而不是每帧都随机生成。
    • 想要在球体上分布得更均匀,可以考虑其他球面采样算法(如 Golomb Ruler、Fibonacci sphere 等)。
    • 可以为点云添加着色器或纹理,让点看上去更炫酷。
  5. 性能注意
    count 很大时(几十万以上),使用 JavaScript 层面的遍历对每个顶点计算插值可能会造成性能瓶颈。届时可以考虑把相关逻辑放到着色器(Shader)里,通过 GPU 来实现大规模的顶点计算。

代码

github

https://github.com/calmound/threejs-demo/tree/main/point (opens in a new tab)

gitee

https://gitee.com/calmound/threejs-demo/tree/main/point (opens in a new tab)