【Python】Pygameで光の粒子を操る方法|コピペ可、コードを徹底解説

Arduino 機器制作



 

粒子がゆっくり集まって富士山を形成するPythonコード徹底解説

この記事では、Pygameを使って多数の粒子がゆっくりと集まり、美しい富士山の形を形成するプログラムをご紹介します。富士山形状を「円すい台(フラスコのような形)」で近似し、3D空間内で粒子が移動する様子を透視投影(Perspective Projection)によって2D画面上に描画します。

今回のプログラムの特徴は、「Rキーを押し続けることで粒子が徐々に集まる」という点です。キーを押しっぱなしにすることで、通常よりもゆっくりと頂上へ近づいていく様子が観察できます。さらに、富士山が完成した後は数秒間表示を続けてプログラムを自動終了します。Arduinoとの連携であれば、Rキーのコマンド部分をArduinoのセンサーに反応させるだけで可能です。他の機器作成と同様です。

コードの各部分の詳細解説はもちろん、追加の知識などをふんだんに盛り込みました。ぜひ最後までお付き合いください。




目次

  1. 全体の概要
  2. パラメータ一覧
  3. コード全文
  4. コード解説
  5. 実行方法とポイント
  6. 応用アイデア
  7. まとめ

1. 全体の概要

このプログラムは、以下のような手順で進行します:

  1. 初期配置: 一定範囲内の球形空間にランダムに粒子を配置する。
  2. ターゲット形状(富士山形状): 富士山を円すい台で近似し、その表面上に粒子数分のランダムなターゲット位置を用意する。
  3. アニメーション:
    • Rキーを押している間だけ、各粒子がターゲット(富士山表面)の座標へゆっくり吸い寄せられる。
    • 20秒経過で富士山が完成したとみなし、ピタッと形が固定される。
    • 完成後5秒経つと自動終了。
  4. 3D描画: Y軸回転 + 透視投影を行い、粒子を2Dスクリーン上に1ピクセルの点として描画する。

富士山が完成するまでは回転角度を0から2πまで徐々に変化させるため、見る視点が回転しているように見えます。完成後は回転を固定し、しばらく鑑賞してから終了します。




2. パラメータ一覧

このプログラムでは、さまざまな定数(パラメータ)を使います。大きく分けて「画面設定」「富士山形状」「初期位置」「カメラ・投影関連」「アニメーション時間関連」などがあります。以下に表としてまとめました。

カテゴリ変数名初期値意味
画面設定SCREEN_WIDTH800画面の横幅(ピクセル)
SCREEN_HEIGHT600画面の縦幅(ピクセル)
富士山形状FUJI_HEIGHT600富士山の高さ
FUJI_BASE_RADIUS400富士山の麓(円すい台の底面)の半径
FUJI_TOP_RADIUS80山頂(円すい台の上面)の半径
NUM_FUJI_POINTS6000富士山表面を構成する粒子の数
初期配置INIT_RADIUS500粒子を最初に球状に配置する際の半径
カメラ・投影CAM_DIST400.0カメラが配置される奥行(z軸)位置
FOV300.0視野の広がり(画面への投影のスケール係数)
アニメーションGATHER_TIME20Rキーを押し続けた後、富士山が完成するまでの時間(秒)
END_WAIT_TIME5富士山完成後、プログラム終了までの待機時間(秒)

また、各粒子に対する「速度」「減衰率」「ばね係数(k)」を調整し、富士山にゆっくり集まる挙動を表現しています。これらはプログラム内で直接数値として設定されています(詳しくは後述)。




3. コード全文

まず、完成版のコードを一気にご覧ください。Python 3系で動作確認済みです。
※ 事前に pygame ライブラリのインストールが必要です。


import pygame
import sys
import math
import random
import time

# --- 画面設定 ---
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600

# --- 富士山形状パラメータ(円すい台) ---
FUJI_HEIGHT = 600           # 富士山の高さ(大きめに)
FUJI_BASE_RADIUS = 400      # 麓の半径(大きめに)
FUJI_TOP_RADIUS  = 80       # 山頂側の半径(小さめに, 0に近いほど尖る)
NUM_FUJI_POINTS  = 6000     # 富士山を構成する点の数(増やして形をはっきり)

# パーティクルの初期配置用(大きな球の内部)
INIT_RADIUS = 500

# 3Dカメラと投影の設定
CAM_DIST = 400.0   # カメラの奥行き位置(小さめにして形を大きく映す)
FOV = 300.0        # 視野の広がり(大きめにして拡大)

# アニメーション時間指定
GATHER_TIME = 20   # Rキー押下後、富士山が完成するまでの時間(秒)
END_WAIT_TIME = 5  # 完成後の待機時間(秒)

def rotate_y(x, y, z, angle):
    """
    Y軸周りに angle(ラジアン) 回転した座標を返す
    """
    cos_a = math.cos(angle)
    sin_a = math.sin(angle)
    rx = x * cos_a + z * sin_a
    ry = y
    rz = -x * sin_a + z * cos_a
    return rx, ry, rz

def perspective_project(x, y, z, fov, cam_dist, screen_cx, screen_cy):
    """
    簡易的な透視投影で、3D座標(x, y, z)を画面座標(sx, sy)へ変換
    """
    z_cam = z + cam_dist
    if z_cam < 1.0:
        z_cam = 1.0
    scale = fov / z_cam
    sx = screen_cx + x * scale
    sy = screen_cy - y * scale  # pygame座標系に合わせるため yは引き算
    return sx, sy

def create_fuji_points(num_points, height, base_radius, top_radius):
    """
    円すい台(frustum)の表面上に点を散らばらせて富士山形状の点群を作る。
    - y=0 が麓(base_radius)、y=height が山頂(top_radius)
    - 円周方向にランダム配置
    """
    points = []
    for _ in range(num_points):
        # 高さ方向(0~1)の位置
        t = random.random()
        y = t * height
        # 半径を線形補間して円周上にランダム配置
        r = base_radius + (top_radius - base_radius) * t
        theta = random.uniform(0, 2.0 * math.pi)
        x = r * math.cos(theta)
        z = r * math.sin(theta)
        points.append((x, y, z))
    return points

def main():
    pygame.init()
    screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
    pygame.display.set_caption("Large Mt. Fuji - Slow Gathering (Press & Hold R)")
    clock = pygame.time.Clock()

    screen_cx = SCREEN_WIDTH // 2
    screen_cy = SCREEN_HEIGHT // 2

    # 富士山の点群(最終形)
    fuji_points = create_fuji_points(NUM_FUJI_POINTS,
                                     FUJI_HEIGHT,
                                     FUJI_BASE_RADIUS,
                                     FUJI_TOP_RADIUS)
    num_points = len(fuji_points)

    # 各粒子が最終的に到達するターゲット座標
    targets = [p for p in fuji_points]

    # 粒子の初期位置(大きな球の内部)
    particles = []
    velocities = []
    for i in range(num_points):
        # 球の内部を一様分布
        r = INIT_RADIUS * (random.random() ** (1.0/3.0))
        theta = random.random() * 2.0 * math.pi
        phi = math.acos(2.0 * random.random() - 1.0)

        sx = r * math.sin(phi) * math.cos(theta)
        sy = r * math.sin(phi) * math.sin(theta)
        sz = r * math.cos(phi)
        particles.append([sx, sy, sz])
        velocities.append([0.0, 0.0, 0.0])

    # アニメーション管理
    gather_elapsed = 0.0
    fuji_formed = False
    fuji_formed_time = 0.0

    running = True
    last_time = time.time()

    # 物理パラメータ
    k = 0.02      # ターゲットへ向かう強さ
    damping = 0.95  # 速度減衰係数

    while running:
        dt = clock.tick(60) / 1000.0
        current_time = time.time()
        frame_time = current_time - last_time
        last_time = current_time

        # イベント処理
        keys = pygame.key.get_pressed()
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            if event.type == pygame.KEYDOWN:
                if event.key == pygame.K_ESCAPE:
                    running = False

        # Rキー押下中のみ粒子がターゲット位置へ近づく
        r_pressed = keys[pygame.K_r]
        if not fuji_formed:
            if r_pressed:
                gather_elapsed += frame_time
                # 粒子をターゲット位置へゆっくり近づける
                for i in range(num_points):
                    px, py, pz = particles[i]
                    tx, ty, tz = targets[i]
                    vx, vy, vz = velocities[i]

                    dx = tx - px
                    dy = ty - py
                    dz = tz - pz

                    # 速度更新
                    vx = vx * damping + k * dx * dt
                    vy = vy * damping + k * dy * dt
                    vz = vz * damping + k * dz * dt

                    # 位置更新
                    px += vx
                    py += vy
                    pz += vz

                    particles[i] = [px, py, pz]
                    velocities[i] = [vx, vy, vz]

                # 20秒経過で完成に移行
                if gather_elapsed >= GATHER_TIME:
                    fuji_formed = True
                    fuji_formed_time = current_time
                    for i in range(num_points):
                        particles[i] = list(targets[i])
                        velocities[i] = [0.0, 0.0, 0.0]
        else:
            # 完成後5秒待って終了
            if (current_time - fuji_formed_time) >= END_WAIT_TIME:
                running = False

        # 描画処理
        screen.fill((0, 0, 0))

        # 回転量(集結中0→2π, 完成後は2πで固定)
        if not fuji_formed:
            progress = min(gather_elapsed / GATHER_TIME, 1.0)
            rot_angle = progress * (2.0 * math.pi)
        else:
            rot_angle = 2.0 * math.pi

        # 粒子描画
        for i in range(num_points):
            px, py, pz = particles[i]

            # Y軸回転
            rx, ry, rz = rotate_y(px, py, pz, rot_angle)

            # 透視投影
            sx, sy = perspective_project(rx, ry, rz, FOV, CAM_DIST,
                                         screen_cx, screen_cy)

            # 1ピクセルの白点を描画
            ix, iy = int(sx), int(sy)
            if 0 <= ix < SCREEN_WIDTH and 0 <= iy < SCREEN_HEIGHT:
                screen.set_at((ix, iy), (255, 255, 255))

        # ガイドテキスト
        font = pygame.font.SysFont(None, 24)
        if not fuji_formed:
            text_surface = font.render(
                f"Press & Hold R: {gather_elapsed:.1f}/{GATHER_TIME} sec",
                True,
                (255, 255, 255)
            )
        else:
            remain = END_WAIT_TIME - (current_time - fuji_formed_time)
            text_surface = font.render(
                f"Fuji formed! Ends in {remain:.1f} sec",
                True,
                (255, 255, 255)
            )
        screen.blit(text_surface, (10, 10))

        pygame.display.flip()

    pygame.quit()
    sys.exit()

if __name__ == "__main__":
    main()



4. コード解説

ここでは、プログラムをセクションごとに解説します。特徴的なポイントだけでなく、Pygameの基礎的な使い方や3D計算に関する補足も入れていきます。

4-1. ライブラリのインポート

import pygame
import sys
import math
import random
import time

ここではPythonでよく使われる標準ライブラリと、pygameが読み込まれています。
math」「random」「time」などは3D座標計算や乱数生成、フレーム間の時間計測に使われます。

4-2. 定数設定

# --- 画面設定 ---
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600

ゲームウィンドウ(Pygameウィンドウ)の解像度(横幅と縦幅)を設定します。
大きいほど見栄えが良いですが、パフォーマンスへの影響も考慮してください。

# --- 富士山形状パラメータ(円すい台) ---
FUJI_HEIGHT = 600
FUJI_BASE_RADIUS = 400
FUJI_TOP_RADIUS = 80
NUM_FUJI_POINTS = 6000

円すい台として富士山を作るため、高さと底面・上面の半径を指定しています。
また、NUM_FUJI_POINTS は富士山表面に配置される点の数です。値を大きくするとより密度が高くなりますが、その分描画負荷が高まります。

# パーティクルの初期配置用(大きな球の内部)
INIT_RADIUS = 500

開始時、粒子を球の内部にランダム配置する際の半径を指定しています。
この値を大きくすると粒子が画面外に広がりすぎる場合があるので、あまり大きくしすぎない方が良いかもしれません。

# 3Dカメラと投影の設定
CAM_DIST = 400.0
FOV = 300.0

ここでは3D描画を2D画面に投影する際の設定を行います。
CAM_DIST はカメラがどのくらい後方にあるか(z方向奥)。
FOV(Field Of Viewの略)は、視野角に相当する値で、実質的に投影スケールに関わります。

# アニメーション時間指定
GATHER_TIME = 20
END_WAIT_TIME = 5

富士山が完成するまでの時間をGATHER_TIME、完成してから終了までの待機時間をEND_WAIT_TIMEとしている部分です。ここでは「20秒かけて集まる」「完成から5秒後に終了」という挙動になります。

4-3. 関数定義

rotate_y(): Y軸まわりの回転

def rotate_y(x, y, z, angle):
    cos_a = math.cos(angle)
    sin_a = math.sin(angle)
    rx = x * cos_a + z * sin_a
    ry = y
    rz = -x * sin_a + z * cos_a
    return rx, ry, rz

3D空間における点(x, y, z)をY軸(鉛直方向)周りにangleだけ回転した新しい座標(rx, ry, rz)を求める関数です。cossinを用いた基本的な回転公式をそのまま実装しています。

perspective_project(): 透視投影

def perspective_project(x, y, z, fov, cam_dist, screen_cx, screen_cy):
    z_cam = z + cam_dist
    if z_cam < 1.0:
        z_cam = 1.0
    scale = fov / z_cam
    sx = screen_cx + x * scale
    sy = screen_cy - y * scale
    return sx, sy

3D座標を2Dスクリーンへ写すための簡易的な透視投影です。
z + cam_dist が小さすぎると計算が破綻するため、1.0未満になった場合はz_cam = 1.0としています。

create_fuji_points(): 富士山表面上の点群生成

def create_fuji_points(num_points, height, base_radius, top_radius):
    points = []
    for _ in range(num_points):
        t = random.random()
        y = t * height
        r = base_radius + (top_radius - base_radius) * t
        theta = random.uniform(0, 2.0 * math.pi)
        x = r * math.cos(theta)
        z = r * math.sin(theta)
        points.append((x, y, z))
    return points

0から1までの乱数tを用いて高さyを決定し、半径rを線形補間し、さらに角度thetaをランダムに振ることで円周方向へ点をばら撒いています。これを繰り返すことで富士山形状(円すい台)の表面を埋め尽くす点群を得ます。

4-4. main()関数

いよいよ本体です。if __name__ == "__main__": main()で呼ばれる部分になります。

Pygameの初期化

pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Large Mt. Fuji - Slow Gathering (Press & Hold R)")
clock = pygame.time.Clock()

Pygameを使う際はpygame.init()で初期化し、ウィンドウの大きさを指定してset_mode()を呼ぶのが定形です。
clock = pygame.time.Clock()はFPS(フレームレート)管理用です。

ターゲット点群の生成

fuji_points = create_fuji_points(NUM_FUJI_POINTS,
                                 FUJI_HEIGHT,
                                 FUJI_BASE_RADIUS,
                                 FUJI_TOP_RADIUS)
num_points = len(fuji_points)
targets = [p for p in fuji_points]

富士山を構成するNUM_FUJI_POINTS個の点群を生成し、それをtargetsとしてコピーします。
targets = [p for p in fuji_points] としているのは単純に同じリストを別名で保持するため」です。

粒子の初期位置と速度

particles = []
velocities = []
for i in range(num_points):
    r = INIT_RADIUS * (random.random() ** (1.0/3.0))
    theta = random.random() * 2.0 * math.pi
    phi = math.acos(2.0 * random.random() - 1.0)

    sx = r * math.sin(phi) * math.cos(theta)
    sy = r * math.sin(phi) * math.sin(theta)
    sz = r * math.cos(phi)
    particles.append([sx, sy, sz])
    velocities.append([0.0, 0.0, 0.0])

(r, theta, phi) は球面座標で、r は半径、theta0~2πの方位角、phi0~πの極角(天頂角)に相当します。
この書き方では、一様に球の内部に点が分布するようになっています(ただしrの乱数に(random.random() ** 1/3)を用いるなど、工夫が必要です)。

アニメーション管理のための変数

gather_elapsed = 0.0
fuji_formed = False
fuji_formed_time = 0.0

gather_elapsed は「Rキー押下後、どのくらいの時間粒子を集めているか」を管理するためのもの。
fuji_formedTrue になると富士山が完成した状態を示します。

メインループ

running = True
last_time = time.time()

k = 0.02
damping = 0.95

kはターゲットへの「ばね定数」のようなもので、粒子がターゲット位置へ吸い寄せられる強さに影響します。
dampingは速度の減衰率です。1.0に近いと慣性が大きく、0.0に近いとほぼ即座に止まります。

イベント処理 & Rキー判定

for event in pygame.event.get():
    if event.type == pygame.QUIT:
        running = False
    if event.type == pygame.KEYDOWN:
        if event.key == pygame.K_ESCAPE:
            running = False

r_pressed = keys[pygame.K_r]
if not fuji_formed:
    if r_pressed:
        gather_elapsed += frame_time
        ...

PygameのイベントループでQUITイベントがあればrunning = Falseにしてゲームループを抜ける仕組みです。
また、ESCキーでも終了するようにしています。
r_pressed はRキーが押下されているかどうかを判定します。富士山がまだ完成していなければ、r_pressedTrueの間だけ粒子がターゲットへ集まるわけです。

富士山への収束計算

for i in range(num_points):
    px, py, pz = particles[i]
    tx, ty, tz = targets[i]
    vx, vy, vz = velocities[i]

    dx = tx - px
    dy = ty - py
    dz = tz - pz

    vx = vx * damping + k * dx * dt
    vy = vy * damping + k * dy * dt
    vz = vz * damping + k * dz * dt

    px += vx
    py += vy
    pz += vz
    ...

ここでdx, dy, dzが「ターゲットへの差分ベクトル」です。
vxなどの速度成分は、まずdampingを掛けて減衰させ、その後k * dx * dtで少しだけターゲット側に引っ張られます。
最終的に粒子の座標(px, py, pz)を更新して、particles[i]に保存します。

時間管理と強制完成

if gather_elapsed >= GATHER_TIME:
    fuji_formed = True
    fuji_formed_time = current_time
    for i in range(num_points):
        particles[i] = list(targets[i])
        velocities[i] = [0.0, 0.0, 0.0]

収束にかける時間gather_elapsedGATHER_TIME(20秒)以上になると、強制的に富士山完成状態に切り替えています。
完成した瞬間には、粒子の位置をターゲット位置そのものにし、速度を0にしています。
さらにfuji_formed = Trueにして完成フラグを立てます。

完成後の動作

else:
    # 富士山完成後は 5秒待って終了
    if (current_time - fuji_formed_time) >= END_WAIT_TIME:
        running = False

完成後5秒経過したら、プログラムを終了(running = False)にするシンプルな仕組みです。
完成した富士山を少し眺める時間を確保する狙いがあります。

描画パート

screen.fill((0, 0, 0))  # 背景色を黒に

# 回転角度計算
if not fuji_formed:
    progress = min(gather_elapsed / GATHER_TIME, 1.0)
    rot_angle = progress * (2.0 * math.pi)
else:
    rot_angle = 2.0 * math.pi

まだ完成していない間は、0からまで徐々に回転角度を増やしています。完成後は(一回転分)の角度で固定し、回転を停止した状態を表現しています。

for i in range(num_points):
    px, py, pz = particles[i]
    rx, ry, rz = rotate_y(px, py, pz, rot_angle)
    sx, sy = perspective_project(rx, ry, rz, FOV, CAM_DIST, screen_cx, screen_cy)
    ix, iy = int(sx), int(sy)
    if 0 <= ix < SCREEN_WIDTH and 0 <= iy < SCREEN_HEIGHT:
        screen.set_at((ix, iy), (255, 255, 255))

各粒子をY軸回転し、透視投影を適用して2D座標(sx, sy)を得ます。そのあとscreen.set_at()で1ピクセルの白点を打ち込んでいます。
条件式0 <= ix < SCREEN_WIDTH and 0 <= iy < SCREEN_HEIGHTは、画面外への描画を防ぐためのものです。

テキストの描画

font = pygame.font.SysFont(None, 24)
if not fuji_formed:
    text_surface = font.render(
        f"Press & Hold R: {gather_elapsed:.1f}/{GATHER_TIME} sec",
        True,
        (255, 255, 255)
    )
else:
    remain = END_WAIT_TIME - (current_time - fuji_formed_time)
    text_surface = font.render(
        f"Fuji formed! Ends in {remain:.1f} sec",
        True,
        (255, 255, 255)
    )
screen.blit(text_surface, (10, 10))

pygame.font.SysFont()でフォントを生成し、font.render()でテキストを画像化し、screen.blit()で貼り付けます。
富士山が未完成なら「Rキー押下中のみ進行中の時間を表示」、完成後なら「残り時間のカウントダウン」を表示しています。

ループ終了とPygameの終了処理

pygame.quit()
sys.exit()

ゲームループを抜けたらPygameを終了し、Pythonのシステムを終了します。ウィンドウも閉じられます。




5. 実行方法とポイント

本コードを実行する前に、次のライブラリをインストールしておく必要があります。

  1. Python 3系
  2. Pygame: pip install pygame などでインストール

あとは、上記のコードを「mt_fuji_slow.py」などのファイル名で保存し、python mt_fuji_slow.pyを実行すればOKです。
実行すると、黒い背景にランダムに散らばった無数の点が表示されるはずです。
Rキーを押し続けると、点がゆっくりと中央付近に集まり、最終的に富士山の形に固定されます。
作り終わったあとは約5秒待つとプログラムが終了します。

描画負荷が高い場合は、NUM_FUJI_POINTSや画面サイズを少し減らしてください。
また、kdampingを調整すると収束速度や「揺れ」の具合が変わるので、好みのアニメーションを探してみるのも面白いでしょう。




6. 応用アイデア

このプログラムは基本的に「多数の粒子がターゲット形状に収束する」仕組みを実装しています。これを活かせば、他にもさまざまな応用が可能です。

  1. 任意の3D形状: 富士山ではなく、任意の3Dモデル(例: 文字のロゴや3Dオブジェクト)に点を散らばせれば、粒子が集まってロゴを形成する演出などができます。
  2. 動的形状の遷移: 途中でターゲット形状を変更することで、粒子が「富士山 → 球体 → 文字」などと形を変化させる様子が楽しめます。
  3. 回転の追加: 今回はY軸回転のみですが、X軸やZ軸周りの回転を加えてさらにダイナミックな見せ方も可能です。
  4. 色情報: 粒子ごとに色を持たせる、ターゲット位置によって色を変える、などでカラフルな映像にすることもできます。

このように「粒子が集まる」という現象はビジュアル的に非常に面白く、多くの応用が考えられます。ぜひ工夫して独自のアート作品に挑戦してみてください。


7. まとめ

以上、粒子がゆっくり集まって富士山を形成するPygameプログラムの解説でした。
本記事では、約15,000字にわたりソースコードやパラメータ、そして3D計算について詳しくご紹介しました。少しでも3DプログラミングやPygameに興味を持っていただければ嬉しいです。

今回のポイントをおさらいすると、以下のようになります:

  • Y軸回転と簡単な透視投影で3D→2D表示を実現
  • 「Rキー押下中」というインタラクティブな操作でゆっくり収束を演出
  • 富士山形状を円すい台で表現し、ランダムに点をばら撒く
  • 物理パラメータ(kdamping)を調整して思い通りの動きを作れる

実際に動かしてみると、粒子がふわふわと動きながら山の形を作っていく様子がとても癒し系で魅力的です。
処理が重い場合は点の数や画面サイズを調整してみてください。
「パーティクルが目標形状に収束するアニメーション」というアイデアを拡張すれば、オリジナルのクリエイティブな作品を作ることもできます。
ぜひ、さまざまなパラメータを変えたり、別の形に挑戦してみたりして、楽しんでみてください。


最後までお読みいただき、ありがとうございました。この記事が、Pygameや3Dプログラミングに触れるきっかけになれば幸いです。

 

特集記事

コメント

この記事へのコメントはありません。

TOP