Panda3D 3次元ゲームエンジン基礎

【概要】

Python用3Dゲームエンジン「Panda3D」の基礎を10項目に分けて学習する。環境構築から座標系、カメラ制御、メッシュとマテリアル、ライティング、エンティティの階層構造、入力処理、アニメーションと物理演算、簡易ゲーム制作、波動シミュレーションまで、実装例とともに解説する。各項目には3DCGとゲームエンジンの重要な用語解説を含む。

【目次】

  1. 環境構築とサンプルコード実行
  2. 3D座標系とトランスフォーム(位置、回転、スケール)
  3. カメラとビューポート
  4. メッシュとマテリアル(色)
  5. ライティングとシェーディング
  6. エンティティ(Entity)の生成と制御
  7. 入力処理(キーボード)
  8. アニメーションと物理演算
  9. ゲーム制作(簡易的な3Dアクションゲーム)
  10. 波動シミュレーション(水面の波)

【サイト内の関連ページ】



1. 環境構築とサンプルコード実行


予備知識


Panda3Dのインストール

Panda3DはPythonのパッケージ管理システムpipを使用してインストールする。Python 3.10以降が必要である。

ゲームループとフレーム

3Dゲームエンジンはゲームループと呼ばれる繰り返し処理によって動作する。ゲームループは、入力処理、状態更新、描画の繰り返しであり、ゲームが実行されている間、毎秒数十回から数百回の頻度で繰り返し実行される。この1回の繰り返しをフレームと呼ぶ。ゲームやアニメーションは、このフレームを連続的に更新・描画することで動きを表現する。1秒間に何フレーム処理できるかを示す指標がFPS(Frames Per Second、フレームレート)である。一般的なゲームでは60fps(毎秒60フレーム、1秒間に60回の更新)を目標とする。

デルタ時間とフレームレート非依存

デルタ時間(delta time)は、前回のフレームから現在のフレームまでの経過時間を秒単位で表したものである。コンピュータの処理速度は環境によって異なるため、フレームレートは常に一定とは限らない。フレームレート非依存の動きを実現するには、オブジェクトの移動量や回転量をデルタ時間で調整する必要がある。例えば、1秒間に10度回転させたい場合、毎フレーム「10 × dt」度回転させることで、フレームレートに関係なく一定の速度で回転する。


Panda3Dのプログラムは以下の基本構造を持つ:



┌─────────────────────────────────────┐
│ 1. モジュールのインポート              │
│    from direct.showbase.ShowBase    │
│    import ShowBase                  │
└─────────────────────────────────────┘
           ↓
┌─────────────────────────────────────┐
│ 2. アプリケーションクラスの定義        │
│    class MyApp(ShowBase)            │
└─────────────────────────────────────┘
           ↓
┌─────────────────────────────────────┐
│ 3. オブジェクトの作成                 │
│    self.loader.loadModel(...)       │
└─────────────────────────────────────┘
           ↓
┌─────────────────────────────────────┐
│ 4. アプリケーションの実行             │
│    app.run()                        │
└─────────────────────────────────────┘

Python開発環境、ライブラリ類

ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.html で詳しく解説しているので、必要に応じて参照されたい。

Python 3.12 のインストール

インストール済みの場合は実行不要である。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行する。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要である。

REM wingetの利用開始のための承諾。質問が出た場合は「y」
winget list --accept-source-agreements
REM Python をシステム領域にインストール
winget install --scope machine --id Python.Python.3.12 -e --silent
REM Python のパス設定
set "PYTHON_PATH=C:\Program Files\Python312"
set "PYTHON_SCRIPTS_PATH=C:\Program Files\Python312\Scripts"
echo "%PATH%" | find /i "%PYTHON_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_PATH%" /M >nul
echo "%PATH%" | find /i "%PYTHON_SCRIPTS_PATH%" >nul
if errorlevel 1 setx PATH "%PATH%;%PYTHON_SCRIPTS_PATH%" /M >nul

関連する外部ページ

Python の公式ページ: https://www.python.org/

AI エディタ Windsurf のインストール

Pythonプログラムの編集・実行には、AI エディタの利用を推奨する。ここでは、Windsurfのインストールを説明する。

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、Windsurfをシステム全体にインストールする。管理者権限は、wingetの--scope machineオプションでシステム全体にソフトウェアをインストールするために必要となる。

winget install --scope machine --id Codeium.Windsurf -e --silent --accept-source-agreements --accept-package-agreements

関連する外部ページ

Windsurf の公式ページ: https://windsurf.com/

Panda3D のインストール手順

管理者権限でコマンドプロンプトを起動(手順:Windowsキーまたはスタートメニュー > cmd と入力 > 右クリック > 「管理者として実行」)し、以下を実行して、システム全体にインストールする。


pip install panda3d

実装例

Panda3Dで単色のオレンジ色の立方体を回転させるプログラム。デルタ時間を使用してフレームレート非依存の回転を実現。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)  # Panda3Dエンジンの初期化

        # 回転する立方体
        self.cube = self.loader.loadModel("models/box")  # 組み込みモデルの読み込み
        self.cube.setScale(1)  # スケールの設定(1.0が元のサイズ)
        self.cube.setPos(0, 5, 0)  # 位置の設定(X, Y, Z座標)
        self.cube.setColor(1, 0.5, 0, 1)  # 色の設定(R, G, B, A)
        self.cube.setTextureOff(1)  # テクスチャを強制的にオフ
        self.cube.reparentTo(self.render)  # シーングラフへの追加

        # 更新タスクの追加
        self.taskMgr.add(self.update, "updateTask")  # 毎フレーム呼び出される関数を登録

        # 前回のフレーム時刻を記録
        self.prev_time = globalClock.getFrameTime()  # エンジン起動からの経過時間を取得

    def update(self, task):
        # デルタ時間の計算
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time  # 前フレームからの経過時間(デルタ時間)
        self.prev_time = current_time

        # 立方体の回転(フレームレート非依存)
        self.cube.setH(self.cube.getH() + 50 * dt)  # Y軸周りの回転(Heading)
        self.cube.setP(self.cube.getP() + 30 * dt)  # X軸周りの回転(Pitch)

        return Task.cont  # タスクを継続

app = MyApp()
app.run()  # ゲームループの開始
実行結果:オレンジ色の立方体が画面中央で回転する。

実装例

Panda3Dで3Dシーンを構築するプログラム。緑色の地面(平たい直方体)を配置し、その上に色相環に基づく3色(赤・緑・青)の立方体を横一列に並べる。全てのオブジェクトはテクスチャなしの単色表示。カメラは斜め後方から3つの立方体全体を見渡す位置に配置し、画面左上に「Panda3D Test」のテキストを表示。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from panda3d.core import TextNode
from panda3d.core import Mat4
from direct.gui.OnscreenText import OnscreenText

def hsv_to_rgb(h, s, v):
    """HSV色空間からRGB色空間への変換関数"""
    h = h / 360.0
    c = v * s
    x = c * (1 - abs((h * 6) % 2 - 1))
    m = v - c

    if h < 1/6:
        r, g, b = c, x, 0
    elif h < 2/6:
        r, g, b = x, c, 0
    elif h < 3/6:
        r, g, b = 0, c, x
    elif h < 4/6:
        r, g, b = 0, x, c
    elif h < 5/6:
        r, g, b = x, 0, c
    else:
        r, g, b = c, 0, x

    return r + m, g + m, b + m

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(20, 20, 0.1)  # X, Y, Z方向のスケール
        ground.setPos(-10, -10, 0)
        ground.setColor(0, 0.7, 0, 1)
        ground.setTextureOff(1)  # テクスチャを解除
        ground.reparentTo(self.render)

        # 複数のオブジェクト(色相環に基づく配色)
        for i in range(3):
            cube = self.loader.loadModel("models/box")
            hue = i * 120  # 色相を120度ずつずらす
            r, g, b = hsv_to_rgb(hue, 1, 1)
            cube.setColor(r, g, b, 1)
            cube.setTextureOff(1)  # テクスチャを解除
            cube.setPos(i * 2 - 2, 3, 0)
            cube.reparentTo(self.render)

        # テキスト表示(2D UIオーバーレイ)
        self.text = OnscreenText(
            text='Panda3D Test',
            pos=(-0.5, 0.8),  # 画面座標(-1~1の範囲)
            scale=0.1,
            fg=(1, 1, 1, 1),
            align=TextNode.ALeft
        )

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -10, 4)  # カメラ位置を調整
        self.camera.lookAt(0, 2, 0.5)  # 視点を3つの立方体の中心付近に設定
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

app = MyApp()
app.run()

実行結果:緑の地面の上に3つの異なる色の立方体が並び、画面上部にテキストが表示される。

ポイント


演習問題1


問題

緑色の立方体を位置(0, 3, 2)に配置し、Z軸周り(Roll)に毎秒90度の速度で回転させるプログラムを作成せよ。回転はフレームレート非依存で実装すること。立方体はテクスチャなしの単色表示とする。

ヒント

解答例

コードの説明:Panda3Dで緑色の立方体をZ軸周りに回転させるプログラム。立方体は位置(0, 3, 2)に配置され、テクスチャなしの単色表示。デルタ時間(dt)を使用してフレームレート非依存の回転を実現し、毎秒90度の一定速度でZ軸周り(Roll)に回転する。globalClock.getFrameTime()で前フレームからの経過時間を計算し、setR()メソッドで回転角度を更新。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 緑色の立方体
        self.cube = self.loader.loadModel("models/box")
        self.cube.setPos(0, 3, 2)
        self.cube.setColor(0, 1, 0, 1)  # 緑色
        self.cube.setTextureOff(1)  # テクスチャを解除
        self.cube.reparentTo(self.render)

        # 更新タスクの追加
        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def update(self, task):
        # デルタ時間の計算
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # Z軸周りの回転(毎秒90度)
        self.cube.setR(self.cube.getR() + 90 * dt)

        return Task.cont

app = MyApp()
app.run()

2. 3D座標系とトランスフォーム(位置、回転、スケール)


予備知識


3次元空間の座標系

3次元空間では、XYZ軸による直交座標系(互いに垂直な3つの座標軸)で位置を表現する。各軸は空間内の方向を表し、3つの数値(x, y, z)の組み合わせで任意の点の位置を唯一の方法で特定できる。X軸は左右、Y軸は前後、Z軸は上下を表す。点(2, 3, 1)は右に2、前に3、上に1の位置を示す。

Panda3Dの座標系

Panda3DはZ-up右手座標系を採用している。右手座標系とは、右手の親指をX軸の正方向、人差し指をY軸の正方向に向けたとき、中指がZ軸の正方向を指す座標系である。あるいは、親指(X)、人差し指(Y)、中指(Z)を直角に交差させた時の右手の形で表現される。



正の方向 用途
X軸 左右の位置
Y軸 前後の位置
Z軸 高さ

Point3とVec3

Point3は3D空間内の位置を表し、Vec3は方向と大きさを持つ量であるベクトルを表現する。位置と方向の違いを明確に区別することで、object.setPos(Point3(0, 0, 0))のように位置設定などの3D空間での操作を正確に行える。点の減算でベクトルを得られ、点にベクトルを加えて新しい点を得られる。Vec3(1, 0, 0)はX方向への単位ベクトルを表す。

ベクトルと基本演算

ベクトルは、大きさと方向を持つ量である。3次元空間では(x, y, z)の3つの成分で表現され、位置、速度、加速度などを表すために使用される。ベクトルの長さ(大きさ)は、√(x² + y² + z²)で計算される。2つのベクトルの内積は、ベクトル間の角度や投影を計算する際に使用される重要な演算である。

トランスフォーム(変換)

3Dオブジェクトの配置と姿勢は、3つの基本的な変換で制御される。移動(translation)は、オブジェクトの位置を変更する操作である。回転(rotation)は、オブジェクトの向きを変える操作で、X軸、Y軸、Z軸のそれぞれを中心に回転できる。角度は度数法(0~360度)で指定する。スケール(scale)は、オブジェクトの大きさを変える操作で、(x, y, z)の3つの値で各軸方向の拡大率を指定する。1.0が元のサイズ、2.0で2倍、0.5で半分になる。

オイラー角(HPR)

オイラー角は、Heading(水平回転、ヨー)、Pitch(縦回転、ピッチ)、Roll(傾き、ロール)という3つの角度で物体の向きを表現する回転表現方法である。object.setHpr(45, 0, 0)でY軸周りに45度回転させるなど、直感的な回転制御が可能である。Panda3Dでは、H(Heading)はY軸周りの回転、P(Pitch)はX軸周りの回転、R(Roll)はZ軸周りの回転を表す。

座標系の種類

ワールド座標系は、空間全体の基準となる座標系であり、ワールド空間の原点(0, 0, 0)を基準とした絶対座標である。ローカル座標系は、オブジェクトごとの基準となる座標系であり、あるオブジェクトを基準とした相対座標で、親オブジェクトからの相対的な位置を表す。親オブジェクトが移動すると、相対座標で配置された子オブジェクトも一緒に移動する。


実装例

Panda3Dで立方体の拡大・縮小と回転を行うプログラム。オレンジ色の立方体をテクスチャなしで表示し、Y軸周りに45度回転、X方向に2倍の非均等スケールを適用。初期位置(0, 5, 1)から相対移動でX方向に+2移動し、最終位置は(2, 5, 1)。LVector3を使用したベクトル演算による位置更新を実装。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from panda3d.core import LVector3
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # エンティティの作成と配置
        self.box = self.loader.loadModel("models/box")
        self.box.setPos(0, 5, 1)  # 絶対座標での位置設定
        self.box.setColor(1, 0.5, 0, 1)
        self.box.setTextureOff(1)  # テクスチャを解除
        self.box.reparentTo(self.render)

        # 回転の設定(Heading: Y軸周り、Pitch: X軸周り、Roll: Z軸周り)
        self.box.setH(45)  # Y軸周りに45度回転

        # スケールの設定(非均等スケール)
        self.box.setScale(2, 1, 1)  # X方向に2倍、Y, Z方向は元のまま

        # 相対移動(ベクトル演算による位置の更新)
        current_pos = self.box.getPos()  # 現在位置の取得
        self.box.setPos(current_pos + LVector3(2, 0, 0))  # X方向に+2移動

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(5, -10, 3)
        self.camera.lookAt(self.box)  # オブジェクトを注視
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

app = MyApp()
app.run()

実行結果:箱のモデルが右に2単位、前方5単位、高さ1の位置に配置され、Y軸周りに45度回転し、X方向に2倍に拡大されて表示される。

ポイント


演習問題2


問題

位置(0, 10, 0)を中心として、半径5の円周上に5つの立方体を等間隔に配置せよ。各立方体の色は同一色とし、高さは全てz=1とすること。円周上の配置には三角関数(math.cos、math.sin)を使用すること。

ヒント

解答例

Panda3Dで5つの立方体を円周上に等間隔配置するプログラム。Y=10を中心とした半径5の円周上に、三角関数(cos、sin)を使用して72度間隔で立方体を配置。全ての立方体は同一色(白色)で表示され、高さz=1に配置される。カメラはY=-12の位置から円周の中心Y=10を向き、テクスチャなしの単色表示となる。


from direct.showbase.ShowBase import ShowBase
from panda3d.core import Mat4
import math

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        radius = 5
        num_cubes = 5
        center_y = 10  # 円の中心Y座標

        for i in range(num_cubes):
            cube = self.loader.loadModel("models/box")

            # 円周上の位置計算
            angle = i * 72  # 度数法
            angle_rad = math.radians(angle)  # ラジアンに変換
            x = radius * math.cos(angle_rad)
            y = center_y + radius * math.sin(angle_rad)  # 中心をY=10に移動
            z = 1

            cube.setPos(x, y, z)

            # 同一色設定(白色)
            cube.setColor(1, 1, 1, 1)
            cube.setTextureOff(1)  # テクスチャを解除

            cube.reparentTo(self.render)

app = MyApp()
app.run()

マウスで視点を調整してください


3. カメラとビューポート


予備知識


視点と注視点

視点(camera position)は、3D空間を見る観察者(カメラ)の位置である。注視点(look-at point)は、見つめる対象の位置、カメラが向いている方向の目標点である。この2点で視線方向が決定する。カメラの位置を変えることで、シーンを様々な角度から見ることができる。カメラはこの注視点を常に画面中央に捉えるように向きを調整する。

ビューポートと視点制御

ビューポートは、3D空間がレンダリングされて表示される画面領域である。ウィンドウ全体または一部の矩形領域として定義される。一人称視点は、プレイヤーの目線からシーンを見る視点方式である。キャラクターの目の位置にカメラを配置し、視線方向を制御することで実装される。

視野角(Field of View, FOV)

視野角は、カメラが捉える視界の広さを角度で表現するパラメータである。値が大きいほど広い範囲が見えるが画面の歪みも大きくなる。一般的なゲームでは60度から90度の範囲で設定され、base.camLens.setFov(80)で80度に設定するなど、3D表現の重要な要素となる。

クリッピング面

クリッピング面は、表示範囲を定める面であり、近接面(near plane)と遠方面(far plane)で設定する。この範囲外のオブジェクトは描画されない。描画負荷の最適化に重要である。近接面と遠方面の間にあるオブジェクトのみが画面に表示される。


実装例

Panda3Dで一人称視点のキャラクター移動を実装するプログラム。灰色の地面上に色相環に基づく5色の立方体を横一列に配置し、全てテクスチャなしの単色表示。WASDキーでカメラ(プレイヤー)を前後左右に移動可能。キー状態管理方式により、複数キーの同時押下に対応。デルタ時間を使用してフレームレート非依存の移動速度(5単位/秒)を実現。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4

def hsv_to_rgb(h, s, v):
    """HSV色空間からRGB色空間への変換関数"""
    h = h / 360.0
    c = v * s
    x = c * (1 - abs((h * 6) % 2 - 1))
    m = v - c

    if h < 1/6:
        r, g, b = c, x, 0
    elif h < 2/6:
        r, g, b = x, c, 0
    elif h < 3/6:
        r, g, b = 0, c, x
    elif h < 4/6:
        r, g, b = 0, x, c
    elif h < 5/6:
        r, g, b = x, 0, c
    else:
        r, g, b = c, 0, x

    return r + m, g + m, b + m

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # テスト用のオブジェクト配置(色相環に基づく配色)
        for i in range(5):
            cube = self.loader.loadModel("models/box")
            hue = i * 72  # 色相を72度ずつずらす(360÷5)
            r, g, b = hsv_to_rgb(hue, 1, 1)
            cube.setColor(r, g, b, 1)
            cube.setTextureOff(1)  # テクスチャを解除
            cube.setPos(i * 3, 0, 0)
            cube.reparentTo(self.render)

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(40, 40, 0.1)
        ground.setPos(-20, -20, 0)
        ground.setColor(0.5, 0.5, 0.5, 1)
        ground.setTextureOff(1)  # テクスチャを解除
        ground.reparentTo(self.render)

        # プレイヤーカメラ設定(一人称視点の初期位置)
        self.player_pos = [0, -10, 2]
        self.disableMouse()
        self.camera.setPos(self.player_pos[0], self.player_pos[1], self.player_pos[2])
        self.camera.lookAt(0, 0, 0)  # オブジェクトを注視
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

        # キー入力の設定(キー状態管理方式)
        self.keys = {
            'w': False,
            'a': False,
            's': False,
            'd': False
        }
        # キーイベントハンドラの登録(押下と解放の両方を検出)
        self.accept('w', self.setKey, ['w', True])
        self.accept('w-up', self.setKey, ['w', False])
        self.accept('a', self.setKey, ['a', True])
        self.accept('a-up', self.setKey, ['a', False])
        self.accept('s', self.setKey, ['s', True])
        self.accept('s-up', self.setKey, ['s', False])
        self.accept('d', self.setKey, ['d', True])
        self.accept('d-up', self.setKey, ['d', False])

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def setKey(self, key, value):
        """キーの状態を更新する関数"""
        self.keys[key] = value

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        speed = 5  # 移動速度(単位/秒)
        # キー状態に基づく継続的な移動処理
        if self.keys['w']:
            self.player_pos[1] += speed * dt  # 前進
        if self.keys['s']:
            self.player_pos[1] -= speed * dt  # 後退
        if self.keys['a']:
            self.player_pos[0] -= speed * dt  # 左移動
        if self.keys['d']:
            self.player_pos[0] += speed * dt  # 右移動

        # カメラ位置の更新(一人称視点の実装)
        self.camera.setPos(self.player_pos[0], self.player_pos[1], self.player_pos[2])

        return Task.cont

app = MyApp()
app.run()

実行結果:WASDキーでカメラを移動できる。一人称視点制御がカメラ制御を提供する。

ポイント


演習問題3


問題

位置(0, 0, 3)に青い立方体を配置し、カメラが立方体の周りを円運動するプログラムを作成せよ。カメラは立方体から半径10の距離を保ち、常に立方体を注視しながら、毎秒30度の速度で反時計回りに回転すること。

ヒント

解答例

Panda3Dでカメラを円運動させるプログラム。青い立方体を位置(0, 0, 3)に配置し、テクスチャなしの単色表示。カメラは半径10の円周上を毎秒30度の速度で回転しながら、常に立方体を注視する。三角関数(cos、sin)を使用してカメラのXY座標を計算し、高さはZ=5に固定。デルタ時間でフレームレート非依存の滑らかな回転を実現。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
import math

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 青い立方体
        self.cube = self.loader.loadModel("models/box")
        self.cube.setPos(0, 0, 3)
        self.cube.setColor(0, 0, 1, 1)
        self.cube.setTextureOff(1)  # テクスチャを解除
        self.cube.reparentTo(self.render)

        self.radius = 10  # カメラの半径
        self.angle = 0  # 現在の角度

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # 角度の更新(毎秒30度)
        self.angle += 30 * dt
        angle_rad = math.radians(self.angle)

        # カメラ位置の計算(円運動)
        camera_x = self.radius * math.cos(angle_rad)
        camera_y = self.radius * math.sin(angle_rad)
        camera_z = 5

        self.camera.setPos(camera_x, camera_y, camera_z)
        self.camera.lookAt(self.cube)  # 立方体を常に注視

        return Task.cont

app = MyApp()
app.run()

4. メッシュとマテリアル(色)


予備知識


メッシュと基本図形

メッシュ(mesh)は、3Dオブジェクトの形状を定義する頂点と面の集合である。Panda3Dでは、loadModel()メソッドで指定する。Panda3Dには以下の基本図形(プリミティブ)が組み込まれており、'models/box'(立方体)、'models/sphere'(球体)、'models/plane'(平面)、'models/cylinder'(円柱)、'models/quad'(四角形)などを使用できる。

3Dモデルのファイル形式

3Dモデルのファイル形式には、OBJ、FBXなどの標準形式がある。これらの形式で3Dモデルデータを保存する。形状、材質、テクスチャ(表面画像)、アニメーションなどの情報を含む。Panda3Dでは、独自のegg形式も使用される。

色の表現

RGB色空間は、赤(Red)、緑(Green)、青(Blue)の3つの成分を組み合わせて色を表現する方式である。各成分は0.0~1.0の範囲で指定する(0.0が最小、1.0が最大)。例えば、(1, 0, 0)は赤、(0, 1, 0)は緑、(0, 0, 1)は青、(1, 1, 1)は白、(0, 0, 0)は黒を表す。


実装例

このコードはPanda3Dで3Dシーンを作成する。2つの立方体(テクスチャ付きの立方体,テクスチャの無いオレンジ色)と緑色の地面を配置し、カメラをY=-12、Z=6の位置に設定している。オブジェクトは全てY=10付近に配置され、カメラがその位置を向くことで正しく表示される。Panda3DではY軸が奥行き、Z軸が高さを表す。


from direct.showbase.ShowBase import ShowBase
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 色付き立方体
        self.textured_cube = self.loader.loadModel("models/box")
        self.textured_cube.setPos(-3, 10, 0)
        self.textured_cube.setColor(1, 1, 1, 1)
        self.textured_cube.reparentTo(self.render)

        # 色付き立方体
        self.colored_cube = self.loader.loadModel("models/box")
        self.colored_cube.setPos(0, 10, 0)
        self.colored_cube.setColor(1, 0.5, 0, 1)
        self.colored_cube.setTextureOff(1)
        self.colored_cube.reparentTo(self.render)

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(20, 20, 0.1)
        ground.setPos(-10, 0, 0)
        ground.setColor(0, 0.7, 0, 1)
        ground.setTextureOff(1)
        ground.reparentTo(self.render)

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(-1.5, -12, 6)
        self.camera.lookAt(-1.5, 10, 1)  # lookAtのY座標をオブジェクトの位置に合わせる
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

app = MyApp()
app.run()

実行結果:2つの立方体(テクスチャ付きの立方体,テクスチャの無いオレンジ色)、緑の平面が表示される。

ポイント


演習問題4


問題

3種類の立方体(box)を横一列に配置し、それぞれに異なるRGB色(赤、緑、青の純色)を割り当てよ。各立方体は高さz=1に配置し、x軸方向に2単位ずつ間隔を空けること。さらに、3つの立方体全てが同時に、Y軸周りに毎秒60度の速度で回転するようにせよ。

ヒント

解答例

このコードは3つの立方体を赤、緑、青に色付けし、x軸方向に等間隔で配置する。各立方体はY=10の位置に配置される。テクスチャは解除され、純色のみが表示される。updateメソッドで時間差分を計算し、全ての立方体を毎秒60度でY軸周りに回転させる。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 図形とその色の定義
        models = [
            ('models/box', (1, 0, 0, 1)),      # 赤い立方体
            ('models/box', (0, 1, 0, 1)),      # 緑い立方体
            ('models/box', (0, 0, 1, 1))       # 青い立方体
        ]

        self.objects = []

        for i, (model_name, color) in enumerate(models):
            obj = self.loader.loadModel(model_name)
            obj.setPos(i * 2 - 2, 10, 0)
            obj.setColor(*color)
            obj.setTextureOff(1)  # テクスチャを解除
            obj.reparentTo(self.render)
            self.objects.append(obj)

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # 全ての図形を同時に回転
        for obj in self.objects:
            obj.setH(obj.getH() + 60 * dt)

        return Task.cont

app = MyApp()
app.run()

5. ライティングとシェーディング


予備知識


光源の種類

光源は、3D空間で光を発するオブジェクトである。オブジェクトを照らし、影を作り出すことで立体感を表現する。Panda3Dでは主に2種類の光源を使用する。環境光(AmbientLight)は、全方向から均一に照らす光である。影を作らず、シーン全体の基本的な明るさを確保する。指向性光源(DirectionalLight)は、太陽光のように特定の方向から平行に照らす光である。遠くにある光源を表現し、明確な影を作る。環境光、平行光(指向性光源)、点光源、スポットライトなどの異なる種類の光源を組み合わせることで、リアルな光の表現が可能となる。

HSV色空間と色相環

HSV色空間は、色相(Hue)、彩度(Saturation)、明度(Value)の3つの成分で色を表現する方式である。色相は色の種類を0~360度の角度で表し、0度が赤、120度が緑、240度が青となる。色相環(カラーホイール)は、色相を円環状に配置したもので、規則的に異なる色を生成する際に使用される。例えば、5つのオブジェクトに異なる色を割り当てる場合、色相を72度(360÷5)ずつずらすことで、視覚的にバランスの取れた配色が得られる。


実装例

Panda3Dで色相環に基づいた5色の立方体を横一列に配置し、灰色の地面上に表示するプログラムである。HSV色空間からRGB色空間への変換関数を用いて、色相を72度ずつずらした鮮やかな色を各立方体に適用している。環境光と指向性光源の2種類の照明で立体感を演出し、カメラは斜め上から俯瞰する位置に固定している。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from panda3d.core import AmbientLight, DirectionalLight
from panda3d.core import Mat4

def hsv_to_rgb(h, s, v):
    """HSV色空間からRGB色空間への変換関数"""
    h = h / 360.0
    c = v * s
    x = c * (1 - abs((h * 6) % 2 - 1))
    m = v - c

    if h < 1/6:
        r, g, b = c, x, 0
    elif h < 2/6:
        r, g, b = x, c, 0
    elif h < 3/6:
        r, g, b = 0, c, x
    elif h < 4/6:
        r, g, b = 0, x, c
    elif h < 5/6:
        r, g, b = x, 0, c
    else:
        r, g, b = c, 0, x

    return r + m, g + m, b + m

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 環境光の設定(全方向から均一に照らす光)
        ambient = AmbientLight('ambient')
        ambient.setColor((0.4, 0.4, 0.4, 1))  # 暗めの白色光
        ambient_np = self.render.attachNewNode(ambient)  # ノードとしてシーングラフに追加
        self.render.setLight(ambient_np)  # シーン全体に環境光を適用

        # 指向性光源(太陽光のような平行光)
        sun = DirectionalLight('sun')
        sun.setColor((0.8, 0.8, 0.8, 1))  # 明るい白色光
        sun_np = self.render.attachNewNode(sun)
        sun_np.setHpr(45, -60, 0)  # 光の方向を設定(角度で指定)
        self.render.setLight(sun_np)  # シーン全体に指向性光源を適用

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(40, 40, 0.1)
        ground.setPos(-20, -20, 0)
        ground.setColor(0.5, 0.5, 0.5, 1)
        ground.setTextureOff(1)  # テクスチャを強制的にオフ
        ground.reparentTo(self.render)

        # オブジェクトの配置(色相環に基づく配色)
        for i in range(5):
            cube = self.loader.loadModel("models/box")
            cube.setPos(i * 3 - 6, 0, 1)
            hue = i * 72  # 色相を72度ずつずらす
            r, g, b = hsv_to_rgb(hue, 1, 1)
            cube.setColor(r, g, b, 1)
            cube.setTextureOff(1)  # テクスチャを強制的にオフ
            cube.reparentTo(self.render)

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -20, 10)
        self.camera.lookAt(0, 0, 0)
        # mouseInterfaceNodeを現在のカメラ変換に合わせる
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()  # これで初期位置を保ったまま有効化

app = MyApp()
app.run()

実行結果:環境光で全体が明るくなり、指向性光源が影を作る。複数の立方体がライティングの効果で立体的に見える。

ポイント


演習問題5


問題

環境光(色:0.3, 0.3, 0.3)と指向性光源(色:1.0, 1.0, 1.0、方向:Heading=0, Pitch=-45)を設定し、白い立方体を5つ、Z軸方向に1単位ずつ高さを変えて積み上げよ。最も下の立方体の中心はz=1とし、各立方体のサイズを考慮して接触するように配置すること。

ヒント

解答例

Panda3Dで白い立方体を5個垂直に積み上げて表示するプログラムである。環境光と指向性光源で照明を設定し、カメラを斜め上から立方体群を見下ろす位置に配置している。


from direct.showbase.ShowBase import ShowBase
from panda3d.core import AmbientLight, DirectionalLight
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 環境光の設定
        ambient = AmbientLight('ambient')
        ambient.setColor((0.3, 0.3, 0.3, 1))
        ambient_np = self.render.attachNewNode(ambient)
        self.render.setLight(ambient_np)

        # 指向性光源の設定
        sun = DirectionalLight('sun')
        sun.setColor((1.0, 1.0, 1.0, 1))
        sun_np = self.render.attachNewNode(sun)
        sun_np.setHpr(0, -45, 0)
        self.render.setLight(sun_np)

        # 立方体を積み上げる
        for i in range(5):
            cube = self.loader.loadModel("models/box")
            cube.setPos(0, 0, 1 + i * 2)  # z座標を2ずつ増やす
            cube.setColor(1, 1, 1, 1)  # 白色
            cube.setTextureOff(1)  # テクスチャを強制的にオフ
            cube.reparentTo(self.render)

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(10, -15, 5)
        self.camera.lookAt(0, 0, 5)
        # mouseInterfaceNodeを現在のカメラ変換に合わせる
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()  # これで初期位置を保ったまま有効化

app = MyApp()
app.run()

6. エンティティ(Entity)の生成と制御


予備知識


シーングラフと階層構造

シーングラフ(Scene Graph)は、3D空間内のオブジェクトの階層構造を管理するツリー構造のデータ構造である。シーングラフ内のノードを参照するためのオブジェクトであるNodePathの集合として表現され、座標変換や属性の継承が可能である。各オブジェクトはノードとして表現され、親子関係によって階層的に組織化される。子オブジェクトは親オブジェクトに従属し、親の変換(移動、回転、スケール)の影響を受ける。これを変換の伝播と呼ぶ。親の移動が子に影響し、子の移動は親に影響しない特性を持つ。例えば、車体を親ノードとし、4つの車輪を子ノードとして配置すると、車体を移動させたときに全ての車輪も一緒に移動する。複数のオブジェクトを階層構造で組織化することで、複雑なオブジェクトを効率的に制御できる。

NodePath

NodePathは、シーングラフ内のノードを参照するためのオブジェクトである。階層構造の中でノードへのパスを表し、attachNewNode()で階層構造を構築し、reparentTo()でノードの親子関係を設定する。3Dオブジェクトの配置や変形を制御するために有用である。renderは最上位ノードとなる。


階層構造の例:


vehicle (親)
  |
  +-- body (車体)
  |
  +-- wheel_fl (前左車輪)
  |
  +-- wheel_fr (前右車輪)
  |
  +-- wheel_rl (後左車輪)
  |
  +-- wheel_rr (後右車輪)

親を移動 → 全ての子も移動
子を移動 → 親は影響を受けない

実装例

Panda3Dで親子関係を持つ階層構造の車オブジェクトを作成し、前方に移動させるプログラムである。空のノードを親として作成し、赤い車体と4つの黒い車輪を子ノードとして配置している。親ノードを移動させることで、全ての子ノードが一緒に移動する。車が画面外に出ると初期位置に戻る。カメラは斜め後方から車を追跡する位置に固定されている。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import NodePath
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 親エンティティ(車体)の作成(空のノード)
        self.vehicle = NodePath("vehicle")
        self.vehicle.reparentTo(self.render)  # ルートノード(render)に追加

        # 車体本体
        body = self.loader.loadModel("models/box")
        body.setScale(2, 3, 1)
        body.setColor(1, 0, 0, 1)
        body.setTextureOff(1)  # テクスチャを強制的にオフ
        body.reparentTo(self.vehicle)  # vehicleノードの子として追加

        # 車輪の作成(4つ)
        wheel_positions = [
            (0, 2, -0.5),   # 前左
            (1.5, 2, -0.5),    # 前右
            (0, 0, -0.5),  # 後左
            (1.5, 0, -0.5)    # 後右
        ]

        for pos in wheel_positions:
            wheel = self.loader.loadModel("models/box")
            wheel.setScale(0.5)
            wheel.setColor(0, 0, 0, 1)
            wheel.setPos(pos[0], pos[1], pos[2])  # 親からの相対座標
            wheel.setTextureOff(1)  # テクスチャを強制的にオフ
            wheel.reparentTo(self.vehicle)  # vehicleノードの子として追加

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -15, 5)
        self.camera.lookAt(self.vehicle)
        # mouseInterfaceNodeを現在のカメラ変換に合わせる
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()  # これで初期位置を保ったまま有効化

        # 親を移動すると全ての子も移動する(変換の伝播)
        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # 親ノードの移動(全ての子ノードも一緒に移動)
        current_y = self.vehicle.getY()
        self.vehicle.setY(current_y + 3 * dt)

        # 画面外に出たら初期位置に戻す
        if self.vehicle.getY() > 10:
            self.vehicle.setY(-10)

        return Task.cont

app = MyApp()
app.run()

実行結果:車体と4つの車輪が一体となって前方に移動する。車輪の位置は車体からの相対位置で管理されている。

ポイント


演習問題6


問題

太陽系モデルを作成せよ。中心に黄色の立方体(太陽)を配置し、その周りを青い小さな立方体(地球)が半径3で公転するようにせよ。さらに、地球の周りを灰色のさらに小さな立方体(月)が半径1で公転するようにせよ。地球の公転周期は10秒、月の公転周期は3秒とすること。階層構造を適切に設定し、変換の伝播を利用すること。

ヒント

解答例

Panda3Dで太陽・地球・月の階層的な天体運動システムを実装したプログラムである。黄色の太陽を中心に、青い地球が10秒周期で公転し、灰色の月が地球の周りを3秒周期で公転している。これらの親子関係により、月は地球の公転運動を継承しながら自身の公転も行う動きを実現している。カメラは斜め上から天体系全体を俯瞰する位置に固定され、マウス操作による視点変更も可能である。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import NodePath
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 太陽(親ノード)
        self.sun_system = NodePath("sun_system")
        self.sun_system.reparentTo(self.render)

        sun = self.loader.loadModel("models/box")
        sun.setScale(0.8)
        sun.setColor(1, 1, 0, 1)  # 黄色
        sun.setTextureOff(1)  # テクスチャを強制的にオフ
        sun.reparentTo(self.sun_system)

        # 地球システム(太陽の子)
        self.earth_system = NodePath("earth_system")
        self.earth_system.reparentTo(self.sun_system)

        earth = self.loader.loadModel("models/box")
        earth.setScale(0.3)
        earth.setPos(3, 0, 0)  # 太陽から半径3の位置
        earth.setColor(0, 0, 1, 1)  # 青色
        earth.setTextureOff(1)  # テクスチャを強制的にオフ
        earth.reparentTo(self.earth_system)

        # 月システム(地球の子)
        self.moon_system = NodePath("moon_system")
        self.moon_system.setPos(3, 0, 0)  # 地球と同じ位置に配置
        self.moon_system.reparentTo(self.earth_system)

        moon = self.loader.loadModel("models/box")
        moon.setScale(0.15)
        moon.setPos(1, 0, 0)  # 地球から半径1の位置
        moon.setColor(0.5, 0.5, 0.5, 1)  # 灰色
        moon.setTextureOff(1)  # テクスチャを強制的にオフ
        moon.reparentTo(self.moon_system)

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -15, 8)
        self.camera.lookAt(0, 0, 0)
        # mouseInterfaceNodeを現在のカメラ変換に合わせる
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()  # これで初期位置を保ったまま有効化

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # 地球の公転(10秒で1回転 = 毎秒36度)
        self.earth_system.setH(self.earth_system.getH() + 36 * dt)

        # 月の公転(3秒で1回転 = 毎秒120度)
        self.moon_system.setH(self.moon_system.getH() + 120 * dt)

        return Task.cont

app = MyApp()
app.run()

7. 入力処理(キーボード)


予備知識


イベント駆動プログラミング

イベント駆動プログラミングでは、キー入力やマウス操作をイベント(プログラム上の事象)として検出する。コールバック関数(イベント発生時に実行される処理の関数)で対応する処理を実行する。Panda3Dではaccept()メソッドでイベントを登録する。

キー入力の検出

ゲームエンジンにおける入力処理には、イベント駆動方式がある。イベント駆動方式では、キーが押された瞬間や離された瞬間にイベントが発生し、それに対応する処理が実行される。Panda3Dでは、accept()メソッドでキーイベントを検出できる。キーの押下、保持、解放を区別する。継続的な入力(押し続ける)が必要な移動操作などでは、キーの状態を管理する辞書で現在押されているキーを確認する方法が適している。単発の入力(1回押す)が必要なジャンプや攻撃などでは、イベント駆動方式が適している。


主なキー入力の種類:


入力方法 用途
キー状態管理 継続的な入力(押し続ける) 移動、回転
accept()メソッド 単発の入力(1回押す) ジャンプ、攻撃

実装例

Panda3Dでキャラクター制御とジャンプ機能を実装したプログラムである。WASDキーで水平移動、スペースキーでジャンプを行い、重力加速度(-20)により自然な放物線軌道を描く。キーの押下・解放イベントを辞書で管理することで複数キー同時押しに対応し、地面判定でジャンプ状態をリセットしている。青いプレイヤーキューブが緑の地面上を移動し、カメラは斜め後方の固定位置から全体を俯瞰する。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # プレイヤー
        self.player = self.loader.loadModel("models/box")
        self.player.setPos(0, 0, 0)
        self.player.setColor(0.5, 0.7, 1, 1)
        self.player.setTextureOff(1)  # テクスチャを強制的にオフ
        self.player.reparentTo(self.render)

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(20, 20, 0.1)
        ground.setPos(0, 0, 0)
        ground.setColor(0, 0.7, 0, 1)
        ground.setTextureOff(1)  # テクスチャを強制的にオフ
        ground.reparentTo(self.render)

        # 移動速度とジャンプ
        self.speed = 5
        self.jump_force = 0  # 現在のジャンプ力(速度)
        self.is_jumping = False  # ジャンプ中フラグ

        # キー入力の設定(キー状態管理方式)
        self.keys = {
            'w': False,
            'a': False,
            's': False,
            'd': False
        }
        # キーイベントハンドラの登録(押下と解放の両方を検出)
        self.accept('w', self.setKey, ['w', True])
        self.accept('w-up', self.setKey, ['w', False])
        self.accept('a', self.setKey, ['a', True])
        self.accept('a-up', self.setKey, ['a', False])
        self.accept('s', self.setKey, ['s', True])
        self.accept('s-up', self.setKey, ['s', False])
        self.accept('d', self.setKey, ['d', True])
        self.accept('d-up', self.setKey, ['d', False])
        self.accept('space', self.jump)  # スペースキー押下時にjump()を呼び出し

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -15, 5)
        self.camera.lookAt(self.player)
        # mouseInterfaceNodeを現在のカメラ変換に合わせる
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()  # これで初期位置を保ったまま有効化

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def setKey(self, key, value):
        """キーの状態を更新する関数"""
        self.keys[key] = value

    def jump(self):
        """ジャンプ処理(単発入力)"""
        if not self.is_jumping:  # 地面にいる場合のみジャンプ可能
            self.jump_force = 5  # 初速度を設定
            self.is_jumping = True

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # キーボードによる移動(継続的な入力処理)
        if self.keys['w']:
            self.player.setY(self.player.getY() + self.speed * dt)
        if self.keys['s']:
            self.player.setY(self.player.getY() - self.speed * dt)
        if self.keys['a']:
            self.player.setX(self.player.getX() - self.speed * dt)
        if self.keys['d']:
            self.player.setX(self.player.getX() + self.speed * dt)

        # ジャンプと重力のシミュレーション
        self.jump_force += -20 * dt  # 重力加速度を適用(下向き)
        new_z = self.player.getZ() + self.jump_force * dt  # 速度から位置を計算
        self.player.setZ(new_z)

        # 地面との衝突判定
        h = 0
        if self.player.getZ() <= h:
            self.player.setZ(h)  # 位置を地面に固定
            self.jump_force = 0  # 速度をリセット
            self.is_jumping = False  # ジャンプ終了

        return Task.cont

app = MyApp()
app.run()

実行結果:WASDキーでプレイヤーを移動、スペースキーでジャンプできる。重力により放物線を描いて落下する。

ポイント


演習問題7


問題

矢印キー(arrow_up, arrow_down, arrow_left, arrow_right)で立方体を移動させ、Enterキーを押すと立方体の色がランダムに変わるプログラムを作成せよ。移動速度は毎秒3単位とし、立方体の初期位置は(0, 0, 1)、初期色は白色とする。

ヒント

解答例


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4

import random

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 立方体
        self.cube = self.loader.loadModel("models/box")
        self.cube.setPos(0, 0, 0)
        self.cube.setColor(1, 1, 1, 1)  # 白色
        self.cube.setTextureOff(1)  # テクスチャを強制的にオフ
        self.cube.reparentTo(self.render)

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(10, 10, 0.1)
        ground.setPos(0, 0, 0)
        ground.setColor(0.5, 0.5, 0.5, 1)
        ground.setTextureOff(1)  # テクスチャを強制的にオフ
        ground.reparentTo(self.render)

        self.speed = 3

        # キー入力の設定
        self.keys = {
            'arrow_up': False,
            'arrow_down': False,
            'arrow_left': False,
            'arrow_right': False
        }
        self.accept('arrow_up', self.setKey, ['arrow_up', True])
        self.accept('arrow_up-up', self.setKey, ['arrow_up', False])
        self.accept('arrow_down', self.setKey, ['arrow_down', True])
        self.accept('arrow_down-up', self.setKey, ['arrow_down', False])
        self.accept('arrow_left', self.setKey, ['arrow_left', True])
        self.accept('arrow_left-up', self.setKey, ['arrow_left', False])
        self.accept('arrow_right', self.setKey, ['arrow_right', True])
        self.accept('arrow_right-up', self.setKey, ['arrow_right', False])
        self.accept('enter', self.changeColor)  # Enterキーで色変更

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -15, 8)
        self.camera.lookAt(self.cube)
        # mouseInterfaceNodeを現在のカメラ変換に合わせる
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()  # これで初期位置を保ったまま有効化

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def setKey(self, key, value):
        self.keys[key] = value

    def changeColor(self):
        """ランダムな色に変更"""
        r = random.random()
        g = random.random()
        b = random.random()
        self.cube.setColor(r, g, b, 1)

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # 矢印キーによる移動
        if self.keys['arrow_up']:
            self.cube.setY(self.cube.getY() + self.speed * dt)
        if self.keys['arrow_down']:
            self.cube.setY(self.cube.getY() - self.speed * dt)
        if self.keys['arrow_left']:
            self.cube.setX(self.cube.getX() - self.speed * dt)
        if self.keys['arrow_right']:
            self.cube.setX(self.cube.getX() + self.speed * dt)

        return Task.cont

app = MyApp()
app.run()

8. アニメーションと物理演算


予備知識


アニメーションの基本

アニメーションは、オブジェクトの属性(位置、回転、スケール、色など)を時間とともに変化させることで実現する。三角関数(sin、cos)を使用すると、周期的な動きを簡単に作成できる。例えば、sin関数は-1から1の間を周期的に変化するため、オブジェクトの上下運動や拡大縮小アニメーションに適している。

物理演算の基本

物理演算では、オブジェクトの動きをニュートンの運動法則に基づいてシミュレートする。速度(velocity)は、オブジェクトの移動の速さと方向を持つベクトルである。単位は通常、m/s(メートル毎秒)である。加速度(acceleration)は、速度の変化率である。重力、摩擦、推進力などによって生じる。単位は通常、m/s²(メートル毎秒毎秒)である。重力加速度は、地球の重力によって生じる加速度で、地表では約-9.8 m/s²(下向き)である。

運動方程式と衝突判定

運動方程式に基づく物理シミュレーションでは、まず加速度を速度に加算し(velocity += acceleration × dt)、次に速度を位置に加算する(position += velocity × dt)。衝突判定は、2つのオブジェクトが接触または重なっているかを判定する処理である。衝突が検出されたら、位置を補正し、速度を反転させる。反発係数は、衝突時の跳ね返りの強さを示す値で、0.0で完全非弾性衝突(跳ねない)、1.0で完全弾性衝突(エネルギー損失なし)となる。質量を持つが大きさを持たない理想化されたオブジェクトを点質量と呼び、簡易的な物理シミュレーションで使用される。


実装例(アニメーション)

Panda3Dで3種類のアニメーションパターンを示すプログラムである。赤いキューブは連続回転、青いキューブはsin関数による周期的な上下動、緑のキューブはsin関数による拡大縮小を行う。経過時間を累積してsin関数の引数とすることで滑らかな周期運動を実現している。灰色の地面上に3つのキューブが横一列に配置され、カメラは正面やや上方の固定位置から俯瞰する。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4

import math

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(20, 20, 0.1)
        ground.setPos(0, 0, 0)
        ground.setColor(0.5, 0.5, 0.5, 1)
        ground.setTextureOff(1)  # テクスチャを強制的にオフ
        ground.reparentTo(self.render)

        # 回転するキューブ
        self.rotating_cube = self.loader.loadModel("models/box")
        self.rotating_cube.setColor(1, 0, 0, 1)
        self.rotating_cube.setPos(-4, 0, 1)
        self.rotating_cube.setTextureOff(1)  # テクスチャを強制的にオフ
        self.rotating_cube.reparentTo(self.render)

        # 上下移動する立方体
        self.bouncing_cube = self.loader.loadModel("models/box")
        self.bouncing_cube.setColor(0, 0, 1, 1)
        self.bouncing_cube.setPos(0, 0, 1)
        self.bouncing_cube.setTextureOff(1)  # テクスチャを強制的にオフ
        self.bouncing_cube.reparentTo(self.render)

        # 拡大縮小する立方体
        self.scaling_cube = self.loader.loadModel("models/box")
        self.scaling_cube.setColor(0, 1, 0, 1)
        self.scaling_cube.setPos(4, 0, 1)
        self.scaling_cube.setTextureOff(1)  # テクスチャを強制的にオフ
        self.scaling_cube.reparentTo(self.render)

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -12, 5)
        self.camera.lookAt(0, 0, 1)
        # mouseInterfaceNodeを現在のカメラ変換に合わせる
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()  # これで初期位置を保ったまま有効化

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()
        self.elapsed_time = 0  # 経過時間の累積(三角関数の引数として使用)

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time
        self.elapsed_time += dt  # 経過時間を累積

        # 回転アニメーション(線形的な変化)
        self.rotating_cube.setH(self.rotating_cube.getH() + 100 * dt)

        # 上下移動アニメーション(sin関数による周期的な動き)
        z_pos = 1 + math.sin(self.elapsed_time * 3) * 0.5  # 振幅0.5、周波数3
        self.bouncing_cube.setZ(z_pos)

        # 拡大縮小アニメーション(sin関数による周期的な変化)
        scale_factor = 1 + math.sin(self.elapsed_time * 2) * 0.3  # 基準値1.0、振幅0.3
        self.scaling_cube.setScale(1, 1, scale_factor)

        return Task.cont

app = MyApp()
app.run()

実行結果:赤い立方体が回転し、青い立方体が上下に移動し、緑の立方体が拡大縮小する。

Panda3Dで複数立方体の自由落下と反発を実装したプログラムである。色相環に基づく5色の立方体が異なる高さから落下し、重力で加速しながら地面に到達する。衝突時に反発係数0.5で跳ね返り、エネルギー損失により徐々に静止する。各立方体の速度を別リストで管理することで個別の物理挙動を実現している。カメラは正面やや上方から落下する立方体群を俯瞰する。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。

実装例(物理演算)



from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import LVector3
from panda3d.core import Mat4

def hsv_to_rgb(h, s, v):
    """HSV色空間からRGB色空間への変換関数"""
    h = h / 360.0
    c = v * s
    x = c * (1 - abs((h * 6) % 2 - 1))
    m = v - c

    if h < 1/6:
        r, g, b = c, x, 0
    elif h < 2/6:
        r, g, b = x, c, 0
    elif h < 3/6:
        r, g, b = 0, c, x
    elif h < 4/6:
        r, g, b = 0, x, c
    elif h < 5/6:
        r, g, b = x, 0, c
    else:
        r, g, b = c, 0, x

    return r + m, g + m, b + m

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 地面
        self.ground = self.loader.loadModel("models/box")
        self.ground.setScale(40, 40, 0.1)
        self.ground.setPos(-20, -20, 0)
        self.ground.setColor(0, 0.7, 0, 1)
        self.ground.setTextureOff(1)
        self.ground.reparentTo(self.render)

        # 地面の上面Z座標を計算(中心0 + 厚さ0.1の半分)
        self.ground_top = 0.05
        # 箱のサイズ(デフォルトで1x1x1、中心が原点)
        self.box_half_height = 0.5

        # 落下する箱(色相環に基づく配色)
        self.boxes = []
        self.velocities = []  # 速度を別リストで管理
        for i in range(5):
            box = self.loader.loadModel("models/box")
            hue = i * 72
            r, g, b = hsv_to_rgb(hue, 1, 1)
            box.setColor(r, g, b, 1)
            box.setPos(i * 2 - 4, 0, 10 + i * 2)
            box.setTextureOff(1)
            box.reparentTo(self.render)
            self.boxes.append(box)
            self.velocities.append(LVector3(0, 0, 0))  # 対応する速度ベクトル

        self.gravity = -9.8

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -20, 5)
        self.camera.lookAt(0, 0, 2)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        for i, box in enumerate(self.boxes):
            velocity = self.velocities[i]

            # 重力を適用
            velocity.z += self.gravity * dt
            new_pos = box.getPos() + velocity * dt
            box.setPos(new_pos)

            # 地面との衝突判定(箱の底面が地面の上面に接触)
            collision_z = 0
            if box.getZ() <= collision_z:
                box.setZ(collision_z)
                velocity.z = -velocity.z * 0.5

                # 速度が小さくなったら停止
                if abs(velocity.z) < 0.1:
                    velocity.z = 0

        return Task.cont

app = MyApp()
app.run()

実行結果:複数の箱が重力により落下し、地面に着地してバウンドする。徐々に跳ねる高さが低くなり、最終的に停止する。

ポイント


演習問題8


問題

位置(0, 0, 10)から立方体を水平方向(Y軸正方向)に初速度5 m/sで投射し、重力加速度-9.8 m/s²の影響を受けて放物運動するシミュレーションを作成せよ。立方体が地面に到達したら、反発係数0.7でバウンドするようにせよ。立方体の色は赤色とすること。

ヒント

解答例

Panda3Dで放物運動と地面反発を実装したプログラムである。赤い立方体がY方向の初速度を持ちながら重力で落下し、地面と衝突すると反発係数0.7で跳ね返る。エネルギー損失により徐々に跳躍高度が減少し、最終的に停止する。緑の地面上を放物線軌道で移動する立方体を、カメラは斜め後方から俯瞰する位置で捉えている。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import LVector3
from panda3d.core import Mat4

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # 立方体
        self.cube = self.loader.loadModel("models/box")
        self.cube.setPos(0, 0, 10)
        self.cube.setColor(1, 0, 0, 1)
        self.cube.setTextureOff(1)
        self.cube.reparentTo(self.render)
        self.cube_velocity = LVector3(0, 5, 0)  # 速度を別変数で管理

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(200, 200, 0.1)
        ground.setPos(-100, -100, 0)
        ground.setColor(0, 0.7, 0, 1)
        ground.setTextureOff(1)
        ground.reparentTo(self.render)

        # 地面の上面Z座標(中心0 + 厚さ0.1の半分)
        self.ground_top = 0.05
        # 箱の半分の高さ
        self.box_half_height = 0.5

        self.gravity = -9.8
        self.restitution = 0.7

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -25, 10)
        self.camera.lookAt(0, 10, 5)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # 重力を適用(Z方向のみ)
        self.cube_velocity.z += self.gravity * dt

        # 位置を更新
        new_pos = self.cube.getPos() + self.cube_velocity * dt
        self.cube.setPos(new_pos)

        # 地面との衝突判定
        collision_z = 0
        if self.cube.getZ() <= collision_z:
            self.cube.setZ(collision_z)
            self.cube_velocity.z = -self.cube_velocity.z * self.restitution

            # 速度が小さくなったら停止
            if abs(self.cube_velocity.z) < 0.1:
                self.cube_velocity.z = 0

        return Task.cont

app = MyApp()
app.run()

9. ゲーム制作(簡易的な3Dアクションゲーム)


予備知識


ゲーム制作の基本要素

3Dゲームは、プレイヤー、環境(地面、障害物)、ゲームロジック(スコア、クリア条件)を組み合わせて構成される。プレイヤーキャラクターの移動制御、アイテムとの距離判定、スコア管理、UI表示などを統合することで、インタラクティブなゲーム体験を実現できる。


実装例

Panda3Dでアイテム収集ゲームの基本構造を示すプログラムである。水色のキューブをプレイヤーとして操作し、緑色の地面上にランダム配置された10個の金色キューブ(収集アイテム)を集める。WASDキーによる移動入力を受け付け、フレーム間の経過時間(dt)を用いて移動量を計算することでフレームレートに依存しない滑らかな移動を実現している。プレイヤーとアイテム間の距離をベクトル演算で計算し、閾値以下になった場合にアイテムを削除してスコアを加算する衝突判定を実装している。カメラはプレイヤーの後方上空に固定された三人称視点で追従し、画面左上には現在のスコアが2Dオーバーレイとして表示される。全アイテム収集時にはゲームクリアメッセージを表示する。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。



from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import TextNode
from panda3d.core import Mat4
from direct.gui.OnscreenText import OnscreenText
import random

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # プレイヤー
        self.player = self.loader.loadModel("models/box")
        self.player.setPos(0, 0, 1)
        self.player.setColor(0.5, 0.7, 1, 1)
        self.player.setTextureOff(1)  # テクスチャを強制的にオフ
        self.player.reparentTo(self.render)

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(100, 100, 0.1)
        ground.setPos(-50, -50, 0)
        ground.setColor(0, 0.7, 0, 1)
        ground.setTextureOff(1)  # テクスチャを強制的にオフ
        ground.reparentTo(self.render)

        # 収集アイテム(ランダムな位置に配置)
        self.collectibles = []
        for i in range(10):
            item = self.loader.loadModel("models/box")
            item.setColor(1, 0.84, 0, 1)  # 金色
            x = random.uniform(-20, 20)  # -20~20の範囲でランダムな位置
            y = random.uniform(-20, 20)
            item.setPos(x, y, 0.5)
            item.setScale(0.5)
            item.setTextureOff(1)  # テクスチャを強制的にオフ
            item.reparentTo(self.render)
            self.collectibles.append(item)

        # スコア表示(2D UIオーバーレイ)
        self.score = 0
        self.score_text = OnscreenText(
            text=f'Score: {self.score}',
            pos=(-1.3, 0.9),  # 画面左上
            scale=0.1,
            fg=(1, 1, 1, 1),
            align=TextNode.ALeft
        )

        self.speed = 5

        # キー入力の設定
        self.keys = {
            'w': False,
            'a': False,
            's': False,
            'd': False
        }
        self.accept('w', self.setKey, ['w', True])
        self.accept('w-up', self.setKey, ['w', False])
        self.accept('a', self.setKey, ['a', True])
        self.accept('a-up', self.setKey, ['a', False])
        self.accept('s', self.setKey, ['s', True])
        self.accept('s-up', self.setKey, ['s', False])
        self.accept('d', self.setKey, ['d', True])
        self.accept('d-up', self.setKey, ['d', False])

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -15, 10)  # カメラ位置を調整
        self.camera.lookAt(0, 0, 0)  # 視点を設定
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def setKey(self, key, value):
        self.keys[key] = value

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # プレイヤー移動
        if self.keys['w']:
            self.player.setY(self.player.getY() + self.speed * dt)
        if self.keys['s']:
            self.player.setY(self.player.getY() - self.speed * dt)
        if self.keys['a']:
            self.player.setX(self.player.getX() - self.speed * dt)
        if self.keys['d']:
            self.player.setX(self.player.getX() + self.speed * dt)

        # アイテム収集判定(距離計算による衝突判定)
        player_pos = self.player.getPos()
        for item in self.collectibles[:]:  # リストのコピーをイテレート
            item_pos = item.getPos()
            distance = (player_pos - item_pos).length()  # ベクトル演算で距離を計算
            if distance < 1.5:  # 閾値以下なら収集
                item.removeNode()  # シーングラフから削除
                self.collectibles.remove(item)  # リストから削除
                self.score += 10
                self.score_text.setText(f'Score: {self.score}')  # UI更新

        # ゲームクリア判定
        if len(self.collectibles) == 0:
            self.score_text.setText('Game Clear!')

        # カメラをプレイヤーに追従(三人称視点)
        self.camera.setPos(player_pos.x, player_pos.y - 15, 10)

        return Task.cont

app = MyApp()
app.run()

実行結果:WASDキーでプレイヤーを操作し、金色の立方体を収集する。全てのアイテムを収集するとゲームクリアとなる。

ポイント


演習問題9


問題

タイマー付きの収集ゲームを作成せよ。プレイヤー(青い立方体)は初期位置(0, 0, 1)から開始し、30秒以内にランダムに配置された5つの赤い立方体を全て収集すればクリア、時間切れになればゲームオーバーとなる。画面左上にスコア、右上に残り時間を表示すること。プレイヤーの移動速度は毎秒7単位とする。

ヒント

解答例


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import TextNode
from panda3d.core import Mat4
from direct.gui.OnscreenText import OnscreenText
import random

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # プレイヤー
        self.player = self.loader.loadModel("models/box")
        self.player.setPos(0, 0, 1)
        self.player.setColor(0, 0, 1, 1)  # 青色
        self.player.setTextureOff(1)  # テクスチャを強制的にオフ
        self.player.reparentTo(self.render)

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(100, 100, 0.1)
        ground.setPos(-50, -50, 0)
        ground.setColor(0, 0.7, 0, 1)
        ground.setTextureOff(1)  # テクスチャを強制的にオフ
        ground.reparentTo(self.render)

        # 収集アイテム(赤い立方体)
        self.collectibles = []
        for i in range(5):
            item = self.loader.loadModel("models/box")
            item.setColor(1, 0, 0, 1)  # 赤色
            x = random.uniform(-10, 10)
            y = random.uniform(-10, 10)
            item.setPos(x, y, 0.5)
            item.setScale(0.5)
            item.setTextureOff(1)  # テクスチャを強制的にオフ
            item.reparentTo(self.render)
            self.collectibles.append(item)

        # スコア表示
        self.score = 0
        self.score_text = OnscreenText(
            text=f'Score: {self.score}',
            pos=(-1.3, 0.9),
            scale=0.1,
            fg=(1, 1, 1, 1),
            align=TextNode.ALeft
        )

        # タイマー表示
        self.time_limit = 30
        self.elapsed_time = 0
        self.timer_text = OnscreenText(
            text=f'Time: {self.time_limit:.1f}',
            pos=(1.1, 0.9),
            scale=0.1,
            fg=(1, 1, 1, 1),
            align=TextNode.ARight
        )

        self.speed = 7
        self.game_over = False

        # キー入力の設定
        self.keys = {'w': False, 'a': False, 's': False, 'd': False}
        self.accept('w', self.setKey, ['w', True])
        self.accept('w-up', self.setKey, ['w', False])
        self.accept('a', self.setKey, ['a', True])
        self.accept('a-up', self.setKey, ['a', False])
        self.accept('s', self.setKey, ['s', True])
        self.accept('s-up', self.setKey, ['s', False])
        self.accept('d', self.setKey, ['d', True])
        self.accept('d-up', self.setKey, ['d', False])

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -20, 15)  # カメラ位置を調整
        self.camera.lookAt(0, 0, 0)  # 視点を設定
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def setKey(self, key, value):
        self.keys[key] = value

    def update(self, task):
        if self.game_over:
            return Task.cont

        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # タイマー更新
        self.elapsed_time += dt
        remaining_time = self.time_limit - self.elapsed_time
        self.timer_text.setText(f'Time: {remaining_time:.1f}')

        # 時間切れ判定
        if remaining_time <= 0:
            self.timer_text.setText('Game Over!')
            self.game_over = True
            return Task.cont

        # プレイヤー移動
        if self.keys['w']:
            self.player.setY(self.player.getY() + self.speed * dt)
        if self.keys['s']:
            self.player.setY(self.player.getY() - self.speed * dt)
        if self.keys['a']:
            self.player.setX(self.player.getX() - self.speed * dt)
        if self.keys['d']:
            self.player.setX(self.player.getX() + self.speed * dt)

        # アイテム収集判定
        player_pos = self.player.getPos()
        for item in self.collectibles[:]:
            item_pos = item.getPos()
            distance = (player_pos - item_pos).length()
            if distance < 1.2:
                item.removeNode()
                self.collectibles.remove(item)
                self.score += 20
                self.score_text.setText(f'Score: {self.score}')

        # ゲームクリア判定
        if len(self.collectibles) == 0:
            self.timer_text.setText('Game Clear!')
            self.game_over = True

        return Task.cont

app = MyApp()
app.run()

10. 波動シミュレーション(水面の波)


予備知識


波動方程式と数値シミュレーション

波動方程式は、波の伝播を記述する偏微分方程式である。水面に物体が落下したときの波紋の広がりなど、物理的に正確な波の動きを再現できる。コンピュータで波動方程式を解くには、数値シミュレーション手法を使用する。有限差分法は、連続的な空間を離散的な格子点(グリッド)で近似し、微分を差分で置き換えて計算する手法である。

波動シミュレーションの要素

水面を格子状のグリッドで表現し、各格子点の高さを計算することで波の形状を表現する。波の伝播は、ある格子点の変化が隣接する格子点に影響を与えることで実現される。実際の水面では、波は時間とともにエネルギーを失って小さくなる。この現象を減衰と呼び、シミュレーションに減衰項を加えることで現実的な動きを再現する。グリッドの端での波の振る舞いを定義する条件を境界条件と呼ぶ。固定境界(波が反射する)や自由境界(波が通過する)などがある。


実装例

Panda3Dで波動方程式に基づく水面シミュレーションを示すプログラムである。50×50の格子点で構成されるメッシュを動的に生成し、各格子点の高さを波動方程式の数値解法(有限差分法)で計算することでリアルタイムに波の伝播を再現している。初期状態として格子中央に高さ5.0の山を配置し、そこから波紋が広がる様子を観察できる。ラプラシアン(2次空間微分の離散近似)と時間積分により各点の加速度と速度を求め、減衰係数を適用することで波が徐々に収束する物理的挙動を表現している。格子端は固定境界条件(高さ0に固定)として処理される。メッシュの頂点色は高さの絶対値に応じて対数スケールで変化し、微細な波の動きを視覚的に強調している。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Mat4
from panda3d.core import Geom, GeomTriangles, GeomNode
import math

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # グリッドサイズ(格子点の数)
        self.grid_size = 50
        self.spacing = 0.5  # 格子点の間隔

        # 波動シミュレーション用の配列(現在の高さと1フレーム前の高さ)
        self.current = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]
        self.previous = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]

        # 波動方程式のパラメータ
        self.wave_speed = 0.5  # 波の伝播速度
        self.damping = 0.99  # 減衰係数(1.0で減衰なし)

        # 初期の波(中央に山を作る)
        center = self.grid_size // 2
        self.current[center][center] = 5.0

        # メッシュの作成
        self.water_mesh = self.create_water_mesh()
        self.water_node = self.render.attachNewNode(self.water_mesh)
        self.water_node.setPos(-self.grid_size * self.spacing / 2, -self.grid_size * self.spacing / 2, 0)
        self.water_node.setTextureOff(1)  # テクスチャを強制的にオフ

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -30, 20)  # カメラ位置を調整
        self.camera.lookAt(0, 0, 0)  # 視点を設定
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def create_water_mesh(self):
        """動的に更新可能なメッシュを作成"""
        format = GeomVertexFormat.getV3n3c4()  # 頂点フォーマット(位置、法線、色)
        vdata = GeomVertexData('water', format, Geom.UHDynamic)  # 動的更新を指定

        vertex = GeomVertexWriter(vdata, 'vertex')  # 頂点位置の書き込み
        normal = GeomVertexWriter(vdata, 'normal')  # 法線ベクトルの書き込み
        color = GeomVertexWriter(vdata, 'color')  # 頂点色の書き込み

        # 頂点の作成
        for i in range(self.grid_size):
            for j in range(self.grid_size):
                x = i * self.spacing
                y = j * self.spacing
                z = self.current[i][j]
                vertex.addData3(x, y, z)
                normal.addData3(0, 0, 1)  # 上向きの法線
                color.addData4(0.2, 0.5, 0.8, 1.0)  # 青色

        # 三角形の作成(2つの三角形で1つの四角形を構成)
        tris = GeomTriangles(Geom.UHDynamic)
        for i in range(self.grid_size - 1):
            for j in range(self.grid_size - 1):
                v0 = i * self.grid_size + j
                v1 = v0 + 1
                v2 = v0 + self.grid_size
                v3 = v2 + 1

                # 頂点インデックスを指定して三角形を構成
                tris.addVertices(v0, v2, v1)
                tris.addVertices(v1, v2, v3)

        geom = Geom(vdata)
        geom.addPrimitive(tris)

        node = GeomNode('water_node')
        node.addGeom(geom)

        return node

    def update_wave(self, dt):
        """波動方程式の数値計算(有限差分法)"""
        # dtの上限を設定(数値的安定性のため)
        dt = min(dt, 0.1)

        c_squared = self.wave_speed * self.wave_speed
        dt_squared = dt * dt

        new = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]

        for i in range(1, self.grid_size - 1):
            for j in range(1, self.grid_size - 1):
                # ラプラシアンの計算(2次微分の離散近似)
                laplacian = (
                    self.current[i+1][j] + self.current[i-1][j] +
                    self.current[i][j+1] + self.current[i][j-1] -
                    4 * self.current[i][j]
                ) / (self.spacing * self.spacing)

                # 波動方程式: ∂²u/∂t² = c² ∇²u
                acceleration = c_squared * laplacian

                # 速度の計算(後退差分)
                velocity = (self.current[i][j] - self.previous[i][j]) / dt

                # 減衰の適用(エネルギー損失)
                velocity *= self.damping

                # 新しい高さの計算(運動方程式の数値積分)
                new[i][j] = self.current[i][j] + velocity * dt + acceleration * dt_squared

        # 境界条件(固定境界:端の高さを0に固定)
        for i in range(self.grid_size):
            new[i][0] = 0
            new[i][self.grid_size-1] = 0
            new[0][i] = 0
            new[self.grid_size-1][i] = 0

        # 配列の更新(時間ステップを進める)
        self.previous = [row[:] for row in self.current]
        self.current = new

    def update_mesh(self):
        """メッシュの頂点位置と色を更新"""
        geom = self.water_mesh.modifyGeom(0)
        vdata = geom.modifyVertexData()

        vertex = GeomVertexWriter(vdata, 'vertex')
        color = GeomVertexWriter(vdata, 'color')

        for i in range(self.grid_size):
            for j in range(self.grid_size):
                x = i * self.spacing
                y = j * self.spacing
                z = self.current[i][j]
                vertex.setData3(x, y, z)  # 頂点位置を更新

                # 高さに応じた色の変化(対数スケールで微細な変化を強調)
                abs_z = abs(z)

                # 対数変換(微細な変化を強調)
                # log(1 + x) を使用することで、x=0 でも安全に計算可能
                # スケール係数を調整して適切な範囲にマッピング
                if abs_z < 0.001:
                    # 極めて小さい値は線形に扱う(対数の特異点を回避)
                    height_ratio = abs_z / 0.001 * 0.1
                else:
                    # 対数スケール: log(1 + 10*z) で微細な変化を強調
                    # 係数10は感度調整用(大きいほど微細な変化に敏感)
                    log_value = math.log(1.0 + 10.0 * abs_z)
                    # 正規化: log(1 + 10*5) ≈ 4.14 を基準に0~1の範囲へ
                    height_ratio = min(log_value / 4.14, 1.0)

                # 色の計算
                blue = 0.8 - height_ratio * 0.3
                green = 0.5 + height_ratio * 0.3
                color.setData4(0.2, green, blue, 1.0)

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # dtが0または極端に小さい場合はスキップ
        if dt < 0.0001:
            return Task.cont

        # 波動シミュレーションの更新
        self.update_wave(dt)
        self.update_mesh()  # メッシュを動的に更新

        return Task.cont

app = MyApp()
app.run()

実行結果:中央から波紋が広がり、格子の端で反射する。波は時間とともに減衰し、物理的に正確な波の伝播が観察できる。

ポイント

実装例(CTRLキーとマウスクリックによる波の生成)

Panda3Dで波動方程式に基づく水面シミュレーションにマウスピッキングによるインタラクション機能を追加したプログラムである。50×50の格子点で構成されるメッシュを動的に生成し、各格子点の高さを波動方程式の数値解法(有限差分法)で計算することでリアルタイムに波の伝播を再現している。初期状態として格子中央に高さ5.0の山を配置し、そこから波紋が広がる。Ctrl+マウスクリックにより、カメラからマウス位置へのCollisionRayを発射し、水面メッシュとの衝突点を検出する。衝突点のワールド座標をグリッド座標に変換し、該当する格子点の高さに5.0を加算することで、任意の位置に新たな波を生成できる。ラプラシアン(2次空間微分の離散近似)と時間積分により各点の加速度と速度を求め、減衰係数を適用することで波が徐々に収束する物理的挙動を表現している。格子端は固定境界条件(高さ0に固定)として処理される。メッシュの頂点色は高さの絶対値に応じて対数スケールで変化し、微細な波の動きを視覚的に強調している。

Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Mat4
from panda3d.core import Geom, GeomTriangles, GeomNode
from panda3d.core import CollisionTraverser, CollisionNode, CollisionRay, CollisionHandlerQueue
from panda3d.core import Point3, BitMask32
import math

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)

        # グリッドサイズ(格子点の数)
        self.grid_size = 50
        self.spacing = 0.5  # 格子点の間隔

        # 波動シミュレーション用の配列(現在の高さと1フレーム前の高さ)
        self.current = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]
        self.previous = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]

        # 波動方程式のパラメータ
        self.wave_speed = 0.5  # 波の伝播速度
        self.damping = 0.99  # 減衰係数(1.0で減衰なし)

        # 初期の波(中央に山を作る)
        center = self.grid_size // 2
        self.current[center][center] = 5.0

        # メッシュの作成
        self.water_mesh = self.create_water_mesh()
        self.water_node = self.render.attachNewNode(self.water_mesh)
        self.water_node.setPos(-self.grid_size * self.spacing / 2, -self.grid_size * self.spacing / 2, 0)
        self.water_node.setTextureOff(1)  # テクスチャを強制的にオフ

        # コリジョン設定(マウスピッキング用)
        self.setup_collision()

        # マウスクリックイベントの設定(Ctrl + クリック)
        self.accept('control-mouse1', self.on_mouse_click)

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -30, 20)  # カメラ位置を調整
        self.camera.lookAt(0, 0, 0)  # 視点を設定
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

        self.taskMgr.add(self.update, "updateTask")
        self.prev_time = globalClock.getFrameTime()

    def setup_collision(self):
        """マウスピッキング用のコリジョンシステムを設定"""
        # コリジョントラバーサーとハンドラーの作成
        self.picker = CollisionTraverser()
        self.pq = CollisionHandlerQueue()

        # コリジョンレイの作成(カメラからマウス位置へのレイ)
        self.pickerNode = CollisionNode('mouseRay')
        self.pickerNP = self.camera.attachNewNode(self.pickerNode)
        self.pickerNode.setFromCollideMask(BitMask32.bit(1))
        self.pickerRay = CollisionRay()
        self.pickerNode.addSolid(self.pickerRay)
        self.picker.addCollider(self.pickerNP, self.pq)

        # 水面メッシュにコリジョンマスクを設定
        self.water_node.setCollideMask(BitMask32.bit(1))

    def on_mouse_click(self):
        """Ctrl + クリックで波を生成"""
        # マウスの2D座標を取得
        if not self.mouseWatcherNode.hasMouse():
            return

        mpos = self.mouseWatcherNode.getMouse()

        # カメラからマウス位置へのレイを設定
        self.pickerRay.setFromLens(self.camNode, mpos.getX(), mpos.getY())

        # コリジョン判定を実行
        self.picker.traverse(self.render)

        if self.pq.getNumEntries() > 0:
            # 最も近い衝突点を取得
            self.pq.sortEntries()
            entry = self.pq.getEntry(0)
            collision_point = entry.getSurfacePoint(self.render)

            # ワールド座標からグリッド座標に変換
            world_x = collision_point.getX()
            world_y = collision_point.getY()

            # グリッドの原点オフセットを考慮
            grid_offset = self.grid_size * self.spacing / 2
            grid_x = int((world_x + grid_offset) / self.spacing)
            grid_y = int((world_y + grid_offset) / self.spacing)

            # グリッドの範囲内かチェック
            if 0 <= grid_x < self.grid_size and 0 <= grid_y < self.grid_size:
                # クリック位置に波を生成
                self.current[grid_x][grid_y] += 5.0
                print(f"波を生成: グリッド座標 ({grid_x}, {grid_y})")

    def create_water_mesh(self):
        """動的に更新可能なメッシュを作成"""
        format = GeomVertexFormat.getV3n3c4()  # 頂点フォーマット(位置、法線、色)
        vdata = GeomVertexData('water', format, Geom.UHDynamic)  # 動的更新を指定

        vertex = GeomVertexWriter(vdata, 'vertex')  # 頂点位置の書き込み
        normal = GeomVertexWriter(vdata, 'normal')  # 法線ベクトルの書き込み
        color = GeomVertexWriter(vdata, 'color')  # 頂点色の書き込み

        # 頂点の作成
        for i in range(self.grid_size):
            for j in range(self.grid_size):
                x = i * self.spacing
                y = j * self.spacing
                z = self.current[i][j]
                vertex.addData3(x, y, z)
                normal.addData3(0, 0, 1)  # 上向きの法線
                color.addData4(0.2, 0.5, 0.8, 1.0)  # 青色

        # 三角形の作成(2つの三角形で1つの四角形を構成)
        tris = GeomTriangles(Geom.UHDynamic)
        for i in range(self.grid_size - 1):
            for j in range(self.grid_size - 1):
                v0 = i * self.grid_size + j
                v1 = v0 + 1
                v2 = v0 + self.grid_size
                v3 = v2 + 1

                # 頂点インデックスを指定して三角形を構成
                tris.addVertices(v0, v2, v1)
                tris.addVertices(v1, v2, v3)

        geom = Geom(vdata)
        geom.addPrimitive(tris)

        node = GeomNode('water_node')
        node.addGeom(geom)

        return node

    def update_wave(self, dt):
        """波動方程式の数値計算(有限差分法)"""
        # dtの上限を設定(数値的安定性のため)
        dt = min(dt, 0.1)

        c_squared = self.wave_speed * self.wave_speed
        dt_squared = dt * dt

        new = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]

        for i in range(1, self.grid_size - 1):
            for j in range(1, self.grid_size - 1):
                # ラプラシアンの計算(2次微分の離散近似)
                laplacian = (
                    self.current[i+1][j] + self.current[i-1][j] +
                    self.current[i][j+1] + self.current[i][j-1] -
                    4 * self.current[i][j]
                ) / (self.spacing * self.spacing)

                # 波動方程式: ∂²u/∂t² = c² ∇²u
                acceleration = c_squared * laplacian

                # 速度の計算(後退差分)
                velocity = (self.current[i][j] - self.previous[i][j]) / dt

                # 減衰の適用(エネルギー損失)
                velocity *= self.damping

                # 新しい高さの計算(運動方程式の数値積分)
                new[i][j] = self.current[i][j] + velocity * dt + acceleration * dt_squared

        # 境界条件(固定境界:端の高さを0に固定)
        for i in range(self.grid_size):
            new[i][0] = 0
            new[i][self.grid_size-1] = 0
            new[0][i] = 0
            new[self.grid_size-1][i] = 0

        # 配列の更新(時間ステップを進める)
        self.previous = [row[:] for row in self.current]
        self.current = new

    def update_mesh(self):
        """メッシュの頂点位置と色を更新"""
        geom = self.water_mesh.modifyGeom(0)
        vdata = geom.modifyVertexData()

        vertex = GeomVertexWriter(vdata, 'vertex')
        color = GeomVertexWriter(vdata, 'color')

        for i in range(self.grid_size):
            for j in range(self.grid_size):
                x = i * self.spacing
                y = j * self.spacing
                z = self.current[i][j]
                vertex.setData3(x, y, z)  # 頂点位置を更新

                # 高さに応じた色の変化(対数スケールで微細な変化を強調)
                abs_z = abs(z)

                # 対数変換(微細な変化を強調)
                # log(1 + x) を使用することで、x=0 でも安全に計算可能
                # スケール係数を調整して適切な範囲にマッピング
                if abs_z < 0.001:
                    # 極めて小さい値は線形に扱う(対数の特異点を回避)
                    height_ratio = abs_z / 0.001 * 0.1
                else:
                    # 対数スケール: log(1 + 10*z) で微細な変化を強調
                    # 係数10は感度調整用(大きいほど微細な変化に敏感)
                    log_value = math.log(1.0 + 10.0 * abs_z)
                    # 正規化: log(1 + 10*5) ≈ 4.14 を基準に0~1の範囲へ
                    height_ratio = min(log_value / 4.14, 1.0)

                # 色の計算
                blue = 0.8 - height_ratio * 0.3
                green = 0.5 + height_ratio * 0.3
                color.setData4(0.2, green, blue, 1.0)

    def update(self, task):
        current_time = globalClock.getFrameTime()
        dt = current_time - self.prev_time
        self.prev_time = current_time

        # dtが0または極端に小さい場合はスキップ
        if dt < 0.0001:
            return Task.cont

        # 波動シミュレーションの更新
        self.update_wave(dt)
        self.update_mesh()  # メッシュを動的に更新

        return Task.cont

app = MyApp()
app.run()