Panda3Dゲームエンジン:システム構築の基礎(演習)

【概要】本教材はPanda3Dを用いて3次元コンピュータグラフィックスの主要機能を演習形式で学ぶ。シーン管理、衝突判定、物理シミュレーション、アニメーション制御、GUI制御、カメラ制御、入力処理、タスク管理、シャドウマッピング、サウンド制御、スプライト表示、パーティクル制御、シーン遷移の各テーマについて、予備知識と実行可能なコード例を示す。各演習は《手順》《ヒント》《考察ポイント》で構成する。

【動作環境】Windowsのパソコンで動作する。GPU搭載機・CPUのみの機のいずれでも動作する。Linux等の知識は不要である。

【演習の進め方】

  1. 各章のコードをファイルに保存し実行する
  2. 画面で動作を確認する
  3. 《ヒント》に示すパラメータを変更し、結果の違いを観察する
  4. 《考察ポイント》について自分の言葉でまとめる

【目次】

  1. 演習1. シーン管理
  2. 演習2. 衝突判定
  3. 演習3. 物理シミュレーション
  4. 演習4. アニメーション制御
  5. 演習5. GUI制御
  6. 演習6. カメラ制御
  7. 演習7. 入力処理
  8. 演習8. タスク管理
  9. 演習9. シャドウマッピング(影の描画)
  10. 演習10. サウンド制御
  11. 演習11. スプライト表示(2D画像の表示)
  12. 演習12. パーティクル制御
  13. 演習13. シーン遷移
  14. 演習14. 統合実装:ファーストパーソンビューのオープンワールド

事前準備:Pythonのインストール

本教材はPythonが動作する環境を前提とする。Pythonが未導入の場合は事前にインストールする。Panda3D 1.10系はPython 3.8〜3.13に対応するため、この範囲のPythonを用いる。

Panda3Dのインストール

すべての演習でPanda3Dを使用する。Panda3Dは3次元コンピュータグラフィックスのためのライブラリである。2026年5月時点の最新安定版は1.10.16である。インストールは1回だけ行えばよい。

手順:コマンドプロンプトを起動する(Windowsキー → 「cmd」と入力 → Enter)。起動したコマンドプロンプトに次を入力して実行する。

pip install panda3d

プログラムの実行

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

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

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

python a.py

全演習に共通する前提

コードを読み解く前に、全演習に共通する次の4点を理解しておく。これらは各演習で繰り返し登場する。

(1) 座標系。Panda3Dはz軸が上を向く座標系(Z-up)を用いる。各座標値の意味は次のとおりである。一般的な数学・グラフィックス教材ではy軸を上に取ることが多いが、Panda3Dではy軸が奥行きである。

たとえばsetPos(0, 10, 0)はカメラから奥に10だけ離れた位置を表す。

(2) インポート不要で使える変数。各演習のコードには、render(3D空間の最上位ノード)、loader(モデルや画像の読み込み役)、taskMgr(定期処理の管理役)、globalClock(経過時間を測る時計)、base(アプリ本体)といった変数が、インポートなしで登場する。これらはShowBaseクラスを継承した時点でPanda3Dが自動的にグローバル変数として用意するため、個別にimportする必要はない。Visual Studio Codeなどのエディタではこれらが「未定義」と警告されることがあるが、実行には支障がない。

(3) 同梱アセット(モデル・画像・音声)。コード中のmodels/box(立方体)、models/misc/sphere(球)、models/panda-model(パンダ)、models/maps/以下の画像、models/audio/以下の音声は、すべてPanda3Dのインストール時に同梱されるサンプルアセットである。別途ダウンロードは不要で、パスをそのまま指定すれば読み込める。

(4) プログラムの終了方法。実行したプログラムは、ウィンドウ右上の×ボタンで終了できる。これはすべての演習に共通する。加えて、演習7と演習14ではESCキーでも終了でき、演習5には終了用のExitボタンがある。

演習1. シーン管理

3D空間内のオブジェクトはシーングラフ(オブジェクトをツリー構造で管理する仕組み)で管理される。ツリーの各要素をノードと呼び、最上位のノードがrender(3D空間全体の親ノード)である。親ノードを動かすと子ノードも一緒に動く。本演習では、親子関係をもつ2つの立方体を配置し、その性質を観察する。

《手順》

  1. 下記コードを実行し、赤い立方体(親ノードに付随)と緑色の小さな立方体(子ノードに付随)が表示されることを確認する
  2. parent.setPos()の座標値を変更して再実行し、2つの立方体が一緒に移動することを確認する
  3. child.setPos()の座標値を変更して再実行し、子の立方体のみが移動することを確認する
from direct.showbase.ShowBase import ShowBase

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

        # 親ノードを作成し、立方体を取り付ける
        parent = render.attachNewNode("parent")
        parent.setPos(0, 10, 0)  # 親の絶対位置

        parent_model = loader.loadModel("models/box")
        parent_model.reparentTo(parent)
        parent_model.setTextureOff(1)
        parent_model.setColor(0.7, 0.3, 0.3, 1)  # 赤

        # 子ノードを親に取り付け、別の立方体を取り付ける
        child = parent.attachNewNode("child")
        child.setPos(2, 0, 0)  # 親からの相対位置

        child_model = loader.loadModel("models/box")
        child_model.reparentTo(child)
        child_model.setScale(0.5)
        child_model.setTextureOff(1)
        child_model.setColor(0.3, 0.7, 0.3, 1)  # 緑

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習2. 衝突判定

3D空間内で物体同士が接触したかを検出する仕組みを衝突判定という。Panda3Dでは衝突元(from、動いて当たる側)と衝突先(into、当てられる側)を区別して扱う。

《手順》

  1. 下記コードを実行し、緑の球が左右に動くことを確認する
  2. 球が赤い立方体の中心に近づき、不可視の球状判定領域が重なった瞬間、球が黄色に変わり、画面左上の表示が変化することを確認する
  3. 球の初期位置や移動範囲を変更し、衝突するタイミングが変わることを確認する
from direct.showbase.ShowBase import ShowBase
from panda3d.core import CollisionTraverser, CollisionHandlerQueue
from panda3d.core import CollisionNode, CollisionSphere
from direct.gui.OnscreenText import OnscreenText

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

        # 衝突検出の設定
        self.traverser = CollisionTraverser()
        self.handler = CollisionHandlerQueue()

        # 衝突情報表示
        self.collision_text = OnscreenText(
            text="Collision: None",
            pos=(-1.3, 0.9),
            scale=0.06,
            fg=(1, 1, 1, 1),
            align=0
        )

        # 球(衝突元 from)
        self.sphere_model = loader.loadModel("models/misc/sphere")
        self.sphere_model.reparentTo(render)
        self.sphere_model.setScale(0.5)
        self.sphere_model.setPos(-3, 10, 0)
        self.sphere_model.setTextureOff(1)
        self.sphere_model.setColor(0.3, 0.7, 0.3, 1)

        sphere_cnode = CollisionNode("sphere_from")
        sphere_cnode.addSolid(CollisionSphere(0, 0, 0, 1.0))
        self.sphere_cnp = self.sphere_model.attachNewNode(sphere_cnode)
        self.sphere_cnp.show()

        # 立方体(衝突先 into)
        self.box_model = loader.loadModel("models/box")
        self.box_model.reparentTo(render)
        self.box_model.setPos(3, 10, 0)
        self.box_model.setTextureOff(1)
        self.box_model.setColor(0.7, 0.3, 0.3, 1)

        box_cnode = CollisionNode("box_into")
        box_cnode.addSolid(CollisionSphere(0, 0, 0, 1.5))
        self.box_cnp = self.box_model.attachNewNode(box_cnode)
        self.box_cnp.show()

        # 衝突検出の登録(fromのみ)
        self.traverser.addCollider(self.sphere_cnp, self.handler)

        # 移動方向
        self.direction = 1

        taskMgr.add(self.update, "update_task")

    def update(self, task):
        dt = globalClock.getDt()

        # 球を左右に移動
        new_x = self.sphere_model.getX() + self.direction * 3 * dt
        if new_x > 3:
            self.direction = -1
        elif new_x < -3:
            self.direction = 1
        self.sphere_model.setX(new_x)

        # 衝突判定の実行
        self.traverser.traverse(render)

        # 衝突結果の確認
        if self.handler.getNumEntries() > 0:
            entry = self.handler.getEntry(0)
            self.collision_text.setText(f"Collision: {entry.getIntoNodePath().getName()}")
            self.sphere_model.setColor(1, 1, 0, 1)
        else:
            self.collision_text.setText("Collision: None")
            self.sphere_model.setColor(0.3, 0.7, 0.3, 1)

        return task.cont

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習3. 物理シミュレーション

重力や慣性を計算で再現する仕組みを物理シミュレーションといい、それを実行するライブラリを物理エンジンと呼ぶ。Panda3DはBullet Physics(オープンソースの物理エンジン)を利用し、剛体(変形しない物体)に対して重力下での落下や衝突を計算する。Bulletの機能はPanda3D本体に同梱されており、追加インストールは不要である。

《手順》

  1. 下記コードを実行し、赤い球が地面に落下する様子を確認する
  2. setGravityの値を変更し、落下速度の違いを確認する
  3. 球の初期位置(setPos)の高さを変更し、落下時間の違いを確認する
from direct.showbase.ShowBase import ShowBase
from panda3d.core import Vec3
from panda3d.bullet import BulletWorld, BulletSphereShape, BulletRigidBodyNode
from panda3d.bullet import BulletPlaneShape

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

        # 物理世界の初期化
        self.physics_world = BulletWorld()
        self.physics_world.setGravity(Vec3(0, 0, -9.81))

        # 地面の物理形状(無限平面、法線は上向き)
        ground_node = BulletRigidBodyNode("ground")
        ground_node.addShape(BulletPlaneShape(Vec3(0, 0, 1), 0))
        ground_np = render.attachNewNode(ground_node)
        ground_np.setPos(0, 0, -2)
        self.physics_world.attachRigidBody(ground_node)

        # 地面の可視化用モデル(物理面より少し下に置き、表示上の重なりを避ける)
        ground_model = loader.loadModel("models/box")
        ground_model.reparentTo(render)
        ground_model.setScale(10, 10, 0.1)
        ground_model.setPos(0, 10, -2.05)
        ground_model.setTextureOff(1)
        ground_model.setColor(0.4, 0.4, 0.4, 1)

        # 剛体球の作成
        body = BulletRigidBodyNode("sphere")
        body.addShape(BulletSphereShape(1.0))
        body.setMass(1.0)
        self.body_np = render.attachNewNode(body)
        self.body_np.setPos(0, 10, 5)
        self.physics_world.attachRigidBody(body)

        # 球の可視化用モデル
        sphere_model = loader.loadModel("models/misc/sphere")
        sphere_model.reparentTo(self.body_np)
        sphere_model.setTextureOff(1)
        sphere_model.setColor(0.7, 0.3, 0.3, 1)

        # カメラ位置の調整
        self.cam.setPos(0, -10, 5)
        self.cam.lookAt(0, 10, 0)

        # 物理シミュレーションの更新タスク
        taskMgr.add(self.update, "physics_update")

    def update(self, task):
        dt = globalClock.getDt()
        self.physics_world.doPhysics(dt)
        return task.cont

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習4. アニメーション制御

キャラクターを動かすには、あらかじめ作成された動作データ(アニメーション)を再生する。Actorクラス(アニメーション付きキャラクターを管理するクラス)がその管理を担う。

《手順》

  1. 下記コードを実行し、パンダのモデルが表示されることを確認する
  2. 「Walk」ボタンで歩行アニメーションが開始されることを確認する
  3. 「Stop」「Fast」ボタンで再生制御の違いを確認する
from direct.showbase.ShowBase import ShowBase
from direct.actor.Actor import Actor
from direct.gui.DirectGui import DirectButton

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

        # アニメーション付きモデルの読み込み
        # 第1引数:モデル本体、第2引数:アニメーション名とファイルの辞書
        self.panda = Actor("models/panda-model",
                           {"walk": "models/panda-walk4"})
        self.panda.reparentTo(render)
        self.panda.setScale(0.5)
        self.panda.setPos(0, 10, -1)

        # 操作ボタン
        DirectButton(text="Walk", pos=(-0.6, 0, 0.8),
                     scale=0.08, command=self.startWalk)
        DirectButton(text="Stop", pos=(0, 0, 0.8),
                     scale=0.08, command=self.stopWalk)
        DirectButton(text="Fast", pos=(0.6, 0, 0.8),
                     scale=0.08, command=self.fastWalk)

    def startWalk(self):
        self.panda.loop("walk")

    def stopWalk(self):
        self.panda.stop()

    def fastWalk(self):
        self.panda.setPlayRate(2.0, "walk")
        self.panda.loop("walk")

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習5. GUI制御

3D画面上に2DのUI(ユーザインタフェース:ボタン、テキストなどの操作・表示部品)を重ねて表示する。スコア表示やメニューに用いる。

《手順》

  1. 下記コードを実行し、画面左上にスコア、中央にボタンが表示されることを確認する
  2. 「Add Score」ボタンを押すたびにスコアが10ずつ増えることを確認する
  3. 「Exit」ボタンでプログラムが終了することを確認する
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
from direct.gui.DirectGui import DirectButton

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

        self.score = 0

        # テキスト表示
        self.score_text = OnscreenText(
            text="Score: 0",
            pos=(-1.3, 0.9),
            scale=0.07,
            fg=(1, 1, 1, 1),
            align=0
        )

        # スコア追加ボタン
        DirectButton(
            text="Add Score",
            pos=(0, 0, 0),
            scale=0.1,
            command=self.addScore
        )

        # 終了ボタン
        DirectButton(
            text="Exit",
            pos=(0, 0, -0.3),
            scale=0.1,
            command=self.userExit
        )

    def addScore(self):
        self.score += 10
        self.score_text.setText(f"Score: {self.score}")

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習6. カメラ制御

3D空間における視点の位置と向きを制御する。マウスで視点を回転させる一人称視点(プレイヤーの目線で世界を見る方式)を実現する。

《手順》

  1. 下記コードを実行し、複数の立方体が格子状に並ぶ風景が表示されることを確認する
  2. マウスを動かすと視点が回転し、画面左上の角度表示(H, P)が変化することを確認する
  3. 視野角setFovの値を30、60、120と変えて視界の違いを確認する
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
from panda3d.core import WindowProperties

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

        # カメラの初期設定
        self.cam.setPos(0, -20, 5)
        self.cam.lookAt(0, 0, 0)
        base.camLens.setFov(80)

        # 視点回転用の変数
        self.heading = 0
        self.pitch = 0
        self.sensitivity = 0.2

        # 情報表示
        self.info_text = OnscreenText(
            text="Camera Control Demo\nMove mouse to rotate view",
            pos=(-1.3, 0.9),
            scale=0.06,
            fg=(1, 1, 1, 1),
            align=0
        )

        self.angle_text = OnscreenText(
            text="H: 0.0, P: 0.0",
            pos=(-1.3, 0.75),
            scale=0.05,
            fg=(0.8, 0.8, 0.8, 1),
            align=0
        )

        # 参照用オブジェクトの配置
        for i in range(-2, 3):
            for j in range(-2, 3):
                cube = loader.loadModel("models/box")
                cube.reparentTo(render)
                cube.setPos(i * 3, j * 3, 0)
                cube.setScale(0.5)
                cube.setTextureOff(1)
                cube.setColor(0.3 + i * 0.1, 0.5, 0.3 + j * 0.1, 1)

        # マウスカーソルを中央に固定
        props = WindowProperties()
        props.setCursorHidden(True)
        props.setMouseMode(WindowProperties.M_relative)
        self.win.requestProperties(props)

        # 視点更新タスクの登録
        taskMgr.add(self.updateCamera, "camera_update")

    def updateCamera(self, task):
        if self.mouseWatcherNode.hasMouse():
            x = self.mouseWatcherNode.getMouseX()
            y = self.mouseWatcherNode.getMouseY()

            self.heading -= x * self.sensitivity * 100
            self.pitch += y * self.sensitivity * 100

            # ピッチの制限
            self.pitch = max(-89, min(89, self.pitch))

        # カメラの向きを更新
        self.cam.setHpr(self.heading, self.pitch, 0)

        # 角度情報の表示更新
        self.angle_text.setText(f"H: {self.heading:.1f}, P: {self.pitch:.1f}")

        return task.cont

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習7. 入力処理

キーボード入力を検出し、オブジェクトを操作する。キーの押下と解放を別々に検出することで、複数キーの同時押しに対応する。

《手順》

  1. 下記コードを実行し、緑色の立方体が地面(表示用の飾り)の付近に表示されることを確認する
  2. W/A/S/Dキーで立方体が上下左右に移動することを確認する
  3. スペースキーでジャンプすることを確認する。複数キーの同時押しも試す
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
import sys

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

        # キー状態の管理
        self.keys = {"w": False, "a": False, "s": False, "d": False, "space": False}

        # 情報表示
        self.info_text = OnscreenText(
            text="Input Control Demo\nW/A/S/D: Move  Space: Jump  ESC: Exit",
            pos=(-1.3, 0.9),
            scale=0.06,
            fg=(1, 1, 1, 1),
            align=0
        )

        self.status_text = OnscreenText(
            text="Keys: None",
            pos=(-1.3, 0.75),
            scale=0.05,
            fg=(0.8, 0.8, 0.8, 1),
            align=0
        )

        # 操作対象のオブジェクト
        self.player = loader.loadModel("models/box")
        self.player.reparentTo(render)
        self.player.setPos(0, 10, 0)
        self.player.setScale(0.5)
        self.player.setTextureOff(1)
        self.player.setColor(0.3, 0.7, 0.3, 1)

        # 地面(表示用。接地判定とは連動しない)
        ground = loader.loadModel("models/box")
        ground.reparentTo(render)
        ground.setScale(10, 10, 0.1)
        ground.setPos(0, 10, -1)
        ground.setTextureOff(1)
        ground.setColor(0.5, 0.5, 0.5, 1)

        # カメラ位置の調整
        self.cam.setPos(0, -5, 8)
        self.cam.lookAt(0, 10, 0)

        # キーイベントの登録
        self.accept("escape", sys.exit)
        for key in ["w", "a", "s", "d", "space"]:
            self.accept(key, self.updateKey, [key, True])
            self.accept(f"{key}-up", self.updateKey, [key, False])

        # ジャンプ関連の変数
        self.is_jumping = False
        self.jump_velocity = 0
        self.gravity = -20

        taskMgr.add(self.update, "update_task")

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

        # 押されているキーの表示
        pressed_keys = [k.upper() for k, v in self.keys.items() if v]
        if pressed_keys:
            self.status_text.setText(f"Keys: {', '.join(pressed_keys)}")
        else:
            self.status_text.setText("Keys: None")

    def update(self, task):
        dt = globalClock.getDt()
        speed = 5

        # 移動処理
        pos = self.player.getPos()
        if self.keys["w"]:
            pos.y += speed * dt
        if self.keys["s"]:
            pos.y -= speed * dt
        if self.keys["a"]:
            pos.x -= speed * dt
        if self.keys["d"]:
            pos.x += speed * dt

        # ジャンプ処理(z = 0 を着地面とみなす)
        if self.keys["space"] and not self.is_jumping and pos.z <= 0:
            self.is_jumping = True
            self.jump_velocity = 8

        if self.is_jumping:
            pos.z += self.jump_velocity * dt
            self.jump_velocity += self.gravity * dt
            if pos.z <= 0:
                pos.z = 0
                self.is_jumping = False
                self.jump_velocity = 0

        self.player.setPos(pos)

        return task.cont

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習8. タスク管理

ゲームは毎フレーム(画面の1回ごとの更新)、入力・状態更新・描画を繰り返す。Panda3DではtaskMgr(タスクマネージャ、定期処理の管理役)でこの定期処理を管理する。

《手順》

  1. 下記コードを実行し、青い立方体が回転することを確認する
  2. 画面上部の経過時間が増加することを確認する
  3. 回転速度を決める「50」の値を変更し、回転の速さが変わることを確認する
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText

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

        self.elapsed_time = 0.0

        # 経過時間表示
        self.time_text = OnscreenText(
            text="Time: 0.00",
            pos=(0, 0.9),
            scale=0.07,
            fg=(1, 1, 1, 1)
        )

        # 回転するオブジェクト
        self.cube = loader.loadModel("models/box")
        self.cube.reparentTo(render)
        self.cube.setPos(0, 10, 0)
        self.cube.setTextureOff(1)
        self.cube.setColor(0.3, 0.5, 0.8, 1)

        # 定期実行タスクの登録
        taskMgr.add(self.update, "UpdateTask")

    def update(self, task):
        dt = globalClock.getDt()

        # 経過時間の更新
        self.elapsed_time += dt
        self.time_text.setText(f"Time: {self.elapsed_time:.2f}")

        # オブジェクトを回転(1秒あたり50度)
        self.cube.setH(self.cube.getH() + 50 * dt)

        return task.cont

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習9. シャドウマッピング(影の描画)

3D空間に影をつけることで奥行きや立体感が向上する。シャドウマッピングは、光源視点で各点までの距離(深度)を画像(シャドウマップ)として記録し、それを用いて影を描画する手法である。光源には、空間全体を一様に照らす環境光と、太陽光のように一方向から照らす平行光がある。影は平行光のような方向のある光源から生じる。

《手順》

  1. 下記コードを実行し、赤い立方体が地面に影を落としていることを確認する
  2. 環境光alightの色値を(0.1, 0.1, 0.1)や(0.6, 0.6, 0.6)に変えて、影の濃さの違いを確認する
  3. 平行光dlnpsetHprの角度を変えて、影の方向が変わることを確認する
from direct.showbase.ShowBase import ShowBase
from panda3d.core import DirectionalLight, AmbientLight
from panda3d.core import Vec4

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

        # 平行光(影を生成)
        dlight = DirectionalLight("shadow")
        dlight.setColor(Vec4(1, 1, 1, 1))
        dlight.setShadowCaster(True, 2048, 2048)

        # 影が映る範囲(フィルムサイズ)と奥行きを調整
        lens = dlight.getLens()
        lens.setFilmSize(30, 30)
        lens.setNearFar(1, 100)

        dlnp = render.attachNewNode(dlight)
        dlnp.setPos(0, 10, 20)
        dlnp.setHpr(-30, -60, 0)
        render.setLight(dlnp)

        # 環境光
        alight = AmbientLight("ambient")
        alight.setColor(Vec4(0.3, 0.3, 0.3, 1))
        alnp = render.attachNewNode(alight)
        render.setLight(alnp)

        # シャドウ有効化
        render.setShaderAuto()

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

        # 影を落とす立方体
        cube = loader.loadModel("models/box")
        cube.reparentTo(render)
        cube.setPos(0, 10, 0)
        cube.setTextureOff(1)
        cube.setColor(0.8, 0.4, 0.4, 1)

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習10. サウンド制御

効果音やBGM(Background Music、背景音楽)を再生する。Panda3Dは効果音(loadSfx)とBGM(loadMusic)を区別して扱う。

《手順》

  1. 下記コードを実行し、3つのボタンとスライダーが表示されることを確認する
  2. 「Play Sound Effect」で効果音、「Play Music」でループ再生される音が出ることを確認する
  3. スライダーで音量変更、「Stop Music」で停止することを確認する
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
from direct.gui.DirectGui import DirectButton, DirectSlider

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

        # 説明テキスト
        self.info_text = OnscreenText(
            text="Sound Control Demo",
            pos=(0, 0.8),
            scale=0.08,
            fg=(1, 1, 1, 1)
        )

        # 状態テキスト
        self.status_text = OnscreenText(
            text="Status: Ready",
            pos=(0, 0.6),
            scale=0.06,
            fg=(0.8, 0.8, 0.8, 1)
        )

        # Panda3D同梱のサンプル音声を読み込み
        self.sound = loader.loadSfx("models/audio/sfx/GUI_rollover.wav")
        self.music = loader.loadMusic("models/audio/sfx/GUI_click.wav")

        # 音量の初期設定
        self.sound.setVolume(0.8)
        self.music.setVolume(0.5)
        self.music.setLoop(True)

        # 効果音再生ボタン
        DirectButton(
            text="Play Sound Effect",
            pos=(-0.6, 0, 0.2),
            scale=0.08,
            command=self.playSound
        )

        # BGM再生ボタン
        DirectButton(
            text="Play Music",
            pos=(0.6, 0, 0.2),
            scale=0.08,
            command=self.playMusic
        )

        # BGM停止ボタン
        DirectButton(
            text="Stop Music",
            pos=(0.6, 0, 0),
            scale=0.08,
            command=self.stopMusic
        )

        # 音量ラベル
        OnscreenText(
            text="Volume:",
            pos=(-0.3, -0.3),
            scale=0.06,
            fg=(1, 1, 1, 1)
        )

        # 音量スライダー
        self.volume_slider = DirectSlider(
            range=(0, 1),
            value=0.8,
            pageSize=0.1,
            pos=(0.3, 0, -0.3),
            scale=0.5,
            command=self.setVolume
        )

    def playSound(self):
        self.sound.play()
        self.status_text.setText("Status: Sound effect played")

    def playMusic(self):
        self.music.play()
        self.status_text.setText("Status: Music playing (loop)")

    def stopMusic(self):
        self.music.stop()
        self.status_text.setText("Status: Music stopped")

    def setVolume(self):
        volume = self.volume_slider['value']
        self.sound.setVolume(volume)
        self.music.setVolume(volume)
        self.status_text.setText(f"Status: Volume set to {volume:.2f}")

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習11. スプライト表示(2D画像の表示)

3D空間内に平面(カード)を置き、画像(テクスチャ、面に貼り付ける画像データ)を貼ることで2D表現を行う。これをスプライトという。アイコン、ロゴ、UI画像などに用いる。

《手順》

  1. 下記コードを実行し、画面に2枚の画像と、中央下に透過画像(背景が透けて見える画像)が表示されることを確認する
  2. setFrameの値を変更し、スプライトの大きさが変わることを確認する
  3. setPosの値を変更し、表示位置が変わることを確認する
from direct.showbase.ShowBase import ShowBase
from panda3d.core import CardMaker, TransparencyAttrib
from direct.gui.OnscreenText import OnscreenText

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

        # 説明テキスト
        OnscreenText(
            text="Sprite Demo with Texture",
            pos=(0, 0.9),
            scale=0.07,
            fg=(1, 1, 1, 1)
        )

        # 背景色
        base.setBackgroundColor(0.2, 0.2, 0.3)

        # スプライト1
        cm1 = CardMaker("sprite1")
        cm1.setFrame(-0.4, 0.4, -0.4, 0.4)
        sprite1 = render2d.attachNewNode(cm1.generate())
        sprite1.setPos(-0.5, 0, 0)
        sprite1.setTexture(loader.loadTexture("models/maps/envir-groundforest.jpg"))

        # スプライト2
        cm2 = CardMaker("sprite2")
        cm2.setFrame(-0.4, 0.4, -0.4, 0.4)
        sprite2 = render2d.attachNewNode(cm2.generate())
        sprite2.setPos(0.5, 0, 0)
        sprite2.setTexture(loader.loadTexture("models/maps/envir-mountain1.jpg"))

        # スプライト3(透過画像)
        cm3 = CardMaker("sprite3")
        cm3.setFrame(-0.2, 0.2, -0.2, 0.2)
        sprite3 = render2d.attachNewNode(cm3.generate())
        sprite3.setPos(0, 0, -0.5)
        sprite3.setTransparency(TransparencyAttrib.MAlpha)
        sprite3.setTexture(loader.loadTexture("models/maps/circle.rgba"))

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習12. パーティクル制御

多数の小さな粒子(パーティクル)で炎、煙、爆発などを表現する仕組みをパーティクルシステムという。

《手順》

  1. 下記コードを実行し、点の集合が放出される様子を確認する
  2. setPoolSize(粒子の総数)、setBirthRate(発生頻度)の値を変えて、表現の変化を観察する
  3. setStartColorsetEndColorで粒子の色変化を変更してみる
from direct.showbase.ShowBase import ShowBase
from direct.particles.ParticleEffect import ParticleEffect
from direct.particles.Particles import Particles
from direct.particles.ForceGroup import ForceGroup
from panda3d.physics import BaseParticleEmitter
from panda3d.physics import PointParticleFactory, PointParticleRenderer
from panda3d.physics import LinearVectorForce
from panda3d.core import Vec4

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

        # パーティクルシステム(内蔵物理エンジン)の有効化
        base.enableParticles()

        # パーティクルエフェクト
        self.p = ParticleEffect()

        # パーティクルの設定
        particles = Particles("particles")
        particles.setPoolSize(100)
        particles.setBirthRate(0.1)
        particles.setLitterSize(5)
        particles.setLitterSpread(2)
        particles.setLocalVelocityFlag(True)
        particles.setSystemGrowsOlderFlag(False)

        # エミッターの設定
        particles.setEmitter(BaseParticleEmitter.ETCUSTOM)
        emitter = particles.getEmitter()
        emitter.setEmissionType(BaseParticleEmitter.ETRADIATE)
        emitter.setAmplitude(1.0)
        emitter.setAmplitudeSpread(0.5)

        # レンダラーの設定
        renderer = PointParticleRenderer()
        renderer.setPointSize(5.0)
        renderer.setStartColor(Vec4(1, 0.5, 0, 1))
        renderer.setEndColor(Vec4(1, 0, 0, 0))
        particles.setRenderer(renderer)

        # ファクトリの設定
        particles.setFactory(PointParticleFactory())
        factory = particles.getFactory()
        factory.setLifespanBase(1.5)
        factory.setLifespanSpread(0.5)

        self.p.addParticles(particles)

        # 力の設定(下向きベクトル力を追加)
        forces = ForceGroup("forces")
        forces.addForce(LinearVectorForce(0, 0, -5))
        self.p.addForceGroup(forces)

        # パーティクルの開始
        self.p.start(parent=render)
        self.p.setPos(0, 10, 0)

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習13. シーン遷移

メニュー画面とゲーム画面のように、複数のシーンを切り替える仕組みを実装する。

《手順》

  1. 下記コードを実行し、最初に「Menu Scene」が表示されることを確認する
  2. 「Start Game」ボタンで緑色の立方体が表示されるゲームシーンに切り替わることを確認する
  3. 「Back to Menu」ボタンでメニューに戻れることを確認する
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
from direct.gui.DirectGui import DirectButton

class Scene:
    def __init__(self, app):
        self.app = app
        self.elements = []

    def setup(self):
        pass

    def cleanup(self):
        # DirectGuiウィジェットはdestroy()、純粋なNodePathはremoveNode()
        for element in self.elements:
            if hasattr(element, 'destroy'):
                element.destroy()
            else:
                element.removeNode()
        self.elements = []

class MenuScene(Scene):
    def setup(self):
        title = OnscreenText(
            text="Menu Scene",
            pos=(0, 0.5),
            scale=0.1,
            fg=(1, 1, 0, 1)
        )
        self.elements.append(title)

        start_button = DirectButton(
            text="Start Game",
            pos=(0, 0, 0),
            scale=0.1,
            command=lambda: self.app.changeScene(GameScene(self.app))
        )
        self.elements.append(start_button)

class GameScene(Scene):
    def setup(self):
        title = OnscreenText(
            text="Game Scene",
            pos=(0, 0.5),
            scale=0.1,
            fg=(0, 1, 0, 1)
        )
        self.elements.append(title)

        # ゲームオブジェクト
        cube = loader.loadModel("models/box")
        cube.reparentTo(render)
        cube.setPos(0, 10, 0)
        cube.setTextureOff(1)
        cube.setColor(0.3, 0.7, 0.3, 1)
        self.elements.append(cube)

        back_button = DirectButton(
            text="Back to Menu",
            pos=(0, 0, -0.3),
            scale=0.1,
            command=lambda: self.app.changeScene(MenuScene(self.app))
        )
        self.elements.append(back_button)

class MyApp(ShowBase):
    def __init__(self):
        ShowBase.__init__(self)
        self.currentScene = None

        # 初期シーン
        self.changeScene(MenuScene(self))

    def changeScene(self, newScene):
        if self.currentScene:
            self.currentScene.cleanup()
        self.currentScene = newScene
        self.currentScene.setup()

app = MyApp()
app.run()

《ヒント》

《考察ポイント》

演習14. 統合実装:ファーストパーソンビューのオープンワールド

これまでの演習で学んだ機能を統合し、マウスとキーボードで自由に歩き回れる3D空間を構築する。シーン管理(演習1)、カメラ制御(演習6)、入力処理(演習7)、タスク管理(演習8)、シャドウマッピング(演習9)を組み合わせる。

《手順》

  1. 下記コードを実行し、緑色の地面と4色の立方体が表示されることを確認する
  2. マウス移動で視点が回転し、W/A/S/Dキーで前後左右に移動できることを確認する
  3. スペースキーでカメラの前方に白い球が出現/消滅することを確認する
  4. 地面と立方体・球に影が落ちていることを確認する
from direct.showbase.ShowBase import ShowBase
from panda3d.core import Point3, Vec4
from panda3d.core import AmbientLight, DirectionalLight
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Geom, GeomTriangles, GeomNode
from panda3d.core import CardMaker, WindowProperties
from math import pi, sin, cos
import sys

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

        # マウスカーソルを非表示にする
        props = WindowProperties()
        props.setCursorHidden(True)
        self.win.requestProperties(props)

        # システムの初期化(依存関係に基づく順序)
        self.init_lighting()
        self.init_scene()
        self.init_camera_control()
        self.setup_keys()

        # 動的オブジェクト管理
        self.sphere = None

    def init_lighting(self):
        """照明システムの初期設定"""
        # 環境光
        ambient_light = AmbientLight("ambient")
        ambient_light.setColor(Vec4(0.2, 0.2, 0.2, 1))
        self.render.setLight(self.render.attachNewNode(ambient_light))

        # 平行光とシャドウマッピング
        directional_light = DirectionalLight("directional")
        directional_light.setColor(Vec4(1, 1, 1, 1))
        directional_light.setShadowCaster(True, 2048, 2048)

        # 影が映る範囲(フィルムサイズ)と奥行きを調整
        lens = directional_light.getLens()
        lens.setFilmSize(40, 40)
        lens.setNearFar(1, 200)

        light_np = self.render.attachNewNode(directional_light)
        light_np.setPos(20, -20, 40)
        light_np.lookAt(0, 0, 0)
        self.render.setLight(light_np)

        self.render.setShaderAuto()

    def init_scene(self):
        """シーンの初期設定"""
        # 地面
        cm = CardMaker("ground")
        cm.setFrame(-50, 50, -50, 50)
        ground = self.render.attachNewNode(cm.generate())
        ground.setP(-90)
        ground.setColor(0.2, 0.5, 0.2, 1)

        # 4つの立方体を配置
        cube_positions = [
            (Point3(-3, 3, 0.5), Vec4(1, 0, 0, 1)),
            (Point3(3, 3, 0.5), Vec4(0, 1, 0, 1)),
            (Point3(-3, -3, 0.5), Vec4(0, 0, 1, 1)),
            (Point3(3, -3, 0.5), Vec4(1, 1, 0, 1))
        ]
        for pos, color in cube_positions:
            cube = self.loader.loadModel("models/box")
            cube.reparentTo(self.render)
            cube.setPos(pos)
            cube.setColor(color)

    def init_camera_control(self):
        """カメラ制御の初期設定"""
        self.camera.setPos(0, -10, 3)
        self.camera.lookAt(0, 0, 0)
        self.camLens.setFov(80)

        self.heading = 0
        self.pitch = 0
        self.mouse_sensitivity = 0.2

        self.win.movePointer(0, self.win.getXSize() // 2, self.win.getYSize() // 2)

        self.taskMgr.add(self.update_camera, "UpdateCameraTask")
        self.taskMgr.add(self.move_camera, "MoveCameraTask")

    def setup_keys(self):
        """キー設定の初期化"""
        self.move_speed = 0.2
        self.keys = {"w": False, "a": False, "s": False, "d": False}

        self.accept("escape", sys.exit)
        self.accept("space", self.toggle_sphere)

        for key in ["w", "a", "s", "d"]:
            self.accept(key, self.update_key, [key, True])
            self.accept(f"{key}-up", self.update_key, [key, False])

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

    def move_camera(self, task):
        if self.keys["w"]:
            self.camera.setPos(self.camera, Point3(0, self.move_speed, 0))
        if self.keys["s"]:
            self.camera.setPos(self.camera, Point3(0, -self.move_speed, 0))
        if self.keys["a"]:
            self.camera.setPos(self.camera, Point3(-self.move_speed, 0, 0))
        if self.keys["d"]:
            self.camera.setPos(self.camera, Point3(self.move_speed, 0, 0))
        return task.cont

    def update_camera(self, task):
        if not self.mouseWatcherNode.hasMouse():
            return task.cont

        md = self.win.getPointer(0)
        x = md.getX()
        y = md.getY()
        center_x = self.win.getXSize() // 2
        center_y = self.win.getYSize() // 2

        if self.win.movePointer(0, center_x, center_y):
            self.heading -= (x - center_x) * self.mouse_sensitivity
            self.pitch -= (y - center_y) * self.mouse_sensitivity
            self.pitch = max(-90, min(90, self.pitch))
            self.camera.setHpr(self.heading, self.pitch, 0)

        return task.cont

    def toggle_sphere(self):
        """球の生成・消去"""
        if self.sphere is None:
            forward = self.camera.getQuat().getForward()
            pos = self.camera.getPos() + forward * 2
            sphere_node = self.create_sphere_mesh(radius=0.3, rings=16, segments=16)
            self.sphere = self.render.attachNewNode(sphere_node)
            self.sphere.setPos(pos)
        else:
            self.sphere.removeNode()
            self.sphere = None

    def create_sphere_mesh(self, radius=1, rings=16, segments=16):
        """球の頂点データを生成"""
        format = GeomVertexFormat.getV3n3c4()
        vdata = GeomVertexData('sphere', format, Geom.UHStatic)

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

        for ring in range(rings + 1):
            phi = ring * pi / rings
            for segment in range(segments + 1):
                theta = segment * 2.0 * pi / segments

                x = radius * sin(phi) * cos(theta)
                y = radius * sin(phi) * sin(theta)
                z = radius * cos(phi)

                vertex.addData3(x, y, z)
                normal.addData3(x/radius, y/radius, z/radius)
                color.addData4(1, 1, 1, 1)

        tris = GeomTriangles(Geom.UHStatic)
        for ring in range(rings):
            for segment in range(segments):
                i = ring * (segments + 1) + segment
                tris.addVertices(i, i + segments + 1, i + 1)
                tris.addVertices(i + 1, i + segments + 1, i + segments + 2)

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

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

        return node

app = OpenWorldGame()
app.run()

《ヒント》

《考察ポイント》

発展課題

初級:

中級:

上級: