跳到正文
返回

用原生 WebGL 跑 24,000 个粒子

约 3 分钟

这个页面里有 24,000 个粒子。鼠标移动时,视角会跟着变化。整个演示没有加载 Three.js,也没有请求外部脚本,浏览器只需要支持 WebGL。

移动鼠标或拖动手指可以改变视角。在新页面打开演示

我一开始担心的是 JS 动画循环。如果每一帧都遍历 24,000 个粒子,再逐个改坐标,主线程会承担大量重复计算。这个版本只在初始化时生成位置、颜色、大小和随机种子。每一帧的粒子位置由顶点着色器并行计算,JS 只更新 uniform、相机矩阵并提交一次绘制。

边注

这里说的 GPU 计算只指粒子顶点。相机矩阵和时间仍由 JS 更新。

初始分布决定星系形状

初始位置只生成一次。半径经过幂函数处理,所以更多粒子集中在中心。每个粒子还会分到三条旋臂中的一条,并根据半径增加旋转角度。

// public/demos/particle-shader.html
const radius = Math.pow(Math.random(), 1.7) * 4.6;
const branch = ((i % 3) / 3) * Math.PI * 2;
const spin = radius * 1.05;
const spread =
  Math.pow(Math.random(), 2.6) *
  (Math.random() < 0.5 ? 1 : -1) *
  0.55;
const theta = branch + spin + spread;

spread 使用偏向小数值的随机分布。大部分粒子靠近旋臂,少数粒子散到外侧。颜色也按半径插值,中心接近白蓝色,外侧逐渐变成紫色。

顶点着色器负责运动

顶点着色器先用 Simplex noise 改变粒子位置,再按照半径计算不同的旋转速度。内圈转得更快,外圈转得更慢,所以旋臂不会像一张圆盘那样整体转动。

float t = uTime * 0.06;
float n1 = snoise(vec3(p.xz * 0.34, t));
float n2 = snoise(vec3(p.zy * 0.29, t + 11.3));
p += vec3(n1, n2 * 0.55, n2) * 0.42;

float r = length(p.xz);
float ang = uTime * (0.19 / (r + 0.9));
float s = sin(ang), c = cos(ang);
p.xz = mat2(c, -s, s, c) * p.xz;

粒子大小还会除以相机空间中的深度。近处的粒子更大,远处的粒子更小。每个粒子的 aSeed 不同,因此闪烁时间也不同。

片元着色器把方点削成圆点

WebGL 的点默认是方形。片元着色器使用 gl_PointCoord 计算当前片元到中心的距离,然后把边缘透明度降到零。透明度太低的片元会直接丢弃。

float d = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.0, d);
a = pow(a, 1.9);
if (a < 0.01) discard;
gl_FragColor = vec4(vColor, a * vAlpha);

绘制时使用加法混合,粒子重叠的位置会变亮。深度测试已经关闭,所以透明粒子不会因为写入深度而互相挡住。

页面里怎么嵌入

演示本身是一个独立 HTML 文件,博客文章通过带 sandbox="allow-scripts"iframe 加载它。独立页面的全屏样式和脚本不会改到博客正文,也不能访问博客页面的 DOM。

这个文件还处理了几个运行边界:

  • 设备像素比最多取 2,避免高分屏扩大过多像素。
  • 用户开启减少动态效果后,页面只绘制一帧。
  • WebGL 创建失败时,页面会显示文字提示。
  • 动画不依赖网络,静态部署后可以直接运行。

在新页面打开完整演示


分享这篇文章:

接着读

  1. MCP 草案改了什么:会话和握手都被删掉了本文整理自 MCP 官方规范草案变更日志,对比 2025-11-25 版本,重点看会话、握手、MRTR 和迁移影响。
  2. 最新速递短、快、一事一篇。看到新工具、长文或线上现象时随手记下的入口。
  3. 拆 Orloj,Agent 运行时、控制面和嵌入式控制台细拆 Orloj 的单 Agent 循环、多 Agent 图路由、工具与治理,以及 Go 控制面、Postgres 租约和 React 控制台怎么接上同一套资源。带核心代码与结构图。

Discussion

评论

用 GitHub 账号登录即可留言。想法、纠错、补充都欢迎。