Panda3D オープンワールドゲーム教材
【本教材を読む前に】次の3点を前提として把握しておくとよい。
- 2つの座標系がある:本教材には、画面上の実寸である「ワールド座標」と、ハイトマップ(高さ配列)の格子上の位置である「ハイトマップ座標」の2層がある。両者は
terrain_scale(水平方向の拡大率)で変換される。プレイヤーの移動速度やカメラ距離はワールド座標で、ノイズやLODのしきい値はおもにハイトマップ座標で考える。 - 影の描画にはGPUが要る:影は
setup_lighting内のself.render.setShaderAuto()によるシェーダ自動生成で描かれ、GLSL対応のGPUを実質的に必要とする。古いCPU内蔵GPUや一部の仮想環境では、影が出ない・画面が暗くなることがある。その場合はこの1行をコメントアウトして無効化すれば、影なしで動作する。 - 追加ライブラリが必要:地形生成にPanda3Dのほか、ノイズ生成ライブラリ
opensimplexと数値計算ライブラリnumpyを使う。インストール方法は「前準備」で説明する。
【目次】
プログラム利用ガイド
1. このプログラムの利用シーン
本プログラムは、Panda3Dゲームエンジンを使用した3D地形生成とキャラクター操作の学習教材である。手続き的地形生成、LOD(Level of Detail:詳細度。視点から遠いものほど粗く描画して処理を軽くする仕組み)制御、三人称カメラ(プレイヤーを後方から映すカメラ)の実装方法を、動作するプログラムを通じて理解できる。
2. 主な機能
- 動的地形生成:起動時にOpenSimplexノイズと侵食シミュレーション(雨水による地形の削れを模した計算)で地形を生成する。
- 三人称カメラ:プレイヤーを追従するカメラで、マウス操作により視点を回転できる。
- 地形追従移動:プレイヤーは地形の起伏に沿って移動し、高度が自動的に調整される。
- LOD地形描画:視点からの距離に応じて地形の詳細度が変化し、描画負荷を抑制する。
3. 基本的な使い方
- 起動:
コマンドプロンプトでpythonコマンドを実行する。地形生成には数秒かかる場合がある。
- 移動操作:
WASDキーでプレイヤーを前後左右に移動させる。移動方向はカメラの向きを基準とする。
- カメラ操作:
左マウスボタンを押しながらドラッグすると、カメラが回転する。マウスホイールで視点距離を調整できる。
- 終了:
ESCキーを押すとプログラムが終了する。
4. 便利な機能
- カメラピッチ制限:カメラの垂直角度(ピッチ:見上げ・見下ろしの角度)は5度から80度の範囲に制限されており、地面にめり込んだり真上を向いたりすることを防ぐ。
- ズーム範囲:カメラ距離は10から100の範囲で調整でき、ホイール1回で3ずつ変化する。
- 地形の色分け:高度に応じて地形の色が変化し、低地(緑)、中腹(黄緑)、高地(茶)、山頂(白)を視覚的に区別できる。
- 影の描画:太陽光(指向性光源)による影が描画される。
前準備:環境構築とプログラムの実行方法
事前準備:Pythonのインストール
本教材はWindowsのパソコンで動作することを前提とする。Pythonが動作する環境が必要であり、未導入の場合は事前にインストールしておく。Panda3Dおよび使用ライブラリはバージョンによって対応するPythonのバージョンが決まっているため、Pythonは比較的新しい安定版(3.10〜3.13系など)を導入しておくとよい。GPU搭載機・CPUのみの機のいずれでも動作する(影や地形描画はGPUがあるほど快適だが、影が出ない場合の対処は冒頭の「本教材を読む前に」を参照)。
必要なライブラリのインストール
本教材の演習では、3次元コンピュータグラフィックスのためのライブラリ panda3d、ノイズ生成ライブラリ opensimplex、数値計算ライブラリ numpy を使う。インストールは1回だけ行えばよい。
手順:コマンドプロンプトを起動する(Windowsキー → 「cmd」と入力 → Enter)。起動したコマンドプロンプトに次を入力して実行する。
pip install --no-user panda3d numpy opensimplex
(opensimplex はインストール時に numpy も自動的に導入するが、上記のように明示しておくと分かりやすい。)
プログラムの実行
本ページのコードを実行する方法は2通りある。どちらでも動作する。
方法A:Visual Studio Codeで実行する。Visual Studio Codeがインストール・設定済みであれば、本ページのコードを編集画面にコピー&ペーストし、そのまま実行する。
方法B:ファイルに保存して実行する。メモ帳などのテキストエディタを開き、本ページのコードを貼り付ける。a.pyのようなファイル名で保存する。コマンドプロンプトでそのファイルがあるフォルダに移動し、次を入力して実行する。
python a.py
プログラムコードの説明
1. 概要
本プログラムは、Panda3Dゲームエンジンを使用した三人称視点のオープンワールドゲーム教材である。実行時にOpenSimplexノイズでハイトマップ(地形の高さを格子状に並べた2次元配列)を動的に生成し、粒子ベースの侵食シミュレーションを適用した後、GeoMipTerrainで地形をレンダリング(3次元データから画面に描画する処理)する。プレイヤーはWASDキーとマウス操作で地形上を移動できる。ノイズ生成と配列処理にはNumPyを用いており、グリッド全体をまとめて計算するため、1画素ずつ計算するよりはるかに短く・高速なコードになっている。
2. 主要技術
OpenSimplexノイズ(ライブラリ利用)
手続き的地形生成には、なめらかに変化するノイズ(雑音状の値)が必要である。ノイズには、Ken Perlinが1985年に発表したPerlinノイズ[1]を起源とする一連の手法があり、本教材ではその後継であるOpenSimplexノイズを opensimplex ライブラリ経由で利用する。OpenSimplexはPerlinノイズに見られがちな格子状のムラ(アーティファクト)が出にくく、地形生成に向く。ライブラリの noise2array 関数は、x座標とy座標の配列を渡すとグリッド全体のノイズ値を一度に返すため、コードを簡潔に保てる。本プログラムでは、基本ノイズ、リッジノイズ(尾根状の形状を生成する変形ノイズ)、マスクノイズ(地形タイプを区分するためのノイズ)を組み合わせて複合地形を生成する。
GeoMipTerrain
Willem H. de Boerが2000年に発表したGeoMipMapping(Geometrical MipMapping)アルゴリズムに基づくPanda3Dの地形レンダリングクラスである[2]。地形をブロック単位に分割し、視点からの距離に応じて各ブロックのLOD(詳細度)を変化させる。視点移動時に全体を再生成する必要がなく、変更のあるブロックのみを更新する。
3. 技術的特徴
- 複合ノイズ生成
地形マスクを用いて平地、丘陵、山岳エリアを区分し、それぞれに適したノイズ合成比率を適用する。基本ノイズ(オクターブノイズ:複数の周波数のノイズを重ね合わせたもの)、リッジノイズ、詳細ノイズを組み合わせることで、単一のノイズ関数では表現できない多様な地形形状を実現する。これらはすべてNumPy配列に対する演算として計算する。
- 粒子ベース侵食シミュレーション
ランダムな位置に仮想的な雨粒を配置し、勾配(傾き)に沿って流下させながら侵食と堆積を計算する手法である。本実装は、文献[3](GPU上の流体侵食)に着想を得た簡易版であり、各格子点の4近傍のうち最も低い隣へ雨粒を流す最急降下モデルを採用している。各粒子は運搬容量(一度に運べる土砂の量)を持ち、勾配が急な箇所では地形を削り、緩やかな箇所では堆積物を放出する。この処理により、尾根や谷といった水流による地形特徴が加わる。
- 距離ベースLOD制御
カメラ位置からの距離に応じてLODのしきい値を設定する。近距離では詳細なメッシュ(多数のポリゴンで構成された3次元モデル)を、遠距離では簡略化されたメッシュをレンダリングすることで、視覚品質と処理負荷のバランスを取る。
- 高さベーステクスチャ生成
ハイトマップの高度値に応じて色を割り当てる方式で地形テクスチャ(3Dモデルの表面に貼る画像)を生成する。低地は緑(草原)、中腹は黄緑、高地は茶色(岩)、山頂は白(雪)として着色する。
4. 実装の特色
教材としての利用を想定し、以下の機能を備える。
- 三人称追従カメラ(球面座標系で位置を計算。プレイヤーを中心とする球面上の点としてカメラ位置を表す方式)
- マウスドラッグによるカメラ回転とホイールによるズーム
- カメラの向きを基準とした移動方向の計算
- シャドウマッピング(光源から見た深度情報を用いて影を描画する技術)による影の描画
- プレイヤーの高さを地形に追従させる処理
5. 参考文献
- Perlin, K. (1985). An Image Synthesizer. Proceedings of the 12th Annual Conference on Computer Graphics and Interactive Techniques (SIGGRAPH '85), 287-296. https://doi.org/10.1145/325334.325247
- de Boer, W. H. (2000). Fast Terrain Rendering Using Geometrical MipMapping. https://www.flipcode.com/archives/article_geomipmaps.pdf
- Mei, X., Decaudin, P., & Hu, B.-G. (2007). Fast Hydraulic Erosion Simulation and Visualization on GPU. Proceedings of the 15th Pacific Conference on Computer Graphics and Applications, 47-56. https://doi.org/10.1109/PG.2007.15
"""
Panda3D オープンワールドゲーム教材
- 実行時にOpenSimplexノイズ + NumPyでハイトマップを動的生成
- GeoMipTerrainによるリアルな地形
- 三人称カメラ
- WASD + マウスによる簡易移動
"""
from direct.showbase.ShowBase import ShowBase
from direct.task import Task
from panda3d.core import (
GeoMipTerrain, PNMImage, Texture,
Vec3, DirectionalLight, AmbientLight,
WindowProperties, LColor, ClockObject
)
import math
import sys
import random
import numpy as np
import opensimplex
# ---- ノイズ生成のヘルパー(すべてNumPy配列に対して計算する) ----
def noise_grid(seed, xs, ys):
"""指定シードで、座標配列 xs, ys から (len(ys), len(xs)) のノイズ配列を生成。
返り値は -1〜1 の範囲。"""
opensimplex.seed(seed)
return opensimplex.noise2array(xs, ys)
def octave_grid(seed, coords, scale, octaves=6, persistence=0.5):
"""オクターブノイズ(複数周波数の合成)を配列で計算。返り値は おおむね -1〜1。"""
total = np.zeros((coords.size, coords.size))
frequency = 1.0
amplitude = 1.0
max_value = 0.0
for _ in range(octaves):
xs = coords * scale * frequency
total += noise_grid(seed, xs, xs) * amplitude
max_value += amplitude
amplitude *= persistence
frequency *= 2.0
return total / max_value
def ridge_grid(seed, coords, scale, octaves=6, persistence=0.5):
"""リッジノイズ(尾根・谷を生成する変形ノイズ)を配列で計算。返り値は 0〜1。"""
total = np.zeros((coords.size, coords.size))
frequency = 1.0
amplitude = 1.0
max_value = 0.0
for _ in range(octaves):
xs = coords * scale * frequency
n = noise_grid(seed, xs, xs)
n = 1.0 - np.abs(n) # 尾根の形状
n = n * n # より鋭い尾根に
total += n * amplitude
max_value += amplitude
amplitude *= persistence
frequency *= 2.0
return total / max_value
class OpenWorldGame(ShowBase):
def __init__(self):
ShowBase.__init__(self)
# グローバルクロック(フレーム間の経過時間 dt を取得するために使用)
self.clock = ClockObject.getGlobalClock()
# ウィンドウ設定
self.set_window_title("Open World - Panda3D教材")
# 地形パラメータ
self.terrain_size = 513 # 2^n + 1 である必要がある
self.terrain_scale = 2.5 # 水平方向の拡大率(ワールド座標 = ハイトマップ座標 × この値)
self.height_scale = 80.0 # 高低差の倍率
# ノイズのシード値(演習3で変更する)
self.seed_base = 42 # 基本地形
self.seed_ridge = 123 # 山脈用
self.seed_detail = 789 # 細部用
self.seed_mask = 456 # 平地/山地マスク用
# 侵食シミュレーションの反復回数(演習4で変更する)
self.erosion_iterations = 5000
# プレイヤー設定(マップ中央付近に配置)
# マップの一辺の長さ = (terrain_size - 1) * terrain_scale なので、その半分が中央
center = (self.terrain_size - 1) * self.terrain_scale / 2.0
self.player_pos = Vec3(center, center, 0)
self.player_heading = 0
self.move_speed = 50.0 # 広いマップ用に速度を上げている(ワールド座標での速度)
# カメラ設定
self.camera_distance = 30.0 # 広いマップ用に距離を拡大
self.camera_height = 15.0
self.camera_heading = 0 # カメラの水平角度
self.camera_pitch = 30 # カメラの垂直角度
self.mouse_dragging = False
self.last_mouse_x = 0
self.last_mouse_y = 0
# 移動状態
self.key_map = {
"forward": False, "backward": False,
"left": False, "right": False
}
# 初期化
self.setup_terrain()
self.setup_player()
self.setup_camera()
self.setup_lighting()
self.setup_controls()
self.setup_sky()
# メインループ
self.taskMgr.add(self.update, "update")
def set_window_title(self, title):
"""ウィンドウタイトル設定"""
props = WindowProperties()
props.setTitle(title)
self.win.requestProperties(props)
def generate_heightmap(self):
"""複合ノイズ+侵食シミュレーションでハイトマップを動的生成(NumPy配列で計算)"""
size = self.terrain_size
# 0, 1, 2, ... という格子座標(ハイトマップ座標)
coords = np.arange(size, dtype=np.float64)
# 各種ノイズをグリッド全体でまとめて生成(0〜1 に正規化)
mask = octave_grid(self.seed_mask, coords, 0.005, octaves=3, persistence=0.5)
mask = (mask + 1) / 2
mask = np.power(mask, 0.8) # コントラスト調整
base = octave_grid(self.seed_base, coords, 0.015, octaves=6, persistence=0.5)
base = (base + 1) / 2
ridge = ridge_grid(self.seed_ridge, coords, 0.012, octaves=5, persistence=0.45) # すでに0〜1
detail = octave_grid(self.seed_detail, coords, 0.05, octaves=3, persistence=0.4)
detail = (detail + 1) / 2
# マスクに基づいて3つの地形タイプを合成
# mask < 0.35: 平地優勢 / 0.35〜0.55: 丘陵(遷移)/ mask >= 0.55: 山岳優勢
flat = np.power(base * 0.3 + detail * 0.1, 2.0) # 平地(低く平坦)
hill = base * 0.5 + ridge * 0.3 + detail * 0.1 # 丘陵
mountain = np.power(base * 0.3 + ridge * 0.5 + detail * 0.15, 1.2) * 0.6 + 0.4 # 山岳
# 丘陵は平地と丘陵地形を遷移パラメータ t で混ぜる
t = np.clip((mask - 0.35) / 0.2, 0.0, 1.0)
hill_blended = flat * (1 - t) + hill * t
# マスク値に応じて平地 / 丘陵 / 山岳を選択
heightdata = np.where(mask < 0.35, flat,
np.where(mask < 0.55, hill_blended, mountain))
# 侵食シミュレーション
heightdata = self.apply_erosion(heightdata, size, self.erosion_iterations)
# 0〜1に正規化
min_h, max_h = heightdata.min(), heightdata.max()
range_h = (max_h - min_h) if max_h > min_h else 1.0
heightdata = (heightdata - min_h) / range_h
# PNMImageに書き込む
img = PNMImage(size, size)
for y in range(size):
for x in range(size):
h = float(heightdata[y][x])
img.setXel(x, y, h, h, h) # setXel は 0.0〜1.0 の浮動小数で指定
# テクスチャ生成用に保持
self.heightdata = heightdata
return img
def apply_erosion(self, heightdata, size, iterations):
"""簡易粒子ベース侵食シミュレーション(4近傍の最急降下モデル)"""
erosion_rate, deposition_rate, evaporation, min_slope = 0.3, 0.3, 0.02, 0.01
for _ in range(iterations):
x, y = random.uniform(1, size - 2), random.uniform(1, size - 2)
sediment, water = 0.0, 1.0
for _ in range(50): # 雨粒が流れ落ちる(最大50ステップ)
ix, iy = int(x), int(y)
if ix < 1 or ix >= size - 1 or iy < 1 or iy >= size - 1:
break
h = heightdata[iy][ix]
neighbors = [(ix-1, iy, heightdata[iy][ix-1]), (ix+1, iy, heightdata[iy][ix+1]),
(ix, iy-1, heightdata[iy-1][ix]), (ix, iy+1, heightdata[iy+1][ix])]
lowest = min(neighbors, key=lambda n: n[2])
slope = h - lowest[2]
if slope < min_slope:
heightdata[iy][ix] += sediment * deposition_rate
break
capacity = slope * water
if sediment > capacity:
deposit = (sediment - capacity) * deposition_rate
heightdata[iy][ix] += deposit
sediment -= deposit
else:
erode = min((capacity - sediment) * erosion_rate, h * 0.1)
heightdata[iy][ix] -= erode
sediment += erode
x, y = float(lowest[0]), float(lowest[1])
water *= (1 - evaporation)
if water < 0.01:
break
return heightdata
def setup_terrain(self):
"""地形の生成と設定"""
# ハイトマップ生成
heightmap = self.generate_heightmap()
# GeoMipTerrain作成
self.terrain = GeoMipTerrain("terrain")
self.terrain.setHeightfield(heightmap)
# 地形設定
self.terrain.setBlockSize(32)
self.terrain.setNear(40)
self.terrain.setFar(200)
self.terrain.setFocalPoint(self.camera)
# 地形生成
self.terrain.generate()
# シーンに追加
self.terrain_root = self.terrain.getRoot()
self.terrain_root.reparentTo(self.render)
self.terrain_root.setScale(self.terrain_scale, self.terrain_scale, self.height_scale)
# テクスチャ生成(高さに応じた色分け)
self.apply_terrain_texture()
# 定期的にLOD更新
self.taskMgr.add(self.update_terrain, "update_terrain")
def apply_terrain_texture(self):
"""高さに応じた地形テクスチャを生成(self.heightdata を利用)"""
size = self.terrain_size
h = self.heightdata # 0〜1 のNumPy配列
# 高さしきい値で4色に色分け(演習6で変更する)
# RGBそれぞれの配列を作り、しきい値ごとに色を上書きする
r = np.full((size, size), 0.9) # 既定は山頂(白)
g = np.full((size, size), 0.9)
b = np.full((size, size), 0.95)
# 低地: 緑(草原)
m = h < 0.3
r[m], g[m], b[m] = 0.2, 0.6, 0.2
# 中腹: 黄緑
m = (h >= 0.3) & (h < 0.5)
r[m], g[m], b[m] = 0.4, 0.5, 0.2
# 高地: 茶色(岩)
m = (h >= 0.5) & (h < 0.7)
r[m], g[m], b[m] = 0.5, 0.4, 0.3
# 山頂(h >= 0.7)は既定値のまま
tex_img = PNMImage(size, size)
for y in range(size):
for x in range(size):
tex_img.setXel(x, y, float(r[y][x]), float(g[y][x]), float(b[y][x]))
tex = Texture()
tex.load(tex_img) # PNMImage を Texture に読み込む
self.terrain_root.setTexture(tex)
def update_terrain(self, task):
"""地形LODの更新"""
self.terrain.update()
return Task.cont
def setup_player(self):
"""プレイヤー(シンプルな球体)の作成"""
# プレイヤーノード
self.player = self.render.attachNewNode("player")
# 可視化用の球体(Panda3D同梱モデルを使用)
self.player_model = self.loader.loadModel("models/misc/sphere")
self.player_model.setScale(1.0)
self.player_model.setColor(1, 0.5, 0, 1) # オレンジ
self.player_model.reparentTo(self.player)
# 初期位置設定
start_height = self.get_terrain_height(self.player_pos.x, self.player_pos.y)
self.player_pos.z = start_height + 2.0
self.player.setPos(self.player_pos)
def setup_camera(self):
"""三人称追従カメラの設定"""
# デフォルトのマウス操作(Panda3D標準のカメラ操作)を無効化
self.disableMouse()
def setup_lighting(self):
"""ライティング設定(環境光+指向性光源+シャドウ)"""
# 環境光の設定(全方向から均一に照らす光)
ambient = AmbientLight('ambient')
ambient.setColor(LColor(0.3, 0.3, 0.35, 1)) # やや暗めの環境光
ambient_np = self.render.attachNewNode(ambient)
self.render.setLight(ambient_np)
# 指向性光源(太陽光のような平行光)
sun = DirectionalLight('sun')
sun.setColor(LColor(0.9, 0.85, 0.7, 1)) # 暖色系の太陽光
# シャドウマッピングの設定
sun.setShadowCaster(True, 2048, 2048) # 影を有効化(2048x2048解像度)
sun.getLens().setFilmSize(200, 200) # 影の範囲(広いマップ用)
sun.getLens().setNearFar(1, 400) # 影の描画範囲
sun_np = self.render.attachNewNode(sun)
sun_np.setHpr(45, -60, 0) # 光の方向を設定(HPR: Heading, Pitch, Roll)
self.render.setLight(sun_np)
# シェーダ自動生成を有効化(シャドウマッピングの描画に必要)
# 影が出ない・画面が暗い環境では、次の1行をコメントアウトすると影なしで動作する
self.render.setShaderAuto()
self.sun_np = sun_np # 後で参照できるよう保持
def setup_sky(self):
"""空の背景色設定"""
self.setBackgroundColor(0.5, 0.7, 1.0, 1) # 水色
def setup_controls(self):
"""キー・マウス操作の設定"""
# キーバインド
self.accept("w", self.set_key, ["forward", True])
self.accept("w-up", self.set_key, ["forward", False])
self.accept("s", self.set_key, ["backward", True])
self.accept("s-up", self.set_key, ["backward", False])
self.accept("a", self.set_key, ["left", True])
self.accept("a-up", self.set_key, ["left", False])
self.accept("d", self.set_key, ["right", True])
self.accept("d-up", self.set_key, ["right", False])
# マウスドラッグでカメラ回転
self.accept("mouse1", self.start_drag)
self.accept("mouse1-up", self.stop_drag)
# マウスホイールでズーム
self.accept("wheel_up", self.zoom_in)
self.accept("wheel_down", self.zoom_out)
# 終了
self.accept("escape", sys.exit)
def start_drag(self):
"""マウスドラッグ開始"""
self.mouse_dragging = True
if self.mouseWatcherNode.hasMouse():
self.last_mouse_x = self.mouseWatcherNode.getMouseX()
self.last_mouse_y = self.mouseWatcherNode.getMouseY()
def stop_drag(self):
"""マウスドラッグ終了"""
self.mouse_dragging = False
def zoom_in(self):
"""ズームイン"""
self.camera_distance = max(10, self.camera_distance - 3)
def zoom_out(self):
"""ズームアウト"""
self.camera_distance = min(100, self.camera_distance + 3)
def set_key(self, key, value):
"""キー状態の更新"""
self.key_map[key] = value
def get_terrain_height(self, x, y):
"""指定座標の地形高さを取得"""
# ワールド座標を地形(ハイトマップ)座標に変換
tx = x / self.terrain_scale
ty = y / self.terrain_scale
# 範囲チェック
if 0 <= tx < self.terrain_size - 1 and 0 <= ty < self.terrain_size - 1:
elevation = self.terrain.getElevation(tx, ty)
return elevation * self.height_scale
return 0
def update(self, task):
"""メインループ"""
dt = self.clock.getDt() # 前フレームからの経過時間(秒)
# マウスドラッグでカメラ回転
self.handle_mouse_drag()
# キー入力で移動
self.handle_movement(dt)
# カメラをプレイヤーに追従
self.update_camera()
return Task.cont
def handle_mouse_drag(self):
"""マウスドラッグ処理"""
if self.mouse_dragging and self.mouseWatcherNode.hasMouse():
mx = self.mouseWatcherNode.getMouseX()
my = self.mouseWatcherNode.getMouseY()
# マウス移動量を計算
dx = mx - self.last_mouse_x
dy = my - self.last_mouse_y
# カメラ角度を更新
self.camera_heading -= dx * 100
self.camera_pitch += dy * 50
# ピッチ制限(5〜80度)
self.camera_pitch = max(5, min(80, self.camera_pitch))
self.last_mouse_x = mx
self.last_mouse_y = my
def handle_movement(self, dt):
"""移動処理(カメラの向きを基準に移動)"""
# 移動ベクトル計算
move_vec = Vec3(0, 0, 0)
if self.key_map["forward"]:
move_vec.y += 1
if self.key_map["backward"]:
move_vec.y -= 1
if self.key_map["left"]:
move_vec.x -= 1
if self.key_map["right"]:
move_vec.x += 1
if move_vec.length() > 0:
move_vec.normalize()
move_vec *= self.move_speed * dt
# カメラの向きに応じて移動方向を回転
heading_rad = math.radians(self.camera_heading)
cos_h = math.cos(heading_rad)
sin_h = math.sin(heading_rad)
new_x = move_vec.x * cos_h - move_vec.y * sin_h
new_y = move_vec.x * sin_h + move_vec.y * cos_h
self.player_pos.x += new_x
self.player_pos.y += new_y
# プレイヤーの向きをカメラの向きに合わせる
self.player_heading = self.camera_heading
# 地形に沿って高さを調整
terrain_height = self.get_terrain_height(self.player_pos.x, self.player_pos.y)
self.player_pos.z = terrain_height + 2.0
# プレイヤー位置更新
self.player.setPos(self.player_pos)
self.player.setH(self.player_heading)
def update_camera(self):
"""カメラをプレイヤーに追従させる"""
# 球面座標でカメラ位置を計算
heading_rad = math.radians(self.camera_heading)
pitch_rad = math.radians(self.camera_pitch)
# カメラ位置(プレイヤーを中心とした球面上)
cam_x = self.player_pos.x - self.camera_distance * math.sin(heading_rad) * math.cos(pitch_rad)
cam_y = self.player_pos.y - self.camera_distance * math.cos(heading_rad) * math.cos(pitch_rad)
cam_z = self.player_pos.z + self.camera_distance * math.sin(pitch_rad)
# カメラ位置設定
self.camera.setPos(cam_x, cam_y, cam_z)
# プレイヤーを見る
self.camera.lookAt(self.player_pos + Vec3(0, 0, 2))
# メイン実行
if __name__ == "__main__":
print("=== Open World Game ===")
print("操作方法:")
print(" WASD: プレイヤー移動")
print(" 左ドラッグ: カメラ回転")
print(" ホイール: ズームイン/アウト")
print(" ESC: 終了")
print("地形を生成中...")
game = OpenWorldGame()
game.run()
演習:3次元CGをパラメータ調整で体験する
本章では、プログラムのパラメータ(数値設定)を変えながら3次元コンピュータグラフィックスの表現を体験する。各演習は《手順》《ヒント》《考察ポイント》で構成する。コードを変更したら保存し、再実行して画面の変化を観察すること。
演習1:プログラムを動かして3D空間を体感する
《手順》
- プログラムを実行する。
- WASDキーで地形を歩き回る。
- 左マウスボタンを押しながらドラッグし、視点を回転させる。
- マウスホイールでズームイン・ズームアウトを行う。
- 低地(緑)、中腹(黄緑)、高地(茶)、山頂(白)の各エリアまで移動する。
《ヒント》
- 起動直後は地形生成のため数秒待つ。
- カメラの垂直角度は5度〜80度に制限されており、真上や真下は向けられない。
- 移動方向はカメラの向きを基準とする。前進はつねに「画面の奥」となる。
《考察ポイント》
- 2次元の画像と比べ、視点を自由に動かせることでどのような情報が増えるか。
- 同じ地形でも、視点の高さや角度で印象が大きく変わることを確認する。
- 遠景と近景で地形の詳細度に差があることに気づくか(LODの効果)。
演習2:地形の起伏を変える
《手順》
- コード中の
self.height_scale = 80.0(地形の高低差の倍率)を40.0に変更し、実行する。 - 続いて
120.0、160.0に変更し、それぞれ実行する。 - 各設定で同じ場所まで移動し、見た目を比較する。
《ヒント》
- 値を変える前後で、同じ視点・同じ場所で比較する。
- 値が大きすぎると、プレイヤーが急斜面に取り残されることがある。
《考察ポイント》
- 高低差を変えるだけで、地形の印象(平原・丘陵・山岳)がどう変わるか。
- 「自然に見える」高低差はどの程度の値か。
演習3:地形の形を変える(ノイズのシード値)
シード値とは乱数発生の初期値のことであり、同じシード値からは毎回まったく同じノイズが得られる。
《手順》
__init__内のself.seed_base = 42の数値を別の整数(例:7、100、2024)に変更し、実行する。- 同様に
self.seed_ridge、self.seed_detail、self.seed_maskのシード値も変えて試す。 - 気に入った地形が現れたシード値を記録する。
《ヒント》
- シード値が同じであれば、毎回まったく同じ地形が生成される。各ノイズは生成のたびに自分のシードを設定するため、1つのシードだけ変えても他のノイズには影響しない。
- 4つのシード値(base、ridge、detail、mask)の組み合わせで多様な地形が得られる。
《考察ポイント》
- 同じアルゴリズムでも、入力(シード値)が変わると結果がまったく異なる。手続き的生成の特徴を体感する。
- 「気に入った地形」を再現するうえで、シード値はどのような役割を果たすか。
演習4:侵食の効果を観察する
《手順》
__init__内のself.erosion_iterations = 5000(侵食シミュレーションの反復回数)を0に変更し、実行する(侵食なし)。- 続いて
2000、10000、20000に変更し、それぞれ実行する。 - 谷や尾根の見え方を比較する。地形生成にかかる時間も観察する。
《ヒント》
- 反復回数が大きいほど生成に時間がかかる。20000では数十秒待つ場合がある。
- 山岳エリア(白い山頂付近)まで移動すると、侵食の差が見えやすい。
《考察ポイント》
- ノイズだけの地形と、侵食を加えた地形の違いはどこに現れるか。
- 自然な見た目と処理時間のバランスとして、どの値が妥当か。
演習5:LODの効きを確認する
《手順》
self.terrain.setNear(40)とself.terrain.setFar(200)(setNearはこの距離以下で最高詳細度、setFarはこの距離以上で最低詳細度となる、距離のしきい値)を、setNear(10)・setFar(50)に変更し、実行する。- 遠くの地形を観察し、ポリゴン(メッシュを構成する三角形などの面)の粗さを確認する。
- 続いて
setNear(100)・setFar(500)に変更し、実行する。 - マウスホイールでズームアウトし、全景を見比べる。
《ヒント》
- 遠景の山稜を見ると差がわかりやすい。
- setNearの値はsetFarより小さく設定する。
- 見た目の粗さは、LODのしきい値だけでなくズーム距離やカメラの焦点位置の影響も受ける。LODの効果だけを切り分けたいときは、ズーム距離を一定に保ったまま
setNear・setFarだけを変えて比較するとよい。
《考察ポイント》
- 遠くを簡略化することで、画質と処理速度のどちらが、どれだけ変わるか。
- 広いマップを描画するうえで、LODが果たす役割は何か。
演習6:色分けの境界を変える
《手順》
apply_terrain_texture内の高さしきい値(0.3、0.5、0.7)を変更する。- 例:雪のエリアを広げるには山頂判定(
h >= 0.7)のしきい値を0.55に下げる。雪を減らすには0.85に上げる(高地判定の上側のしきい値もあわせて調整する)。 - 各エリアの面積比がどう変わるかを観察する。
- 余裕があれば、色(r, g, b:赤・緑・青の各成分を0.0〜1.0で指定)も変えて独自のカラースキーム(配色)を作る。
《ヒント》
- 値の範囲は0.0〜1.0(正規化済みの高さ。最低点を0、最高点を1とした相対値)。
- しきい値は昇順となるよう保つ(例:0.2 < 0.5 < 0.8)。
《考察ポイント》
- 同じ地形でも、色分けの違いだけで風景の印象がどれほど変わるか。
- テクスチャ(色)と形状(ハイトマップ)が、3DCGにおいてそれぞれ担う役割は何か。
演習7:光と影の演出を変える
《手順》
setup_lighting内のsun_np.setHpr(45, -60, 0)の第2引数(光源のピッチ。値が負で絶対値が大きいほど光が地面に対して上から差す)を-20(夕方寄り)や-80(ほぼ真上)に変更する。- 太陽光の色
LColor(0.9, 0.85, 0.7, 1)(r, g, b, アルファ値)を、夕焼け風(LColor(1.0, 0.5, 0.3, 1))や月光風(LColor(0.5, 0.6, 0.8, 1))に変える。 - 背景色
setBackgroundColor(0.5, 0.7, 1.0, 1)も時間帯に合わせて変える。
《ヒント》
- 光が地面に対して浅い角度で差すほど(−20度など)、影が長く伸びる。
- 太陽光・環境光・背景色の3つを揃えると、雰囲気の一貫性が出る。
《考察ポイント》
- 形状を変えなくても、光の向きと色だけで時間帯や気象を表現できることを確認する。
- 3DCGにおいて、ライティングが画作りに占める比重を体感する。
演習8:自分だけの地形を作る(総合)
《手順》
- 演習2〜7で得た知見をもとに、テーマ(例:「雪山」「砂漠」「夕焼けの草原」)を一つ決める。
- height_scale、ノイズのシード値、侵食回数、色分け、ライティングを組み合わせて調整する。
- 完成したパラメータをスクリーンショットとともに記録する。
- 変更したパラメータの一覧と、その意図を簡潔にまとめる。
《ヒント》
- 一度に多くのパラメータを変えず、一つずつ確認しながら積み上げる。
- 「狙い」と「実際の結果」の差を記録すると、次の調整に活きる。
《考察ポイント》
- 同じプログラムから、まったく異なる風景を作り出せた要因は何か。
- 3DCGにおいて、形状・色・光のうち、表現上もっとも効果が大きかった要素はどれか。