简介:基于JavaScript的粒子生命模拟项目,用动态粒子替代传统细胞网格,重现生命游戏中集群涌现、移动与衰亡的画面,视觉体验更接近自然生态系统。项目源自CodeParade的原始实现,主要逻辑全部用JavaScript编写,代码分层明确,适合对生成艺术、粒子系统、Canvas渲染和前端性能优化感兴趣的开发者阅读、模仿与二次创作。压缩包共16个文件,整体体积仅13KB,核心包括5个JavaScript源码模块,分别实现宇宙主流程、粒子行为、色彩转换、数组容量扩展等;其余为项目说明、依赖清单、部署脚本、编辑器配置、许可证等工程性文件,结构干净易上手。目前已有370人学习下载。资源还保留了作者记录的已知瓶颈与优化思路,如渲染帧率偏低、算法和数据结构调整、随机库性能取舍、相机缩放与跟踪等,并附有npm开发和构建命令,读者既可根据这些线索进行性能调优,也可以直接运行观察粒子集群的自组织过程,作为生成艺术或Web动画实验的起点。
1. particle-life 是什么:一屏粒子,几条规则,涌现出像生命一样的运动
如果把几百个粒子撒在屏幕上,每个粒子只认识自己的“物种”,每种物种之间只有“吸引/排斥/忽略”三种关系——没有任何物理引擎、没有神经网络、没有全局约束,它们却会自发地聚成条纹、绕成旋涡、彼此追逐,甚至像一群微生物一样呼吸和游动。这就是 particle-life(粒子生命)模拟的核心景象:用一张极简的规则表驱动自组织行为。它解决的是“怎么让动态视觉持续产生意外但不失控的变化”这一问题;适合想做低成本动态视觉的开发者、刚入门自组织仿真的读者,以及那些想给孩子做一个能调参数玩一下午的互动动画的人。接下来我把这套系统的规则、最小实现和调参心得从头过一遍。
2. 粒子生命的规则设计:距离衰减、规则表与自组织
2.1 核心规则:一张“物种对物种”的吸引排斥表
实现这个系统最核心的数据结构是一张二维数组:rules[物种A][物种B]。它的值是 AB 两个物种之间的相互作用系数:正数表示 A 被 B 吸引,负数表示 A 被 B 排斥,零表示忽略。注意这里的相互作用是非对称的——也就是说rules[0][1]和rules[1][0]完全可以不同,它们分别是物种 0 的粒子看待物种 1 的方式,以及物种 1 的粒子看待物种 0 的方式。
规则的物理形式我一般这样写:对每个粒子 i 遍历所有粒子 j,根据两者物种从规则表里取一个系数 f,再乘上一个距离衰减因子,得到当前的受力方向。计算方式是把两粒子之间连线方向的单位向量乘以f * overlap,其中overlap = (R - d) / R,d 是两粒子距离,R 是感知半径。这样距离越近,作用力越强;距离超过 R,相互作用直接归零。
这套机制和元胞自动机里的“生命游戏”有本质区别:生命游戏是离散网格加同步状态更新,规则是“邻居数量决定生死”;而粒子生命发生在连续二维空间里,作用力是平滑变化的,因此能看到更接近真实生物的蠕动、追逐和集群行为。理解这一点有助于你后续调试:一切奇怪的形态,都要先回规则表里找原因。
2.2 距离衰减函数怎么选:线性衰减是默认项
距离衰减函数决定了力随距离变化的曲线形状。最常见的做法是线性衰减,即上文提到的overlap = (R - d) / R。它的好处是直觉好懂、梯度平滑,粒子之间不会出现突然的受力跳变;代码实现也最简单。
如果你希望系统表现得更“柔软”,可以用指数衰减:overlap = exp(-d / R)。这样远处仍有微弱作用力,整体行为更绵长,但会产生额外的参数——衰减系数——让调参复杂化。反过来,如果你希望粒子边界更清晰、相互作用更短促,那么可以给受力加一个最小距离下限:当 d < 某个临界值(比如 2 像素)时,直接跳过受力计算,避免两个粒子重叠时产生巨大的力导致爆炸。
选型建议是:第一版先无脑用线性衰减,跑通后再对比指数衰减的效果。这两个版本在代码上只差一行,但体验差异非常明显。如果你想要那种“游丝一样”的拖曳感,指数衰减值得换;如果你想要边界分明的细胞状结构,线性衰减更可控。
2.3 规则表从哪来:随机表起步,手工表进阶
规则表有两种来源。第一种是随机生成:每个rules[i][j]取[-1, 1]之间的随机数,正为吸引,负为排斥。这是我最常用的起步方式,因为粒子生命的行为空间极其广阔,随机表往往能撞出意想不到的结构,比如螺旋、网状、聚团后整体漂移、甚至周期性的收缩膨胀。
第二种是手工微调:把全部规则置零,然后逐个打开某几对关系。比如想让物种 0 追物种 1,就设rules[0][1] = 1、rules[1][0] = -1;想要一个环形结构,可以构造一圈物种之间的单向吸引链,使每个物种只被它的“下一环”吸引。这种方式适合有明确目标形态的场景。
手工微调的困难在于:多物种之间的相互作用是非线性的,改一个系数往往引发连锁反应。我的习惯是一次只改一个数,记录改动前后形态的差异;同时维护一份“配方笔记”,写上哪个参数配合哪种形态。时间长了,你就能凭感觉猜出某个规则表大概长什么样。
2.4 参数面板:密度、感知半径、力幅、阻尼
除了规则表,运行参数决定了一屏粒子“性格”的大方向。按照我常用的默认值,主要参数有这些:
| 参数名 | 含义 | 常见取值范围 | 取值过大 | 取值过小 |
|---|---|---|---|---|
| N | 粒子总数 | 300 ~ 1500 | 计算量大,画面拥挤 | 交互稀疏,结构出不来 |
| SPECIES | 物种数 | 3 ~ 6 | 视觉分离度差,难观察 | 相互作用组合少 |
| R | 感知半径(像素) | 40 ~ 100 | 角色混乱,全局黏连 | 局部孤立,各自为政 |
| SCALE | 力幅倍率 | 0.02 ~ 0.1 | 剧烈抖动、粒子爆炸 | 运动迟钝、几乎静止 |
| FRICTION | 速度阻尼系数 | 0.6 ~ 0.95 | 惯性大、容易抽风 | 运动衰减太快、冻结 |
| MAX_SPEED | 速度上限 | 2 ~ 6 | 画面过冲 | 粒子爬行 |
这里最容易踩坑的是FRICTION和SCALE的组合。FRICTION = 0.8表示每帧速度乘以 0.8,值越小阻尼越大。如果你看到粒子要么不动、要么疯跑,先查这两个数的配合对不对,而不是急着改规则表。
3. 最小可运行实现:用 HTML + Canvas 在浏览器里跑通粒子生命
3.1 完整代码:把下面的内容存成 HTML 直接打开
我选择用 Canvas 和原生 JavaScript 来实现最小版本,因为它不需要任何安装步骤、没有依赖、双击就能运行,适合快速验证规则想法。下面就是个可运行的完整实现。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>particle-life 最小实现</title> <style> body { margin: 0; background: #111; display: flex; justify-content: center; align-items: center; height: 100vh; } canvas { background: #111; } </style> </head> <body> <canvas id="c" width="800" height="600"></canvas> <script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); const W = canvas.width, H = canvas.height; // ---- 可调参数 ---- const N = 500; // 粒子总数 const SPECIES = 5; // 物种数量 const R = 80; // 感知半径 const SCALE = 0.05; // 力的倍率 const FRICTION = 0.82; // 速度阻尼,越大惯性越强 const MAX_SPEED = 4; // 最大速度限制 const DT = 1; // 固定时间步 // ---- 随机生成规则表:正吸引,负排斥 ---- const rules = []; for (let s1 = 0; s1 < SPECIES; s1++) { rules[s1] = []; for (let s2 = 0; s2 < SPECIES; s2++) { rules[s1][s2] = Math.random() * 2 - 1; // -1 到 1 的随机数 } } // ---- 粒子类 ---- class Particle { constructor() { this.x = Math.random() * W; this.y = Math.random() * H; this.vx = (Math.random() - 0.5) * 4; // 随机初始速度 this.vy = (Math.random() - 0.5) * 4; this.species = Math.floor(Math.random() * SPECIES); } } const particles = Array.from({ length: N }, () => new Particle()); // ---- 计算每个粒子所受的合力(写进 ax/ay)---- function applyRules() { for (let i = 0; i < N; i++) { let ax = 0, ay = 0; for (let j = 0; j < N; j++) { if (i === j) continue; const dx = particles[i].x - particles[j].x; const dy = particles[i].y - particles[j].y; const d2 = dx * dx + dy * dy; if (d2 > R * R || d2 < 1) continue; // 超出感知半径或完全重叠则跳过 const d = Math.sqrt(d2); const overlap = (R - d) / R; // 距离越近作用力越强 const f = rules[particles[i].species][particles[j].species] * overlap * SCALE; ax += f * dx / d; ay += f * dy / d; } particles[i].ax = ax; particles[i].ay = ay; } } // ---- 更新位置与速度 ---- function update() { applyRules(); for (let p of particles) { p.vx += p.ax * DT; p.vy += p.ay * DT; p.vx *= FRICTION; // 每帧摩擦 p.vy *= FRICTION; const speed = Math.hypot(p.vx, p.vy); if (speed > MAX_SPEED) { // 限速,防止数值爆炸 p.vx = p.vx / speed * MAX_SPEED; p.vy = p.vy / speed * MAX_SPEED; } p.x += p.vx * DT; p.y += p.vy * DT; // 边界反弹:碰到边缘,反向并拉回 if (p.x < 0) { p.x = 0; p.vx *= -1; } if (p.x > W) { p.x = W; p.vx *= -1; } if (p.y < 0) { p.y = 0; p.vy *= -1; } if (p.y > H) { p.y = H; p.vy *= -1; } } } // ---- 渲染 ---- function draw() { ctx.fillStyle = 'rgba(17, 17, 17, 0.25)'; // 半透明叠加形成尾迹 ctx.fillRect(0, 0, W, H); const palette = ['#ff5e5e', '#4ecdc4', '#ffe66d', '#a78bfa', '#f472b6']; for (let p of particles) { ctx.fillStyle = palette[p.species % palette.length]; ctx.fillRect(p.x - 1.5, p.y - 1.5, 3, 3); } requestAnimationFrame(loop); } function loop() { // 每个粒子独立更新多次,让单帧内运动更稳定 update(); draw(); } loop(); </script> </body> </html>把这段代码保存成particle-life.html,双击用浏览器打开,就能看到满屏粒子开始按规则表运动。每次刷新都会生成不同的随机规则表,所以每次运行都是一副新面孔。
3.2 代码逻辑说明:力为什么要除以距离
上面代码的核心逻辑就一个双循环:粒子 i 对每个粒子 j 计算位移向量(dx, dy),然后根据距离 d 算出归一化方向(dx/d, dy/d),乘上规则系数和距离权重。这里的ax += f * dx / d是在把“力”分解到 x、y 两个方向上,/d是把向量归一化,确保无论粒子相距多少,力的方向都是单位向量;实际力的大小由f = 系数 * overlap * SCALE决定。
为什么要设置d2 < 1就跳过?因为当两个粒子几乎重叠时,overlap接近 1,但距离 d 接近 0,万用方向向量的数值会剧烈跳动,导致力呈脉冲状,粒子会被弹飞。跳过这个极小区间可以保持系统稳定。所有物理模拟里,都需要警惕“除以一个接近零的数”。
初看这个代码会觉得SCALE、FRICTION、MAX_SPEED三个参数互相拉扯,但其实它们分工明确:SCALE决定力能多大,MAX_SPEED决定速度上限,防止瞬间飞走;FRICTION决定能量在这个系统里衰减多快。三者配合得当,系统才能进入“有持续运动但不过激”的状态。
3.3 如何验证你的模拟“活了”
刚跑通时,你可能会看到一坨粒子挤在一起不动,或者全部飞散到屏幕外。这不算“活”。我判断一个粒子生命系统是否活着的标准有三条:第一,粒子群的平均速度稳定在一个非零水平,而不是单调递减到静止;第二,存在可识别的持续结构,比如细长的丝带、环状聚集或周期性伸缩,且这个结构在几十帧内保持可辨认;第三,局部的粒子群会在整体结构不变的前提下缓慢流动、旋转或换位——就像鸟群一样,整体形态稳定,个体却在不断交换位置。
如果运行一两秒后画面变成一坨静止的色斑,或全部在高速抖动,先不急着改规则表。按下一节的排查清单检查参数,大概率能定位问题。
4. 粒子生命实战避坑:我调参时常翻的 5 个车
4.1 所有粒子挤成一团,完全不运动
现象:开跑 2 秒后,所有粒子收缩成一个几乎静止的圆团,无论等多久都没有变化。
原因:吸引力占绝对主导,而且FRICTION设得太小(阻尼太大),粒子在靠近的过程中把速度损耗光了,系统被“冻”在了局部能量最低点。规则表里正数偏多,或者某对物种之间的吸引力系数恰好过大。
解决:先降低SCALE到 0.02 左右试试——降低力幅能让粒子不至于一次靠拢就失去速度。同时把FRICTION调高到 0.9,让粒子保持更多惯性,惯性是让系统走出静止团块的关键。如果还不行,在 update 里给每个粒子每帧加一个极小的随机扰动力,例如p.vx += (Math.random() - 0.5) * 0.05,相当于模拟环境噪声,专门用来打破冻结状态。
4.2 粒子向屏幕四角逃散,留下大片空白
现象:粒子越跑越远,最后全部贴在边缘上,像一群被甩到角落的弹球。
原因:规则表里排斥力整体占优,驱赶作用大于凝聚作用;再加上简单的反弹边界使得粒子像碰壁弹球一样在角落里出不去。某个物种如果同时对其他所有物种呈强排斥,它就会成为系统里的“孤狼”,不断把别人推开。
解决:把边界改成软边界——给每个粒子增加一个朝向画布中心的弱吸引,距离越远向心力越强。实现上只需在update()里加一句:p.vx += (W / 2 - p.x) * 0.001; p.vy += (H / 2 - p.y) * 0.001;。这个力很弱,不会破坏规则表产生的局部结构,但能把粒子拉回屏幕中部。如果你不想改动物理逻辑,更直接的办法是重新生成规则表,或用“行列式调参法”把排斥改成吸引。
4.3 玩了 10 秒后系统“死”掉,形态不再变化
现象:开场时还像模像样地旋转、游走,但过了一会儿所有粒子速度趋近于零,停在某个静止的构型里再也不动了。
原因:这是粒子生命系统里最常见的“热寂”,本质是规则表太稀疏(大部分系数接近 0),粒子之间缺乏持续驱动。另一个诱因是MAX_SPEED太低,想动但被限速卡死;和第一种冻结的差别在于,这里系统是慢慢停下来的。
解决:先确认规则表确实有足够多的非零值——随机生成的表基本不会全零,但如果手工表全零就是一个空洞。用console.table(rules)打印规则表,看看对角线和关键物种对的数值分布。然后检查MAX_SPEED:把它从 4 提到 6,速度上限放宽,粒子就有更多空间维持波动性。最后检查是否所有初始速度都为 0——如果是,给所有粒子一个随机初速度非常重要,初始动能是系统激活的引子。
4.4 粒子数量一多就卡成幻灯片
现象:N 从 500 提到 3000 后,帧率从 60 掉到 10 甚至更低,整个页面卡顿。
原因:此实现是标准的 O(n²) 双循环,一帧就要计算 n(n-1)/2 个距离。3000 粒子的单帧计算量接近 450 万次距离计算,在普通浏览器里必然卡顿。
解决:最有效的办法是引入空间哈希(spatial hash grid)。把整个画面按边长等于感知半径 R 的格子划分,每个粒子只检查自己所在格子及周围 8 个格子的粒子。因为感知半径本身限制了交互范围,实际参与计算的配对数量会大幅下降。具体做法是:先按Math.floor(x / R)算出格子索引,存到一个Map里;遍历时只取周围格子的粒子数组做双重循环。但请注意,空间哈希的核心约束是格子边长必须等于 R,不然会漏掉感知范围内的配对,这是最容易出问题的地方。
如果不想做空间哈希,直接把N控制在 800 以内,这台代码也能流畅跑;同时把画布缩小到 600×450,靠降低面积来维持视觉密度,也是一个合理的妥协。
4.5 每次刷新画面效果都不一样,无法复现好看的形态
现象:好不容易调出一套漂亮的螺旋结构,刷新一下页面就没了;想复现,发现随机规则表和初始位置都变了。
原因:默认代码里规则表和粒子位置、初速度全部用了Math.random(),每次运行的种子不同,仪表形态自然不同。对探索性模拟这可能是优点,但当你找到心仪形态后,它就变成纯粹的后悔药缺失。
解决:给模拟一个可复现的随机种子。最简单的方式是使用 Mulberry32 这类几十行的伪随机函数,把所有随机采样从Math.random()换成seedRandom();在页面顶部把种子数写成一个常量,比如const SEED = 2024001。刷新后种子不变,规则表、粒子位置、初速度全部一致,你就能反复打磨同一套视觉效果,直到满意为止。这一招在后续批量筛选规则表时尤其重要。
5. 把粒子生命从“能跑”玩到“好看”:形态筛选、行为指纹与交互增强
5.1 用批量筛选代替手动碰运气
随机规则表虽然有趣,但大部分表生成的形态其实是平庸的——要么死寂,要么乱成噪音。我自己的习惯是写一个批量筛选脚本:随机生成 500 组规则表,每组让模拟跑 300 帧,然后用两个指标打分——聚集度(粒子间平均距离的倒数)和活跃度(平均速度是否维持在一定区间)。分数最高的 20 组自动保留回放。这样能快速把 500 组里最像生物的几种形态挑出来,而不需要手动试半天。再加一个固定种子作为种子编号保存下来,下次跑直接用。
5.2 给粒子生命加交互:鼠标扰动与注入
展示阶段加上鼠标交互能极大提升吸引力。实现上有两个低成本方案:一是在鼠标位置模拟一个“排斥场”——检测粒子与鼠标的距离,若在 120 像素内,给粒子加一个远离鼠标的力;这样就像在鱼群里投了一块石头,结构会破开再愈合。二是鼠标点击时在点击处生成 20 个新粒子,物种随机;新粒子会立即与周围环境互动,成为局部形态的新变数。这两个改动各不到十行代码,但对演示效果的影响很大。
5.3 三个行为指纹:聚集度、平均速度、结构变化率
如果你想更系统地判断“这组参数到底是不是足够好看”,建议给模拟加三行可视化信息:聚集度(全局粒子对的最短距离平均值,越低说明结构越是抱团)、平均速度(系统是否还活着)、帧间位移差(粒子群体逐帧位置变化量,反映结构是否在演化)。用一条简单的折线图展示这三个指标的变化,你就能一眼看出系统是进入静止、周期性振荡还是混沌状态。
这三项指标的实现都很简单:在每一帧的update()结束后遍历一次粒子,累计距离和速度后除以 N。唯一的注意点是不要让测量逻辑拖累渲染,建议每 10 帧采样一次,而不是每帧采样。
5.4 尾迹、配色与“类质感”渲染的小技巧
粒子生命默认的小方块渲染其实已经足够清晰,但如果想让画面更接近“生命体液”的质感,有两个便宜好用的改动。第一,把fillRect改成小半径圆形;第二,在每帧背景填充时把透明度从 0.25 降低到 0.08,让尾迹拖得更长,形成流动的丝线感。配色方面,6 个物种以内的主色调用色相环均匀取色即可;超过 6 种就非常容易糊成一团,不如按物种聚类后只染 3 种主色。
我的经验是,粒子生命的“好看”往往不在粒子本身,而在整体的节奏感——尾迹再长一点,粒子再小一点,速度再慢一点,往往一组平庸参数也能散发出某种水族箱般的宁静。你想要的形态,通常在“参数超级敏感”和“参数超级迟钝”的边界线附近,调参时需要勇气,也需要耐心。
做这套模拟时我得到过最大的教训,就是永远不要只看现象就去调规则表——先看数据,先重现,再动手。有一次某开发者把一坨白团当成 bug,改了半天规则都无效,最后发现只是物种数量设太多导致颜色碰撞,视觉难以区分。从那以后,我把“固定随机种子、打印规则表、记录行为指标”变成了惯例。希望这一套流程也能让你少走一点弯路,希望今天的这些参数和踩坑记录帮到你。
本文还有配套的精品资源,点击获取