星系演化听起来离前端开发很远,但本质上它是一个持续迭代的粒子系统:大量质点在相互引力作用下运动,经过足够长的时间步,旋臂、棒状结构这些宏观形态就会自然涌现。用Node.js来做这件事其实是合理的——计算密集的模拟逻辑放在服务端,浏览器只负责渲染,两边通过WebSocket同步状态。本文就以DeGalaxy项目为线索,把这个系统的设计思路和关键代码完整梳理一遍。

一、物理模型:从万有引力到简化N体
真实的星系包含上千亿颗恒星,直接两两计算引力的复杂度是O(n²),对任何机器都不现实。常用的简化方案是质点法配合软化引力(softened gravity):把每个粒子当成一个有质量的质点,引力公式改为 F = G·m1·m2 / (r² + ε²),其中ε是软化长度。这个小小的改动避免了两颗粒子距离趋近于零时引力爆炸的问题,数值上稳定得多。
DeGalaxy采用的进一步简化是把星系建模为“中心质量 + 盘面粒子”。中心是一个超大质量的黑洞加核球,盘面粒子围绕它旋转,粒子之间的相互作用用简化势函数近似。这样每个粒子受到的力可以拆成两部分:来自中心的确定引力,加上邻近粒子的微扰。粒子数控制在几千到几万的量级时,Node.js完全可以胜任。
初始条件的设定也有讲究。要让盘面粒子稳定旋转,初速度应当近似为圆轨道速度 v = sqrt(G·M/r),再叠加少量随机扰动。如果初速度给得不合适,整个盘要么塌缩成一个点,要么飞散掉,模拟就失去意义了。
二、数值积分:Verlet算法的实现
有了力和初始条件,下一步是时间推进。显式欧拉法能量漂移严重,跑几十万步后轨道就面目全非。DeGalaxy使用速度Verlet积分,它具有辛积分性质,长时间运行的能量误差有界,特别适合轨道力学这种需要长期演化的问题。速度Verlet的递推公式很简单:先根据当前加速度更新位置,再用新旧位置的差分更新速度。
class Galaxy {
constructor(n) {
this.particles = this.initDisk(n);
}
step(dt) {
const ps = this.particles;
// 第一步:用当前加速度更新位置,同时暂存旧加速度
for (const p of ps) {
p.x += p.vx * dt + 0.5 * p.ax * dt * dt;
p.y += p.vy * dt + 0.5 * p.ay * dt * dt;
p.oldAx = p.ax;
p.oldAy = p.ay;
}
// 重新计算所有粒子的加速度
this.computeForces();
// 第二步:用新旧加速度的平均值更新速度
for (const p of ps) {
p.vx += 0.5 * (p.oldAx + p.ax) * dt;
p.vy += 0.5 * (p.oldAy + p.ay) * dt;
}
}
}时间步长dt的选择需要权衡:太大则轨道精度崩坏,太小则演化速度慢。经验上取轨道周期的百分之一左右比较安全,DeGalaxy里每步相当于约十万年,跑满十亿年演化需要上万步,全程由Node.js在服务端按固定频率推进。
三、服务端架构:Worker Threads与主线程分工
Node.js是单线程的,如果引力计算直接放在HTTP主循环里,接口会被完全卡死。DeGalaxy的做法是:模拟核心跑在Worker Threads里,主线程只负责接收控制指令(暂停、加速、调整参数)和把粒子状态广播给前端。
// 主线程 server.js
const { Worker } = require('worker_threads');
const express = require('express');
const app = express();
const wss = new (require('ws').Server)({ port: 8081 });
const sim = new Worker('./simulation.js', {
workerData: { particleCount: 5000 }
});
sim.on('message', state => {
// 收到一帧粒子数据,广播给所有浏览器客户端
const buf = Buffer.from(state);
wss.clients.forEach(c => c.send(buf));
});粒子数据传输用ArrayBuffer而非JSON。五千颗粒子的坐标若序列化成JSON字符串,一帧就有几百KB,而Float32Array的二进制形式只有120KB,且省去序列化开销。主线程与Worker之间用transferable objects转移所有权,避免结构化克隆的深拷贝成本,这一点在粒子数上万时差异非常明显。
控制层面还需要考虑指令幂等性。客户端发来的“调整时间倍速”指令应当带版本号,Worker按序处理,否则网络乱序可能导致模拟状态与用户预期不一致。这类细节在演示项目里常被忽略,但恰恰是工程化的价值所在。
四、前端渲染:Canvas与数据的对接
浏览器端拿到二进制帧后,直接映射回Float32Array逐点绘制。绘制的性能要点在于避免每帧创建对象、避免逐粒子调用fillRect之外的高成本API。五千个粒子用单个路径批量绘制即可稳定60帧;若粒子数超过两万,可以按颜色分层渲染,或者把静态的恒星背景预渲染到离屏Canvas,只重绘动态粒子层。
const socket = new WebSocket('ws://127.0.0.1:8081');
socket.binaryType = 'arraybuffer';
socket.onmessage = e => {
const data = new Float32Array(e.data);
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#ffe9c4';
ctx.beginPath();
for (let i = 0; i < data.length; i += 4) {
ctx.moveTo(data[i] + 400, data[i + 1] + 300);
ctx.arc(data[i] + 400, data[i + 1] + 300, data[i + 3], 0, 6.283);
}
ctx.fill();
};实测数据值得参考:在普通四核开发机上,5000粒子全程稳定60fps;粒子数升到20000时服务端单步计算耗时从3ms涨到约40ms,此时需要把引力计算再拆到多个Worker分片并行,或者引入Barnes-Hut树算法把复杂度降到O(n log n)。对演示用途来说,几千粒子的规模已经足以呈现旋臂形成的全过程,这也是DeGalaxy最终选择的平衡点。
整体来看,这类项目把物理建模、数值方法、并发编程和实时渲染串成了一条完整链路,作为Node.js的综合练习非常合适。如果你想在浏览器里看到一片自己“养”出来的星系,不妨从最简的质点模型起步,先让一个粒子稳定绕转,再逐步扩展到整个盘面。
修改时间:2026-09-09 11:59:04