Panda3Dゲームエンジン中級編:システム構築の基礎

【概要】Panda3Dを用いた3Dゲーム開発の実践的機能を解説する。シーン管理、衝突判定、物理シミュレーション、アニメーション制御、GUI制御、カメラ制御、入力処理、タスク管理、シャドウマッピング、サウンド制御、スプライト表示、パーティクル制御、シーン遷移を扱う。各項目では予備知識として関連する概念や用語を説明し、続いて実行可能なコード例を提示している。

【目次】

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

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

1. シーン管理の基本

予備知識

メッシュ(Mesh)

3Dモデルを構成する頂点、辺、面の集合である。ポリゴン(3つ以上の頂点で構成される面)の集合によって形状を表現する。

頂点(Vertex)

3D空間内の点を表す要素であり、位置座標(x,y,z)を持つ。ポリゴンを構成する基本単位であり、3Dモデルの形状を定義する。

ポリゴン(Polygon)

3つ以上の頂点で構成される面であり、3Dモデルの表面を形成する。基本的なポリゴンは三角形である。

3次元座標系

互いに垂直な3つの座標軸であるXYZ軸による座標系で、3D空間内の位置を表現する。X軸は左右、Y軸は前後、Z軸は上下を表す。空間全体の基準となるワールド座標系とオブジェクトごとの基準となるローカル座標系がある。

シーングラフ(Scene Graph)

3D空間内のオブジェクトの階層構造を管理するツリー構造である。親の移動が子に影響し、子の移動は親に影響しない特性を持つ。render.attachNewNode("parent")で親ノードを作成する。

NodePath

シーングラフ内のノードを参照するためのオブジェクトである。attachNewNode()で階層構造を構築し、reparentTo()でノードの親子関係を設定する。renderは最上位ノードとなる。

階層構造の概念

オブジェクト間の親子関係を管理する仕組みである。親オブジェクトの変形は子オブジェクトに影響する。例えば、車体(親)とタイヤ(子)、腕(親)と手(子)の関係がある。

以下のコードでは、ノードの階層構造を構築し、3Dモデルをシーンに配置する。attachNewNode()による階層作成とreparentTo()による親子関係の設定を示している。

from direct.showbase.ShowBase import ShowBase

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

        # ノードの階層構造
        parent = render.attachNewNode("parent")
        child = parent.attachNewNode("child")
        child.setPos(1, 0, 0)  # 親からの相対位置

        # モデルの読み込みと配置
        model = loader.loadModel("models/box")
        model.reparentTo(render)
        model.setTextureOff(1)  # テクスチャを強制的にオフ
        model.setColor(0.7, 0.7, 0.7, 1)  # グレー色を設定
        model.setPos(0, 5, 0)  # カメラから見える位置に配置

app = MyApp()
app.run()

シーン管理のポイント

2. 衝突判定の基本

予備知識

境界球(バウンディングスフィア)と境界ボックス(バウンディングボックス)

オブジェクトを包む単純な形状で近似する手法である。境界球は中心と半径で定義され、境界ボックスは中心と各軸方向の長さで定義される。計算コストの削減を可能にする。

衝突検出の仕組み

Panda3Dの衝突検出では、衝突元(from)と衝突先(into)を区別する。fromオブジェクトがintoオブジェクトに衝突したかを判定する。CollisionTraverserに登録するのはfromオブジェクトのみである。

以下のコードでは、衝突検出システムの基本設定を行う。CollisionTraverserによる検出管理、CollisionHandlerQueueによる結果管理、CollisionNodeによる衝突形状の定義を実装している。

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
        )

        # 1つ目のオブジェクト(球)- 衝突元(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)

        # 球の衝突形状(from)
        sphere_coll = CollisionSphere(0, 0, 0, 1.0)
        sphere_cnode = CollisionNode("sphere_from")
        sphere_cnode.addSolid(sphere_coll)
        self.sphere_cnp = self.sphere_model.attachNewNode(sphere_cnode)
        self.sphere_cnp.show()

        # 2つ目のオブジェクト(ボックス)- 衝突先(into)
        self.box_model = loader.loadModel("models/box")
        self.box_model.reparentTo(render)
        self.box_model.setScale(1.0)
        self.box_model.setPos(3, 10, 0)
        self.box_model.setTextureOff(1)
        self.box_model.setColor(0.7, 0.3, 0.3, 1)

        # ボックスの衝突形状(into)
        box_coll = CollisionSphere(0, 0, 0, 1.5)
        box_cnode = CollisionNode("box_into")
        box_cnode.addSolid(box_coll)
        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()

        # 球を左右に移動
        current_x = self.sphere_model.getX()
        new_x = current_x + 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:
            self.handler.sortEntries()
            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. 物理シミュレーションの基本

予備知識

運動方程式の基本

F=ma(力=質量×加速度)を基本とするニュートンの運動法則である。力、質量、加速度の関係を定義し、物体の動きを計算する基礎となる。

剛体力学の基本

変形しない物体の運動を扱う力学の分野である。質量と重心位置が主要なパラメータである。衝突や接触による力の伝達を計算する。

BulletWorld

Panda3Dの物理エンジンであるBullet Physicsの物理空間を管理するクラスである。重力の設定、剛体の登録、物理演算の実行を行う。doPhysics()を毎フレーム呼び出して物理演算を更新する。

以下のコードでは、物理シミュレーションの基本設定を行う。BulletWorldによる物理空間の管理、重力の設定、剛体の作成と質量設定を実装している。

from direct.showbase.ShowBase import ShowBase
from panda3d.core import Vec3
from panda3d.bullet import BulletWorld, BulletSphereShape, BulletRigidBodyNode
from panda3d.bullet import BulletPlaneShape, BulletBoxShape

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

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

        # 地面の物理形状
        ground_shape = BulletPlaneShape(Vec3(0, 0, 1), 0)
        ground_node = BulletRigidBodyNode("ground")
        ground_node.addShape(ground_shape)
        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)

        # 剛体球の作成
        shape = BulletSphereShape(1.0)
        body = BulletRigidBodyNode("sphere")
        body.addShape(shape)
        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クラス

Panda3Dでアニメーション付きモデルを管理するクラスである。モデルファイルとアニメーションファイルを読み込み、再生制御を行う。loop()で繰り返し再生、play()で単発再生、stop()で停止を制御する。

以下のコードでは、アニメーション付きモデルの基本的な制御を実装する。Actorクラスによるモデル読み込み、アニメーションの再生と停止、再生速度の調整を示している。

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)

        # アニメーション付きモデルの読み込み
        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制御の基本

予備知識

2D座標系

ウィンドウ上の位置を(x,y)で表現するシステムである。Panda3Dのaspect2d座標系では、原点は画面中央に配置され、x軸は右方向、y軸は上方向が正である。

ウィジェット

ボタン、テキスト、スライダーなどのUI部品の総称である。階層構造で管理され、親子関係により位置や表示状態が制御される。

以下のコードでは、基本的なGUI要素を画面上に配置する。OnscreenTextによるテキスト表示、DirectButtonによるクリック可能なボタンの作成、位置とサイズの制御を実装している。

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  # 左揃え
        )

        # ボタンの作成
        self.start_button = DirectButton(
            text="Add Score",
            pos=(0, 0, 0),
            scale=0.1,
            command=self.addScore
        )

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

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

    def exitGame(self):
        self.userExit()

app = MyApp()
app.run()

GUI制御のポイント

6. カメラ制御の基本

予備知識

カメラ(Camera)

3D空間内の視点を表現するオブジェクトである。位置、向き、視野角などのパラメータを持つ。setPos()で位置を設定し、lookAt()で注視点を設定する。

視点と注視点

視点は観察者(カメラ)の位置を表し、注視点は見つめる対象の位置を表す。この2点によって視線方向が決定される。

視野角(Field of View, FOV)

カメラが捉える視界の広さを角度で表現するパラメータである。値が大きいほど広い範囲が見えるが画面の歪みも大きくなる。一般的なゲームでは60度から90度の範囲で設定される。

オイラー角

ヨー(水平回転)、ピッチ(縦回転)、ロール(傾き)という3つの角度で物体の向きを表現する方法である。setHpr()メソッドで向きを設定する。

以下のコードでは、カメラの基本的な制御を実装する。setPos()による位置設定、lookAt()による注視点指定、setFov()による視野角設定、マウスによる視点回転を示している。

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)

        # マウス移動の検出
        self.accept("mouse1", self.onMouseMove)
        taskMgr.add(self.updateCamera, "camera_update")

    def onMouseMove(self):
        # マウスの相対移動を取得
        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))

    def updateCamera(self, task):
        # マウスの相対移動を取得
        if self.mouseWatcherNode.hasMouse():
            x = self.mouseWatcherNode.getMouseX()
            y = self.mouseWatcherNode.getMouseY()

            if abs(x) > 0.001 or abs(y) > 0.001:
                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. 入力処理の基本

予備知識

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

キー入力やマウス操作をイベントとして検出する手法である。コールバック関数(イベント発生時に実行される関数)で対応する処理を実行する。

キーボード/マウス入力

ユーザーからの操作を検出し処理する仕組みである。キーの押下、保持、解放を区別する。accept()メソッドでイベントを登録する。

入力状態管理

現在の入力状態をフラグや数値で保持する仕組みである。キーの同時押しにも対応できる。dict型で状態を保持する。

以下のコードでは、基本的な入力処理を実装する。accept()によるキーイベントの登録、キー状態の管理、キー入力に応じたオブジェクトの移動制御を示している。

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)

        # W/A/S/Dキーの押下と解放
        self.accept("w", self.updateKey, ["w", True])
        self.accept("w-up", self.updateKey, ["w", False])
        self.accept("a", self.updateKey, ["a", True])
        self.accept("a-up", self.updateKey, ["a", False])
        self.accept("s", self.updateKey, ["s", True])
        self.accept("s-up", self.updateKey, ["s", False])
        self.accept("d", self.updateKey, ["d", True])
        self.accept("d-up", self.updateKey, ["d", False])

        # スペースキー
        self.accept("space", self.updateKey, ["space", True])
        self.accept("space-up", self.updateKey, ["space", 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

        # ジャンプ処理
        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秒あたりの画面更新回数を表すパラメータである。60FPS(1秒間に60回の更新)が標準的である。処理負荷による変動を考慮し、デルタ時間(前フレームからの経過時間)で動きを調整する。

タスク管理

定期実行や遅延実行の管理を行う機能である。taskMgr.add()で定期実行タスクを登録し、globalClock.getDt()でフレーム間の経過時間を取得する。

以下のコードでは、基本的なタスク管理システムを構築する。taskMgr.add()による定期実行タスクの登録、デルタ時間の取得、ゲーム状態の更新処理を実装している。

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.updateGame(dt)
        return task.cont

    def updateGame(self, dt):
        self.elapsed_time += dt
        self.time_text.setText(f"Time: {self.elapsed_time:.2f}")

        # オブジェクトを回転
        self.cube.setH(self.cube.getH() + 50 * dt)

app = MyApp()
app.run()

タスク管理のポイント

9. シャドウマッピングの基本

予備知識

光源の種類

環境光(AmbientLight)は空間全体を均一に照らす光源である。平行光(DirectionalLight)は太陽光のような平行光線を生成し、影を落とすことができる。

シャドウマップの原理

光源から見たシーンの深度情報を記録する手法である。setShadowCaster(True, 2048, 2048)で2048×2048の解像度で影を生成する。setShaderAuto()で影の描画を有効化する。

以下のコードでは、基本的なシャドウマッピングシステムを構築する。光源からのシャドウマップの生成、解像度の設定、シャドウマッピングの有効化を実装している。

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)
        dlnp = render.attachNewNode(dlight)
        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. サウンド制御の基本

予備知識

音声ファイル形式

WAV(非圧縮音声形式)、OGG(圧縮形式)、MP3(圧縮形式)などの形式が存在する。効果音は高品質WAV、BGMは圧縮形式が一般的である。

効果音とBGM

効果音はloadSfx()で読み込み、BGMはloadMusic()で読み込む。setLoop()でループ再生を設定し、setVolume()で音量を調整する。

以下のコードでは、効果音とBGMの基本的な制御を実装する。Panda3Dに同梱されているサンプル音声ファイルを使用し、音量設定、再生制御、ループ設定を示している。

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 = base.loader.loadSfx("models/audio/sfx/GUI_rollover.wav")
        self.music = base.loader.loadMusic("models/audio/sfx/GUI_click.wav")

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

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

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

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

        # 音量ラベル
        self.volume_label = 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画像を画面上に表示する技術である。CardMakerで平面を生成し、テクスチャを適用する。render2dに配置することで2D表示を実現する。

アルファブレンディング

透明度による重ね合わせ処理を行う技術である。setTransparency()で透明度処理を有効化する。

以下のコードでは、2D画像をスプライトとして表示する。CardMakerによる平面生成、テクスチャの読み込みと適用を実装している。

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)

        # 説明テキスト
        self.info_text = 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)

        # Panda3D同梱のテクスチャを適用
        texture1 = loader.loadTexture("models/maps/envir-groundforest.jpg")
        sprite1.setTexture(texture1)

        # スプライト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)

        # 別のサンプルテクスチャを適用
        texture2 = loader.loadTexture("models/maps/envir-mountain1.jpg")
        sprite2.setTexture(texture2)

        # 透明度対応のスプライト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)

        # アルファ付きテクスチャ(同梱サンプル)
        texture3 = loader.loadTexture("models/maps/circle.rgba")
        sprite3.setTexture(texture3)

app = MyApp()
app.run()

スプライト表示のポイント

12. パーティクル制御の基本

予備知識

パーティクルシステム

多数の小さな粒子による視覚効果を制御するシステムである。エミッター(粒子発生装置)から粒子を生成し、物理演算で動きを制御する。炎、煙、魔法などの表現が可能である。

エミッターとパーティクル

エミッターは粒子の発生源となるオブジェクトである。放出角度、速度、頻度を制御する。パーティクルは位置、速度、寿命、色、サイズなどの属性を持つ。

以下のコードでは、パーティクルシステムの基本設定を行う。ParticleEffectによるエフェクト管理、パーティクル設定、エフェクトの開始を実装している。

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, BaseParticleRenderer
from panda3d.physics import PointParticleFactory, PointParticleRenderer
from panda3d.physics import LinearNoiseForce
from panda3d.core import Vec3, 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")
        gravity = LinearNoiseForce(0, 0, -5, 1)
        forces.addForce(gravity)
        self.p.addForceGroup(forces)

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

app = MyApp()
app.run()

パーティクル制御のポイント

13. シーン遷移の基本

予備知識

ゲーム状態管理

メニュー、プレイ中、ポーズなどの状態遷移を制御する仕組みである。各状態での入力処理や描画処理を管理する。

リソース管理

テクスチャ、モデル、サウンドなどのアセットを管理する仕組みである。シーン切り替え時の読み込みと解放を制御する。

以下のコードでは、シーン遷移の基本的な制御を実装する。現在のシーンのクリーンアップ、新しいシーンの初期化、リソースの解放を示している。

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):
        for element in self.elements:
            element.removeNode()
        self.elements = []

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

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

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

        # ゲームオブジェクト
        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.7, 0.3, 1)
        self.elements.append(self.cube)

        self.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(self.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. 統合実装例:ファーストパーソンビューのオープンワールド

予備知識

統合システムの設計

これまで学んだ個別機能を組み合わせて、実用的なゲームシステムを構築する。カメラ制御(6章)、入力処理(7章)、タスク管理(8章)、シャドウマッピング(9章)、シーン管理(1章)を統合する。

初期化順序

システムの初期化は依存関係に基づいて順序を決定する。照明システムは影の計算に必要なため最初に初期化し、シーンオブジェクトは照明設定後に配置する。カメラ制御はシーン初期化後に設定し、入力処理は最後に有効化する。

動的メッシュ生成

実行時にメッシュを生成してシーンに追加する技術である。GeomVertexDataで頂点データを管理し、GeomVertexWriterで頂点座標、法線ベクトル、色情報を書き込む。球体の生成では、緯度角φと経度角θを用いて頂点座標を計算する。

ローカル座標系での移動

setPos(self.camera, Point3(0, speed, 0))の第1引数にself.cameraを指定することで、カメラのローカル座標系での移動を実現する。これにより、カメラの向きに関係なく「前進」「後退」「左右移動」が直感的に動作する。

以下のコードでは、1章から9章で学んだ技術を統合したファーストパーソンビューのオープンワールド環境を構築する。

from direct.showbase.ShowBase import ShowBase
from panda3d.core import Point3, Vec3, 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
from math import pi, sin, cos
import sys

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

        # システムの初期化(依存関係に基づく順序)
        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))
        ambient_np = self.render.attachNewNode(ambient_light)
        self.render.setLight(ambient_np)

        # 平行光とシャドウマッピング
        directional_light = DirectionalLight("directional")
        directional_light.setColor(Vec4(1, 1, 1, 1))
        directional_light.setShadowCaster(True, 2048, 2048)
        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.setPos(0, 0, 0)
        ground.setP(-90)
        ground.setColor(0.2, 0.5, 0.2, 1)
        ground.setShaderAuto()

        # 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:
            self.create_cube(pos, size=1.0, color=color)

    def create_cube(self, pos, size=1.0, color=Vec4(1, 1, 1, 1)):
        """立方体オブジェクトを生成"""
        cube = self.loader.loadModel("models/box")
        cube.setPos(pos)
        cube.setScale(size)
        cube.setColor(color)
        cube.setShaderAuto()
        cube.reparentTo(self.render)

    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:
            self.create_sphere()
        else:
            self.sphere.removeNode()
            self.sphere = None

    def create_sphere(self):
        """球体メッシュを動的に生成"""
        cam_quat = self.camera.getQuat()
        forward = cam_quat.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)
        self.sphere.setShaderAuto()

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

統合実装のポイント

動作確認

発展課題

初級:

中級:

上級: