2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CesiumのVector 3D Tilesを作って表示してみよう!!!

2
Posted at

image.png

みなさんこんにちは!!!!!

前回は、CesiumでMVTを読み込んでみました。MVTDataProviderを使うと、配信済みのMVTをCesiumの地球儀に載せられます。詳しくはこちらの記事をどうぞ!
CesiumでMVTを表示しよう!(2026年版)

公開されているMVTをそのまま使えるのは便利ですが、ただ地形に沿わせるだけでは、空中の航路や、橋の上を通るライン、地下の配管などは表せません。MVTの形状は2次元なので、頂点ごとに高さを持つ3次元の形状を、そのまま格納することはできないためです。

そういうデータも、座標や属性を保ったままタイルで配信したいですよね。

そこで、前回少し触れたVector 3D Tilesです!!!

Vector 3D Tilesって?

Vector 3D Tilesは、点・ライン・ポリゴンを3D Tilesで配信するための仕組みです。3D Tilesの階層や詳細度の管理を使いながら、3次元の座標と、元の地物の形状・属性を扱います。
Cesiumのベクターデータ対応の説明

大規模ラインデータを地形にクランプさせたりとか…

image.png
出典: Vector Tiles: A Technology Preview for Cesium and 3D Tiles

橋梁のひび割れをマッピングできたりとか…

image.png
出典: Help Shape Vector Data Support in 3D Tiles

すごい便利そうですよね!
特に、ひび割れマッピングは3Dデータならでは、という感じがしてとても良い。

ちなみにこちらは3D Tiles 2.0に向けた取り組みの一つで、2026年9月2日には公開プレビューが発表されました。現在の実装は3D Tiles 1.1とglTF拡張の組み合わせで、3D Tiles 2.0への取り込みは今後の予定です。
Vector Tiles Technology Previewの発表

公式に案内されている生成手段はCesium ionです。GeoJSONやGeoPackageなどをアップロードし、Vector 3D Tilesへ変換できます。ただ、形式自体がion専用というわけではありません。

仕様案が公開されていて、Cesiumでも動く。ということは、簡単なデータなら手元でも作れるのでは…?

ということで今回は、拡張仕様に沿ってglTFとtileset.jsonを作り、Cesiumで表示してみます!仕様の説明には、外周と穴の両方があるポリゴンを使います。

仕様はまだ変更される可能性があります。以下は2026年9月時点で確認した仕様案と、CesiumJS 1.145.0で動作確認した実装です。

どんな拡張を使うの?

今回のデータで使う拡張は、主に次の4つです。

拡張 役割
3DTILES_content_gltf_vector タイルの中身をベクターデータとして扱う
EXT_mesh_polygon ポリゴンの三角形と、外周・穴の対応を保持する
EXT_mesh_features 頂点と地物IDを結び付ける
EXT_structural_metadata 地物の名前などを属性テーブルに保存する

EXT_mesh_featuresEXT_structural_metadataは以前からありましたが、残り2つは新設ですね!

ちなみにEXT_*はglTFの拡張ですが、3DTILES_content_gltf_vectorは3D Tiles自体、というよりもtileset.jsonで利用する拡張なので、そこはご注意を!

点やラインにはglTFの基本の描画モードを使いますが、ポリゴンは描画用の三角形に分割し、さらにEXT_mesh_polygonで元の輪郭も残します。

JSONがデータの型や参照関係を表し、バイナリが座標・インデックス・属性値を保持します。まずは、ポリゴンの形だけに絞って見てみましょう。

EXT_mesh_polygonを図から読んでみる

基本的なglTFの構造についてはこちらの記事で解説していたりはするので、暇があれば見てみてください。

EXT_mesh_polygonの仕様案には、次の図が載っています。AとBは穴なし、Cは穴ありのポリゴンです。

EXT_mesh_polygon仕様案の3ポリゴン

この3つを、1つの描画単位であるprimitiveにまとめます。図の細い線は描画用の三角形分割です。Aは3枚、Bは2枚、Cは12枚の三角形になります。

JSONパート

図の形を表すglTFを作ってみました。座標値は図の配置を再現するために設定したもので、公式に示された数値ではありません。頂点番号と三角形のつながりを図に合わせています。

生成したJSON全体は以下です。

{
  "asset": {"version": "2.0"},
  "extensionsUsed": ["EXT_mesh_polygon"],
  "scene": 0,
  "scenes": [{"nodes": [0]}],
  "nodes": [{"mesh": 0}],
  "meshes": [
    {
      "primitives": [
        {
          "attributes": {"POSITION": 0},
          "indices": 1,
          "mode": 4,
          "extensions": {
            "EXT_mesh_polygon": {
              "count": 3,
              "indicesOffsets": 2,
              "loopIndices": 3,
              "loopIndicesOffsets": 4
            }
          }
        }
      ]
    }
  ],
  "buffers": [{"uri": "polygons.bin", "byteLength": 420}],
  "bufferViews": [
    {"buffer": 0, "byteOffset": 0, "byteLength": 252, "target": 34962},
    {"buffer": 0, "byteOffset": 252, "byteLength": 102, "target": 34963},
    {"buffer": 0, "byteOffset": 356, "byteLength": 6},
    {"buffer": 0, "byteOffset": 364, "byteLength": 48},
    {"buffer": 0, "byteOffset": 412, "byteLength": 6}
  ],
  "accessors": [
    {
      "bufferView": 0,
      "componentType": 5126,
      "count": 21,
      "type": "VEC3",
      "min": [0, 0, 0],
      "max": [10, 4, 0]
    },
    {"bufferView": 1, "componentType": 5123, "count": 51, "type": "SCALAR"},
    {"bufferView": 2, "componentType": 5123, "count": 3, "type": "SCALAR"},
    {"bufferView": 3, "componentType": 5123, "count": 24, "type": "SCALAR"},
    {"bufferView": 4, "componentType": 5123, "count": 3, "type": "SCALAR"}
  ]
}

少し長いですが、中心になるのはEXT_mesh_polygonの4項目です。count以外の数値は、accessors配列の参照番号です。

項目 意味
count 3 ポリゴンが3つある
indicesOffsets 2 三角形列の開始位置をaccessorsの2から読む
loopIndices 3 輪郭の頂点列をaccessorsの3から読む
loopIndicesOffsets 4 輪郭列の開始位置をaccessorsの4から読む

accessorsは、データの型・個数・読み取り先を定義します。参照先のbufferViewが、.binのどのバイト範囲を読むかを指定しています。

バイナリパート

わかりやすさのために例として頂点座標はXY平面に置き、Zを0にしました。配列の位置が図の頂点番号です。これはバイナリへ書き込む前の数値を並べたものです。

POSITION = [
  [0,4,0], [3,4,0], [3,3,0], [1,1,0], [0,2,0],
  [4,3.5,0], [6,2.5,0], [6,0.5,0], [4,1.5,0],
  [8,4,0], [9,4,0], [10,3,0], [10,1,0],
  [9,0,0], [8,0,0], [7,1,0], [7,3,0],
  [8,3,0], [9,3,0], [9,1,0], [8,1,0]
]

三角形のインデックスは、A、B、Cの順に続けて格納します。次の配列では、1行が1つの三角形です。バイナリには区分や改行を持たない51要素の配列として書き込みます。

A
  0,  2,  1,
  0,  3,  2,
  0,  4,  3,
B
  5,  8,  6,
  6,  8,  7,
C
 16, 17,  9,
  9, 17, 18,
  9, 18, 10,
 10, 18, 11,
 18, 19, 11,
 11, 19, 12,
 12, 19, 13,
 19, 20, 13,
 13, 20, 14,
 14, 20, 15,
 15, 20, 16,
 16, 20, 17

Aが9要素、Bが6要素なので、Cの開始位置は15です。indicesOffsetsには、三角形の枚数でもバイト位置でもなく、この配列内の開始位置を入れます。

indicesOffsets = [0, 9, 15]
                  A  B   C

外周と穴は、別の配列loopIndicesに保存します。今回は16ビット符号なし整数を使うので、リングの区切りは最大値の65535です。

loopIndices = [
   0,  4,  3,  2,  1,                // Aの外周
   65535,
   5,  8,  7,  6,                    // Bの外周
   65535,
   9, 16, 15, 14, 13, 12, 11, 10,   // Cの外周
   65535,
  17, 18, 19, 20                     // Cの穴
]
loopIndicesOffsets = [0, 6, 11]

Cの範囲は11番目から配列の末尾まで。その中の最初のリングが外周で、次のリングが穴です。穴を別のポリゴンとして数えるわけではないので、countは3のままです。

外周は反時計回り、穴は時計回りにします。今回のXY座標では、+Z側から見た向きです。図の番号を昇順に並べると外周が逆向きになるため、順序を反転しています。また、リングは暗黙に閉じるので、始点を末尾に重複させません。

EXT_mesh_polygon仕様案の3ポリゴン

各配列をリトルエンディアンで書き込んだ.binの配置は、こうなりました。

データ バイト位置 バイト数
頂点座標 32ビット浮動小数点数 0 252
三角形インデックス 16ビット符号なし整数 252 102
indicesOffsets 16ビット符号なし整数 356 6
loopIndices 16ビット符号なし整数 364 48
loopIndicesOffsets 16ビット符号なし整数 412 6

開始位置を4バイト境界にそろえるために0を挟み、末尾も埋めて合計420バイトです。たとえばindicesOffsets[0, 9, 15]は、実際には00 00 09 00 0F 00という6バイトになります。

三角形だけでなく、元のポリゴンの輪郭も一緒に運べるわけです。ここまでわかれば、自分のデータでも作れそうですねー!!!

高さを持つGeoJSONを作ってみる

今度は地球上に置いてみます! 入力は、次の地物をまとめたFeatureCollectionです。座標は東京駅付近の経度・緯度・楕円体高です。

Feature.id 形状の種類 内容
point-1 Point 高さ50 mの点
line-1 LineString 高さ50→75→100 mを結ぶライン
polygon-1 Polygon 各頂点の高さが80 mの四角形
polygon-hole-1 Polygon 外周120 m四方、穴60 m四方のポリゴン

点を表すFeatureを、実際の入力から抜き出します。

{
  "type": "Feature",
  "id": "point-1",
  "properties": { "name": "3Dポイント" },
  "geometry": {
    "type": "Point",
    "coordinates": [139.7671, 35.6812, 50.0]
  }
}

GeoJSONの座標は、経度・緯度に加えて高さも持てます。RFC 7946では、第3成分をWGS84楕円体からの高さとしてメートルで表します。今回の入力もこの形式です。

穴あきポリゴンのGeoJSONはこちらです。coordinatesの最初の配列が外周、次の配列が穴になります。

{
  "type": "Feature",
  "id": "polygon-hole-1",
  "properties": {"name": "穴あき3Dポリゴン", "description": "平面の正方形: 外形120m、中空部60m、中心の楕円体高80m"},
  "geometry": {
    "type": "Polygon",
    "coordinates": [
      [
        [139.7698372, 35.6822592, 80.0],
        [139.7711628, 35.6822592, 80.0],
        [139.7711628, 35.6833408, 80.0],
        [139.7698372, 35.6833408, 80.0],
        [139.7698372, 35.6822592, 80.0]
      ],
      [
        [139.7701686, 35.6825296, 80.0],
        [139.7701686, 35.6830704, 80.0],
        [139.7708314, 35.6830704, 80.0],
        [139.7708314, 35.6825296, 80.0],
        [139.7701686, 35.6825296, 80.0]
      ]
    ]
  }
}

GeoJSONとglTFの対応

このGeoJSONを変換するために、Rustで小さなプログラムを作りました。座標の変換、ポリゴンの三角形分割、属性の格納を行い、glTFとtileset.jsonを出力します。ここからは、その出力と元データの対応を見ていきます。

今回は分かりやすさのために1地物につき1つのGLBにしています。まず、対応関係を並べてみます。

元データ glTFでの格納先
座標 POSITIONが参照するデータの型定義とバイナリ
ラインの接続順 mode: 3とインデックス
ポリゴンの塗りつぶし mode: 4と三角形インデックス
ポリゴンの外周・穴 EXT_mesh_polygon
頂点が属する地物 _FEATURE_ID_0EXT_mesh_features
名前などの属性 EXT_structural_metadataの属性テーブル

座標は近くの原点からのメートル値にする

経度・緯度をそのままPOSITIONには入れません。この実装では、次の順番で座標を変換します。

経度・緯度・楕円体高
  ↓
地球中心を原点とするECEF座標
  ↓
データ近傍を原点とするENU座標(東・北・上、メートル)
  ↓
Y軸を上とするglTF座標(E, N, U) → (E, U, -N)
  ↓
FLOAT / VEC3のPOSITION

POSITIONには、変換後のメートル単位の座標をFLOAT、つまり32ビット浮動小数点数として格納します。入力の経緯度は64ビット浮動小数点数で計算し、最後にこの型へ変換します。近傍の原点を使うことで、地球中心からの大きな座標を直接格納する場合より丸め誤差を抑えられます。

ENUの原点と軸の向きは、tileset.jsonroot.transformに、ENUからECEFへ変換する行列として保存しています。今回は全地物で共通の原点を使い、4つのGLBに同じ変換を適用します。glTF内のノードには配置用の行列を設定していません。

表示時には、CesiumがglTFのY軸上向きの座標をZ軸上向きに戻し、その後でroot.transformを適用して地球上に配置します。

入力の高さは各頂点の位置に反映されます。

ポイントとラインは基本の描画モードを使う

点はmode: 0(POINTS)、今回の1本のラインはmode: 3(LINE_STRIP)です。ラインのインデックスは[0, 1, 2]で、3頂点を順に接続します。glTFの描画単位

ラインは頂点と接続順で表し、太さはCesiumで表示するときに指定します。

ポリゴンは三角形と輪郭の両方を持つ

穴あきポリゴンは、閉じるために重複した終点を除くと、外周4頂点+穴4頂点の計8頂点になります。変換時に穴を考慮して三角形分割し、8枚の三角形、つまり24個のインデックスを作りました。

今回は地物ごとにGLBを分けるため、穴あきポリゴンのcountは1です。生成したタイル(glTF)のJSONパートは次のようになります。

{
  "attributes": { "POSITION": 0, "_FEATURE_ID_0": 5 },
  "indices": 1,
  "mode": 4,
  "extensions": {
    "EXT_mesh_features": {
      "featureIds": [
        { "attribute": 0, "featureCount": 1, "propertyTable": 0 }
      ]
    },
    "EXT_mesh_polygon": {
      "count": 1,
      "indicesOffsets": 2,
      "loopIndices": 3,
      "loopIndicesOffsets": 4
    }
  }
}

POSITION: 0はaccessors0を参照しています。今回の配列は次のとおりです。

accessors番号 内容 要素数
0 頂点座標(FLOAT / VEC3) 8
1 三角形の頂点インデックス(UINT32) 24
2 ポリゴンごとの三角形インデックス開始位置 1
3 外周・穴の頂点インデックス列 9
4 ポリゴンごとの輪郭インデックス開始位置 1
5 頂点ごとの地物ID 8

実際のバイナリから輪郭関連の配列を読み戻すと、以下のようになっています。

indicesOffsets     = [0]
loopIndices        = [0, 1, 2, 3, 4294967295, 4, 5, 6, 7]
loopIndicesOffsets = [0]
                      外周      区切り         穴

公式図の例では16ビット整数でしたが、変換プログラムでは32ビット整数を使っています。そのため、UINT32の最大値4294967295がリングの区切りです。各リングは暗黙に閉じるため、始点を末尾へ重複して入れません。外周と穴は逆向きの頂点順で保持します。
EXT_mesh_polygon仕様案

描画用の三角形に加え、それらが外周と穴を持つ1つのポリゴンに属することも記録します。三角形分割は変換時に済んでいるので、ブラウザでは用意された三角形を描画できます。

三角形列を読み戻すと、次の24要素でした。3要素ずつで1枚の三角形になります。

indices = [
  0,4,5, 7,4,0, 7,0,1, 7,1,2,
  3,0,5, 3,5,6, 6,7,2, 6,2,3
]

このGLBのバイナリでは、形状に関する配列が次の位置に入っています。

データ バイト位置 バイト数
頂点座標 0 96
三角形インデックス 96 96
indicesOffsets 192 4
loopIndices 196 36
loopIndicesOffsets 232 4
頂点ごとの地物ID 240 32

236〜239バイトは詰め物の0で、272バイト以降に属性値を格納しています。

3DTILES_content_gltf_vectorでベクターデータであることを示す

tileset.jsonの最上位タイルを表すrootには、ENUからECEFへの変換行列transformと、データを囲む範囲boundingVolumeを設定します。子タイルからは各GLBを参照します。穴あきポリゴンのcontent部分を抜粋すると、以下のとおりです。

{
  "uri": "feature-3/mesh.glb",
  "extensions": {
    "3DTILES_content_gltf_vector": {
      "vector": true,
      "clip": false
    }
  }
}

vector: trueがベクターデータとして扱う指定です。タイルセットのextensionsUsedにもこの拡張を列挙します。
3DTILES_content_gltf_vector仕様案

このプログラムでは、4地物を単純な階層に置いています。大規模データのタイル分割や簡略化、詳細度ごとのデータ生成は今後の課題です。

Cesiumに読み込んでみる

ビューアーの読み込み部分を抜粋します。

const tileset = await Cesium.Cesium3DTileset.fromUrl(
  "/data/user-sample/tileset.json",
  {
    scene: viewer.scene,
    heightReference: Cesium.HeightReference.NONE,
  },
);
viewer.scene.primitives.add(tileset);

今回は地形に沿わせる処理を使わず、入力の高さを保って表示しました。背景はOpenStreetMap、地表は楕円体です。

色は属性に応じて指定しています。実装のスタイルから色・サイズ指定を抜粋すると、以下のようになります。

color: {
  conditions: [
    ["${kind} === 'Point'", "color('#ffffff')"],
    ["${kind} === 'LineString'", "color('#00c2e0')"],
    ["${sourceId} === 'polygon-hole-1'", "color('#a874ff', 0.9)"],
    ["true", "color('#ffac30', 0.85)"],
  ],
},
pointSize: 17,
lineWidth: 6,

すると、こんな感じになります。

image.png

高さを持った点とライン、2つのポリゴンが載りました!!! 白い点、水色のライン、オレンジの四角形、紫の穴あきポリゴンです。

もっとカメラを地面に近づけるとこんな感じで浮いてます。

image.png

穴の部分は三角形で埋めていないため、背景が見えます。実装の検証でも、塗りつぶし部分はポリゴンとして選択でき、穴の中央と内部4点では地物が選択されないことを確認しました。

地物をクリックすると、保存した属性をカラム名と値の表で表示します。次の画像ではポリゴンを選択しています。

image.png

GLBに保存した名前やIDも確認できました!

おわりに

仕様案を見ながらglTFとtileset.jsonを作ったところ、高さを持つ点・ライン・ポリゴンをCesiumで表示できました!穴あきポリゴンも、属性の取得も動きましたねー!!!

前回のMVTは、既存の2次元タイルをCesiumで使う入口でした。今回は、頂点ごとの高さを持つデータから、自分で配信するファイルを作るところまで試せました。

小さなデータであれば、JSONとバイナリを見比べながら仕様を追えます。皆さんも、手元の3次元データで試してみてください!!!

付録

Q) MVTとVector 3D Tilesって何がちゃうん?
A) 2次元か、3次元かの違いですね。特に、橋梁のひび割れを橋梁モデルにマッピングする、などはMVTでは不可能です。

Q) 普通の3D Tilesとは何がちゃうん?
A) Vector 3D Tilesの場合は例えばLineStringを「線である」という情報を持ったままブラウザ側で描画時に太さなどを動的に変更できます。今までの3D Tilesでは一度ポリゴンに変換して太さをつけるなど、擬似的なスタイリングしかできませんでした。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?