2026.06.21

用一段顶点着色器,捏出一片会旋转的星系

不用任何贴图和模型,只靠 BufferGeometry 和 60 行 GLSL,在浏览器里点亮一万颗粒子组成的螺旋星系。这篇文章记录整个推导过程:从极坐标分布到差速旋转。

这个博客首页的那片星云,最初只是一次失眠的产物。我盯着 Bruno Simon 课程里的 galaxy generator 看了很久,突然意识到:星系的美感来自数学,而不是资源量。不需要贴图,不需要模型,一个 Points 加一段顶点着色器就够了。

先把粒子撒成螺旋

星系的骨架是「分支 + 旋转偏移」。把每个粒子分配到某一条旋臂上,再让半径越大的粒子偏转越多,螺旋就出现了:

const BRANCHES = 4
const SPIN = 1.15

for (let i = 0; i < count; i++) {
  // 幂次让粒子向核心聚集,比均匀分布自然得多
  const r = Math.pow(Math.random(), 0.8) * RADIUS
  const branchAngle = ((i % BRANCHES) / BRANCHES) * Math.PI * 2
  const spinAngle = r * SPIN

  positions[i * 3 + 0] = Math.cos(branchAngle + spinAngle) * r
  positions[i * 3 + 1] = 0
  positions[i * 3 + 2] = Math.sin(branchAngle + spinAngle) * r
}

这时候的星系整齐得像个风扇。真实的星云是「毛茸茸」的 —— 给每个轴叠加一个幂律随机偏移,幂次越高,粒子越贴近旋臂:

const rand = () =>
  Math.pow(Math.random(), 2.9) *          // 幂律:大偏移很罕见
  (Math.random() < 0.5 ? 1 : -1) *        // 正负对称
  0.44 * (r + 0.6)                        // 半径越大越松散

颜色是三段插值

从核心到边缘,我用了三段颜色:核心是恒星密集的暖金,中段过渡到品红,外围随机混入极光青或紫。诀窍是在 JS 里算好颜色塞进 attribute,着色器里只做透明度:

const inside = new THREE.Color('#ffd27d')
const mid    = new THREE.Color('#f973c0')
const outer  = Math.random() < 0.5
  ? new THREE.Color('#6ee7f9')
  : new THREE.Color('#a88bfa')

const t = r / RADIUS
tmp.copy(inside)
t < 0.45
  ? tmp.lerp(mid, t / 0.45)
  : tmp.copy(mid).lerp(outer, (t - 0.45) / 0.55)

差速旋转:星系活起来的关键

如果整个星系用同一个角速度旋转,看起来就是一张贴图在转盘上打转。真实星系是差速旋转的 —— 越靠近核心转得越快。把旋转搬进顶点着色器,每颗粒子按自己的半径算角度:

uniform float uTime;

void main() {
  vec3 p = position;

  float dist  = length(p.xz);
  float angle = atan(p.x, p.z)
              + uTime * 0.24 / (dist * 0.55 + 0.35); // 核心快、边缘慢

  p.x = sin(angle) * dist;
  p.z = cos(angle) * dist;

  vec4 mv = modelViewMatrix * vec4(p, 1.0);
  gl_Position  = projectionMatrix * mv;
  gl_PointSize = uSize * aScale * (42.0 / -mv.z);   // 近大远小
}

片元着色器负责把方形点精灵变成柔光球,pow 的指数决定光晕的「硬度」:

void main() {
  float d = distance(gl_PointCoord, vec2(0.5));
  float strength = pow(1.0 - smoothstep(0.0, 0.5, d), 3.0);
  gl_FragColor = vec4(vColor, strength);
}

最后开加法混合,粒子重叠处自动过曝成辉光,核心不点灯自己就会发光:

new THREE.ShaderMaterial({
  transparent: true,
  depthWrite: false,               // 关键:否则粒子会互相裁剪
  blending: THREE.AdditiveBlending,
  vertexColors: true,
})

踩过的坑

depthWrite: false 忘了关,粒子边缘出现黑色方块,查了一个小时。加法混合 + 深度写入是互斥的,切记。

  • gl_PointSize 要乘 devicePixelRatio,否则高分屏上粒子小得像灰尘
  • 粒子数量不是越多越好:一万颗以后提升有限,移动端直接砍半
  • 旋转速度放在 uniform 里而不是每帧改 rotation,GPU 转比 CPU 转便宜得多

一万颗粒子,一次 draw call,六十行着色器 —— 这大概就是我爱上 WebGL 的原因:用最少的资源,换最大的浪漫