Skip to content

A coral of random walkers

18,000 random walkers wander until they touch the cluster and stick. The coral they build has fractal dimension 2.5, measured from how its mass grows.

DlaCoral
examples/dla_coral.py
"""Diffusion-limited aggregation: random walkers build a coral.

Start with one particle. Release another far away and let it wander at random, one lattice step at
a time, until it touches the cluster, where it sticks. Repeat 18,000 times (Witten & Sander,
1981). The walkers almost never reach the fjords: the tips catch them first, so the tips grow and
branch while the inside stays empty. The cluster is a fractal: its mass grows like its radius to
a power D, measured here from the cluster itself (radius of gyration against particle count, on
log–log axes), close to the known D ≈ 2.5 for three dimensions, between a surface (2) and a solid (3).
Walkers far from the cluster take long jumps to the cluster's bounding sphere, which changes nothing
about where they land but saves most of the walking.
"""

import numpy as np

import manimgx as m

COUNT = 18000
SIZE = 200  # the lattice, SIZE³ cells
CELL = 0.052  # screen units per lattice cell
STOPS = ["#3c096c", "#7b2cbf", "#c9184a", "#ff5d8f", "#ff9e00", "#ffe066"]  # old → new
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:
    """The growing cluster on a cubic lattice, and the walkers wandering toward it."""

    def __init__(self, rng: np.random.Generator) -> None:
        self.rng = rng
        self.center = SIZE // 2
        self.taken = np.zeros((SIZE,) * 3, bool)
        # the cells next to the cluster: a walker that steps onto one sticks
        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:
        """One move for every walker: a lattice step, or, when the cluster's bounding sphere is
        far, a jump straight toward it through the free room (its landing spot is uniform).
        """
        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)  # more walkers as it grows
            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:
        """One more walker's whole path, from launch to where it would stick (lattice steps
        only; it is not stuck: see `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]:
    """A small octahedron at every center, in order (smooth-shaded, it reads as a bead):
    vertices and triangles, wound outward."""
    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)
        )

        # the mass–radius law, measured: radius of gyration of the first n particles
        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  # past the first few hundred, where the law has set in
        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
        )  # the plot's opacity (it steps aside for the close-up)

        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 re-centers the number)

        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)
        # grow: particles in the order they stuck, the camera pulling back with the cluster
        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)
        # one more walker, followed all the way in, up close: of a dozen, the one that lands on
        # the tip that faces the camera
        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")