第10章: カスタムシェーダーによるスタイリング

属性データ駆動の色分けと3D建物グラデーション

はじめに

第9章まででラスタータイルと3D Tilesを統合し、WebGISビューアの基本的な描画パイプラインが完成しました。しかし、これまでの描画は「固定色」が中心でした。GISアプリケーションでは、フィーチャーの属性データに応じて色を動的に変えたい場面が多々あります。建物の高さに応じた色分け、統計データによるヒートマップ、時刻による照明の変化など、データの意味を視覚的に伝えるスタイリングが重要です。

本章では、Three.jsのカスタムシェーダーを使い、属性データ駆動のスタイリングを実現する方法を学びます。GLSL ESの基本概念から、実践的なchoropleth(階級区分図)や高さグラデーション、リアルタイムパラメータ更新まで、段階的に解説します。

コントロールパネル

以下のコントロールで3D建物のシェーダーをリアルタイムに変更できます。パラメータを変更したら「シェーダーを適用」ボタンを押してください。

#1a237e
#ff6f00

Three.jsのマテリアルシステム

Three.jsには3つのレベルでシェーダーをカスタマイズする方法があります。

レベル1: 組み込みマテリアル

MeshBasicMaterialMeshStandardMaterialなど。色、テクスチャ、透明度などをプロパティで制御できますが、描画ロジック自体は変更できません。現在のMVTレンダラー(mvt-mesh.ts)はこのレベルを使っています。

レベル2: onBeforeCompile フック

組み込みマテリアルの内部シェーダーを文字列置換で改変する方法です。既存のライティング計算やシャドウマップなどの機能を維持したまま、一部のロジックだけを差し替えられます。3D Tilesのように、ライブラリが生成するメッシュのマテリアルを後から改変する場合に有効です。

レベル3: ShaderMaterial

頂点シェーダーとフラグメントシェーダーを完全に自前で書く方法です。完全な制御が可能ですが、Three.jsの組み込み機能(ライティング、シャドウなど)は自分で実装する必要があります。MVTポリゴンのように単純な塗りつぶしで十分な場合に適しています。

GLSL ESの基本

WebGLで使用するGLSL ES(OpenGL ES Shading Language)の基本概念です。

修飾子方向用途
attributeCPU → 頂点頂点ごとのデータ(位置、色、属性値)
uniformCPU → シェーダー全頂点共通(行列、時刻、色パラメータ)
varying頂点 → フラグメント頂点間で補間されるデータ

Three.jsが自動的に注入するuniform・attribute:

自動注入される変数
// 頂点シェーダーで自動的に使える変数
uniform mat4 projectionMatrix;  // カメラの射影行列
uniform mat4 modelViewMatrix;   // モデル×ビュー行列
uniform mat4 modelMatrix;       // モデル行列
attribute vec3 position;        // 頂点位置
attribute vec3 normal;          // 法線ベクトル
attribute vec2 uv;              // テクスチャ座標

3D Tilesの高さグラデーション

PLATEAUのLOD1建物データは、各建物がボックス型のメッシュです。ECEF座標系での頂点位置から地球中心までの距離を計算し、WGS84楕円体の半径(約6,378,137m)を引くことで概算の建物高さが得られます。

onModelLoadedコールバックで生成されたメッシュにアクセスし、onBeforeCompileで既存マテリアルのシェーダーを改変します。

custom-shader.ts(高さグラデーション)
// フラグメントシェーダーで高さに応じた色を計算
float h = length(vWorldPos) - 6378137.0;
float t = clamp(
  (h - uMinHeight) / (uMaxHeight - uMinHeight),
  0.0, 1.0
);
vec3 color = mix(uColorLow, uColorHigh, t);

length(vWorldPos)はECEF原点(地球中心)からの距離です。地表面の半径を引けば概算の建物高さが得られます。厳密な計算にはWGS84楕円体の偏平率を考慮する必要がありますが、色分け程度の精度では十分です。

onBeforeCompileによるマテリアル改変

Three.jsの組み込みシェーダーは#include <chunk_name>で構成されています。onBeforeCompileで文字列置換を行い、独自ロジックを挿入します。

onBeforeCompileの仕組み
material.onBeforeCompile = (shader) => {
  // uniformを追加
  shader.uniforms.uMinHeight = { value: 0 };
  shader.uniforms.uMaxHeight = { value: 200 };

  // 頂点シェーダーにvarying追加
  shader.vertexShader = shader.vertexShader
    .replace(
      '#include <common>',
      `#include <common>
       varying vec3 vWorldPos;`
    );

  // フラグメントで高さ色混合
  shader.fragmentShader = shader.fragmentShader
    .replace(
      '#include <color_fragment>',
      `#include <color_fragment>
       diffuseColor.rgb = mix(...);`
    );
};

代表的なシェーダーチャンク:

  • #include <common> — 定数と基本関数の定義
  • #include <color_fragment> — 拡散色の決定
  • #include <worldpos_vertex> — ワールド座標の計算
  • #include <lights_fragment_begin> — ライティング計算の開始

時刻による照明変化

太陽位置をuniformで渡し、時刻に応じた照明の変化をシミュレートします。太陽赤緯(季節変動)と時角から方向ベクトルを概算します。

computeSunDirection
function computeSunDirection(
  hourUTC: number,
  dayOfYear: number
): THREE.Vector3 {
  // 太陽赤緯(季節変動)
  const decl = 23.44 * Math.sin(
    2*PI/365 * (dayOfYear - 81)
  );
  // 時角
  const ha = (hourUTC - 12) * 15;
  // ECEF → シーン座標変換
  return new Vector3(x, z, -y);
}

フラグメントシェーダーではランバート反射モデル(max(dot(normal, sunDir), 0.0))を使い、法線と太陽方向の内積から拡散光の強さを計算します。環境光(ambient)を加えることで、影になる面でも真っ黒にならないようにしています。

パフォーマンスの考慮

操作コスト再コンパイル
uniform値の変更非常に低い不要
attribute値の変更中程度不要
attributeの追加/削除高い不要
シェーダーコード変更非常に高い必要

設計原則: 動的に変化するパラメータ(色のレンジ、不透明度、時刻など)はuniformで渡し、フィーチャー固有のデータ(属性値)はattributeで渡す。シェーダーコードの変更はアプリケーション起動時に行い、ランタイムでは避ける。

上のコントロールパネルで時刻スライダーを動かす操作は、uniformの値を書き換えるだけなので、シェーダーの再コンパイルは発生しません。一方、シェーダーモードの切り替え(高さグラデーション → フラットカラー等)はシェーダーコード自体が変わるため、レイヤーの再構築が必要です。

テクスチャルックアップテーブル

多数の色クラスを使う場合、シェーダー内でif-else分岐を増やすとGPUのワープ分岐ペナルティが発生します。代わりに、1Dテクスチャをカラーパレットとして使うと分岐なしで高速にルックアップできます。

パレットテクスチャ
// CPU側: パレットテクスチャ生成
const data = new Uint8Array(width * 4);
for (let i = 0; i < width; i++) {
  data[i*4+0] = colors[i].r * 255;
  data[i*4+1] = colors[i].g * 255;
  data[i*4+2] = colors[i].b * 255;
  data[i*4+3] = 255;
}

// GPU側: テクスチャルックアップ
float idx = floor(
  vValue * (uPaletteSize - 1.0)
) + 0.5;
vec2 uv = vec2(
  idx / uPaletteSize, 0.5
);
vec4 c = texture2D(uPalette, uv);

マテリアルプーリングとの共存

既存のmvt-mesh.tsはマテリアルプーリングで描画コールを最適化しています。カスタムシェーダーを導入する場合も同じ戦略を適用します。

choroplethでは頂点カラー(attribute)で各フィーチャーを区別するため、マテリアルの共有がさらに容易です。1つのShaderMaterialを全ポリゴンで共有し、色の違いはattributeで表現する設計により、ドローコールを最小限に抑えられます。

カスタムマテリアルプール
const pool = new Map<string, ShaderMaterial>();

function createChoroplethMaterial(
  opacity = 0.7
): ShaderMaterial {
  const key = `choropleth-${opacity}`;
  let mat = pool.get(key);
  if (!mat) {
    mat = new ShaderMaterial({...});
    pool.set(key, mat);
  }
  return mat;
}

Tiles3DAdapterへのコールバック追加

第9章で実装した独自の3D Tilesアダプターがタイルをロードすると、Tiles3DAdapterCallbacksonModelLoadedコールバックで生成されたメッシュにアクセスできます。onModelDisposedと合わせて、マテリアル差し替えとクリーンアップを行います。

tiles3d-adapter.ts
interface Tiles3DAdapterCallbacks {
  onTilesetLoaded?: (sphere) => void;
  onLoadError?: (error, url) => void;
  onModelLoaded?: (
    scene: Object3D, tile: object
  ) => void;
  onModelDisposed?: (
    scene: Object3D
  ) => void;
}

Tiles3DLayershaderModeプロパティに応じて、onModelLoadedで適切なシェーダー適用関数(applyHeightGradientapplyFlatColorapplySunLighting)を呼び出します。onModelDisposedではdisposeCustomMaterialsでカスタムマテリアルをクリーンアップします。

まとめ

本章では3つのレベルでカスタムシェーダーを導入する方法を学びました:

  • MVTの属性駆動スタイリング — 頂点カラーattributeとShaderMaterialで、フィーチャー属性に基づく階級区分図を実現
  • 3D Tilesのマテリアル差し替え — onModelLoadedコールバックとonBeforeCompile/ShaderMaterialで、高さグラデーションや時刻ライティングを実現
  • パフォーマンス設計 — uniform更新を活用してシェーダー再コンパイルを避け、テクスチャLUTで分岐を削減

これらの技法を組み合わせることで、データの意味を視覚的に伝えるGISビジュアライゼーションが可能になります。次章では、全体の統合と今後の拡張について議論します。