引言
这周偶然在 CodePen 上刷到一个名为 "Nova" 的 3D 粒子动画,展示一个星云效果,极其酷炫。

于是我立刻"借鉴"了他的代码,并花了点时间解析其原理。今天就带大家一步步拆解这个 15 万个粒子的高性能动画系统,看看 Three.js 是如何借助 GPU 着色器 实现如此流畅又复杂的视觉效果的。
一、基础场景搭建:相机、渲染器与交互
首先,我们来看最基础的 Three.js 场景搭建部分:
js
import * as THREE from 'https://cdn.skypack.dev/three@0.136.0';
import { OrbitControls } from 'https://cdn.skypack.dev/three@0.136.0/examples/jsm/controls/OrbitControls';
- 使用 Skypack CDN 加载 Three.js v0.136.0 及其 OrbitControls(用于鼠标交互控制相机)。
js
let scene = new THREE.Scene();
scene.background = new THREE.Color(0x160016); // 深紫色背景
let camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 1, 1000);
camera.position.set(0, 4, 21);
let renderer = new THREE.WebGLRenderer();
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
- 创建场景、相机(透视投影)、渲染器,并将渲染器添加到页面。
- 背景为深紫色(0x160016)。
- 相机初始位置在 (0, 4, 21),朝向原点。
PerspectiveCamera 参数详解
根据 Three.js 官方文档,PerspectiveCamera 构造函数的四个参数分别是:
fov(Field of View):60
表示相机的垂直视角(单位:度)。值越大,看到的范围越广,但物体显得更小。60° 是一个接近人眼的自然视角。aspect(宽高比):innerWidth / innerHeight
通常设为画布的宽高比,避免画面拉伸。near(近裁剪面):1
距离相机小于 1 的物体将不会被渲染。far(远裁剪面):1000
距离相机大于 1000 的物体将被裁剪掉。这些参数共同定义了相机的"可视锥体"(View Frustum)。
camera.position与坐标系camera.position.set(0, 4, 21) 将相机放置在 (x=0, y=4, z=21) 的位置,即正对场景中心、略高于原点、距离较远,默认对准原点(0, 0, 0),适合观察整个粒子系统。
- x 轴水平方向位置(左右移动)正方向向右👉
- y 轴垂直方向位置(上下移动)正方向向上👆
- z 轴前后方向位置(远近移动)正方向向前👊
js
window.addEventListener('resize', (event) => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
- 监听窗口大小变化,动态调整相机和渲染器尺寸
js
let controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.enablePan = false;
- 启用轨道控制器(可旋转/缩放视角),开启阻尼效果(平滑移动),禁用平移(pan)。
二、粒子数据生成
1. 定义辅助函数和数组
js
let gu = {
time: { value: 0 },
};
- gu 是一个
uniform对象,用于在着色器中传递时间变量。
js
let sizes = [];
let shift = [];
let pushShift = () => {
shift.push(
Math.random() * Math.PI,
Math.random() * Math.PI * 2,
(Math.random() * 0.9 + 0.1) * Math.PI * 0.1,
Math.random() * 0.9 + 0.1,
);
};
sizes:每个粒子的尺寸缩放因子。shift:每个粒子的动画参数(4个值):shift.x:初始相位角(用于时间偏移)shift.y:另一个相位角shift.z:动画速度(角速度)shift.w:振幅(位移强度)
2. 两种空间分布策略
整个动画包含 15 万个粒子,分为两组生成:
1. 球壳分布(5 万粒子)
js
let pts = new Array(50000).fill().map((p) => {
sizes.push(Math.random() * 1.5 + 0.5);
pushShift();
return new THREE.Vector3().randomDirection().multiplyScalar(Math.random() * 0.5 + 9.5);
});
randomDirection():生成一个单位向量(长度为 1,方向随机)。multiplyScalar(s):将向量的每个分量乘以标量 s,从而缩放其长度。- 最终粒子分布在半径约 9.5 ~ 10 的球壳上。
效果:生成 50,000 个粒子,分布在一个半径约 9.5~10 的球壳上(随机方向 × 随机半径)。
2. 薄状圆柱壳(星环)分布(10 万粒子)
js
for (let i = 0; i < 100000; i++) {
let r = 10,
R = 40;
let rand = Math.pow(Math.random(), 1.5);
let radius = Math.sqrt(R * R * rand + (1 - rand) * r * r);
pts.push(
new THREE.Vector3().setFromCylindricalCoords(
radius,
Math.random() * 2 * Math.PI,
(Math.random() - 0.5) * 2,
),
);
sizes.push(Math.random() * 1.5 + 0.5);
pushShift();
}
- 再生成 100,000 个粒子,分布在内外半径为 10
40 的圆柱壳内(Z 范围 -11)。 setFromCylindricalCoords(radius, theta, y)是 Three.js 提供的便捷方法,用于从柱坐标转换为笛卡尔坐标:radius:到 Z 轴的距离(径向)theta:绕 Z 轴的角度(弧度)y:高度(Z 轴方向)
- 这里通过
Math.sqrt(...)实现在内外半径 [10, 40] 之间的非均匀分布,使粒子更密集于内圈,视觉上更有层次。 (数学技巧,由面积均匀分布的产生的变体,偏向内圈的非均匀分布,这里不深究了)。
三、几何体与材质:BufferGeometry 与 PointsMaterial
BufferGeometry:高效存储顶点数据
js
let g = new THREE.BufferGeometry().setFromPoints(pts);
g.setAttribute('sizes', new THREE.Float32BufferAttribute(sizes, 1));
g.setAttribute('shift', new THREE.Float32BufferAttribute(shift, 4));
BufferGeometry是 Three.js 中最高效的几何体类型,直接操作 GPU 缓冲区。setFromPoints(pts)将 Vector3 数组转换为 position attribute。- setAttribute 用于添加自定义 attribute:
sizes:每个粒子的尺寸缩放因子(1 个 float)shift:每个粒子的动画参数(4 个 float,对应 vec4)
这些 attribute 会在顶点着色器中被读取,实现 per-particle(逐粒子)控制。
PointsMaterial:专为点精灵设计的材质
js
let m = new THREE.PointsMaterial({
size: 0.125,
transparent: true,
depthTest: false,
blending: THREE.AdditiveBlending,
});
size
基础点大小(单位:世界坐标)。transparent: true
启用透明度。depthTest: false
关闭深度测试,避免远处粒子被近处粒子遮挡,确保所有粒子可见(适合星云效果)。blending: AdditiveBlending
使用加法混合,多个粒子重叠时亮度叠加,产生"发光"效果。
*四、通过 onBeforeCompile 注入自定义着色器逻辑
Three.js 的内置材质(如 PointsMaterial)虽然方便,但无法直接访问自定义 attribute 或实现复杂动画逻辑。这时就需要 onBeforeCompile。
官方文档说明
onBeforeCompile允许我们在材质编译为 WebGL 着色器之前,动态修改其顶点/片元着色器代码。
在这个例子中,我们通过它
- 注入自定义
uniform:time(用于驱动动画) - 注入自定义
attribute:sizes和shift - 重写关键着色器片段:替换
gl_PointSize、color_vertex、begin_vertex等内置代码块
自定义着色器里传递 Uniform 变量
js
shader.uniforms.time = gu.time; // 添加 uniform
- 将名为
time的Uniform变量绑定到这个对象上,Three.js 会在渲染时自动把gu.time.value传给 GPU - 这行代码是连接 CPU 与 GPU 的桥梁,让 JavaScript 能够实时控制着色器的行为,是实现动态视觉效果(如流动、脉动、变形等)的核心机制。
顶点着色器(Vertex Sha der)修改
js
shader.vertexShader = `...`.replace(...); // 修改顶点着色器
这是 Three.js 中实现高级自定义效果的标准做法,既保留了材质的便利性,又获得了着色器的完全控制权。
GLSL
+ uniform float time;
+ attribute float sizes;
+ attribute vec4 shift;
+ varying vec3 vColor;
- 声明 uniform,attribute,varying 变量
GLSL
- gl_PointSize = size;
+ gl_PointSize = size * sizes;
- 每个粒子的实际大小 = 基础 size × 自定义 sizes。
让点的大小由 "单一变量 size 控制" 变为 "两个变量 size 和 sizes 的乘积控制",实现更灵活的点大小调节(让不同点根据 sizes 变量呈现不同大小,实现 "点云大小差异化" )。
粒子颜色
GLSL
#include <color_vertex>
+ float d = length(abs(position) / vec3(40., 10., 40));
+ d = clamp(d, 0., 1.);
+ vColor = mix(vec3(227., 155., 0.), vec3(100., 50., 255.), d) / 255.;
- 根据粒子在空间中的归一化位置(x/40, y/10, z/40)计算距离 d。
- 用 mix 在橙色 (227,155,0) 和紫色 (100,50,255) 之间插值,生成颜色。
- 除以 255 是因为 Three.js 默认颜色范围是 0~1。
粒子动画的秘密
GLSL
#include <begin_vertex>
+ float t = time;
+ float moveT = mod(shift.x + shift.z * t, PI2);
+ float moveS = mod(shift.y + shift.z * t, PI2);
+ transformed += vec3(cos(moveS) * sin(moveT), cos(moveT), sin(moveS) * sin(moveT)) * shift.w;
shift.x,shift.y:每个粒子的初始相位(随机值)shift.z:角速度(控制动画快慢)shift.w:振幅(控制抖动幅度)vec3(...):计算单位球面上的一个点(球坐标转笛卡尔)- 最终将这个偏移量加到原始位置上,形成围绕原点的周期性扰动
所有粒子以不同节奏"呼吸",营造出"活"的星云感。
片元着色器(Fragment Shader)修改
片元着色器负责决定每个像素的颜色和透明度。默认的 PointsMaterial 会渲染一个实心方形点,但我们想要的是柔和发光的圆形粒子。
GLSL
#include <clipping_planes_fragment>
+ float d = length(gl_PointCoord.xy - 0.5);
- 计算当前片元到粒子中心的距离(gl_PointCoord 是 0~1 的纹理坐标)。
GLSL
- vec4 diffuseColor = vec4( diffuse, opacity );
+ vec4 diffuseColor = vec4( vColor, smoothstep(0.5, 0.1, d)/* * 0.5 + 0.5*/ );
gl_PointCoord:内置变量,表示当前片元在点精灵中的归一化坐标(0~1)- 使用
smoothstep(0.5, 0.1, d)实现从中心向外平滑衰减的透明度:- 当 d < 0.1 → alpha ≈ 1
- 当 d > 0.5 → alpha ≈ 0
- 中间平滑过渡,形成柔和发光圆点。
这样就得到了边缘柔和、中心明亮的圆形光点,配合
AdditiveBlending,效果极其梦幻。
五、场景添加与动画循环
js
let p = new THREE.Points(g, m);
p.rotation.order = 'ZYX';
p.rotation.z = 0.2;
scene.add(p);
- 创建
Points对象,设置旋转顺序(避免万向节锁),初始绕 Z 轴倾斜 0.2 弧度。
js
let clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
controls.update();
let t = clock.getElapsedTime() * 0.5;
gu.time.value = t * Math.PI;
p.rotation.y = t * 0.05;
renderer.render(scene, camera);
});
- 使用
Clock获取经过时间。 gu.time.value传递给着色器(驱动粒子动画)。- 整个粒子系统缓慢绕 Y 轴旋转(
p.rotation.y += ...),增强动态感。 controls.update()更新轨道控制器阻尼。
六、总结与启示
这个"Nova"粒子系统展示了 Three.js 的强大之处:
- 高性能:15 万粒子全由 GPU 驱动,无 CPU 负担
- 灵活性:通过
onBeforeCompile实现任意着色器逻辑 - 艺术性:颜色、分布、动画的精心设计,视觉效果🐂🍺
学习优秀作品,是提升技术的最佳路径。希望这篇解析对你有所帮助
参考资料: