跳转至

晶体与锥

Voronoi 胞是从种子长出的晶体。从 上方看,它们也是一片锥的田野。

examples/voronoi_cones.py
"""Voronoi 胞是从种子长出的晶体——也是从上方看到的锥之山。

同时播下十六粒种子,让每粒以相同速度生长晶体。一个点属于最先到达它的
晶粒,因此晶粒沿 Voronoi 图相遇:每个点归于其最近的种子。将到达时间画为
深度,每个晶粒便成为一个锥;该图是诸锥的下包络,由 GPU 的深度缓冲
逐像素精确计算。一块暗色平面标记当前时刻,遮住尚未到达的一切,因此
从上方你看到晶粒生长。而后时间倒流,一颗种子获得先起步的优势:其锥升起,
边界弯成双曲线(Johnson–Mehl 镶嵌,正如金属从不同时刻出现的核
结晶时所见)。
"""

import numpy as np

import manimgx as m

N_SEEDS = 16
TOP = 3.0  # 时间零的高度
SLOPE = (
    1.6  # 每单位生长损失的高度:到达时间 t 画在 TOP − SLOPE · t 处
)
WIDTH, HEIGHT = 12.4, 6.6  # 晶体生长的盘
GRID = (248, 132)
PALETTE = [
    "#ef476f",
    "#ffd166",
    "#06d6a0",
    "#118ab2",
    "#8338ec",
    "#ff9f1c",
    "#2ec4b6",
    "#e76f51",
    "#a7c957",
    "#f15bb5",
    "#00bbf9",
    "#fee440",
    "#9b5de5",
    "#00f5d4",
    "#f77f00",
    "#4cc9f0",
]
DARK = "#0b0f14"


def seeds(rng: np.random.Generator) -> np.ndarray:
    """分布均匀的种子:每粒为 30 个随机候选中的最优者(Mitchell 最佳候选法)。"""
    low, high = (
        [-WIDTH / 2 + 0.7, -HEIGHT / 2 + 0.7],
        [WIDTH / 2 - 0.7, HEIGHT / 2 - 0.7],
    )
    points = [rng.uniform(low, high)]
    while len(points) < N_SEEDS:
        candidates = rng.uniform(low, high, (30, 2))
        distance = np.linalg.norm(
            candidates[:, None] - np.array(points)[None], axis=2
        ).min(axis=1)
        points.append(candidates[np.argmax(distance)])
    return np.array(points)


def grid_mesh(nu: int, nv: int, fill_color: str = "#ffffff") -> m.MeshMobject:
    """在 (nu + 1) × (nv + 1) 顶点网格上的光滑光照网格(顶点 i·(nv + 1) + j),
    每个格胞两个三角形;其点由塑造者设定。"""
    idx = np.arange((nu + 1) * (nv + 1)).reshape(nu + 1, nv + 1)
    a, b, c, d = idx[:-1, :-1], idx[1:, :-1], idx[1:, 1:], idx[:-1, 1:]
    cells = np.stack([np.stack([a, b, c], -1), np.stack([a, c, d], -1)], 2)
    return m.MeshMobject(
        np.zeros(((nu + 1) * (nv + 1), 3)),
        cells.reshape(-1, 3),
        shade_in_3d=True,
        fill_color=fill_color,
    )


class Grains(m.Group):
    """所有晶粒的锥,盘上 z = TOP − SLOPE · (tᵢ + |p − sᵢ|),每个裁减到
    它赢得的胞(外加一圈三角形的余量,隐藏在邻居之下)——因此
    从任何方向只存在包络。当起始时间改变时重新计算胞。
    """

    def __init__(self, sites: np.ndarray, starts: list[m.ValueTracker]) -> None:
        xs = np.linspace(-WIDTH / 2, WIDTH / 2, GRID[0] + 1)
        ys = np.linspace(-HEIGHT / 2, HEIGHT / 2, GRID[1] + 1)
        self.gx, self.gy = np.meshgrid(xs, ys, indexing="ij")
        self.distance = np.hypot(
            self.gx[None] - sites[:, 0, None, None],
            self.gy[None] - sites[:, 1, None, None],
        )
        self.starts = starts
        self.drawn: list[float] = []  # 上次生成胞所用的起始时间
        self.cones = [
            grid_mesh(*GRID, fill_color=color) for color in PALETTE[: len(sites)]
        ]
        self.all_triangles = self.cones[0].triangles
        super().__init__(*self.cones)
        self.add_updater(lambda g: g.refresh())
        self.refresh()

    def refresh(self) -> None:
        starts = [s.get_value() for s in self.starts]
        if starts == self.drawn:
            return
        self.drawn = starts
        arrival = np.array(starts)[:, None, None] + self.distance
        owner = np.argmin(arrival, axis=0).ravel()[self.all_triangles]  # (triangles, 3)
        for k, mesh in enumerate(self.cones):
            z = TOP - SLOPE * arrival[k]
            mesh.points = np.stack([self.gx, self.gy, z], -1).reshape(-1, 3)
            mesh.triangles = self.all_triangles[(owner == k).any(axis=1)]


class VoronoiCones(m.ThreeDScene):
    def construct(self) -> None:
        rng = np.random.default_rng(1644)
        sites = seeds(rng)
        starts = [m.ValueTracker(0.0) for _ in sites]
        cones = Grains(sites, starts)
        dots = m.Group(
            *[m.Dot3D(np.array([*s, TOP]), radius=0.06, color=m.WHITE) for s in sites]
        )
        for dot, start, site in zip(dots, starts, sites, strict=True):
            dot.add_updater(
                lambda d, site=site, start=start: d.move_to(
                    np.array([*site, TOP - SLOPE * start.get_value() + 0.02])
                )
            )

        # 当前时刻:高度为时间 T 的暗色平面遮住每个尚未到达的点
        now = m.ValueTracker(0.0)
        shown = m.ValueTracker(
            1.0
        )  # 平面的不透明度(相机倾斜时隐藏)
        present = m.Rectangle(
            width=WIDTH + 0.02,
            height=HEIGHT + 0.02,
            stroke_width=0,
            fill_color=DARK,
            fill_opacity=1,
        )

        def settle(plane: m.Mobject) -> None:
            plane.move_to(np.array([0.0, 0.0, TOP - SLOPE * now.get_value()]))
            plane.set_fill(opacity=shown.get_value())

        present.add_updater(settle)

        title = m.Text("Crystals grown from seeds", font_size=36).to_corner(m.UL)
        subtitle = m.Text(
            "every point joins the grain that reaches it first", font_size=22
        )
        subtitle.set_color(m.GREY_B).next_to(
            title, m.DOWN, aligned_edge=m.LEFT, buff=0.12
        )
        header = m.VGroup(title, subtitle)
        backing = m.BackgroundRectangle(header, color=DARK, fill_opacity=0.9, buff=0.12)
        self.add_fixed_in_frame_mobjects(backing, header)

        def note(tex: str) -> m.VGroup:
            label = m.MathTex(tex, font_size=32).to_corner(m.UR)
            group = m.VGroup(
                m.BackgroundRectangle(label, color=DARK, fill_opacity=0.9, buff=0.1),
                label,
            )
            self.add_fixed_in_frame_mobjects(group)
            self.remove(group)
            return group

        # 0–7 秒:从上方看:晶粒以等速生长并沿 Voronoi 图相遇
        self.set_camera_orientation(
            phi=0,
            theta=-90 * m.DEGREES,
            focal_distance=40,
            zoom=1.08,
            frame_center=np.array([0, -0.25, 0]),
        )
        self.add(cones, present, dots)
        self.play(now.animate.set_value(3.2), run_time=6.5, rate_func=m.linear)
        self.wait(0.5)

        # 7–14 秒:倾斜:时间为深度,每个晶粒是一个锥
        equal = note(r"\text{arrival time} = |p - s_i|")
        self.move_camera(
            phi=55 * m.DEGREES,
            theta=-62 * m.DEGREES,
            focal_distance=24,
            zoom=0.8,
            frame_center=np.array([0, 0, 0.4]),
            added_anims=[shown.animate.set_value(0.0), m.FadeIn(equal)],
            run_time=4,
        )
        self.play(
            self.camera.theta_tracker.animate.set_value(-30 * m.DEGREES), run_time=3
        )

        # 14–18 秒:回到顶部;时间倒回零;一颗种子获得先起步
        chosen = int(np.argmin(np.linalg.norm(sites - np.array([0.5, 0.3]), axis=1)))
        head = note(r"\text{arrival} = t_i + |p - s_i|")
        self.move_camera(
            phi=0,
            theta=-90 * m.DEGREES,
            focal_distance=40,
            zoom=1.08,
            frame_center=np.array([0, -0.25, 0]),
            added_anims=[
                now.animate.set_value(0.0),
                shown.animate.set_value(1.0),
                m.FadeOut(equal),
            ],
            run_time=3.5,
        )
        self.play(
            starts[chosen].animate.set_value(-1.15),
            m.FadeIn(head),
            m.Flash(dots[chosen], color=m.WHITE),
            run_time=1,
        )

        # 18–25 秒:再次生长:早起的晶粒赢得更多地盘,边界弯曲
        self.play(now.animate.set_value(3.3), run_time=6.5, rate_func=m.linear)

        # 25–30 秒:海报
        closing = m.Text(
            "equal starts: straight borders · a head start: hyperbolas", font_size=26
        ).to_edge(m.DOWN, buff=0.3)
        self.add_fixed_in_frame_mobjects(
            m.BackgroundRectangle(closing, color=DARK, fill_opacity=0.9, buff=0.1),
            closing,
        )
        self.play(
            m.FadeIn(closing),
            self.camera.zoom_tracker.animate.set_value(1.12),
            run_time=4.5,
        )


if __name__ == "__main__":
    VoronoiCones().render("voronoi_cones.mp4")