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

【概要】

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

【前提知識】

本教材はPythonの基本文法(変数、関数、クラス、リスト、辞書)を理解していることを前提とする。実行にはPython 3.10以降が必要である。

【目次】

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

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



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


予備知識


ゲームループとフレーム

3Dゲームエンジンはゲームループと呼ばれる繰り返し処理によって動作する。ゲームループは、入力処理、状態更新、描画の3段階を毎秒数十回から数百回繰り返す。この1回の繰り返しをフレームと呼ぶ。1秒あたりのフレーム数をFPS(Frames Per Second、フレームレート)と呼び、一般的なゲームでは60fps(毎秒60フレーム)を目標とする。

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

デルタ時間(delta time、dt)は、前フレームから現フレームまでの経過時間(秒)である。実行環境によってフレームレートは変動するため、移動量や回転量に dt を掛けることで、環境に依らず一定速度の動きが得られる。これをフレームレート非依存(フレームレートが変動しても動きの速さが一定に保たれること)と呼ぶ。例えば、毎秒10度回転させたい場合は、毎フレーム「10 × dt」度ずつ回転させる。


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


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

前準備

Panda3Dのインストール

すべての演習はPanda3Dを使う。Panda3Dは3次元コンピュータグラフィックスのためのライブラリである。インストールは1回だけ行えばよい。波動シミュレーションではnumpy(数値計算用ライブラリ)も使用する。

手順:コマンドプロンプトを管理者権限で起動する(Windowsキー → 「cmd」と入力 → 右クリック → 「管理者として実行」)。起動したコマンドプロンプトに次を入力して実行する。

pip install panda3d numpy

プログラムの実行

各演習のコードを実行する方法は2通りある。どちらでも動作する。

方法A:Visual Studio Codeで実行する。Visual Studio Codeがインストール・設定済みであれば、本ページのコードを編集画面にコピー&ペーストし、そのまま実行する。

方法B:ファイルに保存して実行する。メモ帳などのテキストエディタを開き、本ページのコードを貼り付ける。a.pyのようなファイル名で保存する。コマンドプロンプトでそのファイルがあるフォルダに移動し、次を入力して実行する。

python a.py

Pythonプログラム例

オレンジ色の立方体を回転させるプログラムである。デルタ時間を使うことで、PCの性能に依らず一定速度で回転する。

【マウス操作】Panda3Dではデフォルトで次のマウス操作が使える。左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動。以降のPythonプログラム例でも、コードでカメラを動かしていない場合は同じマウス操作が使える。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task

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

        # 回転する立方体
        self.cube = self.loader.loadModel("models/box")  # 組み込みモデルの読み込み
        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)  # Heading(Z軸周りの回転)
        self.cube.setP(self.cube.getP() + 30 * dt)  # Pitch(X軸周りの回転)

        return Task.cont  # タスクを継続

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

Pythonプログラム例

緑色の地面の上に、色相環に基づく3色(赤・緑・青)の立方体を横一列に並べ、画面上部にテキストを表示するプログラムである。

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

def hsv_to_rgb(h, s, v):
    """HSV色空間からRGB色空間への変換関数"""
    return colorsys.hsv_to_rgb(h / 360.0, s, v)

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

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(20, 20, 0.1)
        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)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

app = MyApp()
app.run()

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

ポイント


演習1


問題

緑色の立方体を位置(0, 3, 2)に配置し、Z軸周り(Heading)に毎秒90度の速度で回転させるプログラム。回転はフレームレート非依存で実装すること。

《手順》

  1. 本節のPythonプログラム例(オレンジの立方体が回転するプログラム)を、方法AまたはBでコピーする。
  2. 立方体の位置・色・回転軸を、問題の指定に合わせて書き換える。
  3. プログラムを実行し、立方体が回転する様子を確認する。マウスで視点を変えて立体的に観察する。

《ヒント》

《考察ポイント》

Pythonプログラム例

緑色の立方体をZ軸周り(Heading)に回転させるプログラムである。

from direct.showbase.ShowBase import ShowBase
from direct.task import Task

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.setH(self.cube.getH() + 90 * dt)

        return Task.cont

app = MyApp()
app.run()

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


予備知識


Panda3Dの座標系

Panda3DはZ-up右手座標系(Z軸を上方向にとる右手系の座標系)を採用している。X軸が右、Y軸が前、Z軸が上である。3つの数値(x, y, z)で空間内の点を一意に指定できる。


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

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

3Dオブジェクトの配置と姿勢は、3つの基本変換で制御される。移動(translation)は位置の変更、回転(rotation)は向きの変更、スケール(scale)は大きさの変更である。スケールは1.0が元のサイズ、2.0で2倍、0.5で半分になる。

オイラー角(HPR)

オイラー角は、H(Heading:Z軸周り)、P(Pitch:X軸周り)、R(Roll:Y軸周り)の3つの角度で物体の向きを表す。object.setHpr(45, 0, 0)でZ軸周りに45度回転する。

Point3とVec3、ベクトル演算

Point3は3D空間内の位置(点)を、Vec3は方向と大きさを持つベクトルを表す。点にベクトルを加えると新しい点が得られ、点どうしの引き算でベクトルが得られる。ベクトルの長さは√(x² + y² + z²)で計算される。位置、速度、加速度の表現に用いる。

ワールド座標系とローカル座標系

ワールド座標系は空間全体の基準となる絶対座標、ローカル座標系は親オブジェクトを基準とする相対座標である。親が動けば子も一緒に動く。


Pythonプログラム例

立方体に回転とスケール変更、相対移動を順に適用するプログラムである。Z軸周り(Heading)に45度回転し、X方向に2倍の非均等スケール(軸ごとに異なる倍率の拡大縮小)を適用する。初期位置(0, 5, 1)からX方向に+2だけ相対移動し、最終位置は(2, 5, 1)となる。

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)

        # 回転の設定
        self.box.setH(45)  # Heading(Z軸周り)に45度回転

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

        # 相対移動(ベクトル演算)
        current_pos = self.box.getPos()
        self.box.setPos(current_pos + LVector3(2, 0, 0))

        # カメラ設定
        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)に配置され、Heading(Z軸周り)に45度回転し、X方向に2倍に拡大されて表示される。

ポイント


演習2


問題

位置(0, 10, 0)を中心として、半径5の円周上に5つの立方体を等間隔に配置せよ。各立方体の色は同一色、高さはすべてz=1とすること。

《手順》

  1. 新しいプログラムを作成し、mathモジュールをimportする。
  2. for文で5回繰り返し、ループ変数iから角度(72度刻み)を計算する。
  3. 三角関数で円周上の(x, y)座標を求め、立方体を配置する。
  4. プログラムを実行し、マウスで視点を回して円形の配置を確認する。

《ヒント》

《考察ポイント》

Pythonプログラム例

5つの立方体を72度間隔で円周上に配置するプログラムである。

from direct.showbase.ShowBase import ShowBase
import math

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

        radius = 5
        num_cubes = 5
        center_y = 10

        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)
            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)はカメラの位置、注視点(look-at point)はカメラが向く目標点である。この2点で視線方向が決まる。

ビューポートと一人称視点

ビューポートは3D空間が表示される画面領域である。一人称視点はキャラクターの目の位置にカメラを置く方式で、プレイヤー自身が空間内にいるような感覚を作る。

視野角(Field of View, FOV)

視野角はカメラが捉える視界の広さである。値が大きいほど広く見えるが歪みも大きくなる。一般的なゲームでは60度から90度に設定する。

クリッピング面

クリッピング面は描画範囲を定める面である。近接面(near plane:カメラに近い側の境界)と遠方面(far plane:カメラから遠い側の境界)の間にあるオブジェクトのみが描画される。


Pythonプログラム例

一人称視点でカメラを移動させるプログラムである。WASDキーで前後左右に移動する。複数キーの同時押しに対応するため、キー状態を辞書で管理する。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
import colorsys

def hsv_to_rgb(h, s, v):
    """HSV色空間からRGB色空間への変換関数"""
    return colorsys.hsv_to_rgb(h / 360.0, s, v)

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

        # オブジェクト配置(色相環に基づく配色)
        for i in range(5):
            cube = self.loader.loadModel("models/box")
            hue = i * 72
            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)

        # キー入力の設定
        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度の速度で回転すること。カメラの高さはz=5に固定する。

《手順》

  1. 新しいプログラムを作成し、青い立方体を位置(0, 0, 3)に配置する。
  2. 累積角度を保持する変数を用意し、update関数内で毎フレーム dt × 30 だけ加算する。
  3. cos/sinでカメラの(x, y)座標を計算し、setPos()でカメラを移動する。
  4. lookAt()でカメラを立方体に向ける。
  5. プログラムを実行し、立方体を中心にカメラが回り続けることを確認する。

《ヒント》

《考察ポイント》

Pythonプログラム例

カメラが半径10、毎秒30度で立方体の周りを円運動するプログラムである。

from direct.showbase.ShowBase import ShowBase
from direct.task import Task
import math

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

        self.disableMouse()

        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

        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()でモデルを読み込む。組み込みの基本形状として'models/box'(立方体)などがある。

色の表現

RGB色空間は赤(R)、緑(G)、青(B)の3成分で色を表す。各成分は0.0~1.0の範囲で指定する。(1, 0, 0)は赤、(0, 1, 0)は緑、(0, 0, 1)は青、(1, 1, 1)は白である。setColor()ではA(不透明度)を加えたRGBA形式で指定する。


Pythonプログラム例

テクスチャ付きの立方体、単色(オレンジ)の立方体、緑色の地面を配置するプログラムである。

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)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

app = MyApp()
app.run()

実行結果:2つの立方体と緑の地面が表示される。

ポイント


演習4


問題

3つの立方体を横一列に配置し、それぞれ赤、緑、青の純色を割り当てよ。3つすべてがZ軸周り(Heading)に毎秒60度の速度で回転するようにせよ。

《手順》

  1. 新しいプログラムを作成する。
  2. 3つの立方体を生成し、それぞれ赤・緑・青の色とX座標をずらした位置を設定する。
  3. 生成した立方体をリストに格納する。
  4. update関数内でリストの全要素に対して回転処理を適用する。
  5. プログラムを実行し、3つの立方体が同期して回転することを確認する。

《ヒント》

《考察ポイント》

Pythonプログラム例

3つの立方体を赤、緑、青に色付けし、同時に回転させるプログラムである。

from direct.showbase.ShowBase import ShowBase
from direct.task import Task

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. ライティングとシェーディング


予備知識


光源の種類

ライティングを設定すると、オブジェクトに陰影が付き立体感が生まれる。Panda3Dで主に使う光源は2種類ある。環境光(AmbientLight)は全方向から均一に照らす光で、影を作らない。指向性光源(DirectionalLight)は太陽光のように特定方向から平行に照らす光で、明確な明暗を作る。光源を設定しないとオブジェクトは平坦に見える。

HSV色空間と色相環

HSV色空間は色相(Hue:0~360度)、彩度(Saturation:色の鮮やかさ)、明度(Value:色の明るさ)の3成分で色を表す。色相環は色相を円環状に配置したもので、等間隔に色を割り当てる際に便利である。例えば5つのオブジェクトに異なる色を割り当てる場合、色相を72度(360÷5)ずつずらす。


Pythonプログラム例

色相環に基づく5色の立方体を、環境光と指向性光源で照明するプログラムである。ライティングにより各面に明暗が付き、立体感が現れる。

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

def hsv_to_rgb(h, s, v):
    return colorsys.hsv_to_rgb(h / 360.0, s, v)

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
            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)
        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つ垂直に積み上げよ。立方体どうしが接触するように配置すること。

《手順》

  1. 新しいプログラムを作成する。
  2. 環境光と指向性光源を生成し、render.setLight()で適用する。
  3. for文で5つの立方体を生成し、Z座標を2.0ずつ加算して垂直に積み上げる。
  4. プログラムを実行し、各立方体の面に明暗が現れることを確認する。

《ヒント》

《考察ポイント》

Pythonプログラム例

白い立方体を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)
            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)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

app = MyApp()
app.run()

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


予備知識


シーングラフと階層構造

シーングラフ(Scene Graph)は3D空間内のオブジェクトを階層的に管理するツリー構造である。各オブジェクトはノードとして表現され、親子関係で結ばれる。子は親の変換(移動・回転・スケール)の影響を受ける。これを変換の伝播(親に適用した変換が子にも自動的に及ぶこと)と呼ぶ。階層構造を使うと、車体と車輪のような複数パーツからなる物体を、親を動かすだけで一体として動かせる。

NodePath

NodePathはシーングラフ内のノードを参照する仕組みである。attachNewNode()で新しいノードを追加し、reparentTo()で親子関係を設定する。renderは最上位ノードである。


階層構造の例:

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

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

Pythonプログラム例

親子関係を持つ車オブジェクトを作り、前方に移動させるプログラムである。親ノードを移動させると、車体と車輪がすべて一緒に移動する。

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)

        # 車体本体
        body = self.loader.loadModel("models/box")
        body.setScale(2, 3, 1)
        body.setColor(1, 0, 0, 1)
        body.setTextureOff(1)
        body.reparentTo(self.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)

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -15, 5)
        self.camera.lookAt(self.vehicle)
        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秒とすること。

《手順》

  1. 新しいプログラムを作成する。
  2. 太陽用、地球用、月用の3つの空ノード(NodePath)を作り、太陽→地球→月の順で親子関係を設定する。
  3. 各天体の立方体を対応する親ノードの子として配置する(地球と月は親ノードからオフセットさせる)。
  4. update関数内で地球の親ノードと月の親ノードを、それぞれ毎秒36度・毎秒120度で回転させる。
  5. プログラムを実行し、月が地球と一緒に公転しながら、さらに地球の周りも回ることを確認する。

《ヒント》

《考察ポイント》

Pythonプログラム例

太陽・地球・月の階層的な天体運動システムである。階層構造により、月は地球の公転運動を継承しながら自身の公転も行う。

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)
        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)
        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)
        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

        self.earth_system.setH(self.earth_system.getH() + 36 * dt)
        self.moon_system.setH(self.moon_system.getH() + 120 * dt)

        return Task.cont

app = MyApp()
app.run()

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


予備知識


イベント駆動とキー入力

Panda3Dではキー入力をイベントとして受け取り、登録した関数で処理する。これをイベント駆動(入力などの出来事の発生に応じて処理を呼び出す方式)と呼び、accept()でイベントと関数を結びつける。継続的な入力(移動など)は、押下/離上で辞書のフラグを切り替える「キー状態管理方式」が適している。単発の入力(ジャンプなど)はaccept()で直接関数を呼ぶ方式が適している。


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

Pythonプログラム例

プレイヤー操作とジャンプを実装したプログラムである。WASDキーで水平移動、スペースキーでジャンプする。重力により放物線を描いて着地する。

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)

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(0, -15, 5)
        self.camera.lookAt(self.player)
        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


問題

矢印キーで立方体を移動させ、Enterキーを押すと立方体の色がランダムに変わるプログラム。移動速度は毎秒3単位とする。

《手順》

  1. 新しいプログラムを作成し、randomモジュールをimportする。
  2. 矢印キー4方向についてキー状態管理用の辞書とaccept()を設定する。
  3. Enterキーには色をランダムに変更する関数を登録する。
  4. update関数でキー状態に応じて立方体を移動させる。
  5. プログラムを実行し、矢印キーで移動、Enterキーで色が変わることを確認する。

《ヒント》

《考察ポイント》

Pythonプログラム例

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)

        self.disableMouse()
        self.camera.setPos(0, -15, 8)
        self.camera.lookAt(self.cube)
        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関数は-1から1の間を周期的に変化するため、上下運動や拡大縮小に適している。

物理演算と運動方程式

物理演算はニュートンの運動法則に基づく。速度は移動の速さと方向、加速度は速度の変化率である。地表の重力加速度は約-9.8 m/s²(下向き)。シミュレーションでは毎フレーム、加速度を速度に加算し(velocity += acceleration × dt)、速度を位置に加算する(position += velocity × dt)。

衝突判定と反発

地面に衝突したら位置を地面の高さに補正し、速度のZ成分を反転させる。反発係数は跳ね返りの強さを示し、0.0で跳ねず、1.0でエネルギー損失なし(衝突前後で速さが変わらない)となる。なお、dtが大きすぎると計算が不安定になるため、上限を設けることが望ましい。


Pythonプログラム例(アニメーション)

3種類のアニメーションを並べたプログラムである。赤い立方体は連続回転、青い立方体はsin関数による上下動、緑の立方体はsin関数による拡大縮小を行う。

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)
        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)
        z_pos = 1 + math.sin(self.elapsed_time * 3) * 0.5
        self.bouncing_cube.setZ(z_pos)
        scale_factor = 1 + math.sin(self.elapsed_time * 2) * 0.3
        self.scaling_cube.setScale(1, 1, scale_factor)

        return Task.cont

app = MyApp()
app.run()

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

Pythonプログラム例(物理演算)

5つの立方体の自由落下と反発を実装したプログラムである。重力で加速しながら地面に到達し、反発係数0.5で跳ね返り、徐々に静止する。


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

def hsv_to_rgb(h, s, v):
    return colorsys.hsv_to_rgb(h / 360.0, s, v)

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)

        # 落下する箱
        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()

実行結果:5つの箱が落下し、地面でバウンドして最終的に停止する。

ポイント


演習8


問題

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

《手順》

  1. 新しいプログラムを作成する。
  2. 立方体を位置(0, 0, 10)に配置し、初速度をLVector3(0, 5, 0)で保持する。
  3. update関数で、速度のZ成分に重力×dtを加算し、位置に速度×dtを加算する。
  4. 立方体のZ座標が0以下になったら、Z成分の速度を-0.7倍にして反発を実装する。
  5. プログラムを実行し、放物線を描きながらバウンドする動きを確認する。

《ヒント》

《考察ポイント》

Pythonプログラム例

放物運動と地面反発を実装したプログラムである。

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)

        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

        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表示を統合することで、操作可能な3D体験が完成する。


Pythonプログラム例

アイテム収集ゲームの基本構造を示すプログラムである。WASDキーでプレイヤーを操作し、ランダムに配置された金色の立方体を収集する。プレイヤーとアイテム間の距離で衝突判定し、収集するとスコアが加算される。全アイテム収集でクリアとなる。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import TextNode
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)
            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)

        # スコア表示
        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)

        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.2:
                item.removeNode()
                self.collectibles.remove(item)
                self.score += 10
                self.score_text.setText(f'Score: {self.score}')

        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


問題

タイマー付きの収集ゲーム。30秒以内に5つの赤い立方体を全て収集すればクリア、時間切れでゲームオーバーとなる。画面左上にスコア、右上に残り時間を表示すること。移動速度は毎秒7単位とする。

《手順》

  1. 本節のPythonプログラム例(アイテム収集ゲーム)をコピーする。
  2. アイテム数を5個、色を赤に変更し、移動速度を7に設定する。
  3. 制限時間(30秒)と経過時間を保持する変数を追加し、右上に残り時間を表示するOnscreenTextを作る。
  4. update関数で経過時間を累積し、残り時間を毎フレーム更新する。
  5. 全アイテム収集でクリア表示、残り時間0以下でゲームオーバー表示となる条件分岐を実装する。
  6. プログラムを実行し、クリアとゲームオーバーの両方を試す。

《ヒント》

《考察ポイント》

Pythonプログラム例

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. 波動シミュレーション(水面の波)


予備知識


波動方程式と有限差分法

波動方程式は波の伝播を記述する偏微分方程式である。有限差分法は、連続的な空間を格子点で離散化し、微分を差分(隣接点との値の差)で近似して数値的に解く手法である。これにより、水面の波紋の広がりをコンピュータで計算できる。

シミュレーションの要素

水面を格子状のグリッドで表し、各格子点の高さを毎フレーム更新する。隣接する格子点との高低差から次の高さが決まり、結果として波が伝播する。減衰(時間経過とともに振幅が小さくなる効果)を加えると波は時間とともに小さくなる。境界条件はグリッド端での波の振る舞い(反射するか吸収するかなど)を決める。グリッドが大きいほど精密だが計算負荷も増す。


Pythonプログラム例

波動方程式を有限差分法で解き、水面の波を描画するプログラムである。50×50の格子点でメッシュを構成し、初期状態として中央に山(突起)を作る。実行すると、その山から波紋が広がっていく様子を観察できる。減衰により波は徐々に収まる。

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 numpy as np
import math

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

        self.grid_size = 50
        self.spacing = 0.5

        self.current = np.zeros((self.grid_size, self.grid_size))
        self.previous = np.zeros((self.grid_size, self.grid_size))

        self.wave_speed = 0.5
        self.damping = 0.99

        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)

        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 = min(dt, 0.1)

        c_squared = self.wave_speed * self.wave_speed
        spacing_squared = self.spacing * self.spacing

        laplacian = (
            np.roll(self.current, 1, axis=0) + np.roll(self.current, -1, axis=0) +
            np.roll(self.current, 1, axis=1) + np.roll(self.current, -1, axis=1) -
            4 * self.current
        ) / spacing_squared

        acceleration = c_squared * laplacian
        velocity = (self.current - self.previous) / dt
        velocity *= self.damping
        new = self.current + velocity * dt + acceleration * dt * dt

        new[0, :] = 0
        new[-1, :] = 0
        new[:, 0] = 0
        new[:, -1] = 0

        self.previous = self.current.copy()
        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)
                if abs_z < 0.001:
                    height_ratio = abs_z / 0.001 * 0.1
                else:
                    log_value = math.log(1.0 + 10.0 * abs_z)
                    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

        if dt < 0.0001:
            return Task.cont

        self.update_wave(dt)
        self.update_mesh()

        return Task.cont

app = MyApp()
app.run()

実行結果:中央から波紋が広がり、端で反射する。波は時間とともに減衰する。

ポイント

Pythonプログラム例(Ctrl+マウスクリックによる波の生成)

上記のプログラムにマウス操作による波生成機能を追加した版である。Ctrl+マウスクリックで任意の位置に新たな波を生成できる。クリック位置から3次元空間に光線(CollisionRay:レイキャストに用いる仮想的な半直線)を飛ばし、水面との交点に対応する格子点の高さを上げることで波を発生させる。

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 BitMask32
import math

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

        self.grid_size = 50
        self.spacing = 0.5

        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

        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()
        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):
        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

    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)

        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 = 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):
                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)

                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

        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)
                if abs_z < 0.001:
                    height_ratio = abs_z / 0.001 * 0.1
                else:
                    log_value = math.log(1.0 + 10.0 * abs_z)
                    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

        if dt < 0.0001:
            return Task.cont

        self.update_wave(dt)
        self.update_mesh()

        return Task.cont

app = MyApp()
app.run()