依頼内容を確認し、作業計画を立てる。 **依頼内容の分類:** 複雑タスク(文章校正、論理構造確認、読者への配慮の検証) **具体的目標:** 1. 過剰にくどい言い回しの対処(内容の簡素化ではない、同じ文章の意図的な繰り返しは維持) 2. 必要な情報の探しやすさ、読者への配慮、理解困難な概念・用語の説明確認 3. さらに推敲の余地があるかの点検 4. 操作のメリットと注意事項が書かれているかの確認 5. 記事として絶対に実施すべき改善があるかの確認 **作業手順:** 1. 文書全体を読み込み、構成と内容を把握する 2. 過剰にくどい言い回しを特定し修正する 3. 読者への配慮(情報の探しやすさ、用語説明)を検証する 4. 各操作のメリットと注意事項の記載を確認する 5. 推敲の余地を点検する 6. 絶対に実施すべき改善を特定し実施する 7. 修正箇所を番号付きリストで報告する --- ## 分析結果と改善提案 ### 1. 過剰にくどい言い回しの特定 以下の箇所で冗長な表現を発見した。 | 箇所 | 現状 | 改善案 | |------|------|--------| | セクション1「実装例」 | 「Panda3Dのデフォルトマウス操作(左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動)が使用可能。」が各実装例で繰り返されている | 最初の実装例で詳細説明し、以降は「Panda3Dのデフォルトマウス操作が使用可能である。」に短縮する | | 各コード内コメント | 「テクスチャを強制的にオフ」と「テクスチャを解除」が混在 | 表現を統一する | | 予備知識セクション | 一部で同じ概念を異なる言葉で繰り返し説明している | 重複を整理する | ### 2. 読者への配慮の検証 **良い点:** - 目次が明確で、各セクションへのリンクがある - 予備知識が各セクションの冒頭に配置されている - コードに詳細なコメントがある - 演習問題にヒントと解答例がある **改善が必要な点:** | 問題 | 箇所 | 改善案 | |------|------|--------| | 初出の専門用語に説明がない | 「シーングラフ」「NodePath」「render」など | 初出時に簡潔な説明を追加 | | 前提知識の明示が不足 | 記事冒頭 | Pythonの基礎知識が必要であることを明記 | | コードの実行環境の説明不足 | セクション1 | どこでコードを実行するか(エディタ、ターミナル)を明記 | ### 3. 操作のメリットと注意事項の確認 **不足している箇所:** | セクション | 不足内容 | |------------|----------| | セクション2(座標系) | なぜZ-up右手座標系を理解する必要があるのかのメリット | | セクション5(ライティング) | 光源設定を誤った場合の影響(注意事項) | | セクション8(物理演算) | dtの上限設定の理由(数値的安定性)の説明が不十分 | | セクション10(波動シミュレーション) | 計算負荷に関する注意事項 | ### 4. 絶対に実施すべき改善 1. **概要セクションに前提知識を追加する** 2. **マウス操作説明の重複を整理する** 3. **用語の表記を統一する** 4. **一部の予備知識の説明を簡潔にする** --- 以下に修正後の文書を示す。 ---

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

【概要】

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

【前提知識】

本教材はPythonの基本文法(変数、関数、クラス、リスト、辞書)を理解していることを前提とする。プログラミング未経験者は、先にPython入門を学習されたい。

【目次】

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

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



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


予備知識


Panda3Dのインストール

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

ゲームループとフレーム

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

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

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


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


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

前準備

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

Python 3.12 のインストール

以下のいずれかの方法で Python 3.12 をインストールする。

方法1:winget によるインストール

Python がインストール済みの場合、この手順は不要である。管理者権限コマンドプロンプトで以下を実行する。管理者権限のコマンドプロンプトを起動するには、Windows キーまたはスタートメニューから「cmd」と入力し、表示された「コマンドプロンプト」を右クリックして「管理者として実行」を選択する。

winget install -e --id Python.Python.3.12 --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 AssociateFiles=1 InstallLauncherAllUsers=1"

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動すると PATH が自動的に設定される。

方法2:インストーラーによるインストール

  1. Python 公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンから Windows 用インストーラーをダウンロードする。
  2. ダウンロードしたインストーラーを実行する。
  3. 初期画面の下部に表示される「Add python.exe to PATH」に必ずチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから python コマンドを実行できない。
  4. 「Install Python 3.xx for all users」にチェックを入れ、「Install」をクリックする。

インストールの確認

コマンドプロンプトで以下を実行する。

python --version

バージョン番号(例:Python 3.12.x)が表示されればインストール成功である。「'python' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

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

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

Windsurf がインストール済みの場合、この手順は不要である。管理者権限コマンドプロンプトで以下を実行する。管理者権限のコマンドプロンプトを起動するには、Windows キーまたはスタートメニューから「cmd」と入力し、表示された「コマンドプロンプト」を右クリックして「管理者として実行」を選択する。

winget install -e --id Codeium.Windsurf --scope machine --accept-source-agreements --accept-package-agreements --override "/VERYSILENT /NORESTART /MERGETASKS=!runcode,addtopath,associatewithfiles,!desktopicon"
powershell -Command "$env:Path=[System.Environment]::GetEnvironmentVariable('Path','Machine')+';'+[System.Environment]::GetEnvironmentVariable('Path','User'); windsurf --install-extension MS-CEINTL.vscode-language-pack-ja --force; windsurf --install-extension ms-python.python --force"

--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動すると PATH が自動的に設定される。

関連する外部ページ

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

Panda3D のインストール手順

管理者権限でコマンドプロンプトを起動し、以下を実行する。

pip install panda3d

実装例

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

【マウス操作】Panda3Dではデフォルトで以下のマウス操作が使用可能である。左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動。以降の実装例でも同様のマウス操作が使用可能である。


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

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

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

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

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

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

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

        return Task.cont  # タスクを継続

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

実装例

Panda3Dで3Dシーンを構築するプログラムである。緑色の地面を配置し、その上に色相環に基づく3色(赤・緑・青)の立方体を横一列に並べる。画面左上にテキストを表示する。

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軸周り(Roll)に毎秒90度の速度で回転させるプログラムを作成せよ。回転はフレームレート非依存で実装すること。

ヒント

解答例

緑色の立方体をZ軸周りに回転させるプログラムである。デルタ時間を使用してフレームレート非依存の回転を実現している。

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

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

        # 緑色の立方体
        self.cube = self.loader.loadModel("models/box")
        self.cube.setPos(0, 3, 2)
        self.cube.setColor(0, 1, 0, 1)
        self.cube.setTextureOff(1)
        self.cube.reparentTo(self.render)

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

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

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

        return Task.cont

app = MyApp()
app.run()

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


予備知識


3次元空間の座標系

3次元空間では、XYZ軸による直交座標系で位置を表現する。3つの数値(x, y, z)の組み合わせで任意の点の位置を一意に特定できる。

Panda3Dの座標系

Panda3DはZ-up右手座標系を採用している。右手座標系とは、右手の親指をX軸正方向、人差し指をY軸正方向に向けたとき、中指がZ軸正方向を指す座標系である。座標系を正しく理解することで、オブジェクトの配置やカメラ設定を意図通りに行える。



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

Point3とVec3

Point3は3D空間内の位置(点)を表し、Vec3は方向と大きさを持つベクトルを表現する。点の減算でベクトルを得られ、点にベクトルを加えて新しい点を得られる。

ベクトルと基本演算

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

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

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

オイラー角(HPR)

オイラー角は、H(Heading:Y軸周り)、P(Pitch:X軸周り)、R(Roll:Z軸周り)の3角度で物体の向きを表現する。object.setHpr(45, 0, 0)でY軸周りに45度回転させるなど、直感的な回転制御が可能である。

座標系の種類

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


実装例

立方体の拡大・縮小と回転を行うプログラムである。Y軸周りに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)  # Y軸周りに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の位置に配置され、Y軸周りに45度回転し、X方向に2倍に拡大されて表示される。

ポイント


演習問題2


問題

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

ヒント

解答例

5つの立方体を円周上に等間隔配置するプログラムである。三角関数を使用して72度間隔で配置する。

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

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

        radius = 5
        num_cubes = 5
        center_y = 10

        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)の間にあるオブジェクトのみが描画される。描画負荷の最適化に有効である。


実装例

一人称視点のキャラクター移動を実装するプログラムである。WASDキーでカメラを前後左右に移動できる。キー状態管理方式により複数キーの同時押下に対応している。


from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
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)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

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

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

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

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

        speed = 5
        if self.keys['w']:
            self.player_pos[1] += speed * dt
        if self.keys['s']:
            self.player_pos[1] -= speed * dt
        if self.keys['a']:
            self.player_pos[0] -= speed * dt
        if self.keys['d']:
            self.player_pos[0] += speed * dt

        self.camera.setPos(self.player_pos[0], self.player_pos[1], self.player_pos[2])

        return Task.cont

app = MyApp()
app.run()

実行結果:WASDキーでカメラを移動できる。

ポイント


演習問題3


問題

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

ヒント

解答例

カメラを円運動させるプログラムである。カメラは半径10の円周上を毎秒30度の速度で回転しながら、常に立方体を注視する。

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

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

        self.cube = self.loader.loadModel("models/box")
        self.cube.setPos(0, 0, 3)
        self.cube.setColor(0, 0, 1, 1)
        self.cube.setTextureOff(1)
        self.cube.reparentTo(self.render)

        self.radius = 10
        self.angle = 0

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

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

        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'(立方体)、'models/sphere'(球体)、'models/plane'(平面)、'models/cylinder'(円柱)などがある。

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

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

色の表現

RGB色空間は赤(Red)、緑(Green)、青(Blue)の3成分で色を表現する方式である。各成分は0.0~1.0の範囲で指定する。(1, 0, 0)は赤、(0, 1, 0)は緑、(0, 0, 1)は青、(1, 1, 1)は白、(0, 0, 0)は黒を表す。


実装例

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

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つの立方体すべてがY軸周りに毎秒60度の速度で回転するようにせよ。

ヒント

解答例

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

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

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

        models = [
            ('models/box', (1, 0, 0, 1)),
            ('models/box', (0, 1, 0, 1)),
            ('models/box', (0, 0, 1, 1))
        ]

        self.objects = []

        for i, (model_name, color) in enumerate(models):
            obj = self.loader.loadModel(model_name)
            obj.setPos(i * 2 - 2, 10, 0)
            obj.setColor(*color)
            obj.setTextureOff(1)
            obj.reparentTo(self.render)
            self.objects.append(obj)

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

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

        for obj in self.objects:
            obj.setH(obj.getH() + 60 * dt)

        return Task.cont

app = MyApp()
app.run()

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


予備知識


光源の種類

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

【注意】光源を設定しない場合、オブジェクトは平坦に見える。複数の光源を組み合わせることでリアルな表現が可能となる。

HSV色空間と色相環

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


実装例

色相環に基づいた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つ垂直に積み上げよ。立方体が接触するように配置すること。

ヒント

解答例

白い立方体を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 (後右車輪)

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

実装例

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

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秒とすること。

ヒント

解答例

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

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()メソッドでイベントを登録する。

キー入力の検出

Panda3Dではaccept()メソッドでキーイベントを検出する。継続的な入力(押し続ける移動操作など)にはキー状態を辞書で管理する方法が適している。単発の入力(ジャンプなど)にはイベント駆動方式が適している。


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

実装例

キャラクター制御とジャンプ機能を実装したプログラムである。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単位とする。

ヒント

解答例

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、cos)を使用すると周期的な動きを作成できる。sin関数は-1から1の間を周期的に変化するため、上下運動や拡大縮小に適している。

物理演算の基本

物理演算では、ニュートンの運動法則に基づいてオブジェクトの動きをシミュレートする。速度は移動の速さと方向、加速度は速度の変化率である。重力加速度は地表で約-9.8 m/s²(下向き)である。

運動方程式と衝突判定

物理シミュレーションでは、加速度を速度に加算し(velocity += acceleration × dt)、速度を位置に加算する(position += velocity × dt)。衝突判定では、オブジェクトが接触しているかを判定し、位置を補正して速度を反転させる。反発係数は跳ね返りの強さを示し、0.0で跳ねない、1.0でエネルギー損失なしとなる。

【注意】dtが大きすぎると計算が不安定になるため、上限を設定することが望ましい。


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

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

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

実装例(物理演算)

複数立方体の自由落下と反発を実装したプログラムである。重力で加速しながら地面に到達し、反発係数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.ground_top = 0.05
        self.box_half_height = 0.5

        # 落下する箱
        self.boxes = []
        self.velocities = []
        for i in range(5):
            box = self.loader.loadModel("models/box")
            hue = i * 72
            r, g, b = hsv_to_rgb(hue, 1, 1)
            box.setColor(r, g, b, 1)
            box.setPos(i * 2 - 4, 0, 10 + i * 2)
            box.setTextureOff(1)
            box.reparentTo(self.render)
            self.boxes.append(box)
            self.velocities.append(LVector3(0, 0, 0))

        self.gravity = -9.8

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

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

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

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

            velocity.z += self.gravity * dt
            new_pos = box.getPos() + velocity * dt
            box.setPos(new_pos)

            collision_z = 0
            if box.getZ() <= collision_z:
                box.setZ(collision_z)
                velocity.z = -velocity.z * 0.5

                if abs(velocity.z) < 0.1:
                    velocity.z = 0

        return Task.cont

app = MyApp()
app.run()

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

ポイント


演習問題8


問題

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

ヒント

解答例

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

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.ground_top = 0.05
        self.box_half_height = 0.5

        self.gravity = -9.8
        self.restitution = 0.7

        self.disableMouse()
        self.camera.setPos(0, -25, 10)
        self.camera.lookAt(0, 10, 5)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

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

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

        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表示などを統合することで、インタラクティブなゲーム体験を実現できる。


実装例

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


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

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

        # プレイヤー
        self.player = self.loader.loadModel("models/box")
        self.player.setPos(0, 0, 1)
        self.player.setColor(0.5, 0.7, 1, 1)
        self.player.setTextureOff(1)
        self.player.reparentTo(self.render)

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

        # 収集アイテム
        self.collectibles = []
        for i in range(10):
            item = self.loader.loadModel("models/box")
            item.setColor(1, 0.84, 0, 1)
            x = random.uniform(-20, 20)
            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)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

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

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

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

        if self.keys['w']:
            self.player.setY(self.player.getY() + self.speed * dt)
        if self.keys['s']:
            self.player.setY(self.player.getY() - self.speed * dt)
        if self.keys['a']:
            self.player.setX(self.player.getX() - self.speed * dt)
        if self.keys['d']:
            self.player.setX(self.player.getX() + self.speed * dt)

        player_pos = self.player.getPos()
        for item in self.collectibles[:]:
            item_pos = item.getPos()
            distance = (player_pos - item_pos).length()
            if distance < 1.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単位とする。

ヒント

解答例

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


予備知識


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

波動方程式は波の伝播を記述する偏微分方程式である。有限差分法は、連続的な空間を離散的な格子点で近似し、微分を差分で置き換えて計算する手法である。これにより、水面の波紋の広がりなどを数値的にシミュレートできる。

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

水面を格子状のグリッドで表現し、各格子点の高さを計算する。ある格子点の変化が隣接する格子点に影響を与えることで波の伝播が実現される。減衰を加えることで、波が時間とともに小さくなる現実的な動きを再現できる。境界条件はグリッド端での波の振る舞いを定義する。

【注意】グリッドサイズを大きくすると計算負荷が増加する。リアルタイム処理には適切なサイズ設定が必要である。


実装例

波動方程式に基づく水面シミュレーションである。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()

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

ポイント

実装例(Ctrl+マウスクリックによる波の生成)

上記のプログラムにマウス操作による波生成機能を追加した版である。Ctrl+マウスクリックで任意の位置に新たな波を生成できる。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 Point3, 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
                print(f"波を生成: グリッド座標 ({grid_x}, {grid_y})")

    def create_water_mesh(self):
        format = GeomVertexFormat.getV3n3c4()
        vdata = GeomVertexData('water', format, Geom.UHDynamic)

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

        for i in range(self.grid_size):
            for j in range(self.grid_size):
                x = i * self.spacing
                y = j * self.spacing
                z = self.current[i][j]
                vertex.addData3(x, y, z)
                normal.addData3(0, 0, 1)
                color.addData4(0.2, 0.5, 0.8, 1.0)

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