Panda3Dゲームエンジン:システム構築の基礎(演習)
【概要】本教材はPanda3Dを用いて3次元コンピュータグラフィックスの主要機能を演習形式で学ぶ。シーン管理、衝突判定、物理シミュレーション、アニメーション制御、GUI制御、カメラ制御、入力処理、タスク管理、シャドウマッピング、サウンド制御、スプライト表示、パーティクル制御、シーン遷移の各テーマについて、予備知識と実行可能なコード例を示す。各演習は《手順》《ヒント》《考察ポイント》で構成する。
【動作環境】Windowsのパソコンで動作する。GPU搭載機・CPUのみの機のいずれでも動作する。Linux等の知識は不要である。
【演習の進め方】
- 各章のコードをファイルに保存し実行する
- 画面で動作を確認する
- 《ヒント》に示すパラメータを変更し、結果の違いを観察する
- 《考察ポイント》について自分の言葉でまとめる
【目次】
事前準備:Pythonのインストール
本教材はPythonが動作する環境を前提とする。Pythonが未導入の場合は事前にインストールする。Panda3D 1.10系はPython 3.8〜3.13に対応するため、この範囲のPythonを用いる。
Panda3Dのインストール
すべての演習でPanda3Dを使用する。Panda3Dは3次元コンピュータグラフィックスのためのライブラリである。2026年5月時点の最新安定版は1.10.16である。インストールは1回だけ行えばよい。
手順:コマンドプロンプトを起動する(Windowsキー → 「cmd」と入力 → Enter)。起動したコマンドプロンプトに次を入力して実行する。
pip install panda3d
プログラムの実行
各演習のコードを実行する方法は2通りある。どちらでも動作する。
方法A:Visual Studio Codeで実行する。Visual Studio Codeがインストール・設定済みであれば、本ページのコードを編集画面に貼り付け、実行する。
方法B:ファイルに保存して実行する。テキストエディタを開き、本ページのコードを貼り付ける。a.pyのようなファイル名で保存する。コマンドプロンプトを起動し、そのファイルがあるフォルダに移動して、次を入力して実行する。
python a.py
全演習に共通する前提
コードを読み解く前に、全演習に共通する次の4点を理解しておく。これらは各演習で繰り返し登場する。
(1) 座標系。Panda3Dはz軸が上を向く座標系(Z-up)を用いる。各座標値の意味は次のとおりである。一般的な数学・グラフィックス教材ではy軸を上に取ることが多いが、Panda3Dではy軸が奥行きである。
- x:左右方向(正の値で右)
- y:奥行き方向(正の値でカメラから遠ざかる)
- z:上下方向(正の値で上)
たとえばsetPos(0, 10, 0)はカメラから奥に10だけ離れた位置を表す。
(2) インポート不要で使える変数。各演習のコードには、render(3D空間の最上位ノード)、loader(モデルや画像の読み込み役)、taskMgr(定期処理の管理役)、globalClock(経過時間を測る時計)、base(アプリ本体)といった変数が、インポートなしで登場する。これらはShowBaseクラスを継承した時点でPanda3Dが自動的にグローバル変数として用意するため、個別にimportする必要はない。Visual Studio Codeなどのエディタではこれらが「未定義」と警告されることがあるが、実行には支障がない。
(3) 同梱アセット(モデル・画像・音声)。コード中のmodels/box(立方体)、models/misc/sphere(球)、models/panda-model(パンダ)、models/maps/以下の画像、models/audio/以下の音声は、すべてPanda3Dのインストール時に同梱されるサンプルアセットである。別途ダウンロードは不要で、パスをそのまま指定すれば読み込める。
(4) プログラムの終了方法。実行したプログラムは、ウィンドウ右上の×ボタンで終了できる。これはすべての演習に共通する。加えて、演習7と演習14ではESCキーでも終了でき、演習5には終了用のExitボタンがある。
演習1. シーン管理
3D空間内のオブジェクトはシーングラフ(オブジェクトをツリー構造で管理する仕組み)で管理される。ツリーの各要素をノードと呼び、最上位のノードがrender(3D空間全体の親ノード)である。親ノードを動かすと子ノードも一緒に動く。本演習では、親子関係をもつ2つの立方体を配置し、その性質を観察する。
《手順》
- 下記コードを実行し、赤い立方体(親ノードに付随)と緑色の小さな立方体(子ノードに付随)が表示されることを確認する
parent.setPos()の座標値を変更して再実行し、2つの立方体が一緒に移動することを確認するchild.setPos()の座標値を変更して再実行し、子の立方体のみが移動することを確認する
from direct.showbase.ShowBase import ShowBase
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 親ノードを作成し、立方体を取り付ける
parent = render.attachNewNode("parent")
parent.setPos(0, 10, 0) # 親の絶対位置
parent_model = loader.loadModel("models/box")
parent_model.reparentTo(parent)
parent_model.setTextureOff(1)
parent_model.setColor(0.7, 0.3, 0.3, 1) # 赤
# 子ノードを親に取り付け、別の立方体を取り付ける
child = parent.attachNewNode("child")
child.setPos(2, 0, 0) # 親からの相対位置
child_model = loader.loadModel("models/box")
child_model.reparentTo(child)
child_model.setScale(0.5)
child_model.setTextureOff(1)
child_model.setColor(0.3, 0.7, 0.3, 1) # 緑
app = MyApp()
app.run()
《ヒント》
attachNewNodeで子ノードを作り、reparentToで親を変更する- 子の座標は親からの相対位置で表される。親が(0,10,0)、子が(2,0,0)なら、子は世界座標で(2,10,0)に表示される
setColorのRGB値(赤・緑・青、各0.0〜1.0)を変えると色が変わる
《考察ポイント》
- 親ノードを移動したとき、子ノードはどう動くか。子ノードを移動したとき、親ノードはどうなるか
- シーングラフの仕組みは、どのような3D表現(例:人体、車両、太陽系)に向くか
演習2. 衝突判定
3D空間内で物体同士が接触したかを検出する仕組みを衝突判定という。Panda3Dでは衝突元(from、動いて当たる側)と衝突先(into、当てられる側)を区別して扱う。
《手順》
- 下記コードを実行し、緑の球が左右に動くことを確認する
- 球が赤い立方体の中心に近づき、不可視の球状判定領域が重なった瞬間、球が黄色に変わり、画面左上の表示が変化することを確認する
- 球の初期位置や移動範囲を変更し、衝突するタイミングが変わることを確認する
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
)
# 球(衝突元 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)
sphere_cnode = CollisionNode("sphere_from")
sphere_cnode.addSolid(CollisionSphere(0, 0, 0, 1.0))
self.sphere_cnp = self.sphere_model.attachNewNode(sphere_cnode)
self.sphere_cnp.show()
# 立方体(衝突先 into)
self.box_model = loader.loadModel("models/box")
self.box_model.reparentTo(render)
self.box_model.setPos(3, 10, 0)
self.box_model.setTextureOff(1)
self.box_model.setColor(0.7, 0.3, 0.3, 1)
box_cnode = CollisionNode("box_into")
box_cnode.addSolid(CollisionSphere(0, 0, 0, 1.5))
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()
# 球を左右に移動
new_x = self.sphere_model.getX() + 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:
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()
《ヒント》
- 見た目は立方体だが、衝突判定用の形状(
CollisionSphere)は球で近似している。計算を高速化するためである - 衝突は見た目の立方体の表面ではなく、立方体側の判定領域(半径1.5)と球側の判定領域(半径1.0)が重なった時点で起こる。そのため衝突が成立する位置は見た目とずれる
- 衝突形状を可視化する場合は
show()、非表示にする場合はhide()を使う CollisionSphereの半径(4番目の引数)を変更すると、衝突が起こる範囲が変わる
《考察ポイント》
- 判定領域を可視化したとき、見た目の形状と判定領域はどの程度ずれているか。単純な球で近似することの利点と欠点は何か
- ゲームの中で衝突判定が必要になる場面を、具体例を挙げて考える
演習3. 物理シミュレーション
重力や慣性を計算で再現する仕組みを物理シミュレーションといい、それを実行するライブラリを物理エンジンと呼ぶ。Panda3DはBullet Physics(オープンソースの物理エンジン)を利用し、剛体(変形しない物体)に対して重力下での落下や衝突を計算する。Bulletの機能はPanda3D本体に同梱されており、追加インストールは不要である。
《手順》
- 下記コードを実行し、赤い球が地面に落下する様子を確認する
setGravityの値を変更し、落下速度の違いを確認する- 球の初期位置(
setPos)の高さを変更し、落下時間の違いを確認する
from direct.showbase.ShowBase import ShowBase
from panda3d.core import Vec3
from panda3d.bullet import BulletWorld, BulletSphereShape, BulletRigidBodyNode
from panda3d.bullet import BulletPlaneShape
class MyApp(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# 物理世界の初期化
self.physics_world = BulletWorld()
self.physics_world.setGravity(Vec3(0, 0, -9.81))
# 地面の物理形状(無限平面、法線は上向き)
ground_node = BulletRigidBodyNode("ground")
ground_node.addShape(BulletPlaneShape(Vec3(0, 0, 1), 0))
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)
# 剛体球の作成
body = BulletRigidBodyNode("sphere")
body.addShape(BulletSphereShape(1.0))
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()
《ヒント》
setGravity(Vec3(0, 0, -9.81))はz軸下向きに9.81 m/s²の加速度を設定する(地表面での重力加速度に相当)。値を変えると落下速度が変わるsetMass(0)に変更すると剛体は静的物体となり動かなくなるdoPhysicsを毎フレーム呼ばないと、剛体は登録しても動かない
《考察ポイント》
- 物理エンジンに任せた落下と、座標を手動で更新する方法(演習1や演習2の方式)とでは、動きや実装にどのような違いが現れるか
- 物理シミュレーションが向くゲームと、向かないゲームの例を考える
演習4. アニメーション制御
キャラクターを動かすには、あらかじめ作成された動作データ(アニメーション)を再生する。Actorクラス(アニメーション付きキャラクターを管理するクラス)がその管理を担う。
《手順》
- 下記コードを実行し、パンダのモデルが表示されることを確認する
- 「Walk」ボタンで歩行アニメーションが開始されることを確認する
- 「Stop」「Fast」ボタンで再生制御の違いを確認する
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)
# アニメーション付きモデルの読み込み
# 第1引数:モデル本体、第2引数:アニメーション名とファイルの辞書
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()
《ヒント》
loopは繰り返し再生、playは1回再生、stopは停止であるsetPlayRateの値を0.5にすると半分の速度、-1.0で逆再生になる
《考察ポイント》
- 「Walk」「Fast」を押したとき、再生の見え方はどう変わるか。1つのキャラクターに複数のアニメーション(歩く・走る・ジャンプ)を切り替えるには、どのような状態管理が必要か
- アニメーションデータを用意する方法と、毎フレーム計算で姿勢を作る方法(プロシージャル、プログラムで動きを生成する手法)の利点・欠点を考える
演習5. GUI制御
3D画面上に2DのUI(ユーザインタフェース:ボタン、テキストなどの操作・表示部品)を重ねて表示する。スコア表示やメニューに用いる。
《手順》
- 下記コードを実行し、画面左上にスコア、中央にボタンが表示されることを確認する
- 「Add Score」ボタンを押すたびにスコアが10ずつ増えることを確認する
- 「Exit」ボタンでプログラムが終了することを確認する
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
)
# スコア追加ボタン
DirectButton(
text="Add Score",
pos=(0, 0, 0),
scale=0.1,
command=self.addScore
)
# 終了ボタン
DirectButton(
text="Exit",
pos=(0, 0, -0.3),
scale=0.1,
command=self.userExit
)
def addScore(self):
self.score += 10
self.score_text.setText(f"Score: {self.score}")
app = MyApp()
app.run()
《ヒント》
- 2D画面の座標は原点が画面中央、左右はxで右が正、上下は上が正である(おおむねx: -1.3〜1.3、上下: -1.0〜1.0)
pos=(x, 0, z)のように3要素で指定するが、平面上の表示なので中央の値(奥行きにあたる成分)は使われない。効くのは1番目(左右)と3番目(上下)であるcommandに指定する関数は、括弧をつけずに関数自体を渡す(押下時にPanda3D側で呼ばれる)
《考察ポイント》
- 「Add Score」を押すと表示が更新される。3D空間内のオブジェクトと2D画面に固定されるUIは、どのように使い分けるべきか
- ゲームで必要となるUI要素(HP、ミニマップ、メッセージ等)の配置を設計してみる
演習6. カメラ制御
3D空間における視点の位置と向きを制御する。マウスで視点を回転させる一人称視点(プレイヤーの目線で世界を見る方式)を実現する。
《手順》
- 下記コードを実行し、複数の立方体が格子状に並ぶ風景が表示されることを確認する
- マウスを動かすと視点が回転し、画面左上の角度表示(H, P)が変化することを確認する
- 視野角
setFovの値を30、60、120と変えて視界の違いを確認する
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)
# 視点更新タスクの登録
taskMgr.add(self.updateCamera, "camera_update")
def updateCamera(self, task):
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))
# カメラの向きを更新
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()
《ヒント》
- H(ヘディング、水平回転=左右の首振り)、P(ピッチ、上下回転=うなずき)、R(ロール、傾き=首をかしげる方向)の3つでカメラの向きを表す
- ピッチを±90度に制限しないと、真上を超えて世界が反転して見える
- 視野角(FOV: Field Of View、カメラに映る角度の広さ)が大きいほど広い範囲が見える
self.sensitivityで視点回転の速さを調整する
《考察ポイント》
- 視野角を30、60、120と変えたとき、同じ風景の見え方はどう変化するか。広角と望遠の違いは現実のカメラと同じか
- 一人称視点と三人称視点(プレイヤーキャラクターを背後から映す方式)では、カメラ制御の実装にどのような違いが必要か
演習7. 入力処理
キーボード入力を検出し、オブジェクトを操作する。キーの押下と解放を別々に検出することで、複数キーの同時押しに対応する。
《手順》
- 下記コードを実行し、緑色の立方体が地面(表示用の飾り)の付近に表示されることを確認する
- W/A/S/Dキーで立方体が上下左右に移動することを確認する
- スペースキーでジャンプすることを確認する。複数キーの同時押しも試す
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)
for key in ["w", "a", "s", "d", "space"]:
self.accept(key, self.updateKey, [key, True])
self.accept(f"{key}-up", self.updateKey, [key, 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
# ジャンプ処理(z = 0 を着地面とみなす)
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()
《ヒント》
- 「キー名」と「キー名-up」(キーを離した瞬間のイベント)の2つを登録することで、押下と解放の両方を検出する
- 辞書(dict)でキー状態を保持すれば、複数キーの同時押しに対応できる
- 移動量にdt(フレーム間の経過時間、秒単位)を掛けることで、フレームレートに依存しない一定の動きになる
- この地面は表示用のモデルであり、着地判定は地面の位置とは無関係に「z = 0」を基準に行っている。立方体は地面に乗っているのではなく、z = 0 を仮想的な床として動いている
《考察ポイント》
- 複数キーを同時に押したとき、立方体の動きはどうなるか。キー状態を毎フレーム参照する方式と、押下イベントの瞬間に処理する方式は、それぞれどのような場面に向くか
- 移動量にdtを掛けない場合、何が起こるか
演習8. タスク管理
ゲームは毎フレーム(画面の1回ごとの更新)、入力・状態更新・描画を繰り返す。Panda3DではtaskMgr(タスクマネージャ、定期処理の管理役)でこの定期処理を管理する。
《手順》
- 下記コードを実行し、青い立方体が回転することを確認する
- 画面上部の経過時間が増加することを確認する
- 回転速度を決める「50」の値を変更し、回転の速さが変わることを確認する
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.elapsed_time += dt
self.time_text.setText(f"Time: {self.elapsed_time:.2f}")
# オブジェクトを回転(1秒あたり50度)
self.cube.setH(self.cube.getH() + 50 * dt)
return task.cont
app = MyApp()
app.run()
《ヒント》
task.contを返すと次フレームも実行され、task.doneを返すと終了するglobalClock.getDt()は前フレームからの経過秒数を返す- 「角度 × dt」とすることで「1秒あたり何度回るか」を指定したことになる
- フレームレート(1秒あたりの画面更新回数)は環境により変動するが、dtを使えば動きの速度は一定に保てる
《考察ポイント》
- 「50」の値を変えると回転の速さはどう変わるか。フレームレートが変動する環境で「フレームごとに+1度」と「秒あたり+50度」では何が違うか
- ゲームの中で毎フレーム実行すべき処理と、必要時のみ実行すべき処理を分類してみる
演習9. シャドウマッピング(影の描画)
3D空間に影をつけることで奥行きや立体感が向上する。シャドウマッピングは、光源視点で各点までの距離(深度)を画像(シャドウマップ)として記録し、それを用いて影を描画する手法である。光源には、空間全体を一様に照らす環境光と、太陽光のように一方向から照らす平行光がある。影は平行光のような方向のある光源から生じる。
《手順》
- 下記コードを実行し、赤い立方体が地面に影を落としていることを確認する
- 環境光
alightの色値を(0.1, 0.1, 0.1)や(0.6, 0.6, 0.6)に変えて、影の濃さの違いを確認する - 平行光
dlnpのsetHprの角度を変えて、影の方向が変わることを確認する
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)
# 影が映る範囲(フィルムサイズ)と奥行きを調整
lens = dlight.getLens()
lens.setFilmSize(30, 30)
lens.setNearFar(1, 100)
dlnp = render.attachNewNode(dlight)
dlnp.setPos(0, 10, 20)
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()
《ヒント》
- 環境光だけでは影がつかない。平行光があってはじめて影が生じる
- 平行光は一方向から平行に差す光なので、影に影響するのは光の向き(
setHpr)のみで、位置(setPos)を変えても影の方向は変わらない setShadowCasterの2048×2048は影の解像度(シャドウマップ画像のピクセル数)である。値を512にすると影がぼやけ、4096にすると鮮明になるsetFilmSizeは影が映る範囲(光源側のカメラに写る平面のサイズ)である。シーンの広さに合わせて指定しないと、影が出ない、または部分的にしか映らないsetShaderAuto()を呼ばないと影は描画されない
《考察ポイント》
- 環境光の値や平行光の角度を変えたとき、影の濃さや方向はどう変わるか。影の有無で立体感はどう変化するか
- シャドウマップの解像度を上げると映像品質は向上するが、何が犠牲になるか
演習10. サウンド制御
効果音やBGM(Background Music、背景音楽)を再生する。Panda3Dは効果音(loadSfx)とBGM(loadMusic)を区別して扱う。
《手順》
- 下記コードを実行し、3つのボタンとスライダーが表示されることを確認する
- 「Play Sound Effect」で効果音、「Play Music」でループ再生される音が出ることを確認する
- スライダーで音量変更、「Stop Music」で停止することを確認する
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 = loader.loadSfx("models/audio/sfx/GUI_rollover.wav")
self.music = loader.loadMusic("models/audio/sfx/GUI_click.wav")
# 音量の初期設定
self.sound.setVolume(0.8)
self.music.setVolume(0.5)
self.music.setLoop(True)
# 効果音再生ボタン
DirectButton(
text="Play Sound Effect",
pos=(-0.6, 0, 0.2),
scale=0.08,
command=self.playSound
)
# BGM再生ボタン
DirectButton(
text="Play Music",
pos=(0.6, 0, 0.2),
scale=0.08,
command=self.playMusic
)
# BGM停止ボタン
DirectButton(
text="Stop Music",
pos=(0.6, 0, 0),
scale=0.08,
command=self.stopMusic
)
# 音量ラベル
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()
《ヒント》
- 効果音は短く頻繁に鳴らすもの、BGMは長くループするもの、として使い分ける
setLoop(True)でループ再生になる- 音が出ない場合は、PCのスピーカ設定と音量を確認する
《考察ポイント》
- 効果音とBGMでは、鳴り方や用途がどう異なるか。映像だけのゲームと音響を加えたゲームで、体験にどのような差が生まれるか
- 3D空間の中で「音源の位置」を表現するには、どのような工夫が必要か
演習11. スプライト表示(2D画像の表示)
3D空間内に平面(カード)を置き、画像(テクスチャ、面に貼り付ける画像データ)を貼ることで2D表現を行う。これをスプライトという。アイコン、ロゴ、UI画像などに用いる。
《手順》
- 下記コードを実行し、画面に2枚の画像と、中央下に透過画像(背景が透けて見える画像)が表示されることを確認する
setFrameの値を変更し、スプライトの大きさが変わることを確認するsetPosの値を変更し、表示位置が変わることを確認する
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)
# 説明テキスト
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)
sprite1.setTexture(loader.loadTexture("models/maps/envir-groundforest.jpg"))
# スプライト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)
sprite2.setTexture(loader.loadTexture("models/maps/envir-mountain1.jpg"))
# スプライト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)
sprite3.setTexture(loader.loadTexture("models/maps/circle.rgba"))
app = MyApp()
app.run()
《ヒント》
render2dに付けると画面に固定され、renderに付けると3D空間内に配置されるrender2dは画面の縦横比を考慮しないため、ウィンドウ形状によっては画像が引き伸ばされて見える。縦横比を保ちたい場合はaspect2dに付ける- 透過画像(アルファ値=透明度を持つ画像)を扱うときは
setTransparency(TransparencyAttrib.MAlpha)を指定する setFrame(left, right, bottom, top)で平面の範囲を指定する
《考察ポイント》
setFrameやsetPosを変えたとき、表示はどう変わるか。3Dモデルではなく2D画像でキャラクターや風景を表現する利点と欠点は何か- 3D空間にスプライトを配置する場合、視点が変わると不自然に見えることがある。なぜか
演習12. パーティクル制御
多数の小さな粒子(パーティクル)で炎、煙、爆発などを表現する仕組みをパーティクルシステムという。
《手順》
- 下記コードを実行し、点の集合が放出される様子を確認する
setPoolSize(粒子の総数)、setBirthRate(発生頻度)の値を変えて、表現の変化を観察するsetStartColor、setEndColorで粒子の色変化を変更してみる
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
from panda3d.physics import PointParticleFactory, PointParticleRenderer
from panda3d.physics import LinearVectorForce
from panda3d.core import 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")
forces.addForce(LinearVectorForce(0, 0, -5))
self.p.addForceGroup(forces)
# パーティクルの開始
self.p.start(parent=render)
self.p.setPos(0, 10, 0)
app = MyApp()
app.run()
《ヒント》
- エミッター(粒子の発生源)、ファクトリ(粒子の生成)、レンダラー(粒子の見た目)、力(粒子の動き)の4要素を組み合わせる
- 色指定の
Vec4は(赤, 緑, 青, アルファ)。最終成分のアルファ値(不透明度)を1から0に変えると粒子は時間とともに消えていく LinearVectorForce(x, y, z)は一定方向の力を加える。値を変えると粒子の流れる方向が変わるbase.enableParticles()を呼ばないとパーティクルは動作しない。パーティクルはPanda3Dの内蔵物理エンジンを利用するためである
《考察ポイント》
- パラメータを変えたとき、粒子の数・色・動きはどう変わるか。炎・煙・水しぶき・魔法を表現するには、それぞれどう設定すればよいか
- 粒子数を1000、10000と増やすと描画コストはどうなるか
演習13. シーン遷移
メニュー画面とゲーム画面のように、複数のシーンを切り替える仕組みを実装する。
《手順》
- 下記コードを実行し、最初に「Menu Scene」が表示されることを確認する
- 「Start Game」ボタンで緑色の立方体が表示されるゲームシーンに切り替わることを確認する
- 「Back to Menu」ボタンでメニューに戻れることを確認する
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):
# DirectGuiウィジェットはdestroy()、純粋なNodePathはremoveNode()
for element in self.elements:
if hasattr(element, 'destroy'):
element.destroy()
else:
element.removeNode()
self.elements = []
class MenuScene(Scene):
def setup(self):
title = OnscreenText(
text="Menu Scene",
pos=(0, 0.5),
scale=0.1,
fg=(1, 1, 0, 1)
)
self.elements.append(title)
start_button = DirectButton(
text="Start Game",
pos=(0, 0, 0),
scale=0.1,
command=lambda: self.app.changeScene(GameScene(self.app))
)
self.elements.append(start_button)
class GameScene(Scene):
def setup(self):
title = OnscreenText(
text="Game Scene",
pos=(0, 0.5),
scale=0.1,
fg=(0, 1, 0, 1)
)
self.elements.append(title)
# ゲームオブジェクト
cube = loader.loadModel("models/box")
cube.reparentTo(render)
cube.setPos(0, 10, 0)
cube.setTextureOff(1)
cube.setColor(0.3, 0.7, 0.3, 1)
self.elements.append(cube)
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(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()
《ヒント》
- シーンを切り替えるときは、前のシーンの要素をクリーンアップ(画面から取り除く処理。
removeNodeまたはdestroyで行う)する。残ったまま重なると不具合になる - DirectGuiウィジェット(ボタン、テキスト等)は
destroy()でイベント登録も含めて整理し、3Dモデル等のNodePathはremoveNode()で取り除く - 各シーンが自分の要素をリストで管理することで、まとめて削除しやすい
- Scene(共通の基底クラス、各シーンの共通機能を定義)を作り、各画面(メニュー、ゲーム、ポーズ等)はこれを継承して実装する設計である
《考察ポイント》
- シーンを切り替えたとき、前の画面の要素は残らず消えているか。リソース(モデル、テクスチャ等)を解放しないと、何が起こるか
- 遷移時に画面をフェードイン・フェードアウトさせるには、どのような実装が考えられるか
演習14. 統合実装:ファーストパーソンビューのオープンワールド
これまでの演習で学んだ機能を統合し、マウスとキーボードで自由に歩き回れる3D空間を構築する。シーン管理(演習1)、カメラ制御(演習6)、入力処理(演習7)、タスク管理(演習8)、シャドウマッピング(演習9)を組み合わせる。
《手順》
- 下記コードを実行し、緑色の地面と4色の立方体が表示されることを確認する
- マウス移動で視点が回転し、W/A/S/Dキーで前後左右に移動できることを確認する
- スペースキーでカメラの前方に白い球が出現/消滅することを確認する
- 地面と立方体・球に影が落ちていることを確認する
from direct.showbase.ShowBase import ShowBase
from panda3d.core import Point3, 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, WindowProperties
from math import pi, sin, cos
import sys
class OpenWorldGame(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# マウスカーソルを非表示にする
props = WindowProperties()
props.setCursorHidden(True)
self.win.requestProperties(props)
# システムの初期化(依存関係に基づく順序)
self.init_lighting()
self.init_scene()
self.init_camera_control()
self.setup_keys()
# 動的オブジェクト管理
self.sphere = None
def init_lighting(self):
"""照明システムの初期設定"""
# 環境光
ambient_light = AmbientLight("ambient")
ambient_light.setColor(Vec4(0.2, 0.2, 0.2, 1))
self.render.setLight(self.render.attachNewNode(ambient_light))
# 平行光とシャドウマッピング
directional_light = DirectionalLight("directional")
directional_light.setColor(Vec4(1, 1, 1, 1))
directional_light.setShadowCaster(True, 2048, 2048)
# 影が映る範囲(フィルムサイズ)と奥行きを調整
lens = directional_light.getLens()
lens.setFilmSize(40, 40)
lens.setNearFar(1, 200)
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):
"""シーンの初期設定"""
# 地面
cm = CardMaker("ground")
cm.setFrame(-50, 50, -50, 50)
ground = self.render.attachNewNode(cm.generate())
ground.setP(-90)
ground.setColor(0.2, 0.5, 0.2, 1)
# 4つの立方体を配置
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:
cube = self.loader.loadModel("models/box")
cube.reparentTo(self.render)
cube.setPos(pos)
cube.setColor(color)
def init_camera_control(self):
"""カメラ制御の初期設定"""
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)
self.taskMgr.add(self.update_camera, "UpdateCameraTask")
self.taskMgr.add(self.move_camera, "MoveCameraTask")
def setup_keys(self):
"""キー設定の初期化"""
self.move_speed = 0.2
self.keys = {"w": False, "a": False, "s": False, "d": False}
self.accept("escape", sys.exit)
self.accept("space", self.toggle_sphere)
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):
self.keys[key] = value
def move_camera(self, task):
if self.keys["w"]:
self.camera.setPos(self.camera, Point3(0, self.move_speed, 0))
if self.keys["s"]:
self.camera.setPos(self.camera, Point3(0, -self.move_speed, 0))
if self.keys["a"]:
self.camera.setPos(self.camera, Point3(-self.move_speed, 0, 0))
if self.keys["d"]:
self.camera.setPos(self.camera, Point3(self.move_speed, 0, 0))
return task.cont
def update_camera(self, task):
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
self.pitch = max(-90, min(90, self.pitch))
self.camera.setHpr(self.heading, self.pitch, 0)
return task.cont
def toggle_sphere(self):
"""球の生成・消去"""
if self.sphere is None:
forward = self.camera.getQuat().getForward()
pos = self.camera.getPos() + forward * 2
sphere_node = self.create_sphere_mesh(radius=0.3, rings=16, segments=16)
self.sphere = self.render.attachNewNode(sphere_node)
self.sphere.setPos(pos)
else:
self.sphere.removeNode()
self.sphere = None
def create_sphere_mesh(self, radius=1, rings=16, segments=16):
"""球の頂点データを生成"""
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
for segment in range(segments + 1):
theta = segment * 2.0 * pi / segments
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)
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
app = OpenWorldGame()
app.run()
《ヒント》
- 初期化は「照明→シーン→カメラ→入力」の順で行う。照明は影の計算に必要なため先に設定する
setPos(self.camera, ...)のように第1引数にカメラを指定すると、カメラのローカル座標系(カメラ自身を基準とした座標軸)での移動になる。これにより視点方向への前進が実現する- 影が出ない、または部分的にしか映らない場合は、
setFilmSizeでシーンを覆う範囲を指定する - 球はモデルファイルを使わず、頂点(3D空間の点、xyz座標を持つ)を緯度・経度の二重ループで計算し、それを三角形でつないでメッシュ(三角形などで構成される3D形状データ)を生成している
《考察ポイント》
- マウスとキーで移動・視点操作したとき、各機能(カメラ・入力・影)はどう連携しているか。各機能を独立したメソッド(
init_lighting、init_scene、init_camera_control、setup_keys)に分離する利点は何か - 動的メッシュ生成(コードで頂点を計算)と、外部モデル読み込み(
loadModel)の使い分けはどのように決めるか
発展課題
初級:
- 立方体の配置位置や色を変更する
- 地面の色を変えて、砂漠や雪原を表現する
- 移動速度(
move_speed)を変更する
中級:
- 複数の球を配置できるよう改造する
- 球の色や大きさを変更可能にする
- 上下移動(Qキー:上昇、Eキー:下降)を実装する
上級:
- 演習3の物理シミュレーションを統合し、球に重力を適用する
- 演習2の衝突判定を追加し、立方体との衝突を検出する
- 演習13のシーン遷移を実装し、複数のマップを切り替える