1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パチンコシミュレーションを落雷に見立てる ─ Pythonで樹状分岐アニメーション

1
Posted at

はじめに

「パチンコ玉が釘に当たって跳ね返る」という物理シミュレーションを書いていたとき、ふと気づいた。

釘でランダムに方向が変わり、かつ「分岐」が起きたら ── それは落雷ではないか?

本記事では、その直感をもとにPythonで落雷シミュレーションを実装し、GIFアニメーションとして可視化するまでの過程を紹介する。

(今回は、上の疑問に対して生成AIであるClaude Codeで検証した内容をClaude Codeで記事としてまとめたものである。)

lightning.png


アナロジーの整理

パチンコ 落雷
玉が釘に当たって反射 放電経路が障害物で方向転換
経路は1本 釘に当たるたびに経路が分岐する
重力で下落 雲から地面へ下降

核心は分岐だ。釘に当たるたびに一定確率で経路が2本に枝分かれし、それが再帰的に繰り返されると落雷の樹状パターンが自然に生まれる。


パチンコシミュレーション(出発点)

まずは単純な1玉のシミュレーション。重力と弾性反射のみ。

import numpy as np
import matplotlib.pyplot as plt
import math

g = 9.8
Lx, Ly = 1, 1
m = 100
r = 0.05
dt = 1e-6

rng = np.random.default_rng()
obstacles_x = rng.uniform(-Lx, Lx, m)
obstacles_y = rng.uniform(-Ly, Ly, m)

x, y = 0, 0.9 * Ly
v_x, v_y = 1, -1
x_ary, y_ary = [], []

while -Lx < x < Lx and -Ly < y < Ly:
    for i in range(m):
        ox, oy = obstacles_x[i], obstacles_y[i]
        if (x - ox)**2 + (y - oy)**2 < r**2:
            nx = (x - ox) / math.sqrt((x-ox)**2 + (y-oy)**2)
            ny = (y - oy) / math.sqrt((x-ox)**2 + (y-oy)**2)
            dot = v_x * nx + v_y * ny
            v_x -= 2 * dot * nx
            v_y -= 2 * dot * ny
    v_y -= g * dt
    x += v_x * dt
    y += v_y * dt
    x_ary.append(x)
    y_ary.append(y)

これだけでも複雑な経路が現れるが、まだ1本の線だ。


落雷シミュレーションへの拡張

設計方針

物理シミュレーション(dt=1e-6、数万ステップ)は計算量が大きい。落雷の見た目には物理的正確さより経路の形状が重要なので、より軽量な角度ベースのランダムウォークに切り替える。

物理シミュレーション ランダムウォーク
ステップ数/経路 〜15,000 200
実行時間 タイムアウト 0.5秒

コアのアイデア:再帰的分岐

def simulate(x0, y0, angle0, depth=0, start_idx=0):
    if depth > max_depth or branch_count[0] >= MAX_BRANCHES:
        return

    x, y = x0, y0
    angle = angle0
    pts = [(x, y)]

    for _ in range(200):
        # 下向きバイアス+ランダムノイズで角度を更新
        angle += rng.normal(0, 0.25)
        angle = angle * 0.85 + np.deg2rad(-90) * 0.15  # 重力の代わり

        nx = x + step * np.cos(angle)
        ny = y + step * np.sin(angle)

        # 衝突判定(numpy で全障害物を一括チェック)
        dists2 = (obstacles_x - nx)**2 + (obstacles_y - ny)**2
        hit_idx = np.argmin(dists2)
        if dists2[hit_idx] < r * r:
            ox, oy = obstacles_x[hit_idx], obstacles_y[hit_idx]
            na = np.arctan2(ny - oy, nx - ox)
            angle = 2 * na - angle                   # 反射
            nx = ox + (r + step) * np.cos(na)        # 障害物の外へ押し出す
            ny = oy + (r + step) * np.sin(na)

            # ─ 分岐(落雷のフォーク)─────────────────────
            if rng.random() < branch_prob:
                branch_count[0] += 1
                fork = angle + rng.choice([-1, 1]) * rng.uniform(0.4, 0.9)
                simulate(nx, ny, fork, depth + 1,
                         start_idx=start_idx + len(pts))  # ← 再帰

        x, y = nx, ny
        pts.append((x, y))
        if not (-Lx < x < Lx and -Ly < y < Ly):
            break

ポイントは3つ

  1. 下向きバイアスangle * 0.85 + (-90°) * 0.15 で重力を近似
  2. 衝突後の押し出しr + step 分だけ外に出すことで「障害物内ループ」を防ぐ
  3. 再帰分岐 ─ 衝突時に確率 branch_prob で別経路を生成。start_idx を引き継ぐことでアニメーションの開始タイミングを同期させる

グロー効果で雷光らしく見せる

3層の線を重ねることで発光感を出す。

depth = p['depth']
alpha = max(0.2, 1.0 - depth * 0.18)   # 枝が深いほど暗く
lw    = max(0.4, 2.5 - depth * 0.45)   # 枝が深いほど細く

ax.plot(xs, ys, color='deepskyblue', alpha=alpha * 0.3,
        linewidth=lw * 4)   # 外側のぼかし(グロー)
ax.plot(xs, ys, color='cyan', alpha=alpha,
        linewidth=lw)        # 本体
ax.plot(xs, ys, color='white', alpha=alpha * 0.5,
        linewidth=lw * 0.35) # 中心の白い芯
レイヤー 役割
外側 deepskyblue(半透明・太) にじんだ光
中間 cyan 主経路
内側 white(細) 高輝度の芯

GIFアニメーション

FuncAnimation で落雷が上から伸びていく様子を再現する。
各フレームで「その時点までに到達している点数分だけ経路を描く」方式。

from matplotlib.animation import FuncAnimation, PillowWriter

def update(frame):
    for line in ax.lines[:]:   # 前フレームの線を削除(釘のパッチは残す)
        line.remove()

    frame_idx = frame * frame_step
    for p in all_paths:
        visible = frame_idx - p['start']   # この経路で見せる点数
        if visible <= 0:
            continue
        pts = p['pts'][:visible + 1]
        xs = [q[0] for q in pts]
        ys = [q[1] for q in pts]
        # ... グロー描画 ...

        # 先端に輝点を置く
        if visible < len(p['pts']):
            ax.plot(xs[-1], ys[-1], 'o', color='white', markersize=3)

ani = FuncAnimation(fig, update, frames=80, interval=60, blit=False)
ani.save('lightning.gif', writer=PillowWriter(fps=18),
         savefig_kwargs={'facecolor': '#0a0a1a'})

p['start'] に各経路の「分岐した時点のインデックス」を記録しておくことで、親経路がある地点に到達した瞬間に子経路のアニメーションが始まる。


完成した結果

生成される lightning.gif は 80フレーム・18fps(約4.4秒)。
実行時間は約4秒。

総パス数: 30, 総分岐数: 30
saved: lightning.gif
python lightning_gif.py  4.41s user  1.54s system

パラメータのチューニング指針

パラメータ デフォルト 増やすと
MAX_BRANCHES 30 より複雑な樹状
branch_prob 0.25 分岐が密に
max_depth 4 細かい枝が増える
m(釘の数) 80 経路が複雑に
step 0.04 小さくするほど滑らか(遅い)
N_FRAMES 80 大きくするほど滑らか(ファイル増)

まとめ

  • パチンコの「釘での反射」に再帰的分岐を加えるだけで落雷パターンが再現できる
  • 物理シミュレーション(dt=1e-6)を角度ベースのランダムウォークに置き換えることで計算量を1/100以下に削減
  • 3層グローと start_idx を使ったフレーム同期で、リアルな落雷アニメーションが得られる

同じ枠組みを使えば、神経細胞の樹状突起川の支流形成など、他の自然界の分岐パターンも描けるはずだ。


ソースコード

ファイル 内容
pachinco.py 元のパチンコシミュレーション
lightning.py 落雷静止画(PNG出力)
lightning_gif.py 落雷アニメーション(GIF出力)

自分の作ったプログラム

参考に、自分の作ったオリジナルプログラムを示す。

python pacinco.py
import numpy as np
import matplotlib.pyplot as plt
import japanize_matplotlib
import math
import matplotlib.patches as patches


fig, ax = plt.subplots()

ax.set_aspect('equal')

g=9.8
Lx=1
Ly=1
n=100
#対象領域のにおける釘の本数(密度)
m=100
rng=np.random.default_rng()
obstacles_x_ary=rng.uniform(-Lx, Lx, m)
obstacles_y_ary=rng.uniform(-Ly, Ly, m)
obstacles_y=0
#釘の半径
r=0.05

#初期位置と初期速度
x=0
y=0.9*Ly

v_x=1
v_y=-1

x_ary=[]
y_ary=[]
t_ary=[]
dt=1e-6
t=0
j=0
while (-Lx<x<Lx and -Ly<y<Ly):
    a_x=0
    a_y=-g
    for i in range(m):
        obstacles_x=obstacles_x_ary[i]
        obstacles_y=obstacles_y_ary[i]
        #釘に当たったときの処理
        if abs(x-obstacles_x)**2+abs(y-obstacles_y)**2<r**2:
            beta = -math.atan2(x - obstacles_x, y - obstacles_y)
            alpha = -math.atan2(v_x, v_y)
            gamma = alpha-beta
            delta=alpha+beta
        
            v = math.sqrt(v_x**2 + v_y**2)
            v_x = v * math.cos(delta)
            v_y = v * math.sin(delta)
    v_x=v_x+a_x*dt
    v_y=v_y+a_y*dt
    x=x+v_x*dt
    y=y+v_y*dt
    x_ary.append(x)
    y_ary.append(y)
    t_ary.append(t)
    t=t+dt
    j=j+1



# plt.plot(t_ary,x_ary)
# plt.show()

# plt.plot(t_ary,y_ary)

plt.grid()
# plt.show()
for i in range(m):
    obstacles_x=obstacles_x_ary[i]
    obstacles_y=obstacles_y_ary[i]
    circle = patches.Circle((obstacles_x,obstacles_y),r,facecolor="pink"  )
    ax.add_patch(circle)
ax.plot(x_ary,y_ary)
plt.savefig("pachinco7.png")
plt.show()

pachinco.png

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?