随机游走长成的珊瑚¶
18000 个随机游走者游荡直到触碰 簇并粘住。它们长成的珊瑚具有分形维数 2.5,由其 质量增长测得。
examples/dla_coral.py
"""扩散限制聚集:随机行走者构建一株珊瑚。
从一个粒子开始。在远处释放另一个粒子,让它随机游走,每次一个晶格步,直到它碰到团簇并在那里粘住。
重复 18,000 次(Witten & Sander,1981)。行走者几乎不会到达峡湾:尖端先捕获它们,因此尖端生长
并分支,而内部保持空旷。团簇是一个分形:其质量按其半径的 D 次幂增长,此处从团簇本身测量
(回转半径对粒子数,在对数–对数坐标上),接近三维已知值 D ≈ 2.5,介于一个面(2)和一个体(3)之间。
远离团簇的行走者直接跳到团簇的包围球上,这不会改变它们落在哪里,但省去了大部分行走。
"""
import numpy as np
import manimgx as m
COUNT = 18000
SIZE = 200 # 晶格,SIZE³ 个格
CELL = 0.052 # 每个晶格格对应的屏幕单位数
STOPS = ["#3c096c", "#7b2cbf", "#c9184a", "#ff5d8f", "#ff9e00", "#ffe066"] # 旧 → 新
MOVES = np.array([[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]])
def colormap(values: np.ndarray, stops: list[str]) -> np.ndarray:
rgb = np.array([m.ManimColor(s).to_rgb() for s in stops])
x = np.clip(values, 0, 1) * (len(stops) - 1)
i = np.minimum(x.astype(int), len(stops) - 2)
f = (x - i)[:, None]
out = np.ones((len(values), 4))
out[:, :3] = rgb[i] * (1 - f) + rgb[i + 1] * f
return out
class Cluster:
"""立方晶格上不断生长的团簇,以及朝它游走的行走者。"""
def __init__(self, rng: np.random.Generator) -> None:
self.rng = rng
self.center = SIZE // 2
self.taken = np.zeros((SIZE,) * 3, bool)
# 团簇旁的格子:行走者踏入其中一个便粘住
self.sticky = np.zeros((SIZE,) * 3, bool)
self.cells: list[tuple[int, int, int]] = []
self.radius = 1.0
self.stick((self.center, self.center, self.center))
def stick(self, cell: tuple[int, int, int]) -> None:
self.taken[cell] = True
for dx, dy, dz in MOVES.tolist():
self.sticky[cell[0] + dx, cell[1] + dy, cell[2] + dz] = True
self.cells.append(cell)
self.radius = max(
self.radius, float(np.linalg.norm(np.array(cell) - self.center))
)
def launch(self, n: int) -> np.ndarray:
v = self.rng.normal(size=(n, 3))
v /= np.linalg.norm(v, axis=1, keepdims=True)
return np.round(self.center + (self.radius + 8) * v).astype(np.int64)
def step(self, walkers: np.ndarray) -> np.ndarray:
"""每个行走者的一步移动:一个晶格步,或者当团簇的包围球很远时,
通过空旷区域直接朝它跳去(其落点是均匀的)。
"""
room = np.linalg.norm(walkers - self.center, axis=1) - self.radius - 3
moves = MOVES[self.rng.integers(0, 6, len(walkers))]
far = np.flatnonzero(room > 2)
v = self.rng.normal(size=(len(far), 3))
v /= np.linalg.norm(v, axis=1, keepdims=True)
moves[far] = np.round(room[far, None] * v).astype(np.int64)
walkers = walkers + moves
lost = np.linalg.norm(walkers - self.center, axis=1) > min(
2 * self.radius + 25, SIZE / 2 - 3
)
walkers[lost] = self.launch(int(lost.sum()))
return walkers
def grow(self, count: int) -> None:
walkers = self.launch(20)
while len(self.cells) < count:
want = min(3000, 20 + len(self.cells) // 5) # 随着团簇生长增加行走者
if len(walkers) < want:
walkers = np.concatenate([walkers, self.launch(want - len(walkers))])
walkers = self.step(walkers)
x, y, z = walkers.T
hit = np.flatnonzero(self.sticky[x, y, z] & ~self.taken[x, y, z])
for i in hit:
cell = (int(walkers[i, 0]), int(walkers[i, 1]), int(walkers[i, 2]))
if not self.taken[cell] and len(self.cells) < count:
self.stick(cell)
walkers[hit] = self.launch(len(hit))
def wander(self) -> np.ndarray:
"""再来一个行走者的完整路径,从发射到它将要粘住的位置(仅晶格步;
它并未粘住:见 `stick`)。"""
walker = self.launch(1)
path = [walker[0].copy()]
while not self.sticky[tuple(walker[0])]:
walker = walker + MOVES[self.rng.integers(0, 6, 1)]
if np.linalg.norm(walker[0] - self.center) > self.radius + 12:
walker = self.launch(1)
path = []
path.append(walker[0].copy())
return np.array(path, float)
def beads(centers: np.ndarray, radius: float) -> tuple[np.ndarray, np.ndarray]:
"""每个中心处的一个小八面体,按顺序排列(平滑着色后读作一颗珠子):
顶点和三角形,向外缠绕。"""
corners = np.concatenate([np.eye(3), -np.eye(3)]) # +x +y +z −x −y −z
faces = []
for sx, sy, sz in np.ndindex(2, 2, 2):
a, b, c = 3 * sx, 1 + 3 * sy, 2 + 3 * sz
faces.append([a, b, c] if (sx + sy + sz) % 2 == 0 else [a, c, b])
verts = (centers[:, None, :] + radius * corners[None]).reshape(-1, 3)
tris = (np.array(faces)[None] + 6 * np.arange(len(centers))[:, None, None]).reshape(
-1, 3
)
return verts, tris
class DlaCoral(m.ThreeDScene):
def construct(self) -> None:
cluster = Cluster(np.random.default_rng(5))
cluster.grow(COUNT)
cells = np.array(cluster.cells, float) - cluster.center
centers = CELL * cells
verts, tris = beads(centers, 0.62 * CELL)
coral = m.MeshMobject(verts, tris, shade_in_3d=True)
coral.paint = coral.paint.but(
fill=np.repeat(colormap(np.linspace(0, 1, COUNT), STOPS), 6, axis=0)
)
# 质量–半径律,测量得到:前 n 个粒子的回转半径
counts = np.unique(np.geomspace(50, COUNT, 24).astype(int))
spread = np.array(
[
np.sqrt(((cells[:n] - cells[:n].mean(0)) ** 2).sum(1).mean())
for n in counts
]
)
scaling = counts >= 300 # 超过前几百个粒子,律已成立
slope = 1 / np.polyfit(np.log(counts[scaling]), np.log(spread[scaling]), 1)[0]
grown = m.ValueTracker(1.0)
plot = (
m.Axes(
x_range=(0.8, 3.6, 1),
y_range=(3.5, 10.0, 1),
x_length=2.8,
y_length=2.4,
tips=False,
axis_config={"stroke_width": 1.5, "include_ticks": False},
)
.to_corner(m.DR)
.shift(0.4 * m.UP + 0.2 * m.LEFT)
)
plot_names = m.VGroup(
m.MathTex(r"\log R_g", font_size=24).next_to(
plot.x_axis, m.DOWN, buff=0.12
),
m.MathTex(r"\log N", font_size=24).next_to(plot.y_axis, m.UP, buff=0.1),
)
hud = m.ValueTracker(
1.0
) # 图的透明度(特写时让到一旁)
def measured() -> m.VGroup:
shown = counts <= grown.get_value()
return m.VGroup(
*[
m.Dot(
plot.c2p(float(np.log(s)), float(np.log(n))),
radius=0.045,
color=m.GOLD,
)
for n, s in zip(counts[shown], spread[shown])
]
).set_opacity(hud.get_value())
dots = m.always_redraw(measured)
fit_value = m.DecimalNumber(slope, num_decimal_places=2, font_size=30)
fit_row = m.VGroup(
m.MathTex(r"N \propto R_g^{D},\ D =", font_size=30), fit_value
)
fit_row.arrange(m.RIGHT, buff=0.12).next_to(plot, m.UP, buff=0.45)
unit = m.Text("particles", font_size=24).to_corner(m.UR)
tally = m.Integer(1, font_size=30)
def count_up(d: m.Mobject) -> None:
assert isinstance(d, m.Integer)
d.set_value(int(grown.get_value()))
d.next_to(unit, m.LEFT, buff=0.15) # (set_value 会将数字重新居中)
tally.add_updater(count_up)
tally_row = m.VGroup(tally, unit)
title = m.Text("Random walkers build a coral", font_size=38).to_corner(m.UL)
subtitle = m.Text(
"each wanders until it touches the cluster, then sticks (Witten & Sander,"
" 1981)",
font_size=22,
).set_color(m.GREY_B)
subtitle.next_to(title, m.DOWN, aligned_edge=m.LEFT, buff=0.12)
self.add_fixed_in_frame_mobjects(
title, subtitle, tally_row, plot, plot_names, dots, fit_row
)
self.remove(fit_row)
self.set_camera_orientation(phi=70 * m.DEGREES, theta=-60 * m.DEGREES, zoom=2.4)
self.begin_ambient_camera_rotation(rate=0.1)
self.add(coral)
# 生长:粒子按粘住的顺序排列,相机随团簇拉远
self.play(
m.Create(coral, rate_func=lambda u: u**1.6),
grown.animate(rate_func=lambda u: u**1.6).set_value(COUNT),
self.camera.zoom_tracker.animate.set_value(0.95),
run_time=19,
)
self.play(m.FadeIn(fit_row), run_time=1)
# 再来一个行走者,一路跟随至近处:从一打中选出落在
# 朝向相机的尖端上的那个
phi, theta = self.camera.get_phi(), self.camera.get_theta()
toward = np.array(
[np.sin(phi) * np.cos(theta), np.sin(phi) * np.sin(theta), np.cos(phi)]
)
paths = [cluster.wander() for _ in range(12)]
chosen = max(paths, key=lambda p: float((p[-1] - cluster.center) @ toward))
end = chosen[-1].astype(int)
cluster.stick((int(end[0]), int(end[1]), int(end[2])))
path = CELL * (chosen - cluster.center)
trail = m.VMobject(stroke_color=m.WHITE, stroke_width=2, shade_in_3d=True)
trail.set_points_as_corners(path[:: max(1, len(path) // 500)])
bead_verts, bead_tris = beads(path[-1:], 0.75 * CELL)
bead = m.MeshMobject(
bead_verts, bead_tris, shade_in_3d=True, fill_color=m.WHITE
)
aside = m.VGroup(title, subtitle, plot, plot_names, fit_row)
self.stop_ambient_camera_rotation()
self.move_camera(
frame_center=path[-1],
zoom=3.0,
added_anims=[aside.animate.set_opacity(0.0), hud.animate.set_value(0.0)],
run_time=2,
)
self.play(m.Create(trail), run_time=3, rate_func=m.linear)
self.add(bead)
self.play(m.FadeOut(trail), run_time=0.6)
self.move_camera(
frame_center=m.ORIGIN,
zoom=0.95,
added_anims=[aside.animate.set_opacity(1.0), hud.animate.set_value(1.0)],
run_time=2.4,
)
self.begin_ambient_camera_rotation(rate=0.1)
closing = (
m.Text(
f"D = {slope:.2f}: more than a surface, less than a solid", font_size=26
)
.to_edge(m.DOWN, buff=0.35)
.shift(1.5 * m.LEFT)
)
self.add_fixed_in_frame_mobjects(closing)
self.remove(closing)
self.play(m.FadeIn(closing), run_time=1)
self.wait(2)
if __name__ == "__main__":
DlaCoral().render("dla_coral.mp4")