从炫酷粒子星云学 Three.js:深度解析一个 15 万粒子的 GPU 动画系统

引言

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

于是我立刻"借鉴"了他的代码,并花了点时间解析其原理。今天就带大家一步步拆解这个 15 万个粒子的高性能动画系统,看看 Three.js 是如何借助 GPU 着色器 实现如此流畅又复杂的视觉效果的。

原作地址:codepen.io/prisoner849...

一、基础场景搭建:相机、渲染器与交互

首先,我们来看最基础的 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 个粒子,分布在内外半径为 1040 的圆柱壳内(Z 范围 -11)。
  • setFromCylindricalCoords(radius, theta, y) 是 Three.js 提供的便捷方法,用于从柱坐标转换为笛卡尔坐标:
    • radius:到 Z 轴的距离(径向)
    • theta:绕 Z 轴的角度(弧度)
    • y:高度(Z 轴方向)
  • 这里通过 Math.sqrt(...) 实现在内外半径 [10, 40] 之间的非均匀分布,使粒子更密集于内圈,视觉上更有层次。 (数学技巧,由面积均匀分布的产生的变体,偏向内圈的非均匀分布,这里不深究了)。

三、几何体与材质:BufferGeometryPointsMaterial

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 着色器之前,动态修改其顶点/片元着色器代码。

在这个例子中,我们通过它

  1. 注入自定义 uniform:time(用于驱动动画)
  2. 注入自定义 attribute:sizesshift
  3. 重写关键着色器片段:替换 gl_PointSizecolor_vertexbegin_vertex 等内置代码块

自定义着色器里传递 Uniform 变量

js 复制代码
shader.uniforms.time = gu.time; // 添加 uniform
  • 将名为 timeUniform 变量绑定到这个对象上,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 实现任意着色器逻辑
  • 艺术性:颜色、分布、动画的精心设计,视觉效果🐂🍺

学习优秀作品,是提升技术的最佳路径。希望这篇解析对你有所帮助

参考资料:

相关推荐
凉_橙3 小时前
移动端h5适配方案
前端
久亮哦3 小时前
开发Electron程序
前端·javascript·electron
敲敲了个代码3 小时前
为什么 Electron 项目推荐使用 Monorepo 架构 [特殊字符][特殊字符][特殊字符]
前端·javascript·学习·架构·electron·github
你们的前端课代表3 小时前
前端如何优雅地“边聊边等”——用 Fetch 实现流式请求大模型
前端
王大宇_3 小时前
React闭包陷阱
前端·javascript
A达峰绮3 小时前
Actix-web 框架性能优化技巧深度解析
前端·性能优化·actix-web
Promise5203 小时前
用油猴脚本实现用户身份快速切换
前端·javascript
玲玲5123 小时前
vue3组件通信:defineEmits和defineModel
前端
温柔53293 小时前
仓颉语言异常捕获机制深度解析
java·服务器·前端