先拆解:一个银河由什么组成
真实的旋涡星系有三个可见结构:中心的核球(暖色、厚重)、 盘面上的旋臂(年轻蓝星密集,夹杂电离氢区的红色)、 以及最外层的弥漫晕(稀疏、暗淡)。把这三种分布各写一个采样函数, 银河的骨架就出来了。
- 核球:
r = random()^1.9 × R,角向均匀,y 轴厚度较大,颜色偏暖黄。 - 旋臂:半径服从指数盘
r = -ln(1-u) × h,角度沿对数螺旋排布。 - 晕族:半径平方根分布 + 随机角度,亮度压到最低,负责“垫底”。
旋臂的形状:对数螺旋
对数螺旋的公式是 θ = arm + k · ln(r)。它的妙处在于,无论放大多少倍,
形状都不变——所以从中心到边缘的弯曲程度是自然连续的,不会出现直胳膊。
const arm = i % ARMS;
const r = -Math.log(1 - Math.random() * 0.968) * 0.245;
/* 旋臂不是一条线:越往外越宽,用正态分布做抖动 */
const off = gauss() * (0.26 + 0.62 * r);
const th = arm * (TAU / ARMS) + 2.35 * Math.log(1 + r * 5.5) + off;
注意 Math.log(1 + r × 5.5) 里的“1 +”是必须的:
它让半径趋近 0 时角度也趋近 0,四条旋臂才会干净地汇聚到核心,而不是在小半径处炸开。
关键一步:把盘面「立起来」
星系的坐标都在盘面 (x, z) 上,要看起来像被斜着观察,需要绕屏幕水平轴做一次旋转。
设观察仰角为 V,投影公式只有两行:
// sy:屏幕纵向位置;dz:朝向观众的深度(用于近大远小)
const sy = y * Math.cos(V) + z * Math.sin(V);
const dz = z * Math.cos(V) - y * Math.sin(V);
// 深度直接喂给尺寸和透明度,立体感就出来了
const size = base * (1 + dz * 0.42);
const alpha = baseA * clamp(1 + dz * 0.42, 0.4, 1.4);
验证一下:V = 90° 时 sy = z,盘面正对观众(正视);
V = 0° 时 sy = y,只剩厚度可见(侧视成一条线)。
本站取 V ≈ 58°,是介于两者之间最有立体感的角度,再叠加鼠标纵向位移做微调。
为什么不用 WebGL,加色混合就够了
粒子效果想要“发光”,最省事的做法是开启加色混合,让重叠的粒子亮度自然累加:
ctx.globalCompositeOperation = 'lighter'; // 等价于加法混合,天然 bloom
ctx.drawImage(sprite, px - s / 2, py - s / 2, s, s);
每个粒子都用一张预渲染的 64×64 径向渐变精灵,而不是 arc() 逐帧画圆。
这样既拿到了柔和光晕,又把每帧的路径构建成本降到了零——2600 个粒子在 60fps 下仍有余量。
用
drawImage缩放小尺寸精灵时,浏览器会做双线性插值, 边缘反而比arc()的硬边更干净,这是意外收获。
旋转不缠绕:密度波近似
如果让每颗恒星按自己的角速度旋转(开普勒式),旋臂会在几十秒内被越搅越糊。 真实星系靠密度波维持形状,而工程上的简化做法是: 整盘做刚性旋转,只给核球单独提速。因为核球角度本来就是均匀分布, 提速不会被看出来,反而让核心区域显得更有生气。
// 每帧只对整盘算一次 cos / sin,然后所有恒星做线性变换
const cg = Math.cos(angle), sg = Math.sin(angle);
const x3 = bx * cg - bz * sg;
const z3 = bx * sg + bz * cg;
这样一帧里真正调用三角函数的次数是个位数,而不是 2600 次。 这也是整个方案能在低端手机上跑满帧率的核心原因。
性能与可访问性兜底
- 粒子总数按视口面积自适应:
clamp(area / 620, 1100, 5200),移动端再乘 0.72。 - 像素比上限 2;连续掉帧时自动降到 0.75 倍分辨率并重建星群。
- 页面切到后台(
visibilitychange)暂停动画,切回来再继续,避免白白耗电。 - 支持
prefers-reduced-motion:命中时只渲染一帧静态银河,不启动循环。
完整实现就在本站的 assets/js/galaxy.js 里,注释比代码还多一点,
可以直接下载过去改参数——试试把 CFG.arms 改成 2 或 6,
会得到完全不同气质的星系。