资讯动态

三维生命游戏从规则到实现:Numpy 邻居计数与可视化全指南

发布时间:2026/10/1 13:01:02 来源:尧图企业网站定制
简介一份基于MATLAB的3D生命游戏三维元胞自动机模拟源码面向对复杂系统、元胞自动机及动态建模感兴趣的开发者与学习者适合用于理解细胞自动机规则在三维空间的扩展与可视化验证。资源包内包含1个m主程序文件压缩包约3KB该脚本以三维网格为空间单元实现邻域计数与生死规则迭代并可借助MATLAB绘图接口展示细胞状态演化过程。规则涵盖孤立死亡、过度拥挤及繁殖生存等经典判据代码结构清晰便于在此基础上调整边界条件、修改邻域范围或扩展为不同拓扑模型。目前已有462人学习浏览适合快速上手与二次开发。通过阅读和运行该源码可掌握三维元胞自动机的核心实现思路运用MATLAB矩阵运算高效统计邻居数量并观察生命规则在立体空间中的自组织行为为混沌系统研究或课堂实验提供直观参考。1. 3D 生命游戏不是把棋盘抬高一层从状态爆炸说起二维生命游戏有个让很多人着迷的“不可预知”特性给定一个初始图案你没法简单预测它会不会永远演化下去只能一格一格跑。这件事搬到三维生命游戏里变得更极端——把棋盘换成 64×64×64 的立方体状态空间直接多了两个数量级元胞自动机按“B/S 规则”在每个网格单元上同步更新邻居从 8 个变成 26 个。三维版本也因此不再是二维棋盘“抬高一层”的视觉玩具它会疯长、会瞬间塌缩、会在某个规则下长出类似珊瑚的分支结构。这篇文章就直接把三维生命游戏从规则设计、Numpy 实现、可视化一直写到踩坑排查适合想拿元胞自动机做生成艺术、想验证 3D 规则行为、或者在做三维网格算法实验的开发者。2. 把二维规则搬到三维邻居定义与 B/S 改写二维康威生命游戏的规则只有一句话死细胞周围恰好 3 个活邻居则重生活细胞周围有 2 或 3 个活邻居则继续活。换成三维这个规则不能原样套用因为“周围”这个概念变了——三维格子有 6 个面邻居、12 个边邻居、8 个角邻居总共 26 个邻居。如果再算上自身一个格子周围最多有 27 个格子在同时影响它。2.1 为什么三维默认用 26 邻居而不是 6 邻居最常见的疑问是既然立方体只有 6 个挨着面的邻居为什么很多三维生命游戏实现都默认统计 26 邻居我自己的实践经验是如果只用 6 邻居三维图案很容易长成“细线团”——每个格子只靠面方向传递信息结构在三维空间里迅速拉成一条条细长的线随后整团断裂成短线段图案失去层次。26 邻居把所有角、边、面方向都算进来信息扩散是“体”而不是“面”图案才更像三维空间里的有机生长。代价是邻居度上升同一个 B/S 规则下行为变化更剧烈一个死细胞周围有 5 个活邻居时到底算不算“邻居太多”完全取决于你怎么定规则。所以三维生命游戏一般会先想清楚“用 6 邻居做细结构演示”还是“用 26 邻居做类流体传播”然后再谈规则。2.2 三维网格的数据结构bool 数组与邻居计数三维网格的第一直觉是用list套三层但 Python 列表访问每一格的邻居会慢到怀疑人生。更实际的数据结构是numpy.ndarray用 dtype 为bool的三维数组表示“活 / 死”邻居计数则单独用一个np.uint8数组保存。为什么计数用uint8三维邻居数最多 26用 8 位无符号整数完全够而且内存最省。很多人一开始习惯用np.int64去计数64³ 网格跑下来每个中间数组多占 8 倍内存属于典型的“看着不大、跑起来卡”。邻居计数最省事的写法是对网格做一圈 pad然后对 26 个方向分别切片相加import numpy as np def count_neighbors(cells: np.ndarray) - np.ndarray: h, w, d cells.shape # 真空边界最外层补一圈 False角落和边上的格子也有完整邻居 padded np.pad(cells, 1, modeconstant, constant_valuesFalse) counts np.zeros_like(cells, dtypenp.uint8) for dx in (-1, 0, 1): for dy in (-1, 0, 1): for dz in (-1, 0, 1): if dx 0 and dy 0 and dz 0: continue counts padded[1 dx : 1 dx h, 1 dy : 1 dy w, 1 dz : 1 dz d] return counts这段代码的逻辑是先给原网格周围包一圈 False这样原来在角落的格子也有 27 个“邻居位”可以取不用单独写边界判断。每个方向切片取的是 padded 里偏移后的同一块区域例如dx-1, dy0, dz0取的是原网格左侧邻居的那一层。1 dx到1 dx h的终点必须用h而不是-1否则负方向切片时会错位一格这一步是三维数组最容易写错的地方。modeconstant表示真空边界逃出网格的细胞直接按死算如果做周期边界把np.pad改成modewrap即可后者适合模拟“没有边界”的循环空间但生命游戏里大多数情况用真空边界更直观。2.3 手写邻居计数版本的问题有段时间我图省事直接在cells上切片做邻居统计用try或if判断索引是否越界。结果每个方向都带一组条件判断26 个方向就是 26 组分支代码丑不说Numpy 的向量化优势也被拖没了。np.pad帮我们把边界问题一次性处理掉循环只负责方向的累加是三维生命游戏实现里值得保留的典型结构。如果你后面要扩展到 GPU 版本也建议沿用这个“先 pad、后偏移”的思路——在 CUDA 里对应的是给共享内存加载 halo 区域边界处理逻辑和这里完全一致。3. 把演化循环跑起来从双缓冲到 Numpy 向量化有了邻居计数下一步就是把 B/S 规则套进去。B/S 记法来自二维生命游戏的“Birth/Survival”B 后面的数字表示死细胞周围有几个活邻居时重生S 后面的数字表示活细胞周围有几个活邻居时继续存活。二维康威规则是 B3/S23三维生命游戏一般不能直接用但记法保留了下来。3.1 BORN/SURVIVE三维 B/S 规则的参数化三维 26 邻居环境里每个格子的邻居数分布比二维密集得多。如果直接套 B3/S23结果是活细胞在三维空间里几乎总是有 2 或 3 个邻居于是该死不死的细胞大量存活新一代又大量出生网格几帧内涨成一大团实心体随后内部缺氧式塌缩整个过程非常无聊。所以三维生命游戏的规则参数要从“邻居度”重新推导。经验上的起点是BORN (5, 6)、SURVIVE (4, 5, 6)这类阈值意思是死细胞需要 5~6 个活邻居才重生活细胞需要 4~6 个邻居才不死。阈值比较苛刻图案才不会饱和。3.2 演化主循环一个 20 行 Numpy 实现把规则变成一个纯函数再放进循环里迭代def step(cells: np.ndarray, born: tuple, survive: tuple) - np.ndarray: n count_neighbors(cells) born_mask np.isin(n, born) survive_mask np.isin(n, survive) # 死细胞满足 born 条件 - 重生活细胞满足 survive 条件 - 存活 return (cells survive_mask) | (~cells born_mask) if __name__ __main__: N 64 rng np.random.default_rng(42) cells rng.random((N, N, N)) 0.20 # 初始存活密度 20% born_set (5, 6) survive_set (4, 5, 6) for frame in range(500): cells step(cells, born_set, survive_set) if frame % 25 0: alive int(cells.sum()) print(fframe {frame:04d}: {alive} cells) if cells.sum() 0: print(all dead, stopping early) break这段实现里没有显式写双缓冲因为cells step(...)这行赋值时右边的 mask 和结果都是新数组旧数组交给垃圾回收等价于每帧换一个新 buffer。手写 for 循环逐格更新才需要显式双缓冲那种写法在 Python 里已经没有必要。np.isin(n, born)会把n中等于 born 任意元素的格子标记为 True。对小尺寸整数数组来说够用如果你要做大规模规则搜索可以改成(n 5) | (n 6)这类逻辑或表达式避免np.isin在每一帧里做一次排序查找。rng.random((N, N, N)) 0.20是初始化随机图案的常见写法0.20 是初始密度。密度太低直接全灭太高会瞬间饱和后面会说到怎么调。3.3 规模与性能64³、96³、128³ 分别意味着什么三维网格体量是体积增长64³ 是 262144 个格子96³ 是 884736 个128³ 是 2097152 个。Numpy 向量化的邻居计数在 64³ 下每帧通常只要几十毫秒量级跑几百帧完全没问题但一旦升级到 128³还要同时渲染每帧的 3D 散点图帧率就会明显掉下来。内存方面bool 数组每个元素 1 字节cells 和 counts 各占约 2MB128³再加上中间 mask 数组峰值总共几十 MB远不是瓶颈真正的瓶颈在 NumPy 的分配开销和np.isin的调用成本。我的习惯是规则探索用 64³跑最终成品用 96³ 或 128³同时把规则参数固定下来不再频繁改动。4. 把三维生命游戏画出来可视化选型与帧序列输出规则跑通了接下来要面对的是“看不见结果等于没做”。三维生命游戏可视化的选择比二维多也比二维更容易做丑。4.1 可视化选型对比matplotlib、ffmpeg 帧序列与 Three.js最常见的三条路线是用 matplotlib 的 3D 散点图快速验证规则、用 ffmpeg 把帧序列合成 MP4、用 Three.js 做浏览器里可交互的点云。第一条路最快代码几行就能看结果第二条路适合出片能控制相机角度和帧率第三条路适合给别人展示但要把渲染和演化逻辑搬到前端工程量不是前两条能比的。我的建议是别一上来就上 Three.js。先用 matplotlib 把规则调稳定确认这个三维图案值得看再导出帧序列只有当你确定要做成交互演示时才去考虑 Three.js 的BufferGeometry和Points。4.2 用 matplotlib 画第一帧复用散点图而不是重绘matplotlib 的 3D 散点图每帧重新创建 figure 是性能灾难。正确做法是创建一次scatter对象后面只更新它的坐标数据import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import Axes3D # noqa: F401 N 64 fig plt.figure(figsize(8, 8)) ax fig.add_subplot(projection3d) coords np.argwhere(cells) scat ax.scatter(coords[:, 0], coords[:, 1], coords[:, 2], s1, c#4488ff, alpha0.8) ax.set_xlim(0, N); ax.set_ylim(0, N); ax.set_zlim(0, N) ax.set_xlabel(x); ax.set_ylabel(y); ax.set_zlabel(z) ax.set_box_aspect((1, 1, 1)) ax.view_init(elev20, azim45) def update_scatter(scat, cells): coords np.argwhere(cells) scat._offsets3d (coords[:, 0], coords[:, 1], coords[:, 2])np.argwhere(cells)返回所有存活格子的三维坐标数组形状是 (存活数, 3)直接喂给scatter。更新使用的_offsets3d是 matplotlib 内部属性算是个公开的秘密——2D 的set_offsets管不到 z 轴社区普遍用这个私有属性如果你不想依赖私有 API只能每帧重建散点图性能差距非常明显。ax.set_box_aspect((1, 1, 1))很关键不设的话 z 轴会被压缩三维结构看起来是扁的。如果你想输出 GIF 而不是 MP4也可以用同一套savefig逐帧存图再交给 PIL 或 ffmpeg 处理。4.3 输出 PNG 序列并用 ffmpeg 合成视频每一帧演化完更新 scatter 并保存import os os.makedirs(frames, exist_okTrue) for frame in range(200): cells step(cells, born_set, survive_set) update_scatter(scat, cells) fig.savefig(fframes/frame_{frame:04d}.png, dpi120)保存后用 ffmpeg 合成视频ffmpeg -framerate 20 -i frames/frame_%04d.png -c:v libx264 -pix_fmt yuv420p game_of_life_3d.mp4-framerate 20控制每秒播放多少帧-pix_fmt yuv420p是为了保证大多数播放器能正常解码。调试阶段不需要全分辨率dpi120已经足够看出结构形态确定要出片再提高分辨率。4.4 如果一定要做 Three.js 交互常见做法也是统一的离线把每帧存活坐标点存成二进制文件前端用BufferGeometry加载点坐标再用PointsMaterial渲染。如果前端想自己跑演化逻辑就要维护两套状态数组和计时循环本质上是在浏览器里重新实现一个三维生命游戏引擎复杂度会高出不少。对大多数场景我更推荐把演化放在 Numpy 里算完前端只负责“播放点云帧”这样后端跑规则、前端做渲染职责清爽。5. 三维生命游戏避坑排查5 个最常见的翻车现场这一章值得写在你的笔记最显眼的位置。三维生命游戏能跑通的人不少能一直跑对的人不多问题通常出在下面 5 个地方。5.1 全灭不是规则错了是初始密度和阈值不匹配现象随机初始化 20% 存活细胞跑几十帧后细胞数快速归零画面一片空。原因三维 26 邻居环境下BORN 阈值太低时细胞会疯狂增长然后集体死亡阈值太高时又会在几帧内全灭。初始密度和阈值必须一起看单调规则参数是调不出来的。解决先固定BORN (5, 6)、SURVIVE (4, 5, 6)然后把初始密度从 0.05 开始往上扫每次加 0.05记录 200 帧后的存活量。密度 0.02~0.15 是常见能出形态的范围超过 0.3 大概率瞬间饱和。5.2 边界上出现幽灵活细胞现象某几帧边界上突然出现一排不规则的活细胞而且这些细胞只出现在 x、y、z 的最外层。原因邻居计数切片写错常见是把padded[1 dx : 1 dx h]的终点写成h后却用了-1或者是手写边界判断时把角落格子的三个方向漏掉导致边界格子的邻居数和内部不一致。解决用np.pad统一处理边界切片终点一律写成1 dx h不要在索引里出现-1。检查方法很简单让整个网格只有正中心一个活细胞跑一步 count_neighbors最外层所有格子计数必须为 0。5.3 内存莫名其妙飙升现象64³ 的网格看起来数据量不大但 Python 进程内存占用轻松超过 500MB。原因cells用了np.int64counts也用了默认 int64每帧五六次临时数组分配每个元素占 8 字节而不是 1 字节或者演化循环里把旧数组都存进了列表用于回放导致帧数越多内存越大。解决cells显式用 bool dtypecounts显式用np.uint8。想回放就把坐标点存盘而不是把整个数组对象留在内存里用np.argwhere(cells)拿到存活坐标再存二进制一帧几 KB 到几百 KB远比存原始网格省。5.4 图案疯长成实心块现象图案几帧内变成一个大实心球随后内部开始空洞化但整体形态完全不是想要的分支结构。原因SURVIVE 阈值太宽松比如直接套二维 B3/S23三维里大量低邻居细胞存活密度上升整个网格进入“高密度噪声”状态。三维空间不稀缺邻居低阈值等于不给细胞死亡压力。解决把 SURVIVE 往高抬建议从(4, 5, 6)开始BORN 从(5, 6)开始。如果出现实心块优先提高 SURVIVE 的最低值比如改成(5, 6)让低邻居细胞死得更快。5.5 帧率卡成 PPT整个工程没法用现象演化一帧要几百毫秒可视化刷新更慢滚动画面前后拖泥带水。原因手写 Python for 循环逐格统计邻居或者可视化每帧重新创建figure和scatter。前者是 O(N³×26) 的 Python 解释器开销后者是 matplotlib 对象重建成千上万个点。解决邻居统计全部转成 Numpy 切片加法可视化只更新scat._offsets3d如果还卡就把 N 降到 48 或 64 先调规则不要在 128³ 上频繁试参数。6. 进阶用存活率曲线找出属于你自己的稳定规则三维生命游戏的规则空间很大与其靠肉眼判断哪个参数好不如写一个最小验证函数用“200 帧后的存活率”作为规则好坏的第一道筛选def survival_ratio(born, survive, N40, steps200, density0.15): rng np.random.default_rng(0) cells rng.random((N, N, N)) density for _ in range(steps): cells step(cells, born, survive) if cells.sum() 0: return 0.0 return float(cells.mean())然后用一个小循环扫描候选规则for born in [(4,), (4, 5), (5, 6)]: for survive in [(4, 5), (5, 6), (4, 5, 6)]: ratio survival_ratio(born, survive) print(fB{born}/S{survive}: {ratio:.3f})存活率趋近 0 的规则会快速全灭趋近 1 的规则会饱和成实心块中间区域比如 0.05~0.3才是值得继续视觉化的候选。这个扫描很快N40、200 帧一组规则几秒到十几秒完全可以睡前挂着跑一轮。我的习惯是每次扫描把born、survive、初始密度写进文件名或者结果表里比如B5_6_S4_5_6_d0.15_ratio0.12.npz。参数这东西不记下来就是玄学隔两天就忘记下来之后每一个能持续演化的三维图案都是可复现的实验结论而不是碰运气。这大概也是三维生命游戏和二维最大的区别二维康威规则是“找出来的”三维生命游戏的规则得靠你自己扫出来。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑