🚀

Next.js × Potree × Cesium で大規模点群をシームレスに可視化

に公開

雑談

みなさん、ChatGPT o3 使いました?
いやーすごいですね。
なんかめちゃくちゃ賢い東大卒の研究者と話してるみたいな感じです。
X で話し方が落合陽一みたいだと言われているのを見ましたが、まさにそんな感じですね。
まああたまの良さっていうより知識の広さが半端ないですね。
というのも、思考の過程をのぞいてみるとめちゃくちゃ検索しているようですね。
日本の Web サイトはもちろん、海外のブログ、X、YouTube まで、一回の質問でインターネットの海へ狩りに出かけて、大量獲得しているようです。
こりゃあ、人間は勝てませんわな。
私は知識はもう LLM さんにお任せして、人間性で勝負することにします。

はじめに

さて、最近はお仕事獲得のためにとりあえず技術記事をいっぱい書こうという活動をしております。
で、いざ書くとなると中々よいアイディアが思いつかないということで、さっそく ChatGPT o3 に聞いてみました。
どう聞いたかというと、まず私の経歴や実績(GIS システム、AWS のデータ基盤構築、Next.js 製の Web アプリ開発)を全部教えて、
何かおもしろくて有益な記事のアイディアを考えて。
という風に聞きました。
すると、本題の Next.js、Potree で大規模点群をローカルにダウンロードせずに可視化するプロジェクトがいいじゃないかとのことでした。
ということで早速作ってみました。
とりあえずソースコードと実際のアプリは以下においておくので、特に説明不要という方はぜひ見ていってください。
ソースコード: https://github.com/st-moritk/nextjs-copc-potree-viewer/
アプリの URL: https://st-moritk.github.io/nextjs-copc-potree-viewer/
(スマホでご覧の方は横向きにしてください)
イメージ画像はこちら
スクショ 1
スクショ 2
スクショ 3

概要

それでこれなんやねんってことですが、こういうアプリです

  • USGS(United States Geological Survey:米国地質調査所)が公開している点群データを可視化
  • 点群データは S3 バケットで公開してある
  • それをダウンロードせずに Web ブラウザ上で可視化
  • 使用技術は Next.js と potree という OSS を拝借

という感じです。

potree って?

octree というデータ構造を活用して、大規模点群データを Web ブラウザ上で可視化できまっせというライブラリです。
https://github.com/potree/potree
ただこれ問題なのが npm がないんですよね…。
リポジトリ自体は頻繁に更新されていて、スターも多いんですが。
あと Github CDN で利用しようとしたらなぜかブロックされるという…。
issue でもこれらの問題は指摘されているみたいですが、特に改善の見込みはなさそうです。
ということで力技ですが、ローカルに potree のリポジトリをクローンして、必要なものだけプロジェクトに直接ぶち込みました。

octree って?

3 次元空間データを 2 分木ならぬ 8 分木にして効率的に取り出したりできるデータ構造らしいです。
3 次元点群データを全部可視化してると処理が大変すぎるんで、
遠くから見ると粗っぽく、近くで見ると鮮明にでるようにしたいですよね。
そのために点群データを近くにある者同士をグループにして、それを階層構造にして圧縮して、
粒度に応じて取り出せるようにするってイメージです。
(スーパーざっくりした説明です)

コード解説

詳細は Github でソースコードを読んでいただければと思いますが、重要なところや難しかったところをかいつまんで説明していきます。

コードの概要

まず page.tsx でクローンした potree のスクリプトを読み込んでいます。

src/app/page.tsx
      <Script
        src={`${basePath}/potree/libs/jquery/jquery-3.1.1.min.js`}
        strategy="beforeInteractive"
      />
      <Script
        src={`${basePath}/potree/libs/other/BinaryHeap.js`}
        strategy="beforeInteractive"
      />
      <Script
        src={`${basePath}/potree/libs/proj4/proj4.js`}
        strategy="beforeInteractive"
      />
      <Script
        src={`${basePath}/potree/libs/three.js/build/three.min.js`}
        strategy="beforeInteractive"
      />
      <Script
        src={`${basePath}/potree/libs/tween/tween.min.js`}
        strategy="beforeInteractive"
      />
      <Script
        src={`${basePath}/potree/libs/d3/d3.js`}
        strategy="beforeInteractive"
      />
      <Script
        src={`${basePath}/potree/libs/copc/index.js`}
        strategy="beforeInteractive"
      />
      <Script
        src={`${basePath}/potree/potree/potree.js`}
        strategy="beforeInteractive"
      />
      <Script
        src={`${basePath}/potree/libs/Cesium/Cesium.js`}
        strategy="beforeInteractive"
      />

続いて Potree や Cesium の初期化の処理を行ったら、USGS の点群データを読み込みます。

src/components/PotreeScene.tsx
    window.Potree.loadPointCloud(url, name, (e: any) => {
      try {
        viewer.scene.addPointCloud(e.pointcloud);

        e.pointcloud.material.size = POTREE_CONFIG.MATERIAL_SIZE;
        e.pointcloud.material.pointSizeType =
          window.Potree.PointSizeType.ADAPTIVE;

        if (e.pointcloud.material._activeAttributeName !== undefined) {
          e.pointcloud.material._activeAttributeName = "elevation";
        }

        viewer.fitToScreen();

        e.pointcloud.position.z = heightOffset;

        resolve({
          pointcloud: e.pointcloud,
          projection: e.pointcloud.projection,
        });
      } catch (error) {
        console.error("点群データの処理中にエラーが発生:", error);
        reject(error);
      }
    });

ここは色々やっているので分割して解説します。


まず以下で受け取った URL の点群データを Potree のビューワに追加しています。
今回の点群データの URL は
https://s3-us-west-2.amazonaws.com/usgs-lidar-public/CA_SanFrancisco_1_B23/ept.json
です。
アメリカのカリフォルニア州のサンフランシスコを LiDAR とういセンサーで点群データ化したものです。
最後のept.jsonとは点群データのメタデータです。点群がどの範囲で存在しているのかとかデータの型などが載っています。
これを loadPointCloud することで点群データを読み込んでいます。

    window.Potree.loadPointCloud(url, name, (e: any) => {
      try {
        viewer.scene.addPointCloud(e.pointcloud);

以下の部分で、点群の点の大きさ、視点を変えた時の点の見え方、色付けの設定を行っています。
今回は点は近いものほど大きく見え、遠いものほど大きく見えます。
そして、高さに応じて色が変わります。

e.pointcloud.material.size = POTREE_CONFIG.MATERIAL_SIZE;
e.pointcloud.material.pointSizeType = window.Potree.PointSizeType.ADAPTIVE;

if (e.pointcloud.material._activeAttributeName !== undefined) {
  e.pointcloud.material._activeAttributeName = "elevation";
}

そして、読み込んだ点群に合わせて視点を変更。
お好みで点群の高さオフセットを設定(今回は 0)
最後に点群データと投影情報(EPSG コードなど)を返します。

viewer.fitToScreen();

e.pointcloud.position.z = heightOffset;

resolve({
  projection: e.pointcloud.projection,
});

続いて、Cesium の 3D マップと Potree の同期処理について解説します。

const camera = potreeViewer.scene.getActiveCamera();

const potreePosition = new window.THREE.Vector3(0, 0, 0).applyMatrix4(
  camera.matrixWorld
);
const potreeUp = new window.THREE.Vector3(
  0,
  SYNC_CONFIG.UP_VECTOR_OFFSET,
  0
).applyMatrix4(camera.matrixWorld);
const potreeTarget = potreeViewer.scene.view.getPivot();

const cesiumPosition = transformer.toCesium(potreePosition);
const cesiumUpTarget = transformer.toCesium(potreeUp);
const cesiumTarget = transformer.toCesium(potreeTarget);

const cesiumDirection = window.Cesium.Cartesian3.subtract(
  cesiumTarget,
  cesiumPosition,
  new window.Cesium.Cartesian3()
);
const cesiumUp = window.Cesium.Cartesian3.subtract(
  cesiumUpTarget,
  cesiumPosition,
  new window.Cesium.Cartesian3()
);

window.Cesium.Cartesian3.normalize(cesiumDirection, cesiumDirection);
window.Cesium.Cartesian3.normalize(cesiumUp, cesiumUp);

cesiumViewer.camera.setView({
  destination: cesiumPosition,
  orientation: {
    direction: cesiumDirection,
    up: cesiumUp,
  },
});

まずこのプロジェクトでは点群データでは Potree、3D マップは Cesium で表示しています。
この 2 つではビューや座標の設定が異なるので、それらを合致させる必要があります。
そのために

const potreeTarget = potreeViewer.scene.view.getPivot();

const cesiumPosition = transformer.toCesium(potreePosition);
const cesiumUpTarget = transformer.toCesium(potreeUp);
const cesiumTarget = transformer.toCesium(potreeTarget);

const cesiumDirection = window.Cesium.Cartesian3.subtract(
  cesiumTarget,
  cesiumPosition,
  new window.Cesium.Cartesian3()
);
const cesiumUp = window.Cesium.Cartesian3.subtract(
  cesiumUpTarget,
  cesiumPosition,
  new window.Cesium.Cartesian3()
);

ここで、カメラ位置(cesiumPosition)、カメラ上位置(cesiumUpTarget)、注視点(cesiumPosition)を取得して、
カメラ → 注視点の方向(cesiumDirection)、カメラの真上方向(cesiumUp)を取得します。
そこから、これら 2 つの方向の単位ベクトルを取得して、Cesium にセットします。

window.Cesium.Cartesian3.normalize(cesiumDirection, cesiumDirection);
window.Cesium.Cartesian3.normalize(cesiumUp, cesiumUp);

cesiumViewer.camera.setView({
  destination: cesiumPosition,
  orientation: {
    direction: cesiumDirection,
    up: cesiumUp,
  },
});

これにより Potree と Cesium の方向(ビュー)が合致しました。

続いて以下の部分について

const aspect = potreeViewer.scene.getActiveCamera().aspect;
const fovy = window.Cesium.Math.toRadians(camera.fov);
if (aspect < 1) {
  // 縦長ウィンドウの場合
  cesiumViewer.camera.frustum.fov = fovy;
} else {
  // 横長ウィンドウの場合
  const fovx = Math.atan(Math.tan(0.5 * fovy) * aspect) * 2;
  cesiumViewer.camera.frustum.fov = fovx;
}

cesiumViewer.render();

ここでは Potree と Cesium の FOV の違いを合わせています。
Cesium は Potree と違って横長だと FOV は水平方向、縦長だと垂直方向なので、それに合わせるように Cesium の FOV を設定しています。
設定が終わったら描画(render)して、これをループさせるということになっています。


以上でこのプロジェクトの要点の解説を終わります。
最後まで読んでくださってありがとうございました!

Discussion