Panda3Dゲームエンジン中級編:システム構築の基礎
【概要】Panda3Dを用いた3Dゲーム開発の実践的機能を解説する。シーン管理、衝突判定、物理シミュレーション、アニメーション制御、GUI制御、カメラ制御、入力処理、タスク管理、シャドウマッピング、サウンド制御、スプライト表示、パーティクル制御、シーン遷移を扱う。各項目では予備知識として関連する概念や用語を説明し、続いて実行可能なコード例を提示している。すべてのコード例はPanda3D同梱のリソースを活用しており、追加ファイルなしで動作確認が可能である。ゲーム開発に必要な個別機能の実装方法を学習できる構成となっている。
【サイト内の関連ページ】
1. シーン管理の基本
予備知識:
メッシュ(Mesh):
3Dモデルを構成する頂点,辺,面の集合である.3つ以上の頂点で構成される面であるポリゴンの集合によって形状を表現する.表面画像であるテクスチャや,物体の材質を定義するマテリアルなどの表面属性を持つ.3Dオブジェクトの見た目を決定する最も重要な要素の一つであり,複雑な形状の表現を可能にする.
ジオメトリ(Geometry):
3D空間内の形状データを表現する基本要素である.点,線,面などで構成され,頂点座標,面や頂点の向きを示す法線ベクトル,テクスチャ座標などの属性を持つ.GeomVertexDataやGeomTrianglesクラスで形状定義が行われ,3D描画の処理であるレンダリングパイプラインで処理される.
頂点(Vertex):
3D空間内の点を表す要素であり,位置座標(x,y,z)を持つ.法線ベクトル,色,テクスチャ座標などの付加的な属性を持ち,GeomVertexWriterで頂点データを生成できる.ポリゴンを構成する基本単位であり,3Dモデルの形状を定義する.
ポリゴン(Polygon):
3つ以上の頂点で構成される面であり,3Dモデルの表面を形成するための要素である.最も基本的なポリゴンは三角形である.3D形状の表現の基礎であり,画像を面に貼り付けるテクスチャマッピングにおいても使用される.
法線ベクトル(Normal Vector):
面や頂点の向きを示す長さが1の単位ベクトルである.光の反射計算に利用される.法線ベクトルの向きは,面の表裏を判定する基準となる.滑らかな陰影表現であるスムーズシェーディングや凹凸表現のバンプマッピングなどの表現にも使用され,3Dオブジェクトの光沢や陰影表現に役立つ.normal.addData3()で法線を設定する.
テクスチャ(Texture):
3Dモデルの表面に貼り付ける画像データである.色や凹凸などの詳細な表現を可能にし,拡散反射,鏡面反射,法線マップなど複数のテクスチャを組み合わせることでより豊かな表現が可能となる.リアルな表面表現を実現する技術である.
3次元座標系:
互いに垂直な3つの座標軸であるXYZ軸による3次元直交座標系で,3D空間内の位置を表現する基本的な仕組みである.X軸は左右,Y軸は前後,Z軸は上下を表し,点(2,3,1)は右に2,前に3,上に1の位置を示す.空間全体の基準となるワールド座標系とオブジェクトごとの基準となるローカル座標系があり,オブジェクトの配置と移動の基礎となる.
右手座標系:
親指(X),人差し指(Y),中指(Z)を直角に交差させた時の右手の形で表現される座標系である.3Dゲームエンジンの標準的な座標系である.回転方向や法線(面に垂直な方向)の判定では,右手座標系の場合と左手座標系の場合で処理が変わる.
Point3とVec3:
Point3は3D空間内の位置を表し,Vec3は方向と大きさを持つ量であるベクトルを表現する.位置と方向の違いを明確に区別することで,object.setPos(Point3(0, 0, 0))のように位置設定などの3D空間での操作を行える.点の減算でベクトルを得られ,点にベクトルを加えて新しい点を得られる.Vec3(1,0,0)はX方向への単位ベクトルを表す.
シーングラフ(Scene Graph):
3D空間内のオブジェクトの階層構造を管理するツリー構造のデータ構造である.シーングラフ内のノードを参照するためのオブジェクトであるNodePathの集合として表現され,座標変換や属性の継承が可能である.親の移動が子に影響し,子の移動は親に影響しない特性を持つ.render.attachNewNode("parent")で親ノードを作成するなど,3D空間の管理と操作のための仕組みである.
NodePath:
シーングラフ内のノードを参照するためのオブジェクトである.階層構造の中でノードへのパスを表し,attachNewNode()で階層構造を構築し,reparentTo()でノードの親子関係を設定する.3Dオブジェクトの配置や変形を制御するために有用である.renderは最上位ノードとなる.
木構造によるデータ構造:
親ノードと子ノード(階層関係を持つデータ要素)の階層関係を表現する構造である.3D空間では親の移動・回転・スケールが子に影響し,子の変換は親に影響しない特徴を持つ.アニメーション(動画表現)やオブジェクトのグループ化に利用する.キャラクターの骨格構造も木構造で表現する.
階層構造の概念:
オブジェクト間の親子関係を管理する仕組みである.親オブジェクトの変形は子オブジェクトに影響する.例えば,車体(親)とタイヤ(子),腕(親)と手(子)の関係がある.3Dモデルの部品管理やアニメーション制御に不可欠である.
3Dモデルのファイル形式:
FBX,OBJ,GLTF(3次元モデルデータの標準形式)などの形式で3Dモデルデータを保存する.形状,材質,テクスチャ(表面画像),アニメーションなどの情報を含む標準フォーマットである.互換性とデータ圧縮が重要な要素である.
以下のコードでは,ノード(シーングラフの構成要素)の階層構造を構築し,3Dモデルをシーンに配置する.attachNewNode()による階層作成とreparentTo()による親子関係の設定を示している.
from direct.showbase.ShowBase import ShowBase
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# ノードの階層構造
parent = render.attachNewNode("parent")
child = parent.attachNewNode("child")
child.setPos(1, 0, 0) # 親からの相対位置
# モデルの読み込みと配置
model = loader.loadModel("models/box")
model.reparentTo(render)
model.setTextureOff(1) # テクスチャを強制的にオフ
model.setColor(0.7, 0.7, 0.7, 1) # グレー色を設定
model.setPos(0, 5, 0) # カメラから見える位置に配置
app = MyApp()
app.run()
シーン管理のポイント:
- attachNewNode()で階層構造を構築する
- reparentTo()でノードの親子関係を設定する
- renderは最上位ノードである
2. 衝突判定の基本
予備知識:
境界球(バウンディングスフィア)と境界ボックス(バウンディングボックス):
オブジェクトを包む単純な形状で近似する手法である.境界球(Bounding Sphere,球形の衝突判定範囲)は中心と半径で定義され,境界ボックス(Bounding Box,直方体の衝突判定範囲)は中心と各軸方向の長さで定義される.粗い衝突判定の第一段階で使用され,計算コストの削減を可能にする.
衝突検出アルゴリズム:
階層的な判定で効率化を図る手法である.まず境界球同士で判定し,次に詳細な形状で判定を行う.空間分割法(空間を区分けして判定対象を絞る手法)で検出対象を絞り込む.連続衝突検出と離散衝突検出の使い分けが重要である.高速かつ正確な衝突判定を実現する.
階層的衝突判定:
複雑な形状を単純な形状の階層で表現する手法である.上位階層で大まかな判定を行い,下位階層で詳細な判定を実行する.処理効率と精度のバランスを取ることが重要である.大規模なシーンでの衝突判定の最適化を可能にする.
以下のコードでは,衝突検出システムの基本設定を行う.CollisionTraverserによる検出管理,CollisionHandlerQueueによる結果管理,そしてCollisionNodeによる衝突形状の定義を実装している.
from direct.showbase.ShowBase import ShowBase
from panda3d.core import CollisionTraverser, CollisionHandlerQueue
from panda3d.core import CollisionNode, CollisionSphere
from direct.gui.OnscreenText import OnscreenText
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 衝突検出の設定
self.traverser = CollisionTraverser()
self.handler = CollisionHandlerQueue()
# 衝突情報表示
self.collision_text = OnscreenText(
text="Collision: None",
pos=(-1.3, 0.9),
scale=0.06,
fg=(1, 1, 1, 1),
align=0
)
# 1つ目のオブジェクト(球)- 衝突元(from)
self.sphere_model = loader.loadModel("models/misc/sphere")
self.sphere_model.reparentTo(render)
self.sphere_model.setScale(0.5)
self.sphere_model.setPos(-3, 10, 0)
self.sphere_model.setTextureOff(1)
self.sphere_model.setColor(0.3, 0.7, 0.3, 1)
# 球の衝突形状(from)
sphere_coll = CollisionSphere(0, 0, 0, 1.0)
sphere_cnode = CollisionNode("sphere_from")
sphere_cnode.addSolid(sphere_coll)
self.sphere_cnp = self.sphere_model.attachNewNode(sphere_cnode)
self.sphere_cnp.show()
# 2つ目のオブジェクト(ボックス)- 衝突先(into)
self.box_model = loader.loadModel("models/box")
self.box_model.reparentTo(render)
self.box_model.setScale(1.0)
self.box_model.setPos(3, 10, 0)
self.box_model.setTextureOff(1)
self.box_model.setColor(0.7, 0.3, 0.3, 1)
# ボックスの衝突形状(into)
box_coll = CollisionSphere(0, 0, 0, 1.5)
box_cnode = CollisionNode("box_into")
box_cnode.addSolid(box_coll)
self.box_cnp = self.box_model.attachNewNode(box_cnode)
self.box_cnp.show()
# 衝突検出の登録(fromオブジェクトのみ登録)
self.traverser.addCollider(self.sphere_cnp, self.handler)
# 移動方向
self.direction = 1
# 更新タスクの登録
taskMgr.add(self.update, "update_task")
def update(self, task):
dt = globalClock.getDt()
# 球を左右に移動
current_x = self.sphere_model.getX()
new_x = current_x + self.direction * 3 * dt
if new_x > 3:
self.direction = -1
elif new_x < -3:
self.direction = 1
self.sphere_model.setX(new_x)
# 衝突判定の実行
self.traverser.traverse(render)
# 衝突結果の確認
if self.handler.getNumEntries() > 0:
self.handler.sortEntries()
entry = self.handler.getEntry(0)
self.collision_text.setText(f"Collision: {entry.getIntoNodePath().getName()}")
self.sphere_model.setColor(1, 1, 0, 1)
else:
self.collision_text.setText("Collision: None")
self.sphere_model.setColor(0.3, 0.7, 0.3, 1)
return task.cont
app = MyApp()
app.run()
衝突判定のポイント:
- CollisionTraverserで衝突検出を管理する
- CollisionHandlerで衝突応答を処理する
- CollisionNodeで衝突形状を定義する
- traverse()を毎フレーム呼び出して衝突判定を実行する
3. 物理シミュレーションの基本
予備知識:
運動方程式の基本:
F=ma(力=質量×加速度)を基本とするニュートンの運動法則である.力,質量,加速度の関係を定義し,物体の動きを計算する基礎となる.並進運動(直線運動)と回転運動の両方を考慮する必要がある.ゲーム内の物体の動きを物理的に正確に表現することを可能にする.
剛体力学の基本:
変形しない物体の運動を扱う力学の分野である.質量,慣性モーメント(回転のしにくさを表す量),重心位置が重要なパラメータである.衝突や接触による力の伝達を計算し,リアルな物体の挙動を再現する.車両や投射物の挙動計算に利用される.
質点系と剛体の違い:
質点(質量が1点に集中したモデル)は質量が1点に集中し,剛体は空間的に分布する.剛体は回転運動を考慮する必要があり,慣性テンソル(方向による慣性モーメントの違い)が重要な役割を果たす.物理演算の複雑さが大きく異なり,表現の精度に影響する.
以下のコードでは,物理シミュレーションの基本設定を行う.BulletWorldによる物理空間の管理,重力の設定,剛体の作成と質量設定を実装している.
from direct.showbase.ShowBase import ShowBase
from panda3d.core import Vec3
from panda3d.bullet import BulletWorld, BulletSphereShape, BulletRigidBodyNode
from panda3d.bullet import BulletPlaneShape, BulletBoxShape
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 物理世界の初期化
self.physics_world = BulletWorld()
self.physics_world.setGravity(Vec3(0, 0, -9.81))
# 地面の物理形状
ground_shape = BulletPlaneShape(Vec3(0, 0, 1), 0)
ground_node = BulletRigidBodyNode("ground")
ground_node.addShape(ground_shape)
ground_np = render.attachNewNode(ground_node)
ground_np.setPos(0, 0, -2)
self.physics_world.attachRigidBody(ground_node)
# 地面の可視化用モデル
ground_model = loader.loadModel("models/box")
ground_model.reparentTo(render)
ground_model.setScale(10, 10, 0.1)
ground_model.setPos(0, 10, -2.05)
ground_model.setTextureOff(1)
ground_model.setColor(0.4, 0.4, 0.4, 1)
# 剛体球の作成
shape = BulletSphereShape(1.0)
body = BulletRigidBodyNode("sphere")
body.addShape(shape)
body.setMass(1.0)
self.body_np = render.attachNewNode(body)
self.body_np.setPos(0, 10, 5)
self.physics_world.attachRigidBody(body)
# 球の可視化用モデル
sphere_model = loader.loadModel("models/misc/sphere")
sphere_model.reparentTo(self.body_np)
sphere_model.setTextureOff(1)
sphere_model.setColor(0.7, 0.3, 0.3, 1)
# カメラ位置の調整
self.cam.setPos(0, -10, 5)
self.cam.lookAt(0, 10, 0)
# 物理シミュレーションの更新タスク
taskMgr.add(self.update, "physics_update")
def update(self, task):
dt = globalClock.getDt()
self.physics_world.doPhysics(dt)
return task.cont
app = MyApp()
app.run()
物理シミュレーションのポイント:
- BulletWorldで物理空間を管理する
- RigidBodyNodeで剛体を作成する
- setMass()で質量を設定する
- doPhysics()を毎フレーム呼び出して物理演算を実行する
4. アニメーション制御の基本
予備知識:
キーフレームアニメーション:
重要な姿勢を指定するキーフレーム(動作の基準となるフレーム)を設定し,中間フレームを補間する手法である.位置,回転,スケールなどの変化を制御する.タイムライン(時間軸)ベースの直感的な制御が可能である.
ボーンアニメーション:
骨格構造(関節の階層構造)による変形制御である.各ボーン(関節)の回転でメッシュの変形を実現する.スキニングウェイト(変形の影響度)で各頂点への影響度を調整する.キャラクターアニメーションの標準的手法である.
アニメーション補間:
キーフレーム間の中間状態を計算する手法である.線形補間(直線的な変化),エルミート補間(滑らかな変化),スプライン補間(曲線的な変化)などの手法がある.滑らかな動きの実現に不可欠である.
Actorクラス:
Panda3Dでアニメーション付きモデルを管理するクラスである.モデルファイルとアニメーションファイルを読み込み,再生制御を行う.loop()で繰り返し再生,play()で単発再生,stop()で停止を制御する.
以下のコードでは,アニメーション付きモデルの基本的な制御を実装する.Actorクラスによるモデル読み込み,アニメーションの再生と停止,再生速度の調整を示している.
from direct.showbase.ShowBase import ShowBase
from direct.actor.Actor import Actor
from direct.gui.DirectGui import DirectButton
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# アニメーション付きモデルの読み込み
self.panda = Actor("models/panda-model",
{"walk": "models/panda-walk4"})
self.panda.reparentTo(render)
self.panda.setScale(0.5)
self.panda.setPos(0, 10, -1)
# 操作ボタンの作成
DirectButton(text="Walk", pos=(-0.6, 0, 0.8),
scale=0.08, command=self.startWalk)
DirectButton(text="Stop", pos=(0, 0, 0.8),
scale=0.08, command=self.stopWalk)
DirectButton(text="Fast", pos=(0.6, 0, 0.8),
scale=0.08, command=self.fastWalk)
def startWalk(self):
self.panda.loop("walk")
def stopWalk(self):
self.panda.stop()
def fastWalk(self):
self.panda.setPlayRate(2.0, "walk")
self.panda.loop("walk")
app = MyApp()
app.run()
アニメーション制御のポイント:
- Actorクラスでアニメーション付きモデルを管理する
- loop()で繰り返し再生,stop()で停止する
- setPlayRate()で再生速度を調整する
5. GUI制御の基本
予備知識:
2D座標系:
ウィンドウ上の位置を(x,y)で表現するシステムである.Panda3Dのaspect2d座標系では,原点は画面中央に配置され,x軸は右方向,y軸は上方向が正である.正規化座標系(-1から1の範囲に統一した座標系)を使用する.ピクセル座標(画素単位の座標)を使用するpixel2d座標系では,原点は画面左上となる.
アスペクト比:
画面の縦横比を表すパラメータである.16:9や4:3などの比率で表現される.GUI要素(グラフィカルユーザーインターフェース部品)の配置やテクスチャの歪み防止に重要な役割を果たす.解像度が変わっても見た目が維持されるよう調整する必要がある.
ウィジェットの構造:
ボタン,テキスト,スライダーなどのUI部品の総称である.階層構造で管理され,親子関係により位置や表示状態が制御される.イベントの伝播(情報の伝達)も階層に従って処理される.ユーザーインターフェースの基本構成要素である.
以下のコードでは,基本的なGUI要素を画面上に配置する.OnscreenTextによるテキスト表示,DirectButtonによるクリック可能なボタンの作成,位置とサイズの制御を実装している.
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
from direct.gui.DirectGui import DirectButton
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.score = 0
# テキスト表示
self.score_text = OnscreenText(
text="Score: 0",
pos=(-1.3, 0.9),
scale=0.07,
fg=(1, 1, 1, 1),
align=0 # 左揃え
)
# ボタンの作成
self.start_button = DirectButton(
text="Add Score",
pos=(0, 0, 0),
scale=0.1,
command=self.addScore
)
# 終了ボタン
self.exit_button = DirectButton(
text="Exit",
pos=(0, 0, -0.3),
scale=0.1,
command=self.exitGame
)
def addScore(self):
self.score += 10
self.score_text.setText(f"Score: {self.score}")
def exitGame(self):
self.userExit()
app = MyApp()
app.run()
GUI制御のポイント:
- OnscreenTextで画面上にテキストを表示する
- DirectButtonでクリック可能なボタンを作成する
- pos,scaleでサイズと位置を調整する
6. カメラ制御の基本
予備知識:
カメラ(Camera):
3D空間内の視点を表現するオブジェクトである.3D空間からスクリーン座標への変換を制御するために利用される.位置,向き,視野角などのパラメータを持ち,透視投影または平行投影の投影方式を指定できる.camera.setPos(0, -10, 3)のようにsetPos()で位置を設定し,lookAt()で注視点を設定する.
視点と注視点の概念:
視点は観察者(カメラ)の位置を表し,注視点は見つめる対象の位置を表す.この2点によって視線方向が決定される.カメラワーク(視点移動)の基本となる概念である.TPS(三人称視点ゲーム)やFPS(一人称視点ゲーム)などの視点制御に利用される.
視野角(Field of View, FOV):
カメラが捉える視界の広さを角度で表現するパラメータである.値が大きいほど広い範囲が見えるが画面の歪みも大きくなる.一般的なゲームでは60度から90度の範囲で設定され,base.camLens.setFov(80)で80度に設定するなど,3D表現の重要な要素となる.
視野角とクリッピング面:
視野角(Field of View)は,カメラが捉える視界の広さを角度で表現するパラメータである.クリッピング面(表示範囲を定める面)は表示距離の範囲を定義し,近接面と遠方面で設定される.描画される範囲を制限することで,レンダリング効率を向上させる.
オイラー角:
ヨー(水平回転),ピッチ(縦回転),ロール(傾き)という3つの角度で物体の向きを表現する回転表現方法である.object.setHpr(0, 0, 0)のようにsetHpr()メソッドで向きをリセットする.特定の角度での回転制限に注意が必要である.直感的な回転制御を可能にする.
以下のコードでは,カメラの基本的な制御を実装する.setPos()による位置設定,lookAt()による注視点指定,setFov()による視野角設定,そしてマウスによる視点回転を示している.
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
from panda3d.core import WindowProperties
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# カメラの初期設定
self.cam.setPos(0, -20, 5)
self.cam.lookAt(0, 0, 0)
base.camLens.setFov(80)
# 視点回転用の変数
self.heading = 0
self.pitch = 0
self.sensitivity = 0.2
# 情報表示
self.info_text = OnscreenText(
text="Camera Control Demo\nMove mouse to rotate view",
pos=(-1.3, 0.9),
scale=0.06,
fg=(1, 1, 1, 1),
align=0
)
self.angle_text = OnscreenText(
text="H: 0.0, P: 0.0",
pos=(-1.3, 0.75),
scale=0.05,
fg=(0.8, 0.8, 0.8, 1),
align=0
)
# 参照用オブジェクトの配置
for i in range(-2, 3):
for j in range(-2, 3):
cube = loader.loadModel("models/box")
cube.reparentTo(render)
cube.setPos(i * 3, j * 3, 0)
cube.setScale(0.5)
cube.setTextureOff(1)
cube.setColor(0.3 + i * 0.1, 0.5, 0.3 + j * 0.1, 1)
# マウスカーソルを中央に固定
props = WindowProperties()
props.setCursorHidden(True)
props.setMouseMode(WindowProperties.M_relative)
self.win.requestProperties(props)
# マウス移動の検出
self.accept("mouse1", self.onMouseMove)
taskMgr.add(self.updateCamera, "camera_update")
def onMouseMove(self):
# マウスの相対移動を取得
if self.mouseWatcherNode.hasMouse():
x = self.mouseWatcherNode.getMouseX()
y = self.mouseWatcherNode.getMouseY()
self.heading -= x * self.sensitivity * 100
self.pitch += y * self.sensitivity * 100
# ピッチの制限
self.pitch = max(-89, min(89, self.pitch))
def updateCamera(self, task):
# マウスの相対移動を取得
if self.mouseWatcherNode.hasMouse():
x = self.mouseWatcherNode.getMouseX()
y = self.mouseWatcherNode.getMouseY()
if abs(x) > 0.001 or abs(y) > 0.001:
self.heading -= x * self.sensitivity * 100
self.pitch += y * self.sensitivity * 100
# ピッチの制限
self.pitch = max(-89, min(89, self.pitch))
# カメラの向きを更新
self.cam.setHpr(self.heading, self.pitch, 0)
# 角度情報の表示更新
self.angle_text.setText(f"H: {self.heading:.1f}, P: {self.pitch:.1f}")
return task.cont
app = MyApp()
app.run()
カメラ制御のポイント:
- setPos()とlookAt()で視点と注視点を設定する
- setFov()で視野角を調整する
- setHpr()で回転角度を設定する
- ピッチ角は-90度から90度に制限する
7. 入力処理の基本
予備知識:
イベント駆動プログラミング:
キー入力やマウス操作をイベント(プログラム上の事象)として検出する手法である.コールバック関数(イベント発生時に実行される処理の関数)で対応する処理を実行する.イベントキュー(処理待ち行列)で入力を管理し,非同期的な処理を実現する.ユーザーとのインタラクションの基盤となる.
キーボード/マウス入力:
ユーザーからの操作を検出し処理する仕組みである.キーの押下,保持,解放を区別し,self.accept("space", self.jump)でスペースキーの処理を登録するなど,accept()メソッドでイベントを登録する.イベント発生時に実行されるコールバック関数で対応する処理を実行し,ユーザーとのインタラクションを実現する基盤となる.
入力状態管理:
現在の入力状態をフラグや数値で保持する仕組みである.キーの同時押しにも対応できる.入力履歴の管理で連続技なども実現できる.dict型やクラス変数で状態を保持し,複雑な入力パターンの処理を可能にする.
以下のコードでは,基本的な入力処理を実装する.accept()によるキーイベントの登録,キー状態の管理,そしてキー入力に応じたオブジェクトの移動制御を示している.
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
import sys
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# キー状態の管理
self.keys = {
"w": False,
"a": False,
"s": False,
"d": False,
"space": False
}
# 情報表示
self.info_text = OnscreenText(
text="Input Control Demo\nW/A/S/D: Move Space: Jump ESC: Exit",
pos=(-1.3, 0.9),
scale=0.06,
fg=(1, 1, 1, 1),
align=0
)
self.status_text = OnscreenText(
text="Keys: None",
pos=(-1.3, 0.75),
scale=0.05,
fg=(0.8, 0.8, 0.8, 1),
align=0
)
# 操作対象のオブジェクト
self.player = loader.loadModel("models/box")
self.player.reparentTo(render)
self.player.setPos(0, 10, 0)
self.player.setScale(0.5)
self.player.setTextureOff(1)
self.player.setColor(0.3, 0.7, 0.3, 1)
# 地面の作成
ground = loader.loadModel("models/box")
ground.reparentTo(render)
ground.setScale(10, 10, 0.1)
ground.setPos(0, 10, -1)
ground.setTextureOff(1)
ground.setColor(0.5, 0.5, 0.5, 1)
# カメラ位置の調整
self.cam.setPos(0, -5, 8)
self.cam.lookAt(0, 10, 0)
# キーイベントの登録
self.accept("escape", sys.exit)
# W/A/S/Dキーの押下と解放
self.accept("w", self.updateKey, ["w", True])
self.accept("w-up", self.updateKey, ["w", False])
self.accept("a", self.updateKey, ["a", True])
self.accept("a-up", self.updateKey, ["a", False])
self.accept("s", self.updateKey, ["s", True])
self.accept("s-up", self.updateKey, ["s", False])
self.accept("d", self.updateKey, ["d", True])
self.accept("d-up", self.updateKey, ["d", False])
# スペースキー
self.accept("space", self.updateKey, ["space", True])
self.accept("space-up", self.updateKey, ["space", False])
# ジャンプ関連の変数
self.is_jumping = False
self.jump_velocity = 0
self.gravity = -20
# 更新タスクの登録
taskMgr.add(self.update, "update_task")
def updateKey(self, key, value):
self.keys[key] = value
# 押されているキーの表示
pressed_keys = [k.upper() for k, v in self.keys.items() if v]
if pressed_keys:
self.status_text.setText(f"Keys: {', '.join(pressed_keys)}")
else:
self.status_text.setText("Keys: None")
def update(self, task):
dt = globalClock.getDt()
speed = 5
# 移動処理
pos = self.player.getPos()
if self.keys["w"]:
pos.y += speed * dt
if self.keys["s"]:
pos.y -= speed * dt
if self.keys["a"]:
pos.x -= speed * dt
if self.keys["d"]:
pos.x += speed * dt
# ジャンプ処理
if self.keys["space"] and not self.is_jumping and pos.z <= 0:
self.is_jumping = True
self.jump_velocity = 8
if self.is_jumping:
pos.z += self.jump_velocity * dt
self.jump_velocity += self.gravity * dt
if pos.z <= 0:
pos.z = 0
self.is_jumping = False
self.jump_velocity = 0
self.player.setPos(pos)
return task.cont
app = MyApp()
app.run()
入力処理のポイント:
- accept()でキーイベントを登録する
- キーの状態をdict形式で管理する
- -upサフィックスでキーを離した時の処理を実装する
- 複数キーの同時押しに対応する
8. タスク管理の基本
予備知識:
ゲームループ:
入力処理,状態更新,描画を繰り返す基本サイクルである.フレームレート(画面更新頻度)の制御や時間管理が重要な役割を果たす.固定時間ステップ(一定間隔の更新)と可変時間ステップ(処理時間に応じた更新)の使い分けが必要である.ゲームの動作とパフォーマンスを決定する核となる.
フレームレートと時間管理:
1秒あたりの画面更新回数を表すパラメータである.60FPS(1秒間に60回の更新)が標準的である.処理負荷による変動を考慮し,デルタ時間(前フレームからの経過時間)で動きを調整する必要がある.垂直同期(モニタの更新と同期)でティアリング(画面が横方向に分割されて表示される現象)を防止する.
タスクスケジューリング:
定期実行や遅延実行の管理を行う仕組みである.優先度による実行順序の制御を行う.コルーチン(中断可能な処理)による非同期処理の実現が可能である.システムの応答性維持に重要な役割を果たす.
タスク管理:
定期実行や遅延実行の管理を行う機能である.taskMgr.add(self.update, "UpdateTask")のようにtaskMgr.add()で定期実行タスクを登録し,dt = globalClock.getDt()でフレーム間の経過時間を取得する.入力処理,状態更新,描画の繰り返しであるゲームループの実装や時間に基づく処理の制御など,動的な3D環境の実現に必要不可欠な機能である.
以下のコードでは,基本的なタスク管理システムを構築する.taskMgr.add()による定期実行タスクの登録,デルタ時間の取得,ゲーム状態の更新処理を実装している.
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.elapsed_time = 0.0
# 経過時間表示
self.time_text = OnscreenText(
text="Time: 0.00",
pos=(0, 0.9),
scale=0.07,
fg=(1, 1, 1, 1)
)
# 回転するオブジェクトの作成
self.cube = loader.loadModel("models/box")
self.cube.reparentTo(render)
self.cube.setPos(0, 10, 0)
self.cube.setTextureOff(1) # テクスチャを強制的にオフ
self.cube.setColor(0.3, 0.5, 0.8, 1) # 青色を設定
# 定期実行タスクの登録
taskMgr.add(self.update, "UpdateTask")
def update(self, task):
dt = globalClock.getDt() # 経過時間
self.updateGame(dt) # ゲーム状態の更新
return task.cont
def updateGame(self, dt):
self.elapsed_time += dt
self.time_text.setText(f"Time: {self.elapsed_time:.2f}")
# オブジェクトを回転
self.cube.setH(self.cube.getH() + 50 * dt)
app = MyApp()
app.run()
タスク管理のポイント:
- taskMgr.add()で定期実行タスクを登録する
- getDt()でフレーム間の経過時間を取得する
- task.contで処理の継続を指示する
9. シャドウマッピングの基本
予備知識:
光の基本特性:
光の直進,反射,屈折,減衰の物理特性を考慮する必要がある.光源からの距離による減衰や物体表面での反射特性を再現する.色はRGB値(赤緑青の三原色)で表現し,各成分は0から1の範囲で指定される.リアルな光の表現を可能にする基礎となる.
拡散反射と鏡面反射:
拡散反射(全方向への均等な反射)は光を全方向に均等に反射する現象である.鏡面反射(特定方向への強い反射)は特定方向への強い反射である.マテリアル(材質)のプロパティでこれらを制御する.物理ベースレンダリング(PBR)では金属度と粗さで質感を表現する.
環境光(AmbientLight)と平行光(DirectionalLight):
環境光(AmbientLight)は空間全体を均一に照らす光源である.平行光(DirectionalLight)は太陽光のような平行光線を生成する.スポットライト(円錐状の光)は円錐状に広がる光を表現する.それぞれ色と強度を設定でき,組み合わせることでリアルな照明を実現する.
シャドウマップの原理:
光源から見たシーンの深度情報(距離情報)を記録する手法である.2段階レンダリング(二段階の描画処理)で影を表現する.1パス目で深度マップ作成,2パス目で影の判定を行う.解像度とフィルタリング(画像の平滑化処理)で品質調整する.dlight.setShadowCaster(True, 2048, 2048)で2048×2048の高解像度影を生成する.
深度バッファの概念:
各ピクセルまでの距離を記録するバッファ(一時的なデータ保存領域)である.Zバッファ(奥行き情報を格納する領域)とも呼ばれる.隠面消去(見えない部分の除去)と影の計算に使用される.精度は16-32ビットで設定され,非線形な分布を持つ.props.setDepthBits(24)で24ビット精度の深度情報を設定する.シャドウマッピングなどの高度な表現技術の基礎となる.
アーティファクトと対策:
シャドウアクネ(斑点状の影の不具合),エイリアシング(ジャギー:輪郭の段差)などの問題が発生することがある.バイアス値調整,PCF(Percentage Closer Filtering:影の境界を滑らかにする処理手法),カスケードシャドウ(視点からの距離に応じた解像度調整)などで対策する.影の品質を向上させるための技術である.
以下のコードでは,基本的なシャドウマッピングシステムを構築する.光源からのシャドウマップの生成,解像度の設定,シャドウマッピングの有効化を実装している.
from direct.showbase.ShowBase import ShowBase
from panda3d.core import DirectionalLight, AmbientLight
from panda3d.core import Vec4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# シャドウマップの設定
dlight = DirectionalLight("shadow")
dlight.setColor(Vec4(1, 1, 1, 1))
dlight.setShadowCaster(True, 2048, 2048) # 解像度2048x2048
dlnp = render.attachNewNode(dlight)
dlnp.setHpr(-30, -60, 0)
render.setLight(dlnp)
# 環境光の追加
alight = AmbientLight("ambient")
alight.setColor(Vec4(0.3, 0.3, 0.3, 1))
alnp = render.attachNewNode(alight)
render.setLight(alnp)
# シャドウ有効化
render.setShaderAuto() # 自動シェーダー設定
# 地面の作成
ground = loader.loadModel("models/box")
ground.reparentTo(render)
ground.setScale(10, 10, 0.1)
ground.setPos(0, 10, -2)
ground.setTextureOff(1) # テクスチャを強制的にオフ
ground.setColor(0.5, 0.5, 0.5, 1)
# 影を落とすオブジェクト
cube = loader.loadModel("models/box")
cube.reparentTo(render)
cube.setPos(0, 10, 0)
cube.setTextureOff(1) # テクスチャを強制的にオフ
cube.setColor(0.8, 0.4, 0.4, 1)
app = MyApp()
app.run()
シャドウマッピングのポイント:
- setShadowCaster()でシャドウマップを有効化する
- setShaderAuto()で自動シェーディング設定を行う
- 環境光と平行光を組み合わせてリアルな照明を実現する
10. サウンド制御の基本
予備知識:
音声ファイル形式:
WAV(非圧縮音声形式),OGG(可逆圧縮形式),MP3(非可逆圧縮形式)などの形式が存在する.サンプリングレート(音声のデジタル化頻度)とビットレート(データ量)でクオリティを調整する.効果音は高品質WAV,BGMは圧縮形式が一般的である.用途に応じて適切な形式を選択する必要がある.
3D音響の基本:
音源の位置による音量と定位(音の位置感)の変化を制御する技術である.ドップラー効果(音源の移動による音程変化)を考慮する.距離減衰と指向性(音の広がり方)を実装する.リバーブ(残響効果)による空間表現を行い,よりリアルな音響環境を実現する.
サウンドバッファ:
音声データを一時的に格納するメモリ領域である.ストリーミング再生(逐次読み込み再生)でメモリ使用を最適化する.バッファサイズとレイテンシー(遅延時間)はトレードオフの関係にある.適切なバッファサイズの設定が重要である.
以下のコードでは,効果音とBGMの基本的な制御を実装する.Panda3Dに同梱されているサンプル音声ファイルを使用し,音量設定,再生制御,ループ設定を示している.
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
from direct.gui.DirectGui import DirectButton, DirectSlider
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 説明テキスト
self.info_text = OnscreenText(
text="Sound Control Demo",
pos=(0, 0.8),
scale=0.08,
fg=(1, 1, 1, 1)
)
# 状態テキスト
self.status_text = OnscreenText(
text="Status: Ready",
pos=(0, 0.6),
scale=0.06,
fg=(0.8, 0.8, 0.8, 1)
)
# Panda3D同梱のサンプル音声を読み込み
self.sound = base.loader.loadSfx("models/audio/sfx/GUI_rollover.wav")
self.music = base.loader.loadMusic("models/audio/sfx/GUI_click.wav")
# 音量の初期設定
self.sound.setVolume(0.8)
self.music.setVolume(0.5)
self.music.setLoop(True)
# 効果音再生ボタン
self.sfx_button = DirectButton(
text="Play Sound Effect",
pos=(-0.6, 0, 0.2),
scale=0.08,
command=self.playSound
)
# BGM再生ボタン
self.music_play_button = DirectButton(
text="Play Music",
pos=(0.6, 0, 0.2),
scale=0.08,
command=self.playMusic
)
# BGM停止ボタン
self.music_stop_button = DirectButton(
text="Stop Music",
pos=(0.6, 0, 0),
scale=0.08,
command=self.stopMusic
)
# 音量ラベル
self.volume_label = OnscreenText(
text="Volume:",
pos=(-0.3, -0.3),
scale=0.06,
fg=(1, 1, 1, 1)
)
# 音量スライダー
self.volume_slider = DirectSlider(
range=(0, 1),
value=0.8,
pageSize=0.1,
pos=(0.3, 0, -0.3),
scale=0.5,
command=self.setVolume
)
def playSound(self):
self.sound.play()
self.status_text.setText("Status: Sound effect played")
def playMusic(self):
self.music.play()
self.status_text.setText("Status: Music playing (loop)")
def stopMusic(self):
self.music.stop()
self.status_text.setText("Status: Music stopped")
def setVolume(self):
volume = self.volume_slider['value']
self.sound.setVolume(volume)
self.music.setVolume(volume)
self.status_text.setText(f"Status: Volume set to {volume:.2f}")
app = MyApp()
app.run()
サウンド制御のポイント:
- loadSfx()で効果音,loadMusic()でBGMを読み込む
- setLoop()でループ再生を設定する
- setVolume()で音量を調整する
11. スプライト表示の基本
予備知識:
テクスチャマッピング:
2D画像を3D物体の表面に貼り付ける技術である.UV座標(テクスチャ座標)で画像の対応位置を指定する.ミップマップ(異なる解像度の画像群)で解像度を自動調整する.テクスチャアトラス(複数画像の統合テクスチャ)で複数画像を1枚にまとめて効率化を図る.リアルな表面表現を実現する基礎技術である.
UV座標系:
テクスチャ上の位置を(0,0)から(1,1)の範囲で指定する座標系である.左下原点で右方向u,上方向vを基準とする.テクスチャの繰り返しやクランプ(端部の処理方法)で境界処理を行う.タイリング(パターンの繰り返し)で広い面の表現が可能となる.
アルファブレンディング:
透明度による重ね合わせ処理を行う技術である.前景と背景の色を透明度で混合する.加算合成(色の加算)やマルチプライ(色の乗算)など複数の合成モードがある.パーティクル(粒子効果)やUI要素の表現に重要な役割を果たす.半透明表現を実現する基礎となる.
以下のコードでは,2D画像をスプライトとして表示する.CardMakerによる平面生成,テクスチャの読み込みと適用,レンダリング設定を実装している.Panda3Dに同梱されているサンプルテクスチャを使用している.
from direct.showbase.ShowBase import ShowBase
from panda3d.core import CardMaker, TransparencyAttrib
from direct.gui.OnscreenText import OnscreenText
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 説明テキスト
self.info_text = OnscreenText(
text="Sprite Demo with Texture",
pos=(0, 0.9),
scale=0.07,
fg=(1, 1, 1, 1)
)
# 背景色の設定
base.setBackgroundColor(0.2, 0.2, 0.3)
# スプライト1の作成
cm1 = CardMaker("sprite1")
cm1.setFrame(-0.4, 0.4, -0.4, 0.4)
sprite1 = render2d.attachNewNode(cm1.generate())
sprite1.setPos(-0.5, 0, 0)
# Panda3D同梱のテクスチャを適用
texture1 = loader.loadTexture("models/maps/envir-groundforest.jpg")
sprite1.setTexture(texture1)
# スプライト2の作成(別のテクスチャ)
cm2 = CardMaker("sprite2")
cm2.setFrame(-0.4, 0.4, -0.4, 0.4)
sprite2 = render2d.attachNewNode(cm2.generate())
sprite2.setPos(0.5, 0, 0)
# 別のサンプルテクスチャを適用
texture2 = loader.loadTexture("models/maps/envir-mountain1.jpg")
sprite2.setTexture(texture2)
# 透明度対応のスプライト3
cm3 = CardMaker("sprite3")
cm3.setFrame(-0.2, 0.2, -0.2, 0.2)
sprite3 = render2d.attachNewNode(cm3.generate())
sprite3.setPos(0, 0, -0.5)
sprite3.setTransparency(TransparencyAttrib.MAlpha)
# アルファ付きテクスチャ(同梱サンプル)
texture3 = loader.loadTexture("models/maps/circle.rgba")
sprite3.setTexture(texture3)
app = MyApp()
app.run()
スプライト表示のポイント:
- CardMakerで2D平面を生成する
- render2dに配置して2D表示する
- setTexture()でテクスチャを適用する
- setTransparency()で透明度処理を有効化する
12. パーティクル制御の基本
予備知識:
パーティクルシステム:
多数の小さな粒子による視覚効果を制御するシステムである.エミッター(粒子発生装置)から粒子を生成し,物理演算で動きを制御する.炎,煙,魔法,爆発などの表現が可能である.GPU(画像処理プロセッサ)加速で大量粒子の処理を実現し,ダイナミックな視覚効果を生み出す.
エミッターとパーティクル:
エミッターは粒子の発生源となるオブジェクトである.放出角度,速度,頻度を制御する.パーティクルは位置,速度,寿命,色,サイズなどのプロパティ(属性)を持つ.乱数で自然な揺らぎを表現し,リアルな視覚効果を実現する.
パーティクル寿命管理:
生成から消滅までの時間を制御する仕組みである.フェードインアウト(徐々な出現消滅)で自然な出現消滅を実現する.アルファ値(透明度)や色,サイズの時間変化を設定する.衝突判定で環境との相互作用を実装し,よりリアルなパーティクル表現を可能にする.
以下のコードでは,パーティクルシステムの基本設定を行う.ParticleEffectによるエフェクト管理,プログラム内でのパーティクル設定,エフェクトの開始と停止制御を実装している.
from direct.showbase.ShowBase import ShowBase
from direct.particles.ParticleEffect import ParticleEffect
from direct.particles.Particles import Particles
from direct.particles.ForceGroup import ForceGroup
from panda3d.physics import BaseParticleEmitter, BaseParticleRenderer
from panda3d.physics import PointParticleFactory, PointParticleRenderer
from panda3d.physics import LinearNoiseForce
from panda3d.core import Vec3, Vec4
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# パーティクルシステムの有効化
base.enableParticles()
# パーティクルエフェクトの作成
self.p = ParticleEffect()
# パーティクルの設定
particles = Particles("particles")
particles.setPoolSize(100)
particles.setBirthRate(0.1)
particles.setLitterSize(5)
particles.setLitterSpread(2)
particles.setLocalVelocityFlag(True)
particles.setSystemGrowsOlderFlag(False)
# エミッターの設定
particles.setEmitter(BaseParticleEmitter.ETCUSTOM)
emitter = particles.getEmitter()
emitter.setEmissionType(BaseParticleEmitter.ETRADIATE)
emitter.setAmplitude(1.0)
emitter.setAmplitudeSpread(0.5)
# レンダラーの設定(ポイントレンダラー使用)
renderer = PointParticleRenderer()
renderer.setPointSize(5.0)
renderer.setStartColor(Vec4(1, 0.5, 0, 1))
renderer.setEndColor(Vec4(1, 0, 0, 0))
particles.setRenderer(renderer)
# ファクトリの設定
particles.setFactory(PointParticleFactory())
factory = particles.getFactory()
factory.setLifespanBase(1.5)
factory.setLifespanSpread(0.5)
self.p.addParticles(particles)
# 力の設定
forces = ForceGroup("forces")
gravity = LinearNoiseForce(0, 0, -5, 1)
forces.addForce(gravity)
self.p.addForceGroup(forces)
# パーティクルの開始
self.p.start(parent=render)
self.p.setPos(0, 10, 0)
app = MyApp()
app.run()
パーティクル制御のポイント:
- ParticleEffectでパーティクル効果を管理する
- Particlesクラスでパーティクルの属性を設定する
- start()/softStop()で制御する
13. シーン遷移の基本
予備知識:
ゲーム状態管理:
メニュー,プレイ中,ポーズなどの状態遷移を制御する仕組みである.FSM(Finite State Machine:有限状態機械)で状態と遷移を定義する.各状態での入力処理や描画処理を管理する.ゲームの進行フローを構造化し,複雑なゲームロジックの実装を可能にする.
リソース管理:
テクスチャ,モデル,サウンドなどのアセット(素材データ)を管理する仕組みである.シーン切り替え時の読み込みと解放を制御する.非同期ローディング(バックグラウンド読み込み)でロード時間を隠蔽する.メモリ使用量の最適化を行い,スムーズなシーン遷移を実現する.
メモリ管理:
動的なリソースの確保と解放を制御する仕組みである.メモリリーク(解放忘れによるメモリ消費)の防止とフラグメンテーション(メモリの断片化)対策を実施する.参照カウント(使用状況の計数)による自動解放を行う.ガベージコレクション(不要メモリの自動回収)のタイミング制御を行い,安定したゲーム動作を保証する.
以下のコードでは,シーン遷移の基本的な制御を実装する.現在のシーンのクリーンアップ,新しいシーンの初期化,リソースの適切な解放を示している.
from direct.showbase.ShowBase import ShowBase
from direct.gui.OnscreenText import OnscreenText
from direct.gui.DirectGui import DirectButton
class Scene:
def __init__(self, app):
self.app = app
self.elements = []
def setup(self):
pass
def cleanup(self):
for element in self.elements:
element.removeNode()
self.elements = []
class MenuScene(Scene):
def setup(self):
self.title = OnscreenText(
text="Menu Scene",
pos=(0, 0.5),
scale=0.1,
fg=(1, 1, 0, 1)
)
self.elements.append(self.title)
self.start_button = DirectButton(
text="Start Game",
pos=(0, 0, 0),
scale=0.1,
command=lambda: self.app.changeScene(GameScene(self.app))
)
self.elements.append(self.start_button)
class GameScene(Scene):
def setup(self):
self.title = OnscreenText(
text="Game Scene",
pos=(0, 0.5),
scale=0.1,
fg=(0, 1, 0, 1)
)
self.elements.append(self.title)
# ゲームオブジェクト
self.cube = loader.loadModel("models/box")
self.cube.reparentTo(render)
self.cube.setPos(0, 10, 0)
self.cube.setTextureOff(1) # テクスチャを強制的にオフ
self.cube.setColor(0.3, 0.7, 0.3, 1)
self.elements.append(self.cube)
self.back_button = DirectButton(
text="Back to Menu",
pos=(0, 0, -0.3),
scale=0.1,
command=lambda: self.app.changeScene(MenuScene(self.app))
)
self.elements.append(self.back_button)
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
self.currentScene = None
# 初期シーンの設定
self.changeScene(MenuScene(self))
def changeScene(self, newScene):
if self.currentScene:
self.currentScene.cleanup()
self.currentScene = newScene
self.currentScene.setup()
app = MyApp()
app.run()
シーン遷移のポイント:
- 現在のシーンのクリーンアップを実行する
- 新しいシーンの初期化を行う
- リソースの適切な解放を実施する
14. 統合実装例:ファーストパーソンビューのオープンワールド
予備知識:
統合システムの設計原則:
これまで学んだ個別機能を組み合わせて,実用的なゲームシステムを構築する手法である.カメラ制御(6章),入力処理(7章),タスク管理(8章),シャドウマッピング(9章),シーン管理(1章)を統合し,ファーストパーソンビューのインタラクティブな3D環境を実現する.
初期化順序の重要性:システムの初期化は依存関係に基づいて順序を決定する必要がある.照明システムは影の計算に必要なため最初に初期化し,シーンオブジェクトは照明設定後に配置する.カメラ制御は配置されたシーンを参照するため,シーン初期化後に設定する.入力処理は他のシステムが準備完了後に有効化する.この順序を守らないと,影が正しく表示されない,カメラの注視点が設定できないなどの問題が発生する.
ファーストパーソンビュー(FPV):
プレイヤーの視点でゲーム世界を体験する表示方式である.カメラがプレイヤーの目の位置に配置され,マウスで視線方向を制御し,キーボードで移動を行う.没入感の高いゲーム体験を提供し,FPS(First Person Shooter)ゲームなどで広く使用される表現手法である.
オープンワールド環境:
広大な3D空間を自由に探索できるゲーム環境である.地形の生成,オブジェクトの配置,照明設定を含む総合的なシーン構築が必要となる.プレイヤーの移動範囲に制限がなく,インタラクティブな要素を配置することで探索の楽しみを提供する.
高解像度シャドウマッピング:
DirectionalLightのsetShadowCaster()メソッドで2048×2048ピクセルの高解像度影を生成する技術である.第1引数(True)でシャドウを有効化し,第2・第3引数で解像度を指定する.解像度が高いほど影の輪郭が鮮明になるが,メモリ消費量と計算負荷も増加する.1024×1024は標準品質,2048×2048は高品質,4096×4096は最高品質として使い分ける.setShaderAuto()でシェーダーを自動設定し,影の描画を有効化する.9章で学んだ基本技術を高解像度で適用することで,より鮮明な影の表現を実現する.
四元数(Quaternion)とベクトル演算:
四元数は3D空間での回転を表現する数学的手法である.オイラー角(ヨー,ピッチ,ロール)と異なり,ジンバルロック(特定角度での回転制御不能)を回避できる利点がある.getQuat()でカメラの回転状態を四元数として取得し,getForward()で前方向の単位ベクトルを得る.このベクトルに距離を乗算(forward * 2)することで,カメラ前方の任意の位置を計算できる.ベクトル加算(pos + forward * 2)により,カメラ位置を基準とした相対座標を求める.
動的メッシュ生成の原理:
実行時にメッシュを生成してシーンに追加する技術である.GeomVertexDataで頂点データを管理し,GeomVertexWriterで頂点座標,法線ベクトル,色情報を書き込む.球体の生成では,緯度角φ(0からπ)と経度角θ(0から2π)を用いて頂点座標を計算する:x = r sin(φ) cos(θ),y = r sin(φ) sin(θ),z = r cos(φ).リング数とセグメント数が分割の細かさを決定し,16×16は滑らかさと性能のバランスが良い標準設定である.8×8は低負荷だが角ばり,32×32は滑らかだが高負荷となる.法線ベクトルは頂点座標を半径で割ることで求められ,光の反射計算に使用される.
複合入力システム:
7章で学んだ入力処理と6章のカメラ制御,8章のタスク管理を統合したシステムである.WASDキーによる移動とマウスによる視点回転を同時に処理し,複数のタスク(move_camera, update_camera)を並行実行する.キー状態を辞書型で管理することで,複数キーの同時押しに対応する.タスクマネージャが毎フレーム各タスクを呼び出し,滑らかな操作を実現する.
座標系とローカル座標変換:
3D空間にはワールド座標系(絶対座標)とローカル座標系(オブジェクト基準の相対座標)がある.setPos(self.camera, Point3(0, self.move_speed, 0))の第1引数にself.cameraを指定することで,カメラのローカル座標系での移動を実現する.これにより,カメラの向きに関係なく「前進」「後退」「左右移動」が直感的に動作する.第1引数を省略するとワールド座標系での移動となり,カメラが回転していても常に同じ方向に移動してしまう.
以下のコードでは,1章から9章で学んだ技術を統合したファーストパーソンビューのオープンワールド環境を構築する.シーン管理(1章),カメラ制御(6章),入力処理(7章),タスク管理(8章),シャドウマッピング(9章)を組み合わせ,動的メッシュ生成による球体の配置・削除機能を実装している.
from direct.showbase.ShowBase import ShowBase
from panda3d.core import Point3, Vec3, Vec4
from panda3d.core import AmbientLight, DirectionalLight
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Geom, GeomTriangles, GeomNode
from panda3d.core import CardMaker
from math import pi, sin, cos
import sys
class OpenWorldGame(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# システムの初期化(依存関係に基づく順序)
self.init_lighting() # 1. 照明(影計算の基盤)
self.init_scene() # 2. シーン(照明設定後に配置)
self.init_camera_control() # 3. カメラ(シーン参照)
self.setup_keys() # 4. 入力(全システム準備後)
# 動的オブジェクト管理
self.sphere = None
def init_lighting(self):
"""照明システムの初期設定(9章の応用)
環境光と平行光を設定し,高解像度シャドウマッピングを有効化する.
環境光は全体を均一に照らし,平行光は太陽光のような平行光線と影を生成する.
"""
# 環境光:暗めに設定して影のコントラストを強調
ambient_light = AmbientLight("ambient")
ambient_light.setColor(Vec4(0.2, 0.2, 0.2, 1))
ambient_np = self.render.attachNewNode(ambient_light)
self.render.setLight(ambient_np)
# 平行光:2048×2048の高解像度シャドウマッピング
directional_light = DirectionalLight("directional")
directional_light.setColor(Vec4(1, 1, 1, 1))
directional_light.setShadowCaster(True, 2048, 2048) # 高品質影
light_np = self.render.attachNewNode(directional_light)
light_np.setPos(20, -20, 40) # 斜め上から照射
light_np.lookAt(0, 0, 0)
self.render.setLight(light_np)
# 自動シェーダー設定で影描画を有効化
self.render.setShaderAuto()
def init_scene(self):
"""シーンの初期設定(1章の応用)
100×100単位の広大な地面と4つの目印となる立方体を配置する.
地面はCardMakerで平面を生成し,P軸を-90度回転して水平に配置する.
"""
# 地面の作成(-50から+50の範囲で100×100単位)
cm = CardMaker("ground")
cm.setFrame(-50, 50, -50, 50)
ground = self.render.attachNewNode(cm.generate())
ground.setPos(0, 0, 0)
ground.setP(-90) # XY平面からXZ平面へ回転
ground.setColor(0.2, 0.5, 0.2, 1) # 草原を想定した緑色
ground.setShaderAuto()
# 4つの立方体を正方形に配置(位置確認の目印)
# z=0.5で地面から0.5単位浮かせて配置(地面との境界を明確化)
cube_positions = [
(Point3(-3, 3, 0.5), Vec4(1, 0, 0, 1)), # 左奥:赤
(Point3(3, 3, 0.5), Vec4(0, 1, 0, 1)), # 右奥:緑
(Point3(-3, -3, 0.5), Vec4(0, 0, 1, 1)), # 左手前:青
(Point3(3, -3, 0.5), Vec4(1, 1, 0, 1)) # 右手前:黄
]
for pos, color in cube_positions:
self.create_cube(pos, size=1.0, color=color)
def create_cube(self, pos, size=1.0, color=Vec4(1, 1, 1, 1)):
"""立方体オブジェクトを生成(1章の応用)
Args:
pos (Point3): 立方体の中心位置
size (float): 立方体の一辺の長さ
color (Vec4): RGBA形式の色(各成分0.0~1.0)
"""
try:
cube = self.loader.loadModel("models/box")
if cube.isEmpty():
print("警告: models/boxの読み込みに失敗しました")
return
cube.setPos(pos)
cube.setScale(size)
cube.setColor(color)
cube.setShaderAuto() # 影を受けるために必要
cube.reparentTo(self.render)
except Exception as e:
print(f"エラー: 立方体の生成に失敗 - {e}")
def init_camera_control(self):
"""カメラ制御の初期設定(6章の応用)
カメラを(0, -10, 3)に配置し,原点を見るように設定する.
視野角80度で広い視界を確保し,マウスポインタを画面中央に固定する.
"""
# カメラの初期位置と注視点
self.camera.setPos(0, -10, 3)
self.camera.lookAt(0, 0, 0)
self.camLens.setFov(80) # 広めの視野角
# 視点制御用パラメータ
self.heading = 0 # 水平回転角(ヨー)
self.pitch = 0 # 垂直回転角(ピッチ)
self.mouse_sensitivity = 0.2 # マウス感度
# マウスポインタを画面中央に移動(視点制御の基準点)
self.win.movePointer(0, self.win.getXSize() // 2, self.win.getYSize() // 2)
# カメラ更新タスクの登録(8章の応用)
self.taskMgr.add(self.update_camera, "UpdateCameraTask")
self.taskMgr.add(self.move_camera, "MoveCameraTask")
def setup_keys(self):
"""キー設定の初期化(7章の応用)
WASDキーで移動,スペースキーで球体生成・削除,ESCキーで終了を設定する.
キーの押下状態を辞書で管理し,複数キー同時押しに対応する.
"""
# 移動速度とキー状態の初期化
self.move_speed = 0.2 # 1フレームあたりの移動量
self.keys = {"w": False, "a": False, "s": False, "d": False}
# 特殊キーのバインド
self.accept("escape", sys.exit)
self.accept("space", self.toggle_sphere)
# WASDキーの押下と解放イベント
for key in ["w", "a", "s", "d"]:
self.accept(key, self.update_key, [key, True])
self.accept(f"{key}-up", self.update_key, [key, False])
def update_key(self, key, value):
"""キー状態を更新(7章の応用)
Args:
key (str): キー名("w", "a", "s", "d")
value (bool): 押下状態(True: 押下中,False: 解放)
"""
self.keys[key] = value
def move_camera(self, task):
"""カメラの移動を更新(6章,7章の統合)
WASDキーの状態に応じてカメラをローカル座標系で移動する.
第1引数にself.cameraを指定することで,カメラの向きに応じた移動を実現.
Args:
task: タスクオブジェクト
Returns:
Task.cont: タスクを継続
"""
# Wキー: 前進(カメラのローカルY軸正方向)
if self.keys["w"]:
self.camera.setPos(self.camera, Point3(0, self.move_speed, 0))
# Sキー: 後退(カメラのローカルY軸負方向)
if self.keys["s"]:
self.camera.setPos(self.camera, Point3(0, -self.move_speed, 0))
# Aキー: 左移動(カメラのローカルX軸負方向)
if self.keys["a"]:
self.camera.setPos(self.camera, Point3(-self.move_speed, 0, 0))
# Dキー: 右移動(カメラのローカルX軸正方向)
if self.keys["d"]:
self.camera.setPos(self.camera, Point3(self.move_speed, 0, 0))
return task.cont
def update_camera(self, task):
"""カメラの視点を更新(6章の応用)
マウスの移動量から回転角を計算し,カメラの向きを更新する.
ピッチ角を-90度から+90度に制限して,視点の反転を防止する.
Args:
task: タスクオブジェクト
Returns:
Task.cont: タスクを継続
"""
# マウスの存在確認
if not self.mouseWatcherNode.hasMouse():
return task.cont
# マウスポインタの現在位置を取得
md = self.win.getPointer(0)
x = md.getX()
y = md.getY()
center_x = self.win.getXSize() // 2
center_y = self.win.getYSize() // 2
# 中央からの移動量を回転角に変換
if self.win.movePointer(0, center_x, center_y):
# 水平回転(ヨー):左右のマウス移動
self.heading -= (x - center_x) * self.mouse_sensitivity
# 垂直回転(ピッチ):上下のマウス移動
self.pitch -= (y - center_y) * self.mouse_sensitivity
# ピッチ角の制限(-90度~+90度)で視点反転を防止
self.pitch = max(-90, min(90, self.pitch))
# カメラの向きを更新(H: ヨー, P: ピッチ, R: ロール)
self.camera.setHpr(self.heading, self.pitch, 0)
return task.cont
def toggle_sphere(self):
"""球体の生成・消去(動的オブジェクト管理)
スペースキーで球体を生成または削除する.
球体が存在しない場合は生成,存在する場合は削除する.
"""
if self.sphere is None:
self.create_sphere()
else:
self.sphere.removeNode()
self.sphere = None
def create_sphere(self):
"""球体メッシュを動的に生成
カメラの前方2単位の位置に半径0.3の球体を生成する.
四元数から前方ベクトルを取得し,カメラ位置との加算で配置位置を計算する.
"""
try:
# カメラの四元数から前方単位ベクトルを取得
cam_quat = self.camera.getQuat()
forward = cam_quat.getForward()
# カメラ位置 + 前方ベクトル×2 で配置位置を計算
pos = self.camera.getPos() + forward * 2
# 球体メッシュの生成(半径0.3,16×16分割)
sphere_node = self.create_sphere_mesh(radius=0.3, rings=16, segments=16)
self.sphere = self.render.attachNewNode(sphere_node)
self.sphere.setPos(pos)
self.sphere.setShaderAuto() # 影を受けるために必要
except Exception as e:
print(f"エラー: 球体の生成に失敗 - {e}")
def create_sphere_mesh(self, radius=1, rings=16, segments=16):
"""球体の頂点データを生成(メッシュ生成の応用)
緯度角φと経度角θを用いて球面上の頂点座標を計算する.
x = r sin(φ) cos(θ), y = r sin(φ) sin(θ), z = r cos(φ)
Args:
radius (float): 球の半径
rings (int): 緯度方向の分割数(多いほど滑らか,8~32推奨)
segments (int): 経度方向の分割数(多いほど滑らか,8~32推奨)
Returns:
GeomNode: 球体のジオメトリノード
Note:
16×16分割は標準的な品質で,ほとんどの用途に適している.
8×8は低負荷だが角ばる,32×32は滑らかだが高負荷となる.
"""
try:
# 頂点フォーマット:位置(V3),法線(n3),色(c4)
format = GeomVertexFormat.getV3n3c4()
vdata = GeomVertexData('sphere', format, Geom.UHStatic)
# 頂点データ書き込み用オブジェクト
vertex = GeomVertexWriter(vdata, 'vertex')
normal = GeomVertexWriter(vdata, 'normal')
color = GeomVertexWriter(vdata, 'color')
# 球面上の頂点を生成
for ring in range(rings + 1):
phi = ring * pi / rings # 緯度角(0~π)
for segment in range(segments + 1):
theta = segment * 2.0 * pi / segments # 経度角(0~2π)
# 球面座標から直交座標への変換
x = radius * sin(phi) * cos(theta)
y = radius * sin(phi) * sin(theta)
z = radius * cos(phi)
# 頂点座標の追加
vertex.addData3(x, y, z)
# 法線ベクトル(頂点座標を正規化)
normal.addData3(x/radius, y/radius, z/radius)
# 白色
color.addData4(1, 1, 1, 1)
# 三角形の構築(隣接する4頂点から2つの三角形を生成)
tris = GeomTriangles(Geom.UHStatic)
for ring in range(rings):
for segment in range(segments):
# 現在のリングとセグメントの頂点インデックス
i = ring * (segments + 1) + segment
# 下側の三角形
tris.addVertices(i, i + segments + 1, i + 1)
# 上側の三角形
tris.addVertices(i + 1, i + segments + 1, i + segments + 2)
# ジオメトリの作成
geom = Geom(vdata)
geom.addPrimitive(tris)
# ノードの作成と返却
node = GeomNode('sphere')
node.addGeom(geom)
return node
except Exception as e:
print(f"エラー: 球体メッシュの生成に失敗 - {e}")
# エラー時は空のノードを返す
return GeomNode('empty')
app = OpenWorldGame()
app.run()
統合実装のポイント:
- 初期化順序を依存関係に基づいて決定する(照明→シーン→カメラ→入力)
- 複数のタスクを並行管理する(カメラ更新,移動処理を独立して実行)
- エラー処理を実装する(try-exceptでモデル読み込み失敗に対応)
- ローカル座標系を活用する(カメラの向きに応じた直感的な移動を実現)
- パラメータの意味を理解する(分割数と品質・性能のトレードオフ)
動作確認とトラブルシューティング:
正常動作の確認項目:
- プログラム起動時に緑色の地面と4つの色付き立方体が表示される
- マウス移動でカメラの向きが変わる
- WASDキーでカメラが移動する
- スペースキーで白い球体が生成・削除される
- 立方体と球体に影が表示される
よくある問題と対処法:
- 影が表示されない:setShaderAuto()の呼び出し忘れ.全オブジェクトに設定が必要
- カメラが動かない:タスクの登録忘れ.taskMgr.add()の呼び出しを確認
- 球体が生成されない:コンソールのエラーメッセージを確認.数学ライブラリのインポート忘れの可能性
- 移動方向がおかしい:setPos()の第1引数を確認.self.cameraの指定が必要
発展課題:
初級:
- 立方体の配置位置や色を変更してみよう
- 地面の色を変えて,砂漠や雪原を表現してみよう
- 移動速度を変更して,走る動作を実装してみよう
中級:
- 複数の球体を配置できるように改造しよう(リストで管理)
- 球体の色や大きさを変更できるようにしよう
- 上下移動(Qキー:上昇,Eキー:下降)を実装してみよう
上級:
- 3章の物理シミュレーションを統合し,球体に重力を適用してみよう
- 2章の衝突判定を追加し,立方体との衝突を検出してみよう
- 13章のシーン遷移を実装し,複数のマップを切り替えてみよう