Panda3D 3次元ゲームエンジン基礎
【概要】
Python用3次元ゲームエンジン「Panda3D」の基礎を10項目に分けて学習する。環境構築、座標系、カメラ制御、メッシュとマテリアル、ライティング、エンティティの階層構造、入力処理、アニメーションと物理演算、簡易ゲーム制作、波動シミュレーションを、Pythonプログラム例とともに解説する。
【前提知識】
本教材はPythonの基本文法(変数、関数、クラス、リスト、辞書)の理解を前提とする。実行にはPython 3.12が必要である。
【目次】
- 1. 環境構築とサンプルコード実行
- 2. 3次元座標系とトランスフォーム(位置、回転、スケール)
- 3. カメラとビューポート
- 4. メッシュとマテリアル(色)
- 5. ライティングとシェーディング
- 6. エンティティ(Entity)の生成と制御
- 7. 入力処理(キーボード)
- 8. アニメーションと物理演算
- 9. ゲーム制作(簡易的な3次元アクションゲーム)
- 10. 波動シミュレーション(水面の波)
【サイト内の関連情報】
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:インストーラーによるインストール
- Python公式サイト(https://www.python.org/downloads/)にアクセスし、「Download Python 3.x.x」ボタンからWindows用インストーラーをダウンロードする。
- ダウンロードしたインストーラーを実行する。
- 初期画面の下部に表示される「Add python.exe to PATH」にチェックを入れてから「Customize installation」を選択する。このチェックを入れ忘れると、コマンドプロンプトから
pythonコマンドを実行できない。 - 「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 プログラムを解釈・実行するソフトウェア)を選択する必要がある.
- コマンドパレット(コマンド名で機能を呼び出す VS Code の入力欄)を開く(
Ctrl+Shift+P) Python: Select Interpreterと入力する
- 表示される一覧から,使用する Python(例:
C:\Program Files\Python312\python.exe)を選択する.
Python プログラム実行手順
[Windows での Python プログラム実行手順を見るには、ここをクリック]
Windows での Python 実行手順(Visual Studio Codeを使用)
プログラムファイルの作成と保存
- 左サイドバーの「エクスプローラー」アイコン(
Ctrl+Shift+E)をクリックする
- 「NO FOLDER OPENED」(作業対象フォルダが未選択の状態)と表示される場合は,「Open Folder」をクリックし,プログラムを保存するフォルダを選択する
続いて「フォルダを信用するか」を確認する画面(フォルダ内のコードを実行してよいか確認する VS Code の仕組み)が表示されるので,チェックして Yes を選択する
- フォルダ名の右側に表示される「新しいファイル」アイコンをクリックする
- ファイル名(例:
aitask.py.ファイル名は何でも良い)を入力しEnterを押す.拡張子は.py(Python ファイルを示す拡張子)とする
- 実行したいコードを選択し,
Ctrl+Cでコピーする.VS Code のエディタ領域にCtrl+Vで貼り付ける Ctrl+Sで保存する
プログラムの実行
- エディタ右上の三角形「▷」アイコン(Run Python File:現在開いている Python ファイルを実行するボタン)をクリックする.または,エディタ上で右クリックし「ターミナルで Python ファイルを実行」を選択する
- VS Code 下部のターミナル(コマンドの入出力を表示する画面)に,実行結果(
print関数の出力等)が表示される
- tkinter(Python 標準の GUI ライブラリ)のファイル選択ダイアログを使うプログラムを実行した場合は,ダイアログが開くので対象画像を選択する
- 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()
ポイント
- ShowBaseクラスを継承してアプリケーションクラスを作成する
- タスクマネージャにupdate()関数を登録して毎フレームの処理を記述する
- 3次元空間に置いた物体はマウスで視点を変えながら観察できる
演習1
問題
緑色の立方体を位置(0, 3, 2)に配置し、Z軸周り(Heading)に毎秒90度の速度で回転させるプログラム。回転はフレームレート非依存で実装すること。
《手順》
- 本節のPythonプログラム例(オレンジ色の立方体が回転するプログラム)を、方法AまたはBでコピーする。
- 立方体の位置・色・回転軸を、問題の指定に合わせて書き換える。
- プログラムを実行し、立方体が回転する様子を確認する。マウスで視点を変えて立体的に観察する。
《ヒント》
- Z軸周りの回転(Heading)はsetH()メソッドを使う
- 毎秒90度回転させるには、毎フレーム「90 × dt」度回転させる
- テクスチャ無効化にはsetTextureOff(1)を使う
《考察ポイント》
- 「× dt」を外して「self.cube.setH(self.cube.getH() + 90)」と書いた場合、回転速度はどう変わるか。PCの性能差で結果が変動する理由を考える。
- setH、setP、setRをそれぞれ単独で適用したとき、立方体の見え方の違いをマウスで視点を変えながら観察する。
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()
ポイント
- setPos()、setH()/setP()/setR()、setScale()でオブジェクトを制御する
- LVector3で3次元ベクトルを扱う
- Z軸が上方向、Y軸が前方向である
演習2
問題
位置(0, 10, 0)を中心として、半径5の円周上に5つの立方体を等間隔に配置せよ。各立方体の色は同一色、高さはすべてz=1とすること。
《手順》
- 新しいプログラムを作成し、mathモジュールをimportする。
- for文で5回繰り返し、ループ変数iから角度(72度刻み)を計算する。
- 三角関数で円周上の(x, y)座標を求め、立方体を配置する。
- プログラムを実行し、マウスで視点を回して円形の配置を確認する。
《ヒント》
- 円周上の点の座標は、x = r × cos(θ)、y = r × sin(θ)で計算できる
- 5つの立方体を等間隔に配置するには、角度を72度(360÷5)ずつずらす
- 度数法をラジアンに変換するにはmath.radians()を使う
《考察ポイント》
- 立方体の数を3個、8個、12個に変えたとき、配置の見え方がどう変わるか試す。
- 真上から見たとき(マウス操作で視点を上に動かす)と斜めから見たときで、同じ円周配置がどう違って見えるかを観察する。
Pythonプログラム例
5つの立方体を72度間隔で円周上に配置するプログラムである。
from direct.showbase.ShowBase import ShowBase
import math
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
radius = 5
num_cubes = 5
center_y = 10
for i in range(num_cubes):
cube = self.loader.loadModel("models/box")
angle = i * 72
angle_rad = math.radians(angle)
x = radius * math.cos(angle_rad)
y = center_y + radius * math.sin(angle_rad)
z = 1
cube.setPos(x, y, z)
cube.setColor(1, 1, 1, 1)
cube.setTextureOff(1)
cube.reparentTo(self.render)
app = MyApp()
app.run()
マウスで視点を調整する。
3. カメラとビューポート
予備知識
視点と注視点
視点(camera position)はカメラの位置、注視点(look-at point)はカメラが向く目標点である。この2点で視線方向が決まる。
ビューポートと一人称視点
ビューポートは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()
ポイント
- setPos()で視点、lookAt()で注視点を設定する
- キー状態を辞書で管理することで、複数キーの同時押しに対応する
- カメラ位置を変えると、同じシーンの見え方が変わる
演習3
問題
位置(0, 0, 3)に青い立方体を配置し、カメラが立方体の周りを円運動するプログラム。カメラは立方体から半径10の距離を保ち、常に立方体を注視しながら、毎秒30度の速度で回転すること。カメラの高さはz=5に固定する。
《手順》
- 新しいプログラムを作成し、青い立方体を位置(0, 0, 3)に配置する。
- 累積角度を保持する変数を用意し、update関数内で毎フレーム dt × 30 だけ加算する。
- cos/sinでカメラの(x, y)座標を計算し、setPos()でカメラを移動する。
- lookAt()でカメラを立方体に向ける。
- プログラムを実行し、立方体を中心にカメラが回り続けることを確認する。
《ヒント》
- 円運動の座標計算にはmath.cos()とmath.sin()を使う
- 経過時間を累積し、それに応じて角度を変化させる
《考察ポイント》
- カメラの高さz=5を1や20に変えると、立方体の見え方(水平/俯瞰)がどう変わるかを試す。
- 半径や速度を変えて、立体感の伝わり方の違いを観察する。
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()
ポイント
- setColor()で色を設定する(RGBA形式)
- setTextureOff(1)でテクスチャを無効にすると、setColor()の色がそのまま現れる
演習4
問題
3つの立方体を横一列に配置し、それぞれ赤、緑、青の純色を割り当てよ。3つすべてがZ軸周り(Heading)に毎秒60度の速度で回転するようにせよ。
《手順》
- 新しいプログラムを作成する。
- 3つの立方体を生成し、それぞれ赤・緑・青の色とX座標をずらした位置を設定する。
- 生成した立方体をリストに格納する。
- update関数内でリストの全要素に対して回転処理を適用する。
- プログラムを実行し、3つの立方体が同期して回転することを確認する。
《ヒント》
- 3つの立方体をリストに格納すると、まとめて処理しやすい
- 赤は(1, 0, 0)、緑は(0, 1, 0)、青は(0, 0, 1)
- Z軸周りの回転にはsetH()を使う
《考察ポイント》
- setTextureOff(1)を外すと、色がどう変化するかを確認する。
- 光源を設定していない状態で、回転中に各面の明暗がどう見えるかを観察する。
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()
ポイント
- AmbientLightで環境光、DirectionalLightで指向性光源を作成する
- setLight()でシーンに光源を適用する
- 光源の方向はsetHpr()で設定する
演習5
問題
環境光(色:0.3, 0.3, 0.3)と指向性光源(色:1.0, 1.0, 1.0、方向:Heading=0, Pitch=-45)を設定し、白い立方体を5つ垂直に積み上げよ。立方体どうしが接触するように配置すること。
《手順》
- 新しいプログラムを作成する。
- 環境光と指向性光源を生成し、render.setLight()で適用する。
- for文で5つの立方体を生成し、Z座標を1.0ずつ加算して垂直に積み上げる。
- プログラムを実行し、各立方体の面に明暗が現れることを確認する。
《ヒント》
- 立方体(models/box)のデフォルトサイズは1.0(一辺の長さが1)であり、原点は立方体の底面の角に位置する
- 立方体どうしが接触するには、Z座標を1.0ずつずらせばよい
《考察ポイント》
- 光源の設定をすべてコメントアウトすると、5つの立方体は1つの白い塊に見える。ライティングと立体表現の関係を確認する。
- 指向性光源のPitchを-10、-45、-89と変えると、面ごとの明るさがどう変化するかを比較する。
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()
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()
ポイント
- reparentTo()で親子関係を設定する
- 親の変換は子に伝播する
- 親を1回動かすだけで複数の子が同時に動く
演習6
問題
太陽系モデル。中心に黄色の立方体(太陽)を配置し、その周りを青い立方体(地球)が半径3で公転するようにせよ。さらに、地球の周りを灰色の立方体(月)が半径1で公転するようにせよ。地球の公転周期は10秒、月の公転周期は3秒とすること。
《手順》
- 新しいプログラムを作成する。
- 太陽用、地球用、月用の3つの空ノード(NodePath)を作り、太陽→地球→月の順で親子関係を設定する。
- 各天体の立方体を対応する親ノードの子として配置する(地球と月は親ノードからオフセットさせる)。
- update関数内で地球の親ノードと月の親ノードを、それぞれ毎秒36度・毎秒120度で回転させる。
- プログラムを実行し、月が地球と一緒に公転しながら、さらに地球の周りも回ることを確認する。
《ヒント》
- 太陽を親、地球を太陽の子、月を地球の子として階層構造を作る
- 各親ノードを回転させることで公転を実現できる
- 10秒で1回転するには、毎秒36度(360÷10)回転させる
《考察ポイント》
- 月を地球の子ではなく太陽の直接の子にしたら、軌道がどう変わるかを予想し、実際に試して確かめる。
- 階層構造を使わずに同じ動きを実現するには、どのような座標計算が必要になるかを考える。
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()
ポイント
- 継続的な入力はキー状態管理、単発の入力はaccept()で処理する
- キー名に「-up」を付けるとキーを離した時のイベントになる
- 移動量にdtを掛けることで、フレームレート非依存の動きになる
演習7
問題
矢印キーで立方体を移動させ、Enterキーを押すと立方体の色がランダムに変わるプログラム。移動速度は毎秒3単位とする。
《手順》
- 新しいプログラムを作成し、randomモジュールをimportする。
- 矢印キー4方向についてキー状態管理用の辞書とaccept()を設定する。
- Enterキーには色をランダムに変更する関数を登録する。
- update関数でキー状態に応じて立方体を移動させる。
- プログラムを実行し、矢印キーで移動、Enterキーで色が変わることを確認する。
《ヒント》
- 矢印キーのキー名は'arrow_up'、'arrow_down'、'arrow_left'、'arrow_right'
- ランダムな色を生成するにはrandom.random()を3回呼び出す
《考察ポイント》
- 移動(継続的入力)と色変更(単発入力)が異なる方式で実装されている理由を、Enterキーを押しっぱなしにしたときの動作で確認する。
- 移動速度を毎秒10単位に上げたとき、操作感がどう変わるかを確認する。
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()
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()
ポイント
- math.sin()やmath.cos()で周期的な動きを作る
- 速度を表す変数を用意し、重力加速度を速度に、速度を位置に毎フレーム加算する
- 地面との衝突を判定し、速度を反転させて反発を表現する
演習8
問題
位置(0, 0, 10)から立方体を水平方向(Y軸正方向)に初速度5 m/sで投射し、重力加速度-9.8 m/s²の影響を受けて放物運動するシミュレーション。地面に到達したら反発係数0.7でバウンドするようにせよ。
《手順》
- 新しいプログラムを作成する。
- 立方体を位置(0, 0, 10)に配置し、初速度をLVector3(0, 5, 0)で保持する。
- update関数で、速度のZ成分に重力×dtを加算し、位置に速度×dtを加算する。
- 立方体のZ座標が0以下になったら、Z成分の速度を-0.7倍にして反発を実装する。
- プログラムを実行し、放物線を描きながらバウンドする動きを確認する。
《ヒント》
- 初速度はLVector3(0, 5, 0)で設定する
- X方向とY方向の速度は重力の影響を受けない
《考察ポイント》
- 反発係数を0.3、0.7、1.0に変えたとき、バウンドの様子がどう変わるかを比較する。
- 重力加速度を月面の値(約-1.6 m/s²)に変えると、軌道がどう変わるかを観察する。
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()
ポイント
- プレイヤー、アイテム、スコアなどの要素を組み合わせる
- 2点間の距離が一定値より小さければ「衝突」とみなす
- removeNode()でオブジェクトを削除する
- OnscreenTextで画面上にスコアなどを表示する
演習9
問題
タイマー付きの収集ゲーム。30秒以内に5つの赤い立方体を全て収集すればクリア、時間切れでゲームオーバーとなる。画面左上にスコア、右上に残り時間を表示すること。移動速度は毎秒7単位とする。
《手順》
- 本節のPythonプログラム例(アイテム収集ゲーム)をコピーする。
- アイテム数を5個、色を赤に変更し、移動速度を7に設定する。
- 制限時間(30秒)と経過時間を保持する変数を追加し、右上に残り時間を表示するOnscreenTextを作る。
- update関数で経過時間を累積し、残り時間を毎フレーム更新する。
- 全アイテム収集でクリア表示、残り時間0以下でゲームオーバー表示となる条件分岐を実装する。
- プログラムを実行し、クリアとゲームオーバーの両方を試す。
《ヒント》
- 経過時間を累積し、30秒から引くことで残り時間を計算する
- OnscreenTextのposで画面右上に配置できる(例:pos=(1.1, 0.9))
《考察ポイント》
- 制限時間とアイテム数のバランスを変えたとき、難易度がどう変わるかを試す。
- ゲーム状態(プレイ中/クリア/ゲームオーバー)をフラグで管理する意義を考える。フラグがない場合にどのような不具合が起きるかを試して確認する。
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 で作成しておくと、実行中に頂点を更新できる。
この章で使う主なクラスは次の通りである。
- GeomVertexData: 頂点データ(座標、法線、色など)の入れ物
- GeomVertexWriter: 頂点データへの書き込み
- GeomVertexRewriter: 頂点データの読み出しと書き換え
- GeomTriangles: 三角形の面(どの頂点を結ぶか)の定義
- GeomNode: シーングラフに登録するためのノード
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 演習
- 10.1 のプログラムで、波数と角振動数の値を変えて、波長と伝わる速さがどのように変わるかを確認しなさい。
- 10.1 のプログラムを変更し、原点ではなく 2 か所から波が出るようにしなさい(2 つの正弦波の和を高さとする)。
- 10.2 のプログラムで K の値を 0.6 に変えて実行し、計算が発散することを確認しなさい。確認後は 0.25 に戻すこと。
- 10.2 のプログラムで、マウスの左ボタンのクリックでも水滴が落ちるようにしなさい(self.accept('mouse1', self.drop) を追加する)。
- 10.2 のプログラムで、格子の分割数 N を 96 に増やし、表示の滑らかさと動作の速さがどう変わるかを調べなさい。