ライフゲーム
ライフゲームとは
知ってる人は下までスキップ推奨です。
ここに"世界"があります。
この世界は格子状のマス目で構成されており、各マスを"セル"(細胞)と呼びます。
各セルは2つの状態を持っています。"生"と"死"です。ここでは生きているセルを紫で、死んでいるセルを白で表します。
この世界に時間の概念を持ち込みましょう。時間が1つ経過する(次の世代)と、各セルは一定のルールに従って状態が変化します。
元の状態 | 隣接している生きたセルの数 | 次の世代での状態 | アナロジー |
---|---|---|---|
生 | 2 / 3 | 生 | 生存 |
生 | 0 / 1 / 4 / 5 / 6 / 7 / 8 | 死 | 過疎/過密死 |
死 | 3 | 生 | 繁殖 |
死 | 0 / 1 / 2 / 4 / 5 / 6 / 7 / 8 | 死 | 過疎/過密死 |
ここで、「隣接している生きたセルの数」の"隣接"は、上下左右と斜め方向に隣り合ったセルを表しています。
ある生きたセルの周りに、2個または3個の生きたセルが隣接する場合、そのセルは次の世代でも生きたままです。
逆に、1個以下または4個以上の生きたセルが隣接する場合、そのセルは次の世代で死んでしまいます。
ご近所さんは少なすぎず・多すぎずがちょうどいい、という感じです。
ある死んだセルの周りに、ちょうど3個の生きたセルが隣接する場合、そのセルは次の世代で生きたセルに変化します。
適切な細胞の量により、細胞が増殖した、みたいなイメージです。
上記の状態遷移に従って、すべてのセルの状態を同時に更新することで、下図のようなアニメーションが得られます。
参考
以上の説明でよくわからなかった方はライフゲームの世界の動画シリーズやWikipediaをご覧ください。
縦、横、過去
ここまで前提。ここから本題です。
上でも説明した通り、ライフゲームは状態変化の連続です。しかし、よく目にするライフゲームは、二次元上で各セルの現在の状態を示しており、状態変化の履歴、つまり過去の状態がわかりにくいものとなっています。
ですので次元を上げましょう。3Dです。
本記事では、「過去の状態がわかりやすい、3Dでのライフゲーム表示」を目指します。
Blenderで表示する
今回はBlenderという無料3DCGソフトウェアを利用して目標を達成します。
Blenderとは
公式ページ:blender.org - Home of the Blender project - Free and Open 3D Creation Software
Blenderは統合型の3DCG制作ソフトウェアです。
インストール方法はBlenderのインストール・日本語化 | traP 3DCG体験会で詳しく説明しています。
BlenderでのPythonの実行
BlenderではPythonによるスクリプティングが可能です。これにより、オリジナルの機能を持ったアドオンを作成したり、オブジェクトの動的な追加等を行うことができます。
これを利用し、
- Pythonでライフゲームを実装する
- 生セルに対応したオブジェクトを追加する
をすることで本記事の目標が達成できそうです。
Pythonでのライフゲームの実装
まずはPythonでライフゲームを実装しましょう。
import time
from typing import Any
import numpy as np
class LifeGame():
__kernel = np.array([[1, 1, 1],
[1, 0, 1],
[1, 1, 1]])
def __init__(self, size: tuple[int, int], rule: tuple[list[int], list[int]] = ([2, 3], [3]), loop: bool = True) -> None:
self.__size = size[::-1]
self.__cells = np.zeros(self.__size, dtype=np.int8)
self.__rule = rule
self.__loop = loop
def __str__(self) -> str:
return "\n".join(["".join(["*" if cell else " " for cell in row]) for row in self.cells])
@property
def cells(self):
return self.__cells
@cells.setter
def cells(self, map: np.ndarray[Any, np.dtype[np.int8]]):
if (map.shape != self.__cells.shape):
raise ValueError("map size is not match")
if (map.dtype != self.__cells.dtype):
raise ValueError("map type is not match")
self.__cells = map
def random(self) -> None:
self.cells = np.random.randint(0, 2, size=self.__size, dtype=np.int8)
def update(self) -> None:
neighbours = self.__convolve2d(self.__cells, self.__kernel)
survives = self.cells & (np.logical_or.reduce(
[neighbours == survive_rule for survive_rule in self.__rule[0]]
))
born = ~self.cells & (np.logical_or.reduce(
[neighbours == born_rule for born_rule in self.__rule[1]]
))
next = survives | born
self.cells = next
# see https://qiita.com/secang0/items/f3a3ff629988dc660d87
def __convolve2d(self, img, kernel):
mode = "wrap" if self.__loop else "constant"
pad = np.pad(img, 1, mode=mode)
# 部分行列の大きさを計算
sub_shape = tuple(np.subtract(pad.shape, kernel.shape) + 1)
# 部分行列の行列を作成
submatrices = np.lib.stride_tricks.as_strided(
pad, kernel.shape + sub_shape, pad.strides * 2
)
# 部分行列とカーネルのアインシュタイン和を計算
convolved_matrix = np.einsum('ij,ijkl->kl', kernel, submatrices)
return convolved_matrix
if __name__ == "__main__":
game = LifeGame((24, 24))
game.random()
for i in range(50):
print("-" * 24)
print(game)
game.update()
time.sleep(0.2)
しました。これを実行することで、(Blender上でも)コンソールでライフゲームが始まると思います。
この記事の主題は「ライフゲームの状態遷移を3Dで表示したらイイ感じになるんじゃね?」なので細かいスクリプト解説は飛ばします。要望があれば追記します...
bpy
によるオブジェクト追加
次はPythonからBlenderを操作して、オブジェクトの追加をしてみましょう。BlenderをPythonから制御するためのAPI(bpy
)が用意されているのでこれを利用します。
公式ドキュメント:Blender 3.1 Python API Documentation — Blender Python API
import bpy
verts = [(-1, -1, -1), (1, -1, -1), (1, 1, -1), (-1, 1, -1),
(-1, -1, 1), (1, -1, 1), (1, 1, 1), (-1, 1, 1)]
faces = [(0, 1, 2, 3), (4, 5, 6, 7), (0, 1, 5, 4),
(2, 3, 7, 6), (0, 3, 7, 4), (1, 2, 6, 5)]
# create mesh
cube_mesh = bpy.data.meshes.new(name="cube")
cube_mesh.from_pydata(verts, [], faces)
# create object
cube_obj = bpy.data.objects.new(name="cube_from_python", object_data=cube_mesh)
cube_obj.data = cube_mesh
# link object to scene
scene = bpy.context.scene
scene.collection.objects.link(cube_obj)
例えば上のようなスクリプトを実行することで、シーンに立方体を追加することができます。ここでは頂点座標の配列と、面を構成する頂点のインデックスの組の配列からメッシュを構成しています。
これを応用して、ライフゲームにおける"生きたセル"を、Blenderにおける"頂点(vertex)"に対応させてみましょう。
import time
from typing import Any
import bpy # <- 追加
import numpy as np
class LifeGame():
# 省略
return convolved_matrix
# ここから追加
def map_to_verts(map: np.ndarray[Any, np.dtype[np.int8]], z: int) -> list[tuple[int, int, int]]:
return list(zip(*np.where(map == 1), [z for _ in range(map.shape[0]*map.shape[1])]))
if __name__ == "__main__":
game = LifeGame((24, 24))
game.random()
max_z = 24
verts = []
for z in range(max_z):
verts += map_to_verts(game.cells, z)
game.update()
# create mesh
cells_mesh = bpy.data.meshes.new(name="cells")
cells_mesh.from_pydata(verts, [], [])
# create object
cells_obj = bpy.data.objects.new(name="cells", object_data=cells_mesh)
cells_obj.data = cells_mesh
# link object to scene
bpy.context.scene.collection.objects.link(cells_obj)
対応させました。これを実行することで、シーンに頂点だけで構成されたオブジェクトが追加されます。
X軸とY軸(画像内の赤/緑線)が各世代の"世界"の横軸と縦軸を、
Z軸が世代数を表しています。上に行くほど世代が進んでいるイメージです。
...が、これではまだ分かりづらいですね🤔
Geometry Nodesによる装飾
頂点による表示だけでは分かりづらいので、各頂点位置に、わかりやすい色/形のオブジェクトを設置してみましょう。これまでのようにPythonによる追加もできますが、ここではより簡単に扱うことのできるGeometry Nodesを利用してみます。
Geometry Nodesは、オブジェクトのジオメトリ(頂点や辺、面など)を、ノードベースの操作により変更するシステムです。
公式ドキュメント:Geometry Nodes — Blender Manual
例えば上図左のようなノードを組むことで、元のオブジェクトの各頂点位置に、任意のオブジェクトを設置することができます。画像の例では立方体の各頂点に"Suzanne"というサルのモデルを設置しています。
これと同様に、ライフゲームでの生セルに対応した頂点に、オブジェクトを設置してみます。
まず、各生セルを表す簡単なオブジェクトを作成しました。つやつやな立方体です。
先ほど例示したGeometry Nodesを使用して、この立方体を頂点位置に配置してみましょう。
Z軸、つまり時間経過に従って、色相が変化するようマテリアルを設定しています。
最後に、世代更新時にセルが生まれるようなアニメーションを作成してみました。
Geometry Nodes内で、アニメーションの経過時間と各セルのZ座標(=世代)からサイズを算出する処理を行って実現しています。ライフゲームの状態遷移がわかりやすくなりましたね。
これで目標達成です。ライフゲームの核である"状態遷移"を、PythonとBlenderによる3DCG作成によりうまく表現できました🎉
盆栽
上の動画を眺めていると、ライフゲームにおけるセルの状態遷移が、植物の成長のように見えてきました。ということで、もっと植物っぽくしてみましょう。
盆栽っぽくする
植木鉢を用意します。
種を植えます。
世代を進めます。
育ちました。
かわいいですね。
別の初期状態で少しだけ生やしてみましょう。
かっこいいですね。
いかがでしたか
BlenderとPythonを組み合わせることで、3DなGenerative Artを比較的簡単に作成できることがお分かりいただけたかと思います。皆さんもぜひ色々試してみてください。