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 的原因:用最少的资源,换最大的浪漫。