Cesiumで美しいヒートマップを高速に描画する

2024-07-08

Cesiumで美しいヒートマップを高速に描画する

イントロ

このブログ投稿では、Cesiumで美しいヒートマップをより速く視覚化する方法を探ります。これを実現するために、CesiumのGround Primitivesをカスタマイズし、ユニークな外観を持たせることができます。したがって、独自のマテリアルとシェーダーを定義するプロセスに飛び込み、以下に示すような驚異的な視覚効果を生み出す方法を探ります。

Cesiumの柔軟性を活用することで、標準的な機能を超えたカスタムビジュアライゼーションを作成し、地理空間データ表現の新しい可能性を開きます。ヒートマップ、カスタム地形オーバーレイ、または他の特殊なビジュアライゼーションを作成したい場合でも、この技術はCesiumでアイデアを実現するためのツールを提供します。

上記の画像は、東京の千代田区の人口密度を示すヒートマップです。今日はこれを中心に取り上げます。Cesiumでヒートマップのビジュアライゼーションを作成します。この特定の例に焦点を当てますが、ここで説明するアプローチは、カスタムタイルのレンダリングや他の種類の地理空間データの表示など、さまざまなカスタムビジュアライゼーションに適用できることに注意してください。

このヒートマップを作成するプロセスを通じて、Cesiumで独自のデータビジュアライゼーションのニーズに適用できる技術についての洞察を得ることができます。

さあ、CesiumのGround Primitivesをハックするエキサイティングな旅を始めましょう!

背景

マテリアルの実装に飛び込む前に、いくつかのことを学ぶ必要があります。

シーン

シーンは、Cesiumの仮想シーン内のすべての3Dグラフィカルオブジェクトと状態のコンテナです。シーンは、明確なイメージを持つために理解する必要があるいくつかのコンポーネントで構成されています。PrimitivesCameraScreenSpaceCameraController、およびAnimationsです。外観を操作するには、次のセクションで説明するPrimitivesと対話する必要があります。

https://github.com/CesiumGS/cesium/wiki/Architecture
https://github.com/CesiumGS/cesium/wiki/Architecture

プリミティブ

プリミティブは、Scene内のジオメトリを表します。これは、単一のGeometryInstanceからのジオメトリと、個々のピクセルがどのように色付けされるかを決定するプリミティブのシェーディングを定義するAppearanceの2つの主要なコンポーネントで構成されています。

https://cesium.com/learn/cesiumjs-learn/cesiumjs-geometry-appearances/
https://cesium.com/learn/cesiumjs-learn/cesiumjs-geometry-appearances/

プリミティブには多くのジオメトリインスタンスが含まれますが、外観は1つだけです。外観の種類に応じて、シェーディングの大部分を定義するマテリアルが含まれます。

https://cesium.com/learn/cesiumjs-learn/cesiumjs-geometry-appearances/
https://cesium.com/learn/cesiumjs-learn/cesiumjs-geometry-appearances/

マテリアル

Cesiumは、マテリアルを定義するためにFabricというシステムを使用しています。Fabricは、マテリアルの外観と動作を記述するためのJSONスキーマです。シンプルなマテリアルの場合、拡散色やスペキュラー強度などのコンポーネントを指定するだけかもしれません。しかし、私たちのヒートマップのような複雑なマテリアルの場合、完全なGLSLシェーダー実装を提供することができます。

https://raw.githubusercontent.com/wiki/CesiumGS/cesium/materials/
https://raw.githubusercontent.com/wiki/CesiumGS/cesium/materials/
https://raw.githubusercontent.com/wiki/CesiumGS/cesium/materials/Dot.PNG
https://raw.githubusercontent.com/wiki/CesiumGS/cesium/materials/Dot.PNG
https://raw.githubusercontent.com/wiki/CesiumGS/cesium/materials/VerticalStripeCircle.png
https://raw.githubusercontent.com/wiki/CesiumGS/cesium/materials/VerticalStripeCircle.png

ヒートマップを生成するステップ

上記のようなヒートマップを実装するには、いくつかのコンポーネントが必要です。次のいずれかが必要です:

  • 適切な数値を持つ各地域のコンセンサスデータ(値マップ)、または

  • 同じ情報を表すメッシュ画像

    PLATEAU VIEWで実際に使用されたカスタム値マップから生成されたメッシュ画像の一例
    PLATEAU VIEWで実際に使用されたカスタム値マップから生成されたメッシュ画像の一例

このブログの目的のために、値マップが提供されていると仮定します。エリアのヒートマップを表示するには、次の手順を実行する必要があります:

  1. 値マップデータからメッシュ画像を作成する
  2. メッシュ画像からCesiumマテリアルを作成する
  3. 作成したマテリアルをプリミティブにアタッチする

各ステップを詳細に見てみましょう。

値マップからメッシュ画像を作成する

メッシュデータを保存する標準フォーマットはありませんが、次のような基本的なものから始めることができます:

type MeshData = {
  codes: Float64Array;
  values: Float32Array;
  scale?: number;
};

値マップデータからメッシュ画像を作成するには、次の手順を実行します:

  1. 入力データの準備:地理コードと値を含む入力データを提供します。
  2. コードを境界に変換:各コードを対応する地理境界に変換します。
  3. メッシュサイズの決定:タイプに基づいてメッシュのサイズを計算します。
  4. 画像データの初期化:ピクセル値を格納するデータ配列を初期化します。
  5. 画像データの入力:スケールされた値を色表現に変換してデータ配列に入力します。
  6. メッシュデータの作成:処理された画像データとメッシュの寸法を含むオブジェクトを返します。
  7. キャンバスの作成:メッシュ寸法を持つキャンバス要素を作成します。
  8. ImageDataの作成:メッシュデータからImageDataオブジェクトを作成します。
  9. 画像データの描画:ImageDataをキャンバスに描画します。
  10. メッシュ画像データの返却:キャンバスと他のメッシュプロパティを含むオブジェクトを返します。

最終結果は次のようになります:

type MeshImageData = {
  image: HTMLCanvasElement;
  width: number;
  height: number;
  maxValue?: number;
  minValue?: number;
};

このMeshImageDataの構造を念頭に置いて、次のように実装することができます:

type GeoCode = string;
type Bounds = { west: number; south: number; east: number; north: number };
type ValueMap = Map<GeoCode, number>;

interface MeshImageData {
  image: HTMLCanvasElement;
  width: number;
  height: number;
  minValue: number;
  maxValue: number;
}

async function createMeshImageFromValueMap(valueMap: ValueMap): Promise<MeshImageData> {
  // ステップ1: 入力データの準備
  const codes = Array.from(valueMap.keys());
  const values = Array.from(valueMap.values());

  // ステップ2: コードを境界に変換
  const boundsList = await convertCodesToBounds(codes);

  // ステップ3: メッシュサイズの決定
  const { width, height } = calculateMeshSize(boundsList);

  // ステップ4: 画像データの初期化
  const imageData = new Uint8ClampedArray(width * height * 4);

  // ステップ5: 画像データの入力
  const { populatedImageData, minValue, maxValue } = populateImageData(imageData, width, height, boundsList, values);

  // ステップ6: メッシュデータの作成
  const meshData = {
    data: populatedImageData,
    width,
    height,
    minValue,
    maxValue
  };

  // ステップ7: キャンバスの作成
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;

  // ステップ8: ImageDataの作成
  const ctx = canvas.getContext('2d')!;
  const cesiumImageData = new ImageData(meshData.data, width, height);

  // ステップ9: 画像データの描画
  ctx.putImageData(cesiumImageData, 0, 0);

  // ステップ10: メッシュ画像データの返却
  return {
    image: canvas,
    width,
    height,
    minValue,
    maxValue
  };
}

// ヘルパー関数

async function convertCodesToBounds(codes: GeoCode[]): Promise<Bounds[]> {
  // 実装は使用するジオコーディングサービスに依存するため、あくまでプレースホルダです
  return codes.map(code => ({ west: 0, south: 0, east: 1, north: 1 }));
}

function calculateMeshSize(boundsList: Bounds[]): { width: number; height: number } {
  // 実装は要件に依存するため、あくまでプレースホルダです
  return { width: 1000, height: 1000 };
}

function populateImageData(
  imageData: Uint8ClampedArray,
  width: number,
  height: number,
  boundsList: Bounds[],
  values: number[]
): { populatedImageData: Uint8ClampedArray; minValue: number; maxValue: number } {
  let minValue = Math.min(...values);
  let maxValue = Math.max(...values);

  for (let i = 0; i < boundsList.length; i++) {
    const bounds = boundsList[i];
    const value = values[i];
    const normalizedValue = (value - minValue) / (maxValue - minValue);

    // この境界に対するピクセル範囲を計算
    const startX = Math.floor(bounds.west * width);
    const startY = Math.floor(bounds.south * height);
    const endX = Math.ceil(bounds.east * width);
    const endY = Math.ceil(bounds.north * height);

    for (let y = startY; y < endY; y++) {
      for (let x = startX; x < endX; x++) {
        const index = (y * width + x) * 4;
        const color = getColorForValue(normalizedValue);
        imageData[index] = color[0];     // Red
        imageData[index + 1] = color[1]; // Green
        imageData[index + 2] = color[2]; // Blue
        imageData[index + 3] = 255;      // Alpha
      }
    }
  }

  return { populatedImageData: imageData, minValue, maxValue };
}

function getColorForValue(value: number): [number, number, number] {
  // 単純な青から赤へのグラデーション
  // より高度なカラーマッピングに置き換えることが可能
  return [
    Math.floor(value * 255),
    0,
    Math.floor((1 - value) * 255)
  ];
}

// 使用例
const valueMap = new Map<GeoCode, number>([
  ['A1', 10],
  ['B2', 20],
  ['C3', 30],
  // ...
]);

createMeshImageFromValueMap(valueMap).then(meshImageData => {
  console.log('Mesh Image Data created:', meshImageData);
  // CesiumのマテリアルでmeshImageDataを使用
});

メッシュ画像からCesiumのマテリアルを作成

メッシュ画像を準備したら、いよいよ楽しい部分、マテリアルの作成に進みましょう。

ヒートマップメッシュマテリアル

ヒートマップマテリアルは、データを含む画像に色付けを施し、輪郭線も追加します。以下はその構造です:

  1. ユニフォームの定義: これらは、JavaScriptから制御できるシェーダーへの入力です。
  2. マテリアル関数の実装: ここで魔法が起こります。データをサンプリングし、色付けを施し、最終的なマテリアルを作成します。
  3. Fabric定義の作成: これにより、シェーダーコードとユニフォームがCesiumが理解できる形式にまとめられます。

ステップごとに説明していきます。

ステップ1: GLSLシェーダー

まず、カスタムマテリアルの核であるGLSLシェーダーコードを見てみましょう。

glslコードをコピーする
uniform sampler2D colorMap;
uniform sampler2D image;
uniform vec2 imageScale;
uniform vec2 imageOffset;
uniform float width;
uniform float height;
uniform float opacity;
uniform float contourSpacing;
uniform float contourThickness;
uniform float contourAlpha;
uniform bool logarithmic;

// ログスケーリング用のヘルパー関数
float pseudoLog(float value) {
  // ... 実装
}

czm_material czm_getMaterial(czm_materialInput materialInput) {
  // 画像データをサンプリング
  vec2 texCoord = materialInput.st * imageScale + imageOffset;
  vec2 pair = sampleBicubic(image, texCoord, vec2(width, height), 1.0 / vec2(width, height));
  float value = pair.x;
  float alpha = pair.y;

  // 必要に応じてログスケーリングを適用
  float scaledValue = logarithmic ? pseudoLog(value) : value;

  // 値を正規化して色を参照
  float normalizedValue = (scaledValue - minValue) / (maxValue - minValue);
  vec3 color = texture(colorMap, vec2(normalizedValue, 0.5)).rgb;

  // 輪郭線を適用
  float contour = makeContour(value - minValue, contourSpacing, contourThickness);
  color = mix(color, vec3(step(dot(color, vec3(0.299, 0.587, 0.114)), 0.5)), contour * contourAlpha);

  // 最終的なマテリアルを作成
  czm_material material = czm_getDefaultMaterial(materialInput);
  material.diffuse = color;
  material.alpha = opacity * alpha;
  return material;
}

このシェーダーは以下の処理を行います:

  1. より滑らかな結果を得るためにバイキュービック補間を使用して画像データをサンプリング
  2. 任意のログスケーリングを適用
  3. データ値を指定されたカラーマップを使って色にマッピング
  4. データ可視化を向上させるために輪郭線を追加
  5. マテリアルの最終的な色と不透明度を設定

ステップ2: Fabric定義の作成

シェーダーが準備できたら、Fabric定義でラップする必要があります:

jsxコードをコピーする
const heatmapMeshMaterial = new Cesium.Material({
  fabric: {
    type: 'HeatmapMesh',
    uniforms: {
      colorMap: colorMap, // https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_cm_listed.py
      image: heatmapImageData, // 前のセクションで作成したもの
      imageScale: new Cesium.Cartesian2(1, 1),
      imageOffset: new Cesium.Cartesian2(0, 0),
      width: heatmapImageWidth,
      height: heatmapImageHeight,
      opacity: 1.0,
      contourSpacing: 10,
      contourThickness: 1,
      contourAlpha: 0.2,
      logarithmic: false
    },
    source: heatmapMeshMaterialShaderSource // これは上記で書いたGLSLコードです
  }
});

このFabric定義は、Cesiumにカスタムマテリアルについて知らせます。JavaScriptからマテリアルを制御するために使用するユニフォームを指定し、完全なGLSLシェーダーソースを含んでいます。

作成したマテリアルをプリミティブにアタッチ

ここでは、カスタムFabricマテリアル、GLSLシェーダー、Cesiumのプリミティブシステムが一体となり、Cesiumの3D地形とシームレスに統合された強力なインタラクティブなヒートマップ可視化を作成する重要な段階に到達します。

一般的な実装フロー

  1. カスタムマテリアルを作成
    • データを解釈して望ましい視覚効果を作成するシェーダーを定義します。
  2. ジオメトリを定義
    • 地球上にヒートマップを表示する場所を示すGeometryInstanceを作成します。
  3. GroundPrimitiveを作成して追加
    • カスタムマテリアルとジオメトリを組み合わせてGroundPrimitiveを作成し、シーンに追加します。
  4. 更新を処理
    • データが変更されたときにマテリアルユニフォームを更新するシステムをセットアップします。
  5. ライフサイクルを管理
    • メモリリークを防ぐためにプリミティブをシーンに適切に追加および削除します。

コンセプチュアルフローの例

tsxコードをコピーする
// 1. カスタムマテリアルを作成
const heatmapMaterial = new Cesium.Material({
  fabric: {
    // カスタムシェーダーをここに定義
  }
});

// 2. ジオメトリを定義
const heatmapGeometry = new Cesium.GeometryInstance({
  geometry: new Cesium.PolygonGeometry({
    // ヒートマップエリアをここに定義
  })
});

// 3. GroundPrimitiveを作成して追加
const heatmapPrimitive = new Cesium.GroundPrimitive({
  geometryInstances: heatmapGeometry,
  appearance: new Cesium.EllipsoidSurfaceAppearance({
    material: heatmapMaterial
  })
});
scene.groundPrimitives.add(heatmapPrimitive);

// 4. 更新を処理
function updateHeatmap(newData) {
  heatmapMaterial.uniforms.dataTexture = newData;
  // 必要に応じて他のユニフォームを更新
}

// 5. ライフサイクルを管理
function cleanup() {
  scene.groundPrimitives.remove(heatmapPrimitive);
}

このアプローチは、Cesiumで動的な地形適合ヒートマップを作成するための柔軟なフレームワークを提供します。マテリアル定義、ジオメトリ作成、および更新ロジックを分離することで、さまざまなデータセットおよび可視化のニーズに容易に適応できるシステムを構築できます。

この実装結果は次のような外観になります:

まとめ

この投稿では、Cesium上で美しいヒートマップをより速く可視化するために、地形に適合するヒートマップに焦点を当てたカスタムビジュアライゼーションの作成プロセスを探りました。主なポイントは以下の通りです。

  • Cesiumのシーンとプリミティブシステムの理解
  • FabricとGLSLを使用したカスタムマテリアルの作成
  • 生データを魅力的な視覚表現に変換する方法

この柔軟なアプローチは、人口密度のヒートマップに留まらず、さまざまな地理空間データセットを視覚化するための強力なツールとなります。制限はあなたの想像力とデータの利用可能性だけです。

これらの技術を適用する際には、以下の点に留意してください。

  • 視覚効果とパフォーマンスのバランスをとる
  • 異なるデバイスやデータの複雑さに対してテストする

これで、Cesiumで地理空間ビジュアライゼーションの限界を押し広げる準備が整いました。環境データ、経済指標、その他の空間情報をマッピングする際に、これらのスキルは非常に役立つでしょう。

ハッピーマッピング!

参考文献

Japanese

Eukaryaでは様々な職種で積極的にエンジニア採用を行っています!OSSにコントリビュートしていただける皆様からの応募をお待ちしております!

Eukarya 採用ページ

Eukarya is hiring for various positions! We are looking forward to your application from everyone who can contribute to OSS!

Eukarya Careers

Eukaryaは、Re:Earthと呼ばれるWebGISのSaaSの開発運営・研究開発を行っています。Web上で3Dを含むGIS(地図アプリの公開、データ管理、データ変換等)に関するあらゆる業務を完結できることを目指しています。ソースコードはほとんどOSSとしてGitHubで公開されています。

Eukarya Webサイト / ➔ note / ➔ GitHub

Eukarya is developing and operating a WebGIS SaaS called Re:Earth. We aim to complete all GIS-related tasks including 3D (such as publishing map applications, data management, and data conversion) on the web. Most of the source code is published on GitHub as OSS.

Eukarya Official Page / ➔ Medium / ➔ GitHub