HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

ProcessingとPythonで雪を降らせる

Pythonではじめる数学の冒険』(Peter Farrell 著 / 鈴木 幸敏 訳 O’Reilly Japan)という本を見つけて面白そうだったので購入した。
この本を一通り読んでみてProcessingとPythonと数学的処理を利用して色々描画する方法を知ったので、本で扱われている一部の内容の復習と応用がてら簡単なアニメーション作ってみた。
どのようなアニメーションかというと、小さな正多角形をパーティクルのようにウインドウ上部から降らせ、まるで雪片が舞っているかのような感じに見せるというものだ。
このアニメーションを描画するためのコードと簡単な説明を記述しておく。

環境

  • Processing 3.5.4
  • Python 3.x (Processing内でインストールする)

Processingのバージョン4.0(ベータ版)もダウンロード可能だが、4.0ではPythonモードに対応しておらず、コードを書いて実行ボタンをクリックしても描画など一切できない。
なのでバージョン3.5.4を使う必要があるので注意。
関係ないが、ProcessingはPythonモードだとどうもコードの自動補完が効かないので非常にコーディングし辛い…

完成イメージ

f:id:rozured:20211229124014g:plain

  • 一定の時間間隔でウインドウ上部に雪片を生成する
  • 雪片の形は正n角形(n = 4 ~ 10)で大きさはランダム
  • 雪片は左右にわずかに揺れながら落下(揺れの周期や落下速度はランダム)
  • 生成から一定時間経過した雪片はフェードアウトする

実装コード

描画に必要な処理の簡単な説明を書いていく。
なお、コードはProcessingのスケッチという.pyde形式であるため厳密にはPythonコードとは異なる。

グローバル変数

コード全体で使う変数、定数をグローバルに定義する。

GENERATE_INTERVAL = 5 # 雪片を生成する時間間隔
LIFESPAN_CONST = 25 # 雪片の生存時間を設定に使う定数
FADEOUT_LINE = 50 # 雪片がフェードアウトを開始するlifespanの基準
SHAKE_AMPLITUDE = 0.5 # 雪片の横揺れの振幅

t = 0 # 経過時間
snowList = [] # 雪片オブジェクトのリスト

各種関数の定義

Processingの組込み関数であるsetup()draw()関数やその他必要な関数を定義する。

setup()

setup()関数はコードを実行時の最初に1回だけ呼ばれるProcessingの組込み関数であり、必ず定義する必要がある。
ここでは画面サイズとcolorModeを設定しているだけだ。
colorModeはRGBとHSBを選べるが、今回はHSBにした。

def setup():
    size(800, 450)
    colorMode(HSB)
draw()

draw()関数もProcessingの組込み関数で、コード実行から停止するまで繰り返し呼ばれ続ける。
時間変数tグローバル変数として使うことを明示する。
時間間隔がGENERATE_INTERVAL毎に(tGENERATE_INTERVALで割り切れる度に)雪片となるSnowオブジェクトを生成し、snowListに追加していく。
Snowオブジェクトを生成する時には、雪片の初期位置のx, y座標を引数として渡す。
x座標をrandint(0, width)、y座標を0とすれば、ウインドウ上端に沿って横方向にランダムな位置を指定できる。

さらに、snowListにループをかけ、各Snowオブジェクトのupdate()メソッドを呼び出し雪片の状態を更新していく。

関数の最後では時間変数tをインクリメントしている。

def draw():
    global t
    background(0)
    
    # 一定の時間間隔でウインドウ上部のランダムな位置に雪片を生成
    if t % GENERATE_INTERVAL == 0:
        snowList.append(Snow(randint(0, width), 0))
    
    for snow in snowList:
        snow.update()
    
    t += 1
polygonMatrix()

雪片の初期位置のx, y座標、雪片の半径、雪片の傾きを引数として受け取り、それらに基づいて正多角形を生成する。
今回は正方形から正十角形のいずれかになるように設定している。
sin()cos()関数を利用して引数のx, y座標を中心とする正多角形の頂点の位置を特定しリストに追加していく。
ランダムな定数のtiltを加えることで、各々の正多角形の傾きがずれて雪片毎に変化をつけられる。

def polygonMatrix(x, y, r, tilt):
    """ 正多角形状になる座標の行列を生成する """
    # 正方形〜正十角形の中からランダムに決定する
    vertexNumber = randint(4, 10)
    centralAngle = 360 / vertexNumber
    newMatrix = []
    for i in range(vertexNumber):
        newMatrix.append([
            x + r * cos(radians(i * centralAngle + tilt)),
            y + r * sin(radians(i * centralAngle + tilt))
        ])
    
    return newMatrix
translateMatrix()

頂点座標リスト、x方向とy方向の変位量を引数として受け取る。
ここで行なっているのは、頂点座標リストの各要素のx, y座標にdxdyをそれぞれ加えているだけだ。
各要素のindex = 0がx座標、index = 1がy座標になっている。

def translateMatrix(matrix, dx, dy):
    """ 行列の各要素に定数を加える """
    newMatrix = []
    for v in matrix:
        newMatrix.append([v[0] + dx, v[1] + dy])
        
    return newMatrix

Snowクラスの定義

舞い散る雪片を1つ1つ制御するための元となるSnowクラスを定義する。

__init__()

Snowクラス生成時に呼び出され、雪片の初期位置のx, y座標を受け取る。
クラスのプロパティには以下のものを定義する。(self.は省略している)

radius:雪片の半径
tiltAngle:雪片として描画する多角形をランダムに傾かせる(初期生成時の回転角度)ための値
vertexList:雪片の多角形の各頂点のx, y座標が入っているリスト
colorListcolor()関数の引数として渡す値を「色相, 彩度, 明度, アルファ値」のリスト形式で指定している
fallDistance:雪片毎に落下速度がばらばらになるようにランダムな値を設定している
shakePeriod:雪片の横揺れの周期をランダムに設定している(ふわっとした感じを出すため)
lifespan:雪片の生存時間で半径に比例定数LIFESPAN_CONSTをかけた値にしている(小さい雪片ほど早く消える)

def __init__(self, x, y):
    self.radius = random(2, 15) # 半径
    self.tiltAngle = random(0, 90) # 傾きの角度
    self.vertexList = polygonMatrix(x, y, self.radius, self.tiltAngle) # 頂点の座標リスト
    self.colorList = [random(155, 165), random(0, 80), 255, 255] # color()の引数として渡す値のリスト
    self.fallDistance = random(1, 5) # 単位時間当たりの落下距離
    self.shakePeriod = 1 / random(30, 40) # 横揺れの周期
    self.lifespan = self.radius * LIFESPAN_CONST # 消えるまでの時間(半径に比例)
update()

Snowオブジェクトの状態を更新する。

dxは雪片の横揺れの変位で、sin()関数を利用して設定している。
dyは単位時間当たりの雪片の落下距離で、fallDistanceが大きいほど早く落下する。
これらをtranslateMatrix()関数に渡し、返ってきた頂点リストでvertexListを更新する。

このままでは延々と雪片が増え続けて負荷が大きくなってしまうので、vertexListに含まれる全ての頂点のy座標がウインドウ下端より下になった時は雪片が落下し切ったと判定し、対象のSnoeオブジェクト自身をvertexListから除外する。
なお、デフォルトのy軸は下向きであるため、雪片がウインドウ下端より下になるということは、ウインドウ下端のy座標(Processingのシステム変数heightで参照可能)より雪片のy座標が大きくなるということである。

lifespanupdate()メソッドが呼ばれる度に減少していき、FADEOUT_LINE未満になると雪片がフェードアウトして見えなくなるような効果を加える。
フェードアウトさせるためには雪片の色のアルファ値を減らして0にすればよいので、colorListのindex = 3のアルファ値だけを減少させている。
こうして更新したcolorList*演算子でアンパックしてcolor()関数に渡せばフェードアウトを表現することができる。
多角形の描画は、beginShape()関数とendShape(CLOSE)関数の間で各頂点の座標を引数としたvertex()関数を繰り返し利用すればよい。

def update(self):
    dx = SHAKE_AMPLITUDE * sin(self.shakePeriod * t) # 横揺れの変位  
    dy = self.fallDistance # 落下距離
    # 雪片の横揺れと落下移動を反映する
    self.vertexList = translateMatrix(self.vertexList, dx, dy)
    
    # ウインドウより下に到達した雪片を削除する
    if all(v[1] > height for v in self.vertexList):
        snowList.remove(self)
        return

    # 生成から一定時間経過後に雪片をフェードアウトさせる
    self.lifespan -= 1
    if self.lifespan < FADEOUT_LINE:
        self.colorList[3] -= 5
    
    # 雪片を描画する
    stroke(color(*self.colorList))
    fill(color(*self.colorList))
    beginShape()
    for v in self.vertexList:
        vertex(v[0], v[1])
    endShape(CLOSE)

おわりに

最後にコード全体を載せておく。

from random import randint

GENERATE_INTERVAL = 5 # 雪片を生成する時間間隔
LIFESPAN_CONST = 25
FADEOUT_LINE = 50 # 雪片がフェードアウトを開始するlifespanの基準

t = 0 # 経過時間
snowList = [] # 雪片オブジェクトのリスト


class Snow:
    def __init__(self, x, y):
        self.radius = random(2, 15) # 半径
        self.tiltAngle = random(0, 90) # 傾きの角度
        self.vertexList = polygonMatrix(x, y, self.radius, self.tiltAngle) # 頂点の座標リスト
        self.colorList = [random(155, 165), random(0, 80), 255, 255] # color()の引数として渡す値のリスト
        self.fallDistance = random(1, 5) # 単位時間当たりの落下距離
        self.shakePeriod = 1 / random(30, 40) # 横揺れの周期
        self.lifespan = self.radius * LIFESPAN_CONST # 消えるまでの時間(半径に比例)
    
    
    def update(self):
        dx = 0.5 * sin(self.shakePeriod * t) # 横揺れの変位  
        dy = self.fallDistance # 落下距離
        # 雪片の横揺れと落下移動を反映する
        self.vertexList = translateMatrix(self.vertexList, dx, dy)
        
        # ウインドウより下に到達した雪片を削除する
        if all(v[1] > height for v in self.vertexList):
            snowList.remove(self)
            return
        
        # 生成から一定時間経過後に雪片をフェードアウトさせる
        self.lifespan -= 1
        if self.lifespan < FADEOUT_LINE:
            self.colorList[3] -= 5
        
        # 雪片を描画する
        stroke(color(*self.colorList))
        fill(color(*self.colorList))
        beginShape()
        for v in self.vertexList:
            vertex(v[0], v[1])
        endShape(CLOSE)
            

def setup():
    size(800, 450)
    colorMode(HSB)


def draw():
    global t
    background(0)
    
    # 一定の時間間隔でウインドウ上部のランダムな位置に雪片を生成
    if t % GENERATE_INTERVAL == 0:
        snowList.append(Snow(randint(0, width), 0))
    
    for snow in snowList:
        snow.update()
    
    t += 1


def polygonMatrix(x, y, r, tilt):
    """ 正多角形状になる座標の行列を生成する """
    # 正方形〜正十角形の中からランダムに決定する
    vertexNumber = randint(4, 10)
    centralAngle = 360 / vertexNumber
    newMatrix = []
    for i in range(vertexNumber):
        newMatrix.append([
            x + r * cos(radians(i * centralAngle + tilt)),
            y + r * sin(radians(i * centralAngle + tilt))
        ])
    
    return newMatrix
        

def translateMatrix(matrix, dx, dy):
    """ 行列の各要素に定数を加える """
    newMatrix = []
    for v in matrix:
        newMatrix.append([v[0] + dx, v[1] + dy])
        
    return newMatrix