☰
三维生命游戏从平面到体素:规则设计、Python实现与可视化指南
2026/10/1 23:17:45 网站建设 项目流程

简介:基于 MATLAB 的三维生命游戏实现源码,适合对元胞自动机、复杂系统模拟和 MATLAB 可视化编程感兴趣的开发者及研究人员。这份源码将经典康威生命游戏规则从二维网格扩展至三维空间,通过计算每个细胞在上下、左右、前后及对角线方向上的活跃邻居数量,按孤立、过度拥挤和繁殖规则迭代更新状态,并实时渲染三维演化画面,是理解三维元胞自动机原理和矩阵位运算技巧的实用样例。压缩包内仅包含 1 个 m 格式的 MATLAB 源文件,体积约 3KB,结构精简,便于直接运行与二次修改调试。目前已有 462 人学习/下载。阅读代码可掌握三维网格创建、邻居统计、状态更新循环和可视化设置等实现细节;调整初始细胞分布或规则阈值,还能观察到不同复杂结构的涌现过程,为深入研究混沌、自组织与涌现计算提供了直观的实验平台。由于全部逻辑集中在一个脚本中,亦便于初学者逐行阅读和开展扩展实验。

1. 三维生命游戏:当元胞自动机从平面走进体素空间

提起元胞自动机,绝大多数人第一时间想到的是康威的二维生命游戏:一张网格、8 个邻居、几条规则,却能涌现出滑翔机、振荡器这类复杂结构。三维生命游戏就是把这个模型推广到 x/y/z 三轴:每个格子有 26 个邻居,生死规则按三维重写,得到一套跑在体素空间里的元胞自动机。它适合两类人:一类做体素可视化与交互演示,一类练大规模并行计算和规则搜索。代价是,2D 的 B3/S23 规则直接搬过来跑不了几代;重新设计规则、高效数邻居、把几十万个体素画出来,才是真正的工程量。这篇笔记按规则、实现、可视化、排错、进阶的顺序,把三维生命游戏落地路径完整讲一遍。

2. 三维规则设计:从 26 邻域到规则参数搜索

2.1 为什么 B3/S23 搬进 3D 会两代全灭

先明确“邻居”概念。2D 生命游戏用 8 邻域(上下左右加四个对角),3D 用 26 邻域:6 个面邻居(距离 1)、12 条棱邻居(距离根号 2)、8 个角邻居(距离根号 3)。对一个随机初始密度 p 的网格,某个格的期望邻居数是 26p。假设 p=0.1,期望邻居数就是 2.6。这套数字看起来不大,但它彻底改变了规则参数的语义。

B3/S23 的意思是:死细胞周围恰好 3 个邻居时诞生,活细胞周围 2 或 3 个邻居时存活。放到 3D 里,问题立刻暴露:出生太苛刻。期望 2.6 个邻居的分布里,“周围至少 3 个活邻居”的概率并不高,新生细胞来源不足。与此同时,活细胞在周围密度正常时很容易超过 3 个邻居,存活窗口过窄,细胞团会在两三代内被撕碎。两者叠加,随机初态通常 20 代内全灭,只剩零星孤点。这不是实现写错,是 3D 邻居计数整体上移,规则阈值必须跟着上移。把 2D 规则直接拿来用,是三维生命游戏第一个坑,也是最多人翻车的起点。

这里还要说清一个等权假设:面、棱、角三种邻居在经典三维生命游戏里权重相同,卷积核里都是 1。有变体会按距离加权,比如面邻居 2、棱邻居 1、角邻居 0.5,但主流实现还是等权,因为卷积核简单、语义干净,也方便和 2D 规则做对照。

2.2 常见三维规则与行为画像

三维规则沿用 B/S 记法:B 是诞生阈值(Born),S 是存活区间(Survive)。几个在真实三维模拟里最常见的规则如下。

规则含义行为特征
B4/S45死细胞 4 个邻居诞生;活细胞 4 或 5 个邻居存活最经典的三维生命游戏规则,能形成稳定块和周期振荡器,适合演示和结构搜索
B5/S67死细胞 5 个邻居诞生;活细胞 6 或 7 个邻居存活随机初态下更“热闹”,容易扩散成大范围活动区域,适合观察混沌演化
B3/S23直接照搬康威规则随机初态通常 20 代内全灭,仅供对照
B4/S456存活区间放宽到 4~6稳定性更高,群体边缘更粗糙,适合跑大规模结构演化

选规则只有一个原则:让诞生阈值和存活区间匹配你预设的初始密度。密度 0.1 时,均值 2.6 个邻居,“死细胞周围≥4”意味着它处于局部较密的区域;“活细胞保持 4 或 5 个邻居”则让群体边缘不至于无限膨胀。B4/S45 之所以经典,是因为它在“维持结构”和“允许变化”之间取了一个平衡:太严的规则几代内死光,太宽的规则整个空间被填满。2D 的 B3 阈值占 8 邻域的 37.5%,3D 的 B4 只占 26 邻域的 15.4%,可见 3D 在比例上更保守。

2.3 用密度与邻居分布预判规则,再批量扫描

邻居数服从二项分布:P(X=k)=C(26,k)p^k(1-p)^(26-k)。p=0.1 时,K=4 或 5 的概率不高不低,正好撑起 B4/S45;p=0.05 时,期望邻居数掉到 1.3,B4 几乎无法触发。所以“调规则”和“调初始密度”必须一起做,分开调等于盲人摸象。

规则空间本身不大:诞生阈值 0~8,存活区间 0~8,组合几百组。一组 64^3 跑 100 步,NumPy 下单核几秒到几十秒,全扫描几小时可完成。常见做法是写一个脚本循环参数,对同一个随机种子初始化,跑完输出最终活细胞数。筛选条件只有三个:没全灭、没全满、相邻世代差异不要剧烈振荡。我在真实项目里先用密度 0.1、尺寸 64^3、跑 100 步做快速筛选,筛出的候选规则再放到 256^3 上复核。64^3 边界效应强,但快;256^3 接近“无限空间”特征,能看出规则是否长期稳定。这步原理很简单,很多人不做,直接拿第一版规则跑大图,结果把参数问题和可视化问题混在一起排错,事倍功半。

提示:B 阈值和 S 区间必须和初始密度一起调。密度 0.05 时 B4 几乎无法诞生,密度 0.15 时 B4/S45 又会显得过于保守,活细胞比例不断爬升。

3. 用 Python + NumPy 把三维元胞自动机跑起来:最小命令与边界策略

3.1 数据结构与初始状态:三维 uint8 数组

3D 生命游戏最直接的数据结构是一块三维网格,每个格一个字节就可以存状态。用 NumPy 的话,64^3 只有 26 万格,约 0.26 MB;128^3 约 2 MB;256^3 约 16 MB。双缓冲翻倍也不到 40 MB,普通笔记本随便跑。所以第一版实现不要急着上稀疏表、不要上 GPU,先用稠密数组把规则跑对,再考虑优化。

存储类型我强烈建议用 uint8 而不是 bool。bool 在科学计算库里经常被当作位处理,做卷积和加减法时会发生隐式类型转换,行为不如 uint8 直观。uint8 可以直接求和、可以直接卷积,后续转 int16 计数也顺手。

import numpy as np from scipy.ndimage import convolve def random_seed(size=64, density=0.1, seed=7): rng = np.random.default_rng(seed) return (rng.random((size, size, size)) < density).astype(np.uint8)

逻辑说明:生成一个 size×size×size 的三维数组,每个坐标独立判断是否小于 density,得到初始活细胞。转成 uint8 是为了后面卷积时不会被当成布尔对象,也方便累加计数。参数说明:size 是边长,64 表示 64^3=26 万格;density 是初始活细胞比例,0.1 对应平均每格 2.6 个邻居,适合 B4/S45 这类规则。seed 固定随机种子,保证同一段脚本每次结果一致,这是后面做规则对比、做验证的前提。

3.2 卷积计数与规则更新:26 邻域一次算完

数邻居是三维生命游戏的核心操作。26 邻域可以用 3×3×3 的卷积核一次算完:核中心是 0,周围 26 个位置都是 1,对当前状态做卷积,得到每个格的邻居数。千万不要自己写三层 Python 循环去遍历每个格子的 26 个邻居,那个速度慢到没法用;scipy.ndimage.convolve 是 C 实现,同样代码量,性能差两个数量级。

def step(state, born=(4,), survive=(4, 5)): kernel = np.ones((3, 3, 3), dtype=np.uint8) kernel[1, 1, 1] = 0 neighbor_count = convolve(state, kernel, mode='wrap') newborn = (~state.astype(bool)) & np.isin(neighbor_count, born) keep_alive = state.astype(bool) & np.isin(neighbor_count, survive) return (newborn | keep_alive).astype(np.uint8)

逻辑说明:先构造 3×3×3 卷积核,中心置 0,卷积结果就是每个格子的 26 邻居数。np.isin(neighbor_count, born) 把邻居数映射成布尔:只要邻居数落在元组里就为 True。newborn 取“当前是死细胞且邻居数在 born 里”,keep_alive 取“当前是活细胞且邻居数在 survive 里”,两者取或得到下一状态。参数说明:born=(4,) 表示死亡细胞周围恰好 4 个活细胞时诞生;survive=(4,5) 表示活细胞周围 4 或 5 个活细胞时继续活。改规则只改这两个元组。mode='wrap' 是环形边界,左边界和右边界相接,模拟三维环面,避免边缘所有邻居被当成墙。想做固定边界,把 mode 改成 'constant',超出网格的区域按 0 处理。

3.3 主循环、双缓冲与存活率检查

另一个常见翻车点是每步都生成新数组,Python 端不断分配内存,跑 100 步就卡。正确做法是双缓冲:准备两份状态,当前和下一状态轮流切换。

def simulate(state, steps=100, born=(4,), survive=(4, 5)): current = state.copy() for i in range(steps): current = step(current, born=born, survive=survive) if i % 20 == 0: alive = int(current.sum()) print(f"step {i:4d} alive {alive}") return current

逻辑说明:每步调用 step 生成新状态并替换 current,同时每 20 步打印一次活细胞数。这个数字是健康的温度计:20 步内骤降为 0,规则太严;超过网格总量 90%,规则太宽。参数说明:steps 控制总代数;i % 20 是打印节奏,改成 i % 10 或 i % 100 都可以。因为 step 内部没有原地修改 state,current 被整体替换,上一代数据不会被污染,所以可以安全复用。

mode='constant' 和 mode='wrap' 的选择会影响整个演化语义。做“封闭容器”里的物理模拟用 constant,边界外永远没有活细胞;做统计性质研究用 wrap,结构在边界处不会死光。两种都跑一遍,如果只在边界附近出现异常,问题多半不在规则而在边界策略。我一般还会用 time.perf_counter() 把主循环包起来,打印总耗时。当 256^3 跑 100 步超过 10 秒,就该考虑降分辨率或换稀疏表示,而不是继续等。

4. 让三维格子可见:体素渲染与交互式展示

4.1 渲染方案怎么选

3D 生命游戏最终要“看见”才有效。常见可选方案如下:

方案优点缺点适合场景
Three.js + InstancedMesh浏览器即可演示,轨道控制现成实例数多时性能下降Web 展示、教学、快速原型
OpenGL/WebGL 自写 shader可控性最强,可做大尺寸点云开发量大,需要自己写镜头与拾取超过百万体素的长期跑批
VMD/Blender 等离线渲染出图质量高无实时交互,每帧导出麻烦论文配图、科普视频

我默认用 Three.js 做落地方案:只要把活细胞坐标导出成 JSON,前端一次性加载,后面改规则不用动渲染层。它也是最快能跑出成果的路。

4.2 把 NumPy 状态导出到 Three.js

Python 端先把活细胞坐标导出成 JSON:

import json def export_coords(state): coords = np.argwhere(state > 0) return json.dumps(coords.tolist(), separators=(',', ':'))

逻辑说明:np.argwhere 返回所有非零位置的三元组列表,转成嵌套列表后 json.dumps 序列化。对 64^3 的网格,典型活细胞数在几千到几万,JSON 体积可以接受;参数说明:np.argwhere(state > 0) 返回形状为 (N,3) 的数组,N 是活细胞数。如果要做年龄着色,把年龄数组也一起导出,格式扩展成 [x, y, z, age] 四元组。

前端用 Three.js 创建体素:

const geometry = new THREE.BoxGeometry(0.98, 0.98, 0.98); const material = new THREE.MeshStandardMaterial({ color: 0x44aaff, roughness: 0.4 }); const mesh = new THREE.InstancedMesh(geometry, material, coords.length); coords.forEach(([x, y, z], i) => { const m = new THREE.Matrix4().setPosition(x, y, z); mesh.setMatrixAt(i, m); }); mesh.instanceMatrix.needsUpdate = true;

逻辑说明:用 InstancedMesh 一次性绘制所有立方体,BoxGeometry 尺寸设为 0.98 而不是 1.0,让相邻方块之间保留 0.02 空隙,避免渲染时共面闪烁。每个实例通过 Matrix4 设置位置。参数说明:0.98 是体素边长系数,越小空隙越大;调成 1.0 会紧贴,很多显卡会出现 z-fighting。颜色和粗糙度按需调整,需要区分新生细胞时,用 age 修改 instanceColor。

4.3 按年龄着色、相机控制与体素拾取

只显示一个时刻会损失时间维度。建议维护 age 数组:每个细胞从诞生起计数,死亡后归零,展示时用颜色映射年龄。

const color = new THREE.Color(); for (let i = 0; i < coords.length; i++) { const t = age[i] / MAX_AGE; color.setHSL(0.55 - 0.4 * t, 0.8, 0.5); mesh.setColorAt(i, color); }

逻辑说明:setColorAt 给每个实例单独上色,HSL 色相从蓝(新生)偏转到红(老细胞),一眼看出哪些结构刚诞生、哪些结构延续很多代。相机控制用 OrbitControls,左键旋转、右键平移、滚轮缩放。参数说明:MAX_AGE 建议取 20~40,太小颜色一直跳,太大几乎所有细胞都变成同一个橘红色。

如果要进一步调性能,把模拟和渲染解耦:模拟端每 10 代导出一帧,渲染端只更新一次 geometry,而不是每帧重建。这样 128^3 的 25 万实例也能保持流畅;对大密度场景再用降采样,每 2 个体素取一个,视觉连续感依然在。调试时还可以加 Raycaster,点击任意体素弹出它的坐标、邻居数和年龄,这个能力在验证规则时比截图管用得多。

5. 避坑指南:三维生命游戏最容易翻车的 5 个地方

下面五条是我实际踩过的坑,按出现频率排序。它们一半是规则选择,一半是工具链细节,每一条都按“现象 → 原因 → 解决”记录。

5.1 规则照搬 2D,20 代内全灭

现象:初始密度 0.1,B3/S23 跑 20 代,活细胞数量降为 0。打印存活率曲线,前 5 代还在,第 10 代开始断崖式下跌。

原因:规则参数没有适配 26 邻域的邻居分布。3D 的期望邻居数远高于 2D,B3 的诞生条件在 3D 里变成稀有事件。

解决:把诞生阈值上调到 4,存活区间调到 4~5。仍全灭就继续放宽到 B5/S67,并把初始密度提到 0.15 验证。预防的做法是扫描规则时把每次 step 的 alive 数量按代数打印出来,看前 20 代斜率是否像跳水一样往下砸。这条曲线是一切规则调试的起点。

5.2 边界策略混用,左右不同步

现象:同一规则、同一初态,用 wrap 边界跑,左边界附近和右边界附近的存活结构明显不一样;用 constant 边界跑,边缘两代内全部死亡,像被剪刀切断。

原因:卷积用了 mode='wrap',但规则判断里又额外把边界格当墙处理;或者卷积用 constant,却在某个角落残留上一版的 wrap 假设。两个语义混在一起,边界行为必然错乱。

解决:全链路统一。要么卷积用 wrap、判断里不做任何边界特判;要么卷积用 constant、明确把边界当成“墙”。验证方法很简单:取一个中心对称的初态跑 5 步,检查结果是否依然镜像对称。不对称,就是边界逻辑错了。血泪经验:先统一边界策略再谈可视化,否则所有渲染图都在演示一个你没有定义的模型。

5.3 模拟每步 30ms,可视化却只有 3FPS

现象:128^3 的网格,Python 模拟每步 30 ms,不算慢,但浏览器里拖动相机时帧率掉到个位数。

原因:前端把几十万个体素当成独立 Mesh 绘制,draw call 打满 GPU;或者每次模拟都全量重建 geometry,把渲染主线程卡死。

解决:渲染层用 InstancedMesh 合并批次,instanceMatrix 一次性上传;模拟和渲染解耦,每 10 代导出一帧,渲染端只更新一次几何。排查时打开 Chrome DevTools Performance 面板,看主线程时间花在 Renderer 还是 Script。如果是 draw call 太多,降采样或抽帧立竿见影。

5.4 相邻体素共面闪烁(z-fighting)

现象:BoxGeometry 边长设成 1.0 时,两个相邻立方体的相邻面在相机移动时交替闪现、出现黑缝和闪烁条纹。

原因:共面几何在深度缓冲精度下互相竞争,3D 体素场景里边界处特别明显。

解决:体素边长取 0.96~0.98,留出小间隙,从视觉上消除冲突。材质 roughness 调低但不要用纯镜面。如果必须无缝,做相邻面剔除:把内部面删掉再渲染,实例数也同时下降。这项操作值得花时间,大网格下的闪面几乎都靠它根治。

5.5 计数类型导致邻居数错乱

现象:某一步突然出现大量“异常”存活细胞,邻居数统计出来超过 26,或者个别格子在密度极低区域凭空激活。

原因:卷积内部对多个 uint8 求和时发生溢出;或者 state 被当作 bool 使用,在加减法中被隐式强转,造成计数与直觉不一致。这是最隐蔽的一类问题,表现像玄学。

解决:在 step 函数入口加一行 state = state.astype(np.int16),用短整型做卷积,返回时再转回 uint8。同时打印 neighbor_count.max(),确认最大值不超过 26。只要这一行确认无误,计数类 bug 基本清零。

6. 进阶:从玩具到工具——稀疏表示、并行化与结构校验

6.1 稀疏哈希表:绕开网格内存上限

稠密数组的内存随边长立方级增长:512^3 的一个 uint8 数组就是 128 MB,双缓冲再加一倍。多数场景活细胞占比远低于 15%,可以把状态存成 set,只记录活细胞坐标。核心循环是对每个活细胞的 26 邻域累加计数:

from collections import defaultdict OFFSETS = [(dx, dy, dz) for dx in (-1,0,1) for dy in (-1,0,1) for dz in (-1,0,1) if not (dx == 0 and dy == 0 and dz == 0)] def step_sparse(alive_set, born=(4,), survive=(4, 5)): counts = defaultdict(int) for x, y, z in alive_set: for dx, dy, dz in OFFSETS: counts[(x + dx, y + dy, z + dz)] += 1 return {cell for cell, n in counts.items() if (cell in alive_set and n in survive) or (cell not in alive_set and n in born)}

逻辑说明:counts 只记录“活细胞邻居”,遍历活细胞的 26 邻域,把出现过的格子加 1。死细胞只有出现在某个活细胞的邻域里才会被计数,这正好覆盖诞生判断的所有候选。参数说明:born 和 survive 语义与卷积版本一致,可以复用。代价是每步生成一个字典,速度明显慢于 NumPy;我的习惯是小规模用稠密数组,超过 512^3 或需要跑几万代时切到稀疏。

6.2 并行步进:分块与 ghost cell

想在集群上跑更大规模,按坐标把网格切成多个区域,各进程独立计算邻居,再交换边界数据。三维切分时,每个进程除了自己的块,还要从相邻进程拿一层厚 1 格的 ghost cell。MPI 或多进程都能做这件事;GPU 方向则用 compute shader 一次并行处理所有格。三维卷积的瓶颈主要在内存带宽,分块收益未必线性,但能让你把单机放不下的尺寸跑起来。

6.3 三个验证让实现不再是黑匣子

我每次改完实现都做三个验证。第一,降维对照:把规则改成 B3/S23,初始状态只放一层 z=0,跑出来的 2D 图案应该和经典康威生命游戏完全一致。第二,平移不变性:把同一初态整体平移一个向量,跑 N 步后再平移回来,应该与原图完全相同。第三,确定性回放:同一初态重复跑 10 步,两次结果必须逐格一致。三条全过,实现基本可信,规则问题才能交给参数扫描去解决。

养成这个习惯后,我改参数时永远先跑验证再做可视化。经验是:先证明你的代码在“小规则、小网格”上是正确的,再去生成漂亮图;否则所有截图都是黑匣子里的玄学。希望这个顺序能帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询