見出し画像

PLATEAU AWARD 2024出展作品 PLATAU Urban Equalizer

都市データを活用したアプリケーション開発に興味はありませんか?

本記事は、PLATEAU AWARD 2024への出展作品「PLATEAU Urban Equalizer」の作成方法を無料で公開するものです。このアプリは、3D都市モデル「PLATEAU」データと音楽を融合し、都市空間を音楽に合わせてダイナミックに変化させるビジュアライゼーションを提案します。

この記事のコードは、以下のGitHubレポジトリで公開しています。記事内容と合わせて、ご参照ください。
https://github.com/creativival/plateau-urban-equalizer


記事概要

本記事では、プログラミング初心者の方でも取り組めるよう、PythonとPanda3Dライブラリを用いて、自分だけの3D都市を一から作成する方法を解説します。

国土交通省が公開している3D都市モデル「PLATEAU」のデータを活用しつつ、PLATEAU専用のパッケージやライブラリを使用せず、可能な限り自力で開発を進めていきます。これにより、各機能の内部構造を深く理解し、自由に機能を追加・カスタマイズすることが可能になります。

具体的には、音楽に合わせてビルが上下に動くサウンドエコライザー機能を実装します。また、画像から色を取得してビルの色に反映させることで、音楽にマッチしたビジュアルエフェクトも楽しめます。これらの機能を通じて、都市データとエンターテインメントを融合させた新しい表現方法を探求していきます。

利用シーン

『PLATEAU Urban Equalizer』は、3D都市データと音楽解析技術を活用した多機能なアプリケーションです。その応用範囲は幅広く、以下のような利用シーンが想定されています:

1. イベントや映像制作

  • まちづくりイベントでの上映
    音楽と連動した動きで、観客を魅了する都市エフェクトを提供します。

  • ミュージックビデオやプロモーション映像の背景
    ビル群が音楽に合わせて踊るビジュアルは、映像作品に動的な魅力をプラスします。

2. 教育と学習

  • 工学部や情報系学生向けのプログラミング教材
    3Dレンダリングや音楽解析を通じて、実践的なプログラミングスキルを学ぶことができます。

  • 初心者向けの学習サポート
    詳細なコード解説により、プログラミング初心者でも気軽に学べます。

3. アプリケーション開発のベース

  • 3D都市アプリケーションのベースとして
    エコライザー機能を分離して、ゲーム、エンターテーメント、観光イベント、都市シミュレーション、研究用途などに応用可能です。

  • インタラクティブな都市体験アプリの構築
    カスタマイズ可能なコードを活用し、新しい都市体験アプリケーションを開発できます。

この記事の内容を応用すれば、3D都市データを使ったゲームやビジュアライゼーション、研究開発など、さまざまな表現が可能になります。都市データの可能性を一緒に広げていきましょう。

この記事は、PLATEAUデータを使った3Dアプリケーションの解説記事です。あくまでプログラミング技術の解説を主目的としており、地図関連の説明について正確性が欠ける点があるかもしれません。その点はご了承いただき、続きの記事をお読みください。また、ご指摘はコメントにて受け付けており、記事の誤りがあれば随時修正してまいります。

1. 環境構築

3D都市を作成するためには、必要なソフトウェアをインストールし、開発環境を整える必要があります。本章では、PythonとPanda3Dを中心に、開発に必要な環境の構築方法を解説します。

本稿は、M2 Mac Studio (macOS Sequoia 15.1) 環境で執筆しています。Windows 11 環境での動作も確認しておりますが、すべての環境での動作を保証するものではございませんのでご了承ください。

1.1 必要なソフトウェアのインストール

Pythonのインストール

まず、Pythonがインストールされているか確認します。ターミナル(コマンドプロンプト)を開き、以下のコマンドを入力してください。

python --version

または

python3 --version

Python 3.9以上のバージョンが表示されれば問題ありません。インストールされていない場合やバージョンが古い場合は、Python公式サイトから最新のPython 3.xをダウンロードしてインストールしてください。

Panda3Dのインストール

Panda3Dは、Pythonで使用できるオープンソースの3Dエンジンです。以下のコマンドでPanda3Dをインストールします。

pip install panda3d

または

pip3 install panda3d

注意:pipが使えない場合は、Pythonのインストール時に「Add Python to PATH」を選択していない可能性があります。その場合は、Pythonを再インストールするか、pipを環境変数に追加してください。

この後の説明では、python, pipコマンドのみ示すものとします。

1.2 開発環境の設定

IDEの選択と設定

開発を効率的に進めるために、以下のような統合開発環境(IDE)やテキストエディタを使用することをお勧めします。

  • Visual Studio Code

    1. 軽量で拡張性が高く、拡張機能も豊富で、無料IDEの決定版です。Pythonのコーディングを行うときは、Python関連の拡張機能をインストールする必要があります。

  • PyCharm

    1. Python専用の強力なIDEで、デバッグ機能やコード補完が優れています。拡張機能をインストールすることなく、Pythonのコーディングを開始できます。Communityエディションは無料で利用可能です。

任意のIDEをインストールし、Pythonの開発環境を設定してください。

プロジェクトフォルダの作成

作業を整理するために、プロジェクト専用のディレクトリを作成します。このディレクトリはわかりやすい任意のパスに作成しますが、ここでは「ドキュメント」直下にプロジェクトを作成する手順を示します。

cd ~/Documents
mkdir plateau-urban-equalizer
cd plateau-urban-equalizer

ディレクトリ構造の作成

プロジェクトフォルダー内に、以下のようなディレクトリ構造を作ります。

plateau-urban-equalizer/
 ├── city/
 │ └── __init__.py
 ├── images/
 ├── sound/
 └── main.py
  • cityフォルダーはパッケージと呼ばれ、機能別のファイルをまとめたフォルダーです。

    • __init__.pyは空の(何も記述していない)ファイルで、cityフォルダーがパッケージであることをPythonに認識させるためのファイルです。

  • imagesフォルダーは、画像ファイルを保存するために使用します。

  • soundフォルダーは、音楽ファイルを保存するために使用します。

  • main.pyは、アプリを起動する実行ファイルです。

これで開発に必要な環境が整いました。次章では、PLATEAUデータの取得とその構造について解説します。

2. PLATEAUデータの取得と理解

3D都市モデルを作成するためには、国土交通省が提供する「PLATEAU」データを利用します。本章では、PLATEAUデータの概要と取得方法、データ構造について解説します。また、データ処理を簡略化するために使用する「plateau-lod2-mvt」データセットについても紹介します。

2.1 PLATEAUとは

PLATEAU(プラトー)は、国土交通省が推進するプロジェクトで、日本全国の3D都市モデルを整備・公開しています。これらのデータは、都市計画や防災、観光など、さまざまな分野での活用が期待されています。

  • 公式サイトPLATEAU 公式ウェブサイト

  • データ形式:CityGML、MVT、3D Tilesなど

  • 詳細レベル:LoD1からLoD4まで(LoD:Level of Detail)

LoD(Level of Detail)の説明

LoD(Level of Detail)は、3Dモデルの詳細度を示す指標で、数字が大きくなるほど詳細なモデルを表します。

  • LoD1:建物を単純なブロック形状で表現したもの。建物の基底面形状と高さのみを示します。

  • LoD2:LoD1に加えて、屋根形状や簡単な外壁の凹凸など、建物の主要な形状が含まれます。建物の外観を大まかに把握できます。

  • LoD3:LoD2に詳細な外装(窓やドアなど)が追加されたモデル。建物の外観を詳細に再現します。

  • LoD4:LoD3に内部構造(部屋の配置や内装)を含めた最も詳細なモデル。

本プロジェクトで使用するのは、LoD2のデータです。LoD2は、建物の基本的な形状と屋根の形状を持ち、都市全体のビジュアライゼーションに適しています。データ量と詳細度のバランスが良く、リアルタイムレンダリングやインタラクティブなアプリケーションでの使用に向いています。

2.2 データの取得方法

PLATEAUデータは、以下の方法で取得できます。

PLATEAU公式サイトからのダウンロード

PLATEAU公式サイトでは、日本全国の都市データをエリア別にダウンロードできます。

  1. 公式サイトにアクセスPLATEAU データダウンロードページ

  2. エリアを選択:地図またはリストから必要なエリアを選びます。

  3. データ形式を選択:CityGML、MVT、3D Tilesなどの形式を選びます。

  4. データをダウンロード:選択したデータをダウンロードします。

「plateau-lod2-mvt」の利用

本プロジェクトでは、データ処理を簡略化するために、GitHubで公開されている「plateau-lod2-mvt」データセットを利用します。

注意:このデータセットは、CC-BY-4.0ライセンスで提供されています。

CC BY 4.0
https://creativecommons.org/licenses/by/4.0/deed.ja

plateau-lod2-mvtとは

  • 概要:plateau-lod2-mvtは、PLATEAUのLoD2データを簡易に扱えるように加工したデータセットです。

  • データソース3D都市モデル(Project PLATEAU)東京都23区(CityGML 2020年度)で公開されているCityGMLデータ

  • データ形式:Mapbox Vector Tile(MVT)形式で提供され、軽量で高速なデータ読み込みが可能です。

  • 利点:オリジナルのPLATEAUデータに比べ、データサイズが小さく、処理が容易です。

plateau-lod2-mvtからデータをダウンロード

図1 GitHubレポジトリからファイルをダウンロード

plateau-lod2-mvtのリポジトリにアクセスします。右上のの「Code」をクリックして、「Download ZIP」を選びます。ダウンロードしたファイルを解凍して、その中の「14」「15」「16」の3つのフォルダーをプロジェクトルートにコピーしてください。コピー後のディレクトリ構造は次のようになっているはずです。

plateau-urban-equalizer/
 ├── 14/
 ├── 15/
 ├── 16/
 ├── city/
 │ └── __init__.py
 ├── images/
 ├── sound/
 └── main.py

以上で、3D都市データの準備ができました。次は、MVTデータについて詳しく見ていきます。

2.3 データ構造の理解

MVTデータの取り扱い

plateau-lod2-mvtデータセットは、Mapbox Vector Tile(MVT)形式で提供されます。PythonでMVTファイルを扱うために、mapbox-vector-tileパッケージを使用して、MVTファイルを辞書形式に変換します。

mapbox-vector-tileパッケージのインストール

pip install mapbox-vector-tile

MVTファイルの読み込みと辞書形式への変換

MVTファイルから辞書形式のデータを取り出すPythonコードを示します。このコードを実行すると、ターミナルにextentなどの値が表示されます。

from mapbox_vector_tile import decode

with open('path/to/your/tile.pbf', 'rb') as f:
    tile_data = f.read() 

tile_dict = decode(tile_data)

print(f"tile extent: {tile_dict['bldg']['extent']}")
print(f"tile version: {tile_dict['bldg']['version']}")
print(f"tile feature sample: {tile_dict['bldg']['features'][0]}")
print(f"tile type: {tile_dict['bldg']['type']}")

次は、取り出された辞書の構造を詳しく見てみましょう。この構造を理解することで、3Dビルディングを作成するための元データを取得することができるようになります。

データ構造の例とキーの説明

mapbox-vector-tileパッケージを使用してMVTファイルをデコードすると、以下のような辞書形式のデータが得られます。

{
  "bldg": {
    "extent": 4096,
    "version": 2,
    "features": [
      {
        "geometry": {
          "type": "Polygon",
          "coordinates": [
            [
              [-80, 2597], [-28, 2593], [-28, 2586], [-80, 2590], [-80, 2597]
             ]
          ]
        },
        "properties": {
          "z": 23.294
        },
        "id": 497674,
        "type": "Feature"
      }
    ],
    "type": "FeatureCollection"
  }
}

各キーと値の説明

  • トップレベルキー("bldg")

    • 説明:レイヤー名。ここでは建物を表す"bldg"レイヤーです。

  • "extent"

    • 説明:タイル座標系の範囲を表す数値。通常は4096で、座標値が0から4096の範囲であることを示します。

  • "version"

    • 説明:MVTのバージョン。一般的に2が使用されます。

  • "features"

    • 説明:フィーチャー(地物)のリストであり、複数の建物情報を含む。各フィーチャーが個々の建物を表します。

  • 各"feature"の内容

    • "geometry"

      • 説明:ジオメトリ情報。建物の形状を定義します。

      • "type"

        • 説明:ジオメトリのタイプ。建物の場合、"Polygon"が使用されます。

      • "coordinates"

        • 説明:座標のリスト。多角形の頂点座標を定義します。この座標が建物を地面に投影した時の頂点を表します。

    • "properties"

      • 説明:フィーチャーの属性情報。建物の高さなどを含みます。

      • "z"

        • 説明:建物の高さを示す数値(メートル単位)。

    • "id"

      • 説明:フィーチャーの一意の識別子。

    • "type"

      • 説明:フィーチャーの種類。常に"Feature"です。

  • "type"("FeatureCollection")

    • 説明:このレイヤーがフィーチャーのコレクションであることを示します。

次に、3Dモデリングに必須の知識である座標系を説明します。

座標系について

図2 三次元直交座標系(Panda3D)

MVTデータ内の座標は、タイル内座標系で表されています。タイル内座標系は、各タイル内での相対的な座標で、範囲は0から"extent"(通常4096)までです。この座標系は、Panda3Dで使用する座標系(図2)と比較すると座標の向きが同じです。

データ処理の指針

データを適切に処理し、3Dモデルとして表示するために、以下の手順を踏みます。

  1. MVTファイルのデコード

    • mapbox-vector-tileパッケージを使用して、MVTファイルを辞書形式にデコードします。

  2. 座標の変換

    • タイル座標系の座標を、実際の3D空間上の座標に変換します。

  3. ジオメトリの構築

    • 各フィーチャーの座標データから、多角形の平面を作成します。これがビルの基底面になります。

  4. 建物の属性情報の活用

    • "properties"に含まれる高さ("z")を使用して、ビルの高さを設定します。この高さ情報により、ビルの側面が作成できます。

  5. データの最適化

    • 大量の建物データを効率的に処理するために、データの軽量化や描画の最適化を行います。

次は、3D都市アプリで特定の場所(例えば、東京駅)を表示するために必要な知識であるズームレベルについて説明します。

2.4 ズームレベルについて

ズームレベルとは

ズームレベルとは、地図の拡大・縮小の度合いを示す指標で、数値が大きくなるほど詳細な情報が表示されます。タイル座標は、地図を細かい正方形(タイル)に分割して管理するための座標系です。

図3 ズームレベル2の地図

ズームレベル(Zoom Level)は、地図タイルを管理する際の尺度で、地図の拡大・縮小の度合いを整数値で表します(▲図3▲)。ズームレベルが大きくなるほど詳細な地図が表示され、タイルの数も増加します。ウェブ地図サービス(例:Googleマップ、OpenStreetMap)で一般的に使用される概念です。

  • ズームレベル0:地球全体を1枚のタイルで表現

  • ズームレベル1:2×2の計4枚のタイルに分割

  • ズームレベル2:4×4の計16枚のタイルに分割

  • 以下同様に、ズームレベルが1上がるごとに、タイル数は縦横それぞれ2倍に増加

ズームレベル Z では、地球全体が $${2^Z × 2^Z}$$ 枚のタイルに分割されます。

タイル座標との関係

MVT(Mapbox Vector Tile)形式のデータでは、地図がタイルに分割され、それぞれのタイルに地物(建物など)のデータが格納されています。各タイルは以下の情報で特定されます。

  • ズームレベル(Z):地図の詳細度を示す整数値

  • タイル座標(X, Y):タイルの位置を示す整数値

タイル座標は、左上を原点(0,0)として、右方向(東方向)に X、上方向(北方向)に Y が増加します。

座標変換におけるズームレベルの役割

タイル座標系の相対的な座標(タイル内での位置)を実際の地理座標(緯度・経度)や3D空間の座標に変換する際、ズームレベルは重要なパラメータとなります。

  • タイル内座標からピクセル座標への変換

    1. タイル内の座標は、通常0から extent(一般的に4096)までの値を取ります。タイル内座標の原点 (0, 0) は左下であり、右上が (4096, 4096) の値を取ります。

ズームレベルの取得方法

plateau-lod2-mvt データセットでは、各MVTファイルが特定のズームレベルとタイル座標に対応しています。通常、ファイルのパスや名前にズームレベルが含まれています。

ファイルパスの例

/{ズームレベル}/{タイルX座標}/{タイルY座標}.mvt

ズームレベルは、使用するデータの解像度と直接関係します。高いズームレベル(例:ズームレベル14以上)では、より詳細な地物情報が含まれますが、扱うタイルの数も増加します。

実装上の注意点

  • 座標変換の精度

    1. ズームレベルを正しく使用しないと、座標変換で誤差が生じ、建物の位置がずれてしまいます。タイル座標とズームレベルを用いた正確な計算が必要です。

    2. Z方向のスケーリング率は、目的地の緯度とズームレベルによって異なります。詳しい計算は省きますが、日本(北緯35度)のズームレベルとZ方向のスケーリング率は次のようになります。

      1. ズームレベル14のZ方向のスケーリング率: 約2.695

      2. ズームレベル15のZ方向のスケーリング率: 約5.390(ズームレベル14の約2倍)

      3. ズームレベル16のZ方向のスケーリング率: 約10.780(ズームレベル14の約4倍)

  • パフォーマンスへの影響

    1. 高いズームレベルではタイル数が増加し、データ読み込みや処理に時間がかかる場合があります。必要な詳細度に応じて、適切なズームレベルを選択してください。

  • データの一貫性

    1. 異なるズームレベルやタイル座標からデータを取得する場合、同一の座標系に統一するための変換処理が必要です。

  • タイル境界の考慮

    1. 建物がタイルの境界をまたぐ場合、複数のタイルにデータが分割されていることがあります。これを適切に処理するためには、タイル間のデータ統合が必要になる場合があります。


これで、plateau-lod2-mvtデータセットの取得方法とデータ構造の理解ができました。

次章では、Panda3Dの基礎について学び、実際に3Dモデルを表示するための準備を進めます。

3. Panda3Dの基礎: 3Dビルディングを表示

Panda3Dは、Pythonで動作するオープンソースの3Dゲームエンジンであり、3Dアプリケーションやゲームの開発に適しています。本章では、Panda3Dの基本的な使い方を学び、前章で紹介した練習プログラムを使って、実際にビルを一つ作成する方法を解説します。

3.1 Panda3Dのインストール

まずは、Panda3Dをシステムにインストールします。Panda3DはPythonパッケージとして提供されているため、pipを使用して簡単にインストールできます。

インストール手順

pip install panda3d

このコマンドを実行すると、Panda3Dがインストールされます。

3.2 Panda3Dの基本構造

Panda3Dでアプリケーションを作成する際の基本的な構造は以下の通りです。

  1. ShowBaseの継承:ShowBaseクラスを継承したクラスを作成します。ShowBaseはPanda3Dのウィンドウ管理やイベントループなど、画面を作成するための基本的な機能を提供します。

    • ウィンドウ管理:アプリケーションのウィンドウを生成し、描画のための準備を行います。

    • イベントループ:アプリケーションが終了するまでの間、継続的に入力や描画の更新を処理します。イベントループはフレームごとに更新され、アニメーションやユーザーの入力を可能にします。

  2. 初期化メソッドの定義:__init__メソッド内で、モデルの読み込みやシーンの設定を行います。

  3. イベントループの開始:app.run()を呼び出して、アプリケーションのイベントループを開始します。これにより、ウィンドウが表示され、アプリケーションが動作を開始します。

イベントループの詳細

  • イベントループとは:アプリケーションが動作している間、継続的に処理を行うためのループです。ゲームやインタラクティブなアプリケーションでは、フレームごとに画面の更新や入力の処理を行う必要があります。

  • Panda3Dにおけるイベントループの役割

    • 入力の処理:キーボードやマウスなど、ユーザーからの入力を受け取り、適切な反応をします。

    • 更新処理:キャラクターの動きやアニメーション、物理演算など、フレームごとに必要な計算を行います。

    • 描画の更新:シーン内のオブジェクトを描画し、画面に表示します。

  • イベントループの流れ

    1. 入力チェック:ユーザーからの入力イベントを検出します。

    2. 状態更新:入力に応じてゲームの状態やオブジェクトの位置を更新します。

    3. 描画:現在の状態に基づいてシーンを描画します。

    4. 繰り返し:この一連の流れを繰り返します。

イベントループがあることで、アプリケーションはリアルタイムでの反応や継続的な描画が可能になり、ユーザーとインタラクティブにやり取りすることができます。

以下は、最もシンプルなPanda3Dアプリケーションの例です。plateau-urban-equalizerフォルダーに「panda3d_example.py」というファイルを作成して、次のコードを記載してください。

from direct.showbase.ShowBase import ShowBase


class MyApp(ShowBase):
    def __init__(self):
        super().__init__()


app = MyApp()
app.run()

このコードは、ShowBaseを初期化することで空のウィンドウが表示します。このコードを実行するには、コマンドライン(PowerShellやターミナル)に次のコードを入力して、エンターキーで実行します。

cd ~/Documents/plateau-urban-equalizer
python panda3d_example.py
図4 Panda3Dのウインドウ

実行すると、背景がグレーのウインドウが表示します(▲図4▲)。これで、Panda3Dの最もシンプルな構造が作成できました。このウインドウを閉じるには、ウインドウの「X」ボタンをクリックするか、コマンドライン上で、「Ctrl + C」または「Command + C」のキーを押します。

3.3 ビルを作成する練習プログラム

前章で紹介した基本プログラムを応用して、ビルを一つ作成する方法を詳しく説明します。

3.3.1 プログラムの全体構造

このプログラムでは、以下の手順でビルを作成しています。

  1. 頂点データの定義:ビルの底面を構成する頂点座標を定義します。この頂点データは、第2章で例示した辞書形式のデータをそのまま利用します。

  2. 頂点データの調整:頂点データが閉じているかのチェック、座標系の調整、頂点の順序の調整を行います。

  3. 頂点データの登録:GeomVertexDataとGeomVertexWriterを使用して、頂点データを登録します。

  4. プリミティブの作成:GeomTrianglesを使用して、ビルの面を構成する三角形を定義します。

  5. ジオメトリとノードの作成:GeomとGeomNodeを使用して、ジオメトリをノードに追加し、シーンに組み込みます。

  6. 描画設定とカメラの配置:ワイヤーフレーム表示に設定し、カメラの位置を調整します。

3.3.2 プログラムの作成

では、ビル建築練習プログラムを作成しましょう。plateau-urban-equalizerフォルダーに「building_app_example.py」という名前のファイルを作成し、次のコードを記載します。

from direct.showbase.ShowBase import ShowBase
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Geom, GeomTriangles, GeomNode


class BuildingAppExample(ShowBase):
    # ズームレベルごとの高さ補正係数
    Z_SCALES = {
        14: 2.695,
        15: 5.390,
        16: 10.780
    }

    def __init__(self):
        super().__init__()

        # ビルを描画するノード
        self.buildings_node = self.render.attachNewNode('buildings_node')

        # 座標データ
        zoom_level = 14
        base_coords = [
            [-80, 2597], [-28, 2593], [-28, 2586], [-80, 2590], [-80, 2597]
        ]
        building_z = 23.294
        color = (1, 0, 0, 1)  # 透明度は1(不透明)

        # 頂点データが閉じているかチェックし、閉じていなければ閉じる
        base_coords = self.ensure_closed(base_coords)

        # 頂点が右回りか左回りか判定し、左回りに調整して面が描画されるようにする
        if self.is_clockwise(base_coords):
            base_coords = base_coords[::-1]  # 順序を反転

        # 高さの補正
        height = building_z * self.Z_SCALES[zoom_level]

        # 頂点データの作成
        format = GeomVertexFormat.getV3c4()  # 3次元座標とRGBA色を持つフォーマット
        vdata = GeomVertexData('building', format, Geom.UHStatic)  # 静的なデータ
        vertex = GeomVertexWriter(vdata, 'vertex')  # 頂点データのライター
        color_writer = GeomVertexWriter(vdata, 'color')  # 色データのライター

        # ライターを使って頂点を追加(底面と上面)
        for x, y in base_coords:
            # 底面の頂点
            vertex.addData3f(x, y, 0)
            color_writer.addData4f(*color)
        for x, y in base_coords:
            # 上面の頂点
            vertex.addData3f(x, y, 1)
            color_writer.addData4f(*color)

        # プリミティブ(基本形状)の作成
        tris = GeomTriangles(Geom.UHStatic)

        # 上面のポリゴンを作成するため、プリミティブに頂点を登録する
        # 多角形を三角形に分割する
        num_vertices = len(base_coords)
        for i in range(1, num_vertices - 2):
            # 三角形の頂点インデックスを登録
            tris.addVertices(num_vertices + 0, num_vertices + i, num_vertices + i + 1)

        # 側面のポリゴンを作成ため、プリミティブに頂点を登録する
        for i in range(num_vertices - 1):
            # 側面を構成する頂点インデックス
            idx0 = i
            idx1 = i + 1
            idx2 = num_vertices + i + 1
            idx3 = num_vertices + i

            # 側面を2つの三角形で構成
            tris.addVertices(idx0, idx1, idx2)
            tris.addVertices(idx0, idx2, idx3)

        # ジオメトリの作成
        geom = Geom(vdata)  # 頂点データからジオメトリを作成
        geom.addPrimitive(tris)  # プリミティブを追加
        node = GeomNode('building')  # GeomNodeノードを作成
        node.addGeom(geom)  # ジオメトリをノードに追加
        building_nodepath = self.buildings_node.attachNewNode(node)  # ノードをシーンに追加

        # ビルの高さを設定
        self.buildings_node.setSz(height)

        # ワイヤーフレーム表示に設定
        building_nodepath.setRenderModeWireframe()
        building_nodepath.setRenderModeThickness(2)  # 線の太さを調整(任意)

        # カメラの位置を設定(斜めからの視点)
        self.disableMouse()  # マウスによるカメラ操作を無効化
        base_x, base_y = base_coords[0]
        self.camera.setPos(base_x - 200, base_y - 200, 200)  # カメラ位置の移動
        self.camera.lookAt(base_x, base_y, 0)  # カメラの注視点を設定

        # 座標軸を表示する
        self.axis = self.loader.loadModel('zup-axis')  # 座標軸のモデルをロード
        self.axis.reparentTo(self.render)  # シーンに追加
        self.axis.setPos(base_x, base_y, 0)  # 座標軸の位置を設定

    @staticmethod
    def ensure_closed(coords):
        """
        頂点リストが閉じているかをチェックし、閉じていない場合は最初の頂点をリストの最後に追加する。

        Args:
            coords (list of tuple): 頂点の座標リスト

        Returns:
            list of tuple: 閉じた頂点の座標リスト
        """
        if coords[0] != coords[-1]:
            coords.append(coords[0])
        return coords

    @staticmethod
    def is_clockwise(coords):
        """
        多角形の頂点座標を受け取り、時計回りか反時計回りかを判定します。

        Args:
            coords: 多角形の頂点座標のリスト。各頂点は(x, y)のタプルまたはリストで表されます。

        Returns:
            時計回りの場合はTrue、反時計回りの場合はFalse、点が一直線上にある場合はNoneを返します。
        """
        if len(coords) < 3:
            return None  # 3点未満では多角形を形成しない

        # 外積の総和を計算
        sum_cross_product = 0
        for i in range(len(coords)):
            x1, y1 = coords[i]
            x2, y2 = coords[(i + 1) % len(coords)]  # 次の頂点、最後は最初の頂点に戻る
            sum_cross_product += (x2 - x1) * (y2 + y1)

        if sum_cross_product > 0:
            return True  # 時計回り
        elif sum_cross_product < 0:
            return False  # 反時計回り
        else:
            return None  # 点が一直線上


app = BuildingAppExample()
app.run()

3.3.3 コードの詳細な解説

上記のコードは、いきなり難しくなった感じるかもしれませんが、3D都市作成のための基本的な要素がすべて含まれています。ビルを1つ作成できれば、そのコードを街全体を作成するコードに変換することは容易なことです。以下のコード解説を参照して、Panda3Dにおけるビル建築の流れを理解してみてください。

1. 必要なモジュールのインポート

from direct.showbase.ShowBase import ShowBase
from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Geom, GeomTriangles, GeomNode
  • ShowBase:Panda3Dの基本クラスで、ウィンドウの作成やイベントループを管理します。

  • GeomVertexFormatなど:ジオメトリを作成するためのクラス群です。

2. Z方向のスケーリング率の設定

    # ズームレベルごとの高さ補正係数
    Z_SCALES = {
        14: 2.695,
        15: 5.390,
        16: 10.780
    }
  • クラス変数 Z_SCALESは、ズームレベルとスケーリング率の辞書です。この補正係数をかけることで、ズームレベルごとにビルの高さを適正化します。

3. アプリケーションクラスの定義

class BuildingAppExample(ShowBase):
    # ズームレベルごとの高さ補正係数
    # 略

    def __init__(self):
        super().__init__()
  • BuildingAppExample クラスを定義し、Panda3Dの ShowBase を継承します。

  • super().__init__() を使用して、親クラスである ShowBase の初期化メソッドを呼び出します。これにより、Panda3Dのウィンドウ管理やイベントループなど、基本的な機能がセットアップされます。

  • これらの設定によって、ShowBaseを基盤としてアプリケーションを構築していくことが可能になります。

4. ビル描画用のノードを作成

        # ビルを描画するノード
        self.buildings_node = self.render.attachNewNode('buildings_node')
  • Panda3Dでは、すべてのオブジェクトはシーングラフと呼ばれる階層構造のノードに配置され、管理されます。

  • self.renderは、シーングラフのルートノード(最上位のノード)です。すべてのオブジェクトは、このルートノードに関連付けられます。

  • attachNewNodeにより、新しいノードをルートノード(self.render)の子ノードとして作成します。作成されたノードは「ノードパス(NodePath)」というオブジェクトで管理され、操作や描画設定が可能になります。

5. ビルの頂点データの定義

        # 座標データ
        zoom_level = 14
        base_coords = [
            [-80, 2597], [-28, 2593], [-28, 2586], [-80, 2590], [-80, 2597]
        ]
        building_z = 23.294
        color = (1, 0, 0, 1)  # 透明度は1(不透明)
  • zoom_level : ズームレベル

  • base_coords:ビルの底面を構成する頂点の座標リスト。要素は5つあるが、最初と最後の値は同じ(つまり閉じている)ので、頂点は4つで四角形である。

  • building_z:ビルの高さ(補正前)。

  • color:ビルの色(RGBA形式)。Rは赤、Gは緑、Bは青で、3色の混合で色を表現する。Alphaは透明度を表す。いずれの値も0から1の小数で定義する。例えば、(1, 0, 0, 1) は「赤色で透明度が0%(完全に不透明)」を意味します。

6. 頂点データの閉鎖チェック

        # 頂点データが閉じているかチェックし、閉じていなければ閉じる
        base_coords = self.ensure_closed(base_coords)
  • ensure_closedメソッドを使用して、頂点データが閉じているかを確認します。

  • 閉じていない場合、最初の頂点をリストの最後に追加して、多角形を閉じることで、多角形が正しく表示できるようにします。

7. 高さの補正

        # 高さの補正
        height = building_z * self.Z_SCALES[zoom_level]
  • zの値にズームレベルに合わせたスケーリング率をかけることで、高さを補正します。

8. 頂点の順序の調整(左回り)

        # 頂点が右回りか左回りか判定し、左回りに調整して面が描画されるようにする
        if self.is_clockwise(base_coords):
            base_coords = base_coords[::-1]  # 順序を反転
  • is_clockwiseメソッドで、頂点の順序が右回り(時計回り)か左回り(反時計回り)かを判定します。

  • 面が描画されるように、頂点の順序を左回りにします。

  • 注記:頂点を左回りに設定することで、面が表側からのみ描画され、裏側からは見えなくなります。これは、描画負荷を減らす試みであり、レンダリングの効率を向上させます。node.setTwoSided(True)という、面の両方を描画するメソッドもありますが、デバッグ目的以外では推奨されません。

9. 頂点データの登録

Geomオブジェクトを使って、図形を描画する準備をします。

        # 頂点データの作成
        format = GeomVertexFormat.getV3c4()  # 3次元座標とRGBA色を持つフォーマット
        vdata = GeomVertexData('building', format, Geom.UHStatic)  # 静的なデータ
        vertex = GeomVertexWriter(vdata, 'vertex')  # 頂点データのライター
        color_writer = GeomVertexWriter(vdata, 'color')  # 色データのライター
  • GeomVertexFormat.getV3c4():頂点が3D座標とカラー情報を持つフォーマットを指定します。

  • GeomVertexData:頂点データを格納するコンテナを作成します。

  • GeomVertexWriter:頂点データに情報を書き込むためのライターです。

10. 頂点の追加

vertexとcolor_writerという2つのライターを使って、頂点のデータをvdataに登録しています。

        # ライターを使って頂点を追加(底面と上面)
        for x, y in base_coords:
            # 底面の頂点
            vertex.addData3f(x, y, 0)
            color_writer.addData4f(*color)
        for x, y in base_coords:
            # 上面の頂点
            vertex.addData3f(x, y, 1)
            color_writer.addData4f(*color)
  • 底面の頂点:Z座標を0として、底面の頂点を追加します。

  • 上面の頂点:Z座標を高さ1として、上面の頂点を追加します。この設定により基準高さ(= 1)のビルを配置することができ、その後にビルの高さを自由に操作できるようにしています。

  • 頂点の色は、ここではすべて同じ(赤)にしていますが、頂点ごとに別の色を指定することも可能です。そのとき、面はグラデーションで描画されます。(ただしグラデーションにすると、描画の負荷は大きくなります。)

11. プリミティブ(基本形状)の作成

GeomTrianglesは最も一般的な GeomPrimitive の種類である。このプリミティブは、任意の数の三角形を格納します。各三角形の頂点は、三角形の正面から見て反時計回りの順序で並べなければなりません。

        # プリミティブ(基本形状)の作成
        tris = GeomTriangles(Geom.UHStatic)
  • GeomTrianglesを使用して、三角形の集合としてプリミティブを定義します。

12. 上面のポリゴンの作成

        # 上面のポリゴンを作成するため、プリミティブに頂点を登録する
        # 多角形を三角形に分割する
        num_vertices = len(base_coords)
        for i in range(1, num_vertices - 2):
            # 三角形の頂点インデックスを登録
            tris.addVertices(num_vertices + 0, num_vertices + i, num_vertices + i + 1)
  • 多角形の上面を三角形に分割して描画します。

  • 頂点インデックスを指定して、プリミティブに三角形を追加します。

  • 頂点インデックスとはライターで頂点を登録した順番のことで、0から数え上げます。

13. 側面のポリゴンの作成

側面は、上面と底面をつなぐ四角形です。多角形の頂点数が側面の枚数になります(四角形なら4枚の側面)。

        # 側面のポリゴンを作成ため、プリミティブに頂点を登録する
        for i in range(num_vertices - 1):
            # 側面を構成する頂点インデックス
            idx0 = i
            idx1 = i + 1
            idx2 = num_vertices + i + 1
            idx3 = num_vertices + i

            # 側面を2つの三角形で構成
            tris.addVertices(idx0, idx1, idx2)
            tris.addVertices(idx0, idx2, idx3)
  • ビルの側面を構成するために、底面と上面の頂点を使用して三角形を定義します。

  • 各側面は2つの三角形で構成されます。

14. ジオメトリの作成とシーンへの追加

        # ジオメトリの作成
        geom = Geom(vdata)  # 頂点データからジオメトリを作成
        geom.addPrimitive(tris)  # プリミティブを追加
        node = GeomNode('building')  # GeomNodeノードを作成
        node.addGeom(geom)  # ジオメトリをノードに追加
        building_nodepath = self.render.attachNewNode(node)  # ノードをシーンに追加
  • Geomオブジェクトを作成し、頂点データとプリミティブを組み合わせます。

  • GeomNodeにジオメトリを追加し、シーンに組み込みます。

  • 手順のまとめ

    • 頂点データからジオメトリを作成する。

    • プリミティブをジオメトリに追加する。

    • ジオメトリをGeomNodeに追加する。

    • ノードをシーンに追加する。

15. ビルの高さを設定

        # ビルの高さを設定
        self.buildings_node.setSz(height)
  • ビルのモデルは高さ1のスケールで作成されています。そのため、モデル自体を変更するのではなく、モデルを配置しているノード buildings_node を縦方向に引き伸ばして高さを調整します。

  • ノードの縦方向のスケールを指定した値(height)に設定します。これにより、ビルの高さが height に比例して引き伸ばされ、正確な高さを反映させることができます。

16. 描画設定とカメラの配置

        # ワイヤーフレーム表示に設定
        building_nodepath.setRenderModeWireframe()
        building_nodepath.setRenderModeThickness(2)  # 線の太さを調整(任意)

        # カメラの位置を設定(斜めからの視点)
        self.disableMouse()  # マウスによるカメラ操作を無効化
        base_x, base_y = base_coords[0]
        self.camera.setPos(base_x - 200, base_y - 200, 200)  # カメラ位置の移動
        self.camera.lookAt(base_x, base_y, 0)  # カメラの注視点を設定
  • ワイヤーフレーム表示:setRenderModeWireframe()でワイヤーフレーム表示に設定します。このコードはデバッグ用であり、本番プログラムではコメントにして無効化します。

  • カメラの配置:カメラの位置と注視点を設定して、ビルが見やすいようにします。

17. 座標軸の表示

        # 座標軸を表示する
        self.axis = self.loader.loadModel('zup-axis')  # 座標軸のモデルをロード
        self.axis.reparentTo(self.render)  # シーンに追加
        self.axis.setPos(base_x, base_y, 0)  # 座標軸の位置を設定
  • zup-axisモデルをロードして、シーンに追加します。

  • 座標軸を表示することで、シーン内のオブジェクトの位置関係を把握しやすくなります。この座標軸はデバッグ用です。

18. 補助メソッドの定義

ensure_closedメソッド

    @staticmethod
    def ensure_closed(coords):
        """
        頂点リストが閉じているかをチェックし、閉じていない場合は最初の頂点をリストの最後に追加する。

        Args:
            coords (list of tuple): 頂点の座標リスト

        Returns:
            list of tuple: 閉じた頂点の座標リスト
        """
        if coords[0] != coords[-1]:
            coords.append(coords[0])
        return coords
  • 頂点リストが閉じているかを確認し、閉じていなければ最初の頂点を追加します。

  • @staticmethodデコレーターは、クラスやインスタンスに依存せずに呼び出せるメソッドを定義します。これは、関数としてクラス外に定義することも可能ですが、クラスと深く関連しているため、スタッティックメソッドとして、クラス内のまとめました。

is_clockwiseメソッド

    @staticmethod
    def is_clockwise(coords):
        """
        多角形の頂点座標を受け取り、時計回りか反時計回りかを判定します。

        Args:
            coords: 多角形の頂点座標のリスト。各頂点は(x, y)のタプルまたはリストで表されます。

        Returns:
            時計回りの場合はTrue、反時計回りの場合はFalse、点が一直線上にある場合はNoneを返します。
        """
        if len(coords) < 3:
            return None  # 3点未満では多角形を形成しない

        # 外積の総和を計算
        sum_cross_product = 0
        for i in range(len(coords)):
            x1, y1 = coords[i]
            x2, y2 = coords[(i + 1) % len(coords)]  # 次の頂点、最後は最初の頂点に戻る
            sum_cross_product += (x2 - x1) * (y2 + y1)

        if sum_cross_product > 0:
            return True  # 時計回り
        elif sum_cross_product < 0:
            return False  # 反時計回り
        else:
            return None  # 点が一直線上
  • 外積の総和を計算して、頂点の順序が右回りか左回りかを判定します。プリミティブに頂点を登録する時に、頂点を左回りで指定する必要があるため、このメソッドで毎回チェックを行っています。

18. アプリケーションの実行

app = BuildingAppExample()
app.run()
  • アプリケーションのインスタンスを作成し、イベントループを開始します。

以上で、コード解説は完了です。cdコマンドでplateau-urban-equalizerフォルダーに移動してから、次のコードを実行してください。

python building_app_example.py

3.3.4 プログラムの実行結果

図5 ビルの建築

このプログラムを実行すると、指定した座標と高さを持つビルがワイヤーフレームで表示されます(▲図5▲)。カメラは斜め上からビルを見下ろす位置に設定されており、ビルの形状や構造を確認できます。

3.4 まとめ

この章では、Panda3Dの基本的な使い方と、ビルを一つ作成する方法を解説しました。主なポイントは以下の通りです。

  • Panda3Dの基本構造:ShowBaseを継承したクラスを作成し、app.run()でアプリケーションを実行します。

  • 頂点データの取り扱い:GeomVertexDataとGeomVertexWriterを使用して、頂点データを登録します。

  • ジオメトリとプリミティブの作成:GeomとGeomTrianglesを使用して、プリミティブを作成し、ジオメトリに追加します。

  • 描画負荷の軽減:頂点の順序を左回りに設定することで、面の裏側の描画を省略し、描画負荷を減らします。

  • シーンへの追加:GeomNodeにジオメトリを追加し、シーンに組み込みます。

このプログラムを基に、頂点データや高さ、色などを変更して、さまざまなビルを作成してみてください。Panda3Dの機能を活用することで、より複雑でリアルな3D都市モデルを構築することができます。


次章では、複数のビルを一度に描画する方法や、画像から取得した色データからビルの色を設定する方法について解説します。

4. 3D都市の表示

4.1 概要

この章では、Panda3Dを使用して複数の3Dビルディングを表示して、3D都市を作成する方法を学びます。前章でビルを1つ作成する方法を学びましたが、ここではそれを拡張し、複数のビルを配置して都市のようなシーンを作成します。また、画像ファイルから色情報を読み込んで、ビルの位置に合わせて色を決定する方法も紹介します。

3D都市アプリケーションの実装は、以下の5つの主要なコンポーネントから構成されます。Buildingクラスは個々のビルの情報を管理し、DataLoaderクラスはデータセットからビル情報を読み込みます。BuildingRendererクラスはこれらのビル情報を基に3Dモデルを生成し、シーンに追加します。最後に、Cameraクラスがユーザーの視点を制御します。

  1. ビル管理クラス (building.py)

  2. データをロード (data_loader.py)

  3. ビルをレンダリング (building_render.py)

  4. カメラ管理 (camera.py)

  5. 起動ファイル (main.py) 

4.2 ライブラリのインポート

第4章で使用するライブラリをまとめてインストールします。ターミナルで次のコマンドを実行してください。

pip install mapbox_vector_tile sharpely Pillow

使用するライブラリの説明

  • mapbox_vector_tile

    • Mapbox Vector Tile(MVT)形式のデータをデコードするためのライブラリです。

    • plateau-lod2-mvtのデータを辞書形式変換するのに使用します。こうすることで、Pythonでビルの情報(頂点や高さなど)を簡単に処理できるようになります。

  • shapely

    • 幾何学的な形状を作成・操作するためのライブラリです。

    • 多角形(ポリゴン)の簡略化や重心の計算、空間的な操作に使用します。

  • Pillow

    • Pythonで画像を操作するためのライブラリです。

    • 背景画像の読み込みや、ビルの色を設定する際に使用します。

4.3 3D都市アプリケーションの実装

ここから、コーディングを開始します。plateau-urban-equalizerは全部で数百行のコードになるため、機能別に分けて複数のファイルにコードを記載します。コードを構造化することで、デバッグを容易にすることができます。また、コードの再利用も簡単になります。

4.3.1 ビルのクラス

初めに、個々のビル情報を格納するクラスを作成します。cityフォルダーにbuilding.pyという名前のファイルを作成して、次のコードを記載します。

from shapely.geometry import Polygon, LinearRing, Point


class Building:
    # 簡略化の度合いを設定(値が大きいほど頂点数が減少)
    simplification_tolerance = 30  # 必要に応じて調整

    def __init__(self, base, building_id, coordinates, building_z):
        self.base = base
        self.id = building_id
        self.coordinates = coordinates
        self.building_z = building_z
        self.height = 0  # ビルの高さ(後で設定)
        self.color = (0.5, 0.5, 0.5, 1)  # デフォルトの色(グレー)

        # 簡略化した座標(初期値は元の座標)
        self.simplified_coords = coordinates

        # 重心の座標
        self.centroid = Point(0, 0)

        # 全ての頂点を含む円の半径(衝突判定用、未使用)
        self.bounding_circle_radius = 0

        # ビルのノードを作成し、シーンに追加
        self.node = base.buildings_node.attachNewNode(str(self.id))

        # ビルのジオメトリを計算し、インスタンス変数を設定
        self.calculate_geometry()

        # 画像からビルの色を取得
        self.extract_color_from_image(self.centroid.x, self.centroid.y)

    def calculate_geometry(self):
        """
        ビルのジオメトリ情報を計算し、インスタンス変数に設定します。
        """
        # 座標からシェイプリーのポリゴンを作成
        linear_ring = LinearRing(self.coordinates)
        polygon = Polygon(linear_ring)

        # ポリゴンの簡略化を行うかどうか
        if self.base.use_simplified_coords:
            # トポロジーを保持しつつ簡略化
            simplified_polygon = polygon.simplify(self.simplification_tolerance, preserve_topology=True)
        else:
            simplified_polygon = polygon  # 簡略化しない

        # 簡略化した頂点座標を取得
        self.simplified_coords = list(simplified_polygon.exterior.coords)

        # 重心の計算
        self.centroid = simplified_polygon.centroid

        # 包含円の半径を計算(衝突判定用)
        all_points = [Point(pt) for pt in self.simplified_coords]
        self.bounding_circle_radius = self.calculate_bounding_circle_radius(all_points, self.centroid)

        # 元の頂点数と簡略化後の頂点数を記録
        self.base.vertex_count += len(polygon.exterior.coords)
        self.base.simplified_vertex_count += len(simplified_polygon.exterior.coords)

    def extract_color_from_image(self, x, y):
        """
        ビルの重心座標から画像の対応する色を取得し、ビルの色として設定します。

        Args:
            x (float): 重心のX座標
            y (float): 重心のY座標
        """
        # ビルの座標範囲を画像のサイズにマッピング
        x_min, x_max = 0, 4096
        y_min, y_max = 0, 4096

        # 座標を画像のピクセル座標に変換
        pixel_x = int((x - x_min) / (x_max - x_min) * self.base.image_width)
        pixel_y = int((y - y_min) / (y_max - y_min) * self.base.image_height)

        # 画像の範囲内に収まるようにクリップ
        pixel_x = max(0, min(self.base.image_width - 1, pixel_x))
        pixel_y = max(0, min(self.base.image_height - 1, pixel_y))

        # 画像のY軸は上が0なので、Y座標を反転
        pixel_y = self.base.image_height - pixel_y - 1

        # ピクセルの色を取得
        color = self.base.background_image.getpixel((pixel_x, pixel_y))

        # RGBA値を0〜1の範囲に正規化して設定
        if len(color) == 4:
            r, g, b, a = color
        else:
            r, g, b = color
            a = 255  # アルファ値がない場合は255(不透明)

        self.color = (r / 255.0, g / 255.0, b / 255.0, a / 255.0)

    @staticmethod
    def calculate_bounding_circle_radius(points, centroid):
        """
        重心から各頂点までの距離を計算し、最大値を包含円の半径として返します。

        Args:
            points (list of Point): 頂点のリスト
            centroid (Point): 重心の座標

        Returns:
            float: 包含円の半径
        """
        # 重心から各ポイントまでの距離のリストを作成
        distances = [centroid.distance(point) for point in points]
        # 最大距離を半径として返す
        return max(distances)

    @staticmethod
    def calculate_centroid(points):
        """
        与えられたポイントの集合から重心を計算して返します。

        Args:
            points (list of Point): ポイントのリスト

        Returns:
            Point: 重心の座標
        """
        x_coords = [point.x for point in points]
        y_coords = [point.y for point in points]
        centroid_x = sum(x_coords) / len(x_coords)
        centroid_y = sum(y_coords) / len(y_coords)
        return Point(centroid_x, centroid_y)

このクラスは、ビルのジオメトリ(形状)情報を計算し、色や位置情報を管理するためのものです。また、Shapelyライブラリを使用して基底面の座標を簡略化する処理も行います。

3Dアプリケーション、特に本アプリのように多数のオブジェクトを配置する場合、描画するオブジェクトの数を減らすことが最も重要なポイントの一つとなります。コンピュータの限られたリソースを活用して効率的に3D都市を再現するため、この簡略化処理は不可欠な工程です。

簡略化により、見た目に大きな影響を与えずに計算量を削減し、アプリケーション全体のパフォーマンスを向上させます。このプロセスによって、CG専用の高性能グラフィックパソコンを必要とせず、通常のパソコンでも3D都市をリアルに再現できるようになります。

図6 Shapelyモジュールのテスト

図6は、Shapelyライブラリを使って、10角形(水色)の図形を四角形(オレンジの点線)に変換した画像です。Shapelyライブラリは、図形の飛び出したところ、凹んだところの点を削除して、元の図形を形状を残したまま頂点数を減らすことができます。

さらに、Pillowライブラリを使用して画像から色情報を取得し、それをビルの色として設定する機能も搭載しています。この機能により、背景画像を活用して都市の色合いを統一し、視覚的に魅力的な3D都市を構築できます。この背景画像は自由に設定できるため、ユーザー独自の視覚的なカスタマイズが可能になります。

インスタンス変数の定義

        self.base = base
        self.id = building_id
        self.coordinates = coordinates
        self.building_z = building_z
        self.height = 0  # ビルの高さ(後で設定)
        self.color = (0.5, 0.5, 0.5, 1)  # デフォルトの色(グレー)
  • base: アプリケーション全体の基盤オブジェクトを参照し、共有リソース(背景画像、ビルのノードなど)を利用します。

  • building_id: ビルを識別するための一意のID。

  • coordinates: ビルの基底面の頂点座標。

  • building_z: ビルの高さ。この値は補正前であり、実際に描画するときは、ズームレベルに合わせて補正係数を掛けて高さ合わせをします。

  • height: 補正済みのビルの高さ。あとで再計算する。

  • color: デフォルトのビルの色。あとで画像から読み込んだ色に変更する。

計算が必要なインスタンス変数の定義

        # 簡略化した座標(初期値は元の座標)
        self.simplified_coords = coordinates

        # 重心の座標
        self.centroid = Point(0, 0)

        # 全ての頂点を含む円の半径(衝突判定用、未使用)
        self.bounding_circle_radius = 0
  • simplified_coordsなどのインスタンス変数は、calculate_geometryメソッドによって計算し直され、正しい値に更新されます。

  • 初期値として、それぞれ適切な値を定義しています。初期値を与えることで、プログラムのエラー終了(クラッシュ)を防止しています。

ビル専用ノードを作成

        # ビルのノードを作成し、シーンに追加
        self.node = base.buildings_node.attachNewNode(str(self.id))
  • ビルごとに別々に高さを変えるために、ビルを構成する図形(上面や側面)を個別の専用ノードに配置するようにします。

  • node: ビルを表す図形を配置する専用ノード。base.buildings_nodeを親ノードにして、ノード名はビルの識別IDにします。

ジオメトリ計算 (calculate_geometry)

    def calculate_geometry(self):
        """
        ビルのジオメトリ情報を計算し、インスタンス変数に設定します。
        """

ビルの形状に関する計算を行い、頂点座標の簡略化や重心の設定を行います。

  • 主な処理内容:

    • Polygon と LinearRing:

      • Shapely を使って座標リストから多角形オブジェクトを生成。

    • 簡略化処理:

      • polygon.simplify: 頂点数を減らす処理。preserve_topology=True によって、多角形の形状を大きく崩さずに簡略化します。

      • 簡略化された頂点を self.simplified_coords に設定。

    • 重心計算:

      • Shapely の centroid を使用し、ポリゴンの重心を計算。

      • 結果を self.centroid に設定。

    • 包含円の半径計算:

      • self.calculate_bounding_circle_radius を呼び出し、頂点から重心までの最大距離を半径として計算。


色の取得 (extract_color_from_image)

    def extract_color_from_image(self, x, y):
        """
        ビルの重心座標から画像の対応する色を取得し、ビルの色として設定します。

        Args:
            x (float): 重心のX座標
            y (float): 重心のY座標
        """

ビルの重心座標から、背景画像に基づいてビルの色を設定します。このメソッドにより、画像に合わせたカラフルな3D都市を建築できます。

  • 主な処理内容:

    • 座標を画像のピクセル座標にマッピング(座標系の違いを考慮)。

    • ピクセル座標を背景画像内に収める(範囲外をクリップ)。

    • ピクセルの色を取得し、RGBA 値として正規化。

    • self.color に設定することでビルの色を更新する。


4. 包含円の半径計算 (calculate_bounding_circle_radius)

    @staticmethod
    def calculate_bounding_circle_radius(points, centroid):
        """
        重心から各頂点までの距離を計算し、最大値を包含円の半径として返します。

        Args:
            points (list of Point): 頂点のリスト
            centroid (Point): 重心の座標

        Returns:
            float: 包含円の半径
        """

ビルの衝突判定用として、頂点群の中で重心から最も遠い点の距離(半径)を計算します。本アプリでは、衝突判定を使った機能は実装しませんが、今後の応用のために、コードを残しておきます。

  • 主な処理内容:

    • 各頂点から重心までの距離を計算。

    • 最大距離を返却。

4.3.2 データセットからデータを抜き出す

次に、plateau-lod2-mvtデータセットからデータを抜き出すためのDataLoaderクラスを実行します。cityフォルダーにdata_loader.pyを作成し、DataLoaderクラスを作成します。

import os
from mapbox_vector_tile import decode
from .building import Building


class DataLoader:
    def __init__(self, base, zoom_level, tile_x, tile_y):
        self.base = base
        self.zoom_level = zoom_level
        self.tile_x = tile_x
        self.tile_y = tile_y

        # 建物データの読み込み
        self.load_buildings()

    def load_buildings(self):
        """
        建物データを読み込み、Buildingインスタンスを作成してリストに追加します。
        """
        # PBFファイルのパスを構築
        pbf_file = f'{self.zoom_level}/{self.tile_x}/{self.tile_y}.pbf'

        # ファイルが存在するか確認
        if not os.path.isfile(pbf_file):
            print(f"PBFファイルが見つかりません: {pbf_file}")
            return

        # PBFファイルの読み込み
        with open(pbf_file, 'rb') as f:
            tile_data = f.read()
            tile_dict = decode(tile_data)

        # 'bldg' レイヤーのフィーチャーを処理
        for feature in tile_dict.get('bldg', {}).get('features', []):
            building_id = feature.get('id')
            coordinates = feature.get('geometry', {}).get('coordinates')
            building_z = feature.get('properties', {}).get('z')

            if coordinates is None or building_z is None:
                print(f"建物ID {building_id} のデータが不完全です。")
                continue

            # 座標のネストの深さを取得
            depth = self.get_list_depth(coordinates)

            # Buildingインスタンスの作成
            if depth == 3:
                # 単一のポリゴンの場合
                building = Building(self.base, building_id, coordinates[0], building_z)
                self.base.building_list.append(building)
            elif depth == 4:
                # 複数のポリゴン(穴あきポリゴンなど)の場合
                for coords in coordinates:
                    building = Building(self.base, building_id, coords[0], building_z)
                    self.base.building_list.append(building)
            else:
                print(f"建物ID {building_id} の座標の深さが予期しない値です(深さ: {depth})")

    @staticmethod
    def get_list_depth(lst):
        """
        リストのネストの深さを再帰的に計算します。

        Args:
            lst (list): ネストされたリスト

        Returns:
            int: リストのネストの深さ
        """
        if isinstance(lst, list):
            if not lst:
                return 1  # 空のリストの深さは1
            return 1 + max(DataLoader.get_list_depth(item) for item in lst)
        else:
            return 0

このクラスは、指定されたズームレベルとタイル位置(tile_x, tile_y)に対応する PBFファイル を読み込み、そのデータを解析します。そして、解析結果を基に Building インスタンスを生成し、self.base.building_list リストに格納します。


処理の流れ

  1. PBFファイルの読み込み:

    • ズームレベルとタイル座標を基にファイルパスを生成し、PBFファイルを開きます。

    • ファイルが存在しない場合は警告を表示して処理を中断します。

  2. PBFファイルのデコード:

    • mapbox_vector_tile ライブラリを使用して、PBFファイルをPythonの辞書形式に変換します。

  3. 建物データの解析:

    • デコードしたデータから建物情報(id, coordinates, z)を抽出します。

    • 座標データの構造(単一ポリゴンまたは複数ポリゴン)に応じて処理を分岐します。複数ポリゴンの場合はforループを使って、リストのすべての要素(各建物データ)を処理します。

  4. Building インスタンスの作成:

    • 各建物データを基に Building インスタンスを生成し、self.base.building_list リストに追加します。


主なメソッドの説明

  1. load_buildings:

    • 建物データを読み込み、Building インスタンスを生成する主処理。

    • ファイルの存在確認、不完全なデータのスキップ、座標の深さに応じた処理を行います。

  2. get_list_depth:

    • 座標データのネスト深さを再帰的に計算。

    • 単一ポリゴンか複数ポリゴンかを判断するために使用します。

4.3.3 ビルの描画クラスの作成

複数のビルを描画するために、ビルを描画するBuildingRendererクラスを作成します。このクラスは、第3章で作成したbuilding_app_example.pyを改造して、ビル専用ノード(building.node)に図形(四角形など)を描く機能だけを残したものです。cityフォルダーにbuilding_render.pyを作成し、次のコードを記述します。

from panda3d.core import GeomVertexFormat, GeomVertexData, GeomVertexWriter
from panda3d.core import Geom, GeomTriangles, GeomNode


class BuildingRenderer:
    # ズームレベルごとの高さ補正係数
    Z_SCALES = {
        14: 2.695,
        15: 5.390,
        16: 10.780
    }

    def __init__(self, building):
        base_coords = building.simplified_coords

        # 頂点データが閉じているかチェックし、閉じていなければ閉じる
        base_coords = BuildingRenderer.ensure_closed(base_coords)

        # 頂点が右回りか左回りか判定し、左回りに調整して面が描画されるようにする
        if BuildingRenderer.is_clockwise(base_coords):
            base_coords = base_coords[::-1]  # 順序を反転

        # 高さの補正
        building.height = building.building_z * BuildingRenderer.Z_SCALES[building.base.zoom_level]

        # 頂点データの作成
        format = GeomVertexFormat.getV3c4()  # 3次元座標とRGBA色を持つフォーマット
        vdata = GeomVertexData('building', format, Geom.UHStatic)  # 静的なデータ
        vertex = GeomVertexWriter(vdata, 'vertex')  # 頂点データのライター
        color_writer = GeomVertexWriter(vdata, 'color')  # 色データのライター

        # ライターを使って頂点を追加(底面と上面)
        for x, y in base_coords:
            # 底面の頂点
            vertex.addData3f(x, y, 0)
            color_writer.addData4f(*building.color)
        for x, y in base_coords:
            # 上面の頂点
            vertex.addData3f(x, y, 1)
            color_writer.addData4f(*building.color)

        # プリミティブ(基本形状)の作成
        tris = GeomTriangles(Geom.UHStatic)

        # 上面のポリゴンを作成するため、プリミティブに頂点を登録する
        # 多角形を三角形に分割する
        num_vertices = len(base_coords)
        for i in range(1, num_vertices - 2):
            # 三角形の頂点インデックスを登録
            tris.addVertices(num_vertices + 0, num_vertices + i, num_vertices + i + 1)

        # 側面のポリゴンを作成ため、プリミティブに頂点を登録する
        for i in range(num_vertices - 1):
            # 側面を構成する頂点インデックス
            idx0 = i
            idx1 = i + 1
            idx2 = num_vertices + i + 1
            idx3 = num_vertices + i

            # 側面を2つの三角形で構成
            tris.addVertices(idx0, idx1, idx2)
            tris.addVertices(idx0, idx2, idx3)

        # ジオメトリの作成
        geom = Geom(vdata)  # 頂点データからジオメトリを作成
        geom.addPrimitive(tris)  # プリミティブを追加
        node = GeomNode('building')  # GeomNodeノードを作成
        node.addGeom(geom)  # ジオメトリをノードに追加
        building_nodepath = building.node.attachNewNode(node)  # ノードをシーンに追加

        # ビルの高さを設定
        building.node.setSz(building.height)

        # ワイヤーフレーム表示に設定
        if building.base.use_wireframe:
            building_nodepath.setRenderModeWireframe()
            building_nodepath.setRenderModeThickness(2)  # 線の太さを調整(任意)

    @staticmethod
    def ensure_closed(coords):
        """
        頂点リストが閉じているかをチェックし、閉じていない場合は最初の頂点をリストの最後に追加する。

        Args:
            coords (list of tuple): 頂点の座標リスト

        Returns:
            list of tuple: 閉じた頂点の座標リスト
        """
        if coords[0] != coords[-1]:
            coords.append(coords[0])
        return coords

    @staticmethod
    def is_clockwise(coords):
        """
        多角形の頂点座標を受け取り、時計回りか反時計回りかを判定します。

        Args:
            coords: 多角形の頂点座標のリスト。各頂点は(x, y)のタプルまたはリストで表されます。

        Returns:
            時計回りの場合はTrue、反時計回りの場合はFalse、点が一直線上にある場合はNoneを返します。
        """
        if len(coords) < 3:
            return None  # 3点未満では多角形を形成しない

        # 外積の総和を計算
        sum_cross_product = 0
        for i in range(len(coords)):
            x1, y1 = coords[i]
            x2, y2 = coords[(i + 1) % len(coords)]  # 次の頂点、最後は最初の頂点に戻る
            sum_cross_product += (x2 - x1) * (y2 + y1)

        if sum_cross_product > 0:
            return True  # 時計回り
        elif sum_cross_product < 0:
            return False  # 反時計回り
        else:
            return None  # 点が一直線上

このコードは BuildingRenderer クラスを定義し、建物(ビル)の3Dモデルを作成してPanda3Dのシーンに描画する役割を果たします。具体的には以下の処理を行います:

  1. ビルの基底面の頂点データを処理(閉じる、回転順序の調整)。

  2. 高さ情報を適用して3Dジオメトリを構築。

  3. Panda3Dのシーンにジオメトリを追加し、オプションでワイヤーフレーム表示に対応。

このコードは、第3章の3Dビルディング作成プログラムとほぼ同じです。コードの解説を知りたい方は、building_app_example.pyの解説文をご参照ください。

4.3.4 カメラのクラス

次は、カメラを管理するCameraクラスを作成します。このクラスはカメラをユーザー操作で移動できるようにします。カメラは3D都市全体を俯瞰する「外部カメラ」とプレイヤー視点である「内部カメラ」を切り替えられるようにして、多彩な視点で都市景観を眺められるようにします。cityフォルダーにcamera.pyを作成します。

from panda3d.core import WindowProperties


class Camera:
    heading_angular_velocity = 1500  # カメラの水平回転速度
    pitch_angular_velocity = 500  # カメラの垂直回転速度
    max_pitch_angle = 60  # カメラの最大ピッチ角度
    min_pitch_angle = -80  # カメラの最小ピッチ角度

    def __init__(self, base):
        self.base = base
        self.mode = 'external'  # 'external'(外部視点)または 'internal'(内部視点)

        # カメラの初期設定
        self.setup_cameras()

        # キー入力のマッピング
        self.base.accept('c', self.toggle_mode)  # 'c'キーでカメラモードを切り替え

        # キー入力状態を管理するマップ
        self.key_map = {
            "forward": False, "backward": False,
            "left": False, "right": False,
            "up": False, "down": False
        }
        self.mouse_sensitivity = 0.2  # マウス感度

        # キー入力の受付
        self.setup_key_bindings()

        # マウスの初期設定
        self.disable_mouse_control()  # マウスを非アクティブ化
        self.prev_mouse_pos = (0, 0)  # 前フレームのマウス位置

        # カメラ制御のタスクを登録
        self.base.taskMgr.add(self.update, 'camera_update_task')

    def setup_cameras(self):
        """
        カメラの初期設定を行います。
        現在のモード(external/internal)に応じて設定を変更します。
        """
        if self.mode == 'external':
            self.setup_external_camera()
        elif self.mode == 'internal':
            self.setup_internal_camera()

    def setup_external_camera(self):
        """
        外部カメラの設定を行います。
        """
        self.base.disableMouse()  # デフォルトのカメラ制御を無効化
        self.base.camera.setPos(2048, -5000, 2048)  # カメラを上空に配置
        self.base.camera.lookAt(0, 0, 0)  # 原点を注視
        self.enable_mouse_control()  # マウスでカメラを回転可能にする

    def setup_internal_camera(self):
        """
        内部カメラの設定を行います。
        """
        self.base.disableMouse()
        self.base.camera.setPos(0, 0, 1.6)  # カメラを地面上に配置
        self.base.camera.setHpr(0, 0, 0)  # カメラの回転を初期化
        self.disable_mouse_control()

    def toggle_mode(self):
        """
        カメラモードを切り替えます。
        """
        self.mode = 'internal' if self.mode == 'external' else 'external'
        self.setup_cameras()

    def setup_key_bindings(self):
        """
        キー入力のマッピングを設定します。
        """
        key_actions = [
            ('w', 'forward', True), ('w-up', 'forward', False),
            ('s', 'backward', True), ('s-up', 'backward', False),
            ('a', 'left', True), ('a-up', 'left', False),
            ('d', 'right', True), ('d-up', 'right', False),
            ('q', 'up', True), ('q-up', 'up', False),
            ('e', 'down', True), ('e-up', 'down', False),
        ]
        for key, action, value in key_actions:
            self.base.accept(key, self.set_key, [action, value])

    def set_key(self, key, value):
        """
        キー入力状態を設定します。

        Args:
            key (str): キー名。
            value (bool): キーの状態(True: 押下、False: 離された)。
        """
        self.key_map[key] = value

    def enable_mouse_control(self):
        """
        マウス制御を有効化し、カーソルを非表示にします。
        """
        props = WindowProperties()
        props.setCursorHidden(True)
        self.base.win.requestProperties(props)
        self.base.win.movePointer(0, int(self.base.win.getProperties().getXSize() / 2),
                                  int(self.base.win.getProperties().getYSize() / 2))
        self.prev_mouse_pos = None

    def disable_mouse_control(self):
        """
        マウス制御を無効化し、カーソルを表示します。
        """
        props = WindowProperties()
        props.setCursorHidden(False)
        self.base.win.requestProperties(props)

    def update(self, task):
        """
        カメラの状態を更新するタスク。

        Args:
            task: Panda3Dのタスクオブジェクト。

        Returns:
            task.cont: タスクを継続する。
        """
        dt = globalClock.getDt()
        if self.mode == 'internal':
            self.control_internal_camera(dt)
        elif self.mode == 'external':
            self.control_external_camera(dt)
        return task.cont

    def control_internal_camera(self, dt):
        """
        内部カメラ(プレイヤー視点)の制御。

        Args:
            dt (float): フレーム間の経過時間。
        """
        speed = 200 * dt
        x_direction = self.base.camera.getMat().getRow3(0)
        y_direction = self.base.camera.getMat().getRow3(1)
        camera_x, camera_y, camera_z = self.base.camera.getPos()

        if self.key_map['forward']:
            camera_x = camera_x + y_direction.x * speed
            camera_y = camera_y + y_direction.y * speed
        if self.key_map['backward']:
            camera_x = camera_x - y_direction.x * speed
            camera_y = camera_y - y_direction.y * speed
        if self.key_map['left']:
            camera_x = camera_x - x_direction.x * speed
            camera_y = camera_y - x_direction.y * speed
        if self.key_map['right']:
            camera_x = camera_x + x_direction.x * speed
            camera_y = camera_y + x_direction.y * speed
        if self.key_map['up']:
            camera_z = camera_z + speed
        if self.key_map['down']:
            camera_z = camera_z - speed

        self.base.camera.setPos(camera_x, camera_y, camera_z)

        # マウスによる視点の回転
        if self.base.mouseWatcherNode.hasMouse():
            x = self.base.mouseWatcherNode.getMouseX()
            y = self.base.mouseWatcherNode.getMouseY()
            if self.prev_mouse_pos is not None:
                dx = x - self.prev_mouse_pos[0]
                dy = y - self.prev_mouse_pos[1]
                heading = self.base.camera.getH() - dx * Camera.heading_angular_velocity * speed
                pitch = self.base.camera.getP() + dy * Camera.pitch_angular_velocity * speed
                pitch = min(Camera.max_pitch_angle, max(Camera.min_pitch_angle, pitch))
                self.base.camera.setH(heading)
                self.base.camera.setP(pitch)
            self.prev_mouse_pos = (x, y)

    def control_external_camera(self, dt):
        """
        外部カメラ(鳥瞰視点)の制御。

        Args:
            dt (float): フレーム間の経過時間。
        """
        speed = 500 * dt
        if self.key_map['forward']:
            self.base.camera.setY(self.base.camera, speed)
        if self.key_map['backward']:
            self.base.camera.setY(self.base.camera, -speed)
        if self.key_map['left']:
            self.base.camera.setX(self.base.camera, -speed)
        if self.key_map['right']:
            self.base.camera.setX(self.base.camera, speed)
        if self.key_map['up']:
            self.base.camera.setZ(self.base.camera.getZ() + speed)
        if self.key_map['down']:
            self.base.camera.setZ(self.base.camera.getZ() - speed)

        # マウスによるカメラの回転
        if self.base.mouseWatcherNode.hasMouse():
            x = self.base.mouseWatcherNode.getMouseX()
            y = self.base.mouseWatcherNode.getMouseY()
            if self.prev_mouse_pos is not None:
                dx = x - self.prev_mouse_pos[0]
                dy = y - self.prev_mouse_pos[1]
                self.base.camera.setH(self.base.camera.getH() - dx * 100 * self.mouse_sensitivity)
                self.base.camera.setP(self.base.camera.getP() - dy * 100 * self.mouse_sensitivity)
            self.prev_mouse_pos = (x, y)

このクラスは、Panda3Dを用いてカメラの挙動を制御するためのものです。外部視点(鳥瞰)と内部視点(プレイヤー視点)の切り替えが可能で、キーボードやマウスを用いてカメラを移動・回転させる機能を提供します。


主な機能

  1. 外部カメラモードと内部カメラモードの切り替え

    • 鳥瞰視点で都市全体を見渡す「外部モード」と、プレイヤーの目線に近い視点を提供する「内部モード」を切り替え可能です。

  2. カメラの移動制御

    • キーボード入力に応じて、カメラを前後・左右・上下に移動させます。

  3. カメラの回転制御

    • マウスの動きを検知し、視点を回転させます。水平(Heading)および垂直(Pitch)の角度を調整します。

  4. マウス感度や回転速度のカスタマイズ

    • マウス感度や回転速度の設定を行い、操作性を調整可能です。

  5. シーングラフとの統合

    • Panda3Dのシーングラフに統合され、カメラがゲーム内で自然に機能するように設計されています。


詳細な解説

1. クラス変数の定義

    heading_angular_velocity = 1500  # カメラの水平回転速度
    pitch_angular_velocity = 500  # カメラの垂直回転速度
    max_pitch_angle = 60  # カメラの最大ピッチ角度
    min_pitch_angle = -80  # カメラの最小ピッチ角度
  • 水平回転や垂直回転の速度を設定。

  • 垂直方向の回転は、視点が真上や真下を向かないように制限を設けています。


2. 初期化処理

    def __init__(self, base):
        self.base = base
        self.mode = 'external'  # 'external'(外部視点)または 'internal'(内部視点)
  • 初期状態では外部モード('external')が選択されています。この変数の値を判定して、外部カメラと内部カメラの切り替えを行います。


3. 外部カメラと内部カメラの設定

    def setup_external_camera(self):
        """
        外部カメラの設定を行います。
        """
        self.base.disableMouse()  # デフォルトのカメラ制御を無効化
        self.base.camera.setPos(2048, -5000, 2048)  # カメラを上空に配置
        self.base.camera.lookAt(0, 0, 0)  # 原点を注視
        self.enable_mouse_control()  # マウスでカメラを回転可能にする
  • 外部カメラでは都市全体を俯瞰するように高い位置に配置し、原点(都市の中心)を注視します。

    def setup_internal_camera(self):
        """
        内部カメラの設定を行います。
        """
        self.base.disableMouse()
        self.base.camera.setPos(0, 0, 1.6)  # カメラを地面上に配置
        self.base.camera.setHpr(0, 0, 0)  # カメラの回転を初期化
        self.disable_mouse_control()
  • 内部カメラではプレイヤーの目線に近い位置(地上約1.6m)に配置。

  • プレイヤーは前後左右上下にキー操作で移動できます。


4. カメラモードの切り替え

    def toggle_mode(self):
        """
        カメラモードを切り替えます。
        """
        self.mode = 'internal' if self.mode == 'external' else 'external'
        self.setup_cameras()
  • 'c'キーを押すと、カメラモードが外部と内部で切り替わります。


5. キー入力の設定

    def setup_key_bindings(self):
        """
        キー入力のマッピングを設定します。
        """
        key_actions = [
            ('w', 'forward', True), ('w-up', 'forward', False),
            ('s', 'backward', True), ('s-up', 'backward', False),
            ('a', 'left', True), ('a-up', 'left', False),
            ('d', 'right', True), ('d-up', 'right', False),
            ('q', 'up', True), ('q-up', 'up', False),
            ('e', 'down', True), ('e-up', 'down', False),
        ]
        for key, action, value in key_actions:
            self.base.accept(key, self.set_key, [action, value])
  • キー(WASDやQE)に対して、カメラの移動アクションを設定。

    def set_key(self, key, value):
        """
        キー入力状態を設定します。

        Args:
            key (str): キー名。
            value (bool): キーの状態(True: 押下、False: 離された)。
        """
        self.key_map[key] = value
  • 各キーの状態を管理する辞書(key_map)に値を更新します。

  • この辞書の値を変更することで、キー長押しによる連続移動を可能にしています。


6. 内部カメラの移動

    def control_internal_camera(self, dt):
        """
        内部カメラ(プレイヤー視点)の制御。

        Args:
            dt (float): フレーム間の経過時間。
        """
        speed = 200 * dt
        x_direction = self.base.camera.getMat().getRow3(0)
        y_direction = self.base.camera.getMat().getRow3(1)
        camera_x, camera_y, camera_z = self.base.camera.getPos()
  • カメラの移動方向を取得し、キー入力に応じて移動。

        if self.key_map['forward']:
            camera_x += y_direction.x * speed
            camera_y += y_direction.y * speed
        if self.key_map['backward']:
            camera_x -= y_direction.x * speed
            camera_y -= y_direction.y * speed
        if self.key_map['left']:
            camera_x -= x_direction.x * speed
            camera_y -= x_direction.y * speed
        if self.key_map['right']:
            camera_x += x_direction.x * speed
            camera_y += x_direction.y * speed
        if self.key_map['up']:
            camera_z += speed
        if self.key_map['down']:
            camera_z -= speed
  • forwardなどのキーが押されている間、カメラを該当方向に動かします。


7. 内部カメラのマウスによる回転

        # マウスによる視点の回転
        if self.base.mouseWatcherNode.hasMouse():
            x = self.base.mouseWatcherNode.getMouseX()
            y = self.base.mouseWatcherNode.getMouseY()
  • マウスの位置を取得し、前フレームとの差分で回転を計算。


                dx = x - self.prev_mouse_pos[0]
                dy = y - self.prev_mouse_pos[1]
                heading = self.base.camera.getH() - dx * Camera.heading_angular_velocity * speed
                pitch = self.base.camera.getP() + dy * Camera.pitch_angular_velocity * speed
                pitch = min(Camera.max_pitch_angle, max(Camera.min_pitch_angle, pitch))
                self.base.camera.setH(heading)
                self.base.camera.setP(pitch)
  • カメラの水平回転(Heading)と垂直回転(Pitch)を調整します。

4.3.5 アプリの実行ファイル

最後に、上記の機能別ファイルを統合して、3D都市アプリケーションを実行する起動ファイルを作成します。既に作成済みのmain.pyに次のコードを記載します。

from direct.showbase.ShowBase import ShowBase
from panda3d.core import WindowProperties, CardMaker, TransparencyAttrib
from PIL import Image
from city.data_loader import DataLoader
from city.building_render import BuildingRenderer
from city.camera import Camera


class BuildingApp(ShowBase):
    # 元の頂点数と簡略化後の頂点数
    vertex_count = 0
    simplified_vertex_count = 0
    # アプリの基本設定
    use_wireframe = False  # ワイヤーフレーム表示の有無
    use_simplified_coords = True  # 簡略化された座標を使用するかどうか
    image_path = 'images/mirror_ball.png'

    def __init__(self, zoom_level, tile_x, tile_y):
        super().__init__()

        # インスタンス変数
        self.zoom_level = zoom_level

        # ウインドウの設定
        self.props = WindowProperties()
        self.props.setTitle('Plateau Urban Equalizer')  # タイトルを設定
        self.props.setSize(1600, 900)  # ウインドウサイズは環境に合わせて調整する。
        self.win.requestProperties(self.props)
        self.setBackgroundColor(0, 0, 0)  # ウインドウの背景色を黒に設定。

        # 全てを配置するノード
        self.world_node = self.render.attachNewNode('world_node')
        self.world_node.setPos(-2048, -2048, 0)  # 地図の中心を原点に移動

        # 座標軸を表示する
        self.axis = self.loader.loadModel('zup-axis')
        self.axis.reparentTo(self.render)
        self.axis.setScale(100)  # 座標軸の長さを設定

        # 地面を描画
        self.card = CardMaker('ground')
        self.card.setFrame(0, 4096, 0, 4096)
        self.ground = self.world_node.attachNewNode(self.card.generate())
        self.ground.setP(-90)
        self.ground.setColor(0, 0.5, 0, 0.3)  # 緑色
        # 透明度属性とブレンディングを有効にする
        self.ground.setTransparency(TransparencyAttrib.MAlpha)

        # カメラコントローラーを初期化
        self.camera_controller = Camera(self)

        # 背景画像の読み込み
        self.background_image = Image.open(self.image_path)
        self.image_width, self.image_height = self.background_image.size

        # 全てのビルを配置するノード
        self.buildings_node = self.world_node.attachNewNode('buildings_node')

        # 建物データをロード
        self.building_list = []
        DataLoader(self, zoom_level, tile_x, tile_y)
        print("元の頂点数:", self.vertex_count)
        print("簡略化後の頂点数:", self.simplified_vertex_count)

        # ビルを配置
        for building in self.building_list:
            BuildingRenderer(building)

        # アプリの停止
        self.accept('escape', exit)


if __name__ == '__main__':
    # 渋谷駅のタイル座標35.6582972,139.70146677
    # ズームレベル 14: x=14549, y=6452
    # ズームレベル 15: x=29099, y=12905
    # ズームレベル 16: x=58199, y=25811

    # タイル座標を指定
    # z = 14
    # x = 14549
    # y = 6452
    z = 15
    x = 29099
    y = 12905
    # z = 16
    # x = 58199
    # y = 25811

    app = BuildingApp(z, x, y)
    app.run()

この起動ファイルは、Panda3Dを使用して都市の3Dビルディングをレンダリングおよび可視化するアプリケーションを起動するためのものです。以下に、コードの各部分について詳細に解説します。

インポート部分

from direct.showbase.ShowBase import ShowBase
from panda3d.core import WindowProperties, CardMaker, TransparencyAttrib
from PIL import Image
from city.data_loader import DataLoader
from city.building_render import BuildingRenderer
from city.camera import Camera
  1. Panda3D関連のインポート:

    • ShowBase:

      • Panda3Dの基本クラスであり、アプリケーションの基盤を提供します。これを継承することで、Panda3Dの機能を活用したアプリケーションを構築できます。

    • panda3d.core:

      • Panda3Dのコア機能を提供するモジュールです。ここでは、WindowProperties, CardMaker, TransparencyAttribオブジェクトをインポートしています。

  2. PIL (Python Imaging Library) のインポート:

    • 画像処理を行うためのライブラリで、ここでは背景画像の読み込みに使用されています。

  3. カスタムモジュールのインポート:

    • DataLoader:

      • 建物データをロードするためのクラス。

    • BuildingRenderer:

      • ロードした建物データをレンダリングするためのクラス。3Dモデルとしてビルを表示します。

    • Camera:

      • カメラの制御を行うクラス。ユーザーがカメラを移動できるようにします。

BuildingApp クラス

class BuildingApp(ShowBase):
    # 元の頂点数と簡略化後の頂点数
    vertex_count = 0
    simplified_vertex_count = 0
    # アプリの基本設定
    use_wireframe = False  # ワイヤーフレーム表示の有無
    use_simplified_coords = True  # 簡略化された座標を使用するかどうか
    image_path = 'images/mirror_ball.png'
  • クラス継承:

    • BuildingApp は ShowBase を継承しています。これにより、Panda3Dの基本機能(レンダリング、ウィンドウ管理、イベント処理など)を利用できます。

  • クラス変数:

    • vertex_count と simplified_vertex_count:

      • ビルの元の頂点数と簡略化後の頂点数を追跡するための変数。

    • use_wireframe:

      • ワイヤーフレーム表示の有無を制御します。デバッグや視覚的な効果に使用できます。

    • use_simplified_coords:

      • 簡略化された座標を使用するかどうかを制御します。複雑なモデルを軽量化する際に役立ちます。

    • image_path:

      • 背景画像のパス('images/mirror_ball.png')を指定します。mirror_ball.pngは次のファイルをダウンロードして、imagesフォルダーに保存して下さい。

図7 ミラーボール(DALL-Eにより生成)

__init__ メソッド

def __init__(self, zoom_level, tile_x, tile_y):
    super().__init__()

    # インスタンス変数
    self.zoom_level = zoom_level

    # ウインドウの設定
    self.props = WindowProperties()
    self.props.setTitle('Plateau Urban Equalizer')  # タイトルを設定
    self.props.setSize(1600, 900)  # ウインドウサイズは環境に合わせて調整する。
    self.win.requestProperties(self.props)
    self.setBackgroundColor(0, 0, 0)  # ウインドウの背景色を黒に設定。

    # 全てを配置するノード
    self.world_node = self.render.attachNewNode('world_node')
    self.world_node.setPos(-2048, -2048, 0)  # 地図の中心を原点に移動

    # 座標軸を表示する
    self.axis = self.loader.loadModel('zup-axis')
    self.axis.reparentTo(self.render)
    self.axis.setScale(100)  # 座標軸の長さを設定

    # 地面を描画
    self.card = CardMaker('ground')
    self.card.setFrame(0, 4096, 0, 4096)
    self.ground = self.world_node.attachNewNode(self.card.generate())
    self.ground.setP(-90)
    self.ground.setColor(0, 0.5, 0, 0.3)  # 緑色
    # 透明度属性とブレンディングを有効にする
    self.ground.setTransparency(TransparencyAttrib.MAlpha)

    # カメラコントローラーを初期化
    self.camera_controller = Camera(self)

    # 背景画像の読み込み
    self.background_image = Image.open(self.image_path)
    self.image_width, self.image_height = self.background_image.size

    # 全てのビルを配置するノード
    self.buildings_node = self.world_node.attachNewNode('buildings_node')

    # 建物データをロード
    self.building_list = []
    DataLoader(self, zoom_level, tile_x, tile_y)
    print("元の頂点数:", self.vertex_count)
    print("簡略化後の頂点数:", self.simplified_vertex_count)

    # ビルを配置
    for building in self.building_list:
        BuildingRenderer(building)

    # アプリの停止
    self.accept('escape', exit)
  1. コンストラクタの初期化:

    • super().__init__():

      • ShowBase のコンストラクタを呼び出し、Panda3Dの基本機能を初期化します。

  2. インスタンス変数の設定:

    • self.zoom_level = zoom_level:

      • ズームレベルをインスタンス変数として保存します。これはタイル座標の解像度に影響します。

  3. ウインドウの設定:

    • WindowProperties を使用してウインドウのタイトルとサイズを設定します。

    • self.win.requestProperties(self.props):

      • ウインドウプロパティを適用します。

    • self.setBackgroundColor(0, 0, 0):

      • 背景色を黒に設定します。

  4. シーンのノード構造:

    • self.world_node = self.render.attachNewNode('world_node'):

      • 全てのオブジェクトを配置するための親ノードを作成します。

      • self.world_node.setPos(-2048, -2048, 0):

        • 地図の中心を原点に移動します。座標系の調整です。

  5. 座標軸の表示:

    • self.loader.loadModel('zup-axis'):

      • Panda3Dに標準で用意されている座標軸モデルをロードします。

    • self.axis.reparentTo(self.render):

      • シーンに座標軸を追加します。

    • self.axis.setScale(100):

      • 座標軸の大きさを調整します。

  6. 地面の描画:

    • CardMaker を使用して地面を描画します。

    • self.card.setFrame(0, 4096, 0, 4096):

      • 地面のサイズを設定します。

    • self.ground.setP(-90):

      • 地面を適切な角度に回転させます(ここではピッチを -90 度)。

    • self.ground.setColor(0, 0.5, 0, 0.3):

      • 地面の色を緑色(透明度 0.3)に設定します。

    • self.ground.setTransparency(TransparencyAttrib.MAlpha):

      • 透明度を有効にし、半透明の地面を実現します。

  7. カメラコントローラーの初期化:

    • self.camera_controller = Camera(self):

      • カメラの制御を行うカスタムクラス Camera を初期化します。これにより、ユーザーはシーン内をナビゲートできるようになります。

  8. 背景画像の読み込み:

    • self.background_image = Image.open(self.image_path):

      • PILを使用して背景画像を読み込みます。

    • self.image_width, self.image_height = self.background_image.size:

      • 画像の幅と高さを取得します。これは後続の処理で使用される可能性があります。

  9. ビルディングノードの作成:

    • self.buildings_node = self.world_node.attachNewNode('buildings_node'):

      • 全てのビルディングオブジェクトを配置するための親ノードを作成します。

  10. 建物データのロード:

    • self.building_list = []:

      • 建物データを格納するリストを初期化します。

    • DataLoader(self, zoom_level, tile_x, tile_y):

      • DataLoader クラスを使用して、指定されたズームレベルとタイル座標から建物データをロードします。DataLoader はロードしたデータを self.building_list に追加することが想定されます。

    • print("元の頂点数:", self.vertex_count) と print("簡略化後の頂点数:", self.simplified_vertex_count):

      • ロードした建物データの頂点数を表示します。これにより、データの複雑さや簡略化の効果を確認できます。

  11. ビルディングのレンダリング:

    • for building in self.building_list::

      • ロードした各建物データに対してループを実行します。

    • BuildingRenderer(building):

      • BuildingRenderer クラスを使用して、各建物データを3Dモデルとしてレンダリングします。これにより、シーン内にビルディングが表示されます。

  12. アプリケーションの終了設定:

    • self.accept('escape', exit):

      • ユーザーが Escape キーを押すと、アプリケーションが終了するように設定します。これにより、簡単にアプリを閉じることができます。

メイン部分

if __name__ == '__main__':
    # 渋谷駅のタイル座標35.6582972,139.70146677
    # ズームレベル 14: x=14549, y=6452
    # ズームレベル 15: x=29099, y=12905
    # ズームレベル 16: x=58199, y=25811

    # タイル座標を指定
    # z = 14
    # x = 14549
    # y = 6452
    z = 15
    x = 29099
    y = 12905
    # z = 16
    # x = 58199
    # y = 25811

    app = BuildingApp(z, x, y)
    app.run()
  1. エントリーポイントのチェック:

    • if __name__ == '__main__'::

      • このファイルが直接実行された場合にのみ、以下のコードブロックが実行されます。他のファイルからインポートされた場合は実行されません。

  2. タイル座標の設定:

    • 渋谷駅の緯度経度 35.6582972, 139.70146677 を基にしたタイル座標の例がコメントされています。

    • 各ズームレベルに対応するタイル座標 (x, y) が示されています。

      • ズームレベル 14: x=14549, y=6452

      • ズームレベル 15: x=29099, y=12905

      • ズームレベル 16: x=58199, y=25811

    • 実際には、ズームレベル 15 のタイル座標 (x=29099, y=12905) が使用されています。ズームレベルを変更することで、表示する地図の詳細度を調整できます。

    • 緯度経度からズームレベルに対応するx, yの値を調べる方法は、末尾の付録を参照して下さい。

  3. アプリケーションのインスタンス化と実行:

    • app = BuildingApp(z, x, y):

      • BuildingApp クラスのインスタンスを作成します。ここではズームレベル 15 のタイル座標が渡されています。

    • app.run():

      • Panda3Dのメインループを開始し、アプリケーションを実行します。

以上で、コードの解説は完了です。コードを実行してみましょう。

4.4 3Dアプリケーションの実行

ターミナルを起動して、cdコマンドでplateau-urban-equalizerフォルダーに移動します。次のコマンドを実行して、アプリを起動します。

python main.py
図8 外部カメラで3D都市を俯瞰する
図9 内部カメラでビルを見上げる
図10 内部カメラを上空に移動して見下ろす

図7、8、9は、外部カメラおよび内部カメラで撮影した3D都市の画像です。ビルの色は、図7のミラーボールを反映してカラフルに表示されています。カメラは「WSAD」キーで前後左右に、「QE」キーで上下に動かすことができます。また、マウスホイールを使用してカメラを回転させることが可能です。

アプリを終了するときは、ウインドウの「x」ボタンをクリックするか、ESCAPEキーを押して下さい。

3D都市の背景画像は入れ替えることができます。imagesフォルダーにお好きな画像を保存した後、クラス変数 image_path のファイル名を変更してください。再起動すると、別の背景画像を基にした3D都市が作成されます。

以上で、3D都市アプリケーションは完成です。次に、3D都市アプリケーションの応用例の一つとして、サウンドエコライザー機能を実装し、音楽と都市景観を融合させた「PLATEAU Urban Equalizer」を作成します。

5. サウンドエコライザー機能の実装

ここから、3D都市アプリケーションの応用として、音楽に合わせてビルの高さが上下するサウンドエコライザー機能を実装します。本アプリケーションでは、ビルごとに別々のノードを持つため、ビルの位置情報に基づいて音の強さを取得し、ビルノードの高さを変化させることで、エコライザーのような外観を再現することが可能です。

5.1 概要

サウンドエコライザー機能は、音楽のリズムや音量に応じて3D都市内のビルの高さを動的に変化させることで、視覚と聴覚の融合を実現する機能です。この機能により、ユーザーは音楽のビートに合わせてビルが躍動するようなインタラクティブな体験を楽しむことができます。

具体的には、音楽の強弱や周波数に基づいてビルの高さをリアルタイムで調整し、エコライザーのような視覚効果を都市景観に取り入れます。これにより、音楽と都市のビジュアルが一体となり、より没入感のあるアプリケーションとなります。

5.2 ライブラリのインストール

サウンドエコライザー機能を実装するためには、音声処理と再生を行うためのPythonライブラリが必要です。以下のコマンドを使用して、必要なライブラリをインストールしてください。

pip install pydub sounddevice
  • pydub: 音声ファイルの読み込みや加工を簡単に行うためのライブラリです。音楽ファイルの解析や波形データの取得に使用します。

  • sounddevice: オーディオの再生や録音を行うためのライブラリです。リアルタイムで音声を再生し、そのデータをエコライザー機能に渡す役割を担います。

これらのライブラリをインストールすることで、音楽の再生と音声データの取得が可能となり、エコライザー機能の基盤を構築できます。

5.3 サウンドエコライザーの実装

サウンドエコライザー機能の実装は、以下の3つの主要なコンポーネントから構成されます。

  1. 音楽再生 (Sound.py)

  2. エコライザー (Equalizer.py)

  3. 起動ファイル (main.py) の修正

5.3.1 音楽再生と分析機能

音楽機能を実装します。cityフォルダーにsound.pyを作成して、次のコードを記載します。

import numpy as np
from pydub import AudioSegment
import sounddevice as sd
from queue import Queue
import threading


class Sound:
    def __init__(self, file_path, chunk_size=1024, update_interval=0.1):
        # 音声データの読み込みと前処理
        self.audio = AudioSegment.from_mp3(file_path).set_channels(1).set_frame_rate(44100)
        self.raw_data = np.frombuffer(self.audio.raw_data, dtype=np.int16)
        self.sample_rate = self.audio.frame_rate
        self.chunk_size = chunk_size
        self.update_interval = update_interval

        # 再生用のフレームカウンタ(現在の再生位置を追跡)
        self.audio_frame = [0]

        # 振幅データを格納するキュー(解析結果を格納)
        self.amplitude_queue = Queue()

        # 再生中フラグ(スレッドセーフな状態管理)
        self.is_playing = threading.Event()

    def audio_callback(self, outdata, frames, time, status):
        """
        再生中に呼び出されるコールバック関数。音声データを出力し、振幅データを解析します。

        Args:
            outdata (numpy.ndarray): 出力バッファ。
            frames (int): 再生するフレーム数。
            time: 再生タイミング情報(未使用)。
            status: 再生ステータス情報(未使用)。

        Raises:
            sd.CallbackStop: 音声データが終了した場合。
        """
        # 再生範囲を計算
        start = self.audio_frame[0]
        end = start + frames
        data = self.raw_data[start:end]

        # データが足りない場合(音声の終端)
        if len(data) < frames:
            # ゼロでパディングして無音を補充
            data = np.pad(data, (0, frames - len(data)), 'constant')

            # 出力バッファにデータをコピー
            outdata[:len(data)] = data.reshape(-1, 1)
            outdata[len(data):] = np.zeros((frames - len(data), 1), dtype='int16')

            # 振幅を計算してキューに追加
            amplitude = self._calculate_amplitude(data)
            self.amplitude_queue.put(amplitude)

            # 再生終了を通知
            raise sd.CallbackStop()

        # 通常データ処理
        outdata[:] = data.reshape(-1, 1)

        # 振幅を計算してキューに追加
        amplitude = self._calculate_amplitude(data)
        self.amplitude_queue.put(amplitude)

        # 再生位置を更新
        self.audio_frame[0] += frames

    def play(self):
        """
        音声データの再生を開始します。
        """
        # 再生中フラグをセット
        self.is_playing.set()

        # サウンドデバイスのストリームを開いて再生
        with sd.OutputStream(
            samplerate=self.sample_rate,
            channels=1,
            dtype='int16',
            callback=self.audio_callback,
            blocksize=int(self.sample_rate * self.update_interval)
        ):
            # 音声データの再生時間に応じてスリープ
            sd.sleep(int(len(self.raw_data) / self.sample_rate * 1000))

        # 再生が完了したら再生中フラグをクリア
        self.is_playing.clear()

    def _calculate_amplitude(self, data):
        """
        振幅(RMS値)を計算します。

        Args:
            data (numpy.ndarray): 音声データ。

        Returns:
            float: 振幅のRMS値。
        """
        return np.sqrt(np.mean(data.astype(np.float32) ** 2))

このクラスは、指定されたMP3ファイルを読み込み、音声を再生しながら音量(振幅)をリアルタイムで解析するためのものです。

1. 初期化メソッド (__init__)

def __init__(self, file_path, chunk_size=1024, update_interval=0.1):
  • file_path: 再生するMP3ファイルのパス。

  • chunk_size: 再生時に処理するデータの単位(フレーム数)。

  • update_interval: 再生の更新間隔(秒)。

音源の読み込み

self.audio = AudioSegment.from_mp3(file_path)
self.audio = self.audio.set_channels(1)  # モノラルに変換
self.audio = self.audio.set_frame_rate(44100)  # サンプリングレートを設定
  • AudioSegment.from_mp3: pydubライブラリを使ってMP3ファイルを読み込みます。

  • 音声データは1チャンネル(モノラル)に変換し、サンプリングレートを44.1kHzに設定します。

self.raw_data = np.frombuffer(self.audio.raw_data, dtype=np.int16)
  • 音声データをバイト列からNumPy配列(int16型)に変換します。この配列を再生や振幅解析に利用します。

インスタンス変数

self.audio_frame = [0]
self.amplitude_queue = Queue()
self.is_playing = threading.Event()
  • self.audio_frame: 再生中の現在位置(フレーム単位)を追跡します。

  • self.amplitude_queue: 振幅データ(RMS値)を格納するキュー。

  • self.is_playing: 再生中かどうかを示すフラグ。


2. 音声データの再生 (play)

def play(self):

処理の流れ

  1. 再生中フラグをセット。

  2. OutputStream を使って音声をストリーミング再生。

  3. 再生が完了するまでスリープ。

self.is_playing.set()
with sd.OutputStream(...):
    sd.sleep(...)
self.is_playing.clear()
  • 再生が完了すると is_playing フラグをリセットします。

  • OutputStream の callback パラメータで、再生中のデータ処理を audio_callback メソッドに委譲します。


3. 再生中のコールバック処理 (audio_callback)

def audio_callback(self, outdata, frames, time, status):
  • 再生時にOutputStreamから呼び出されるコールバック関数です。

  • フレーム単位で音声データを再生し、振幅を解析します。

処理の流れ

処理1. 再生データを取得:

start = self.audio_frame[0]
end = start + frames
data = self.raw_data[start:end]
  • 再生位置(start から end)のデータを取得します。

処理2. データが不足している場合:

if len(data) < frames:
    data = np.pad(data, (0, frames - len(data)), 'constant')
    raise sd.CallbackStop()
  • 再生データが足りない場合、無音(0で埋める)を補填し、再生を停止します。

処理3. 振幅(RMS値)の計算:

amplitude = np.sqrt(np.mean(data.astype(np.float32) ** 2))
self.amplitude_queue.put(amplitude)
  • data のRMS値を計算し、振幅データとしてキューに格納します。

処理4. 出力バッファにデータをコピー:

outdata[:] = data.reshape(-1, 1)

処理5. 再生位置を更新:

self.audio_frame[0] += frames

4. 振幅の計算 (_calculate_amplitude)

def _calculate_amplitude(self, data):
  • 振幅のRMS値を計算する補助関数です。

  • RMS値は音声信号の音量を示す指標で、イコライザーや視覚化エフェクトの入力データとして使用されます。

return np.sqrt(np.mean(data.astype(np.float32) ** 2))
  • データの二乗平均の平方根を計算し、RMS値を求めます。


音声解析で取得している情報

  • RMS値(振幅): 音声の音量(エネルギーの強さ)を示します。

    • 高いRMS値:音が大きい。

    • 低いRMS値:音が小さい。

    • 主に視覚化(例:イコライザー)に利用。

  • 周波数情報は含まれない: このコードは振幅解析に特化しており、周波数解析(例:FFT)は計算コストが高く、リアルタイム処理に適していないため行いません。

5.3.2 エコライザー機能

次に、エコライザー機能を実装するために、cityフォルダーの中にequalizer.pyを作成し、次のコードを記載します。

import math
from direct.stdpy import threading
from queue import Empty


class Equalizer:
    # 波の進行方向
    wave_directions = ['concentric', 'x_axis', 'y_axis', 'xy_axes']
    wave_direction_id = 0
    wave_direction = wave_directions[wave_direction_id]
    # 波のパラメータ
    wave_length = 500  # 波の長さ(空間的な波長)
    wave_speed = 2  # 波の速度(値を調整して波の進行速度を変える)
    wave_height_scale = 1000  # 波の高さを調整するスケール

    def __init__(self, base):
        self.base = base
        self.sound_thread = None

        # サウンドの再生とビルの高さ更新
        self.base.accept('space', self.play_app)

        # 波の進行方向を切り替え
        self.base.accept('z', self.switch_wave_direction)

    def switch_wave_direction(self):
        self.wave_direction_id = (self.wave_direction_id + 1) % len(self.wave_directions)
        self.wave_direction = self.wave_directions[self.wave_direction_id]
        print('Wave direction:', self.wave_direction)

    def play_app(self):
        if self.base.sound.is_playing.is_set():
            return

        self.play_sound()
        self.start_equalizer()

    def play_sound(self):
        # サウンドの再生を別スレッドで開始
        self.sound_thread = threading.Thread(target=self.base.sound.play)
        self.sound_thread.start()

    def start_equalizer(self):
        # ビルの高さを更新するタスクを追加
        self.base.taskMgr.doMethodLater(0.1, self.update_buildings_height_task, 'UpdateBuildingsTask')

    def update_buildings_height_task(self, task):
        if not self.base.sound.is_playing.is_set() and self.base.sound.amplitude_queue.empty():
            return task.done  # サウンドの再生が終了したらタスクを停止

        try:
            # 振幅データを取得
            amplitude = self.base.sound.amplitude_queue.get_nowait()
        except Empty:
            # 振幅データがまだない場合は次のフレームへ
            return task.cont

        # 振幅を正規化(0〜1の範囲)
        max_amplitude = 32768  # int16の最大値
        normalized_amplitude = amplitude / max_amplitude

        # 現在の時間を取得
        current_time = globalClock.getFrameTime()

        # ビルの高さを更新
        for building in self.base.building_list:
            x, y = building.centroid.x, building.centroid.y

            # 波の位相を計算
            if self.wave_direction == 'x_axis':
                phase = x / self.wave_length - self.wave_speed * current_time
            elif self.wave_direction == 'y_axis':
                phase = y / self.wave_length - self.wave_speed * current_time
            elif self.wave_direction == 'xy_axes':
                phase = (x + y) / self.wave_length - self.wave_speed * current_time
            else:  # 'concentric'
                phase = math.sqrt((x - 2048) ** 2 + (y - 2048) ** 2) / self.wave_length - self.wave_speed * current_time

            # 波の高さを計算
            wave_height = normalized_amplitude * math.sin(phase) * self.wave_height_scale + self.wave_height_scale / 4

            # ビルの高さを更新
            if building.node:
                building.node.setSz(max(wave_height, 1))  # 最小高さを1に設定

        return task.cont  # タスクを継続

このクラスは、音楽の振幅(音量)に応じて波のようなエフェクトを生成し、ビルの高さを動的に変化させる機能を提供します。
音楽のリアルタイム解析結果に基づき、以下のような動作をします:

  1. 音楽の再生を開始。

  2. 音量データ(振幅)を基に波状のエフェクトを計算。

  3. 各ビルの高さを波のエフェクトに従って更新。

また、波の進行方向(例: 同心円状、X軸方向など)を切り替える機能もあります。


1. クラス変数

# 波のパラメータ
wave_length = 500  # 波の長さ(空間的な波長)
wave_speed = 2  # 波の速度(値を調整して波の進行速度を変える)
wave_height_scale = 1000  # 波の高さを調整するスケール
  1. wave_length:

    • 波長(1周期の空間的な長さ)を設定します。値を大きくすると波が広がり、小さくすると密になります。

  2. wave_speed:

    • 波の速度を設定します。この値を変更することで波の進行スピードを調整可能です。

  3. wave_height_scale:

    • 波の高さを調整するスケール係数です。振幅データを元にした高さをさらに強調するために使用します。


2. 初期化 (__init__)

    def __init__(self, base):
        self.base = base
        # 波の進行方向
        self.wave_directions = ['concentric', 'x_axis', 'y_axis', 'xy_axes']
        self.wave_direction_id = 0
        self.wave_direction = self.wave_directions[self.wave_direction_id]
        self.sound_thread = None

        # サウンドの再生とビルの高さ更新
        self.base.accept('space', self.play_app)

        # 波の進行方向を切り替え
        self.base.accept('z', self.switch_wave_direction)

    def switch_wave_direction(self):
        self.wave_direction_id = (self.wave_direction_id + 1) % len(self.wave_directions)
        self.wave_direction = self.wave_directions[self.wave_direction_id]
        print('Wave direction:', self.wave_direction)
  1. base:

    • アプリケーション全体の状態や構成要素を持つオブジェクトを参照します(例: サウンドやビルリスト)。

  2. wave_directions:

    • 波の進行方向を定義します。

    • 同心円状(concentric)、X軸方向、Y軸方向、XY軸方向の4種類があります。

    • wave_direction_idによって、波の種類を切り替えられるようにします。

  3. キー入力イベントの登録:

    • space キーで音楽再生と波エフェクトを開始(play_app)。

    • z キーで波の進行方向を切り替え(switch_wave_direction)。


3. 波の進行方向の切り替え (switch_wave_direction)

def switch_wave_direction(self):
    self.wave_direction_id = (self.wave_direction_id + 1) % len(self.wave_directions)
    self.wave_direction = self.wave_directions[self.wave_direction_id]
    print('Wave direction:', self.wave_direction)
  1. wave_direction_id をインクリメントして次の波の方向を選択します。

  2. リストの最後まで進んだら最初に戻るように % 演算を使用。

  3. 現在の波の方向をコンソールに表示。


4. 音楽再生とエフェクトの開始 (play_app)

def play_app(self):
    if self.base.sound.is_playing.is_set():
        return

    self.play_sound()
    self.start_equalizer()
  1. is_playing フラグで既に再生中か確認します。再生中なら処理を中断。

  2. 音楽の再生を開始(play_sound)。

  3. ビルの高さを更新するタスクを開始(start_equalizer)。


5. 音楽の再生 (play_sound)

def play_sound(self):
    self.sound_thread = threading.Thread(target=self.base.sound.play)
    self.sound_thread.start()
  • 音楽の再生処理を別スレッドで実行します。スレッドとは、プログラム内で並行して実行できる処理単位のことです。

  • threading.Thread は、新しいスレッドを作成するためのクラスです。

  • target=self.base.sound.play によって、play メソッドがスレッド内で実行されます。

なぜスレッドを使用するのか?

  • Pythonがシングルスレッドで動作する制約(GIL)を考慮しつつ、音楽再生処理を効率的に並行実行するためです。

  • 音楽の再生処理には時間がかかるため、スレッドを分けることでアプリケーション全体がスムーズに動作します。

スレッドの開始

  • start() メソッドを呼び出すと、新しいスレッドが作成され、指定したターゲットメソッドが実行されます。


6. エフェクトの開始 (start_equalizer)

def start_equalizer(self):
    self.base.taskMgr.doMethodLater(0.1, self.update_buildings_height_task, 'UpdateBuildingsTask')
  • タスクマネージャにタスク(update_buildings_height_task)を0.1秒ごとに実行するよう登録します。


7. ビルの高さを更新 (update_buildings_height_task)

def update_buildings_height_task(self, task): if not self.base.sound.is_playing.is_set() and self.base.sound.amplitude_queue.empty(): return task.done

  • 音楽の再生が終了し、振幅データが空の場合はタスクを停止。

振幅データの取得

amplitude = self.base.sound.amplitude_queue.get_nowait()
normalized_amplitude = amplitude / 32768
  • 音楽の振幅データ(RMS値)を取得し、int16 の最大値(32768)で正規化します(範囲は 0〜1)。

波の位相計算

if self.wave_direction == 'x_axis':
    phase = x / self.wave_length - self.wave_speed * current_time
elif self.wave_direction == 'y_axis':
    phase = y / self.wave_length - self.wave_speed * current_time
elif self.wave_direction == 'xy_axes':
    phase = (x + y) / self.wave_length - self.wave_speed * current_time
else:  # 'concentric'
    phase = math.sqrt((x - 2048) ** 2 + (y - 2048) ** 2) / self.wave_length - self.wave_speed * current_time
  • 波の進行方向に応じて位相(波の進行状況)を計算。

  • current_time はフレームタイム(現在のシステム時間)。

波の高さ計算

if self.wave_direction == 'x_axis':
    phase = x / self.wave_length - self.wave_speed * current_time
elif self.wave_direction == 'y_axis':
    phase = y / self.wave_length - self.wave_speed * current_time
elif self.wave_direction == 'xy_axes':
    phase = (x + y) / self.wave_length - self.wave_speed * current_time
else:  # 'concentric'
    phase = math.sqrt((x - 2048) ** 2 + (y - 2048) ** 2) / self.wave_length - self.wave_speed * current_time
  • 波の振幅に基づいてビルの高さを計算。

  • ビルの高さを setSz を使って更新(最小値は1)。

5.3.3 起動ファイル (main.py) の修正

main.py は、アプリケーションのエントリーポイントであり、BuildingApp クラスのインスタンスを生成し、アプリケーションを起動します。サウンドエコライザー機能を統合するために、以下の修正を行います。

次のコードに示すように、3箇所にコードを追記して下さい。

from direct.showbase.ShowBase import ShowBase
from panda3d.core import *
from PIL import Image
from city.data_loader import DataLoader
from city.building_render import BuildingRenderer
from city.camera import Camera
from city.sound import Sound  # ここから(1)
from city.equalizer import Equalizer  # ここまで(1)


class BuildingApp(ShowBase):
    # 元の頂点数と簡略化後の頂点数
    vertex_count = 0
    simplified_vertex_count = 0
    # アプリの基本設定
    use_wireframe = False  # ワイヤーフレーム表示の有無
    use_simplified_coords = True  # 簡略化された座標を使用するかどうか
    image_path = 'images/mirror_ball.png'
    sound_path = 'sound/Dive_To_Mod.mp3'  # (2)

    def __init__(self, zoom_level, tile_x, tile_y):
        super().__init__()

        # インスタンス変数
        self.zoom_level = zoom_level

        # ウインドウの設定
        self.props = WindowProperties()
        self.props.setTitle('Plateau Urban Equalizer')  # タイトルを設定
        self.props.setSize(1600, 900)  # ウインドウサイズは環境に合わせて調整する。
        self.win.requestProperties(self.props)
        self.setBackgroundColor(0, 0, 0)  # ウインドウの背景色を黒に設定。

        # 全てを配置するノード
        self.world_node = self.render.attachNewNode('world_node')
        self.world_node.setPos(-2048, -2048, 0)  # 地図の中心を原点に移動

        # 座標軸を表示する
        self.axis = self.loader.loadModel('zup-axis')
        self.axis.reparentTo(self.render)
        self.axis.setScale(100)  # 座標軸の長さを設定

        # 地面を描画
        self.card = CardMaker('ground')
        self.card.setFrame(0, 4096, 0, 4096)
        self.ground = self.world_node.attachNewNode(self.card.generate())
        self.ground.setP(-90)
        self.ground.setColor(0, 0.5, 0, 0.3)  # 緑色
        # 透明度属性とブレンディングを有効にする
        self.ground.setTransparency(TransparencyAttrib.MAlpha)

        # カメラコントローラーを初期化
        self.camera_controller = Camera(self)

        # 背景画像の読み込み
        self.background_image = Image.open(self.image_path)
        self.image_width, self.image_height = self.background_image.size

        # 全てのビルを配置するノード
        self.buildings_node = self.world_node.attachNewNode('buildings_node')

        # 建物データをロード
        self.building_list = []
        DataLoader(self, zoom_level, tile_x, tile_y)
        print("元の頂点数:", self.vertex_count)
        print("簡略化後の頂点数:", self.simplified_vertex_count)

        # ビルを配置
        for building in self.building_list:
            BuildingRenderer(building)

        # アプリの停止
        self.accept('escape', exit)

        # Soundクラスを初期化  # ここから(3)
        self.sound = Sound(self.sound_path)

        # イコライザーを初期化
        self.equalizer = Equalizer(self)  # ここまで(3)


if __name__ == '__main__':
    # 渋谷駅のタイル座標35.6582972,139.70146677
    # ズームレベル 14: x=14549, y=6452
    # ズームレベル 15: x=29099, y=12905
    # ズームレベル 16: x=58199, y=25811

    # タイル座標を指定
    # z = 14
    # x = 14549
    # y = 6452
    z = 15
    x = 29099
    y = 12905
    # z = 16
    # x = 58199
    # y = 25811

    app = BuildingApp(z, x, y)
    app.run()

このコードでは、サウンドエコライザー機能をアプリケーションに統合しています。以下は、各修正箇所の詳細な説明です。


1. ライブラリのインポート

from city.sound import Sound  # ここから(1)
from city.equalizer import Equalizer  # ここまで(1)
  • Sound: 音楽ファイルを読み込み、再生しながら振幅データをリアルタイムで取得するクラスです。これにより、音楽のボリュームに基づいてエコライザーを動かせます。

  • Equalizer: 音楽の振幅データを利用して、ビルの高さを動的に変更するクラスです。このクラスを追加することで、エコライザーの波形表現を実現します。


2. 音楽ファイルのパス設定

sound_path = 'sound/Dive_To_Mod.mp3'  # (2)
  • サウンドファイルのパスを指定しています。このファイルがエコライザーの基となる音楽データとして使用されます。

  • サウンドファイルは MP3形式 で用意する必要があります。ここでは、「Dive_To_Mod.mp3」というサウンドファイルを読み込むように設定しています。


3. イコライザー機能の初期化

# Soundクラスを初期化
self.sound = Sound(self.sound_path)

# イコライザーを初期化
self.equalizer = Equalizer(self)  # ここまで(3)
  • self.sound:

    • Sound クラスのインスタンスを作成し、音楽の再生や振幅データの収集を行います。

    • 音楽ファイルのロード、再生、リアルタイム解析を担当します。

  • self.equalizer:

    • Equalizer クラスのインスタンスを作成し、音楽の振幅データを基にビルの高さを動的に変更するエコライザー機能を初期化します。

    • self(BuildingApp)を引数に渡すことで、アプリケーション全体と連携します。

これらの修正により、アプリケーション起動時にサウンドエコライザー機能が有効化され、音楽と3D都市のビジュアルが連動するようになります。

以上で、すべてのコード解説が完了しました。サウンドエコライザー内蔵の3D都市アプリケーションを実行してみましょう。

5.4 サウンドエコライザーの実行

サウンドエコライザー機能を実行するための手順は以下の通りです。

5.4.1 音楽ファイルの準備

音楽ファイルを準備して、アプリケーションで音楽再生できるようにします。

  • お手持ちのMP3ファイルをsoundフォルダーに保存して、クラス変数sound_pathの値を「'sound/your_file_name.mp3'」に変更する

  • 適当なMP3ファイルをお持ちでないときは、著作権に気をつけて、音楽ファイルを配布サイトからダンロードします。本記事では、DOVA-SYNDROME様より。「Dive To Mod」をダウンロードして、soundフォルダーに保存する想定で書かれています。

  • 別のファイルをダウンロードした場合は、クラス変数sound_pathのファイル名を変更します。

フリーBGM素材「Dive To Mod」by MFP【Marron Fields Production】

5.4.2 アプリケーションの実行

ターミナルを起動して、cdコマンドでplateau-urban-equalizerフォルダーに移動します。次のコマンドを実行して、アプリを起動します。スペースキーを押すと、音楽再生が始まります。

python main.py
図11 円形ウェーブ
図12 Xウェーブ
図13 Yウェーブ
図14 XYウェーブ

図11から図14は、外部カメラから3D都市を俯瞰した画像です。Zキーを押すことで、波の方向を変更できます。キー操作やマウスホイールを使用して、カメラの位置や視点を調整し、異なる角度からエコライザー効果を楽しむことができます。

以上の手順を通じて、音楽と連動したサウンドエコライザー機能を搭載した3D都市アプリケーションを実行することができます。これにより、ユーザーは視覚と聴覚が一体となった新しい都市体験を楽しむことができます。

5.5 トラブルシューティング

各種トラブルシューティングをまとめます。

5.5.1 サウンド関係

音楽が再生できない原因として、次のようなものがあります。

  • サウンドパスが正しくない

    • サウンドパスを修正

  • MP3ファイルが壊れている

    • 別のMP3ファイルに入れ替える

  • ffmpegがインストールされていない

    • 以下のコマンドでインストール可能です。

      • macOS: brew install ffmpeg

      • Ubuntu: sudo apt install ffmpeg

      • Windows: 公式サイトからダウンロードしてPATHを設定。

5.5.2 画像関係

画像が表示できない原因として、次のようなものがあります。

  • 画像パスが正しくない

    • 画像パスを修正

  • 画像ファイルが壊れている

    • 別の画像ファイルに入れ替える

6. 応用アプリPLATEAU Pianoの作成

PLATEAU Urban Equalizer は拡張性を重視した設計がされています。各機能はモジュール単位で分離されているため、機能の追加や削除を簡単に行うことが可能です。

ここでは応用例として、3D都市をピアノの鍵盤に見立てるアプリケーション「PLATEAU Piano」の作成方法を解説します。この事例を通じて、PLATEAU Urban Equalizer を自由にカスタマイズし、自分のアイデアを実現する方法を学ぶことができます。

6.1 応用の指針

PLATEAU Urban Equalizer を改造する際の基本的な指針を以下に示します。

  • 1. 改造の元となる形

    • 第4章「3D都市の表示」の完成形が、改造のベースとなります。このベースに追加したい機能を組み込んでいきます。

  • 2. 新機能の追加方法

    • 追加したい機能は、city フォルダー内に「function.py」というファイルを作成し、その中にクラスとして実装します。この構造により、モジュール化が容易になり、管理もしやすくなります。

  • 3. クラスの読み込み

    • 新たに作成したクラスは、実行ファイルである main.py から読み込むことで、アプリケーションに組み込むことができます。

  • 4. リソースの変更

    • 背景画像やサウンドなどのリソースは、アプリケーションのテーマに合わせて適切なものに差し替えます。これにより、アプリの世界観をカスタマイズできます。

3D都市アプリケーションの基盤はすでに完成しているため、改造は新しい機能を加えるだけで行えます。この柔軟性を活用して、自分だけの3D都市アプリケーションを作成しましょう。

次の節では、ピアノ機能の具体的な実装方法について解説していきます。

6.2 PLATEAU Piano の設計

PLATEAU Piano の中心となる機能は、次の2つです:

  1. ピアノ機能の実装
    パソコンのキーボードをピアノの鍵盤として使い、各キーを押すことで音を再生します。

  2. 建物とピアノ鍵盤の対応付け
    都市のビル群をピアノの鍵盤(白鍵【虹色に着色】・黒鍵)に対応付け、それぞれのビルが押された鍵に反応して上下に動くようにします。

これらの機能を段階的に実装していきます。


6.3 ピアノ機能の実装

6.3.1. 必要なモジュールの準備

PLATEAU Piano では、Python ライブラリ simpleaudio を使用して音声を再生します。このライブラリは軽量で、音声の再生に特化しているため、簡単かつ効率的にピアノ機能を実現できます。

以下のコマンドで simpleaudio をインストールしてください:

pip install simpleaudio

6.3.2 ピアノクラスの作成

新しいクラス Piano を city フォルダー内の piano.py ファイルに作成します。このクラスでは、次の役割を担います:

  • キーボード入力に基づいて音を再生する

  • ビルの高さを動的に変更する

具体的なコードは以下のようになります:

import sys
import time
import numpy as np
import simpleaudio as sa


class Piano:
    EXTENT = 4096
    KEY_MAPPING = {
        'z': ('C', 261.63), 's': ('C#', 277.18),
        'x': ('D', 293.66), 'd': ('D#', 311.13),
        'c': ('E', 329.63),
        'v': ('F', 349.23), 'g': ('F#', 369.99),
        'b': ('G', 392.00), 'h': ('G#', 415.30),
        'n': ('A', 440.00), 'j': ('A#', 466.16),
        'm': ('B', 493.88)
    }

    def __init__(self, base):
        """
        Pianoクラスの初期化

        Args:
            base: Panda3DのShowBaseオブジェクト
        """
        self.base = base
        self.building_list = base.building_list
        self.key_to_building = {key: [] for key in self.KEY_MAPPING}
        self.key_map = {key: False for key in self.KEY_MAPPING}  # 各キーの状態を管理

        # 建物の初期高さを設定
        self.initialize_key_to_building()

        # キー入力を登録
        self.register_key_events()

        # 高さの更新タスクを追加
        self.base.taskMgr.add(self.update_building_heights, "UpdateBuildingHeights")

    def generate_sine_wave(self, frequency, duration=0.5, sample_rate=44100, amplitude=0.5):
        """
        サイン波を生成

        Args:
            frequency (float): 周波数(Hz)
            duration (float): 継続時間(秒)
            sample_rate (int): サンプルレート
            amplitude (float): 音量(0.0〜1.0)

        Returns:
            numpy.ndarray: 音声データ
        """
        t = np.linspace(0, duration, int(sample_rate * duration), False)
        wave = amplitude * np.sin(2 * np.pi * frequency * t)
        audio = (wave * 32767).astype(np.int16)  # 16-bit PCM形式に変換
        return audio

    def play_tone(self, frequency):
        """
        指定された周波数の音を再生

        Args:
            frequency (float): 再生する音の周波数(Hz)
        """
        audio = self.generate_sine_wave(frequency)
        play_obj = sa.play_buffer(audio, 1, 2, 44100)
        play_obj.wait_done()

    def initialize_key_to_building(self):
        """
        建物をピアノの鍵盤に対応付ける
        """
        # 7つの鍵盤に対応する建物を設定
        x_step = self.EXTENT / 7
        for building in self.building_list:
            x = building.centroid.x

            if x < x_step:
                key = 's' if self.is_black(building.color) else 'z'
            elif x < 1.5 * x_step:
                key = 's' if self.is_black(building.color) else 'x'
            elif x < 2 * x_step:
                key = 'd' if self.is_black(building.color) else 'x'
            elif x < 2.5 * x_step:
                key = 'd' if self.is_black(building.color) else 'c'
            elif x < 3 * x_step:
                key = 'c'
            elif x < 4 * x_step:
                key = 'g' if self.is_black(building.color) else 'v'
            elif x < 4.5 * x_step:
                key = 'g' if self.is_black(building.color) else 'b'
            elif x < 5 * x_step:
                key = 'h' if self.is_black(building.color) else 'b'
            elif x < 5.5 * x_step:
                key = 'h' if self.is_black(building.color) else 'n'
            elif x < 6 * x_step:
                key = 'j' if self.is_black(building.color) else 'n'
            elif x < 6.5 * x_step:
                key = 'j' if self.is_black(building.color) else 'm'
            else:
                key = 'm'

            self.key_to_building[key].append(building)

    def register_key_events(self):
        """
        キーボード入力をPanda3Dに登録
        """
        for key in self.KEY_MAPPING:
            self.base.accept(key, self.handle_key_press, [key])
            self.base.accept(f"{key}-up", self.handle_key_release, [key])

    def handle_key_press(self, key):
        """
        キー押下時の処理

        Args:
            key (str): 押されたキー
        """
        if key in self.KEY_MAPPING:
            self.key_map[key] = True  # キー状態を更新
            _, frequency = self.KEY_MAPPING[key]
            self.play_tone(frequency)

    def handle_key_release(self, key):
        """
        キー解放時の処理

        Args:
            key (str): 解放されたキー
        """
        if key in self.KEY_MAPPING:
            self.key_map[key] = False  # キー状態を更新

    def update_building_heights(self, task):
        """
        キー入力状態に基づいて建物の高さを更新するタスク

        Args:
            task: Panda3Dのタスク
        """
        for key, is_pressed in self.key_map.items():
            for building in self.key_to_building[key]:
                if is_pressed:
                    if building.node.getSz() != 1:
                        building.node.setSz(1)
                else:
                    if building.node.getSz() == 1:
                        building.node.setSz(building.height)
        return task.cont
    
    @staticmethod
    def is_black(color):
        """
        色が黒かどうかを判定

        Args:
            color (tuple): RGBの色情報

        Returns:
            bool: 黒の場合はTrue、それ以外はFalse
        """
        return color[0] == 0.0 and color[1] == 0.0 and color[2] == 0.0

このコードは、3D都市アプリケーションを改造して、ピアノのように動作するアプリケーションを実現します。キーボードの特定のキーに対応する音を鳴らし、それに基づいて都市の建物の高さを変化させる機能を提供します。

以下、コードの主要な部分を順を追って解説します。

1. クラス変数

    EXTENT = 4096
    KEY_MAPPING = {
        'z': ('C', 261.63), 's': ('C#', 277.18),
        'x': ('D', 293.66), 'd': ('D#', 311.13),
        'c': ('E', 329.63),
        'v': ('F', 349.23), 'g': ('F#', 369.99),
        'b': ('G', 392.00), 'h': ('G#', 415.30),
        'n': ('A', 440.00), 'j': ('A#', 466.16),
        'm': ('B', 493.88)
    }
  • EXTENT: 都市の範囲を定義(デフォルトは 4096)。

  • KEY_MAPPING: キーボードのキーに対応する音階(名前と周波数)を定義。(例: 'z': C音、周波数 261.63 Hz)

2. コンストラクタ

def __init__(self, base):
  • base: Panda3Dの ShowBase オブジェクトを受け取ります。

  • building_list: 都市の建物リストを取得。

  • key_to_building: 各キーに対応する建物のリストを格納。

  • key_map: 各キーの押下状態を管理する辞書(True: 押下中, False: 非押下)。

主要な初期化処理:

  1. initialize_key_to_building: 都市の建物をピアノの鍵盤に対応付け。

  2. register_key_events: キーボード入力を登録。

  3. update_building_heights: 定期的に建物の高さを更新するタスクを追加。

3. サイン波の生成

def generate_sine_wave(self, frequency, duration=0.5, sample_rate=44100, amplitude=0.5):
  • サイン波を生成して音声データを作成。

  • numpy を使用して指定した周波数と継続時間で波形データを作成。

  • 音声データを16-bit PCM形式(オーディオの標準形式)に変換。

4. 音の再生

def play_tone(self, frequency):
  • generate_sine_wave で生成した音声データを simpleaudio を使って再生。

  • 再生中の処理をブロックしないため、他の処理と並行して実行可能。

5. 建物の初期化

def initialize_key_to_building(self):
  • 都市の建物をピアノの鍵盤に対応付けます。

  • 各建物のX座標を基準に、建物を特定の鍵盤に割り当てます。

  • 建物の色をチェックして、白鍵と黒鍵を区別します。

    • is_black: 色が黒であるかどうかを判定するヘルパーメソッド。

6. キーボード入力の登録

def register_key_events(self):
  • Panda3Dの accept メソッドを使い、キー押下およびキー解放時のイベントを登録。

  • 例:

    • 'z' が押されたとき: handle_key_press が実行され、音が鳴る。

    • 'z' が解放されたとき: handle_key_release が実行される。

7. キー押下時の処理

def handle_key_press(self, key):
  • key_map を更新し、キーが押された状態に設定。

  • 該当する音を鳴らす(play_tone を呼び出し)。

8. キー解放時の処理

def handle_key_release(self, key):
  • key_map を更新し、キーが解放された状態に設定。

9. 建物の高さを更新

def update_building_heights(self, task):
  • 定期的に呼び出されるタスク。

  • 押されているキーに対応する建物の高さを低く(1に)設定。

  • 解放されている場合は元の高さに戻します。

例:

  • z キーが押される:

    • 対応する建物の高さが 1 に設定される。

  • z キーが解放される:

    • 対応する建物の高さが元の高さに戻る。

10. 黒鍵の判定

@staticmethod
def is_black(color):
  • 色情報(RGBタプル)を基に、建物が黒鍵に対応するか判定。

  • 黒鍵: (0.0, 0.0, 0.0)(完全な黒)。


6.4 実行と確認

6.4.1 main.pyの修正

main.py に以下のコードを追加して、Piano クラスを初期化します。また、カラフルな鍵盤に変化させる画像を読み込めるようにします。

from direct.showbase.ShowBase import ShowBase
from panda3d.core import *
from PIL import Image
from city.data_loader import DataLoader
from city.building_render import BuildingRenderer
from city.camera import Camera
from city.sound import Sound
from city.equalizer import Equalizer
from city.piano import Piano  # (1)


class BuildingApp(ShowBase):
    # 略
    # image_path = 'images/mirror_ball.png'  # ここから(2)
    image_path = 'images/piano_keyboard_rainbow.png'  # ここまで(2)
    sound_path = 'sound/Dive_To_Mod.mp3'

    def __init__(self, zoom_level, tile_x, tile_y):
        super().__init__()

        # 略

        # アプリの停止
        self.accept('escape', exit)

        # # Soundクラスを初期化  # ここから(3)
        # self.sound = Sound(self.sound_path)
        #
        # # イコライザーを初期化
        # self.equalizer = Equalizer(self)  # ここまで(3)

        # ピアノを初期化
        self.piano = Piano(self)  # (4)


if __name__ == '__main__':
    # 渋谷駅のタイル座標35.6582972,139.70146677
    # ズームレベル 14: x=14549, y=6452
    # ズームレベル 15: x=29099, y=12905
    # ズームレベル 16: x=58199, y=25811

    # タイル座標を指定
    # z = 14
    # x = 14549
    # y = 6452
    z = 15
    x = 29099
    y = 12905
    # z = 16
    # x = 58199
    # y = 25811

    app = BuildingApp(z, x, y)
    app.run()

修正箇所をコメントに示しました(1から4)。この4箇所をインデントに注意して書き換えてください。

修正箇所の詳細解説

1. Pianoクラスのインポート(コメント: # (1))

  • Piano クラスを city/piano.py からインポートします。

  • このクラスが、ユーザー入力に応じてピアノ音を鳴らし、建物の高さを変化させる主要な機能を提供します。

  • main.py にPianoクラスを組み込むことで、アプリにピアノ機能が追加されます。

2. 背景画像の変更(コメント: # (2))

  • 背景画像をピアノの鍵盤をイメージした画像 piano_keyboard_rainbow.png に変更します。

  • カラフルな鍵盤を都市全体に反映することで、見た目からもピアノとしてのインタラクションがわかりやすくなります。

3. Equalizerの無効化(コメント: # (3))

サウンド機能(Sound)とイコライザー機能 (Equalizer) をコメントにして無効化します。これで、基本の3D都市アプリケーションに戻ります。

4. Pianoクラスの初期化(コメント: # (4))

  • Piano クラスを BuildingApp 内で初期化します。

  • Piano クラスに BuildingApp の情報を渡すことで、都市の建物データやユーザー入力を処理します。

以上で、改造コードの解説は終わりです。PLATEAU Pianoを実行しましょう。

6.3.3. PLATEAU Pianoの実行

PLATEAU Pianoを実行する前に、次の画像をダウンロードして、imagesフォルダーに保存してください。これで、ピアノの鍵盤に合わせた背景画像に変更できます。

図15 カラフルな鍵盤ピアノ

これで全ての準備が終わりました。ターミナルで次のコマンドを実行します。

python main.py


図16 PLATEAU Piano

アプリが起動しました。キーボードの z キーとvキーを押してみてください。対応する音(ドとファ)が再生され、建物が上下に動きます。

図17 PLATEAU Piano(ドとファの音を鳴らす)

6.5 まとめ

PLATEAU Piano の作成を通じて、PLATEAU Urban Equalizer の拡張方法を学びました。ピアノ機能のように、音楽やインタラクティブな要素を加えることで、3D都市アプリケーションの可能性をさらに広げることができます。

このアプリケーションの魅力は、シンプルな構造を活かしてアイデア次第で自由に改造できる点にあります。例えば、以下のような応用例を考えることができます:

  1. 楽器演奏の拡張: ピアノだけでなく、ギターやドラムのシミュレーション機能を加えることで、仮想都市を楽器として演奏することが可能です。

  2. ゲーム要素の追加: 音楽に合わせて建物がリズミカルに変化する「音楽ゲーム」を実装し、プレイヤーがリズムに合わせて操作するゲーム体験を提供することができます。

  3. 教育用途への展開: 音楽と建築デザインの関係を学べる教材として、学生向けに拡張することも考えられます。

  4. 観光やプロモーションツール: 都市ごとに異なる音階やエフェクトを設定し、観光客に都市の魅力をアピールするツールとして活用できます。

まとめ

本章では、音楽データを利用してビルの高さを動的に変化させるサウンドエコライザー機能を3D都市アプリケーションに統合する方法を解説しました。以下に、このプロジェクトの特徴を振り返ります。

  1. 3D都市の再現
    Shapelyライブラリを使用して地理データを効率的に処理し、Panda3Dを用いたシーン描画によって、3D都市をリアルタイムで視覚化しました。さらに、建物の描画効率を向上させるための座標簡略化処理を導入し、パフォーマンスと見た目のバランスを確保しました。

  2. 音楽連動機能の実装
    Soundクラスを利用して音楽データをリアルタイムで解析し、その振幅データをエコライザー機能に反映しました。これにより、音楽のリズムやダイナミクスが3D都市に視覚的に表現される、新しい体験を実現しました。

  3. インタラクティブな操作
    ユーザーはカメラの位置や視点を自由に操作し、異なる角度からエコライザー効果を楽しむことができます。また、波の進行方向を切り替えることで、多様な視覚表現を試すことができます。

  4. 拡張性の高い設計
    プロジェクト全体はモジュール化されており、新しい波形やエフェクトの追加、別のデータセットへの適用が容易です。また、3D都市にさらなる視覚効果を追加するための基盤が整っています。


このプロジェクトを通じて、音楽データのリアルタイム解析や3Dシミュレーション、そしてインタラクティブな視覚表現がどのように統合できるかを学ぶことができました。このような技術は、ゲーム、エンターテインメント、教育、データビジュアライゼーションなど、幅広い分野に応用可能です。

ぜひ、さらなる機能を追加し、オリジナルの都市シミュレーションを構築してみてください。音楽と都市が織りなす新しいデジタル体験が、多くの可能性を秘めていることを実感できるはずです。

付録

本アプリケーションは、東京の任意の場所を表示できます。ズームレベルをzとして、タイルの座標を x, y とすると、次のコードで3D都市を表示します。

    app = BuildingApp(z, x, y)
    app.run()

緯度軽度からズームレベルに対応するタイル座標を調べる 

東京の任意の場所のタイル座標を調べる方法を紹介します。例えば、東京駅のタイル座標を調べてみましょう。

まず、東京駅の緯度経度を調べます。緯度経度検索サイトは複数ありますが、ここでは、Geocoding.jpを利用します。

図1 Geocoding.jp

サイトにアクセスしたら、検索フィールドに「東京駅」を入力し、エンターキーで確定します。

図2 緯度経度が取得できる

東京駅の緯度経度が表示されます。東京駅は「緯度: 35.681236 経度: 139.767125」で、この値をコピーしておきます。

次は、緯度経度からタイル座標を調べます。タイル座標確認ページを開きます。

図3 タイル座標確認ページ

サイトにアクセスしたら、緯度経度の値を入力します。ズームレベルを選択して、「移動」をクリックします。すると、東京駅のz, x, y が「14/14552/6451」であることが確認できました。ズームレベル15, 16も同様のやり方で調べることができます。

以上の検索結果を利用して、ズームレベル14, 15, 16の東京駅を表示するコードは次の通りです。

if __name__ == '__main__':
    # 東京駅のタイル座標35.681236, 139.767125
    # ズームレベル 14: x=14552, y=6451
    # ズームレベル 15: x=29105, y=12903
    # ズームレベル 16: x=58211, y=25806

    # タイル座標を指定
    z = 14
    x = 14552
    y = 6451
    # z = 15
    # x = 29105
    # y = 12903
    # z = 16
    # x = 58211
    # y = 25806

    app = MyApp(z, x, y)
    app.run()

以上で、タイル座標の調べ方の解説を完了します。


いいなと思ったら応援しよう!