从立方体变化到球体的粒子过渡效果
今天,带大家一起实现粒子过渡动画,使用了 gasp 动画库,从立方体到球体的变化。
一、场景与渲染器基础设置
我们首先要搭建一个可视化的 3D 环境,包括场景(Scene)、相机(Camera) 和 渲染器(Renderer)。这样我们才能把随后创建的几何体渲染出来,并且能够在浏览器窗口里自由查看。下面这个代码块就是完成场景、相机和渲染器初始化的关键步骤。
THREE.Scene()
:Three.js 的场景容器,所有物体和光源都要放进场景里才能被渲染出来。THREE.PerspectiveCamera
:透视摄像机,用来模拟人眼或真实镜头观察物体的方式。它的 4 个核心参数分别是视场角(FOV)、宽高比、近截面和远截面。THREE.WebGLRenderer
:使用 GPU 的加速渲染器,能让 3D 效果在浏览器中高效地显示。为了避免画面锯齿,我们将antialias
设置为true
。renderer.setSize(window.innerWidth, window.innerHeight)
:设置渲染画布的宽和高,使之与浏览器窗口匹配。- 将
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 控制器,它可以让我们用鼠标来旋转、平移并缩放相机,方便在浏览器中随意观察对象。
OrbitControls
来自three/examples/jsm/controls/OrbitControls
,可以监听鼠标事件并同步更新相机位置。controls.enableDamping
设置为true
,表示在拖拽停止后相机的旋转/平移会有一点“缓动”效果,更加顺滑。controls.dampingFactor
控制缓动速度,值越大,阻尼越强。controls.rotateSpeed
控制旋转速度,值越大,旋转更灵敏。- 每帧都需要调用
controls.update()
,才能使控制器的参数变化生效并实时更新相机。
// 添加 OrbitControls
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 开启阻尼感
controls.dampingFactor = 0.05; // 阻尼系数
controls.rotateSpeed = 0.5; // 旋转速度
这一步的好处在于,我们无需自行编写繁琐的鼠标交互逻辑。只要在动画循环中让 controls.update()
生效,我们就可以通过鼠标拖拽或滚轮缩放来随意观看我们的点云。
三、浏览器窗口自适应
在实际项目中,通常需要在浏览器窗口大小发生变化时,自动更新相机和渲染器的尺寸,保证渲染画面不变形。接下来这段代码演示了自适应窗口大小的处理方式。
camera.aspect = window.innerWidth / window.innerHeight
在窗口变化时同步修改相机宽高比。camera.updateProjectionMatrix()
在修改完宽高比后,需要更新摄像机投影矩阵,才能生效。renderer.setSize(window.innerWidth, window.innerHeight)
再次调整渲染区域的大小。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)中创建好存放顶点坐标的数组,并使用随机分布的方式把它们分配到一个立方体空间内。
distance
表示一个数值范围,用来控制立方体和球体的大小,比如(Math.random() - 0.5) * 2 * distance
会产生[-distance, distance]
之间的随机坐标。count
表示顶点数量,也就是要生成多少个点。new THREE.BufferGeometry()
创建一个可管理大量顶点数据的几何体。它可以让我们自行往里写顶点(position)等属性。- 声明
vertices = []
数组,用来存储所有点的 x、y、z 坐标。 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
进行渲染。
THREE.PointsMaterial
常用于渲染成千上万个点,它有size
、color
、sizeAttenuation
等属性可选。color: 0xff44ff
表示点的颜色,这里是带点粉紫的色调。size: 2
表示每个点的大小,数值越大,点云看上去越粗。new THREE.Points(geometry, material)
生成一个点云对象,将前面定义的几何体与材质绑定在一起。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;
这里的分层组合(renderingParent
,resizeContainer
)可以让我们在后续实现整体动画或缩放时更加灵活。如果直接把 particles
丢到场景里,要做全局变换就不如给它包一层 Group 来得方便。
六、顶点插值:立方体到球体
这是整个示例的核心逻辑:在动画循环中,根据 animationState.morphFactor
对点的坐标进行插值,从而实现点云从立方体形态平滑过渡到球体形态。下面这个代码块的作用就是实时更新几何体中的 position,从而在下一帧的渲染中显示形态变化。
updateVertices()
函数会在每一帧中被调用,或者在 GSAP 动画更新时被调用。- 首先获取
geometry.attributes.position.array
,它是一个包含所有顶点数据的数组。 - 为了实现立方体和球体之间的插值,需要先确定“立方体坐标”和“球体坐标”。在这里,我们重新随机生成 “立方体坐标”(
cubeX
,cubeY
,cubeZ
),又计算出 “球体坐标”(sphereX
,sphereY
,sphereZ
)。- 球体坐标的算法通过
phi
、theta
分配在球面上,phi
通常与纬度相关,theta
通常与经度相关。 Math.cos()
、Math.sin()
等函数结合距离distance
,可以把点固定到球面上。
- 球体坐标的算法通过
- 利用
positions[i3] = cubeX + (sphereX - cubeX) * morphFactor
这种公式把立方体坐标和球体坐标做线性插值。morphFactor
从 0 到 1,就能让点的位置从立方体渐渐挪到球体的位置。 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()
函数里,每帧都会做以下事情:
requestAnimationFrame(animate)
让浏览器在下一帧继续调用animate()
,形成循环。controls.update()
刷新 OrbitControls 的状态,使相机视角随着鼠标操作而更新。updateVertices()
刷新点云每个顶点的位置,从而实现形态变换的计算。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 可以很轻松地实现各种平滑的补间动画。
gsap.to(animationState, { morphFactor: 1, ... })
表示将animationState.morphFactor
从原值(默认 0)动画到 1。duration: 2
是动画持续时长 2 秒,ease: "power2.inOut"
指定缓动函数来控制变换的节奏,repeat: -1
指循环无限次,yoyo: true
表示每次动画结束后会反向回放。onUpdate: updateVertices
确保在morphFactor
值发生改变的同时,即刻更新顶点位置。- 通过这种方式,我们就可以看到点云在立方体和球体之间来回变化,形成一个持续的呼吸或脉动效果。
// 创建形状变换动画
gsap.to(animationState, {
morphFactor: 1,
duration: 2,
ease: "power2.inOut",
repeat: -1,
yoyo: true,
onUpdate: updateVertices,
});
这样的动画逻辑非常直观:morphFactor
不断从 0~1 再回到 0,点云就会先从立方体状态过渡到球体,再从球体过渡回立方体,周而复始。
九、整体缩放动画
在示例中,我们还使用了另一段 GSAP 动画来使点云整体有节奏地缩放,看起来仿佛在呼吸。
- 这里定义了一个
animProps = { scale: 1 }
,并对它做补间动画,使scale
在 1 和 1.3 之间来回波动。 - 在
onUpdate
回调中,将renderingParent.scale.set(animProps.scale, animProps.scale, animProps.scale)
,就能让整个点云组合随scale
大小变动。 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 倍,再缓慢地回到原始大小,如此往复。配合形态转换动画,会带来一种相当有节奏感的视觉冲击。
十、总结与扩展
- 点云与插值核心
这份示例展示了如何使用 BufferGeometry 管理数千个顶点,并通过插值公式(线性插值)在立方体和球体两种坐标之间过渡。 - GSAP 与 Three.js 结合
我们充分利用了 GSAP 强大的补间动画能力,实时更新morphFactor
和整体缩放效果,只要在onUpdate
里调用updateVertices()
或相关操作,就能实现平滑的视觉动画。 - OrbitControls 的旋转与缩放
通过 OrbitControls,用户可以随意改变相机视角;加上我们在动画中做的缓动效果,让整个场景体验更加有趣。 - 可能的改进
- 如果你想要“固定”随机到的立方体坐标,可以只在初始化时生成每个点的立方体坐标并存储下来,而不是每帧都随机生成。
- 想要在球体上分布得更均匀,可以考虑其他球面采样算法(如 Golomb Ruler、Fibonacci sphere 等)。
- 可以为点云添加着色器或纹理,让点看上去更炫酷。
- 性能注意
当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)