これは MIERUNE Advent Calendar 2024 の13日目の記事です。
昨日は @dayjournal さんによる Wherobots CloudでOverture Mapsのデータを空間検索 でした。
はじめに
PLATEAUのデータには高品質なテクスチャが貼られていますね!
(LOD1の建築物データなどは貼られてませんが。)
結構いい感じに貼られてますし描画のパフォーマンスも良いんですが、実はCityGMLに格納されている生のテクスチャではなく、効率よく描画するため画像に特殊な加工を加えていたりします。
PLATEAUの元データ(CityGML)に付随するテクスチャファイル(JPEG)の一例を見てみましょう。
東京都・中央区のどこか適当な3次メッシュに含まれる、大量のテクスチャの中の1枚です。
このファイルは800KB程度のファイルだったんですが、1つの3次メッシュにはこんなようなテクスチャファイルが4300枚入っており、全部合わせると36MBになっていました。
PLATEAU VIEWで閲覧できるデータは事前に特殊な加工処理をしているため、このようなパフォーマンスで閲覧することができます。
しかし、昨年度までのPLATEAU GIS Converterでは、この4300枚のテクスチャ(当然、複数のメッシュを選択すればどんどん増えていく)をすべてそのまま利用しており、Cesiumで秒画する際には大量の画像ファイルを読み込んでくる必要があったため、パフォーマンスに難がありました。
そのため、パフォーマンスチューニングの一環として、テクスチャアトラスを作成することが重要となりました。
今回は、Rustを用いてテクスチャアトラスを作成するライブラリを作ってみたので、紹介しようと思います。
テクスチャアトラスとは
テクスチャアトラスは、複数の小さなテクスチャ画像を一つの大きな画像にまとめたものです。
たくさんの画像を一つにまとめることで、描画時のテクスチャの切り替え回数を減らし、パフォーマンスを向上させることができます。
実際に生成された画像はこんな感じです。
以下のようなメリットがあります。
-
描画速度の向上:
- GPUはレンダリングのたびにテクスチャをロードしてくる必要があるため、コストが高い
- アトラス化によりテクスチャ切り替えのオーバーヘッドが減少し、全体の描画速度が向上
-
ドローコールの削減:
- テクスチャを大量に利用していると、CPUはGPUに対して個別に描画命令を出す必要がある
- アトラス化によりテクスチャのバインド回数が減るため、GPUへの命令数が減少
-
メモリ効率の向上:
- アトラス化によりテクスチャを一つにまとめることで、メモリの無駄な領域を削減
作成されたテクスチャアトラスは、glTFなどの内部で呼び出しを行われますが、このように適切に貼り付けることでパフォーマンスが向上します。
今回の例だと、1024 * 1024のテクスチャ8枚にパッキングすることができました。
ちなみに8096 * 8096のテクスチャ1枚にパッキングすることもできるんですが、以下のような課題がありました。
このため、データ処理中にすべてのテクスチャを動的に確認して、いい感じの大きさ・枚数で出力するようにしています。
- 大きすぎる画像だと1発目のロードのオーバーヘッドが大きく、逆に遅くなることもある
- 地域によってはテクスチャに空きが多くできてしまう可能性がある
- 3D Tilesではズームレベルに応じて低解像度化するため、大きなアトラスは不要
使い方
https://github.com/MIERUNE/atlas-packer/blob/1814f6f6049f6727c87efa99965f26838d0536f7/examples/test_pack.rs にexampleとしてプログラムを格納しています。
以下のように利用することで、テクスチャアトラスを作成することができるようになっています。
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;
use atlas_packer::export::WebpAtlasExporter;
use atlas_packer::texture::PolygonMappedTexture;
use rayon::prelude::*;
use atlas_packer::{
pack::AtlasPacker,
place::{GuillotineTexturePlacer, TexturePlacerConfig},
texture::{
cache::{TextureCache, TextureSizeCache},
DownsampleFactor,
},
};
#[derive(Debug, Clone)]
struct Polygon {
id: String,
uv_coords: Vec<(f64, f64)>,
texture_uri: PathBuf,
downsample_factor: DownsampleFactor,
}
fn main() {
let all_process_start = Instant::now();
// 3D Tiles Sink passes the texture path and UV coordinates for each polygon
let mut polygons: Vec<Polygon> = Vec::new();
let downsample_factor = 1.0;
for i in 0..200 {
for j in 1..11 {
// Specify a polygon to crop around the center of the image
let uv_coords = vec![
(0.2, 0.3),
(0.3, 0.2),
(0.6, 0.2),
(0.8, 0.3),
(0.8, 0.7),
(0.6, 0.8),
(0.3, 0.8),
(0.2, 0.7),
];
let path_string: String = format!("./examples/assets/{}.png", j);
let image_path = PathBuf::from(path_string.as_str());
polygons.push(Polygon {
id: format!("texture_{}_{}", i, j),
uv_coords,
texture_uri: image_path,
downsample_factor: DownsampleFactor::new(&downsample_factor),
});
}
}
// initialize texture packer
let config = TexturePlacerConfig {
width: 4096,
height: 4096,
padding: 0,
};
let packer = Mutex::new(AtlasPacker::default());
let packing_start = Instant::now();
// cache image size
let texture_size_cache = TextureSizeCache::new();
// place textures on the atlas
polygons.par_iter().for_each(|polygon| {
let place_start = Instant::now();
let texture_size = texture_size_cache.get_or_insert(&polygon.texture_uri);
let cropped_texture = PolygonMappedTexture::new(
&polygon.texture_uri,
texture_size,
&polygon.uv_coords,
polygon.downsample_factor.clone(),
);
packer
.lock()
.unwrap()
.add_texture(polygon.id.clone(), cropped_texture);
let place_duration = place_start.elapsed();
println!("{}, texture place process {:?}", polygon.id, place_duration);
});
let packer = packer.into_inner().unwrap();
let packed = packer.pack(GuillotineTexturePlacer::new(config.clone()));
let duration = packing_start.elapsed();
println!("all packing process {:?}", duration);
let start = Instant::now();
// Caches the original textures for exporting to an atlas.
let texture_cache = TextureCache::new(100_000_000);
let output_dir = Path::new("./examples/output/");
packed.export(
WebpAtlasExporter::default(),
output_dir,
&texture_cache,
config.width(),
config.height(),
);
let duration = start.elapsed();
println!("all atlas export process {:?}", duration);
let duration = all_process_start.elapsed();
println!("all process {:?}", duration);
}
処理の内容
必要な情報
まず、テクスチャアトラスを作成する際には、以下の情報を必要としています。
- アトラス化したい元画像のパス
- 切り抜きたい箇所のUV座標の矩形(2次元ポリゴン)
- 低解像度化の割合(1~0)
また、「切り抜かれたテクスチャはアトラス上のどこにいるのか」を返さないとglTFなどのメッシュに貼ることができないため、ユニークなidを振る必要があります。
画像のパスやUV座標を持ったPolygon構造体を大量に用意してあげます。
let mut polygons: Vec<Polygon> = Vec::new();
let downsample_factor = 1.0;
for i in 0..200 {
for j in 1..11 {
// Specify a polygon to crop around the center of the image
let uv_coords = vec![
(0.2, 0.3),
(0.3, 0.2),
(0.6, 0.2),
(0.8, 0.3),
(0.8, 0.7),
(0.6, 0.8),
(0.3, 0.8),
(0.2, 0.7),
];
let path_string: String = format!("./examples/assets/{}.png", j);
let image_path = PathBuf::from(path_string.as_str());
polygons.push(Polygon {
id: format!("texture_{}_{}", i, j),
uv_coords,
texture_uri: image_path,
downsample_factor: DownsampleFactor::new(&downsample_factor),
});
}
}
アトラス化に必要なインスタンスを準備
テクスチャアトラスの最小サイズを決定します。
アトラス化したいテクスチャ群のテクスチャサイズを見ながら、自由に選択してあげると良いと思います。
また、最重要なAtlasPacker構造体を、マルチスレッドから利用できるようにMutexで囲ってインスタンス化します。
3D Tilesではズームレベルごとにファイルを作成する、つまりズームレベルの数だけ何度も同じ建物の同じテクスチャを呼び出します。このため大量のテクスチャをロードする必要があることから、一度読み取った画像を再度読み取ることのないように、キャッシュを作成します。
let config = TexturePlacerConfig {
width: 4096,
height: 4096,
padding: 0,
};
let packer = Mutex::new(AtlasPacker::default());
let texture_size_cache = TextureSizeCache::new();
アトラスの作成
作成されたAtrasPackerにテクスチャを流し込んでいきます。
ここでは一旦、「テクスチャのパス」と「テクスチャのサイズ」のみ取得し、キャッシュします(キャッシュが存在すれば取り出します)。
いきなり画像本体を切り抜き、テクスチャアトラスファイルに書き込むようなことをしてしまうと、アトラスサイズの最適化ができないため、空の画像を空白の空間に並べていくような処理をします。
すべてのテクスチャをイテレーションし終えたら、別途実装したアルゴリズム(GuillotineTexturePlacer
)でアトラス化を行っていきます。
(めっちゃ強そうな名前っすよね。)
polygons.par_iter().for_each(|polygon| {
let texture_size = texture_size_cache.get_or_insert(&polygon.texture_uri);
let cropped_texture = PolygonMappedTexture::new(
&polygon.texture_uri,
texture_size,
&polygon.uv_coords,
polygon.downsample_factor.clone(),
);
packer
.lock()
.unwrap()
.add_texture(polygon.id.clone(), cropped_texture);
});
let packer = packer.into_inner().unwrap();
let packed = packer.pack(GuillotineTexturePlacer::new(config.clone()));
アトラスの書き出し
最後に、作成されたアトラス配置に基づき、実際にテクスチャアトラスを書き込んでいきます。
今度はテクスチャ本体を読み込むため、再度キャッシュのためのインスタンスを立ち上げつつ、アトラスを書き込むディレクトリを指定します。
今回はWebpAtlasExporter
を指定しているため、アトラスはWebpで出力されますが、JPEGとPNGのexporterも実装済みです。
let texture_cache = TextureCache::new(100_000_000);
let output_dir = Path::new("./examples/output/");
packed.export(
WebpAtlasExporter::default(),
output_dir,
&texture_cache,
config.width(),
config.height(),
);
最終的に、上にも貼ったようなテクスチャアトラスが生成されます。
実装の工夫
説明したものも、詳しく説明していないものも含みますが、アトラス化のアルゴリズムには以下のような工夫を施しました。
1. テクスチャのクラスタリング
- PLATEAUのテクスチャは複数のメッシュで同一テクスチャの同一領域が使いまわされている場合がある
- アトラス化の際にはR-Treeを利用し、重複領域を高速に抽出している
2. ギロチンアルゴリズムでのパッキング
- そもそも、テクスチャアトラス化のアルゴリズムは後からいくらでも追加できるように、インターフェイスが作成されている
- デフォルトではギロチンアルゴリズムを採用
- アトラスにテクスチャ領域が割り当てられたら、その境目を縦横に分断し、空き領域として設定する手法
3. 複数の書き出し手法
- Webp・JPEG・PNGの3種類のエクスポートが可能
- 将来的にはBasis Universalなどの超圧縮テクスチャ出力も行いたい
まとめ
Rustでテクスチャアトラスを作成することで、それなりに高速かつ大規模な変換ができるようになりましたー!
今後は、さらなる最適化や他のプロジェクトへの応用も検討していきたいと思います!