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

概要

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

【前提知識】

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

目次

サイト内の関連情報

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


予備知識


ゲームループとフレーム

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

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

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


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


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

Python 3.12 のインストール

Pythonのインストールを行い、Pythonのプログラムを実行する環境を整える。扱う環境は、Windows搭載パソコンである。金子研究室では、Python 3.12.10を推奨する。

[Windows での Python 3.12 のインストール手順を見るには、ここをクリック]

Windows での Python 3.12 のインストール

以下のいずれかの方法でPython 3.12をインストールする。Pythonがインストール済みの場合、この手順は不要である。

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

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windowsキーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして、コマンド全体をコマンドプロンプトにコピー&ペーストする。

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

REM Python 3.12 をシステム領域にインストール
winget install --id Python.Python.3.12 -e --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_pip=1 Include_launcher=1 InstallLauncherAllUsers=1 TargetDir=\"C:\Program Files\Python312\""
if not "%ERRORLEVEL%"=="0" ( color 0c & echo Python 3.12 のインストールに失敗しました & ping 127.0.0.1 -n 6 >nul & color )

REM Python と Scripts を PATH 先頭に追加
powershell -NoProfile -Command "$p='C:\Program Files\Python312'; $s=\"$p\Scripts\"; if(Test-Path $p){$k=[Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment',$true); $c=$k.GetValue('Path','',[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames); $t=$k.GetValueKind('Path'); $new=$c; if((';'+$new+';') -notlike \"*;$p;*\"){$new=$p+';'+$new}; if((';'+$new+';') -notlike \"*;$s;*\"){$new=$s+';'+$new}; if($new -ne $c){$k.SetValue('Path',$new,$t)}; $k.Close()}"

REM 現在のセッションにも反映(システムPATHを再取得して連結)
for /f "usebackq tokens=2,*" %A in (`reg query "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment" /v Path`) do set "PATH=%B"

REM pip / wheel の更新
python -m pip install --no-user -U pip wheel
if not "%ERRORLEVEL%"=="0" ( color 0c & echo pip / wheel の更新に失敗しました & ping 127.0.0.1 -n 6 >nul & color )

方法 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' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。

Python の開発環境 Visual Studio Code のインストールと Python 用の設定

Python の開発環境Visual Studio Code(プログラムを編集するソフトウェア。以下、VS Code)を整える。

[Windows での Visual Studio Code のインストールと Python 用の設定手順を見るには、ここをクリック]

Windows での Visual Studio Code のインストールと Python 用の設定手順

1. VS Code と拡張機能のインストール

以下のコマンドにより,既存の VS Code を削除し,全ユーザー共有の設定で再インストールしたうえで,拡張機能(VS Code に機能を追加するソフトウェア)をまとめて導入する.

インストールコマンドの実行方法

管理者権限コマンドプロンプトを起動する(手順:Windows キーまたはスタートメニュー → cmd と入力 → 右クリック → 「管理者として実行」)。そして,コマンド全体をコマンドプロンプトにコピー&ペーストする。

インストールコマンド


REM ============================================================
REM Microsoft Visual Studio Code
REM ============================================================
REM Build Tools + Desktop development with C++(VCTools)+ 追加コンポーネント(一括)
REM 未インストール時: winget で新規インストール
REM インストール済み時: setup.exe modify でコンポーネント追加(バージョンは変更しない)
winget list --id Microsoft.VisualStudio.BuildTools 2>nul | findstr /i "BuildTools" >nul 2>&1
if %ERRORLEVEL% EQU 0 (
    for /f "usebackq delims=" %P in (`"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe" -products Microsoft.VisualStudio.Product.BuildTools -property installationPath`) do start /wait "" "C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe" modify --installPath "%P" --add Microsoft.VisualStudio.Workload.VCTools --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --add Microsoft.VisualStudio.Component.VC.v143.x86.x64 --includeRecommended --quiet --norestart --nocache
    if not "%ERRORLEVEL%"=="0" ( color 0c & echo Build Tools のコンポーネント追加に失敗しました & ping 127.0.0.1 -n 6 >nul & color )
) else (
    winget install --scope machine --id Microsoft.VisualStudio.BuildTools -e --silent --disable-interactivity --force --accept-source-agreements --accept-package-agreements --override "--quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --add Microsoft.VisualStudio.Workload.MSBuildTools --add Microsoft.VisualStudio.Component.VC.CMake.Project --add Microsoft.VisualStudio.Component.VC.Llvm.Clang --add Microsoft.VisualStudio.Component.VC.Llvm.ClangToolset --add Microsoft.VisualStudio.Component.Windows11SDK.26100 --add Microsoft.VisualStudio.Component.VC.v143.x86.x64"
    if not "%ERRORLEVEL%"=="0" ( color 0c & echo Build Tools のインストールに失敗しました & ping 127.0.0.1 -n 6 >nul & color )
)

REM 全ユーザー共有の拡張機能フォルダ
if not exist "C:\ProgramData\vscode-extensions" mkdir "C:\ProgramData\vscode-extensions"
icacls "C:\ProgramData\vscode-extensions" /grant "Everyone:(OI)(CI)M" /T

REM スタートメニューのショートカットを --extensions-dir 付きで再作成
if exist "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code" rmdir /s /q "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code"
if exist "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk" del "C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk"
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); $lnk.TargetPath='C:\Program Files\Microsoft VS Code\Code.exe'; $lnk.Arguments='--extensions-dir \"C:\ProgramData\vscode-extensions\"'; $lnk.Save()"
REM ショートカットの検証
powershell -NoProfile -Command "$s=New-Object -ComObject WScript.Shell; $lnk=$s.CreateShortcut('C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Visual Studio Code.lnk'); Write-Host 'TargetPath:' $lnk.TargetPath; Write-Host 'Arguments:' $lnk.Arguments"

REM ファイル / フォルダ右クリックの「Code で開く」を登録
reg add "HKLM\SOFTWARE\Classes\*\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%1\"" /f
reg add "HKLM\SOFTWARE\Classes\Directory\Background\shell\VSCode\command" /ve /d "\"C:\Program Files\Microsoft VS Code\Code.exe\" --extensions-dir \"C:\ProgramData\vscode-extensions\" \"%V\"" /f

REM --extensions-dir 付きで起動する code.cmd ラッパを作成
REM (%* を echo で書くと対話的 cmd で失われるため、PowerShell で [char]37+'*' を書き出す)
powershell -NoProfile -Command "$pct=[char]37; $q=[char]34; $c='@echo off'+[char]13+[char]10+$q+'C:\Program Files\Microsoft VS Code\bin\code.cmd'+$q+' --extensions-dir '+$q+'C:\ProgramData\vscode-extensions'+$q+' '+$pct+'*'+[char]13+[char]10; [IO.File]::WriteAllText('C:\ProgramData\vscode-extensions\vscode.cmd',$c,[Text.Encoding]::ASCII)"

REM 拡張機能のインストール
set "CODE=C:\Program Files\Microsoft VS Code\bin\code.cmd"
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --uninstall-extension GitHub.copilot-chat
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.python
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.vscode-pylance
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension ms-python.debugpy
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension MS-CEINTL.vscode-language-pack-ja
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension saoudrizwan.claude-dev
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension rust-lang.rust-analyzer
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension tamasfe.even-better-toml
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension anthropic.claude-code
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --install-extension almenon.arepl
"%CODE%" --extensions-dir "C:\ProgramData\vscode-extensions" --list-extensions --show-versions

REM settings.json を作成(自動更新オフ、Python、Claude Code 設定)
if not exist "%APPDATA%\Code\User" mkdir "%APPDATA%\Code\User"
python -c "import json,os;data={'update.mode':'none','update.enableWindowsBackgroundUpdates':False,'extensions.autoUpdate':False,'python.defaultInterpreterPath':r'C:\Program Files\Python312\python.exe','claudeCode.environmentVariables':[{'name':'ANTHROPIC_API_KEY','value':'not-needed'},{'name':'ANTHROPIC_AUTH_TOKEN','value':'ollama'},{'name':'ANTHROPIC_BASE_URL','value':'http://localhost:11434'},{'name':'ANTHROPIC_MODEL','value':'glm-4.7-flash'},{'name':'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC','value':'1'}]};p=os.path.join(os.environ['APPDATA'],'Code','User','settings.json');open(p,'w',encoding='utf-8').write(json.dumps(data,indent=4));print('Done:',p)"

REM 自動更新の抑止ポリシー(settings.json に加えて、レジストリ側でも明示的にオフ)
reg add "HKLM\SOFTWARE\Policies\Microsoft\VSCode" /v "UpdateMode" /t REG_SZ /d "none" /f
echo === セットアップ完了 ===

2. Python インタプリタの選択

同一マシンに複数の Python がインストールされている場合,VS Code で使用する Python 本体(インタプリタ:Python プログラムを解釈・実行するソフトウェア)を選択する必要がある.

  1. コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(Ctrl+Shift+P
  2. Python: Select Interpreter と入力する
  3. 表示される一覧から,使用する Python(例:C:\Program Files\Python312\python.exe)を選択する.

Python プログラム実行手順

[Windows での Python プログラム実行手順を見るには、ここをクリック]

Windows での Python 実行手順(Visual Studio Codeを使用)

プログラムファイルの作成と保存

  1. 左サイドバーの「エクスプローラー」アイコン(Ctrl+Shift+E)をクリックする
  2. 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する

    続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する

  3. フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
  4. ファイル名(例:aitask.py.ファイル名は何でも良い)を入力し Enter を押す.拡張子は .py(Python ファイルを示す拡張子)とする
  5. 実行したいコードを選択し,Ctrl+C でコピーする.VS Code のエディタ領域に Ctrl+V で貼り付ける
  6. Ctrl+S で保存する

プログラムの実行

  1. エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
  2. VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(print 関数の出力等)が表示される
  3. tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
  4. VS Code 下部のターミナルで実行結果を確認する.OpenCV ウィンドウ(OpenCV が画像を表示するために開く専用ウィンドウ)が開いた場合はそちらも確認する.OpenCV ウィンドウは,マウスクリックでウィンドウをアクティブ(操作対象の状態)にしてからキーを押すと終了する

必要なライブラリのインストール

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

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

pip install --no-user -U panda3d numpy

プログラムの実行

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

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

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

python a.py

Pythonプログラム例

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

【マウス操作】Panda3Dの既定のカメラ操作は次のとおりである。左ドラッグで視点を左右・上下に平行移動、右ドラッグで視点を前後に移動(ズームイン・ズームアウト)、中ドラッグで原点を中心に視点を回転。以降のPythonプログラム例でも、コードでカメラ操作を無効化していない場合は同じマウス操作が使える。


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

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

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

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

    def update(self, task):
        # デルタ時間の取得(前フレームからの経過時間、秒)
        dt = globalClock.getDt()

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

        return Task.cont  # タスクを継続

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

Pythonプログラム例

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

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

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

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

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

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

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

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

app = MyApp()
app.run()

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

ポイント


演習1


問題

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

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

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

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

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

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

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

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

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

        return Task.cont

app = MyApp()
app.run()
緑色の立方体が回転する実行結果

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


予備知識


Panda3Dの座標系

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


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

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

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

オイラー角(HPR)

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

Point3とVec3、ベクトル演算

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

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

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


Pythonプログラム例

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

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

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

        # エンティティの作成と配置
        self.box = self.loader.loadModel("models/box")
        self.box.setPos(0, 5, 1)
        self.box.setColor(1, 0.5, 0, 1)
        self.box.setTextureOff(1)
        self.box.reparentTo(self.render)

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

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

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

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

app = MyApp()
app.run()

実行結果:立方体が位置(2, 5, 1)に配置され、Heading(Z軸周り)に45度回転し、X方向に2倍に拡大されて表示される。
回転と拡大を適用した立方体

ポイント


演習2


問題

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

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

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

from direct.showbase.ShowBase import ShowBase
import math

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

        radius = 5
        num_cubes = 5
        center_y = 10

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

            angle = i * 72
            angle_rad = math.radians(angle)
            x = radius * math.cos(angle_rad)
            y = center_y + radius * math.sin(angle_rad)
            z = 1

            cube.setPos(x, y, z)
            cube.setColor(1, 1, 1, 1)
            cube.setTextureOff(1)
            cube.reparentTo(self.render)

app = MyApp()
app.run()

マウスで視点を調整する。

円周上に配置された5つの立方体

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


予備知識


視点と注視点

視点(camera position)はカメラの位置、注視点(look-at point)はカメラが向く目標点である。この2点で視線方向が決まる。

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

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

視野角(Field of View, FOV)

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

クリッピング面

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


Pythonプログラム例

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


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

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

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

        # オブジェクト配置(色相環に基づく配色)
        for i in range(5):
            cube = self.loader.loadModel("models/box")
            hue = i * 72
            r, g, b = hsv_to_rgb(hue, 1, 1)
            cube.setColor(r, g, b, 1)
            cube.setTextureOff(1)
            cube.setPos(i * 3, 0, 0)
            cube.reparentTo(self.render)

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

        # プレイヤーカメラ設定
        self.player_pos = [0, -10, 2]
        self.disableMouse()
        self.camera.setPos(self.player_pos[0], self.player_pos[1], self.player_pos[2])
        self.camera.lookAt(0, 0, 0)

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

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

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

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

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

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

        return Task.cont

app = MyApp()
app.run()

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

ポイント


演習3


問題

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

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

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

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

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

        self.disableMouse()

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

        self.radius = 10
        self.angle = 0

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

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

        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)は3次元オブジェクトの形状を定義する頂点と面の集合である。Panda3DではloadModel()でモデルを読み込む。組み込みの基本形状として'models/box'(立方体)などがある。'models/box'の原点は立方体の一つの角に位置し、デフォルトのサイズは1×1×1(一辺の長さが1)である。

色の表現

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


Pythonプログラム例

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

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

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

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

        # テクスチャなし立方体
        self.colored_cube = self.loader.loadModel("models/box")
        self.colored_cube.setPos(0, 10, 0)
        self.colored_cube.setColor(1, 0.5, 0, 1)
        self.colored_cube.setTextureOff(1)
        self.colored_cube.reparentTo(self.render)

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

        # カメラ設定
        self.disableMouse()
        self.camera.setPos(-1.5, -12, 6)
        self.camera.lookAt(-1.5, 10, 1)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

app = MyApp()
app.run()

実行結果:2つの立方体と緑の地面が表示される。
テクスチャ付きと単色の立方体、緑の地面

ポイント


演習4


問題

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

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

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

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

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

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

        self.objects = []

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

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

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

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

        return Task.cont

app = MyApp()
app.run()
赤・緑・青の立方体が回転する実行結果

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


予備知識


光源の種類

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

HSV色空間と色相環

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


Pythonプログラム例

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

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

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

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

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

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

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

        # オブジェクト配置
        for i in range(5):
            cube = self.loader.loadModel("models/box")
            cube.setPos(i * 3 - 6, 0, 1)
            hue = i * 72
            r, g, b = hsv_to_rgb(hue, 1, 1)
            cube.setColor(r, g, b, 1)
            cube.setTextureOff(1)
            cube.reparentTo(self.render)

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

app = MyApp()
app.run()

実行結果:環境光で全体が均一に明るくなり、指向性光源で各面に明暗が付く。立方体が立体的に見える。
照明を当てた5色の立方体

ポイント


演習5


問題

環境光(色:0.3, 0.3, 0.3)と指向性光源(色:1.0, 1.0, 1.0、方向:Heading=0, Pitch=-45)を設定し、白い立方体を5つ垂直に積み上げよ。立方体どうしが接触するように配置すること。

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

白い立方体を5個垂直に積み上げるプログラムである。

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

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

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

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

        # 立方体を積み上げる(原点は底面の角にあるため、Zを1.0ずつずらすと接触する)
        for i in range(5):
            cube = self.loader.loadModel("models/box")
            cube.setPos(0, 0, i)
            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, 2.5)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

app = MyApp()
app.run()
垂直に積み上げた5つの白い立方体

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


予備知識


シーングラフと階層構造

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

NodePath

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


階層構造の例:

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

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

Pythonプログラム例

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

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

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

        # 親エンティティ(空のノード)
        self.vehicle = NodePath("vehicle")
        self.vehicle.reparentTo(self.render)

        # 車体本体
        body = self.loader.loadModel("models/box")
        body.setScale(2, 3, 1)
        body.setColor(1, 0, 0, 1)
        body.setTextureOff(1)
        body.reparentTo(self.vehicle)

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

        for pos in wheel_positions:
            wheel = self.loader.loadModel("models/box")
            wheel.setScale(0.5)
            wheel.setColor(0, 0, 0, 1)
            wheel.setPos(pos[0], pos[1], pos[2])
            wheel.setTextureOff(1)
            wheel.reparentTo(self.vehicle)

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

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

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

        current_y = self.vehicle.getY()
        self.vehicle.setY(current_y + 3 * dt)

        if self.vehicle.getY() > 10:
            self.vehicle.setY(-10)

        return Task.cont

app = MyApp()
app.run()

実行結果:車体と4つの車輪が一体となって前方に移動する。
車体と車輪が一体で移動する実行結果

ポイント


演習6


問題

太陽系モデル。中心に黄色の立方体(太陽)を配置し、その周りを青い立方体(地球)が半径3で公転するようにせよ。さらに、地球の周りを灰色の立方体(月)が半径1で公転するようにせよ。地球の公転周期は10秒、月の公転周期は3秒とすること。

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

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

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

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

        # 太陽
        self.sun_system = NodePath("sun_system")
        self.sun_system.reparentTo(self.render)

        sun = self.loader.loadModel("models/box")
        sun.setScale(0.8)
        sun.setColor(1, 1, 0, 1)
        sun.setTextureOff(1)
        sun.reparentTo(self.sun_system)

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

        earth = self.loader.loadModel("models/box")
        earth.setScale(0.3)
        earth.setPos(3, 0, 0)
        earth.setColor(0, 0, 1, 1)
        earth.setTextureOff(1)
        earth.reparentTo(self.earth_system)

        # 月システム
        self.moon_system = NodePath("moon_system")
        self.moon_system.setPos(3, 0, 0)
        self.moon_system.reparentTo(self.earth_system)

        moon = self.loader.loadModel("models/box")
        moon.setScale(0.15)
        moon.setPos(1, 0, 0)
        moon.setColor(0.5, 0.5, 0.5, 1)
        moon.setTextureOff(1)
        moon.reparentTo(self.moon_system)

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

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

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

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

        return Task.cont

app = MyApp()
app.run()
太陽・地球・月の階層的な公転

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


予備知識


イベント駆動とキー入力

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


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

Pythonプログラム例

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

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

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

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

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(20, 20, 0.1)
        ground.setPos(-10, -10, 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")

    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):
        dt = globalClock.getDt()

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

        self.jump_force += -20 * dt
        new_z = self.player.getZ() + self.jump_force * dt
        self.player.setZ(new_z)

        h = 0
        if self.player.getZ() <= h:
            self.player.setZ(h)
            self.jump_force = 0
            self.is_jumping = False

        return Task.cont

app = MyApp()
app.run()

実行結果:WASDキーでプレイヤーを移動、スペースキーでジャンプできる。
プレイヤーの移動とジャンプ

ポイント


演習7


問題

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

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

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

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

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

        ground = self.loader.loadModel("models/box")
        ground.setScale(10, 10, 0.1)
        ground.setPos(-5, -5, 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")

    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):
        dt = globalClock.getDt()

        if self.keys['arrow_up']:
            self.cube.setY(self.cube.getY() + self.speed * dt)
        if self.keys['arrow_down']:
            self.cube.setY(self.cube.getY() - self.speed * dt)
        if self.keys['arrow_left']:
            self.cube.setX(self.cube.getX() - self.speed * dt)
        if self.keys['arrow_right']:
            self.cube.setX(self.cube.getX() + self.speed * dt)

        return Task.cont

app = MyApp()
app.run()
矢印キーで移動する立方体

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


予備知識


周期的なアニメーション

アニメーションは、オブジェクトの位置・回転・スケールなどを時間とともに変化させることで実現する。sin関数は-1から1の間を周期的に変化するため、上下運動や拡大縮小に用いる。

物理演算と運動方程式

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

衝突判定と反発

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


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

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

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

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

        # 地面
        ground = self.loader.loadModel("models/box")
        ground.setScale(20, 20, 0.1)
        ground.setPos(-10, -10, 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.elapsed_time = 0

    def update(self, task):
        dt = globalClock.getDt()
        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()

実行結果:赤い立方体が回転し、青い立方体が上下に動き、緑の立方体が拡大縮小する。
3種類のアニメーションを行う立方体

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

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


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

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

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

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

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

        self.gravity = -9.8

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

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

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

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

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

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

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

        return Task.cont

app = MyApp()
app.run()

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

ポイント


演習8


問題

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

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

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

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

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

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

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

        self.gravity = -9.8
        self.restitution = 0.7

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

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

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

        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. ゲーム制作(簡易的な3次元アクションゲーム)


予備知識


ゲームの基本要素

3次元ゲームは、プレイヤー、環境、ゲームロジック(スコア、クリア条件)の組み合わせで構成される。移動制御、衝突判定、スコア管理、UI表示を統合することで、操作可能な3次元体験が完成する。


Pythonプログラム例

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


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

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

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

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

        # 収集アイテム
        self.collectibles = []
        for i in range(10):
            item = self.loader.loadModel("models/box")
            item.setColor(1, 0.84, 0, 1)
            x = random.uniform(-20, 20)
            y = random.uniform(-20, 20)
            item.setPos(x, y, 0.5)
            item.setScale(0.5)
            item.setTextureOff(1)
            item.reparentTo(self.render)
            self.collectibles.append(item)

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

        self.speed = 5

        self.keys = {'w': False, 'a': False, 's': False, 'd': False}
        self.accept('w', self.setKey, ['w', True])
        self.accept('w-up', self.setKey, ['w', False])
        self.accept('a', self.setKey, ['a', True])
        self.accept('a-up', self.setKey, ['a', False])
        self.accept('s', self.setKey, ['s', True])
        self.accept('s-up', self.setKey, ['s', False])
        self.accept('d', self.setKey, ['d', True])
        self.accept('d-up', self.setKey, ['d', False])

        self.disableMouse()
        self.camera.setPos(0, -15, 10)
        self.camera.lookAt(0, 0, 0)

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

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

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

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

        player_pos = self.player.getPos()
        for item in self.collectibles[:]:
            item_pos = item.getPos()
            distance = (player_pos - item_pos).length()
            if distance < 1.2:
                item.removeNode()
                self.collectibles.remove(item)
                self.score += 10
                self.score_text.setText(f'Score: {self.score}')

        if len(self.collectibles) == 0:
            self.score_text.setText('Game Clear!')

        self.camera.setPos(player_pos.x, player_pos.y - 15, 10)

        return Task.cont

app = MyApp()
app.run()

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

ポイント


演習9


問題

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

《手順》

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

《ヒント》

《考察ポイント》

Pythonプログラム例

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

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

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

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

        self.collectibles = []
        for i in range(5):
            item = self.loader.loadModel("models/box")
            item.setColor(1, 0, 0, 1)
            x = random.uniform(-10, 10)
            y = random.uniform(-10, 10)
            item.setPos(x, y, 0.5)
            item.setScale(0.5)
            item.setTextureOff(1)
            item.reparentTo(self.render)
            self.collectibles.append(item)

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

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

        self.speed = 7
        self.game_over = False

        self.keys = {'w': False, 'a': False, 's': False, 'd': False}
        self.accept('w', self.setKey, ['w', True])
        self.accept('w-up', self.setKey, ['w', False])
        self.accept('a', self.setKey, ['a', True])
        self.accept('a-up', self.setKey, ['a', False])
        self.accept('s', self.setKey, ['s', True])
        self.accept('s-up', self.setKey, ['s', False])
        self.accept('d', self.setKey, ['d', True])
        self.accept('d-up', self.setKey, ['d', False])

        self.disableMouse()
        self.camera.setPos(0, -20, 15)
        self.camera.lookAt(0, 0, 0)
        mat = Mat4(self.camera.getMat())
        mat.invertInPlace()
        self.mouseInterfaceNode.setMat(mat)
        self.enableMouse()

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

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

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

        dt = globalClock.getDt()

        self.elapsed_time += dt
        remaining_time = self.time_limit - self.elapsed_time
        self.timer_text.setText(f'Time: {remaining_time:.1f}')

        if remaining_time <= 0:
            self.timer_text.setText('Game Over!')
            self.game_over = True
            return Task.cont

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

        player_pos = self.player.getPos()
        for item in self.collectibles[:]:
            item_pos = item.getPos()
            distance = (player_pos - item_pos).length()
            if distance < 1.2:
                item.removeNode()
                self.collectibles.remove(item)
                self.score += 20
                self.score_text.setText(f'Score: {self.score}')

        if len(self.collectibles) == 0:
            self.timer_text.setText('Game Clear!')
            self.game_over = True

        return Task.cont

app = MyApp()
app.run()
タイマー付き収集ゲームの実行画面

10. 波のシミュレーション

この章では、メッシュの頂点座標を毎フレーム書き換えることにより、水面を伝わる波を表示する。Panda3D では、モデルファイルを読み込むだけでなく、プログラムで頂点データを組み立てて表示することができる。頂点データを Geom.UHDynamic で作成しておくと、実行中に頂点を更新できる。

この章で使う主なクラスは次の通りである。

10.1 格子メッシュの生成と正弦波

まず、平面を N×N に分割した格子メッシュを作り、各頂点の高さ z を時刻 t の関数として与える。ここでは、原点から外側に広がる円形の波を、次の式で表す。

z = A sin(k r - ω t) exp(-d r) (r は原点からの水平距離)

exp(-d r) は、原点から離れるほど波が小さくなる減衰を表す。

import math
from direct.showbase.ShowBase import ShowBase
from panda3d.core import (Geom, GeomNode, GeomTriangles, GeomVertexData,
                          GeomVertexFormat, GeomVertexWriter, GeomVertexRewriter)

N = 40        # 格子の分割数
SIZE = 10.0   # 一辺の長さ
AMP = 0.5     # 振幅


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

        # 頂点データの作成(座標と色)。UHDynamic は実行中に更新することを示す
        fmt = GeomVertexFormat.getV3c4()
        self.vdata = GeomVertexData('wave', fmt, Geom.UHDynamic)
        self.vdata.setNumRows((N + 1) * (N + 1))

        vertex = GeomVertexWriter(self.vdata, 'vertex')
        color = GeomVertexWriter(self.vdata, 'color')
        for j in range(N + 1):
            for i in range(N + 1):
                x = -SIZE / 2 + SIZE * i / N
                y = -SIZE / 2 + SIZE * j / N
                vertex.addData3(x, y, 0.0)
                color.addData4(0.2, 0.5, 1.0, 1.0)

        # 三角形の定義(1つの格子を2つの三角形に分割)
        tris = GeomTriangles(Geom.UHStatic)
        for j in range(N):
            for i in range(N):
                v0 = j * (N + 1) + i
                v1 = v0 + 1
                v2 = v0 + (N + 1)
                v3 = v2 + 1
                tris.addVertices(v0, v1, v3)
                tris.addVertices(v0, v3, v2)

        geom = Geom(self.vdata)
        geom.addPrimitive(tris)
        node = GeomNode('wave')
        node.addGeom(geom)
        self.surface = self.render.attachNewNode(node)

        # カメラの配置(マウス操作を切り、固定位置から見下ろす)
        self.disableMouse()
        self.camera.setPos(0, -18, 9)
        self.camera.lookAt(0, 0, 0)

        self.taskMgr.add(self.update, 'update')

    def update(self, task):
        t = task.time
        rw = GeomVertexRewriter(self.vdata, 'vertex')
        while not rw.isAtEnd():
            x, y, z = rw.getData3()
            r = math.sqrt(x * x + y * y)
            h = AMP * math.sin(3.0 * r - 4.0 * t) * math.exp(-0.2 * r)
            rw.setData3(x, y, h)
        return task.cont


app = WaveApp()
app.run()

実行すると、原点を中心に同心円状の波が外側に広がる様子が表示される。GeomVertexRewriter は、読み出し位置と書き込み位置を持ち、getData3 で 1 行読み、setData3 で同じ行に書き戻す。isAtEnd が真になるまで繰り返すことで、すべての頂点を更新できる。

波の見え方は、式の係数で変わる。3.0 は波数(値を大きくすると波長が短くなる)、4.0 は角振動数(値を大きくすると速く伝わる)、0.2 は減衰の強さである。

10.2 波動方程式による数値シミュレーション

10.1 は、あらかじめ決めた式で高さを与えるものであった。ここでは、波動方程式を差分法で解き、波の反射や干渉が自然に現れるシミュレーションを行う。

各格子点の高さ u について、時刻を 1 段階進める式は次の通りである。

unext = 2u - uprev + K (u + u + u + u - 4u)

K は (c Δt / Δx)2 にあたる係数で、2 次元では K が 0.5 以下でないと計算が発散する。ここでは K = 0.25 とする。境界は高さ 0 に固定し、そこで波が反射する。

計算には numpy を用いる。numpy が入っていない場合は、次のコマンドでインストールする。

pip install --no-user numpy
import numpy as np
from direct.showbase.ShowBase import ShowBase
from panda3d.core import (Geom, GeomNode, GeomTriangles, GeomVertexData,
                          GeomVertexFormat, GeomVertexWriter, GeomVertexRewriter)

N = 48        # 格子の分割数
SIZE = 10.0   # 一辺の長さ
K = 0.25      # 安定条件は 0.5 以下
DAMP = 0.999  # 減衰(1.0 に近いほど波が長く残る)


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

        # 高さの配列(現在と 1 つ前の時刻)
        self.u = np.zeros((N + 1, N + 1), dtype=np.float32)
        self.u_prev = np.zeros((N + 1, N + 1), dtype=np.float32)

        fmt = GeomVertexFormat.getV3c4()
        self.vdata = GeomVertexData('wave', fmt, Geom.UHDynamic)
        self.vdata.setNumRows((N + 1) * (N + 1))

        vertex = GeomVertexWriter(self.vdata, 'vertex')
        color = GeomVertexWriter(self.vdata, 'color')
        for j in range(N + 1):
            for i in range(N + 1):
                x = -SIZE / 2 + SIZE * i / N
                y = -SIZE / 2 + SIZE * j / N
                vertex.addData3(x, y, 0.0)
                color.addData4(0.2, 0.5, 1.0, 1.0)

        tris = GeomTriangles(Geom.UHStatic)
        for j in range(N):
            for i in range(N):
                v0 = j * (N + 1) + i
                tris.addVertices(v0, v0 + 1, v0 + N + 2)
                tris.addVertices(v0, v0 + N + 2, v0 + N + 1)

        geom = Geom(self.vdata)
        geom.addPrimitive(tris)
        node = GeomNode('wave')
        node.addGeom(geom)
        self.surface = self.render.attachNewNode(node)

        self.disableMouse()
        self.camera.setPos(0, -18, 9)
        self.camera.lookAt(0, 0, 0)

        # スペースキーで水滴を落とす
        self.accept('space', self.drop)
        self.drop()

        self.taskMgr.add(self.update, 'update')

    def drop(self):
        """ランダムな位置に山型の初期変位を与える"""
        ci = np.random.randint(6, N - 5)
        cj = np.random.randint(6, N - 5)
        for j in range(cj - 3, cj + 4):
            for i in range(ci - 3, ci + 4):
                d2 = (i - ci) ** 2 + (j - cj) ** 2
                self.u[j, i] += 1.5 * float(np.exp(-d2 / 4.0))

    def step(self):
        u = self.u
        lap = np.zeros_like(u)
        lap[1:-1, 1:-1] = (u[:-2, 1:-1] + u[2:, 1:-1] +
                           u[1:-1, :-2] + u[1:-1, 2:] - 4.0 * u[1:-1, 1:-1])
        u_next = (2.0 * u - self.u_prev + K * lap) * DAMP
        # 境界は 0 に固定(壁で反射する)
        u_next[0, :] = 0.0
        u_next[-1, :] = 0.0
        u_next[:, 0] = 0.0
        u_next[:, -1] = 0.0
        self.u_prev = u
        self.u = u_next

    def update(self, task):
        self.step()
        flat = self.u.reshape(-1)
        rw = GeomVertexRewriter(self.vdata, 'vertex')
        idx = 0
        while not rw.isAtEnd():
            x, y, z = rw.getData3()
            rw.setData3(x, y, float(flat[idx]))
            idx += 1
        return task.cont


app = WaveEqApp()
app.run()

実行すると、水滴が落ちたように波が広がり、四方の壁で反射して互いに重なり合う。スペースキーを押すたびに新しい水滴が落ち、2 つの波が重なる干渉の様子を観察できる。

この方法では、波の伝わり方を式で書き下す必要がない。各時刻の高さは、周囲の高さとの差(ラプラシアン)から自動的に決まる。DAMP を 1.0 に近づけると波が長く残り、小さくすると早く静まる。

10.3 高さによる色付け

高さに応じて色を変えると、波の形が見やすくなる。update の中で、頂点座標に加えて色も書き換える。次のコードを update メソッドの中(頂点更新のあと)に追加する。

        cw = GeomVertexRewriter(self.vdata, 'color')
        idx = 0
        while not cw.isAtEnd():
            h = float(flat[idx])
            s = max(0.0, min(1.0, 0.5 + 0.5 * h))   # 0 から 1 に正規化
            cw.setData4(0.2 + 0.6 * s, 0.4, 1.0 - 0.5 * s, 1.0)
            idx += 1

山の部分が明るく、谷の部分が暗く表示される。

10.4 演習

  1. 10.1 のプログラムで、波数と角振動数の値を変えて、波長と伝わる速さがどのように変わるかを確認しなさい。
  2. 10.1 のプログラムを変更し、原点ではなく 2 か所から波が出るようにしなさい(2 つの正弦波の和を高さとする)。
  3. 10.2 のプログラムで K の値を 0.6 に変えて実行し、計算が発散することを確認しなさい。確認後は 0.25 に戻すこと。
  4. 10.2 のプログラムで、マウスの左ボタンのクリックでも水滴が落ちるようにしなさい(self.accept('mouse1', self.drop) を追加する)。
  5. 10.2 のプログラムで、格子の分割数 N を 96 に増やし、表示の滑らかさと動作の速さがどう変わるかを調べなさい。