生成艺术 - 自主性:细胞自动机
生成艺术一章摘录。
![]() |
生成艺术 使用 Processing 的实用指南 Matt Pearson 你就是最常见的自主代理的单个例子。你钱包里的卡片正在书写你的经济自传,你的推文和短信正在作为社交地图的一部分书写 ASCII 日记,你口袋里的手机正在描绘你日常心理地理的 GPS 图。本文基于生成艺术的第 7 章,通过计算机科学早期的一种休闲游戏:元胞自动机,让你熟悉自主性概念和这类对象的涌现复杂性。 为 Code Project 读者提供 Manning.com 的特别优惠,在 manning.com 购买《生成艺术》可享受 40% 的折扣。使用促销代码 code40project,即可享受电子书和纸质书 40% 的折扣。所有纸质书购买均包含免费电子格式(PDF、ePub 和 Kindle),一旦可用。 |
你最熟悉的自主代理的单个例子,至少对你而言,就是正在看着这一页的你。亲爱的读者,你在一天的行为由生物学、心理学以及鞋子舒适度等复杂因素共同决定,你是一个自主的物体,在你创建的数据中创造出有趣的模式。如今你所做的一切几乎都会留下数据痕迹:你的每一次购买,你在商店会员卡上赚取的每一个积分,你点击的每一个链接,以及你进行的每一次旅行。你钱包里的卡片正在书写你的经济自传,你的推文和短信正在作为社交地图的一部分书写 ASCII 日记,你口袋里的手机正在描绘你日常心理地理的 GPS 图。
为了让你熟悉自主性概念和这类对象的涌现复杂性,我们将玩一个计算机科学的早期休闲游戏:元胞自动机。
在 20 世纪 70 年代,计算机科学领域痴迷于元胞自动机 (CA)。超级计算机曾被用于数周内运行约翰·康威的《生命游戏》的迭代。如今,这种计算能力在我们的手机中就能实现,因此我们可以在一个轻量级的 Processing 草图 中轻松模拟元胞自动机。
一个二维 CA 是一个由单元格组成的网格(参见图 1),每个单元格只有两种状态:开和关、黑或白、生或死。每个单元格的局部知识有限,只能看到它的八个直接邻居。在一系列循环中,每个单元格根据其周围单元格的当前状态决定其下一个状态。
设置框架
最好的演示方式是举例。以下清单创建一个具有开/关状态的单元格网格,并引用它们的直接邻居。
清单 1 CA 框架
Cell[][] _cellArray;
int _cellSize = 10;
int _numX, _numY;
void setup() {
size(500, 300);
_numX = floor(width/_cellSize);
_numY = floor(height/_cellSize);
restart();
}
void restart() {
_cellArray = new Cell[_numX][_numY]; #A
for (int x = 0; x<_numX; x++) { #A
for (int y = 0; y<_numY; y++) {#A
Cell newCell = new Cell(x, y); #A
_cellArray[x][y] = newCell; #A
} #A
} #A
for (int x = 0; x < _numX; x++) { #B
for (int y = 0; y < _numY; y++) { #B
int above = y-1; #C
int below = y+1; #C
int left = x-1; #C
int right = x+1; #C
if (above < 0) { above = _numY-1; } #D
if (below == _numY) { below = 0; } #D
if (left < 0) { left = _numX-1; } #D
if (right == _numX) { right = 0; } #D
_cellArray[x][y].addNeighbour(_cellArray[left][above]); #E
_cellArray[x][y].addNeighbour(_cellArray[left][y]); #E
_cellArray[x][y].addNeighbour(_cellArray[left][below]); #E
_cellArray[x][y].addNeighbour(_cellArray[x][below]); #E
_cellArray[x][y].addNeighbour(_cellArray[right][below]); #E
_cellArray[x][y].addNeighbour(_cellArray[right][y]); #E
_cellArray[x][y].addNeighbour(_cellArray[right][above]); #E
_cellArray[x][y].addNeighbour(_cellArray[x][above]); #E
}
}
}
void draw() {
background(200);
for (int x = 0; x < _numX; x++) { #F
for (int y = 0; y < _numY; y++) { #F
_cellArray[x][y].calcNextState(); #F
} #F
} #F
translate(_cellSize/2, _cellSize/2);
for (int x = 0; x < _numX; x++) { #G
for (int y = 0; y < _numY; y++) { #G
_cellArray[x][y].drawMe(); #G
} #G
} #G
} #G
void mousePressed() {
restart();
}
//================================= object
class Cell {
float x, y;
boolean state; #H
boolean nextState;
Cell[] neighbors;
Cell(float ex, float why) {
x = ex * _cellSize;
y = why * _cellSize;
if (random(2) > 1) { #I
nextState = true; #I
} else { #I
nextState = false; #I
} #I
state = nextState;
neighbors = new Cell[0];
}
void addNeighbor(Cell cell) {
neighbors = (Cell[])append(neighbors, cell);
}
void calcNextState() {
// to come
}
void drawMe() {
state = nextState;
stroke(0);
if (state == true) {
fill(0);
} else {
fill(255);
}
ellipse(x, y, _cellSize, _cellSize);
}
}
#A Creates grid of cells
#B Tells each object its neighbors
#C Gets locations to each side
#D Wraps locations at edges
#E Passes refs to surrounding locs
#F Calculates next states first
#G Draws all cells
#H On or off
#I Randomizes initial state
这是一个很长的起点,但你以前肯定遇到过。在脚本底部,你定义了一个对象,一个单元格(Cell),它有 x 和 y 位置以及一个状态,要么是开,要么是关。它还有一个用于其下一个状态(nextState)的容器,即下一次调用其 drawMe 方法时它将进入的状态。
在 setup 中,你根据宽度、高度和所需的单元格大小计算行数和列数;然后,你调用 restart 函数来用这个网格填充一个二维数组。
网格创建后,会再次遍历数组,告诉每个单元格它的邻居是谁(在它上方、下方、左侧和右侧)。
然后,绘制循环对数组进行两次遍历。第一次遍历触发每个单元格计算其下一个状态,第二次遍历触发每个单元格进行转换并绘制自身。
请注意,这需要分两个阶段完成,以便每个单元格都有一组静态的邻居来作为其下一个状态计算的基础。
现在你已经有了单元格网格(如图 1 所示),你可以开始赋予单元格简单的行为。最著名的 CA 是约翰·康威的《生命游戏》,所以我们将从它开始。
多维数组
一维数组是用于存储元素列表的数据结构。你这样定义一个:
int[] numberArray = {1, 2, 3};
但是,如果你想存储的不仅仅是一个列表,如果你有一个要跟踪的元素矩阵,你可以给数组添加一个额外的维度。一个二维数组这样初始化:
int[][] twoDimArray = { {1, 2, 3}, {4, 5, 6}, {7, 8, 9} };
本质上,你正在定义一个数组的数组。在清单 1 中,你可以看到创建和遍历二维数组的示例。
如果你想要更多维度,你可以拥有它们。你可以自由地添加任意多的维度,只要你能理解:
int[][][] threeDimArray;
int[][][][] fourDimArray;
...
生命游戏
康威《生命游戏》(GOL)的流行主要归因于它在数学领域之外所发现的关联性。生物学家、经济学家、社会学家和神经科学家等,都详细讨论了遵循 GOL 规则的 CA 行为与他们各自领域研究结果之间的相似之处。它本质上是一种简单的人工生命计算形式,一个充满自主代理的数学培养皿。
GOL 规则如下:
- 规则 1:如果一个活着的(黑色)细胞有两个或三个邻居,它会继续存活。否则它会死亡,无论是孤独还是过度拥挤。
- 规则 2:如果一个死亡的细胞正好有三个邻居,奇迹发生了:它复活了。
你将它写成代码如下。根据以下清单完成 calcNextState 函数。
清单 2 使用 GOL 规则计算下一个状态
void calcNextState() {
int liveCount = 0;
for (int i=0; i < neighbours.length; i++) {
if (neighbors[i].state == true) {
liveCount++;
}
}
if (state == true) {
if ((liveCount == 2) || (liveCount == 3)) {
nextState = true;
} else {
nextState = false;
}
} else {
if (liveCount == 3) {
nextState = true;
} else {
nextState = false;
}
}
}
此代码首先计算存活的邻居数量,然后应用 GOL 规则。如果你运行几秒钟,你会开始看到像显微镜下抽象生物的视图(参见图 2)。有些部分冒泡然后消失;另一些则达到循环稳定性。关于这个游戏已经进行了很多研究,识别出了形成的数学“生命”:滑翔机、蟾蜍、船、闪烁体、信标等等。GOL 维基百科页面有许多这些简单规则产生的复杂形状和模式的动画示例:参见 http://en.wikipedia.org/wiki/Conway’s_Game_of_Life#Examples_of_patterns。
我们很容易在这里跑题,因为你所编写的本质上是一个人工智能程序。人工智能是一个领域,诚然,它在生成艺术的创作方面具有巨大的潜力。[1] 我们将保持我们的关注点相对较浅,并玩弄每个细胞的可能性,以其局部、受限的行为,以视觉上有趣的方式呈现在屏幕上。
一个简单的黑白点只是一个起点。在图 3 和图 4 的两个示例中,我使用了单元格存活的邻居数量以及它保持存活的帧数来确定在该单元格位置绘制的形状的大小、旋转和颜色。你可能想尝试类似的实验。
GOL 只是众多潜在规则集中的一个。除了可视化实验,我鼓励你尝试发明自己的规则集。在接下来的页面中,我将演示另外两种著名的模式和一种发明的行为,每种模式都会产生在自然界中有相似之处的形式。
维奇尼亚克投票
第一种模式,维奇尼亚克投票(以首次进行实验的 Gerard Vichniac 命名),是一堂关于顺从的课。每个单元格都特别容易受到同伴群体压力的影响,它会观察其邻居以了解当前的趋势。如果单元格的颜色占多数,则保持不变。如果占少数,则改变。为了确保有奇数个单元格来做出决定,单元格在进行计算时会将其自身的当前状态与其八个邻居一起考虑。
要查看投票的实际效果,请使用以下清单中的代码重写 calcNextState 函数。为了在算法中创建人为的不稳定性,你将交换四个和五个邻居的规则;否则,它会很快稳定成静态模式。
清单 3 维奇尼亚克投票规则
void calcNextState() {
int liveCount = 0;
if (state) { liveCount++; } #A
for (int i=0; i < neighbors.length; i++) { #A
if (neighbors[i].state == true) { #A
liveCount++; #A
} #A
} #A
if (liveCount <= 4) { #B
nextState = false; #B
} else if (liveCount > 4) { #B
nextState = true; #B
} #B
if ((liveCount == 4) || (liveCount == 5)) { #C
nextState = !nextState; #C
} #C
} #C
#A Counts neighbors, including me
#B Am I in the majority?
#C Swaps rules for 4 and 5
结果如图 5 所示。你可以看到聚类效应如何产生类似于牛皮或从上方看到的景观轮廓的图案。
在维奇尼亚克投票中,你应用的规则是社会学的——代理人屈服于邻居的同伴群体压力——但结果却呈现出更熟悉的生物学或地质学美学。这是否意味着这些学科都由共同的计算原理支撑?
布莱恩的大脑
这是一种三态元胞自动机,这意味着一个细胞除了开或关之外,还可以处于一种额外的状态。布莱恩的大脑 CA 的状态是:激发、静息和关闭。它旨在模拟大脑中神经元的行为,神经元会激发然后静息,然后才能再次激发。规则如下:
- 如果状态是激发,则下一个状态是静息。
- 如果状态是静息,则下一个状态是关闭。
- 如果状态是关闭,并且正好有两个邻居正在激发,则状态变为激发。
你需要修改单元格类,如下面的代码所示。请注意,你必须将状态类型更改为 int,以便它可以有多个值。整数 0、1 和 2 分别表示关闭、激发和静息。
清单 4 单元格对象修改以适应三态布莱恩大脑行为
class Cell {
float x, y;
int state; #A
int nextState;
Cell[] neighbors;
Cell(float ex, float why) {
x = ex * _cellSize;
y = why * _cellSize;
nextState = int(random(2));
state = nextState;
neighbors = new Cell[0];
}
void addNeighbor(Cell cell) {
neighbors = (Cell[])append(neighbors, cell);
}
void calcNextState() {
if (state == 0) {
int firingCount = 0;
for (int i=0; i < neighbors.length; i++) {
if (neighbors[i].state == 1) { #B
firingCount++; #B
} #B
}
if (firingCount == 2) { #C
nextState = 1; #C
} else {
nextState = state; #D
}
} else if (state == 1) { #E
nextState = 2; #E
} else if (state == 2) { #F
nextState = 0; #F
}
}
void drawMe() {
state = nextState;
stroke(0);
if (state == 1) {
fill(0); #G
} else if (state == 2) {
fill(150); #H
} else {
fill(255); #I
}
ellipse(x, y, _cellSize, _cellSize);
}
}
#A State 1, 2, or 0
#B Counts firing Neighbors
#C If two neighbors are firing, fire too
#D Else, don’t change
#E If just fired, rest
#F If rested, turn off
#G Firing = black
#H Resting = grey
#I Off = white
结果看起来像图 6。太空飞船(CA 术语,不是我的)在平面上沿水平、垂直和有时对角线方向移动。
这种行为是否反映了你的思想是如何从激发突触的电子爆发中形成的?你所看到的是否是意识本身的数学模型?也许吧;但就我们的目的而言,至少暂时,它只是另一种涌现行为,你可以将其用作生成可视化的基础。
在我们继续之前,再举一个例子。这不是著名的规则集之一,而是一种自定义行为。不过,它产生的模式应该会相当熟悉。
这最后一个模式是一个具有连续行为的 CA 的示例。单元格没有理由必须局限于两个或三个不同的值:它的状态可以在一系列值中变化。在这个例子中,你将使用 255 个值,即从白色到黑色的灰度。这是一种自定义行为,所以它没有名字,但我将其基于一个标准的物理模型——平均——即混沌状态通过其邻居的影响而稳定下来。
规则如下:
- 如果相邻状态的平均值为 255,则状态变为 0。
- 如果相邻状态的平均值为 0,则状态变为 255。
- 否则,新状态 = 当前状态 + 邻域平均值 – 之前状态值。
- 如果新状态超过 255,则设为 255。如果新状态低于 0,则设为 0。
如果你遵循物理模型,你只需要第三条规则。我添加了其他规则以制造不稳定性,以阻止它稳定下来。代码在下一个列表中。输出如图 7 所示。
清单 5 自定义波浪状行为
class Cell {
float x, y;
float state;
float nextState;
float lastState = 0;
Cell[] neighbors;
Cell(float ex, float why) {
x = ex * _cellSize;
y = why * _cellSize;
nextState = ((x/500) + (y/300)) * 14; #A
state = nextState;
neighbors = new Cell[0];
}
void addNeighbor(Cell cell) {
neighbors = (Cell[])append(neighbors, cell);
}
void calcNextState() {
float total = 0; #B
for (int i=0; i < neighbours.length; i++) { #B
total += neighbors[i].state; #B
} #B
float average = int(total/8); #B
if (average == 255) {
nextState = 0;
} else if (average == 0) {
nextState = 255;
} else {
nextState = state + average;
if (lastState > 0) { nextState -= lastState; }
if (nextState > 255) { nextState = 255; }
else if (nextState < 0) { nextState = 0; }
}
lastState = state; #C
}
void drawMe() {
state = nextState;
stroke(0);
fill(state); #D
ellipse(x, y, _cellSize, _cellSize);
}
}
#A Creates initial gradient
#B Calculate neighborhood average
#C Stores previous state
#D Uses state value as fill color
这种行为最明显的比较是液体。就像雨落在浅水池中一样,它搅动并变化。
这个例子的目的是强调你不仅可以调整规则,还可以调整框架本身。你不需要网格,不需要圆形,也不需要有限的灰度值集合:你的系统可以像你想要的那样微妙和复杂(参见图 8)。
摘要
面向对象的代码方法只是一个开始。通过这种方法,你可以开始实现更复杂的系统。你的对象可以成为自主代理,在人工环境中相互作用,产生涌现的复杂性供你可视化,这是一个你在元胞自动机实验中探索过的想法。
![]() |
JavaScript Ninja 的秘密 |
![]() |
《Android 实战(第二版)》 |
![]() |
《iPhone 和 iPad 实战》 |
[1] 如果你对这个主题感兴趣,我建议你寻找 Douglas Hofstadter 荣获普利策奖的《哥德尔、埃舍尔、巴赫》(1979)作为有趣且不太吓人的入门点。