新星爆发是天文学中极具视觉冲击力的现象:双星系统中的白矮星不断吸积伴星物质,当表层氢积累到临界点时发生剧烈的热核反应,亮度在短时间内暴增。这个从缓慢积累到瞬间释放的过程,本身就非常像一个计算模型——一段时间持续累积能量,达到阈值后触发爆发。用Node.js做一个名为DeNova的模拟项目,把这个过程可视化出来,既是一次有趣的物理建模练习,也是一次对Node.js实时数据推送能力的实战检验。

一、DeNova的物理建模:如何简化才合理
真实的新星爆发涉及流体力学、核反应速率、辐射传输等复杂物理过程,直接照搬方程在浏览器端跑是不现实的。DeNova采取的策略是抓主要矛盾:白矮星吸积、临界质量触发、爆发抛射三个阶段分别建模,每个阶段只用最核心的数学关系。
吸积阶段用线性增长近似,白矮星质量随时间以固定速率增加。当质量接近钱德拉塞卡极限的一个设定比例时,引入不稳定性因子,让增长率出现随机扰动,模拟真实吸积盘的物质传输波动。爆发阶段则是把积累的引力能按幂律分布释放,转化为粒子的初速度和亮度。
抛射物用粒子系统表示,每个粒子有位置、速度、质量、温度四个属性。温度决定粒子的颜色渲染——从高温的蓝白色逐渐冷却到橙红色,这个细节能让视觉效果真实不少。下面是核心模型的数据结构设计:
// DeNova 核心恒星模型
class StellarModel {
constructor() {
this.mass = 0.8; // 当前质量,单位为太阳质量的简化值
this.criticalMass = 1.35; // 临界质量阈值
this.accretionRate = 0.002; // 每tick吸积增量
this.instability = 0; // 不稳定性因子
this.state = 'accreting'; // accreting / erupting / cooldown
this.energy = 0; // 积累的爆发能量
}
tick() {
if (this.state === 'accreting') {
// 接近临界值时引入随机扰动,模拟吸积盘波动
const ratio = this.mass / this.criticalMass;
const jitter = ratio > 0.9 ? (Math.random() - 0.5) * 0.004 : 0;
this.mass += this.accretionRate + jitter;
if (this.mass >= this.criticalMass) {
this.state = 'erupting';
// 爆发能量与超过临界值的质量成正比
this.energy = (this.mass - this.criticalMass + 0.15) * 1000;
return this.generateEjecta();
}
}
return null;
}
// 生成抛射粒子,速度按幂律分布
generateEjecta() {
const particles = [];
const count = 1500;
for (let i = 0; i < count; i++) {
const theta = Math.random() * Math.PI * 2; // 平面角度
const phi = Math.acos(2 * Math.random() - 1); // 立体角均匀采样
const speed = 0.5 + Math.pow(Math.random(), 2) * 3; // 幂律速度分布
particles.push({
x: 0, y: 0, z: 0,
vx: Math.sin(phi) * Math.cos(theta) * speed,
vy: Math.sin(phi) * Math.sin(theta) * speed,
vz: Math.cos(phi) * speed,
mass: 0.5 + Math.random(),
temp: 25000 + Math.random() * 20000 // 初始温度,单位K的简化值
});
}
return particles;
}
}
module.exports = StellarModel;这里的立体角均匀采样是个容易被忽略的细节。如果直接用两个随机角度生成方向,粒子会在两极聚集、赤道稀疏,爆发看起来像哑铃而不是球壳。用Math.acos(2 * Math.random() - 1)对第二个角度做反余弦变换,才能得到真正的球面均匀分布。
二、Node.js服务端架构:模拟循环与数据推送
DeNova的架构选择是把物理模拟放在Node.js服务端,浏览器只负责渲染。这样做的好处是逻辑集中,多客户端看到的是同一场爆发,而不是各算各的。服务端用setInterval驱动模拟tick,把粒子状态通过WebSocket推送给所有连接的客户端。
推送频率是个需要权衡的问题。粒子的物理更新可以每16毫秒算一次,但每次都把1500个粒子的完整坐标推下去,网络开销相当可观。DeNova的做法是服务端只推送粒子的速度和初始位置,客户端自己根据本地时间做插值计算——也就是所谓的快照加外推方案。爆发瞬间推一次完整快照,之后每秒只推一次温度冷却参数。
// server.js —— DeNova 模拟服务
const WebSocket = require('ws');
const StellarModel = require('./stellar-model');
const wss = new WebSocket.Server({ port: 8080 });
const model = new StellarModel();
let ejecta = null;
// 模拟主循环:每16ms一个tick
setInterval(() => {
const result = model.tick();
if (result) {
// 爆发触发:推送完整粒子快照
ejecta = result;
const snapshot = {
type: 'nova-burst',
time: Date.now(),
particles: ejecta.map(p => ({
x: p.x, y: p.y, z: p.z,
vx: p.vx, vy: p.vy, vz: p.vz,
temp: p.temp
}))
};
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(snapshot));
}
});
// 爆发后进入冷却,一段时间后重置模型循环演示
setTimeout(() => {
model.mass = 0.8;
model.state = 'accreting';
ejecta = null;
}, 12000);
}
}, 16);
// 低频推送温度冷却系数,客户端据此调整粒子颜色
setInterval(() => {
if (!ejecta) return;
const cooling = {
type: 'cooling',
factor: 0.985,
time: Date.now()
};
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(cooling));
}
});
}, 1000);
console.log('DeNova server running on ws://127.0.0.1:8080');值得注意的一点是ws库的广播写法。许多新手会遍历连接数组发送,但wss.clients本身是一个Set,直接遍历并检查readyState是官方推荐的方式,能避免向已断开的死连接写入数据导致进程抛异常。
另外,JSON序列化1500个粒子的耗时在大约3到5毫秒之间,这在单线程的Node.js里是可以接受的。但如果粒子数量提升到上万级别,建议改用二进制协议,把坐标编码为Float32Array再传输,体积能压缩到JSON的三分之一以下,序列化耗时也几乎可以忽略。
三、前端Canvas渲染:投影、色彩与性能
客户端拿到三维粒子坐标后,需要投影到二维画布上。DeNova用的是最简单的透视投影:根据z坐标计算缩放因子,越远的粒子越小、越暗。渲染循环用requestAnimationFrame驱动,粒子的当前位置由初始位置、速度和经过的时间计算得出,不需要服务端持续推送。
// client.js —— 浏览器端渲染核心逻辑
const canvas = document.getElementById('scene');
const ctx = canvas.getContext('2d');
const socket = new WebSocket('ws://127.0.0.1:8080');
let burst = null; // 爆发快照
let burstTime = 0; // 爆发时间戳
let cooling = 1; // 温度冷却系数
socket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'nova-burst') {
burst = msg.particles;
burstTime = msg.time;
} else if (msg.type === 'cooling') {
cooling = msg.factor;
}
};
// 温度转RGB:高温蓝白,低温橙红
function tempToColor(t) {
const ratio = Math.min(t / 45000, 1);
const r = Math.round(255 * Math.min(1, 1.2 - ratio));
const g = Math.round(200 * ratio + 40);
const b = Math.round(255 * ratio);
return `rgb(${r},${g},${b})`;
}
function render(now) {
ctx.fillStyle = 'rgba(0, 0, 5, 0.35)'; // 半透明背景制造拖尾效果
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (burst) {
const elapsed = (now - burstTime) / 1000;
const cx = canvas.width / 2, cy = canvas.height / 2;
for (const p of burst) {
// 根据速度和时间外推当前位置
const x = p.x + p.vx * elapsed;
const y = p.y + p.vy * elapsed;
const z = p.z + p.vz * elapsed;
const scale = 300 / (300 + z); // 透视投影
const sx = cx + x * scale * 100;
const sy = cy + y * scale * 100;
const alpha = Math.max(0, scale - 0.2);
ctx.fillStyle = tempToColor(p.temp * Math.pow(cooling, elapsed));
ctx.globalAlpha = alpha;
ctx.fillRect(sx, sy, 2 * scale, 2 * scale);
}
ctx.globalAlpha = 1;
}
requestAnimationFrame(render);
}
requestAnimationFrame(render);渲染部分有三个提升观感的关键技巧。第一,背景用半透明黑色填充而不是clearRect,粒子会自然形成运动拖尾,爆发瞬间向外喷射的丝缕感就出来了。第二,粒子绘制用fillRect而非arc,后者每个粒子都要走一次路径构建,1500个粒子时帧率差距能到两倍。第三,温度颜色映射让爆发从中心亮白逐渐过渡到边缘暗红,比单色粒子真实得多。
四、常见坑与优化方向
实现过程中有几个容易踩的坑。一是客户端与服务端时钟不同步:外推算法依赖Date.now(),如果两端时钟偏差超过几百毫秒,粒子一出场位置就不对。解决办法是在握手阶段做一次时间校准,消息里携带服务端时间戳,客户端计算偏移量修正。
二是粒子数量的性能边界。Canvas 2D在三千个粒子以内基本能保持60帧,再多就应该考虑分层:核心亮区用Canvas绘制,稀薄外层降频渲染。如果追求更极致的效果,可以迁移到WebGL,用点精灵一次绘制上万个粒子,Node.js服务端的架构完全不用改动。
三是模拟重置时的状态清理。爆发结束后重置模型,必须确保客户端收到新的爆发快照时旧粒子会被替换,否则两次爆发的粒子会叠加渲染。上面代码中直接用burst = msg.particles整体替换,天然规避了这个问题。
进一步扩展的话,可以加入双星轨道可视化,让用户直观看到伴星物质如何流向白矮星;也可以开放参数面板,调节吸积速率和临界质量,观察爆发周期的变化。这些改动都只涉及模型层,服务端推送和客户端渲染的框架可以原样复用,这正是把模拟逻辑和展示层分离带来的好处。