はじめに
「パチンコ玉が釘に当たって跳ね返る」という物理シミュレーションを書いていたとき、ふと気づいた。
釘でランダムに方向が変わり、かつ「分岐」が起きたら ── それは落雷ではないか?
本記事では、その直感をもとにPythonで落雷シミュレーションを実装し、GIFアニメーションとして可視化するまでの過程を紹介する。
(今回は、上の疑問に対して生成AIであるClaude Codeで検証した内容をClaude Codeで記事としてまとめたものである。)
アナロジーの整理
| パチンコ | 落雷 |
|---|---|
| 玉が釘に当たって反射 | 放電経路が障害物で方向転換 |
| 経路は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つ:
-
下向きバイアス ─
angle * 0.85 + (-90°) * 0.15で重力を近似 -
衝突後の押し出し ─
r + step分だけ外に出すことで「障害物内ループ」を防ぐ -
再帰分岐 ─ 衝突時に確率
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出力) |
自分の作ったプログラム
参考に、自分の作ったオリジナルプログラムを示す。
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()

