Panda3D 3次元ゲームエンジン基礎
Python用3Dゲームエンジン「Panda3D」の基礎を10項目に分けて学習する。環境構築から座標系、カメラ制御、メッシュとマテリアル、ライティング、エンティティの階層構造、入力処理、アニメーションと物理演算、簡易ゲーム制作、波動シミュレーションまで、実装例とともに解説する。各項目には3DCGとゲームエンジンの用語解説を含む。
【前提知識】
本教材はPythonの基本文法(変数、関数、クラス、リスト、辞書)を理解していることを前提とする。プログラミング未経験者は、先にPython入門を学習されたい。
【目次】
- 環境構築とサンプルコード実行
- 前準備
- 3D座標系とトランスフォーム(位置、回転、スケール)
- カメラとビューポート
- メッシュとマテリアル(色)
- ライティングとシェーディング
- エンティティ(Entity)の生成と制御
- 入力処理(キーボード)
- アニメーションと物理演算
- ゲーム制作(簡易的な3Dアクションゲーム)
- 波動シミュレーション(水面の波)
【サイト内の関連ページ】
1. 環境構築とサンプルコード実行
予備知識
Panda3Dのインストール
Panda3DはPythonのパッケージ管理システムpipを使用してインストールする。Python 3.10以降が必要である。
ゲームループとフレーム
3Dゲームエンジンはゲームループと呼ばれる繰り返し処理によって動作する。ゲームループは、入力処理、状態更新、描画の3段階を繰り返す処理であり、ゲーム実行中は毎秒数十回から数百回の頻度で実行される。この1回の繰り返しをフレームと呼ぶ。1秒間に処理できるフレーム数をFPS(Frames Per Second、フレームレート)と呼び、一般的なゲームでは60fpsを目標とする。
デルタ時間とフレームレート非依存
デルタ時間(delta time、dt)は、前回のフレームから現在のフレームまでの経過時間(秒)である。コンピュータの処理速度は環境によって異なるため、フレームレートは一定とは限らない。オブジェクトの移動量や回転量をデルタ時間で調整することで、フレームレート非依存の動きを実現できる。例えば、1秒間に10度回転させたい場合、毎フレーム「10 × dt」度回転させることで、フレームレートに関係なく一定速度で回転する。
Panda3Dのプログラムは以下の基本構造を持つ。
┌─────────────────────────────────────┐
│ 1. モジュールのインポート │
│ from direct.showbase.ShowBase │
│ import ShowBase │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ 2. アプリケーションクラスの定義 │
│ class MyApp(ShowBase) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ 3. オブジェクトの作成 │
│ self.loader.loadModel(...) │
└─────────────────────────────────────┘
↓
┌─────────────────────────────────────┐
│ 4. アプリケーションの実行 │
│ app.run() │
└─────────────────────────────────────┘
前準備
ここでは、最低限の事前準備について説明する。機械学習や深層学習を行う場合は、NVIDIA CUDA、Visual Studio、Cursorなどを追加でインストールすると便利である。これらについては別ページ https://www.kkaneko.jp/cc/dev/aiassist.html で解説している。
Python 3.12 のインストール
以下のいずれかの方法で Python 3.12 をインストールする。
方法1:winget によるインストール
Python がインストール済みの場合、この手順は不要である。管理者権限のコマンドプロンプトで以下を実行する。管理者権限のコマンドプロンプトを起動するには、Windows キーまたはスタートメニューから「cmd」と入力し、表示された「コマンドプロンプト」を右クリックして「管理者として実行」を選択する。
winget install -e --id Python.Python.3.12 --scope machine --silent --accept-source-agreements --accept-package-agreements --override "/quiet InstallAllUsers=1 PrependPath=1 AssociateFiles=1 InstallLauncherAllUsers=1"
--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動すると PATH が自動的に設定される。
方法2:インストーラーによるインストール
- 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' は、内部コマンドまたは外部コマンドとして認識されていません。」と表示される場合は、インストールが正常に完了していない。
AIエディタ Windsurf のインストール
Pythonプログラムの編集・実行には、AIエディタの利用を推奨する。ここでは、Windsurfのインストールを説明する。
Windsurf がインストール済みの場合、この手順は不要である。管理者権限のコマンドプロンプトで以下を実行する。管理者権限のコマンドプロンプトを起動するには、Windows キーまたはスタートメニューから「cmd」と入力し、表示された「コマンドプロンプト」を右クリックして「管理者として実行」を選択する。
winget install -e --id Codeium.Windsurf --scope machine --accept-source-agreements --accept-package-agreements --override "/VERYSILENT /NORESTART /MERGETASKS=!runcode,addtopath,associatewithfiles,!desktopicon"
powershell -Command "$env:Path=[System.Environment]::GetEnvironmentVariable('Path','Machine')+';'+[System.Environment]::GetEnvironmentVariable('Path','User'); windsurf --install-extension MS-CEINTL.vscode-language-pack-ja --force; windsurf --install-extension ms-python.python --force"
--scope machine を指定することで、システム全体(全ユーザー向け)にインストールされる。このオプションの実行には管理者権限が必要である。インストール完了後、コマンドプロンプトを再起動すると PATH が自動的に設定される。
【関連する外部ページ】
Windsurf の公式ページ: https://windsurf.com/
Panda3D のインストール手順
管理者権限でコマンドプロンプトを起動し、以下を実行する。
pip install panda3d
実装例
Panda3Dで単色のオレンジ色の立方体を回転させるプログラムである。デルタ時間を使用してフレームレート非依存の回転を実現している。
【マウス操作】Panda3Dではデフォルトで以下のマウス操作が使用可能である。左クリックドラッグで回転、右クリックドラッグでズーム、中クリックドラッグで平行移動。以降の実装例でも同様のマウス操作が使用可能である。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self) # Panda3Dエンジンの初期化
# 回転する立方体
self.cube = self.loader.loadModel("models/box") # 組み込みモデルの読み込み
self.cube.setScale(1) # スケールの設定(1.0が元のサイズ)
self.cube.setPos(0, 5, 0) # 位置の設定(X, Y, Z座標)
self.cube.setColor(1, 0.5, 0, 1) # 色の設定(R, G, B, A)
self.cube.setTextureOff(1) # テクスチャを無効化
self.cube.reparentTo(self.render) # シーングラフへの追加
# 更新タスクの追加
self.taskMgr.add(self.update, "updateTask") # 毎フレーム呼び出される関数を登録
# 前回のフレーム時刻を記録
self.prev_time = globalClock.getFrameTime() # エンジン起動からの経過時間を取得
def update(self, task):
# デルタ時間の計算
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time # 前フレームからの経過時間
self.prev_time = current_time
# 立方体の回転(フレームレート非依存)
self.cube.setH(self.cube.getH() + 50 * dt) # Y軸周りの回転(Heading)
self.cube.setP(self.cube.getP() + 30 * dt) # X軸周りの回転(Pitch)
return Task.cont # タスクを継続
app = MyApp()
app.run() # ゲームループの開始
実装例
Panda3Dで3Dシーンを構築するプログラムである。緑色の地面を配置し、その上に色相環に基づく3色(赤・緑・青)の立方体を横一列に並べる。画面左上にテキストを表示する。
from direct.showbase.ShowBase import ShowBase
from panda3d.core import TextNode
from panda3d.core import Mat4
from direct.gui.OnscreenText import OnscreenText
import colorsys
def hsv_to_rgb(h, s, v):
"""HSV色空間からRGB色空間への変換関数"""
return colorsys.hsv_to_rgb(h / 360.0, s, v)
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 地面
ground = self.loader.loadModel("models/box")
ground.setScale(20, 20, 0.1)
ground.setPos(-10, -10, 0)
ground.setColor(0, 0.7, 0, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
# 複数のオブジェクト(色相環に基づく配色)
for i in range(3):
cube = self.loader.loadModel("models/box")
hue = i * 120 # 色相を120度ずつずらす
r, g, b = hsv_to_rgb(hue, 1, 1)
cube.setColor(r, g, b, 1)
cube.setTextureOff(1)
cube.setPos(i * 2 - 2, 3, 0)
cube.reparentTo(self.render)
# テキスト表示(2D UIオーバーレイ)
self.text = OnscreenText(
text='Panda3D Test',
pos=(-0.5, 0.8), # 画面座標(-1~1の範囲)
scale=0.1,
fg=(1, 1, 1, 1),
align=TextNode.ALeft
)
# カメラ設定
self.disableMouse()
self.camera.setPos(0, -10, 4)
self.camera.lookAt(0, 2, 0.5)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
app = MyApp()
app.run()
ポイント
- pip installでインストールできる
- ShowBaseクラスを継承してアプリケーションクラスを作成する
- ShowBase.__init__(self)でアプリケーションを初期化する
- タスクマネージャにupdate()関数を登録して毎フレームの処理を記述する
演習問題1
問題
緑色の立方体を位置(0, 3, 2)に配置し、Z軸周り(Roll)に毎秒90度の速度で回転させるプログラムを作成せよ。回転はフレームレート非依存で実装すること。
ヒント
- Z軸周りの回転はsetR()メソッドを使用する
- 毎秒90度回転させるには、毎フレーム「90 × dt」度回転させる
- テクスチャ無効化にはsetTextureOff(1)を使用する
解答例
緑色の立方体をZ軸周りに回転させるプログラムである。デルタ時間を使用してフレームレート非依存の回転を実現している。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 緑色の立方体
self.cube = self.loader.loadModel("models/box")
self.cube.setPos(0, 3, 2)
self.cube.setColor(0, 1, 0, 1)
self.cube.setTextureOff(1)
self.cube.reparentTo(self.render)
# 更新タスクの追加
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
# Z軸周りの回転(毎秒90度)
self.cube.setR(self.cube.getR() + 90 * dt)
return Task.cont
app = MyApp()
app.run()
2. 3D座標系とトランスフォーム(位置、回転、スケール)
予備知識
3次元空間の座標系
3次元空間では、XYZ軸による直交座標系で位置を表現する。3つの数値(x, y, z)の組み合わせで任意の点の位置を一意に特定できる。
Panda3Dの座標系
Panda3DはZ-up右手座標系を採用している。右手座標系とは、右手の親指をX軸正方向、人差し指をY軸正方向に向けたとき、中指がZ軸正方向を指す座標系である。座標系を正しく理解することで、オブジェクトの配置やカメラ設定を意図通りに行える。
| 軸 | 正の方向 | 用途 |
|---|---|---|
| X軸 | 右 | 左右の位置 |
| Y軸 | 前 | 前後の位置 |
| Z軸 | 上 | 高さ |
Point3とVec3
Point3は3D空間内の位置(点)を表し、Vec3は方向と大きさを持つベクトルを表現する。点の減算でベクトルを得られ、点にベクトルを加えて新しい点を得られる。
ベクトルと基本演算
ベクトルは、大きさと方向を持つ量である。3次元空間では(x, y, z)の3成分で表現され、位置、速度、加速度などを表すために使用される。ベクトルの長さは√(x² + y² + z²)で計算される。
トランスフォーム(変換)
3Dオブジェクトの配置と姿勢は、3つの基本変換で制御される。移動(translation)は位置の変更、回転(rotation)は向きの変更、スケール(scale)は大きさの変更である。スケールは1.0が元のサイズ、2.0で2倍、0.5で半分になる。
オイラー角(HPR)
オイラー角は、H(Heading:Y軸周り)、P(Pitch:X軸周り)、R(Roll:Z軸周り)の3角度で物体の向きを表現する。object.setHpr(45, 0, 0)でY軸周りに45度回転させるなど、直感的な回転制御が可能である。
座標系の種類
ワールド座標系は空間全体の基準となる絶対座標である。ローカル座標系は親オブジェクトからの相対座標であり、親が移動すると子も一緒に移動する。
実装例
立方体の拡大・縮小と回転を行うプログラムである。Y軸周りに45度回転し、X方向に2倍の非均等スケールを適用する。初期位置(0, 5, 1)から相対移動でX方向に+2移動し、最終位置は(2, 5, 1)となる。
from direct.showbase.ShowBase import ShowBase
from panda3d.core import LVector3
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# エンティティの作成と配置
self.box = self.loader.loadModel("models/box")
self.box.setPos(0, 5, 1)
self.box.setColor(1, 0.5, 0, 1)
self.box.setTextureOff(1)
self.box.reparentTo(self.render)
# 回転の設定
self.box.setH(45) # Y軸周りに45度回転
# スケールの設定(非均等スケール)
self.box.setScale(2, 1, 1) # X方向に2倍
# 相対移動(ベクトル演算)
current_pos = self.box.getPos()
self.box.setPos(current_pos + LVector3(2, 0, 0))
# カメラ設定
self.disableMouse()
self.camera.setPos(5, -10, 3)
self.camera.lookAt(self.box)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
app = MyApp()
app.run()
ポイント
- setPos()、setH()/setP()/setR()、setScale()でオブジェクトを制御する
- LVector3を使用して3次元ベクトルを扱う
- Point3は位置、Vec3は方向を表現する
- Z軸が上方向、Y軸が前方向である
演習問題2
問題
位置(0, 10, 0)を中心として、半径5の円周上に5つの立方体を等間隔に配置せよ。各立方体の色は同一色とし、高さはすべてz=1とすること。
ヒント
- 円周上の点の座標は、x = r × cos(θ)、y = r × sin(θ)で計算できる
- 5つの立方体を等間隔に配置するには、角度を72度(360÷5)ずつずらす
- 度数法をラジアンに変換するには、math.radians()を使用する
解答例
5つの立方体を円周上に等間隔配置するプログラムである。三角関数を使用して72度間隔で配置する。
from direct.showbase.ShowBase import ShowBase
from panda3d.core import Mat4
import math
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
radius = 5
num_cubes = 5
center_y = 10
for i in range(num_cubes):
cube = self.loader.loadModel("models/box")
angle = i * 72
angle_rad = math.radians(angle)
x = radius * math.cos(angle_rad)
y = center_y + radius * math.sin(angle_rad)
z = 1
cube.setPos(x, y, z)
cube.setColor(1, 1, 1, 1)
cube.setTextureOff(1)
cube.reparentTo(self.render)
app = MyApp()
app.run()
マウスで視点を調整する。
3. カメラとビューポート
予備知識
視点と注視点
視点(camera position)はカメラの位置、注視点(look-at point)はカメラが向く目標点である。この2点で視線方向が決まる。
ビューポートと視点制御
ビューポートは3D空間が表示される画面領域である。一人称視点はプレイヤーの目線からシーンを見る方式で、キャラクターの目の位置にカメラを配置して実装する。
視野角(Field of View, FOV)
視野角はカメラが捉える視界の広さである。値が大きいほど広い範囲が見えるが歪みも大きくなる。一般的なゲームでは60度から90度の範囲で設定する。
クリッピング面
クリッピング面は表示範囲を定める面である。近接面(near plane)と遠方面(far plane)の間にあるオブジェクトのみが描画される。描画負荷の最適化に有効である。
実装例
一人称視点のキャラクター移動を実装するプログラムである。WASDキーでカメラを前後左右に移動できる。キー状態管理方式により複数キーの同時押下に対応している。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
import colorsys
def hsv_to_rgb(h, s, v):
"""HSV色空間からRGB色空間への変換関数"""
return colorsys.hsv_to_rgb(h / 360.0, s, v)
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# オブジェクト配置(色相環に基づく配色)
for i in range(5):
cube = self.loader.loadModel("models/box")
hue = i * 72
r, g, b = hsv_to_rgb(hue, 1, 1)
cube.setColor(r, g, b, 1)
cube.setTextureOff(1)
cube.setPos(i * 3, 0, 0)
cube.reparentTo(self.render)
# 地面
ground = self.loader.loadModel("models/box")
ground.setScale(40, 40, 0.1)
ground.setPos(-20, -20, 0)
ground.setColor(0.5, 0.5, 0.5, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
# プレイヤーカメラ設定
self.player_pos = [0, -10, 2]
self.disableMouse()
self.camera.setPos(self.player_pos[0], self.player_pos[1], self.player_pos[2])
self.camera.lookAt(0, 0, 0)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
# キー入力の設定
self.keys = {'w': False, 'a': False, 's': False, 'd': False}
self.accept('w', self.setKey, ['w', True])
self.accept('w-up', self.setKey, ['w', False])
self.accept('a', self.setKey, ['a', True])
self.accept('a-up', self.setKey, ['a', False])
self.accept('s', self.setKey, ['s', True])
self.accept('s-up', self.setKey, ['s', False])
self.accept('d', self.setKey, ['d', True])
self.accept('d-up', self.setKey, ['d', False])
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def setKey(self, key, value):
self.keys[key] = value
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
speed = 5
if self.keys['w']:
self.player_pos[1] += speed * dt
if self.keys['s']:
self.player_pos[1] -= speed * dt
if self.keys['a']:
self.player_pos[0] -= speed * dt
if self.keys['d']:
self.player_pos[0] += speed * dt
self.camera.setPos(self.player_pos[0], self.player_pos[1], self.player_pos[2])
return Task.cont
app = MyApp()
app.run()
ポイント
- キー入力処理とタスクを組み合わせて一人称視点を実装できる
- WASDキーで移動を実装する
- カメラの位置と向きを制御することで視点制御を行う
- setPos()で視点、lookAt()で注視点を設定する
演習問題3
問題
位置(0, 0, 3)に青い立方体を配置し、カメラが立方体の周りを円運動するプログラムを作成せよ。カメラは立方体から半径10の距離を保ち、常に立方体を注視しながら、毎秒30度の速度で回転すること。
ヒント
- 円運動の座標計算にはmath.cos()とmath.sin()を使用する
- 経過時間を累積し、それに応じて角度を変化させる
- カメラの高さはz=5に固定する
解答例
カメラを円運動させるプログラムである。カメラは半径10の円周上を毎秒30度の速度で回転しながら、常に立方体を注視する。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
import math
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.cube = self.loader.loadModel("models/box")
self.cube.setPos(0, 0, 3)
self.cube.setColor(0, 0, 1, 1)
self.cube.setTextureOff(1)
self.cube.reparentTo(self.render)
self.radius = 10
self.angle = 0
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
self.angle += 30 * dt
angle_rad = math.radians(self.angle)
camera_x = self.radius * math.cos(angle_rad)
camera_y = self.radius * math.sin(angle_rad)
camera_z = 5
self.camera.setPos(camera_x, camera_y, camera_z)
self.camera.lookAt(self.cube)
return Task.cont
app = MyApp()
app.run()
4. メッシュとマテリアル(色)
予備知識
メッシュと基本図形
メッシュ(mesh)は3Dオブジェクトの形状を定義する頂点と面の集合である。Panda3DではloadModel()メソッドでモデルを読み込む。組み込みの基本図形として、'models/box'(立方体)、'models/sphere'(球体)、'models/plane'(平面)、'models/cylinder'(円柱)などがある。
3Dモデルのファイル形式
3Dモデルのファイル形式にはOBJ、FBXなどの標準形式がある。Panda3Dでは独自のegg形式も使用される。これらの形式は形状、材質、テクスチャ、アニメーションなどの情報を含む。
色の表現
RGB色空間は赤(Red)、緑(Green)、青(Blue)の3成分で色を表現する方式である。各成分は0.0~1.0の範囲で指定する。(1, 0, 0)は赤、(0, 1, 0)は緑、(0, 0, 1)は青、(1, 1, 1)は白、(0, 0, 0)は黒を表す。
実装例
2つの立方体(テクスチャ付き、テクスチャなしのオレンジ色)と緑色の地面を配置するプログラムである。
from direct.showbase.ShowBase import ShowBase
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# テクスチャ付き立方体
self.textured_cube = self.loader.loadModel("models/box")
self.textured_cube.setPos(-3, 10, 0)
self.textured_cube.setColor(1, 1, 1, 1)
self.textured_cube.reparentTo(self.render)
# テクスチャなし立方体
self.colored_cube = self.loader.loadModel("models/box")
self.colored_cube.setPos(0, 10, 0)
self.colored_cube.setColor(1, 0.5, 0, 1)
self.colored_cube.setTextureOff(1)
self.colored_cube.reparentTo(self.render)
# 地面
ground = self.loader.loadModel("models/box")
ground.setScale(20, 20, 0.1)
ground.setPos(-10, 0, 0)
ground.setColor(0, 0.7, 0, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
# カメラ設定
self.disableMouse()
self.camera.setPos(-1.5, -12, 6)
self.camera.lookAt(-1.5, 10, 1)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
app = MyApp()
app.run()
ポイント
- setColor()メソッドで色を設定する
- Panda3Dには複数の組み込みモデルがある
- 色はRGBA形式で指定する
演習問題4
問題
3種類の立方体を横一列に配置し、それぞれ赤、緑、青の純色を割り当てよ。3つの立方体すべてがY軸周りに毎秒60度の速度で回転するようにせよ。
ヒント
- 3つの図形をリストに格納すると、まとめて処理しやすい
- 赤は(1, 0, 0)、緑は(0, 1, 0)、青は(0, 0, 1)
解答例
3つの立方体を赤、緑、青に色付けし、同時に回転させるプログラムである。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
models = [
('models/box', (1, 0, 0, 1)),
('models/box', (0, 1, 0, 1)),
('models/box', (0, 0, 1, 1))
]
self.objects = []
for i, (model_name, color) in enumerate(models):
obj = self.loader.loadModel(model_name)
obj.setPos(i * 2 - 2, 10, 0)
obj.setColor(*color)
obj.setTextureOff(1)
obj.reparentTo(self.render)
self.objects.append(obj)
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
for obj in self.objects:
obj.setH(obj.getH() + 60 * dt)
return Task.cont
app = MyApp()
app.run()
5. ライティングとシェーディング
予備知識
光源の種類
光源は3D空間で光を発するオブジェクトである。ライティングを設定することで、オブジェクトに立体感と陰影を与えることができる。Panda3Dでは主に2種類の光源を使用する。環境光(AmbientLight)は全方向から均一に照らす光で、影を作らない。指向性光源(DirectionalLight)は太陽光のように特定方向から平行に照らす光で、明確な影を作る。
【注意】光源を設定しない場合、オブジェクトは平坦に見える。複数の光源を組み合わせることでリアルな表現が可能となる。
HSV色空間と色相環
HSV色空間は色相(Hue:0~360度)、彩度(Saturation)、明度(Value)の3成分で色を表現する。色相環は色相を円環状に配置したもので、等間隔に色を配置する際に便利である。例えば5つのオブジェクトに異なる色を割り当てる場合、色相を72度(360÷5)ずつずらす。
実装例
色相環に基づいた5色の立方体を配置し、環境光と指向性光源で照明するプログラムである。ライティングにより立体感が生まれる。
from direct.showbase.ShowBase import ShowBase
from panda3d.core import AmbientLight, DirectionalLight
from panda3d.core import Mat4
import colorsys
def hsv_to_rgb(h, s, v):
return colorsys.hsv_to_rgb(h / 360.0, s, v)
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 環境光の設定
ambient = AmbientLight('ambient')
ambient.setColor((0.4, 0.4, 0.4, 1))
ambient_np = self.render.attachNewNode(ambient)
self.render.setLight(ambient_np)
# 指向性光源
sun = DirectionalLight('sun')
sun.setColor((0.8, 0.8, 0.8, 1))
sun_np = self.render.attachNewNode(sun)
sun_np.setHpr(45, -60, 0)
self.render.setLight(sun_np)
# 地面
ground = self.loader.loadModel("models/box")
ground.setScale(40, 40, 0.1)
ground.setPos(-20, -20, 0)
ground.setColor(0.5, 0.5, 0.5, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
# オブジェクト配置
for i in range(5):
cube = self.loader.loadModel("models/box")
cube.setPos(i * 3 - 6, 0, 1)
hue = i * 72
r, g, b = hsv_to_rgb(hue, 1, 1)
cube.setColor(r, g, b, 1)
cube.setTextureOff(1)
cube.reparentTo(self.render)
# カメラ設定
self.disableMouse()
self.camera.setPos(0, -20, 10)
self.camera.lookAt(0, 0, 0)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
app = MyApp()
app.run()
ポイント
- AmbientLightで環境光を設定する
- DirectionalLightで平行光を作成する
- setLight()でシーンに光源を適用する
- 光源の方向はsetHpr()で設定する
演習問題5
問題
環境光(色:0.3, 0.3, 0.3)と指向性光源(色:1.0, 1.0, 1.0、方向:Heading=0, Pitch=-45)を設定し、白い立方体を5つ垂直に積み上げよ。立方体が接触するように配置すること。
ヒント
- 立方体のデフォルトサイズは2.0(-1から1の範囲)である
- 立方体が接触するには、中心間の距離が2.0になる必要がある
解答例
白い立方体を5個垂直に積み上げるプログラムである。
from direct.showbase.ShowBase import ShowBase
from panda3d.core import AmbientLight, DirectionalLight
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 環境光
ambient = AmbientLight('ambient')
ambient.setColor((0.3, 0.3, 0.3, 1))
ambient_np = self.render.attachNewNode(ambient)
self.render.setLight(ambient_np)
# 指向性光源
sun = DirectionalLight('sun')
sun.setColor((1.0, 1.0, 1.0, 1))
sun_np = self.render.attachNewNode(sun)
sun_np.setHpr(0, -45, 0)
self.render.setLight(sun_np)
# 立方体を積み上げる
for i in range(5):
cube = self.loader.loadModel("models/box")
cube.setPos(0, 0, 1 + i * 2)
cube.setColor(1, 1, 1, 1)
cube.setTextureOff(1)
cube.reparentTo(self.render)
# カメラ設定
self.disableMouse()
self.camera.setPos(10, -15, 5)
self.camera.lookAt(0, 0, 5)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
app = MyApp()
app.run()
6. エンティティ(Entity)の生成と制御
予備知識
シーングラフと階層構造
シーングラフ(Scene Graph)は3D空間内のオブジェクトの階層構造を管理するツリー構造である。各オブジェクトはノードとして表現され、親子関係で組織化される。子オブジェクトは親の変換(移動、回転、スケール)の影響を受ける。これを変換の伝播と呼ぶ。
【メリット】階層構造を使用することで、複数のパーツからなる複雑なオブジェクト(車体と車輪など)を効率的に制御できる。親を移動させるだけですべての子も一緒に移動する。
NodePath
NodePathはシーングラフ内のノードを参照するオブジェクトである。attachNewNode()で階層構造を構築し、reparentTo()で親子関係を設定する。renderは最上位ノードである。
階層構造の例:
vehicle (親)
|
+-- body (車体)
+-- wheel_fl (前左車輪)
+-- wheel_fr (前右車輪)
+-- wheel_rl (後左車輪)
+-- wheel_rr (後右車輪)
親を移動 → 全ての子も移動
子を移動 → 親は影響を受けない
実装例
親子関係を持つ車オブジェクトを作成し、前方に移動させるプログラムである。親ノードを移動させることで、車体と車輪がすべて一緒に移動する。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import NodePath
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 親エンティティ(空のノード)
self.vehicle = NodePath("vehicle")
self.vehicle.reparentTo(self.render)
# 車体本体
body = self.loader.loadModel("models/box")
body.setScale(2, 3, 1)
body.setColor(1, 0, 0, 1)
body.setTextureOff(1)
body.reparentTo(self.vehicle)
# 車輪の作成(4つ)
wheel_positions = [
(0, 2, -0.5),
(1.5, 2, -0.5),
(0, 0, -0.5),
(1.5, 0, -0.5)
]
for pos in wheel_positions:
wheel = self.loader.loadModel("models/box")
wheel.setScale(0.5)
wheel.setColor(0, 0, 0, 1)
wheel.setPos(pos[0], pos[1], pos[2])
wheel.setTextureOff(1)
wheel.reparentTo(self.vehicle)
# カメラ設定
self.disableMouse()
self.camera.setPos(0, -15, 5)
self.camera.lookAt(self.vehicle)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
current_y = self.vehicle.getY()
self.vehicle.setY(current_y + 3 * dt)
if self.vehicle.getY() > 10:
self.vehicle.setY(-10)
return Task.cont
app = MyApp()
app.run()
ポイント
- reparentTo()メソッドで親子関係を設定する
- 親の変換は子に伝播する
- タスクに登録したupdate()関数で毎フレームの処理を記述する
- attachNewNode()で階層構造を構築する
演習問題6
問題
太陽系モデルを作成せよ。中心に黄色の立方体(太陽)を配置し、その周りを青い立方体(地球)が半径3で公転するようにせよ。さらに、地球の周りを灰色の立方体(月)が半径1で公転するようにせよ。地球の公転周期は10秒、月の公転周期は3秒とすること。
ヒント
- 太陽を親、地球を太陽の子、月を地球の子として階層構造を作る
- 各親ノードを回転させることで公転を実現できる
- 10秒で1回転するには、毎秒36度(360÷10)回転させる
解答例
太陽・地球・月の階層的な天体運動システムである。階層構造により、月は地球の公転運動を継承しながら自身の公転も行う。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import NodePath
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 太陽
self.sun_system = NodePath("sun_system")
self.sun_system.reparentTo(self.render)
sun = self.loader.loadModel("models/box")
sun.setScale(0.8)
sun.setColor(1, 1, 0, 1)
sun.setTextureOff(1)
sun.reparentTo(self.sun_system)
# 地球システム
self.earth_system = NodePath("earth_system")
self.earth_system.reparentTo(self.sun_system)
earth = self.loader.loadModel("models/box")
earth.setScale(0.3)
earth.setPos(3, 0, 0)
earth.setColor(0, 0, 1, 1)
earth.setTextureOff(1)
earth.reparentTo(self.earth_system)
# 月システム
self.moon_system = NodePath("moon_system")
self.moon_system.setPos(3, 0, 0)
self.moon_system.reparentTo(self.earth_system)
moon = self.loader.loadModel("models/box")
moon.setScale(0.15)
moon.setPos(1, 0, 0)
moon.setColor(0.5, 0.5, 0.5, 1)
moon.setTextureOff(1)
moon.reparentTo(self.moon_system)
# カメラ設定
self.disableMouse()
self.camera.setPos(0, -15, 8)
self.camera.lookAt(0, 0, 0)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
self.earth_system.setH(self.earth_system.getH() + 36 * dt)
self.moon_system.setH(self.moon_system.getH() + 120 * dt)
return Task.cont
app = MyApp()
app.run()
7. 入力処理(キーボード)
予備知識
イベント駆動プログラミング
イベント駆動プログラミングでは、キー入力やマウス操作をイベントとして検出し、コールバック関数で処理を実行する。Panda3Dではaccept()メソッドでイベントを登録する。
キー入力の検出
Panda3Dではaccept()メソッドでキーイベントを検出する。継続的な入力(押し続ける移動操作など)にはキー状態を辞書で管理する方法が適している。単発の入力(ジャンプなど)にはイベント駆動方式が適している。
| 入力方法 | 用途 | 例 |
|---|---|---|
| キー状態管理 | 継続的な入力 | 移動、回転 |
| accept()メソッド | 単発の入力 | ジャンプ、攻撃 |
実装例
キャラクター制御とジャンプ機能を実装したプログラムである。WASDキーで水平移動、スペースキーでジャンプを行う。重力により放物線軌道を描く。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# プレイヤー
self.player = self.loader.loadModel("models/box")
self.player.setPos(0, 0, 0)
self.player.setColor(0.5, 0.7, 1, 1)
self.player.setTextureOff(1)
self.player.reparentTo(self.render)
# 地面
ground = self.loader.loadModel("models/box")
ground.setScale(20, 20, 0.1)
ground.setPos(0, 0, 0)
ground.setColor(0, 0.7, 0, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
self.speed = 5
self.jump_force = 0
self.is_jumping = False
# キー入力の設定
self.keys = {'w': False, 'a': False, 's': False, 'd': False}
self.accept('w', self.setKey, ['w', True])
self.accept('w-up', self.setKey, ['w', False])
self.accept('a', self.setKey, ['a', True])
self.accept('a-up', self.setKey, ['a', False])
self.accept('s', self.setKey, ['s', True])
self.accept('s-up', self.setKey, ['s', False])
self.accept('d', self.setKey, ['d', True])
self.accept('d-up', self.setKey, ['d', False])
self.accept('space', self.jump)
# カメラ設定
self.disableMouse()
self.camera.setPos(0, -15, 5)
self.camera.lookAt(self.player)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def setKey(self, key, value):
self.keys[key] = value
def jump(self):
if not self.is_jumping:
self.jump_force = 5
self.is_jumping = True
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
if self.keys['w']:
self.player.setY(self.player.getY() + self.speed * dt)
if self.keys['s']:
self.player.setY(self.player.getY() - self.speed * dt)
if self.keys['a']:
self.player.setX(self.player.getX() - self.speed * dt)
if self.keys['d']:
self.player.setX(self.player.getX() + self.speed * dt)
self.jump_force += -20 * dt
new_z = self.player.getZ() + self.jump_force * dt
self.player.setZ(new_z)
h = 0
if self.player.getZ() <= h:
self.player.setZ(h)
self.jump_force = 0
self.is_jumping = False
return Task.cont
app = MyApp()
app.run()
ポイント
- キー状態を管理する辞書で継続的なキー入力を検出する
- accept()メソッドで単発のキーイベントを処理する
- デルタ時間を使用してフレームレート非依存の動きを実現する
- -upサフィックスでキーを離した時の処理を実装する
演習問題7
問題
矢印キーで立方体を移動させ、Enterキーを押すと立方体の色がランダムに変わるプログラムを作成せよ。移動速度は毎秒3単位とする。
ヒント
- 矢印キーのキー名は'arrow_up'、'arrow_down'、'arrow_left'、'arrow_right'
- ランダムな色を生成するにはrandom.random()を3回呼び出す
解答例
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
import random
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.cube = self.loader.loadModel("models/box")
self.cube.setPos(0, 0, 0)
self.cube.setColor(1, 1, 1, 1)
self.cube.setTextureOff(1)
self.cube.reparentTo(self.render)
ground = self.loader.loadModel("models/box")
ground.setScale(10, 10, 0.1)
ground.setPos(0, 0, 0)
ground.setColor(0.5, 0.5, 0.5, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
self.speed = 3
self.keys = {
'arrow_up': False,
'arrow_down': False,
'arrow_left': False,
'arrow_right': False
}
self.accept('arrow_up', self.setKey, ['arrow_up', True])
self.accept('arrow_up-up', self.setKey, ['arrow_up', False])
self.accept('arrow_down', self.setKey, ['arrow_down', True])
self.accept('arrow_down-up', self.setKey, ['arrow_down', False])
self.accept('arrow_left', self.setKey, ['arrow_left', True])
self.accept('arrow_left-up', self.setKey, ['arrow_left', False])
self.accept('arrow_right', self.setKey, ['arrow_right', True])
self.accept('arrow_right-up', self.setKey, ['arrow_right', False])
self.accept('enter', self.changeColor)
self.disableMouse()
self.camera.setPos(0, -15, 8)
self.camera.lookAt(self.cube)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def setKey(self, key, value):
self.keys[key] = value
def changeColor(self):
r = random.random()
g = random.random()
b = random.random()
self.cube.setColor(r, g, b, 1)
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
if self.keys['arrow_up']:
self.cube.setY(self.cube.getY() + self.speed * dt)
if self.keys['arrow_down']:
self.cube.setY(self.cube.getY() - self.speed * dt)
if self.keys['arrow_left']:
self.cube.setX(self.cube.getX() - self.speed * dt)
if self.keys['arrow_right']:
self.cube.setX(self.cube.getX() + self.speed * dt)
return Task.cont
app = MyApp()
app.run()
8. アニメーションと物理演算
予備知識
アニメーションの基本
アニメーションは、オブジェクトの属性(位置、回転、スケールなど)を時間とともに変化させることで実現する。三角関数(sin、cos)を使用すると周期的な動きを作成できる。sin関数は-1から1の間を周期的に変化するため、上下運動や拡大縮小に適している。
物理演算の基本
物理演算では、ニュートンの運動法則に基づいてオブジェクトの動きをシミュレートする。速度は移動の速さと方向、加速度は速度の変化率である。重力加速度は地表で約-9.8 m/s²(下向き)である。
運動方程式と衝突判定
物理シミュレーションでは、加速度を速度に加算し(velocity += acceleration × dt)、速度を位置に加算する(position += velocity × dt)。衝突判定では、オブジェクトが接触しているかを判定し、位置を補正して速度を反転させる。反発係数は跳ね返りの強さを示し、0.0で跳ねない、1.0でエネルギー損失なしとなる。
【注意】dtが大きすぎると計算が不安定になるため、上限を設定することが望ましい。
実装例(アニメーション)
3種類のアニメーションパターンを示すプログラムである。赤いキューブは連続回転、青いキューブはsin関数による上下動、緑のキューブはsin関数による拡大縮小を行う。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import Mat4
import math
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 地面
ground = self.loader.loadModel("models/box")
ground.setScale(20, 20, 0.1)
ground.setPos(0, 0, 0)
ground.setColor(0.5, 0.5, 0.5, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
# 回転するキューブ
self.rotating_cube = self.loader.loadModel("models/box")
self.rotating_cube.setColor(1, 0, 0, 1)
self.rotating_cube.setPos(-4, 0, 1)
self.rotating_cube.setTextureOff(1)
self.rotating_cube.reparentTo(self.render)
# 上下移動する立方体
self.bouncing_cube = self.loader.loadModel("models/box")
self.bouncing_cube.setColor(0, 0, 1, 1)
self.bouncing_cube.setPos(0, 0, 1)
self.bouncing_cube.setTextureOff(1)
self.bouncing_cube.reparentTo(self.render)
# 拡大縮小する立方体
self.scaling_cube = self.loader.loadModel("models/box")
self.scaling_cube.setColor(0, 1, 0, 1)
self.scaling_cube.setPos(4, 0, 1)
self.scaling_cube.setTextureOff(1)
self.scaling_cube.reparentTo(self.render)
# カメラ設定
self.disableMouse()
self.camera.setPos(0, -12, 5)
self.camera.lookAt(0, 0, 1)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
self.elapsed_time = 0
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
self.elapsed_time += dt
self.rotating_cube.setH(self.rotating_cube.getH() + 100 * dt)
z_pos = 1 + math.sin(self.elapsed_time * 3) * 0.5
self.bouncing_cube.setZ(z_pos)
scale_factor = 1 + math.sin(self.elapsed_time * 2) * 0.3
self.scaling_cube.setScale(1, 1, scale_factor)
return Task.cont
app = MyApp()
app.run()
実装例(物理演算)
複数立方体の自由落下と反発を実装したプログラムである。重力で加速しながら地面に到達し、反発係数0.5で跳ね返る。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import LVector3
from panda3d.core import Mat4
import colorsys
def hsv_to_rgb(h, s, v):
return colorsys.hsv_to_rgb(h / 360.0, s, v)
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 地面
self.ground = self.loader.loadModel("models/box")
self.ground.setScale(40, 40, 0.1)
self.ground.setPos(-20, -20, 0)
self.ground.setColor(0, 0.7, 0, 1)
self.ground.setTextureOff(1)
self.ground.reparentTo(self.render)
self.ground_top = 0.05
self.box_half_height = 0.5
# 落下する箱
self.boxes = []
self.velocities = []
for i in range(5):
box = self.loader.loadModel("models/box")
hue = i * 72
r, g, b = hsv_to_rgb(hue, 1, 1)
box.setColor(r, g, b, 1)
box.setPos(i * 2 - 4, 0, 10 + i * 2)
box.setTextureOff(1)
box.reparentTo(self.render)
self.boxes.append(box)
self.velocities.append(LVector3(0, 0, 0))
self.gravity = -9.8
# カメラ設定
self.disableMouse()
self.camera.setPos(0, -20, 5)
self.camera.lookAt(0, 0, 2)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
for i, box in enumerate(self.boxes):
velocity = self.velocities[i]
velocity.z += self.gravity * dt
new_pos = box.getPos() + velocity * dt
box.setPos(new_pos)
collision_z = 0
if box.getZ() <= collision_z:
box.setZ(collision_z)
velocity.z = -velocity.z * 0.5
if abs(velocity.z) < 0.1:
velocity.z = 0
return Task.cont
app = MyApp()
app.run()
ポイント
- math.sin()やmath.cos()で周期的な動きを作成できる
- 物理演算ではオブジェクトに速度を管理する変数を追加する
- 重力加速度を速度に加算し、速度を位置に加算する
- 衝突判定を実装することで物理シミュレーションを行う
演習問題8
問題
位置(0, 0, 10)から立方体を水平方向(Y軸正方向)に初速度5 m/sで投射し、重力加速度-9.8 m/s²の影響を受けて放物運動するシミュレーションを作成せよ。地面に到達したら反発係数0.7でバウンドするようにせよ。
ヒント
- 初速度はLVector3(0, 5, 0)で設定する
- X方向とY方向の速度は重力の影響を受けない
解答例
放物運動と地面反発を実装したプログラムである。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import LVector3
from panda3d.core import Mat4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.cube = self.loader.loadModel("models/box")
self.cube.setPos(0, 0, 10)
self.cube.setColor(1, 0, 0, 1)
self.cube.setTextureOff(1)
self.cube.reparentTo(self.render)
self.cube_velocity = LVector3(0, 5, 0)
ground = self.loader.loadModel("models/box")
ground.setScale(200, 200, 0.1)
ground.setPos(-100, -100, 0)
ground.setColor(0, 0.7, 0, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
self.ground_top = 0.05
self.box_half_height = 0.5
self.gravity = -9.8
self.restitution = 0.7
self.disableMouse()
self.camera.setPos(0, -25, 10)
self.camera.lookAt(0, 10, 5)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
self.cube_velocity.z += self.gravity * dt
new_pos = self.cube.getPos() + self.cube_velocity * dt
self.cube.setPos(new_pos)
collision_z = 0
if self.cube.getZ() <= collision_z:
self.cube.setZ(collision_z)
self.cube_velocity.z = -self.cube_velocity.z * self.restitution
if abs(self.cube_velocity.z) < 0.1:
self.cube_velocity.z = 0
return Task.cont
app = MyApp()
app.run()
9. ゲーム制作(簡易的な3Dアクションゲーム)
予備知識
ゲーム制作の基本要素
3Dゲームは、プレイヤー、環境、ゲームロジック(スコア、クリア条件)を組み合わせて構成される。移動制御、衝突判定、スコア管理、UI表示などを統合することで、インタラクティブなゲーム体験を実現できる。
実装例
アイテム収集ゲームの基本構造を示すプログラムである。WASDキーでプレイヤーを操作し、ランダム配置された金色の立方体を収集する。プレイヤーとアイテム間の距離で衝突判定を行い、収集するとスコアが加算される。全アイテム収集でゲームクリアとなる。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import TextNode
from panda3d.core import Mat4
from direct.gui.OnscreenText import OnscreenText
import random
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# プレイヤー
self.player = self.loader.loadModel("models/box")
self.player.setPos(0, 0, 1)
self.player.setColor(0.5, 0.7, 1, 1)
self.player.setTextureOff(1)
self.player.reparentTo(self.render)
# 地面
ground = self.loader.loadModel("models/box")
ground.setScale(100, 100, 0.1)
ground.setPos(-50, -50, 0)
ground.setColor(0, 0.7, 0, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
# 収集アイテム
self.collectibles = []
for i in range(10):
item = self.loader.loadModel("models/box")
item.setColor(1, 0.84, 0, 1)
x = random.uniform(-20, 20)
y = random.uniform(-20, 20)
item.setPos(x, y, 0.5)
item.setScale(0.5)
item.setTextureOff(1)
item.reparentTo(self.render)
self.collectibles.append(item)
# スコア表示
self.score = 0
self.score_text = OnscreenText(
text=f'Score: {self.score}',
pos=(-1.3, 0.9),
scale=0.1,
fg=(1, 1, 1, 1),
align=TextNode.ALeft
)
self.speed = 5
self.keys = {'w': False, 'a': False, 's': False, 'd': False}
self.accept('w', self.setKey, ['w', True])
self.accept('w-up', self.setKey, ['w', False])
self.accept('a', self.setKey, ['a', True])
self.accept('a-up', self.setKey, ['a', False])
self.accept('s', self.setKey, ['s', True])
self.accept('s-up', self.setKey, ['s', False])
self.accept('d', self.setKey, ['d', True])
self.accept('d-up', self.setKey, ['d', False])
self.disableMouse()
self.camera.setPos(0, -15, 10)
self.camera.lookAt(0, 0, 0)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def setKey(self, key, value):
self.keys[key] = value
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
if self.keys['w']:
self.player.setY(self.player.getY() + self.speed * dt)
if self.keys['s']:
self.player.setY(self.player.getY() - self.speed * dt)
if self.keys['a']:
self.player.setX(self.player.getX() - self.speed * dt)
if self.keys['d']:
self.player.setX(self.player.getX() + self.speed * dt)
player_pos = self.player.getPos()
for item in self.collectibles[:]:
item_pos = item.getPos()
distance = (player_pos - item_pos).length()
if distance < 1.2:
item.removeNode()
self.collectibles.remove(item)
self.score += 10
self.score_text.setText(f'Score: {self.score}')
if len(self.collectibles) == 0:
self.score_text.setText('Game Clear!')
self.camera.setPos(player_pos.x, player_pos.y - 15, 10)
return Task.cont
app = MyApp()
app.run()
ポイント
- 複数の要素(プレイヤー、アイテム、スコア)を組み合わせる
- removeNode()でオブジェクトを削除する
- OnscreenTextクラスで画面上に文字を表示する
演習問題9
問題
タイマー付きの収集ゲームを作成せよ。30秒以内に5つの赤い立方体を全て収集すればクリア、時間切れでゲームオーバーとなる。画面左上にスコア、右上に残り時間を表示すること。移動速度は毎秒7単位とする。
ヒント
- 経過時間を累積し、30秒から引くことで残り時間を計算する
- OnscreenTextのposで画面右上に配置できる(例:pos=(1.1, 0.9))
解答例
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import TextNode
from panda3d.core import Mat4
from direct.gui.OnscreenText import OnscreenText
import random
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.player = self.loader.loadModel("models/box")
self.player.setPos(0, 0, 1)
self.player.setColor(0, 0, 1, 1)
self.player.setTextureOff(1)
self.player.reparentTo(self.render)
ground = self.loader.loadModel("models/box")
ground.setScale(100, 100, 0.1)
ground.setPos(-50, -50, 0)
ground.setColor(0, 0.7, 0, 1)
ground.setTextureOff(1)
ground.reparentTo(self.render)
self.collectibles = []
for i in range(5):
item = self.loader.loadModel("models/box")
item.setColor(1, 0, 0, 1)
x = random.uniform(-10, 10)
y = random.uniform(-10, 10)
item.setPos(x, y, 0.5)
item.setScale(0.5)
item.setTextureOff(1)
item.reparentTo(self.render)
self.collectibles.append(item)
self.score = 0
self.score_text = OnscreenText(
text=f'Score: {self.score}',
pos=(-1.3, 0.9),
scale=0.1,
fg=(1, 1, 1, 1),
align=TextNode.ALeft
)
self.time_limit = 30
self.elapsed_time = 0
self.timer_text = OnscreenText(
text=f'Time: {self.time_limit:.1f}',
pos=(1.1, 0.9),
scale=0.1,
fg=(1, 1, 1, 1),
align=TextNode.ARight
)
self.speed = 7
self.game_over = False
self.keys = {'w': False, 'a': False, 's': False, 'd': False}
self.accept('w', self.setKey, ['w', True])
self.accept('w-up', self.setKey, ['w', False])
self.accept('a', self.setKey, ['a', True])
self.accept('a-up', self.setKey, ['a', False])
self.accept('s', self.setKey, ['s', True])
self.accept('s-up', self.setKey, ['s', False])
self.accept('d', self.setKey, ['d', True])
self.accept('d-up', self.setKey, ['d', False])
self.disableMouse()
self.camera.setPos(0, -20, 15)
self.camera.lookAt(0, 0, 0)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def setKey(self, key, value):
self.keys[key] = value
def update(self, task):
if self.game_over:
return Task.cont
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
self.elapsed_time += dt
remaining_time = self.time_limit - self.elapsed_time
self.timer_text.setText(f'Time: {remaining_time:.1f}')
if remaining_time <= 0:
self.timer_text.setText('Game Over!')
self.game_over = True
return Task.cont
if self.keys['w']:
self.player.setY(self.player.getY() + self.speed * dt)
if self.keys['s']:
self.player.setY(self.player.getY() - self.speed * dt)
if self.keys['a']:
self.player.setX(self.player.getX() - self.speed * dt)
if self.keys['d']:
self.player.setX(self.player.getX() + self.speed * dt)
player_pos = self.player.getPos()
for item in self.collectibles[:]:
item_pos = item.getPos()
distance = (player_pos - item_pos).length()
if distance < 1.2:
item.removeNode()
self.collectibles.remove(item)
self.score += 20
self.score_text.setText(f'Score: {self.score}')
if len(self.collectibles) == 0:
self.timer_text.setText('Game Clear!')
self.game_over = True
return Task.cont
app = MyApp()
app.run()
10. 波動シミュレーション(水面の波)
予備知識
波動方程式と数値シミュレーション
波動方程式は波の伝播を記述する偏微分方程式である。有限差分法は、連続的な空間を離散的な格子点で近似し、微分を差分で置き換えて計算する手法である。これにより、水面の波紋の広がりなどを数値的にシミュレートできる。
波動シミュレーションの要素
水面を格子状のグリッドで表現し、各格子点の高さを計算する。ある格子点の変化が隣接する格子点に影響を与えることで波の伝播が実現される。減衰を加えることで、波が時間とともに小さくなる現実的な動きを再現できる。境界条件はグリッド端での波の振る舞いを定義する。
【注意】グリッドサイズを大きくすると計算負荷が増加する。リアルタイム処理には適切なサイズ設定が必要である。
実装例
波動方程式に基づく水面シミュレーションである。50×50の格子点でメッシュを構成し、有限差分法で波の伝播を計算する。初期状態として格子中央に山を配置し、波紋が広がる様子を観察できる。減衰係数により波は徐々に収束する。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Mat4
from panda3d.core import Geom, GeomTriangles, GeomNode
import numpy as np
import math
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.grid_size = 50
self.spacing = 0.5
self.current = np.zeros((self.grid_size, self.grid_size))
self.previous = np.zeros((self.grid_size, self.grid_size))
self.wave_speed = 0.5
self.damping = 0.99
center = self.grid_size // 2
self.current[center, center] = 5.0
self.water_mesh = self.create_water_mesh()
self.water_node = self.render.attachNewNode(self.water_mesh)
self.water_node.setPos(-self.grid_size * self.spacing / 2, -self.grid_size * self.spacing / 2, 0)
self.water_node.setTextureOff(1)
self.disableMouse()
self.camera.setPos(0, -30, 20)
self.camera.lookAt(0, 0, 0)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def create_water_mesh(self):
format = GeomVertexFormat.getV3n3c4()
vdata = GeomVertexData('water', format, Geom.UHDynamic)
vertex = GeomVertexWriter(vdata, 'vertex')
normal = GeomVertexWriter(vdata, 'normal')
color = GeomVertexWriter(vdata, 'color')
for i in range(self.grid_size):
for j in range(self.grid_size):
x = i * self.spacing
y = j * self.spacing
z = self.current[i, j]
vertex.addData3(x, y, z)
normal.addData3(0, 0, 1)
color.addData4(0.2, 0.5, 0.8, 1.0)
tris = GeomTriangles(Geom.UHDynamic)
for i in range(self.grid_size - 1):
for j in range(self.grid_size - 1):
v0 = i * self.grid_size + j
v1 = v0 + 1
v2 = v0 + self.grid_size
v3 = v2 + 1
tris.addVertices(v0, v2, v1)
tris.addVertices(v1, v2, v3)
geom = Geom(vdata)
geom.addPrimitive(tris)
node = GeomNode('water_node')
node.addGeom(geom)
return node
def update_wave(self, dt):
dt = min(dt, 0.1)
c_squared = self.wave_speed * self.wave_speed
spacing_squared = self.spacing * self.spacing
laplacian = (
np.roll(self.current, 1, axis=0) + np.roll(self.current, -1, axis=0) +
np.roll(self.current, 1, axis=1) + np.roll(self.current, -1, axis=1) -
4 * self.current
) / spacing_squared
acceleration = c_squared * laplacian
velocity = (self.current - self.previous) / dt
velocity *= self.damping
new = self.current + velocity * dt + acceleration * dt * dt
new[0, :] = 0
new[-1, :] = 0
new[:, 0] = 0
new[:, -1] = 0
self.previous = self.current.copy()
self.current = new
def update_mesh(self):
geom = self.water_mesh.modifyGeom(0)
vdata = geom.modifyVertexData()
vertex = GeomVertexWriter(vdata, 'vertex')
color = GeomVertexWriter(vdata, 'color')
for i in range(self.grid_size):
for j in range(self.grid_size):
x = i * self.spacing
y = j * self.spacing
z = self.current[i, j]
vertex.setData3(x, y, z)
abs_z = abs(z)
if abs_z < 0.001:
height_ratio = abs_z / 0.001 * 0.1
else:
log_value = math.log(1.0 + 10.0 * abs_z)
height_ratio = min(log_value / 4.14, 1.0)
blue = 0.8 - height_ratio * 0.3
green = 0.5 + height_ratio * 0.3
color.setData4(0.2, green, blue, 1.0)
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
if dt < 0.0001:
return Task.cont
self.update_wave(dt)
self.update_mesh()
return Task.cont
app = MyApp()
app.run()
ポイント
- 波動方程式を有限差分法で数値的に解く
- 格子状のメッシュを動的に更新して波の形状を表現する
- 減衰項を加えることで現実的な波の動きを再現する
- GeomVertexWriterでメッシュの頂点を毎フレーム更新する
実装例(Ctrl+マウスクリックによる波の生成)
上記のプログラムにマウス操作による波生成機能を追加した版である。Ctrl+マウスクリックで任意の位置に新たな波を生成できる。CollisionRayを使用して、クリック位置と水面の交点を検出し、該当する格子点の高さを変更する。
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Mat4
from panda3d.core import Geom, GeomTriangles, GeomNode
from panda3d.core import CollisionTraverser, CollisionNode, CollisionRay, CollisionHandlerQueue
from panda3d.core import Point3, BitMask32
import math
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.grid_size = 50
self.spacing = 0.5
self.current = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]
self.previous = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]
self.wave_speed = 0.5
self.damping = 0.99
center = self.grid_size // 2
self.current[center][center] = 5.0
self.water_mesh = self.create_water_mesh()
self.water_node = self.render.attachNewNode(self.water_mesh)
self.water_node.setPos(-self.grid_size * self.spacing / 2, -self.grid_size * self.spacing / 2, 0)
self.water_node.setTextureOff(1)
self.setup_collision()
self.accept('control-mouse1', self.on_mouse_click)
self.disableMouse()
self.camera.setPos(0, -30, 20)
self.camera.lookAt(0, 0, 0)
mat = Mat4(self.camera.getMat())
mat.invertInPlace()
self.mouseInterfaceNode.setMat(mat)
self.enableMouse()
self.taskMgr.add(self.update, "updateTask")
self.prev_time = globalClock.getFrameTime()
def setup_collision(self):
self.picker = CollisionTraverser()
self.pq = CollisionHandlerQueue()
self.pickerNode = CollisionNode('mouseRay')
self.pickerNP = self.camera.attachNewNode(self.pickerNode)
self.pickerNode.setFromCollideMask(BitMask32.bit(1))
self.pickerRay = CollisionRay()
self.pickerNode.addSolid(self.pickerRay)
self.picker.addCollider(self.pickerNP, self.pq)
self.water_node.setCollideMask(BitMask32.bit(1))
def on_mouse_click(self):
if not self.mouseWatcherNode.hasMouse():
return
mpos = self.mouseWatcherNode.getMouse()
self.pickerRay.setFromLens(self.camNode, mpos.getX(), mpos.getY())
self.picker.traverse(self.render)
if self.pq.getNumEntries() > 0:
self.pq.sortEntries()
entry = self.pq.getEntry(0)
collision_point = entry.getSurfacePoint(self.render)
world_x = collision_point.getX()
world_y = collision_point.getY()
grid_offset = self.grid_size * self.spacing / 2
grid_x = int((world_x + grid_offset) / self.spacing)
grid_y = int((world_y + grid_offset) / self.spacing)
if 0 <= grid_x < self.grid_size and 0 <= grid_y < self.grid_size:
self.current[grid_x][grid_y] += 5.0
print(f"波を生成: グリッド座標 ({grid_x}, {grid_y})")
def create_water_mesh(self):
format = GeomVertexFormat.getV3n3c4()
vdata = GeomVertexData('water', format, Geom.UHDynamic)
vertex = GeomVertexWriter(vdata, 'vertex')
normal = GeomVertexWriter(vdata, 'normal')
color = GeomVertexWriter(vdata, 'color')
for i in range(self.grid_size):
for j in range(self.grid_size):
x = i * self.spacing
y = j * self.spacing
z = self.current[i][j]
vertex.addData3(x, y, z)
normal.addData3(0, 0, 1)
color.addData4(0.2, 0.5, 0.8, 1.0)
tris = GeomTriangles(Geom.UHDynamic)
for i in range(self.grid_size - 1):
for j in range(self.grid_size - 1):
v0 = i * self.grid_size + j
v1 = v0 + 1
v2 = v0 + self.grid_size
v3 = v2 + 1
tris.addVertices(v0, v2, v1)
tris.addVertices(v1, v2, v3)
geom = Geom(vdata)
geom.addPrimitive(tris)
node = GeomNode('water_node')
node.addGeom(geom)
return node
def update_wave(self, dt):
dt = min(dt, 0.1)
c_squared = self.wave_speed * self.wave_speed
dt_squared = dt * dt
new = [[0.0 for _ in range(self.grid_size)] for _ in range(self.grid_size)]
for i in range(1, self.grid_size - 1):
for j in range(1, self.grid_size - 1):
laplacian = (
self.current[i+1][j] + self.current[i-1][j] +
self.current[i][j+1] + self.current[i][j-1] -
4 * self.current[i][j]
) / (self.spacing * self.spacing)
acceleration = c_squared * laplacian
velocity = (self.current[i][j] - self.previous[i][j]) / dt
velocity *= self.damping
new[i][j] = self.current[i][j] + velocity * dt + acceleration * dt_squared
for i in range(self.grid_size):
new[i][0] = 0
new[i][self.grid_size-1] = 0
new[0][i] = 0
new[self.grid_size-1][i] = 0
self.previous = [row[:] for row in self.current]
self.current = new
def update_mesh(self):
geom = self.water_mesh.modifyGeom(0)
vdata = geom.modifyVertexData()
vertex = GeomVertexWriter(vdata, 'vertex')
color = GeomVertexWriter(vdata, 'color')
for i in range(self.grid_size):
for j in range(self.grid_size):
x = i * self.spacing
y = j * self.spacing
z = self.current[i][j]
vertex.setData3(x, y, z)
abs_z = abs(z)
if abs_z < 0.001:
height_ratio = abs_z / 0.001 * 0.1
else:
log_value = math.log(1.0 + 10.0 * abs_z)
height_ratio = min(log_value / 4.14, 1.0)
blue = 0.8 - height_ratio * 0.3
green = 0.5 + height_ratio * 0.3
color.setData4(0.2, green, blue, 1.0)
def update(self, task):
current_time = globalClock.getFrameTime()
dt = current_time - self.prev_time
self.prev_time = current_time
if dt < 0.0001:
return Task.cont
self.update_wave(dt)
self.update_mesh()
return Task.cont
app = MyApp()
app.run()