An Embedded Engineer’s Blog

とある組み込みエンジニアの備忘録的なブログです。

【GitHub Copilotと作る Pythonで OpenGL 3Dプログラミング】 - 第10回「バッチレンダリングで描画を高速化」

GitHub Copilotと作る PythonOpenGL 3Dプログラミング

第10回「バッチレンダリングで描画を高速化」

はじめに

前回は、立方体や球体といった複雑な形状をEBO(Element Buffer Object)を使って効率的に描画する方法を学びました。

今回は、バッチレンダリング(Batch Rendering)を実装して、描画パフォーマンスを大幅に向上させる方法を学びます。複数のオブジェクトを1回のドローコールでまとめて描画することで、ドローコールを劇的に削減し、FPSを大幅に向上させることができます。

また、パフォーマンス計測のためのPerformanceManagerも実装し、最適化の効果を定量的に評価できるようにします。

なぜバッチレンダリングが必要なのか?

ドローコールのコスト

現在の実装では、各オブジェクトごとに個別の描画命令(ドローコール)を発行しています:

# 従来の方法:各オブジェクトで個別にdraw()
for obj in objects:
    transform.set_model_identity()
    transform.translate(obj['pos'])
    transform.scale(obj['scale'])
    shader.set_mat4("model", transform.model)
    geometry.draw()  # ← ドローコール発生

問題点

  • オブジェクト数に比例してドローコールが増加
  • CPU-GPU間の通信オーバーヘッドが大きい
  • 現代のGPUは少数の大きな描画を得意とする

パフォーマンス計測基盤の実装

最適化の効果を測定するため、まずパフォーマンス計測の仕組みを実装します。

PerformanceManager クラス

src/utils/performance.py:

from dataclasses import dataclass
from typing import Dict, Optional
import time

@dataclass
class PerformanceStats:
    """パフォーマンス統計情報"""
    fps: float
    frame_time_ms: float
    timing_stats: Dict[str, float]
    hierarchical_stats: Dict[str, any]

class PerformanceManager:
    """パフォーマンス計測マネージャー"""

    def __init__(self):
        self._frame_times: list[float] = []
        self._timing_stats: Dict[str, float] = {}
        self._operation_stack: list[tuple[str, float]] = []
        self._draw_call_count: int = 0
        self._current_fps: float = 0.0

    def begin_frame(self) -> None:
        """フレーム開始"""
        self._frame_start_time = time.time()
        self._timing_stats.clear()
        self._draw_call_count = 0

    def end_frame(self) -> None:
        """フレーム終了"""
        frame_time = time.time() - self._frame_start_time
        self._frame_times.append(frame_time)

        # 10フレームごとにFPS計算
        if len(self._frame_times) >= 10:
            avg_time = sum(self._frame_times[-10:]) / 10
            self._current_fps = 1.0 / avg_time if avg_time > 0 else 0.0

    def time_operation(self, name: str):
        """処理時間計測用コンテキストマネージャー"""
        return OperationTimer(self, name)

    def set_draw_call_count(self, count: int) -> None:
        """ドローコール数を記録"""
        self._draw_call_count = count

OperationTimer コンテキストマネージャー

class OperationTimer:
    """処理時間計測用コンテキストマネージャー"""

    def __init__(self, manager: PerformanceManager, name: str):
        self._manager = manager
        self._name = name
        self._start_time = 0.0

    def __enter__(self):
        self._start_time = time.time()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        elapsed = time.time() - self._start_time
        self._manager._record_operation(self._name, elapsed)
        return False

使用例

# App.pyのメインループ
def run(self):
    while not self._window.should_close():
        performance_manager.begin_frame()

        with performance_manager.time_operation("Render"):
            self._render()

        performance_manager.end_frame()

バッチレンダリングの仕組み

バッチレンダリングの基本的な考え方は、複数のオブジェクトの頂点データを1つのバッファに結合し、1回のドローコールで描画することです。

実装の流れ

1. 各オブジェクトの頂点データを取得
   ↓
2. Transform行列を適用して頂点座標を変換 (CPU側)
   ↓
3. 全オブジェクトの頂点を1つの配列に結合
   ↓
4. 結合したデータでVBO/EBOを作成
   ↓
5. 1回のドローコールで全て描画

重要なポイント

  • Transform行列の適用をCPU側で行う(GPU側では単位行列を使用)
  • インデックスのオフセット調整が必要
  • 同じプリミティブタイプ(TRIANGLES, POINTS, LINES)でグループ化

BatchRenderer クラス

src/graphics/batch_renderer.py:

from dataclasses import dataclass
from typing import List, Optional
import numpy as np
import OpenGL.GL as gl

@dataclass
class RenderBatch:
    """1つのジオメトリの描画情報"""
    vertices: np.ndarray           # 頂点データ(Nx6: x,y,z,r,g,b)
    indices: Optional[np.ndarray]  # インデックスデータ
    transform: np.ndarray          # Model変換行列(4x4)
    vertex_offset: int = 0         # 結合後のオフセット
    vertex_count: int = 0          # 頂点数
    index_offset: int = 0          # インデックスオフセット
    index_count: int = 0           # インデックス数

class BatchRenderer:
    """バッチレンダリングクラス"""

    def __init__(self, primitive_type: PrimitiveType):
        self._primitive_type = primitive_type
        self._batches: List[RenderBatch] = []
        self._vao: int = 0
        self._vbo: int = 0
        self._ebo: int = 0
        self._is_dirty: bool = False
        self._use_indices: bool = False

    def add_geometry(self,
                     vertices: np.ndarray,
                     indices: Optional[np.ndarray],
                     transform: np.ndarray) -> None:
        """ジオメトリをバッチに追加"""
        batch = RenderBatch(
            vertices=vertices.copy(),
            indices=indices.copy() if indices is not None else None,
            transform=transform.copy(),
            vertex_count=len(vertices)
        )
        self._batches.append(batch)
        self._is_dirty = True

    def build(self) -> None:
        """バッチをビルド(頂点結合、バッファ作成)"""
        if not self._batches or not self._is_dirty:
            return

        # 1. Transform適用
        transformed_batches = []
        vertex_offset = 0

        for batch in self._batches:
            # 頂点にTransformを適用
            transformed_verts = self._apply_transform(
                batch.vertices,
                batch.transform
            )
            transformed_batches.append(transformed_verts)

            batch.vertex_offset = vertex_offset
            vertex_offset += batch.vertex_count

        # 2. 頂点データを結合
        combined_vertices = np.concatenate(transformed_batches, axis=0)

        # 3. インデックスデータを結合
        combined_indices = None
        if self._use_indices:
            combined_indices = self._combine_indices()

        # 4. OpenGLバッファを作成
        self._create_buffers(combined_vertices, combined_indices)

        self._is_dirty = False

    def flush(self) -> None:
        """バッチを描画"""
        if self._is_dirty:
            self.build()

        if self._vao == 0:
            return

        gl.glBindVertexArray(self._vao)

        if self._use_indices:
            gl.glDrawElements(
                self._primitive_type.value,
                self._total_indices,
                gl.GL_UNSIGNED_INT,
                None
            )
        else:
            gl.glDrawArrays(
                self._primitive_type.value,
                0,
                self._total_vertices
            )

        gl.glBindVertexArray(0)

Transform行列の適用

バッチレンダリングでは、各オブジェクトのTransform行列をCPU側で頂点に適用します。

_apply_transform メソッド

def _apply_transform(self,
                     vertices: np.ndarray,
                     transform: np.ndarray) -> np.ndarray:
    """頂点データにTransform行列を適用"""
    # 位置(xyz)と色(rgb)を分離
    positions = vertices[:, :3]  # (N, 3)
    colors = vertices[:, 3:6]    # (N, 3)

    # 同次座標に変換(w=1を追加)
    positions_homogeneous = np.hstack([
        positions,
        np.ones((len(positions), 1))
    ])  # (N, 4)

    # Transform行列を適用
    transformed_positions = (transform @ positions_homogeneous.T).T

    # 同次座標から3D座標に戻す(wで除算)
    transformed_positions = (
        transformed_positions[:, :3] /
        transformed_positions[:, 3:4]
    )

    # 位置と色を再結合
    result = np.hstack([transformed_positions, colors])

    return result.astype(np.float32)

ポイント

  • 位置座標のみを変換(色は変換しない)
  • 同次座標(x, y, z, w)で計算
  • 最後にwで除算して3D座標に戻す

インデックスの結合

複数のジオメトリのインデックスを結合する際は、頂点オフセットを考慮する必要があります。

_combine_indices メソッド

def _combine_indices(self) -> Optional[np.ndarray]:
    """インデックスを結合(オフセット調整付き)"""
    combined = []

    for batch in self._batches:
        if batch.indices is not None:
            # 頂点オフセットを加算
            adjusted_indices = batch.indices + batch.vertex_offset
            combined.append(adjusted_indices)

    if combined:
        return np.concatenate(combined, axis=0).astype(np.uint32)

    return None

  • オブジェクト1: 頂点0-3、インデックス[0,1,2, 2,3,0]
  • オブジェクト2: 頂点4-7、インデックス[0,1,2, 2,3,0] → [4,5,6, 6,7,4]に調整

GeometryBase拡張: get_vertex_data()

各ジオメトリクラスに、バッチレンダリング用の頂点データ取得メソッドを追加します。

GeometryBaseの抽象メソッド

src/graphics/geometry.py:

from abc import ABC, abstractmethod

class GeometryBase(ABC):
    """ジオメトリの基底クラス"""

    @abstractmethod
    def get_vertex_data(self) -> Tuple[np.ndarray, Optional[np.ndarray]]:
        """
        バッチレンダリング用の頂点データを取得

        Returns:
            (vertices, indices): 頂点データとインデックスデータ
            vertices: Nx6 array (x,y,z,r,g,b)
            indices: インデックス配列(Optional)
        """
        pass

実装例: RectangleGeometry

class RectangleGeometry(GeometryBase):
    """矩形ジオメトリクラス"""

    def get_vertex_data(self) -> Tuple[np.ndarray, Optional[np.ndarray]]:
        """バッチレンダリング用の頂点データを取得"""
        w = self._width / 2.0
        h = self._height / 2.0
        r, g, b = self._color

        # 4頂点
        vertices = np.array([
            -w, -h, 0.0,  r, g, b,  # 左下
             w, -h, 0.0,  r, g, b,  # 右下
             w,  h, 0.0,  r, g, b,  # 右上
            -w,  h, 0.0,  r, g, b,  # 左上
        ], dtype=np.float32).reshape(4, 6)

        # 6インデックス(2三角形)
        indices = np.array([
            0, 1, 2,
            2, 3, 0,
        ], dtype=np.uint32)

        return vertices, indices

App統合: バッチレンダリングの切り替え

Appクラスで、従来の描画方式とバッチレンダリングを切り替えられるようにします。

バッチレンダリング用の描画メソッド

src/core/app.py:

def _draw_geometries(self) -> None:
    """ジオメトリを描画"""
    if self._use_batch_rendering and self._geometry_mode == 3:
        self._draw_with_batching()  # バッチレンダリング
    else:
        self._draw_without_batching()  # 従来の方法

def _draw_with_batching(self) -> None:
    """バッチレンダリングで描画"""
    # バッチレンダラーを初期化
    if self._batch_renderer_triangles is None:
        self._batch_renderer_triangles = BatchRenderer(
            PrimitiveType.TRIANGLES
        )

    self._batch_renderer_triangles.clear()

    # シェーダー設定
    self._shader.use()
    self._shader.set_mat4("view", camera.view_matrix)
    self._shader.set_mat4("projection", camera.projection_matrix)

    # 全オブジェクトをバッチに追加
    with performance_manager.time_operation("Build Batch"):
        for obj in self._all_mode_objects:
            # Model行列を計算
            self._transform.set_model_identity()
            self._transform.translate_model(*obj['pos'])
            self._transform.scale_model(obj['scale'], obj['scale'], obj['scale'])
            self._transform.rotate_model_x(self._rotation_x)
            self._transform.rotate_model_y(self._rotation_y)
            self._transform.rotate_model_z(self._rotation_z)
            model_matrix = self._transform.model.copy()

            # ジオメトリを取得
            geometry = self._get_geometry_for_type(obj['type'])

            if geometry:
                # 一時的に色を設定
                original_color = geometry._color
                geometry.set_color(*obj['color'])

                # 頂点データを取得
                vertices, indices = geometry.get_vertex_data()

                # 色を戻す
                geometry.set_color(*original_color)

                # バッチに追加
                self._batch_renderer_triangles.add_geometry(
                    vertices, indices, model_matrix
                )

    # バッチを描画(1回のドローコール)
    with performance_manager.time_operation("Draw Batch"):
        # Model行列は単位行列(頂点は既に変換済み)
        self._shader.set_mat4("model", np.eye(4, dtype=np.float32))
        self._batch_renderer_triangles.flush()

    # ドローコール数を記録
    performance_manager.set_draw_call_count(1)

従来の描画方法(比較用)

def _draw_without_batching(self) -> None:
    """従来の方法で描画"""
    draw_call_count = 0

    self._shader.use()
    self._shader.set_mat4("view", camera.view_matrix)
    self._shader.set_mat4("projection", camera.projection_matrix)

    # 各オブジェクトを個別に描画
    for obj in self._all_mode_objects:
        # Model行列を設定
        self._transform.set_model_identity()
        self._transform.translate_model(*obj['pos'])
        self._transform.scale_model(obj['scale'], obj['scale'], obj['scale'])
        self._transform.rotate_model_x(self._rotation_x)
        self._transform.rotate_model_y(self._rotation_y)
        self._transform.rotate_model_z(self._rotation_z)
        self._shader.set_mat4("model", self._transform.model)

        # 描画(各オブジェクトで1ドローコール)
        geometry = self._get_geometry_for_type(obj['type'])
        if geometry:
            original_color = geometry._color
            geometry.set_color(*obj['color'])
            geometry.draw()  # ← ドローコール発生
            geometry.set_color(*original_color)
            draw_call_count += 1

    performance_manager.set_draw_call_count(draw_call_count)

ImGui UI: バッチレンダリング切り替え

Settings ウィンドウにチェックボックスを追加して、バッチレンダリングのON/OFFを切り替えられるようにします。

def _draw_settings_window(self) -> None:
    """設定ウィンドウを描画"""
    imgui.begin("Settings")

    # バッチレンダリング設定
    changed, self._use_batch_rendering = imgui.checkbox(
        "Use Batch Rendering (All Mode)",
        self._use_batch_rendering
    )
    if imgui.is_item_hovered():
        imgui.set_tooltip(
            "Enable batch rendering to reduce draw calls\n"
            "(Only works in All mode)"
        )

    imgui.end()

Performance ウィンドウ: パフォーマンス表示

パフォーマンス計測結果を表示するウィンドウも追加します。

def _draw_performance_window(self) -> None:
    """パフォーマンスウィンドウを描画"""
    imgui.begin("Performance")

    stats = performance_manager.get_previous_frame_info()

    # FPS表示
    imgui.text(f"FPS: {stats.fps:.1f}")
    imgui.text(f"Frame Time: {stats.frame_time_ms:.2f}ms")
    imgui.text(f"Draw Calls: {performance_manager.get_draw_call_count()}")

    imgui.separator()

    # FPS統計
    if imgui.tree_node_ex("FPS Stats", imgui.TreeNodeFlags_.default_open):
        fps_stats = performance_manager.get_fps_stats()
        imgui.text(f"Average: {fps_stats['average']:.1f}")
        imgui.text(f"Max: {fps_stats['max']:.1f}")
        imgui.text(f"Min: {fps_stats['min']:.1f}")
        imgui.tree_pop()

    imgui.separator()

    # 処理時間の階層表示
    if imgui.tree_node_ex("Timing (Hierarchical)",
                          imgui.TreeNodeFlags_.default_open):
        self._draw_hierarchical_stats(stats.hierarchical_stats, 0)
        imgui.tree_pop()

    imgui.separator()

    # ログ出力ボタン
    if imgui.button("Print Stats to Log (Hierarchical)"):
        performance_manager.print_stats(hierarchical=True)

    if imgui.button("Print Stats to Log (Flat, Sorted)"):
        performance_manager.print_stats(hierarchical=False, sort_by_time=True)

    imgui.end()

パフォーマンス比較

実際の計測結果を詳しく見てみましょう。

測定環境と結果サマリー

9個のオブジェクト(矩形3個 + 立方体3個 + 球体3個)+ 点・線・三角形を描画した場合の測定結果:

項目 従来の方法 バッチレンダリング 改善率
FPS 50.7 75.8 +50%
Frame Time 19.73ms 13.20ms -33%
Draw Calls 12回 4回 -67%
描画処理時間 15.24ms 7.71ms -49%

バッチレンダリングにより、ドローコールで12回→4回に削減しました。その結果、FPSが50%向上し、フレーム時間が33%短縮し、描画処理時間が49%改善されました。

注意: これらの数値は特定の環境とオブジェクト数での測定結果です。バッチレンダリングの効果は、オブジェクト数が多いほど顕著になります。数十~数百個のオブジェクトを描画する場合、ドローコール削減による性能向上が明確に現れます。

バッチレンダリングOFF(従来の方法)

バッチレンダリングOFF
バッチレンダリングOFF

=== Performance Stats ===
FPS: 50.7
Frame Time: 19.73ms
Draw Calls: 12

Hierarchical Operation Timings:
  Render: 16.47ms (children: 15.58ms)
    Draw Geometries: 15.24ms (children: 15.14ms)
      Draw Points: 0.05ms
      Draw Lines: 0.01ms
      Draw Triangles: 0.01ms
      Draw All Objects: 15.07ms  ← オブジェクト描画
    Render GUI: 0.45ms

バッチレンダリングON

バッチレンダリングON
バッチレンダリングON

=== Performance Stats ===
FPS: 75.8
Frame Time: 13.20ms
Draw Calls: 4  ← 12回 → 4回に削減!

Hierarchical Operation Timings:
  Render: 8.50ms (children: 8.00ms)
    Draw Geometries: 7.71ms (children: 7.61ms)
      Build Batch: 7.01ms  ← CPU側での頂点変換とバッチ構築
      Draw Batch: 0.59ms   ← GPU描画(4回のドローコール)
    Render GUI: 0.40ms

詳細な性能比較

処理 従来の方法 バッチレンダリング 差異
Draw Geometries全体 15.24ms 7.71ms -49%
Draw All Objects 15.07ms - 個別描画
Build Batch - 7.01ms CPU処理
Draw Batch - 0.59ms GPU描画
ドローコール数 12回 4回 -67%

考察

  • ドローコール数は12回→4回に削減(点1+線1+三角形1+矩形等1)
  • CPU側のバッチ構築コスト(7.01ms)は存在するが、従来の個別描画(15.07ms)と比較して約半分に削減
  • GPU描画時間も非常に短く(0.59ms)、ドライバオーバーヘッド削減の効果が明確
  • 結果として、FPSが50%向上し、フレーム時間が33%短縮
  • バッチレンダリングの効果が明確に確認できる結果
  • より大規模なシーン(数十~数百オブジェクト)では、さらに顕著な効果が期待できる

ユニットテスト

バッチレンダリング機能の品質を保証するため、包括的なユニットテストを実装しました。

test_batch_renderer.py (20テスト)

class TestBatchRenderer:
    """BatchRendererクラスのテスト"""

    def test_add_geometry_basic(self):
        """ジオメトリの追加(基本)"""
        renderer = BatchRenderer(PrimitiveType.TRIANGLES)

        vertices = np.array([
            [0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
            [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
            [0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
        ], dtype=np.float32)

        indices = np.array([0, 1, 2], dtype=np.uint32)
        transform = np.eye(4, dtype=np.float32)

        renderer.add_geometry(vertices, indices, transform)

        assert len(renderer._batches) == 1
        assert renderer._is_dirty is True
        assert renderer.batch_count == 1

    def test_apply_transform_translation(self):
        """Transform適用(平行移動)"""
        renderer = BatchRenderer(PrimitiveType.TRIANGLES)

        vertices = np.array([
            [0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
        ], dtype=np.float32)

        # 平行移動行列(x+1, y+2, z+3)
        transform = np.array([
            [1, 0, 0, 1],
            [0, 1, 0, 2],
            [0, 0, 1, 3],
            [0, 0, 0, 1],
        ], dtype=np.float32)

        result = renderer._apply_transform(vertices, transform)

        # 位置が変換される
        expected_pos = np.array([[1.0, 2.0, 3.0]])
        np.testing.assert_array_almost_equal(result[:, :3], expected_pos)

        # 色は変わらない
        np.testing.assert_array_almost_equal(result[:, 3:], vertices[:, 3:])

    def test_combine_indices_multiple_batches(self):
        """インデックス結合(複数バッチ)"""
        renderer = BatchRenderer(PrimitiveType.TRIANGLES)

        # バッチ1: 頂点0-2
        vertices1 = np.array([
            [0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
            [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
            [0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
        ], dtype=np.float32)
        indices1 = np.array([0, 1, 2], dtype=np.uint32)
        transform = np.eye(4, dtype=np.float32)

        renderer.add_geometry(vertices1, indices1, transform)
        renderer._batches[0].vertex_offset = 0

        # バッチ2: 頂点3-5(オフセット3)
        vertices2 = np.array([
            [2.0, 0.0, 0.0, 1.0, 0.0, 0.0],
            [3.0, 0.0, 0.0, 0.0, 1.0, 0.0],
            [2.0, 1.0, 0.0, 0.0, 0.0, 1.0],
        ], dtype=np.float32)
        indices2 = np.array([0, 1, 2], dtype=np.uint32)

        renderer.add_geometry(vertices2, indices2, transform)
        renderer._batches[1].vertex_offset = 3

        combined = renderer._combine_indices()

        # バッチ2のインデックスはオフセット3が加算される
        expected = np.array([0, 1, 2, 3, 4, 5], dtype=np.uint32)
        np.testing.assert_array_equal(combined, expected)

test_geometry.py拡張 (35テスト追加)

全てのジオメトリクラスのget_vertex_data()メソッドをテストしました:

class TestRectangleGeometryVertexData:
    """RectangleGeometryのget_vertex_data()テスト"""

    def test_get_vertex_data(self) -> None:
        """矩形の頂点データ取得"""
        mock_manager = MockBufferManager()
        geom = RectangleGeometry(width=2.0, height=1.0,
                                 buffer_manager=mock_manager)

        vertices, indices = geom.get_vertex_data()

        assert vertices.shape == (4, 6)  # 4頂点
        assert indices is not None
        assert indices.shape == (6,)  # 2三角形 = 6インデックス
        np.testing.assert_array_equal(indices, [0, 1, 2, 2, 3, 0])

テスト結果

  • ✅ test_batch_renderer.py: 20テスト全て成功
  • ✅ test_geometry.py: 35テスト追加(全て成功)
  • ✅ 合計55テスト

バッチレンダリングの制約と最適化のポイント

制約事項

  1. プリミティブタイプの統一

    • 同じプリミティブ(TRIANGLES, POINTS, LINES)のみバッチ化可能
    • 異なるプリミティブは別々のBatchRendererが必要
  2. 動的オブジェクトのコスト

    • 毎フレーム位置が変わるオブジェクトは、毎回build()が必要
    • 静的オブジェクトの方がバッチレンダリングに適している
  3. CPU処理の増加

    • Transform適用がCPU側で行われる
    • オブジェクト数が非常に多い場合はCPUボトルネックになる可能性

最適化のポイント

  1. 静的バッチング vs 動的バッチング

    • 静的: 位置が固定のオブジェクト → 1回だけbuild()
    • 動的: 毎フレーム動くオブジェクト → 毎フレームbuild()
    • 今回は動的バッチングを実装
  2. インスタンシング(次のステップ)

    • 同じジオメトリを複数描画する場合はインスタンシングが有効
    • GPUで各インスタンスのTransformを処理
    • より多くのオブジェクトを高速に描画可能
  3. カリング(描画範囲外の除外)

    • カメラに映らないオブジェクトは描画しない
    • フラスタムカリングで更に高速化

まとめ

今回は、バッチレンダリングを実装して描画パフォーマンスを大幅に向上させました。

実装した内容

  1. パフォーマンス計測基盤

    • PerformanceManagerクラス
    • 階層的な処理時間計測
    • FPS統計、ドローコール数の記録
  2. BatchRendererクラス

    • 複数ジオメトリの頂点結合
    • CPU側でのTransform適用
    • インデックスのオフセット調整
    • 1回のドローコールで描画
  3. GeometryBase拡張

    • get_vertex_data()メソッド追加
    • 全ジオメトリクラスで実装
  4. App統合

    • バッチレンダリングの切り替え機能
    • Performance ウィンドウでの可視化

達成した結果

  • FPS: 50.7 → 75.8 (+50%)
  • Frame Time: 19.73ms → 13.20ms (-33%)
  • Draw Calls: 12回 → 4回 (-67%)
  • Draw Geometries: 15.24ms → 7.71ms (-49%)
  • Build Batch (CPU): 7.01ms
  • Draw Batch (GPU): 0.59ms

重要な知見

  • ドローコール削減によるパフォーマンスの大幅向上を達成
  • 個別描画時のドライバオーバーヘッド(15.24ms)と比較して、バッチ構築コスト(7.01ms)+ 描画(0.59ms)= 7.60msと約半分に高速化
  • FPSが50%向上し、フレーム時間が33%短縮
  • より大規模なシーン(数十~数百オブジェクト)では、さらに顕著な効果が期待できる

学んだこと

  1. ドローコールの削減が効果的

    • CPU-GPU通信のオーバーヘッドを削減することで描画時間が半減
    • 今回の計測では15.24ms → 7.71ms(-49%)の改善を達成
    • 少数の大きな描画命令が効率的
  2. CPU vs GPU のバランス

    • Transform適用をCPU側に移すことで、GPU側の負荷を大幅削減
    • バッチ構築コスト(7.01ms)よりもドライバオーバーヘッド削減効果(15.24ms → 0.59ms)が大きい
    • トータルで大幅な高速化を実現
  3. 計測の重要性

    • PerformanceManagerで定量的に効果を確認
    • 最適化の指針を得られる

次回は、インスタンシング(Instancing)を実装して、同じジオメトリの大量描画を更に高速化します。お楽しみに!


前回: 第9回「立方体と球体を描く - EBOで頂点を再利用」

次回: 第11回「インスタンシングで同一形状を大量描画」(準備中)


この記事のコード: GitHub - PythonOpenGL Phase 7a

【GitHub Copilotと作る Pythonで OpenGL 3Dプログラミング】 - 第9回「立方体と球体を描く - EBOで頂点を再利用」

GitHub Copilotと作る PythonOpenGL 3Dプログラミング

第9回「立方体と球体を描く - EBOで頂点を再利用」

はじめに

前回は、点・線・三角形という基本形状(プリミティブ)の描画を学び、VBO/VAOの概念を理解しました。

今回は、EBO(Element Buffer Object / Index Buffer)を使って、より複雑な形状を効率的に描画する方法を学びます。具体的には、矩形(Rectangle)立方体(Cube)球体(Sphereの描画を実装します。

また、OpenGL操作を抽象化するBufferManagerパターンを導入し、テスト容易性を向上させます。

BufferManagerパターンの実装

複雑な形状を実装する前に、まずOpenGL操作を抽象化する設計パターンを導入します。これにより、以下のメリットが得られます:

  1. テスト容易性: OpenGLコンテキスト不要でユニットテストが可能
  2. 依存性注入: 実装とテストを切り離せる
  3. 保守性: OpenGL呼び出しが一箇所に集約される

BufferManager Protocol

src/graphics/geometry.py:

class BufferManager(Protocol):
    """OpenGL操作の抽象化インターフェース"""

    def create_buffers(
        self,
        vertices: np.ndarray,
        primitive_type: PrimitiveType
    ) -> tuple[int, int]:
        """VBO/VAOを作成"""
        ...

    def create_indexed_buffers(
        self,
        vertices: np.ndarray,
        indices: np.ndarray,
        primitive_type: PrimitiveType
    ) -> tuple[int, int, int]:
        """VBO/VAO/EBOを作成"""
        ...

    def draw_arrays(self, vao: int, vertex_count: int, primitive_type: PrimitiveType) -> None:
        """通常描画(VBOのみ)"""
        ...

    def draw_elements(self, vao: int, index_count: int, primitive_type: PrimitiveType) -> None:
        """インデックス描画(VBO + EBO)"""
        ...

実装クラスOpenGLBufferManagerが実際のOpenGL関数を呼び出します。

BufferManagerのテスト容易性

このパターンにより、MockBufferManagerを使ってOpenGLコンテキスト不要なユニットテストを実現できます:

# tests/test_geometry.py
class MockBufferManager:
    """テスト用のモックBufferManager"""

    def create_indexed_buffers(self, vertices, indices, primitive_type):
        # 実際のOpenGL呼び出しなし、ダミーID返却
        return (1, 2, 3)  # VAO, VBO, EBO

    def draw_elements(self, vao, index_count, primitive_type):
        # 描画記録のみ
        self.draw_calls.append(("elements", vao, index_count))

# テスト例
def test_rectangle_init():
    mock = MockBufferManager()
    rect = RectangleGeometry(width=2.0, height=1.5, buffer_manager=mock)

    assert rect.vertex_count == 4
    assert rect.index_count == 6
    assert mock.created_buffers_count == 1

全22テストがOpenGL初期化なしで実行可能です:

pytest tests/test_geometry.py -v
# ====================== 22 passed in 0.64s ======================

EBOとは何か?

頂点の重複問題

前回の三角形描画では、頂点データを直接VBOに格納していました。しかし、複雑な形状を描く場合、同じ頂点を複数回使い回す必要があります。

例えば、矩形(四角形)を三角形で描く場合:

三角形2つで矩形を構成
┌───────┐
│ \    │
│   \  │  三角形1: (0, 1, 2)
│     \│  三角形2: (0, 2, 3)
└───────┘

頂点データ(重複なし):
0: (-0.5, -0.5)  左下
1: ( 0.5, -0.5)  右下
2: ( 0.5,  0.5)  右上
3: (-0.5,  0.5)  左上

VBOだけで描画する場合、6頂点(3頂点×2三角形)を送る必要があり、頂点0と頂点2が重複します。

EBOによる解決

EBO(Element Buffer Object)は、頂点のインデックス(番号)を格納するバッファです。頂点データは一度だけVBOに格納し、EBOで「どの順番で頂点を使うか」を指定します。

VBO: 頂点データ4つ
┌────┬────┬────┬────┐
│ 0  │ 1  │ 2  │ 3  │
└────┴────┴────┴────┘

EBO: インデックスデータ6つ
┌────┬────┬────┬────┬────┬────┐
│ 0  │ 1  │ 2  │ 0  │ 2  │ 3  │
└────┴────┴────┴────┴────┴────┘
 └─────────┘  └─────────┘
  三角形1       三角形2

これにより、頂点データの重複を削減し、メモリ効率転送効率が向上します。

矩形(Rectangle)の実装

頂点データとインデックス

4頂点で矩形を構成し、2つの三角形で描画します:

class RectangleGeometry(GeometryBase):
    def _update_buffers(self) -> None:
        w = self._width / 2.0
        h = self._height / 2.0
        r, g, b = self._color

        # 頂点データ(4頂点)
        vertices = np.array([
            # 位置           色
            -w, -h, 0.0,  r, g, b,  # 左下
             w, -h, 0.0,  r, g, b,  # 右下
             w,  h, 0.0,  r, g, b,  # 右上
            -w,  h, 0.0,  r, g, b,  # 左上
        ], dtype=np.float32)

        # インデックスデータ(2三角形 = 6インデックス)
        indices = np.array([
            0, 1, 2,  # 三角形1(左下・右下・右上)
            0, 2, 3,  # 三角形2(左下・右上・左上)
        ], dtype=np.uint32)

        # EBOを使用してバッファ作成
        self._vao, self._vbo, self._ebo = self._buffer_manager.create_indexed_buffers(
            vertices, indices, PrimitiveType.TRIANGLES
        )

サイズ・色の変更

def set_size(self, width: float, height: float) -> None:
    """サイズを変更"""
    self._width = width
    self._height = height
    self._update_buffers()

def set_color(self, r: float, g: float, b: float) -> None:
    """色を変更"""
    self._color = (r, g, b)
    self._update_buffers()

立方体(Cube)の実装

立方体の構造

立方体は8頂点6面(各面は2三角形)で構成されます:

立方体の頂点番号
      7-------6
     /|      /|
    4-------5 |
    | |     | |
    | 3-----|-2
    |/      |/
    0-------1

各面のインデックス(反時計回り):
前面: 0, 1, 5, 0, 5, 4
右面: 1, 2, 6, 1, 6, 5
背面: 2, 3, 7, 2, 7, 6
左面: 3, 0, 4, 3, 4, 7
上面: 4, 5, 6, 4, 6, 7
底面: 3, 2, 1, 3, 1, 0

合計:8頂点、36インデックス(12三角形)

頂点データとインデックス

class CubeGeometry(GeometryBase):
    def _update_buffers(self) -> None:
        s = self._size / 2.0
        r, g, b = self._color

        # 8頂点(各頂点に色を付与)
        vertices = np.array([
            # 位置              色
            -s, -s, -s,  r, g, b,  # 0: 左下前
             s, -s, -s,  r, g, b,  # 1: 右下前
             s,  s, -s,  r, g, b,  # 2: 右上前
            -s,  s, -s,  r, g, b,  # 3: 左上前
            -s, -s,  s,  r, g, b,  # 4: 左下奥
             s, -s,  s,  r, g, b,  # 5: 右下奥
             s,  s,  s,  r, g, b,  # 6: 右上奥
            -s,  s,  s,  r, g, b,  # 7: 左上奥
        ], dtype=np.float32)

        # 6面×2三角形 = 36インデックス
        indices = np.array([
            0, 1, 5, 0, 5, 4,  # 前面
            1, 2, 6, 1, 6, 5,  # 右面
            2, 3, 7, 2, 7, 6,  # 背面
            3, 0, 4, 3, 4, 7,  # 左面
            4, 5, 6, 4, 6, 7,  # 上面
            3, 2, 1, 3, 1, 0,  # 底面
        ], dtype=np.uint32)

        self._vao, self._vbo, self._ebo = self._buffer_manager.create_indexed_buffers(
            vertices, indices, PrimitiveType.TRIANGLES
        )

球体(Sphere)の実装

球面の数式

球体は経度(longitude)緯度(latitude)で分割して生成します。

球面上の点 (x, y, z) は、半径 r、経度 θ(シータ)、緯度 φ(ファイ)から計算します:

x = r × cos(φ) × cos(θ)
y = r × sin(φ)
z = r × cos(φ) × sin(θ)
  • θ(経度): 0〜2π、水平方向の角度
  • φ(緯度): -π/2〜π/2、垂直方向の角度

頂点生成アルゴリズム

def _generate_vertices(self) -> tuple[np.ndarray, np.ndarray]:
    vertices = []
    indices = []

    # 各リング(緯度方向)
    for i in range(self._rings + 1):
        phi = np.pi / 2 - i * np.pi / self._rings  # π/2 → -π/2

        # 各セグメント(経度方向)
        for j in range(self._segments + 1):
            theta = j * 2 * np.pi / self._segments  # 0 → 2π

            x = self._radius * np.cos(phi) * np.cos(theta)
            y = self._radius * np.sin(phi)
            z = self._radius * np.cos(phi) * np.sin(theta)

            vertices.extend([x, y, z, *self._color])

    # インデックス生成(各四角形を2三角形に分割)
    for i in range(self._rings):
        for j in range(self._segments):
            first = i * (self._segments + 1) + j
            second = first + self._segments + 1

            # 三角形1
            indices.extend([first, second, first + 1])
            # 三角形2
            indices.extend([second, second + 1, first + 1])

    return np.array(vertices, dtype=np.float32), np.array(indices, dtype=np.uint32)

頂点数・インデックス数

  • 頂点数: (rings + 1) × (segments + 1)
  • インデックス数: rings × segments × 6

例:segments=16, rings=16の場合: - 頂点数: 17 × 17 = 289 - インデックス数: 16 × 16 × 6 = 1536

アプリケーションへの統合

形状の初期化

src/core/app.py:

def _setup_geometries(self) -> None:
    """ジオメトリをセットアップする"""
    # === 矩形ジオメトリ ===
    self._rectangle_geometry = RectangleGeometry(
        width=1.0,
        height=1.0,
        r=0.5, g=0.5, b=1.0,
    )

    # === 立方体ジオメトリ ===
    self._cube_geometry = CubeGeometry(
        size=1.0,
        r=0.5, g=0.5, b=1.0,
    )

    # === 球体ジオメトリ ===
    self._sphere_geometry = SphereGeometry(
        radius=1.0,
        segments=16,
        rings=16,
        r=0.5, g=0.5, b=1.0,
    )

imgui UI

形状選択、サイズ調整、色変更を可能にします:

def _draw_geometry_window(self) -> None:
    imgui.begin("Geometry")

    # 表示モードの選択
    mode_names = ["Points", "Lines", "Triangles", "All", "Rectangle", "Cube", "Sphere"]
    changed, self._geometry_mode = imgui.combo("Display Mode", self._geometry_mode, mode_names)

    # === 矩形の設定 ===
    if imgui.collapsing_header("Rectangle", imgui.TreeNodeFlags_.default_open.value):
        changed_w, self._rectangle_width = imgui.slider_float("Width", self._rectangle_width, 0.1, 3.0)
        changed_h, self._rectangle_height = imgui.slider_float("Height", self._rectangle_height, 0.1, 3.0)
        if changed_w or changed_h:
            self._rectangle_geometry.set_size(self._rectangle_width, self._rectangle_height)

    # === 立方体の設定 ===
    if imgui.collapsing_header("Cube"):
        changed_s, self._cube_size = imgui.slider_float("Size", self._cube_size, 0.1, 3.0)
        if changed_s:
            self._cube_geometry.set_size(self._cube_size)

    # === 球体の設定 ===
    if imgui.collapsing_header("Sphere"):
        changed_r, self._sphere_radius = imgui.slider_float("Radius", self._sphere_radius, 0.1, 3.0)
        changed_seg, self._sphere_segments = imgui.slider_int("Segments", self._sphere_segments, 4, 64)
        changed_ring, self._sphere_rings = imgui.slider_int("Rings", self._sphere_rings, 2, 64)

        if changed_r or changed_seg or changed_ring:
            # 球体を再生成
            self._sphere_geometry.cleanup()
            self._sphere_geometry = SphereGeometry(
                radius=self._sphere_radius,
                segments=self._sphere_segments,
                rings=self._sphere_rings,
                r=self._shape_color[0],
                g=self._shape_color[1],
                b=self._shape_color[2],
            )

    imgui.end()

描画処理

def _draw_geometries(self) -> None:
    # ... Model/View/Projection行列設定 ...

    if self._geometry_mode == 4:  # Rectangle
        if self._rectangle_geometry:
            self._rectangle_geometry.draw()

    if self._geometry_mode == 5:  # Cube
        if self._cube_geometry:
            self._cube_geometry.draw()

    if self._geometry_mode == 6:  # Sphere
        if self._sphere_geometry:
            self._sphere_geometry.draw()

実行結果

アプリケーションを実行すると:

source .venv/bin/activate && python -m src.main
  1. GeometryウィンドウでDisplay Modeを選択
  2. RectangleCubeSphereを選択すると各形状が表示される
  3. パラメータを調整して、サイズ・色・分割数を変更できる
  4. Transformウィンドウで回転させて立体感を確認
  5. Wireframe Modeで形状の構造を確認
  6. Random Colorボタンで各頂点にランダムな色を設定し、グラデーション効果を楽しめる

Allモード - 複数オブジェクトの描画

Allモード表示
Allモード表示

Point/Line/Triangle + Rectangle/Cube/Sphere(各3個)を同時に表示。ランダムな位置・スケール・色で配置される。

Wireframeモード - 構造の可視化

Wireframeモード表示
Wireframeモード表示

立方体や球体の三角形分割構造が明確に見える。

Rectangle - デフォルト色表示

矩形の表示
矩形の表示

4頂点、6インデックス(2三角形)で構成される矩形。

Cube - ランダム色グラデーション

立方体の表示
立方体の表示

8頂点それぞれに異なる色を設定。各面がカラフルなグラデーションになる。

Sphere - ランダム色グラデーション

球体の表示
球体の表示

全頂点に異なる色を設定。経度・緯度分割による美しいグラデーション球体。

まとめ

今回は、EBO(インデックスバッファ)を使って複雑な形状を効率的に描画する方法を学びました。

学んだこと

  1. BufferManagerパターンによるテスト容易性
  2. EBOによる頂点の再利用(メモリ効率向上)
  3. 矩形の実装(4頂点、6インデックス)
  4. 立方体の実装(8頂点、36インデックス)
  5. 球体の生成アルゴリズム(経度・緯度分割)

次回予告: 次回は、「バッチ描画による高速化」を実装します。複数オブジェクトの描画を効率化し、ドローコールのオーバーヘッドを削減します。


前回: 第8回「点・線・三角形を描く」

次回: 第10回「バッチレンダリングで描画を高速化」

【GitHub Copilotと作る Pythonで OpenGL 3Dプログラミング】 - 第8回「点・線・三角形を描く - VBO/VAOの基礎」

GitHub Copilotと作る PythonOpenGL 3Dプログラミング

第8回「点・線・三角形を描く - VBO/VAOの基礎」

はじめに

前回までで、座標変換とカメラ操作を実装し、3D空間を自由に見回せるようになりました。

今回は、基本形状(プリミティブ)の描画を学びます。OpenGLでは、点・線・三角形が最も基本的な描画単位です。これらを効率的に管理するため、VBO(Vertex Buffer Object)VAO(Vertex Array Object)の概念を理解し、再利用可能なジオメトリクラスを実装します。

VBO/VAOとは

これまで三角形を描画するとき、頂点データをシェーダーに渡していました。実は、その裏側ではVBOとVAOが働いています。

VBO(Vertex Buffer Object)

VBOは、頂点データをGPUメモリに格納するバッファです。CPUからGPUへのデータ転送は遅いため、頂点データを一度VBOに格納しておくことで、描画のたびにデータを転送する必要がなくなります。

CPU側(遅い)          GPU側(速い)
┌────────────┐       ┌────────────┐
│ 頂点データ   │ ──→   │    VBO     │ ──→ シェーダー
└────────────┘ 転送  └────────────┘

VAO(Vertex Array Object)

VAOは、頂点属性の設定を記録するオブジェクトです。「このVBOのこの部分が位置データで、あの部分が色データ」という設定を保存しておくことで、描画時に毎回設定し直す必要がなくなります。

VAOが記憶する情報:
┌─────────────────────────────────────────────┐
│ ・どのVBOを使うか                             │
│ ・属性0(位置): offset=0, 3要素, float型      │
│ ・属性1(色): offset=12, 3要素, float型       │
│ ・stride(1頂点のバイト数): 24バイト           │
└─────────────────────────────────────────────┘

OpenGLのプリミティブタイプ

OpenGLがサポートする基本的な描画モードです:

モード 説明 用途
GL_POINTS 各頂点を点として描画 パーティクル、デバッグ表示
GL_LINES 2頂点ごとに独立した線分 座標軸、ワイヤーフレーム
GL_LINE_STRIP 連続した折れ線 パス、軌跡
GL_LINE_LOOP 折れ線の始点と終点を結ぶ 閉じた輪郭線
GL_TRIANGLES 3頂点ごとに独立した三角形 塗りつぶし面
GL_TRIANGLE_STRIP 連続した三角形(頂点を共有) メッシュの効率的描画
GL_TRIANGLE_FAN 中心点から扇形に広がる三角形 円、扇形

今回はGL_POINTSGL_LINESGL_TRIANGLESを使います。

ジオメトリクラスの設計

VBO/VAOの管理を抽象化し、点・線・三角形それぞれに特化したクラスを作成します。

GeometryBase(抽象基底クラス)
├── PointGeometry     # GL_POINTS
├── LineGeometry      # GL_LINES
└── TriangleGeometry  # GL_TRIANGLES

ジオメトリ基底クラス

src/graphics/geometry.py:

"""
ジオメトリモジュール

基本形状(点・線・三角形)の描画を提供
"""
from abc import ABC, abstractmethod
from enum import Enum
from typing import List, Tuple, Optional

import numpy as np
import OpenGL.GL as gl

from src.utils.logger import logger


class PrimitiveType(Enum):
    """描画プリミティブタイプ"""
    POINTS = gl.GL_POINTS
    LINES = gl.GL_LINES
    LINE_STRIP = gl.GL_LINE_STRIP
    LINE_LOOP = gl.GL_LINE_LOOP
    TRIANGLES = gl.GL_TRIANGLES
    TRIANGLE_STRIP = gl.GL_TRIANGLE_STRIP
    TRIANGLE_FAN = gl.GL_TRIANGLE_FAN


class GeometryBase(ABC):
    """
    ジオメトリの基底クラス

    VBO/VAOを管理し、描画機能を提供する抽象クラス
    """

    def __init__(self) -> None:
        """ジオメトリを初期化する"""
        self._vao: int = 0
        self._vbo: int = 0
        self._vertex_count: int = 0
        self._is_initialized: bool = False

    @property
    @abstractmethod
    def primitive_type(self) -> PrimitiveType:
        """描画プリミティブタイプを取得"""
        pass

    @property
    def vertex_count(self) -> int:
        """頂点数を取得"""
        return self._vertex_count

    @property
    def is_initialized(self) -> bool:
        """初期化済みかどうか"""
        return self._is_initialized

VBO/VAOの作成

頂点データ(位置XYZ + 色RGB)をGPUに転送し、属性を設定します:

def _create_buffers(self, vertices: np.ndarray) -> None:
    """
    VBO/VAOを作成する

    Args:
        vertices: 頂点データ(位置x,y,z + 色r,g,b)
    """
    if self._is_initialized:
        self._delete_buffers()

    # VAO(頂点配列オブジェクト)の作成
    self._vao = gl.glGenVertexArrays(1)
    gl.glBindVertexArray(self._vao)

    # VBO(頂点バッファオブジェクト)の作成
    self._vbo = gl.glGenBuffers(1)
    gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._vbo)
    gl.glBufferData(gl.GL_ARRAY_BUFFER, vertices.nbytes, vertices, gl.GL_STATIC_DRAW)

    # 頂点属性の設定
    stride = 6 * vertices.itemsize  # 1頂点あたり6つのfloat(位置3 + 色3)

    # 属性0: 位置(location = 0)
    gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, None)
    gl.glEnableVertexAttribArray(0)

    # 属性1: 色(location = 1)
    import ctypes
    offset = 3 * vertices.itemsize
    gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, ctypes.c_void_p(offset))
    gl.glEnableVertexAttribArray(1)

    # バインド解除
    gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0)
    gl.glBindVertexArray(0)

    self._vertex_count = len(vertices) // 6
    self._is_initialized = True

ポイント:

  • glGenVertexArrays/glGenBuffers: VAO/VBOを生成
  • glBindVertexArray/glBindBuffer: 操作対象を選択
  • glBufferData: 頂点データをGPUに転送
  • glVertexAttribPointer: 頂点属性のレイアウトを設定
  • glEnableVertexAttribArray: 属性を有効化

描画処理

VAOをバインドしてglDrawArraysを呼ぶだけでシンプルに描画できます:

def draw(self) -> None:
    """ジオメトリを描画する"""
    if not self._is_initialized:
        return

    gl.glBindVertexArray(self._vao)
    gl.glDrawArrays(self.primitive_type.value, 0, self._vertex_count)
    gl.glBindVertexArray(0)

VAOのおかげで、描画時に頂点属性を再設定する必要がありません。

点ジオメトリ(PointGeometry)

点の描画に特化したクラスです。点のサイズをglPointSizeで設定できます。

class PointGeometry(GeometryBase):
    """
    点ジオメトリクラス

    GL_POINTSを使用して点を描画
    """

    def __init__(self, points: Optional[List[Tuple[float, ...]]] = None) -> None:
        super().__init__()
        self._points: List[Tuple[float, ...]] = []
        self._point_size: float = 5.0

        if points:
            self._points = list(points)
            self._update_buffers()

    @property
    def primitive_type(self) -> PrimitiveType:
        return PrimitiveType.POINTS

    def set_point_size(self, size: float) -> None:
        """点のサイズを設定"""
        self._point_size = max(1.0, size)

    def add_point(self, x: float, y: float, z: float,
                  r: float = 1.0, g: float = 1.0, b: float = 1.0) -> None:
        """点を追加する"""
        self._points.append((x, y, z, r, g, b))
        self._update_buffers()

    def draw(self) -> None:
        """点を描画する"""
        if not self._is_initialized:
            return

        # 点のサイズを設定
        gl.glPointSize(self._point_size)
        super().draw()

線ジオメトリ(LineGeometry)

線分の描画に特化したクラスです。単色の線とグラデーション線の両方に対応しています。

class LineGeometry(GeometryBase):
    """
    線ジオメトリクラス

    GL_LINESを使用して線分を描画
    """

    def add_line(self, x1: float, y1: float, z1: float,
                 x2: float, y2: float, z2: float,
                 r: float = 1.0, g: float = 1.0, b: float = 1.0) -> None:
        """線分を追加する(単色)"""
        self._lines.append((
            (x1, y1, z1, r, g, b),
            (x2, y2, z2, r, g, b)
        ))
        self._update_buffers()

    def add_line_colored(self,
                         x1: float, y1: float, z1: float, r1: float, g1: float, b1: float,
                         x2: float, y2: float, z2: float, r2: float, g2: float, b2: float) -> None:
        """線分を追加する(グラデーション)"""
        self._lines.append((
            (x1, y1, z1, r1, g1, b1),
            (x2, y2, z2, r2, g2, b2)
        ))
        self._update_buffers()

Note(macOSの制限): macOSOpenGL Core Profileでは、glLineWidth()に1.0より大きい値を設定しても無視されます(またはエラーになります)。これはOpenGL 3.2以降のCore Profileの仕様で、太い線の描画はサポートされていません。そのため、本サンプルではLine Widthのスライダーは省略しています。太い線が必要な場合は、ジオメトリシェーダーで線を矩形に拡張する方法がありますが、それは将来のフェーズで紹介します。

三角形ジオメトリ(TriangleGeometry)

三角形の描画に特化したクラスです。塗りつぶされた面を描画できます。

class TriangleGeometry(GeometryBase):
    """
    三角形ジオメトリクラス

    GL_TRIANGLESを使用して三角形を描画
    """

    def add_triangle(self, x1: float, y1: float, z1: float,
                     x2: float, y2: float, z2: float,
                     x3: float, y3: float, z3: float,
                     r: float = 1.0, g: float = 1.0, b: float = 1.0) -> None:
        """三角形を追加する(単色)"""
        self._triangles.append((
            (x1, y1, z1, r, g, b),
            (x2, y2, z2, r, g, b),
            (x3, y3, z3, r, g, b)
        ))
        self._update_buffers()

    def add_triangle_colored(self,
                             x1: float, y1: float, z1: float, r1: float, g1: float, b1: float,
                             x2: float, y2: float, z2: float, r2: float, g2: float, b2: float,
                             x3: float, y3: float, z3: float, r3: float, g3: float, b3: float) -> None:
        """三角形を追加する(頂点ごとに色指定)"""
        self._triangles.append((
            (x1, y1, z1, r1, g1, b1),
            (x2, y2, z2, r2, g2, b2),
            (x3, y3, z3, r3, g3, b3)
        ))
        self._update_buffers()

深度テストの有効化

3D空間で複数のオブジェクトを描画する場合、手前のものが奥のものを隠す必要があります。これを実現するのが深度テスト(Depth Test)です。

def _render(self) -> None:
    """描画処理"""
    # 背景色の適用
    gl.glClearColor(*self._clear_color)

    # 深度テストを有効化(3D描画用)
    gl.glEnable(gl.GL_DEPTH_TEST)

    # 画面のクリア(カラーバッファと深度バッファの両方)
    gl.glClear(int(gl.GL_COLOR_BUFFER_BIT) | int(gl.GL_DEPTH_BUFFER_BIT))

    # ジオメトリの描画
    self._draw_geometries()

    # imguiのレンダリング...

ポイント:

  • glEnable(GL_DEPTH_TEST): 深度テストを有効化
  • glClear(GL_DEPTH_BUFFER_BIT): 深度バッファもクリアする必要がある

深度テストの制限

深度テストは「各ピクセルのZ値を比較して、手前にあるものだけを描画する」仕組みです。ただし、以下の場合は期待通りに動作しないことがあります:

  1. 同じ深度の面が重なる場合(Z-fighting): ほぼ同じZ値の面同士がちらつく
  2. 半透明オブジェクト: 深度値は書き込まれるが、奥のオブジェクトは見えなくなる
  3. 描画順序: 同じ深度の三角形は描画順で決まる

下の画像のように、同じZ値(z=0)に多数の三角形を重ねると、描画順序によって表示が崩れることがあります。これは深度テストの正常な動作であり、バグではありません。

同じZ座標に複数の三角形を重ねた場合の表示
同じZ座標に複数の三角形を重ねた場合の表示
同じZ座標に複数の三角形を重ねた場合の表示

より高度な描画(半透明や重なりの正確な処理)には、描画順序の管理やブレンディングの設定が必要になります。これらは後のフェーズで扱います。

Appクラスへの統合

ジオメトリクラスをAppクラスに組み込み、imguiで操作できるようにします。

def _setup_geometries(self) -> None:
    """ジオメトリをセットアップする"""
    # === 点ジオメトリ ===
    self._point_geometry = PointGeometry()
    self._point_geometry.set_point_size(8.0)
    # サンプルの点を追加
    self._point_geometry.add_point(-0.8, 0.8, 0.0, 1.0, 0.0, 0.0)   # 赤
    self._point_geometry.add_point(0.0, 0.8, 0.0, 0.0, 1.0, 0.0)    # 緑
    self._point_geometry.add_point(0.8, 0.8, 0.0, 0.0, 0.0, 1.0)    # 青

    # === 線ジオメトリ ===
    self._line_geometry = LineGeometry()
    self._line_geometry.set_line_width(2.0)
    # 座標軸
    self._line_geometry.add_line(-1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0)  # X軸(赤)
    self._line_geometry.add_line(0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0)  # Y軸(緑)
    self._line_geometry.add_line(0.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0)  # Z軸(青)

    # === 三角形ジオメトリ ===
    self._triangle_geometry = TriangleGeometry()
    # 虹色の三角形
    self._triangle_geometry.add_triangle_colored(
        -0.5, -0.5, 0.0, 1.0, 0.0, 0.0,  # 左下: 赤
        0.5, -0.5, 0.0, 0.0, 1.0, 0.0,   # 右下: 緑
        0.0, 0.5, 0.0, 0.0, 0.0, 1.0     # 上: 青
    )

imguiでインタラクティブに操作

形状の追加・クリア、表示モードの切り替えをimguiで行えるようにしました。

def _draw_geometry_window(self) -> None:
    """形状ウィンドウを描画"""
    imgui.begin("Geometry")

    # 表示モードの選択
    mode_names = ["Points", "Lines", "Triangles", "All"]
    changed, self._geometry_mode = imgui.combo("Display Mode", self._geometry_mode, mode_names)

    # === 点の設定 ===
    if imgui.collapsing_header("Points", imgui.TreeNodeFlags_.default_open.value):
        if self._point_geometry:
            # 点のサイズ
            changed_size, size = imgui.slider_float(
                "Point Size", self._point_geometry.point_size, 1.0, 20.0
            )
            if changed_size:
                self._point_geometry.set_point_size(size)

            imgui.text(f"Point Count: {self._point_geometry.vertex_count}")

            if imgui.button("Clear Points"):
                self._point_geometry.clear()

            if imgui.button("Add Random Point"):
                import random
                x = random.uniform(-1.0, 1.0)
                y = random.uniform(-1.0, 1.0)
                z = random.uniform(-0.5, 0.5)
                r, g, b = random.random(), random.random(), random.random()
                self._point_geometry.add_point(x, y, z, r, g, b)

    # ... 線・三角形も同様 ...

    imgui.end()

実行結果

起動すると、点・線・三角形が表示されます。imguiのGeometryパネルで:

  • Display Mode: 表示する形状を切り替え
  • Add Random: ランダムな位置に形状を追加
  • Clear: 各形状をクリア
  • Point Size: 点のサイズ調整(スライダー)

点・線・三角形の基本形状を表示
点・線・三角形の基本形状を表示
点・線・三角形の基本形状を表示

カメラ操作(ドラッグ・スクロール)も引き続き使用できます。

リソースの解放

OpenGLのリソース(VBO/VAO)は、使い終わったら明示的に解放する必要があります。

def cleanup(self) -> None:
    """リソースを解放する"""
    self._delete_buffers()

def _delete_buffers(self) -> None:
    """VBO/VAOを削除する"""
    if self._vbo:
        gl.glDeleteBuffers(1, [self._vbo])
        self._vbo = 0
    if self._vao:
        gl.glDeleteVertexArrays(1, [self._vao])
        self._vao = 0
    self._is_initialized = False

Appの終了処理で各ジオメトリのcleanup()を呼び出します:

def _shutdown(self) -> None:
    """終了処理"""
    # ジオメトリリソースの解放
    if self._point_geometry:
        self._point_geometry.cleanup()
    if self._line_geometry:
        self._line_geometry.cleanup()
    if self._triangle_geometry:
        self._triangle_geometry.cleanup()

    # シェーダーの解放
    if self._shader:
        self._shader.delete()
    # ...

ディレクトリ構成

今回追加・変更したファイル:

src/
├── main.py           # エントリポイント(変更なし)
├── core/
│   └── app.py        # ジオメトリ管理・imgui UI追加
├── graphics/
│   ├── __init__.py   # エクスポート追加
│   └── geometry.py   # 新規:ジオメトリクラス群
└── shaders/          # (変更なし)

まとめ

今回学んだこと:

  1. VBO(Vertex Buffer Object): 頂点データをGPUに格納
  2. VAO(Vertex Array Object): 頂点属性の設定を記録
  3. プリミティブタイプ: GL_POINTS, GL_LINES, GL_TRIANGLES
  4. 深度テスト: 3D描画での前後関係を正しく表示
  5. リソース管理: 使い終わったVBO/VAOは明示的に解放

ジオメトリクラスで抽象化したことで、点・線・三角形を簡単に追加・描画できるようになりました。

次回は、これを発展させて立方体や球体などの複合形状を描画します。インデックスバッファ(EBO)を使った効率的な頂点管理も学びます。

ソースコード

今回のコードは以下のGitHubリポジトリで公開しています。

次回予告

第9回「立方体と球体を描く」では:

  • インデックスバッファ(EBO)の使用
  • 四角形の描画
  • 立方体と球体の頂点データ生成
  • ワイヤーフレーム表示

お楽しみに!


前回: 第7回「マウスでカメラ操作」

次回: 第9回「立方体と球体を描く - EBOで頂点を再利用」

【GitHub Copilotと作る Pythonで OpenGL 3Dプログラミング】 - 第7回「マウスでカメラ操作」

GitHub Copilotと作る PythonOpenGL 3Dプログラミング

第7回「マウスでカメラ操作」

はじめに

前回は2D/3Dカメラクラスを実装し、それぞれの投影方式を学びました。今回はマウス操作でカメラを直感的に操作できるようにします。

3DビューアやCADソフトでは、マウスドラッグやホイールでカメラを自由に動かせることが必須です。今回はこの機能を実装します。

今回のゴール

  • MouseController クラスでマウス入力を管理
  • CameraController クラスでマウス操作をカメラに反映
  • 3Dモード: 左ドラッグでオービット、右ドラッグでパン、中ドラッグで高さ調整、ホイールでズーム
  • 2Dモード: 左ドラッグで回転、右ドラッグでパン、ホイールでズーム
  • imgui との共存:UIウィジェット操作とカメラ操作を両立

マウス操作の設計

操作方式の選択

3Dソフトウェアによってマウス操作の割り当ては異なります。今回は以下の方式を採用しました:

操作 3Dモード 2Dモード
左ドラッグ オービット(回転) 回転
右ドラッグ 水平パン(XZ/XY平面) パン
中ドラッグ 高さ調整(Y/Z軸) パン
ホイール ズーム ズーム

座標系と水平面

3Dソフトウェアでは「上方向」の軸が異なる場合があります:

Y-up(OpenGL標準)       Z-up(CAD/工学系)
      Y                        Z
      |                        |
      |                        |
      +---- X                  +---- X
     /                        /
    Z                        Y

水平面: XZ平面            水平面: XY平面
高さ軸: Y軸               高さ軸: Z軸
  • Y-up(OpenGL標準): Y軸が上方向。水平面はXZ平面(X軸とZ軸で構成)
  • Z-up(CAD/工学系): Z軸が上方向。水平面はXY平面(X軸とY軸で構成)

今回の実装では両方の座標系に対応し、右ドラッグで水平面上を移動、中ドラッグで高さ軸方向に移動するようにしています。

クラス設計

マウス入力とカメラ操作を分離した設計にします:

MouseController
    ├── GLFWコールバック処理
    ├── ボタン状態管理
    ├── ドラッグ検出
    └── スクロール量取得

CameraController
    ├── MouseController参照
    ├── Camera2D参照
    ├── Camera3D参照
    └── マウス入力→カメラ操作変換

この分離により:

  • マウス入力処理を再利用可能
  • カメラ操作ロジックを独立してテスト可能
  • 将来的にゲームパッド等の入力に対応しやすい

MouseControllerクラス

src/core/mouse_controller.pyを作成します。

"""
マウス入力管理モジュール
"""
from enum import IntEnum
from typing import Callable, Optional, Tuple

import glfw

from src.utils.logger import logger


class MouseButton(IntEnum):
    """マウスボタン"""
    LEFT = glfw.MOUSE_BUTTON_LEFT
    RIGHT = glfw.MOUSE_BUTTON_RIGHT
    MIDDLE = glfw.MOUSE_BUTTON_MIDDLE


class MouseController:
    """
    マウス入力を管理するクラス

    GLFWからのマウスイベントを処理し、
    ドラッグ状態やスクロール量を追跡する

    Note:
        既存のコールバック(imgui等)を保持し、チェーンで呼び出す
    """

    def __init__(self, window_handle) -> None:
        """
        マウスコントローラーを初期化する

        Args:
            window_handle: GLFWウィンドウハンドル
        """
        self._window = window_handle

        # マウス位置
        self._current_x = 0.0
        self._current_y = 0.0
        self._last_x = 0.0
        self._last_y = 0.0

        # ボタン状態
        self._button_pressed = {
            MouseButton.LEFT: False,
            MouseButton.RIGHT: False,
            MouseButton.MIDDLE: False,
        }

        # ドラッグ開始位置
        self._drag_start_x = 0.0
        self._drag_start_y = 0.0

        # スクロール量(フレームごとにリセット)
        self._scroll_x = 0.0
        self._scroll_y = 0.0

        # 既存のコールバックを保存(imgui等)
        self._prev_mouse_button_callback: Optional[Callable] = None
        self._prev_cursor_pos_callback: Optional[Callable] = None
        self._prev_scroll_callback: Optional[Callable] = None

        # 既存のコールバックを取得してから、新しいコールバックを登録
        # Note: glfw.set_*_callback は前のコールバックを返す
        self._prev_mouse_button_callback = glfw.set_mouse_button_callback(
            window_handle, self._mouse_button_callback
        )
        self._prev_cursor_pos_callback = glfw.set_cursor_pos_callback(
            window_handle, self._cursor_pos_callback
        )
        self._prev_scroll_callback = glfw.set_scroll_callback(
            window_handle, self._scroll_callback
        )

        # 初期位置を取得
        x, y = glfw.get_cursor_pos(window_handle)
        self._current_x = x
        self._current_y = y
        self._last_x = x
        self._last_y = y

        logger.info("MouseController initialized")

コールバックチェーンの重要性

imguiのGlfwRendererは初期化時にGLFWのマウスコールバックを登録します。MouseControllerがコールバックを上書きすると、imguiが反応しなくなってしまいます。

これを避けるため、既存のコールバックを保存してチェーンで呼び出す設計にしています:

def _mouse_button_callback(self, window, button: int, action: int, mods: int) -> None:
    """マウスボタンコールバック"""
    # 既存のコールバック(imgui)を先に呼び出す
    if self._prev_mouse_button_callback:
        self._prev_mouse_button_callback(window, button, action, mods)

    # 自分の処理
    if button in [MouseButton.LEFT, MouseButton.RIGHT, MouseButton.MIDDLE]:
        mouse_button = MouseButton(button)
        if action == glfw.PRESS:
            self._button_pressed[mouse_button] = True
            self._drag_start_x = self._current_x
            self._drag_start_y = self._current_y
        elif action == glfw.RELEASE:
            self._button_pressed[mouse_button] = False

この方式により:

  • imguiのUI操作が正常に動作
  • カメラ操作も同時に機能
  • 複数のコールバックハンドラを共存可能

フレーム更新とプロパティ

def update(self) -> None:
    """
    フレーム更新

    前フレームの位置を保存し、スクロール量をリセット
    """
    self._last_x = self._current_x
    self._last_y = self._current_y
    self._scroll_x = 0.0
    self._scroll_y = 0.0

@property
def position(self) -> Tuple[float, float]:
    """現在のマウス位置"""
    return (self._current_x, self._current_y)

@property
def delta(self) -> Tuple[float, float]:
    """前フレームからの移動量"""
    return (self._current_x - self._last_x, self._current_y - self._last_y)

@property
def scroll(self) -> Tuple[float, float]:
    """スクロール量(X, Y)"""
    return (self._scroll_x, self._scroll_y)

@property
def is_left_dragging(self) -> bool:
    """左ボタンでドラッグ中か"""
    return self._button_pressed[MouseButton.LEFT]

@property
def is_right_dragging(self) -> bool:
    """右ボタンでドラッグ中か"""
    return self._button_pressed[MouseButton.RIGHT]

@property
def is_middle_dragging(self) -> bool:
    """中ボタンでドラッグ中か"""
    return self._button_pressed[MouseButton.MIDDLE]
  • delta: 前フレームからの移動量(ドラッグ速度)
  • scroll: ホイールスクロール量(フレームごとにリセット)

CameraControllerクラス

src/core/camera_controller.pyを作成します。

"""
カメラコントローラーモジュール
"""
import numpy as np
from typing import Union

from src.core.mouse_controller import MouseController, MouseButton
from src.graphics.camera import Camera2D, Camera3D, UpAxis
from src.utils.logger import logger


class CameraController:
    """
    マウス入力でカメラを操作するクラス

    操作方法:
    - 左ドラッグ: オービット(3Dカメラのみ)/ 回転(2Dカメラ)
    - 右ドラッグ: パン(平行移動)
    - 中ドラッグ: パン(平行移動)
    - ホイール: ズーム
    """

    def __init__(
        self,
        mouse: MouseController,
        camera_2d: Camera2D,
        camera_3d: Camera3D,
    ) -> None:
        self._mouse = mouse
        self._camera_2d = camera_2d
        self._camera_3d = camera_3d

        # 操作の感度
        self._orbit_sensitivity = 0.5      # オービット感度(度/ピクセル)
        self._pan_sensitivity = 0.01       # パン感度
        self._zoom_sensitivity = 0.1       # ズーム感度
        self._rotation_sensitivity = 0.5   # 2D回転感度(度/ピクセル)

        # imgui上でのマウス操作を無視するフラグ
        self._enabled = True

        logger.info("CameraController initialized")

3Dカメラの更新

3Dカメラはオービット(球面座標での回転)、パン(水平面での移動)、高さ調整ズームを実装します。

def _update_3d_camera(self, dx: float, dy: float, scroll: float) -> None:
    """3Dカメラを更新"""
    # 左ドラッグ: オービット(回転)
    if self._mouse.is_left_dragging:
        azimuth, elevation, distance = self._camera_3d.get_orbit()
        # 水平方向のドラッグで方位角を変更
        azimuth -= dx * self._orbit_sensitivity
        # 垂直方向のドラッグで仰角を変更(上下反転)
        elevation += dy * self._orbit_sensitivity
        # 仰角を-89〜89度にクランプ
        elevation = max(-89.0, min(89.0, elevation))
        self._camera_3d.set_orbit(azimuth, elevation, distance)

仰角を±89度にクランプしているのは、ジンバルロックを防ぐためです。真上や真下を向くと、方位角の回転軸が失われて操作が不安定になります。

水平パン(View行列からの方向取得)

右ドラッグで水平面上を移動します。ここでのポイントは、カメラの向きに応じた方向に移動することです。

# 右ドラッグ: パン(水平面での移動)
if self._mouse.is_right_dragging:
    # カメラのView行列から右方向と前方向を取得
    view = self._camera_3d.view_matrix
    # View行列の1行目が右方向
    right = np.array([view[0, 0], view[0, 1], view[0, 2]], dtype=np.float32)
    # View行列の3行目の符号反転が前方向
    forward = np.array([-view[2, 0], -view[2, 1], -view[2, 2]], dtype=np.float32)

    # 上方向の軸に応じて水平面に投影
    if self._camera_3d.up_axis == UpAxis.Z_UP:
        # Z-up: XY平面に投影(Z成分を0にして正規化)
        right[2] = 0.0
        forward[2] = 0.0
    else:
        # Y-up: XZ平面に投影(Y成分を0にして正規化)
        right[1] = 0.0
        forward[1] = 0.0

    # 正規化
    right_len = np.linalg.norm(right)
    if right_len > 0.001:
        right = right / right_len

    forward_len = np.linalg.norm(forward)
    if forward_len > 0.001:
        forward = forward / forward_len

    # スクリーン座標のドラッグをワールド座標の移動に変換
    move = -dx * self._pan_sensitivity * right - dy * self._pan_sensitivity * forward
    self._camera_3d.translate(move[0], move[1], move[2])

View行列から方向ベクトルを取り出す理由: - カメラの向きに連動: どの方向を向いていても「右にドラッグ→右に移動」 - 水平面に投影: 上方向成分を0にして、地面に平行な移動のみに制限

2Dカメラの更新

2Dカメラでは回転を考慮したパンが必要です。View行列から方向ベクトルを取得することで、回転後も正しい方向に移動できます。

def _update_2d_camera(self, dx: float, dy: float, scroll: float) -> None:
    """2Dカメラを更新"""
    # 左ドラッグ: 回転
    if self._mouse.is_left_dragging:
        rotation = self._camera_2d.rotation
        rotation -= dx * self._rotation_sensitivity
        self._camera_2d.set_rotation(rotation)

    # 右ドラッグまたは中ドラッグ: パン(平行移動)
    if self._mouse.is_right_dragging or self._mouse.is_middle_dragging:
        pos_x, pos_y = self._camera_2d.position
        zoom = self._camera_2d.zoom

        # View行列から右方向と上方向を取得
        view = self._camera_2d.view_matrix
        right_x = view[0, 0]
        right_y = view[0, 1]
        up_x = view[1, 0]
        up_y = view[1, 1]

        # スクリーン座標のドラッグをワールド座標系に変換
        scale = self._pan_sensitivity / zoom
        move_x = (-dx * right_x + dy * up_x) * scale
        move_y = (-dx * right_y + dy * up_y) * scale

        self._camera_2d.set_position(pos_x + move_x, pos_y + move_y)

    # ホイール: ズーム
    if scroll != 0.0:
        zoom = self._camera_2d.zoom
        zoom *= 1.0 + scroll * self._zoom_sensitivity
        zoom = max(0.1, min(10.0, zoom))
        self._camera_2d.set_zoom(zoom)

Appクラスへの統合

src/core/app.pyにMouseControllerとCameraControllerを統合します。

from src.core.mouse_controller import MouseController
from src.core.camera_controller import CameraController

class App:
    def __init__(self, width: int = 800, height: int = 600, title: str = "PythonOpenGL") -> None:
        # ... ウィンドウ、GUI、カメラの初期化 ...

        # マウスコントローラーとカメラコントローラーを初期化
        # Note: GUIの初期化後に行う(imguiのコールバックをチェーンするため)
        self._mouse = MouseController(self._window.handle)
        self._camera_controller = CameraController(
            self._mouse, self._camera_2d, self._camera_3d
        )

初期化順序が重要です: 1. Window → 2. GUI(imguiコールバック登録) → 3. MouseController(コールバックチェーン)

更新処理

def _update(self) -> None:
    """更新処理"""
    # imguiがマウスを使用中かチェック
    io = imgui.get_io()
    self._camera_controller.set_enabled(not io.want_capture_mouse)

    # カメラコントローラーを更新
    self._camera_controller.update(self._use_3d_camera)

    # マウス状態を更新(フレーム終了時)
    self._mouse.update()

io.want_capture_mouseをチェックすることで、imgui上でマウス操作している間はカメラ操作を無効化できます。

実行結果

実行すると、マウスでカメラを操作できるようになります。

3Dモード

3Dモード

  • 左ドラッグで三角形の周りを回転(オービット)
  • 右ドラッグで水平方向に移動(パン)
  • 中ドラッグで上下に移動(高さ調整)
  • ホイールでズームイン/アウト

2Dモード

2Dモード
2Dモード

  • 左ドラッグでカメラを回転
  • 右ドラッグで平行移動(回転後も正しい方向に移動)
  • ホイールでズーム

まとめ

今回はマウスでカメラを操作する機能を実装しました。

学んだこと

  • コールバックチェーン: 既存のコールバック(imgui)を保持して共存
  • View行列からの方向取得: カメラの向きに連動した移動
  • 座標変換: スクリーン座標→ワールド座標への変換
  • 入力と操作の分離: MouseControllerとCameraControllerの責務分離

ファイル構成

src/
├── core/
│   ├── app.py               # Appクラス(統合)
│   ├── mouse_controller.py  # MouseController(マウス入力)
│   └── camera_controller.py # CameraController(カメラ操作)
├── graphics/
│   └── camera.py            # Camera2D/Camera3D
└── ...

クラス構成

MouseController
├── position: (x, y) - 現在位置
├── delta: (dx, dy) - 移動量
├── scroll: (sx, sy) - スクロール量
├── is_left_dragging, is_right_dragging, is_middle_dragging
└── update() - フレーム更新

CameraController
├── mouse: MouseController
├── camera_2d: Camera2D
├── camera_3d: Camera3D
├── orbit_sensitivity, pan_sensitivity, zoom_sensitivity
├── enabled: bool - imgui上では無効化
└── update(use_3d_camera) - カメラ更新

次回は基本形状描画を実装します。点・線・三角形の描画方法と、VBO(Vertex Buffer Object)・VAO(Vertex Array Object)の使い方を詳しく見ていきましょう。


前回: 第6回「2D/3Dカメラの実装」

次回: 第8回「点・線・三角形を描く」

【GitHub Copilotと作る Pythonで OpenGL 3Dプログラミング】 - 第6回「2D/3Dカメラの実装」

GitHub Copilotと作る PythonOpenGL 3Dプログラミング

第6回「2D/3Dカメラの実装」

はじめに

前回は座標変換の基礎として、Model・View・Projection行列について学びました。今回は2D/3Dカメラを実装し、それぞれのカメラクラスを作成します。

2Dゲームや3Dアプリケーションでは、それぞれ異なる投影方式が必要です。2Dでは正射影(Orthographic Projection)、3Dでは透視投影(Perspective Projection)を使用します。

今回のゴール

  • Camera2D / Camera3D クラスを作成し、用途に応じて使い分け
  • 正射影(2Dカメラ)の実装
  • 透視投影(3Dカメラ)の実装
  • 3Dカメラのオービット(球面座標)とパン(平行移動)操作
  • imguiでカメラモードとパラメータを調整

正射影と透視投影の違い

正射影(Orthographic Projection)

正射影は、距離に関係なく同じサイズで表示される投影方式です。

  ┌─────────┐
  │         │
  │    ●    │ ← どの距離でも同じサイズ
  │         │
  └─────────┘

特徴:

  • 遠近感がない
  • 2Dゲーム、UI、設計図などに適している
  • ズーム倍率で表示範囲を制御

透視投影(Perspective Projection)

前回実装した透視投影は、人間の目やカメラのように遠くのものほど小さく見える投影方式です。

     視点
      ╲│╱
       ●
      ╱│╲
     / │ \
    /  │  \
   /   │   \
  ╱────┼────╲  ← 遠くのクリップ面

特徴:

  • 遠近感がある
  • 3Dシーンに適している
  • FOV(視野角) でズームを制御

カメラクラスの設計

今回は基底クラス + 派生クラスの構成でカメラを実装します。

CameraBase (抽象基底クラス)
├── Camera2D (正射影カメラ)
└── Camera3D (透視投影カメラ)

この設計により:

  • 2Dと3Dで異なるパラメータを明確に分離
  • 共通インターフェース(view_matrix, projection_matrix)を提供
  • 教育的にわかりやすい構造

CameraBaseクラス(基底クラス)

src/graphics/camera.pyを作成します。

"""
カメラモジュール

2D/3Dカメラの基底クラスと派生クラスを提供
"""
from abc import ABC, abstractmethod
from enum import Enum
from typing import Tuple, Union

import numpy as np

from src.utils.logger import logger


class CameraMode(Enum):
    """カメラモード"""
    CAMERA_2D = 0  # 正射影(2D)
    CAMERA_3D = 1  # 透視投影(3D)


class CameraBase(ABC):
    """
    カメラの基底クラス

    View行列とProjection行列を提供する抽象クラス
    """

    def __init__(self, width: int = 800, height: int = 600) -> None:
        self._width = width
        self._height = height
        self._aspect = width / height if height > 0 else 1.0

        # 行列のキャッシュ
        self._view_matrix = np.eye(4, dtype=np.float32)
        self._projection_matrix = np.eye(4, dtype=np.float32)

    @property
    @abstractmethod
    def mode(self) -> CameraMode:
        """カメラモードを取得"""
        pass

    @property
    def view_matrix(self) -> np.ndarray:
        """View行列を取得"""
        return self._view_matrix

    @property
    def projection_matrix(self) -> np.ndarray:
        """Projection行列を取得"""
        return self._projection_matrix

    @abstractmethod
    def reset(self) -> None:
        """カメラをデフォルト状態にリセット"""
        pass

Camera2Dクラス(正射影カメラ)

2Dカメラはシンプルな平行移動、ズーム、回転を提供します。

class Camera2D(CameraBase):
    """
    2Dカメラクラス

    正射影(Orthographic Projection)を使用
    """

    def __init__(self, width: int = 800, height: int = 600) -> None:
        super().__init__(width, height)

        # カメラ位置(XY平面上のオフセット)
        self._position_x = 0.0
        self._position_y = 0.0

        # ズーム倍率
        self._zoom = 1.0

        # 回転角度(度)
        self._rotation = 0.0

        # クリップ面
        self._near = -10.0
        self._far = 10.0

        self._update_view_matrix()
        self._update_projection_matrix()
        logger.info("Camera2D initialized")

    @property
    def mode(self) -> CameraMode:
        return CameraMode.CAMERA_2D

    @property
    def position(self) -> Tuple[float, float]:
        """カメラ位置を取得(2D: XY座標)"""
        return (self._position_x, self._position_y)

    def set_position(self, x: float, y: float) -> None:
        """カメラ位置を設定"""
        self._position_x = x
        self._position_y = y
        self._update_view_matrix()

    @property
    def zoom(self) -> float:
        return self._zoom

    def set_zoom(self, zoom: float) -> None:
        self._zoom = max(0.01, zoom)
        self._update_projection_matrix()

    @property
    def rotation(self) -> float:
        """回転角度を取得(度)"""
        return self._rotation

    def set_rotation(self, rotation: float) -> None:
        """回転角度を設定(度)"""
        self._rotation = rotation % 360.0
        self._update_view_matrix()

2D用View行列

2Dモードでは、回転と平行移動を組み合わせたView行列を使用します。

    def _update_view_matrix(self) -> None:
        """View行列を更新"""
        # 回転行列を計算
        angle_rad = np.radians(self._rotation)
        cos_a = np.cos(angle_rad)
        sin_a = np.sin(angle_rad)

        # View行列 = 回転 * 平行移動
        self._view_matrix = np.eye(4, dtype=np.float32)
        # 回転部分
        self._view_matrix[0, 0] = cos_a
        self._view_matrix[0, 1] = sin_a
        self._view_matrix[1, 0] = -sin_a
        self._view_matrix[1, 1] = cos_a
        # 平行移動部分(回転後の座標系で適用)
        self._view_matrix[0, 3] = -(cos_a * self._position_x + sin_a * self._position_y)
        self._view_matrix[1, 3] = -(-sin_a * self._position_x + cos_a * self._position_y)

回転と平行移動を組み合わせることで、カメラを回転させても正しくオフセットが適用されます。

正射影行列の計算

    def _update_projection_matrix(self) -> None:
        """Projection行列を更新(正射影)"""
        # ズームを適用した表示範囲
        half_width = self._aspect / self._zoom
        half_height = 1.0 / self._zoom

        left = -half_width
        right = half_width
        bottom = -half_height
        top = half_height

        # 正射影行列
        self._projection_matrix = np.zeros((4, 4), dtype=np.float32)
        self._projection_matrix[0, 0] = 2.0 / (right - left)
        self._projection_matrix[1, 1] = 2.0 / (top - bottom)
        self._projection_matrix[2, 2] = -2.0 / (self._far - self._near)
        self._projection_matrix[0, 3] = -(right + left) / (right - left)
        self._projection_matrix[1, 3] = -(top + bottom) / (top - bottom)
        self._projection_matrix[2, 3] = -(self._far + self._near) / (self._far - self._near)
        self._projection_matrix[3, 3] = 1.0

正射影行列の各要素: - [0,0][1,1]: 表示範囲を正規化(-1〜1) - [2,2]: Z方向の正規化 - [0,3], [1,3], [2,3]: 中心へのオフセット

Camera3Dクラス(透視投影カメラ)

3Dカメラはオービット(球面座標)とパン(平行移動)の2つの操作方法を提供します。

class Camera3D(CameraBase):
    """
    3Dカメラクラス

    透視投影(Perspective Projection)を使用
    """

    def __init__(self, width: int = 800, height: int = 600) -> None:
        super().__init__(width, height)

        # カメラ位置
        self._position = np.array([0.0, 0.0, 5.0], dtype=np.float32)
        # カメラの視点(見る点)
        self._target = np.array([0.0, 0.0, 0.0], dtype=np.float32)
        # 上ベクトル
        self._up = np.array([0.0, 1.0, 0.0], dtype=np.float32)

        # 透視投影パラメータ
        self._fov = 45.0  # 視野角(度)
        self._near = 0.1
        self._far = 100.0

        self._update_view_matrix()
        self._update_projection_matrix()
        logger.info("Camera3D initialized")

オービット操作(球面座標)

オービットは、視点(target)を中心として球面上にカメラを配置する操作です。マウスドラッグでの回転操作のベースになります。

        ↑ Y (elevation)
        │
        │  ● カメラ
        │ /
        │/  distance
        ●────→ X (azimuth)
       target
    def set_orbit(self, azimuth: float, elevation: float, distance: float) -> None:
        """
        オービット(球面座標)でカメラ位置を設定

        Args:
            azimuth: 方位角(水平回転、度)0=正面、90=右、-90=左
            elevation: 仰角(垂直回転、度)0=水平、90=真上、-90=真下
            distance: 視点からの距離
        """
        azimuth_rad = np.radians(azimuth)
        elevation_rad = np.radians(elevation)

        # 球面座標からカメラ位置を計算
        x = distance * np.cos(elevation_rad) * np.sin(azimuth_rad)
        y = distance * np.sin(elevation_rad)
        z = distance * np.cos(elevation_rad) * np.cos(azimuth_rad)

        self._position = self._target + np.array([x, y, z], dtype=np.float32)
        self._update_view_matrix()

    def get_orbit(self) -> Tuple[float, float, float]:
        """現在のオービットパラメータを取得"""
        rel = self._position - self._target
        distance = float(np.linalg.norm(rel))

        if distance < 0.001:
            return (0.0, 0.0, distance)

        rel_norm = rel / distance
        elevation = np.degrees(np.arcsin(np.clip(rel_norm[1], -1.0, 1.0)))
        azimuth = np.degrees(np.arctan2(rel_norm[0], rel_norm[2]))

        return (float(azimuth), float(elevation), distance)

パン操作(平行移動)

パンは、カメラと視点を同時に移動させる操作です。2Dカメラのオフセットと同様の動きになります。

    def set_pan(self, x: float, y: float, z: float) -> None:
        """
        カメラのパン位置を設定

        カメラ位置と視点の両方にオフセットを適用
        """
        default_pos = np.array([0.0, 0.0, 5.0], dtype=np.float32)
        default_target = np.array([0.0, 0.0, 0.0], dtype=np.float32)
        offset = np.array([x, y, z], dtype=np.float32)

        self._position = default_pos + offset
        self._target = default_target + offset
        self._update_view_matrix()

    @property
    def pan(self) -> Tuple[float, float, float]:
        """現在のパンオフセットを取得"""
        return tuple(self._target)

Look At行列(View行列)

3DカメラではLook At行列を使用します。

    def _update_view_matrix(self) -> None:
        """View行列を更新(Look At行列)"""
        eye = self._position
        target = self._target
        up = self._up

        # 前方向ベクトル(正規化)
        forward = target - eye
        forward = forward / np.linalg.norm(forward)

        # 右ベクトル
        right = np.cross(forward, up)
        right = right / np.linalg.norm(right)

        # 新しい上ベクトル
        up_new = np.cross(right, forward)

        # View行列を構築
        self._view_matrix = np.eye(4, dtype=np.float32)
        self._view_matrix[0, 0:3] = right
        self._view_matrix[1, 0:3] = up_new
        self._view_matrix[2, 0:3] = -forward
        self._view_matrix[0, 3] = -np.dot(right, eye)
        self._view_matrix[1, 3] = -np.dot(up_new, eye)
        self._view_matrix[2, 3] = np.dot(forward, eye)

アプリケーションへの統合

app.pyでCamera2D/Camera3Dクラスを使用するように変更します。

from src.graphics import Shader, Camera2D, Camera3D, CameraMode

class App:
    def __init__(self) -> None:
        # ...

        # カメラ(2D/3D切り替え対応)
        self._camera_2d = Camera2D(800, 600)
        self._camera_3d = Camera3D(800, 600)
        self._use_3d_camera = True  # 3Dカメラを使用

        # 座標変換(Model行列用)
        self._transform = Transform()

カメラウィンドウの更新

モードに応じて異なる設定UIを表示します。

    def _draw_camera_window(self) -> None:
        """Draw camera window"""
        imgui.begin("Camera")

        # Camera mode selection
        mode_names = ["2D (Orthographic)", "3D (Perspective)"]
        current_mode = 1 if self._use_3d_camera else 0
        changed_mode, new_mode = imgui.combo("Mode", current_mode, mode_names)
        if changed_mode:
            self._use_3d_camera = (new_mode == 1)

        imgui.separator()

        if not self._use_3d_camera:
            # === 2D Camera Settings ===
            imgui.text("2D Camera Settings")

            cam_pos = self._camera_2d.position
            changed_x, cam_x = imgui.slider_float("Offset X", cam_pos[0], -5.0, 5.0)
            changed_y, cam_y = imgui.slider_float("Offset Y", cam_pos[1], -5.0, 5.0)
            if changed_x or changed_y:
                self._camera_2d.set_position(cam_x, cam_y)

            changed_zoom, zoom = imgui.slider_float("Zoom", self._camera_2d.zoom, 0.1, 5.0)
            if changed_zoom:
                self._camera_2d.set_zoom(zoom)

            # Rotation
            changed_rot, rotation = imgui.slider_float("Rotation", self._camera_2d.rotation, 0.0, 360.0)
            if changed_rot:
                self._camera_2d.set_rotation(rotation)

        else:
            # === 3D Camera Settings ===
            imgui.text("3D Camera Settings")

            # Orbit (spherical coordinates around target)
            if imgui.collapsing_header("Orbit (Rotate around target)"):
                orbit = self._camera_3d.get_orbit()
                changed_az, azimuth = imgui.slider_float("Azimuth", orbit[0], -180.0, 180.0)
                changed_el, elevation = imgui.slider_float("Elevation", orbit[1], -89.0, 89.0)
                changed_dist, distance = imgui.slider_float("Distance", orbit[2], 1.0, 20.0)
                if changed_az or changed_el or changed_dist:
                    self._camera_3d.set_orbit(azimuth, elevation, distance)

            # Pan (parallel translation)
            if imgui.collapsing_header("Pan (Move camera and target)"):
                pan = self._camera_3d.pan
                changed_pan_x, pan_x = imgui.slider_float("Pan X", pan[0], -5.0, 5.0)
                changed_pan_y, pan_y = imgui.slider_float("Pan Y", pan[1], -5.0, 5.0)
                changed_pan_z, pan_z = imgui.slider_float("Pan Z", pan[2], -5.0, 5.0)
                if changed_pan_x or changed_pan_y or changed_pan_z:
                    self._camera_3d.set_pan(pan_x, pan_y, pan_z)

            # Projection settings
            if imgui.collapsing_header("Projection"):
                changed_fov, fov = imgui.slider_float("FOV", self._camera_3d.fov, 15.0, 120.0)
                if changed_fov:
                    self._camera_3d.set_fov(fov)

        # Reset button
        if imgui.button("Reset Camera"):
            if self._use_3d_camera:
                self._camera_3d.reset()
            else:
                self._camera_2d.reset()

        imgui.end()

描画時の行列設定

    def _draw_triangle(self) -> None:
        """Draw triangle"""
        # ...

        # Get current camera
        camera = self._camera_3d if self._use_3d_camera else self._camera_2d

        # Set matrices to shader
        self._shader.set_mat4("model", self._transform.model)
        self._shader.set_mat4("view", camera.view_matrix)
        self._shader.set_mat4("projection", camera.projection_matrix)

3Dカメラの操作方法

3Dカメラには2つの操作方法があります。

オービット(Orbit)

視点(三角形のある原点)を中心に、カメラが球面上を移動します。

パラメータ 説明 範囲
Azimuth(方位角) 水平回転 -180° 〜 +180°
Elevation(仰角) 垂直回転 -89° 〜 +89°
Distance(距離) 視点からの距離 1 〜 20

次回のPhase 5c「マウスでカメラ操作」で、マウスドラッグでの回転操作のベースになります。

パン(Pan)

カメラと視点を同時に平行移動します。2Dカメラのオフセットと同様の動きです。

パラメータ 説明
Pan X 左右に移動
Pan Y 上下に移動
Pan Z 前後に移動

動作確認

プログラムを実行してみましょう。

source .venv/bin/activate && python -m src.main

CameraウィンドウでModeを切り替えると:

  • 2D (Orthographic): 遠近感なし、ズームで表示範囲を変更
  • 3D (Perspective): 遠近感あり、オービットとパンで操作

3Dモードでは: - OrbitセクションのAzimuthを動かすと、三角形の周りを水平に回転 - Elevationを動かすと、上下に回転 - Panセクションで平行移動

2Dモードの実行結果

2Dモードでは、Offset X/Y、Zoom、Rotationで操作できます。

2Dカメラモード
2Dカメラモード

3Dモードの実行結果

3Dモードでは、Orbit(球面座標)、Pan(平行移動)、Projection(FOV)で操作できます。

3Dカメラモード
3Dカメラモード

2Dと3Dの使い分け

用途 推奨モード 理由
2Dゲーム 正射影 距離に関係なく同じサイズ
UI/HUD 正射影 ピクセル単位で配置したい
3Dシーン 透視投影 リアルな遠近感
CAD/設計図 正射影 寸法が正確
アイソメトリック 正射影 斜め上から見下ろすスタイル

クラス構成のまとめ

CameraBase (抽象基底クラス)
│
├── Camera2D (正射影カメラ)
│   ├── position: (x, y) - 2D座標
│   ├── zoom: ズーム倍率
│   ├── rotation: 回転角度
│   └── set_position(x, y), set_rotation(angle)
│
└── Camera3D (透視投影カメラ)
    ├── position: (x, y, z) - 3D座標
    ├── target: 視点
    ├── fov: 視野角
    ├── set_orbit(azimuth, elevation, distance) - 球面座標
    ├── set_pan(x, y, z) - 平行移動
    └── set_distance(distance) - 距離変更

2Dカメラの操作方法

パラメータ 説明
Offset X/Y 左右・上下に移動
Zoom ズーム倍率(拡大・縮小)
Rotation 回転角度(0〜360度)

まとめ

今回は2D/3Dカメラを実装しました。

  • CameraBase 抽象基底クラスで共通インターフェースを定義
  • Camera2D は正射影で、オフセットとズームを提供
  • Camera3D は透視投影で、オービットとパンを提供
  • オービットは球面座標でカメラを配置(マウスドラッグ回転のベース)
  • パンは平行移動(2Dオフセットと同様の動き)

次回はマウスでカメラ操作を実装します。ドラッグでオービット、右ドラッグでパン、ホイールでズームなど、インタラクティブなカメラ操作を追加していきます。


前回: 第5回「座標変換の基礎」

次回: 第7回「マウスでカメラ操作」

【GitHub Copilotと作る Pythonで OpenGL 3Dプログラミング】 - 第5回「座標変換の基礎」

GitHub Copilotと作る PythonOpenGL 3Dプログラミング

第5回「座標変換の基礎」

はじめに

前回は、シェーダーを学び、虹色の三角形を描画しました。

今回は、座標変換の基礎を学びます。Model、View、Projection行列を使って、3D空間でオブジェクトを配置し、カメラから見た映像を画面に表示する仕組みを実装します。

座標系の理解

OpenGLでは、頂点が画面に表示されるまでに複数の座標系を経由します。

ローカル座標 → ワールド座標 → ビュー座標 → クリップ座標 → スクリーン座標
           Model行列    View行列    Projection行列
座標系 説明
ローカル座標 モデル固有の座標系(モデルの中心が原点)
ワールド座標 シーン全体の共通座標系
ビュー座標 カメラを原点とした座標系
クリップ座標 正規化デバイス座標(-1〜1の範囲)
スクリーン座標 最終的なピクセル座標

これらの変換は、3つの行列を掛け合わせることで実現します:

gl_Position = Projection × View × Model × 頂点座標

注意: 行列の乗算は右から左へ適用されます。

Model行列

Model行列は、オブジェクトをローカル座標からワールド座標に変換します。以下の3つの基本変換を組み合わせます:

変換 説明
平行移動 (Translation) オブジェクトを移動
回転 (Rotation) オブジェクトを回転
スケーリング (Scaling) オブジェクトの大きさを変更

例えば、Y軸周りに45度回転する行列は:

angle_rad = np.radians(45.0)
cos_a, sin_a = np.cos(angle_rad), np.sin(angle_rad)
rotation_y = np.array([
    [cos_a, 0, sin_a, 0],
    [0, 1, 0, 0],
    [-sin_a, 0, cos_a, 0],
    [0, 0, 0, 1]
], dtype=np.float32)

View行列(Look At行列)

View行列は、ワールド座標をカメラ座標に変換します。カメラの位置と向きを定義する3つのパラメータで構成されます:

パラメータ 説明
eye カメラの位置
target カメラが見ている点
up カメラの上方向ベクトル

Look At行列の計算:

def _look_at(self, eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray:
    # 前方向ベクトル(正規化)
    forward = target - eye
    forward = forward / np.linalg.norm(forward)

    # 右ベクトル
    right = np.cross(forward, up)
    right = right / np.linalg.norm(right)

    # 新しい上ベクトル
    up_new = np.cross(right, forward)

    # View行列を構築
    view = np.eye(4, dtype=np.float32)
    view[0, 0:3] = right
    view[1, 0:3] = up_new
    view[2, 0:3] = -forward
    view[0, 3] = -np.dot(right, eye)
    view[1, 3] = -np.dot(up_new, eye)
    view[2, 3] = np.dot(forward, eye)

    return view

Projection行列(透視投影)

Projection行列は、3D空間を2D画面に投影します。透視投影を使うと、遠くのものが小さく見えるリアルな描画ができます。

パラメータ 説明 今回の値
FOV 視野角(度数法) 45.0
aspect アスペクト比(幅/高さ) 800/600
near ニアクリップ面 0.1
far ファークリップ面 100.0
@staticmethod
def _perspective(fov_deg: float, aspect: float, near: float, far: float) -> np.ndarray:
    fov_rad = np.radians(fov_deg)
    f = 1.0 / np.tan(fov_rad / 2.0)

    projection = np.zeros((4, 4), dtype=np.float32)
    projection[0, 0] = f / aspect
    projection[1, 1] = f
    projection[2, 2] = (far + near) / (near - far)
    projection[2, 3] = (2 * far * near) / (near - far)
    projection[3, 2] = -1.0

    return projection

Transformクラス

座標変換を管理するTransformクラスを作成しました。

src/graphics/transform.py:

class Transform:
    """3D座標変換を管理するクラス"""

    def __init__(self) -> None:
        # Model行列
        self._model = np.eye(4, dtype=np.float32)

        # View行列(カメラ)
        self._camera_pos = np.array([0.0, 0.0, 3.0], dtype=np.float32)
        self._camera_target = np.array([0.0, 0.0, 0.0], dtype=np.float32)
        self._camera_up = np.array([0.0, 1.0, 0.0], dtype=np.float32)
        self._view = self._look_at(self._camera_pos, self._camera_target, self._camera_up)

        # Projection行列
        self._fov = 45.0
        self._aspect = 800.0 / 600.0
        self._near = 0.1
        self._far = 100.0
        self._projection = self._perspective(self._fov, self._aspect, self._near, self._far)

Model行列の操作メソッド:

def set_model_identity(self) -> None:
    """Model行列を単位行列にリセット"""
    self._model = np.eye(4, dtype=np.float32)

def rotate_model_x(self, angle_deg: float) -> None:
    """Model行列にX軸回転を適用"""
    # 回転行列を計算してself._modelに適用

def rotate_model_y(self, angle_deg: float) -> None:
    """Model行列にY軸回転を適用"""

def rotate_model_z(self, angle_deg: float) -> None:
    """Model行列にZ軸回転を適用"""

シェーダーの更新

頂点シェーダーにUniform行列を追加しました。

src/shaders/basic.vert:

#version 330 core

layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aColor;

// Uniform変数(行列)
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

out vec3 vertexColor;

void main()
{
    // 座標変換を適用
    gl_Position = projection * view * model * vec4(aPos, 1.0);
    vertexColor = aColor;
}

行列をシェーダーに渡す

行列をシェーダーに渡す際の重要なポイントがあります。numpyは行優先(row-major)でデータを格納しますが、OpenGL列優先(column-major)を期待します。

Shader.set_mat4メソッドでは、GL_TRUEを指定して転置を行います:

def set_mat4(self, name: str, matrix) -> None:
    """4x4行列のUniform変数を設定"""
    location = self._get_uniform_location(name)
    if location != -1:
        # GL_TRUE: numpyの行優先配列をOpenGLの列優先形式に転置
        gl.glUniformMatrix4fv(location, 1, gl.GL_TRUE, matrix)

描画時に行列を設定:

def _draw_triangle(self) -> None:
    # Model行列を更新
    self._transform.set_model_identity()
    self._transform.rotate_model_x(self._rotation_x)
    self._transform.rotate_model_y(self._rotation_y)
    self._transform.rotate_model_z(self._rotation_z)

    # シェーダーを使用
    self._shader.use()

    # 行列をシェーダーに設定
    self._shader.set_mat4("model", self._transform.model)
    self._shader.set_mat4("view", self._transform.view)
    self._shader.set_mat4("projection", self._transform.projection)

    # 描画
    gl.glBindVertexArray(self._vao)
    gl.glDrawArrays(gl.GL_TRIANGLES, 0, 3)
    gl.glBindVertexArray(0)

imguiでパラメータ調整

カメラと回転のパラメータをimguiで調整できるようにしました。

Cameraウィンドウ:

def _draw_camera_window(self) -> None:
    imgui.begin("Camera")

    # カメラ位置
    cam_pos = self._transform.camera_pos
    changed_x, cam_x = imgui.slider_float("Camera X##pos", cam_pos[0], -10.0, 10.0)
    changed_y, cam_y = imgui.slider_float("Camera Y##pos", cam_pos[1], -10.0, 10.0)
    changed_z, cam_z = imgui.slider_float("Camera Z##pos", cam_pos[2], -10.0, 10.0)

    if changed_x or changed_y or changed_z:
        self._transform.set_camera_position(cam_x, cam_y, cam_z)

    # 視野角
    changed_fov, fov = imgui.slider_float("FOV", self._transform.fov, 15.0, 120.0)
    if changed_fov:
        self._transform.set_fov(fov)

    imgui.end()

Transformウィンドウ:

def _draw_transform_window(self) -> None:
    imgui.begin("Transform")

    # Model行列の回転
    changed_x, self._rotation_x = imgui.slider_float("Rotate X", self._rotation_x, 0.0, 360.0)
    changed_y, self._rotation_y = imgui.slider_float("Rotate Y", self._rotation_y, 0.0, 360.0)
    changed_z, self._rotation_z = imgui.slider_float("Rotate Z", self._rotation_z, 0.0, 360.0)

    if imgui.button("Reset Rotation"):
        self._rotation_x = 0.0
        self._rotation_y = 0.0
        self._rotation_z = 0.0

    imgui.end()

ディレクトリ構成

Phase 5で追加・変更したファイル:

src/
├── main.py
├── core/
│   ├── __init__.py
│   ├── app.py          # 変更: Transform統合、Camera/Transform UI追加
│   ├── gui.py
│   └── window.py
├── graphics/
│   ├── __init__.py     # 変更: Transform追加
│   ├── shader.py       # 変更: set_mat4の転置フラグ修正
│   └── transform.py    # 新規: Transformクラス
├── shaders/
│   ├── basic.vert      # 変更: Uniform行列追加
│   └── basic.frag
└── utils/
    ├── __init__.py
    └── logger.py

動作確認

コードを実行してみましょう:

source .venv/bin/activate && python -m src.main

三角形が表示され、imguiのスライダーで回転やカメラ位置を調整できます!

座標変換の基礎
座標変換の基礎

  • Camera X/Y/Z: カメラ位置を移動
  • Target X/Y/Z: カメラの注視点を変更
  • FOV: 視野角を広げたり狭めたり
  • Rotate X/Y/Z: 三角形を回転

よくあるエラーと対処法

三角形が表示されない

  1. 行列の転置忘れ: glUniformMatrix4fvの第3引数がGL_TRUEになっているか確認
  2. カメラ位置が近すぎる/遠すぎる: Camera Zを調整(デフォルト3.0)
  3. Uniform変数名の不一致: シェーダー内の変数名とset_mat4の引数が一致しているか確認

描画がおかしい

  1. 行列の乗算順序: projection * view * modelの順序を確認
  2. near/farクリップ面: オブジェクトがクリップ面の範囲外にないか確認

まとめ

今回は、座標変換の基礎を学びました。

学んだこと:

  • 座標系の流れ: ローカル → ワールド → ビュー → クリップ → スクリーン
  • Model行列: オブジェクトの位置・回転・スケールを制御
  • View行列: カメラの位置と向きを定義(Look At行列)
  • Projection行列: 3Dから2Dへの透視投影
  • 行列の転置: numpyとOpenGLのメモリレイアウトの違い
  • imguiでのリアルタイム調整: パラメータをインタラクティブに変更

次回は、2D/3Dカメラの切り替え機能を実装します。


前回: 第4回「シェーダー入門 - 虹色の三角形を描く」

次回: 第6回「2D/3Dカメラの実装」

【GitHub Copilotと作る Pythonで OpenGL 3Dプログラミング】 - 第4回「シェーダー入門 - 虹色の三角形を描く」

GitHub Copilotと作る PythonOpenGL 3Dプログラミング

第4回「シェーダー入門 - 虹色の三角形を描く」

はじめに

前回は、imguiを組み込んでデバッグUIを追加しました。

今回は、いよいよシェーダーを学び、虹色の三角形を描画します。シェーダーはGPU上で動作するプログラムで、現代のOpenGLでは必須の技術です。

コード構成のリファクタリング

シェーダーの説明に入る前に、今回からコードの見通しを良くするためリファクタリングを行いました。

変更点:

  • src/main.py の肥大化を防ぐため、App/Window/GUI クラスに分割
  • src/utils/logger.py にカスタムロガーを導入(色付き出力、独自レベル対応)
  • OpenGLのインポートを import OpenGL.GL as gl 形式に統一

機能的には前回と変わりませんので、各クラスの詳細説明は割愛します。リファクタリング後のディレクトリ構成は記事末尾をご覧ください。

シェーダーとは

シェーダー(Shader) は、GPU上で実行される小さなプログラムです。OpenGLのグラフィックスパイプラインにおいて、頂点の処理やピクセルの色計算を担当します。

主なシェーダーの種類:

シェーダー 役割
頂点シェーダー 各頂点の位置と属性を処理
フラグメントシェーダー ピクセルの最終的な色を決定
ジオメトリシェーダー 頂点から新しいジオメトリを生成(オプション)
テッセレーションシェーダー 曲面の細分化(オプション)

今回は必須の頂点シェーダーフラグメントシェーダーを使います。

グラフィックスパイプライン

シェーダーがどこで動作するか理解するため、グラフィックスパイプラインを見てみましょう:

頂点データ → [頂点シェーダー] → プリミティブ組立 → ラスタライズ → [フラグメントシェーダー] → 画面表示
  1. 頂点データ: 三角形の3頂点の座標と色
  2. 頂点シェーダー: 各頂点の座標変換(今回は変換なし)
  3. プリミティブ組立: 頂点を三角形として組み立て
  4. ラスタライズ: 三角形内部のピクセルを決定
  5. フラグメントシェーダー: 各ピクセルの色を計算(頂点間で補間)
  6. 画面表示: 最終的なピクセルフレームバッファに書き込み

GLSL(シェーダー言語)

シェーダーはGLSL(OpenGL Shading Language) で記述します。C言語に似た文法で、ベクトルや行列の操作が組み込まれています。

基本的なデータ型:

説明
float 浮動小数 1.0
vec2 2次元ベクトル vec2(1.0, 2.0)
vec3 3次元ベクトル vec3(1.0, 0.0, 0.0)
vec4 4次元ベクトル vec4(1.0, 1.0, 1.0, 1.0)
mat4 4x4行列 座標変換に使用

頂点シェーダー

頂点シェーダーは各頂点に対して実行されます。頂点の位置を変換し、フラグメントシェーダーに渡すデータを準備します。

src/shaders/basic.vert:

#version 330 core

// 頂点属性(入力)
layout (location = 0) in vec3 aPos;      // 頂点座標
layout (location = 1) in vec3 aColor;    // 頂点カラー

// フラグメントシェーダーへの出力
out vec3 vertexColor;

void main()
{
    // 頂点座標をクリップ座標に変換
    // 今回は変換なし(NDC座標をそのまま使用)
    gl_Position = vec4(aPos, 1.0);

    // 頂点カラーをフラグメントシェーダーに渡す
    vertexColor = aColor;
}

ポイント:

  • #version 330 core: OpenGL 3.3 Core Profileを使用
  • layout (location = 0): 頂点属性のインデックス(後でglVertexAttribPointerで指定)
  • in: 入力変数(頂点データから受け取る)
  • out: 出力変数(フラグメントシェーダーに渡す)
  • gl_Position: 組み込み出力変数(頂点の最終位置)

フラグメントシェーダー

フラグメントシェーダーは各ピクセルに対して実行されます。頂点シェーダーからの出力を受け取り、最終的な色を決定します。

src/shaders/basic.frag:

#version 330 core

// 頂点シェーダーからの入力
in vec3 vertexColor;

// 最終的なピクセルカラー(出力)
out vec4 FragColor;

void main()
{
    // 頂点カラーをそのまま出力(アルファは1.0で不透明)
    FragColor = vec4(vertexColor, 1.0);
}

ポイント:

  • in vec3 vertexColor: 頂点シェーダーから補間された色を受け取る
  • out vec4 FragColor: 最終的なピクセル色(RGBA)

重要なのは、vertexColor自動的に補間されることです。三角形の各頂点に異なる色を設定すると、内部のピクセルはグラデーションになります。

NDC座標系

今回は座標変換なしで描画するため、NDC(Normalized Device Coordinates) を使用します。

NDC座標系では: - X軸: -1.0(左端)〜 1.0(右端) - Y軸: -1.0(下端)〜 1.0(上端) - Z軸: -1.0(手前)〜 1.0(奥)

      (0, 1)
        ↑
        │
(-1, 0) ┼───→ (1, 0)
        │
        ↓
      (0, -1)

Shaderクラス

シェーダーの管理を行うクラスを作成します。

src/graphics/shader.py:

"""
シェーダー管理クラス
"""
from pathlib import Path
import OpenGL.GL as gl

from src.utils import logger


class ShaderCompileError(Exception):
    """シェーダーのコンパイルエラー"""
    pass


class ShaderLinkError(Exception):
    """シェーダープログラムのリンクエラー"""
    pass


class Shader:
    """シェーダープログラムを管理するクラス"""

    def __init__(self, vertex_path: str | Path, fragment_path: str | Path) -> None:
        """
        シェーダーファイルを読み込み、プログラムを作成する

        Args:
            vertex_path: 頂点シェーダーファイルのパス
            fragment_path: フラグメントシェーダーファイルのパス
        """
        self._program_id: int = 0

        # シェーダーソースの読み込み
        vertex_source = self._load_shader_source(vertex_path)
        fragment_source = self._load_shader_source(fragment_path)

        # シェーダーのコンパイル
        vertex_shader = self._compile_shader(vertex_source, gl.GL_VERTEX_SHADER, "vertex")
        fragment_shader = self._compile_shader(fragment_source, gl.GL_FRAGMENT_SHADER, "fragment")

        # シェーダープログラムのリンク
        self._program_id = self._link_program(vertex_shader, fragment_shader)

        # コンパイル済みシェーダーの削除(プログラムにリンク済みなので不要)
        gl.glDeleteShader(vertex_shader)
        gl.glDeleteShader(fragment_shader)

        logger.info(f"Shader program created: {Path(vertex_path).name}, {Path(fragment_path).name}")

    def _load_shader_source(self, path: str | Path) -> str:
        """シェーダーファイルを読み込む"""
        path = Path(path)
        if not path.exists():
            raise FileNotFoundError(f"Shader file not found: {path}")

        with open(path, 'r', encoding='utf-8') as f:
            return f.read()

    def _compile_shader(self, source: str, shader_type: int, type_name: str) -> int:
        """シェーダーをコンパイルする"""
        shader = gl.glCreateShader(shader_type)
        gl.glShaderSource(shader, source)
        gl.glCompileShader(shader)

        # コンパイル結果の確認
        success = gl.glGetShaderiv(shader, gl.GL_COMPILE_STATUS)
        if success != gl.GL_TRUE:
            info_log = gl.glGetShaderInfoLog(shader).decode('utf-8')
            gl.glDeleteShader(shader)
            raise ShaderCompileError(f"{type_name} shader compile error:\n{info_log}")

        return shader

    def _link_program(self, vertex_shader: int, fragment_shader: int) -> int:
        """シェーダープログラムをリンクする"""
        program = gl.glCreateProgram()
        gl.glAttachShader(program, vertex_shader)
        gl.glAttachShader(program, fragment_shader)
        gl.glLinkProgram(program)

        # リンク結果の確認
        success = gl.glGetProgramiv(program, gl.GL_LINK_STATUS)
        if success != gl.GL_TRUE:
            info_log = gl.glGetProgramInfoLog(program).decode('utf-8')
            gl.glDeleteProgram(program)
            raise ShaderLinkError(f"Shader program link error:\n{info_log}")

        return program

    def use(self) -> None:
        """このシェーダープログラムを使用する"""
        gl.glUseProgram(self._program_id)

    def delete(self) -> None:
        """シェーダープログラムを削除する"""
        if self._program_id:
            gl.glDeleteProgram(self._program_id)
            self._program_id = 0

VBO/VAOの基礎

頂点データをGPUに送るには、VBO(Vertex Buffer Object)VAO(Vertex Array Object) を使います。

  • VBO: 頂点データを格納するバッファ
  • VAO: 頂点属性の設定を記憶するオブジェクト
def _setup_geometry(self) -> None:
    """三角形のジオメトリをセットアップする"""
    # 頂点データ(位置 x, y, z, 色 r, g, b)
    vertices = np.array([
        # 位置              # 色
        -0.5, -0.5, 0.0,   1.0, 0.0, 0.0,  # 左下: 赤
         0.5, -0.5, 0.0,   0.0, 1.0, 0.0,  # 右下: 緑
         0.0,  0.5, 0.0,   0.0, 0.0, 1.0,  # 上: 青
    ], dtype=np.float32)

    # VAO(頂点配列オブジェクト)の作成
    self._vao = gl.glGenVertexArrays(1)
    gl.glBindVertexArray(self._vao)

    # VBO(頂点バッファオブジェクト)の作成
    self._vbo = gl.glGenBuffers(1)
    gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._vbo)
    gl.glBufferData(gl.GL_ARRAY_BUFFER, vertices.nbytes, vertices, gl.GL_STATIC_DRAW)

    # 頂点属性の設定
    stride = 6 * vertices.itemsize  # 1頂点あたり6つのfloat

    # 属性0: 位置(location = 0)
    gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, None)
    gl.glEnableVertexAttribArray(0)

    # 属性1: 色(location = 1)
    offset = 3 * vertices.itemsize
    gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, ctypes.c_void_p(offset))
    gl.glEnableVertexAttribArray(1)

    # バインド解除
    gl.glBindBuffer(gl.GL_ARRAY_BUFFER, 0)
    gl.glBindVertexArray(0)

頂点データの構造:

頂点0: [x, y, z, r, g, b]  ← 6つのfloat
頂点1: [x, y, z, r, g, b]
頂点2: [x, y, z, r, g, b]

strideは次の頂点までのバイト数、offsetは属性データの開始位置です。

三角形の描画

描画は非常にシンプルです:

def _draw_triangle(self) -> None:
    """三角形を描画する"""
    self._shader.use()              # シェーダーをアクティブに
    gl.glBindVertexArray(self._vao) # VAOをバインド
    gl.glDrawArrays(gl.GL_TRIANGLES, 0, 3)  # 3頂点の三角形を描画
    gl.glBindVertexArray(0)         # VAOのバインド解除

ディレクトリ構成

Phase 4で以下のファイルを追加しました:

src/
├── main.py
├── core/
│   ├── __init__.py
│   ├── app.py          # シェーダー・ジオメトリ追加
│   ├── gui.py
│   └── window.py
├── graphics/
│   ├── __init__.py     # 新規
│   └── shader.py       # 新規: Shaderクラス
├── shaders/
│   ├── basic.vert      # 新規: 頂点シェーダー
│   └── basic.frag      # 新規: フラグメントシェーダー
└── utils/
    ├── __init__.py
    └── logger.py

動作確認

コードを実行してみましょう:

# 仮想環境を有効化
source .venv/bin/activate  # macOS/Linux

# 実行
python -m src.main

虹色にグラデーションした三角形が表示されます!

虹色の三角形
虹色の三角形

各頂点に赤・緑・青を設定したため、フラグメントシェーダーで補間され、美しいグラデーションになっています。

よくあるエラーと対処法

シェーダーコンパイルエラー

ShaderCompileError: vertex shader compile error:
0:1(1): error: syntax error, unexpected ...

→ GLSL文法エラー。行番号を確認してシェーダーファイルを修正。

シェーダーリンクエラー

ShaderLinkError: Shader program link error:
error: undefined variable ...

→ 頂点シェーダーのoutとフラグメントシェーダーのinの変数名が一致しているか確認。

三角形が表示されない

  1. 座標が範囲外: NDC座標は-1.0〜1.0。範囲外だと見えない。
  2. シェーダーが使用されていない: shader.use()を呼んでいるか確認。
  3. VAOがバインドされていない: glBindVertexArray(vao)を確認。

まとめ

今回は、シェーダーを使って虹色の三角形を描画しました。

学んだこと:

  • シェーダー: GPU上で動作するプログラム
  • 頂点シェーダー: 頂点の位置と属性を処理
  • フラグメントシェーダー: ピクセルの色を決定
  • GLSL: シェーダー言語の基礎
  • VBO/VAO: 頂点データのGPU転送と管理
  • 頂点属性: 位置と色のデータレイアウト

シェーダーはOpenGLの核心です。今後のフェーズで座標変換、ライティング、テクスチャなど、より高度なシェーダー技術を学んでいきます。

次回は、カメラと座標変換を学び、3D空間での描画に挑戦します。


前回: 第3回「imguiを組み込む」

次回: 第5回「座標変換の基礎」