DDDDbox Tech Blog

建築設計クラウドサービス『DDDDbox』のテックブログ

クリック判定が逆転する?Three.jsレイキャストの落とし穴

nanobanana pro

こんにちは、DDDDbox事業部で日々3D開発を行っている鈴木です。

先日、こんな現象に遭遇しました。

「手前にあるはずの線オブジェクトをクリックしたいのに、奥にあるメッシュオブジェクトが選択されてしまう」

カメラに近い線が優先されると想定していたのですが、なぜか奥にあるメッシュの方が近いと判定されてしまうという現象です。

今回はこの問題の調査結果を整理してみます。

TL;DR

  • 現象: Line2(境界線)をクリックしたいのに、奥にあるMeshが選択されてしまう
  • 原因: MeshとLine2でレイキャストのdistanceの定義が異なる(Meshは「交点」、Line2は「最近傍点」)
  • 対策: zオフセットを十分に大きく取るなり、R3Fのfilterでソートロジックをカスタマイズするなり

動作確認時のバージョン

ライブラリ バージョン
three ^0.179.1
@react-three/fiber ^9.4.2
@react-three/drei ^9.122.0
react 18

レイキャストの基本的な仕組み

まず前提として、レイキャスト(Raycasting)とは、カメラからシーンに向かってレイ(光線)を飛ばし、オブジェクトとの交差を計算する手法のことです。Three.jsではこの仕組みをクリック判定に利用しています。

Three.jsのRaycasterは、マウス座標とカメラ情報をもとにレイ(光線)を飛ばし、シーン内のオブジェクトとの交差を判定します。複数のオブジェクトに交差した場合は、distance(レイから交点までの距離) が小さい順にソートされ、最も近いオブジェクトが選択されます。

Meshのレイキャスト:「レイが突き刺さった点」

Meshのレイキャストは比較的シンプルです。ジオメトリを構成する各三角形(ポリゴン)に対して、レイとの幾何学的な交点を求めます。

例えば z=-1 の平面にあるMeshに対して、カメラから垂直にレイを飛ばした場合、交点のz座標は厳密に -1 になります。この交点は三角形の面上の点なので、数学的に正確な値を求めることができます。

Line2のレイキャスト:「最も近い点」

線分は断面表示のイメージ図

一方、Line2のレイキャストは複雑です。

「Meshと同じように交差計算すればいいのでは?」と最初ふわっと僕は思ってしまったのですが、Line2のレイキャストが異なるアルゴリズムを採用しているのには理由があります。

理由1: 3D空間でレイと線分はほぼ交差しない

そもそも数学的に、Meshとは根本的に状況が異なります。

Meshのレイキャストがうまくいくのは、レイ(1次元)と三角形の(2次元)の交差を求めているからです。

1次元のものが2次元の面に当たる確率は十分にあります。紙にペンを突き刺すようなイメージですね。

しかし、Line2の場合は線(Line2: 1次元)と線(レイ: 1次元)との交差判定を行うことになります。 想像してみると、2本の直線が3D空間で交差した交点を持つには、完全に同一平面上にある必要があるので、ユーザーのクリック操作によって飛んだレイが、線(Line2)と交差する確率はほぼゼロです。

「でも、Line2には太さがあるんだから、その太さ分のポリゴンと交差判定すればいいのでは?」と思うかもしれません。それが次の理由につながります。

理由2: Line2の「太さ」の定義

Line2(Fat Lines)の太さは、デフォルトでは画面上のピクセル単位で定義されています。 例えば lineWidth={5} と指定した場合、カメラをどれだけズームしても画面上では常に5ピクセルの太さで表示されます(なお LineMaterial には worldUnits オプションがあり、true にするとワールド空間単位に切り替えることも可能です)。

この線の太さを表すポリゴンは、カメラの位置やズームが変わるたびにワールド空間での実際の太さが変わる仕様であるため、GPU側でシェーダーが描画時にリアルタイムに生成しています。 つまり線の太さはCPU上に存在しないため、レイキャストのクリック判定時に線の太さを取得することができません。

Three.jsのLine2は、このような設計上の理由から、線の太さのポリゴンとの交差判定ではなく、別のアプローチを採用しています。

なぜクリック判定が逆転するのか?

3d空間上で交差しない線(表示は円柱)
以上の理由により、Line2のレイキャストはMeshと同様の手法で交点を求めることはできません。 ではどうしているのかというと、レイと線分の距離を求めています。*1 この距離が線の太さ(threshold)以内であればヒットとみなします。

注目すべきは MeshとLine2で レイキャストのクリック判定に利用するdistance の意味そのものが違うということです。

Meshの distance は「レイが面にぶつかった点」までの距離ですが、Line2の distance は「レイ上で線分に最も近い点」までの距離です。Line2の太い線は画面上のピクセル幅で"当たった"と判定されますが、返される distance は3D空間でのレイ上の最近傍点までの距離であり、線の断面との厳密な交点ではありません

  • Mesh: レイが三角形の表面に突き刺さった点までの距離
  • Line2: レイと線分が最も近づいた時の、レイ側の最近傍点までの距離

この定義の違いにより、レイが斜めに飛んでいる場合、Line2のレイ上の最近傍点は、Meshとの交点よりもカメラに近い位置にも遠い位置にもなり得ます。

悪化する条件

実際に検証してみると、このクリック判定の逆転現象は、次の条件が重なると起こりやすくなることがわかりました。

  • カメラが遠い — レイが長くなるほど、最近傍点と交点の位置ズレが大きくなります。
  • Line2とMeshのz方向の間隔が小さい — 間隔が小さいほど、距離の逆転が起きやすくなります。
  • カメラの回転 — アイソメビューのようにレイが斜めになると、Line2は「ねじれの位置にある2直線の最短距離」を解く形になり、最近傍点の位置がMeshの交点とずれやすくなります。さらに座標変換に回転行列が入ることで浮動小数点の丸め誤差も蓄積しやすくなります。
  • 大きなシーン — 座標数値が大きいと行列演算で桁落ちしやすく、上記のズレが増幅されます。

OrthographicCameraを遠方に置いてスケールの広いシーンを俯瞰する構成は、まさにこれらの条件に当てはまります。

解決策

いくつかの解決策が考えられますが、代表的なものを紹介します。

解決策1: zオフセットを大きくする(実用的)

最もシンプルな対策は、MeshをLine2から物理的に離すことです。オブジェクト間の距離差を誤差より大きく取れば、正しい判定順序になります。レイヤーごとにオフセット値を管理し、Line2を最前面(z=0)、その奥にMeshを配置するといった設計が有効です。

// レイヤーごとにオフセットを定義(Line2はz=0、Meshはその奥)
export const NEIGHBOR_MESH_Z_OFFSET = -10;  // 誤差を上回る距離に
<mesh position={[0, 0, NEIGHBOR_MESH_Z_OFFSET]}>
  <planeGeometry args={[4, 2]} />
  <meshBasicMaterial color="red" />
</mesh>

解決策2: Raycasterのソートロジックをカスタマイズする

距離が信頼しづらい場合は、ソート処理をカスタマイズし、距離差が小さいときはLine2を優先するなどのルールを加えられます。R3Fでは、イベントマネージャーのfilter関数で交差結果の並び替えが可能です。

import { Canvas, events } from '@react-three/fiber'

const eventManagerFactory: Parameters<typeof Canvas>[0]['events'] = (state) => ({
  ...events(state),
  filter: (items, state) => {
    const threshold = 0.5  // 距離差がこの範囲内ならLine2を優先
    return items.sort((a, b) => {
      const distDiff = a.distance - b.distance
      if (Math.abs(distDiff) < threshold) {
        const aIsLine = a.object.type === 'Line2' || a.object.type === 'LineSegments2'
        const bIsLine = b.object.type === 'Line2' || b.object.type === 'LineSegments2'
        if (aIsLine && !bIsLine) return -1
        if (!aIsLine && bIsLine) return 1
      }
      return distDiff
    })
  },
})

function App() {
  return <Canvas events={eventManagerFactory}>{/* ... */}</Canvas>
}

まとめ

今回の問題は、MeshとLine2で distance の定義が根本的に異なることに起因する仕様上の挙動でした。

ポイントをまとめると以下のようになります。

  • Mesh: レイと三角形面の「交点」までの距離を返す
  • Line2: レイと線分の「最近傍点(レイ側)」までの距離を返す
    • この定義の違いにより、距離の大小関係が逆転し得る
    • カメラが遠い・レイが斜め・座標値が大きいといった条件が重なると、逆転がより起きやすくなる
  • 対策: zオフセットを十分に大きく取るか、R3Fのfilterでソートロジックをカスタマイズする

--

AMDlabでは、このような3Dグラフィクスの開発に多く取り組んでおり、力を貸していただけるエンジニアさんを大募集しております! 少しでもご興味をお持ちいただけましたら、カジュアルにお話するだけでも大丈夫ですのでお気軽にご連絡ください!

中途求人ページ: https://www.amd-lab.com/recruit-list/mid-career

カジュアル面談がエントリーフォームからできるようになりました。 採用種別を「カジュアル面談(オンライン)」にして必要事項を記載の上送信してください!

エントリーフォーム: https://www.amd-lab.com/entry

*1:3D空間で交差も平行もしていないねじれの位置にある2つの直線の距離を求めるには、「共通垂線」という考え方を使います。これは、両方の直線に対して直角に交わる、たった1本の線分のことです。この共通垂線の長さが、2直線間の最短距離となります

WebWorkerから出力したログはどこへ?

chrome
こんにちは、DDDDbox事業部で日々開発を行っている鈴木です。

以前、重たい3D幾何処理をWebAssembly(Wasm)で実装し、それをWeb Worker上で動かすということをやっていました。メインスレッドをブロックしたくないので、Workerに処理を逃がすというパターンです。

その際のバグ調査でWasm側の挙動を確認したくてログを仕込んだのですが、ChromeのDevToolsを開いてもログが出てきませんでした。

問題:Wasmのログが見えない

最初はWasm側の問題かと思い、web-sysクレートを入れ直してログを出すことを試しました。 しかし、ログは出ませんでした。

このときの構成は以下です。

flowchart TD
    A[React] -->|spawn| B[Web Worker]
    B -->|load, execute| C[WASM]
   
    C -->|postMessage| B
    B -->|postMessage| A

さらに調査を進めると、Web Worker側で console.log を呼んでも、ChromeのDevToolsにログが表示されないことがわかりました。

ここであれこれと小一時間悩んでしまいました。

解決:コンテキストの選択

結論から言うと、Chrome DevTools上で「コンテキスト」の選択をしていなかったことが原因でした。 ChromeのDevToolsのコンソール上部で、top を選択したまま「Selected context only」のチェックボックスがONになっていたため、Workerからのログが表示されていませんでした。

Chrome dev tool1

ここのチェックを外すと、Web Worker(JS側)のログや、Wasm(Rust側)からのログが表示されるようになりました。

プルダウンから top ではなく、該当のWeb Workerの項目を選択しても、ログが表示されるようになります。

とりあえずログが出たことで目的は果たしたのですが、そもそも context とは? top とは?という疑問が出たのでもう少し深掘りしてみることにしました。

「コンテキスト」とは

Chrome DevToolsでいうコンテキストとは何を指しているのか、調査しました。

これはBrowsing Context(閲覧コンテキスト)の概念と密接に関係しているようです。 MDNによると、閲覧コンテキストとは「ブラウザが文書(Document)を表示する環境」を指します。 具体的には、タブ、ウィンドウ、iframe、ポップアップなどはそれぞれ独自の閲覧コンテキストを持ちます。

閲覧コンテキストは独自のwindowオブジェクトを持ち、オリジン(origin)や履歴を個別に管理しています。 そのため、変数スコープが分かれており、互いに変数を直接共有することができません。

Chrome DevToolsのコンテキスト

Chrome DevToolsのコンテキスト選択のUIには、閲覧コンテキストに加えてWeb Workerも表示されます。

Web Workerは window オブジェクトを持つ閲覧コンテキストではなく、独自のグローバルスコープ(selfオブジェクト)を持つ実行環境として選択肢に含まれています。 例えば、Web Workerの中で window オブジェクトを使うとエラーになります。

また、Chrome拡張を入れていると、コンテキスト選択のUIに拡張機能の名前が出てきます。拡張機能のContent Scriptはページと同じDOMを共有していますが、JSの変数、つまりスコープは隔離されていて、別のコンテキストとして扱われます。

Chrome DevToolsのコンテキスト選択は、これらのコンテキストの中から、どのスコープの変数やログを操作・参照するか、を選ぶためのものとなっています。

「top」とは何か

ところで、DevToolsのコンテキスト表示のUIにデフォルト選択されている「top」ですが、何に対しての「トップ」なのでしょうか。

僕は最初、Linuxの top コマンドを連想していましたが、ちょっと意味合いが違うようです。

Chrome DevToolsのドキュメントには「top represents the main document's browsing context」と記載されています。つまり、「メインページの閲覧コンテキスト」を指しています。

歴史的背景

JavaScriptには古くから window.top というプロパティが存在します。 topはウィンドウ階層における最上位のウィンドウへの参照を返します。

// コンソールで試してみると
window.top === window  // true(iframeでなければ)
  • windowself): 自分自身
  • window.parent: 一つ上の親
  • window.top: 階層構造の最上位の閲覧コンテキスト

DevToolsの「top」という表示は、このwindow.topが指す最上位の閲覧コンテキストに由来していると思われます。

2000年代前半のWebでは、framesetタグを使って一つの画面の中に複数のHTMLを埋め込むことがありました。今でもiframeで広告や埋め込み動画を表示するときにその名残があります。

Top (メインページ)
├── iframe A (広告)
├── iframe B (ウィジェット)
│   └── iframe C (孫フレーム)

このような入れ子構造があったとき、「一番外枠の、URLバーに表示されているページのコンテキスト」を区別するために top という名前がついています。

Chrome DevToolsでの扱い

DevToolsのUI上は、top が全てのコンテキストの親要素になっていて、Web Workerはその子要素として表示されているので(並列ではなく)、ここがちょっとした混乱ポイントになっているかと思います。

top

Web WorkerやChrome拡張にとってのメインページは top なので、その考えではコンテキスト選択のUIは正しいです

しかし、Web WorkerやChrome拡張などは、top とはスコープが分かれており、その意味では並列に存在しています。 そのため、top を選択して 「Selected Context Only」の状態だと、Web Workerのログがフィルタされて表示されません。

また、top とライフサイクルが違うもの、例えば google.tag.managerの sw.js は コンテキスト選択のUI上もtop と並列に存在しています。

top に所属するものはタブを閉じれば top もろとも消えますが、top と並列に表示されているものはタブを閉じても、ブラウザの裏側で存在し続けます。

なぜコンテキストは分かれているのか

スコープを分けて名前の衝突を防ぐ理由としてはセキュリティがあります。

例えば、広告配信のスクリプトChrome拡張機能が、Webサイト本体の変数を上書きして壊さないようにするためであったり、また <iframe>の場合、異なるドメインスクリプトがお互いのクッキーやパスワードを盗み見られないように、壁を作る必要があります。いわゆるサンドボックスです。

DevToolsの「コンテキストセレクタ」が存在する理由は、開発者がデバッグするときに、壁の向こう側の変数を見たり操作したりするためです。

おまけ:Workerに名前をつけると便利

コンテキストの話ついでに知った小ネタです。

Web Workerを生成するとき、実は指定した名前をつけられます

// 第2引数で name オプションを指定
const myWorker = new Worker("worker.js", { name: "ImageProcessWorker" });

こうしておくと、DevToolsのコンテキスト一覧やネットワークタブで、ファイルパスのような名前ではなく、指定した ImageProcessWorkerという名前で表示されるようになります。

Workerを複数使うアプリだと、どのWorkerのログなのかわからなくなりがちなので、これは地味に便利だと感じました。

まとめ

  • Workerのログが出ないときは、Chrome DevToolsの「Selected context only」を疑う
  • コンテキストとは、変数の衝突を避けセキュリティを高める仕組み

DDDDboxでは、WebAssemblyやWeb Worker、WebGLなど、ブラウザの低レイヤーな技術も駆使した3D開発に取り組んでいます。こういった技術に興味があるエンジニアさんを大募集中です!

少しでもご興味をお持ちいただけましたら、カジュアルにお話するだけでも大丈夫ですのでお気軽にご連絡ください!

中途求人ページ: https://www.amd-lab.com/recruit-list/mid-career

カジュアル面談がエントリーフォームからできるようになりました。 採用種別を「カジュアル面談(オンライン)」にして必要事項を記載の上送信してください!

エントリーフォーム: https://www.amd-lab.com/entry

なぜ自社プロダクト開発にRustを選んだのか

ferris

こんにちは、株式会社AMDlabのDDDDbox事業部で日々開発を行っている鈴木です。

私たちが開発しているWEBBIMは、建物のボリューム検討、建物のモデリング、図面出力といった建築設計における実務作業に役立つ機能を多く搭載した、ブラウザで動く3D BIMアプリケーションです。

「ブラウザでリアルタイムに反応する3D BIMシステムを実現するには何が必要か?」という課題への私たちの回答として、Rust があります。 私たちは、フロントエンド(WebAssembly)とバックエンドの両方でRustを採用することにしました。

カジュアル面談などでもRustに関してよく質問されるので、今回の記事では、WEBBIMの大まかなアーキテクチャと、なぜRustを選んだのか、実際に使ってみてどうだったのかを整理してみようと思います。

目次

  1. DDDDbox-WEBBIMのアーキテクチャ
  2. フロントエンドのRust
  3. バックエンドのRust
  4. Rust採用のトレードオフ
  5. まとめ
  6. 参考文献

DDDDbox-WEBBIMのアーキテクチャ

まず、WEBBIMのシステム構成を簡単に説明します。

graph TD
    subgraph フロントエンド
        B[React]
        C[WASM Rust]
        B <--> C
    end

    B <-->|GraphQL| D[BFF Go]
    D -->|gRPC| E[バックエンド Rust]
    D -->|REST| G[バックエンド Pythonなど]
    E --> F[(Database)]

フロントエンドは、ReactからWebAssemblyにコンパイルされたRustコードを呼び出して何らかの計算結果を取得し、その結果をThree.js(react-three-fiber)で描画する、といった流れです。 大幅に負荷の高い処理はWebWorkerを使って処理しています。

バックエンドは、メインのDBに接続するサーバーがgRPCサーバーになっており、こちらはRust(tonic)を使用しています。

通信部分は主にGraphqlとgRPCとWebSocketを使用しており、一部にRESTも使用しています。

フロントエンドのRust

WebAssemblyの概要

WebAssemblyは、コンパクトなバイナリー形式の低レベルなアセンブリー風言語で、ブラウザ上でネイティブに近いパフォーマンスを実現する技術です。2025年現在、主要ブラウザでは標準サポートされています。

WebAssembly はブラウザにバイナリ形式で配布されるので、ブラウザは「テキスト(JS)」をパースする必要がありません。 高速にかつ高効率でコンパイルできるため、CPUを多く使う処理ではJavaScriptに比べて高いパフォーマンスを発揮します。

Rustを選んだ3つの理由

1. GCガベージコレクション)問題の回避

3Dモデリングのシステムを作るということで、メモリ管理が重要になってきます。

いくつかのプロトタイプを作成していく中で、一番の課題となっていたのが、JavaScriptガベージコレクションGC)による予測不能な一時停止でした。

例えば、数百万頂点の建物モデルを操作している最中に、GCが実行されると数百ミリ秒から数秒の停止が発生します。これは3Dビューでの操作体験を完全に壊してしまいます。

そこで、本格的な開発に入る前にWebAssembly(Rust)について技術検証を行いました。

その検証結果として、Rustは基本的にビルド時にメモリ管理が完結し、実行時のGC停止は発生しないため、フレーム落ちのない滑らかな3D操作が実現できるという結果が得られました。 (なお、2025年にはWasmGCが標準化されましたが、Rustは元々GCを必要としない設計です)

2. ネイティブに近いパフォーマンス

Rustで書かれたコードをWebAssemblyにコンパイルすると、C/C++に匹敵する実行速度が得られます。

Rustはコンパイル時に余計なランタイムやGCガベージコレクション)がなく、メモリ管理も静的で高速です。

複雑なジオメトリ計算やブーリアン演算も快適に動作するようになり、ブラウザとは思えないくらいの速度が出ます。

3. メモリ安全性

Rustの所有権システムは、コンパイル時にメモリ安全性を保証します。バッファオーバーフローやダングリングポインタといったバグを未然に防いでくれます。

特にWebAssemblyのサンドボックス環境では、メモリ安全性が重要です。Rustのコンパイラが静的に安全性を検証してくれるため、実行時エラーのリスクが大幅に減少します。

ReactとThree.jsとの統合

私たちが今つくっているアーキテクチャでは、UIはReactで構築し、3D描画はThree.js(react-three-fiber)を使用しています。react-three-fiberを活用することで、ReactのコンポーネントモデルとThree.jsのレンダリングを効率的に統合できます。

Rustで計算したジオメトリデータは、SharedArrayBufferを使ってゼロコピーでJavaScriptに渡し、react-three-fiberで描画します。この仕組みにより、大量のメッシュデータを効率的に扱うことができます。

実例プロジェクト

Rust(WebAssembly)を用いたCAD系のアプリケーションとしては、複数の実例があります:

  • CADmium1: Rust製B-repカーネル「Truck」を使ったオープンソースのブラウザCAD。現在はプロジェクトが終了している。
  • Chili3D2: OpenCascade(OCCT)をWebAssembly化したブラウザ3D CAD。リボンUIとパラメトリックモデリングを実現
  • Graphite3: Rust+WASMによる2D画像編集ツール。複雑な画像処理をブラウザで快適に実行

また、Figmaも同様のアーキテクチャを採用しているそうです。

バックエンドのRust

前述の通り、バックエンドのサーバーはRustを選定しています。

選定時に、他の有力な言語候補としてはGoがあったのですが、以下で説明するいくつかのメリットや背景があったことで、Rustを選定しました。

WASM側とのコード再利用

建物の面積計算や体積計算、シーン内にあるオブジェクトの復元や操作といったジオメトリに関わるロジックは、フロントエンドのみならずバックエンドでも必要になる可能性がありました。 バックエンドで建物データを復元して、データ編集を行いたい、などの場合です。

この時にバックエンドもRustであれば、同じコードベースを共有でき、開発効率を上げることができます。

BIMの大量メタデータに対応

BIMアプリケーションは、建物の構造、材料、寸法、部材など膨大なメタデータを扱います。1つの建物プロジェクトで数百MBから数GBのデータを処理することも想定しています。

それらの大量データを扱う可能性がある中で、GCに頼らないRustの厳格なメモリ管理により、メモリリークやダングリングポインタなどのバグを未然に防ぐことができるRustは非常に安心感があります。

この点も重視しました。

AI支援開発との相性

最近では、Claude CodeやCodex などを使ってコードを書くことが増えましたが、選定時はGitHub Copilotを主に使っていました。

当時は現在ほどのコード生成AIではありませんでしたが、それでもAIが生成したコードに対して、動的型付け言語では実行時まで発見されないバグも、Rustではコンパイラがビルド時に厳格なチェックを行い、メモリの扱いのエラーやNull参照などを検出できるのは大きなメリットでした。

独自の所有権システムによるメモリ安全性は、AIが生成したコードに対しても保証されます。

私自身、全てのプログラミング言語に精通しているわけではありませんが、Rustのビルドが通ったときの安心感は、経験してきた他の言語と比較すると圧倒的に高いです。

RustはこういったAIツールとの相性も良いです。

開発チームの知見

私たちのチームには、Rustにある程度馴染みのある開発者が確保できていました。また、WebAssembly側でRustが必須である以上、チームとしてRustの知見を蓄積していく方が長期的に有利だと判断しました。

一度学習すれば、フロントエンドとバックエンドの両方で活用できるため、学習投資の効果が高いとも言えます。

Rust採用のトレードオフ

ここまで良い話ばかりしてきましたが、Rustにも課題はあります。

学習曲線が急

Rustの最大の課題は、所有権、借用、ライフタイムという独自の概念を習得する必要があることです。

特にJavaScriptPythonから来た開発者にとって、メモリ管理を意識する必要があることは大きな違いです。初期は「コンパイラと戦う」体験をすることになり、最初の数週間は生産性が低下することを覚悟する必要があります。

最近ではAIにある程度教えてもらうことも可能なので、このデメリットは少し軽減されています。

ビルド時間が長い

大規模プロジェクトでは、ビルド時間が長くなりがちです。そのためCI/CDなどでの時間が長くなり、PRマージやデプロイの待ち時間が長くなります。

緩和策としては、高速なリンカ(mold、lld)への変更や、プロジェクトの分割、インクリメンタルコンパイルの活用などがあります。これらを組み合わせることで、実用的な範囲に収めることが期待できますが、他言語と比較するとやはりデメリットであることは否めません。

エコシステムの成熟度

最近のニュースでGoogle Cloud用 Rust SDKが利用可能になるなど、Rustのエコシステムは急速に成長していますが、特定分野ではまだライブラリが不足しています。

私たちのケースでは、BIM/CAD関連の幾何学系のライブラリは独自で実装しなければいけない部分がありますし、一部のロジックはPythonサーバを使うなどして開発効率が落ちないように工夫しています。

まとめ

今回は、DDDDbox/WEBBIM開発でRustを採用した理由と、実際の経験をまとめてみました。

初期の学習コストは高いですが、一度習得すれば、ビルドが通ることによる大きな心理的安心が得られ、長期的な開発効率は向上します。

パフォーマンスと安全性の両立、そしてWebAssemblyでのモジュールとバックエンドモジュールの連携などを考えると、Rustは戦略的に正しい選択だったと確信しています。

AMDlabでは、このような建築×3D×Rustの開発に一緒に取り組むエンジニアを募集しています!

少しでもご興味をお持ちいただけましたら、カジュアルにお話するだけでも大丈夫ですのでお気軽にご連絡ください!

中途求人ページ: https://www.amd-lab.com/recruit-list/mid-career

カジュアル面談がエントリーフォームからできるようになりました。 採用種別を「カジュアル面談(オンライン)」にして必要事項を記載の上送信してください!

エントリーフォーム: https://www.amd-lab.com/entry


参考文献


  1. CADmium - GitHub - Rust製のオープンソースブラウザCAD。
  2. Chili3D - OpenCascadeをWebAssembly化したブラウザ3D CAD。
  3. Graphite - GitHub - Rust+WASMによる2D画像編集ツール。

国土地理院の地図タイルをReact Three Fiberで3D空間に表示してみた

1. はじめに

AMDlabインターン生の内田です。 今回は国土地理院の地図タイルとそれをReact Three Fiber(R3F)の3D空間に表示する方法について紹介します。

2. 国土地理院とは

国土地理院公式サイトによると、 「国土地理院は、我が国唯一の国家地図作成機関であり、国土交通省の特別の機関です」 と宣言されています。 国土地理院は、測量・地図の作成・地理空間情報の整備を行い、さらに日本全国の地理情報を公開しています。

3. 地理院タイルについて

地理院タイルとは、国土地理院が配信する標準地図・航空写真などのタイル状の地図データです。 ウェブサイトやアプリで利用する場合、出典を明示すれば申請不要で利用できます。*1

1枚のタイルは256×256ピクセルで、ズームレベル(z)に応じて地図全体が細かく分割されています。
各タイルには (x, y, z) のインデックスが割り当てられており、URLは次のように指定できます:https://cyberjapandata.gsi.go.jp/xyz/{t}/{z}/{x}/{y}.{ext}

  • t:データID
  • z:ズームレベル
  • x:タイル座標のX値
  • y:タイル座標のY値
  • ext:拡張子

例えば、標準地図でズームレベル16の東京駅だったら、次のようなURLになります:https://cyberjapandata.gsi.go.jp/xyz/std/16/58211/25806.png

地球上の緯度経度をメルカトル投影を使ってタイル座標に変換しています。*2

4. 東京駅の地図をReact Three Fiberで表示してみる

主な使用ライブラリは、React + Three.js + @react-three/fiber です。

結果

このような表示になります。

実装全体の流れ

  1. 東京駅の座標をもとに対応する地図タイルを計算
  2. 対応する地図タイルの周囲8タイルを取得(合計3x3枚)
  3. 取得した画像を合成し、1枚のテクスチャとして作成
  4. メッシュに貼り付けて表示

地理院タイルについての実装

import { useEffect, useMemo, useState } from "react";
import * as THREE from "three";

export type Layer = "std" | "seamlessphoto";

/**
 * lonLatToTile:
 * 緯度経度(lon, lat)とズームレベル z からタイル座標 (x, y) を計算する。
 */
function lonLatToTile(lon: number, lat: number, z: number) {
  const latRad = (lat * Math.PI) / 180;
  const n = 2 ** z;
  const xFloat = ((lon + 180) / 360) * n;
  const yFloat =
    ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) *
    n;

  return {
    z,
    x: Math.floor(xFloat),
    y: Math.floor(yFloat),
  };
}

/**
 * urlFor:
 * レイヤー(標準地図 or 航空写真)に応じたタイル画像URLを生成する。
 */
function urlFor(layer: Layer, z: number, x: number, y: number) {
  return layer === "seamlessphoto"
    ? `https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/${z}/${x}/${y}.jpg`
    : `https://cyberjapandata.gsi.go.jp/xyz/std/${z}/${x}/${y}.png`;
}

/**
 * loadImage:
 * 指定URLから画像を読み込み、HTMLImageElementをPromiseで返す。
 */
function loadImage(url: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => resolve(img);
    img.onerror = () => reject(new Error(`画像の取得に失敗しました: ${url}`));
    img.src = url;
  });
}

type Props = {
  /**
   * lat:
   * 表示したい地点の緯度。例: 35.681236。
   */
  lat: number;
  /**
   * lon:
   * 表示したい地点の経度。例: 139.767125。
   */
  lon: number;
  /**
   * z:
   * ズームレベル。既定値は16。
   */
  z?: number;
  /**
   * layer:
   * 地図の種類。"std"(標準地図)または"seamlessphoto"(航空写真)。
   */
  layer?: Layer;
  /**
   * tileSize:
   * 1タイルあたりのピクセル数。既定値は256px。
   */
  tileSize?: number;
  /**
   * range:
   * 取得するタイル範囲。1なら中心+8枚で3×3表示。
   */
  range?: number;
  /**
   * worldTile:
   * 3D空間上で1タイルを何単位で描画するかのスケール係数。
   */
  worldTile?: number;
  /**
   * y:
   * 地図メッシュを地面からどれくらい浮かせるか。重なり回避用。
   */
  y?: number;
};

/**
 * ChiriinMapMesh:
 * 指定座標を中心に range のタイルを取得・合成し、CanvasTextureを貼った平面メッシュとして表示する。
 */
export function ChiriinMapMesh({
  lat,
  lon,
  z = 16,
  layer = "std",
  tileSize = 256,
  range = 1,
  worldTile = 10,
  y = 0.001,
}: Props) {
  const [texture, setTexture] = useState<THREE.Texture | null>(null);

  useEffect(() => {
    let aborted = false;
    (async () => {
      try {
        const { x: cx, y: cy } = lonLatToTile(lon, lat, z);
        const tiles: { x: number; y: number }[] = [];
        for (let dy = -range; dy <= range; dy++) {
          for (let dx = -range; dx <= range; dx++) {
            tiles.push({ x: cx + dx, y: cy + dy });
          }
        }

        const urls = tiles.map((t) => urlFor(layer, z, t.x, t.y));
        const images = await Promise.all(urls.map(loadImage));
        if (aborted) return;

        const count = range * 2 + 1;
        const canvas = document.createElement("canvas");
        canvas.width = count * tileSize;
        canvas.height = count * tileSize;
        const ctx = canvas.getContext("2d");
        if (!ctx) throw new Error("キャンバスのコンテキスト取得に失敗しました");

        images.forEach((img, i) => {
          const col = i % count;
          const row = Math.floor(i / count);
          ctx.drawImage(img, col * tileSize, row * tileSize, tileSize, tileSize);
        });

        const tex = new THREE.CanvasTexture(canvas);
        tex.anisotropy = 16;
        tex.colorSpace = THREE.SRGBColorSpace;
        if (!aborted) setTexture(tex);
      } catch (error) {
        console.error(error);
        setTexture(null);
      }
    })();

    return () => {
      aborted = true;
    };
  }, [lat, lon, z, layer, tileSize, range]);

  const planeSize = useMemo(
    () => (range * 2 + 1) * worldTile,
    [range, worldTile]
  );

  if (!texture) return null;

  return (
    <mesh position={[0, y, 0]} rotation={[-Math.PI / 2, 0, 0]} renderOrder={1}>
      <planeGeometry args={[planeSize, planeSize]} />
      <meshBasicMaterial map={texture} polygonOffset polygonOffsetFactor={-1} />
    </mesh>
  );
}

5. まとめ

今回は、国土地理院が提供する地理院タイルをReact Three Fiberで3D空間に表示してみました。

タイル座標 (x, y, z) の計算など一部の処理は少しややこしいものの、
上記のサンプルのように「地図を取得して3D空間に貼り付ける」だけなら比較的シンプルに実装できます。

一方で、ナビアプリのように、ズームに応じてタイルを切り替える、カメラ移動に合わせてタイルを動的に取得する、 大量の地図データをリアルタイムに扱うといった高度な機能が必要な場合は、 自分で仕組みを構築するよりMapboxなどの地図専用サービスを利用する方が早く実装ができると思います(本記事では紹介しませんが、便利です)。

ただ、このようなサービスは内部処理がブラックボックス化されやすく、
距離計算や独自オブジェクトとの連携など、地図の上で自分の仕組みを作り込みたい場合は扱いにくいこともあります。

その点、React Three Fiberを使えば地図そのものを3Dオブジェクトとして扱えるため、
「地図上に独自のオブジェクトを置きたい」「マップ編集ツールのようなUIを作りたい」
といった用途では特に相性が良いと思います。

国土地理院の地図を素材として扱う方法の参考になれば幸いです。
国土地理院の地図データについての興味がある方は公式の地理院地図を触ってみるといいかもしれません。👉 https://maps.gsi.go.jp/

出典

出典:国土地理院地理院タイル」(https://maps.gsi.go.jp/development/ichiran.html)

※本記事の表示例では、地理院タイル画像を合成・加工して作成しています。

建築ドメインのキャッチアップについて

こんにちは。DDDDbox事業部で日々開発をしている鈴木です。

弊社では自社サービスとして建築設計SaaSの DDDDbox を開発しており、DXソリューション事業でも、建築ドメインを中心にしたプロジェクトを推進しています。

ddddbox.app

www.amd-lab.com

私はDDDDboxで、Webや3D(three.js / react-three-fiber)まわりの開発を中心に行なっていることが多く、前回まではその内容について記事を執筆いたしました。

今回は少し毛色を変えて、建築ドメインのキャッチアップの方法について語りたいと思います。

この記事は「建築バックグラウンドがないエンジニアが、建築ドメインの知識をどうキャッチアップするか」というテーマについて、私の実体験ベースで効果的な方法をまとめたものです。

だれのための記事?

  • 建築系のドメイン知識を身につけたいソフトウェアエンジニアの方
  • 建築に限らずドメイン知識の効果的なキャッチアップ方法が知りたい方
  • 弊社AMDlabに興味はあるが、建築のドメイン知識がないため、選考に進むか悩んでいる方

ステップ1:建築の法規を学ぶ

建築では、建築基準法都市計画法・消防法など様々な建築法規が、建築物の制約条件の体系となっています。

これらの法規による制約はソフトウェアエンジニアリングのベストプラクティスなどとは異なり、絶対に守らなければならない強い制約となります。

これらの建築法規を学ぶことは、建築ドメインの考え方のベースになってきます。

構造化して理解できる

建築をゼロから理解しようとすると、「平面図」「日影規制」「構造計算」「確認申請」「BIM」などの無数の用語や概念があります。 一つ一つの用語の意味はその度に調べれば理解できますが、ある用語の説明にまた別の専門用語が登場してきたり、用語同士の関連をつかむことが難しいです。

これは、まるでプログラミング初心者にオニオンアーキテクチャのアプリケーションをいきなりコードリーディングさせるようなものです。 if文やfor文、プログラミング言語の種類、Webサーバーとは何なのか、そもそもVS Codeとは、などいろんなレイヤーで疑問が浮かぶはずです。

しかし、法規をベースに学ぶと、それぞれの概念がなぜ存在するのか、どう関連しているのかが構造化して理解できます。 その法規に準拠していることを示すために、様々な数値や計算式や図面が必要であることがわかります。 いわば、全体のドメインモデルが頭の中にできる感じです。

例を挙げてみると、「第1種低層住居専用地域」「商業地域」「準工業地域」などの用途地域は、ドメイン知識のない状態だとただのEnum値にしか見えませんよね。

しかし、法規をベースに学ぶと、これらの用途地域はそれぞれ異なるルールを持っていることがわかります。建蔽率容積率の上限、建築制限のルールである斜線制限(道路斜線、隣地斜線、北側斜線)のルール、さらにこれらにはルールの緩和や例外があることなども理解できます。

つまり、「用途地域というのは単なる分類ではなく、建築のビジネスロジックを決定する重要なパラメータである」ということがわかります。

またそもそも用途地域は日本全国すべての地域に割り当てられているのではなく、原則として都市計画区域内のさらに市街化区域にのみ割り当てられることなど、上位の概念も押さえることができます。

誰とでも対等に議論できるツールになる

もう一つの大きなメリットとして、建築法規をベースにすることで、顧客を含むドメインエキスパートと対等に質問・議論することができます。

例えばソフトウェアの世界で、アーキテクチャ設計や複雑なドメインロジックについての会議をするときには、幅広いソフトウェアエンジニアリングの知識が必要になってしまい、新人がすぐに議論に参加するのは難しいと考えます。 意見の根拠をなかなか出しづらいためです。もちろん有名書籍などを根拠に議論することはできますが、書籍に書いてある内容も銀の弾丸ではないので、結局ケースバイケースということが多いためです。

しかし、ソフトウェアの世界でもセキュリティ関連や著作権関連、個人情報保護法GDPRなどの分野であれば、法規をベースに新人とベテランが対等に議論することができますよね。 ケースバイケースなどではなく該当する法規には準拠しなくてはならないためです。

建築の場合は、法規の内容が広範にわたるため、それが顕著です。 プロジェクトに参画してまもない時期で、ほとんどドメイン知識がない状態でも、ある分野における法規を頭に入れておけば、プロダクトオーナーから提案された仕様について、法規をベースにドメインエキスパートと議論することができます。 「この仕様は(建築基準法第52条の)容積率の規定に抵触しませんか?」「ここに建築する場合は特定行政庁の許可が必要ですよね?」といった質問ができるわけです。

建築士の資格を持っていなくても、法規は必ず守らなくてはいけないものなので、対等に議論ができます。

建築法規のキャッチアップ方法

私の場合は、まず法規の全体像をつかみたかったので、書店に行って1冊の建築法規本を選び、一通りざっと読んだ後はプロダクト開発に役立ちそうなところや関連する箇所を適宜読み直しています。 または全てを読むのは時間がかかるので、必要なところだけを読むというのも効果的だと思います。

具体的には、建築基準法の総則、用途地域建蔽率容積率、斜線制限、日影規制あたりを重点的に読みました。

また、書籍に法律の条項が記載してあれば、原文を確認してみるとよいです。 法律は改正されるので、書籍やWeb記事で紹介されている内容が古い場合もありますし、原文を読むことで書籍などの2次情報への理解度が高まります。 e-Gov法令検索(https://elaws.e-gov.go.jp/)で建築基準法建築基準法施行令を検索すれば、最新の条文を確認できます。

このあたり、ブログ情報ではなく、公式ドキュメントやソースコードを読め!っていう感覚に近いですね。

なお、書籍であれば個人的には物理本をお勧めします。


ステップ2:成果物のフォーマットや位置付けを把握する

建築業務での成果物としては、成果図書と呼ばれる各種説明書や計画図、各種図面などがありますが、最終的に提出するフォーマットがおおよそ決まっています。 エンジニア視点でいうと、出力されるJSONYAMLの仕様がおおよそ決まっている状態です。

この成果物をつくるために様々な業務が必要になるので、このフォーマットを理解することで、「どんなデータが必要か」「どんな計算処理が必要か」「どんな表現が必要か」が自然と見えてきます。

メリット1:法規との紐付きが明確になる

建築の成果物に載せる情報には法規との紐付きがあるものが多くあります。

例えば、配置図に敷地境界線や道路境界線を記載するのは、建蔽率容積率、斜線制限などの法規チェックに必要なためと考えられます。

日影図が必要なのは、建築基準法日影規制をクリアしているか確認するためです。

成果物のフォーマットを見ることで、法規との紐付きが明確になり「この法規に準拠してることを示すためにこの資料が必要」という関連が理解できます。

メリット2:ユーザーが必要としているものを把握できる

建築設計の成果物は、誰が・いつ・どの工程で・何を伝えるために作るかという業務の意図と密接に結びついています。 ソフトウェアがこの文脈を無視すると、ユーザからは「使えないツール」という印象になってしまうでしょう。

成果物のフォーマットや位置付けを押さえておくことで、ユーザーにとって業務上重要な情報が何なのかを把握することができます。

例えば、配置図では敷地境界線に道路境界線や隣地境界線といった境界の種類名を書き込んだり、真北の向きを示す記号、道路斜線の後退距離や、敷地と隣地との高低差などを書き込みます。 部材種類や部屋割りなどはここには記載しないことが多いでしょう。

仮に配置図を丸ごと出力する機能をつくるとしたら、これらの項目を設定するUIやその項目自体のデータフローを考える必要があります。ただし部材種類や部屋割りの設定をする機能はこの段階では利用できなくてもよいということが判断できます。

つまり成果物から必要になるであろう機能やUIを逆算することができます。

また、機能仕様に迷っている場合でも、機能から離れて成果物のフォーマットを見ることで、必要な情報を再整理することもできますね。

フォーマットの差異について

自治体や会社によって成果物のフォーマットに差があることがあります。 記載する項目は共通でも、レイアウトや表現方法が異なることがありますが、どう表現しているかが変わるだけで、示したい内容は同じであることがほとんどです。 ソフトウェアで言えば、背後のロジックは同じだけど見せるUIが違う、というような感じです。


まとめ

以上、建築ドメインのキャッチアップについてまとめてみました。

私自身日々建築ドメインをキャッチアップしています。この記事が少しでもお役に立てれば幸いです。

また、弊社では一級建築士や元指定検査機関の方が在籍しており、その方たちにいつでも直接質問することができるため、ドメイン知識のキャッチアップがしやすい環境があります。実際に質問をしてみると、「法規では曖昧な書き方しかされていないので、実例ではこうすることが多い」などの情報も得ることができます。

DDDDboxでは、このような建築×ソフトウェアの開発に一緒に取り組むエンジニアを募集しています。カジュアル面談からでも大歓迎です!

中途求人ページ: https://www.amd-lab.com/recruit-list/mid-career

エントリーフォーム: https://www.amd-lab.com/entry


スクラムマスター研修に参加して感じたスクラム導入への課題と今後の展望

研修概要

こんにちは!DDDDbox開発チームでマネジメントを担当しています、柴田と申します!

先日、下記のRegistered Scrum Master® Trainingを受講してきました。こちら、2日間のオフライン研修で、研修後の認定試験に合格すればスクラムマスターとして認定されるプログラムになります。

scruminc.jp

無事合格することができました!

認定書

この記事では、研修で得た学びや気づきを整理し、所属チームで直面している課題と今後の展望についてまとめたいと思います。

私が所属しているチームでは、すでに「スクラム開発」に取り組んでいますが、正直なところ、これまではスクラムガイドや経験者の話を参考にしながら形だけ取り入れていたため、「そもそもスクラムとは何か」「なぜスクラムをやっているのか」が曖昧なまま進めていたのが実情でした。

そこで今回、本物のスクラムを理解するために研修に参加しました。

スクラムとは?(簡単に)

スクラムに関してはネット上でいくらでも情報があるので、ここでは簡単にスクラムの概要を記載します。

スクラムには「3・5・3」と呼ばれる基本の枠組みがあります。

3つの役割(Roles)

  1. スクラムマスター:チームがスクラムを正しく理解・実践できるよう支援する
  2. プロダクトオーナー(PO):プロダクトバックログを管理し、価値の最大化を担う
  3. 開発チーム:自律的にスプリントゴールを達成するために動く

5つのイベント(Events)

  1. スプリント:1〜4週間の開発サイクル。計画から実行・振り返りまでを含む箱のような期間。
  2. スプリントプランニング:スプリントで何を達成するかを決め、スプリントバックログを作成する。
  3. デイリースクラム:開発チームが毎日15分以内で進捗と課題を共有する、短い打ち合わせ。
  4. スプリントレビュー:スプリントの成果(インクリメント)をステークホルダーと確認し、フィードバックを得る。
  5. スプリントレトロスペクティブ:チーム自身のプロセスや関わり方を振り返り、次のスプリント改善につなげる。

3つの成果物(Artifacts)

  1. プロダクトバックログ:作るべきものの全体リスト
  2. スプリントバックログ:スプリント内でやることのリスト
  3. インクリメント:スプリントの成果物(動くソフトウェアなど)

この「3・5・3」がスクラムの骨格です。

もっと簡単にいうと

スクラム1〜4週間の短いサイクル(スプリント) を区切りに、

  • 計画
  • 実行
  • 振り返り

スクラムチームで繰り返していき、インクリメント(スプリントの成果物)を生み出していくフレームワークです。

私たちのチームでは、2週間のスプリントを設定しています。

(詳細はスクラムガイドを参照 →Scrum Guide 日本語版

チームの現状と課題

私が所属しているチームの現状を整理すると、一部ですが、以下のような課題があります。

  • スプリントプランニングとバックログリファインメントを同時に実施しているため、見積もりが不十分でゴール設定が曖昧なまま終わってしまう
  • スプリントゴールが明確でなく、完了の定義も曖昧なまま進行してしまう
  • ベロシティを活用できておらず、適切なストーリーポイントを設定できていない
  • そもそもスクラムの理解や共通認識がそれぞれのメンバーで異なるため、なんとなくスクラムをやっている

まずはチーム全体で「スクラムの基本的な枠組み」を正しく共有し、本来のスクラムを理解することが出発点だと感じています。

スプリントプランニングとバックログリファインメントの統合

現在、スクラムの5つのイベントのうち、私たちのチームでは スプリントプランニング・デイリースクラム・スプリントレトロスペクティブ を実施しています。

研修に参加するまで、スプリントプランニングの時間にバックログリファインメントもまとめて行っていたため、制限時間内にすべてのバックログを見積もりきれず、スプリントゴールが曖昧なまま終わってしまうことが多々ありました。

研修で講師の方にこの状況を相談したところ、「リファインメント専用に時間確保をせず、プランニングと一緒に扱うチームは珍しくない」とのことでした。スクラムガイドに沿った理想形ではないものの、実務上致し方ないケースの様です。。。

このような状況だったので、8月からまず変えられるものから変えていこうと社内で動きがあり、現在はスプリントプランニングとバックログリファインメントを別々に実施して、効果を測定中です!

スプリントレビューの有無

現在、私が所属しているチームではスプリントレビューは開催していません。

似たような形で過去に開催していたことはあるのですが、現状のチームの開発サイクルでは毎回必ず目に見える形のインクリメントが生まれるとは限らないため、レビューを実施しても「見せるものがない」という状況になることがありました。

本来であればスプリントごとに開催するのが理想ですが、何も生まれないイベントを頻繁に開催しても意義が感じられないため、現在は月に1回オフラインでDDDDboxチームの責任者が集まる機会があるので、そこで共有するようにして、フィードバックをいただいています。

レトロスペクティブ

レトロスペクティブについては、KPTを実施していますが、Problemとして挙がった課題が次のスプリント改善に十分つながっていないのが現状です。

本来であれば、スプリントレビューで得たステークホルダーからのフィードバックをレトロスペクティブでも共有し、それを次のスプリント計画に反映することで、改善のサイクルを回していくのが理想だと思いますが、課題が挙がっても他のタスクを優先してしまい、改善に着手できないまま流れてしまうことが起きているので、改善タスクを次のスプリントで必ず1つは入れるなどルールを決める必要があると感じています。

チーム・ステークホルダーの共通理解

スクラムのイベント全体で見ると、導入当初は「お手本通りにすべてを正確にやろう」とすると、どうしてもコミュニケーションコストが大きく感じられます。スクラムに慣れてスムーズに進められるようになるまでには、時間管理やチーム・ステークホルダーの理解が必要だと感じました。

また、メンバーやステークホルダーの中には複数プロジェクトを兼務していることもあり、「できるだけ会議を減らしたい」という意見が出ても不思議ではないのかとも感じています。

だからこそ、スクラムイベントは必ず時間を区切り、時間内に結論を出すことが理想 なのだと思いますが、中々うまく結論へ導けない難しさも日々感じています。。。

その進行を適切にコントロールし、イベントを効果的に機能させるのは、まさに スクラムマスターの手腕の見せ所 ですが、中々思うようにいかず、私自身もスクラムマスターとしてまだまだ未熟だと痛感しています。このあたりは今後、スクラムを進めていく上でチームで慣れてくると、阿吽の呼吸でもっとスムーズにいくのかとも感じています。

突発タスクへの対応

チーム・ステークホルダーの理解が必要だと感じる点をもう一つ挙げると、突発的に発生するタスク についても課題があります。

スプリントプランニングで決めていないタスクは、バッファ用のストーリーポイントを超えると基本的に次のスプリントに回されてしまいます。その結果、人によっては「すぐに対応してほしいのにできない」というもやもやが溜まるケースがあるかと思います。

こちらに関していうと、スクラムのルールやあるべき姿の共有や共通理解が不十分だと起こりうるケースなので、やはりチーム全体でスクラムの考え方を正しく理解し、共通認識を持つことが先決だと感じています。

組織体制とスクラムの成熟度

バックログ準備の負担

スクラムは明確な目的(バックログ)をもとにサイクルを回すことで成立するため、バックログの準備が出発点になります。

そのため、プロダクトオーナーは次のスプリントに備えて、事前に整理されたバックログを用意しておく必要があります。研修では「常にスプリント2〜3週分のストックを確保しておくのが理想」と教わりました。

しかし、バックログの準備は想像以上に負担が大きい作業です。特に、組織の事情でチームを兼任している場合には、十分な時間を確保するのが難しくなることもあります。つまり、スクラムを効果的に回すためには、組織体制が整っていることが前提 だと強く感じます。組織に余裕がないまま導入すれば、バックログが枯渇してスプリントが回らなくなるリスクもあります。

個人の感想ですが、スクラムの導入は 組織とチーム双方に一定の余裕があって初めて効果を発揮するものではないかと感じています。

タスクの平均化と技術力向上

スクラムを迎え入れる組織体制の課題をもう一つ挙げると、タスクの平均化と技術力向上 です。

スクラムでは、担当者が「自分の得意なタスク」だけを優先するのではなく、チーム全体でタスクを分散し、誰もが幅広く対応できるようにすることが理想とされています。その進め方をコントロールするのはスクラムマスターの責任であり、同時にメンバー全員の技術力向上が不可欠です。

(もっとも、技術向上自体はスクラムに限らず、どの開発手法でも求められることですが。)

開発にスピードが求められてくると、どうしてもメンバーそれぞれが得意なタスクに割り振られることは現実よく出てくることかと思いますが、スクラムを導入するには、そんな場面でも今後のスクラムチームとしての成長を組織が迎え入れる余裕が必要になってくるのかと思います。

まとめ

課題をつらつらと書いてきましたが、まだまだスクラム導入段階でチームとしては伸びしろしかないと感じています。ただ、今回の研修を通じて、スクラムは単なる開発手法ではなく、組織やチームの成熟を前提としたフレームワークであるとも実感しています。

  • 基本の「3・5・3」を理解し、形骸化させずにできる限り、本来のスクラムに沿って実践すること
  • チームとステークホルダー全員がスクラムの意図を正しく理解し、共有すること
  • 組織体制に余裕があり、学習と改善に時間を割ける環境を整えること

これらが揃って初めて、スクラムは効果を発揮するのだと思います。

私自身、まだスクラムマスターとして未熟ですが、まずはチームにスクラムの本質を伝え、できるところから改善を積み重ねていきたいと考えています。

今後も試行錯誤を続けながら、チームがより自律的に成長できるスクラムを実現していきたいです。

今はスクラムの導入段階なので、これからスクラムを導入してチームがどう変わったかを今後の記事で共有していきたいと思います。

3Dプログラミングにおける座標変換の基礎概念

こんにちは、DDDDbox事業部で日々3D開発を行っている鈴木です。 開発中によく遭遇する問題として

  • オブジェクトの表示が想定位置とずれている
  • カメラの位置を変えたら想定と違う見え方になった

といったものがあります。

原因を調べていくと、これらの問題の多くは座標変換の理解不足が主な原因なことが多くありました。

座標変換は3Dプログラミングの基礎中の基礎なのですが、ここの理解があやふやだと、AIに言われるがままの修正に頼ってしまいがちになると感じています。

今回は、3Dオブジェクトが最終的に画面に表示されるまでの「座標系と座標変換」の流れを、実際のコード例とともに整理してみました。

座標系の種類

3D空間における座標の表現方法は以下の通りです。

  • ローカル座標 - キューブ自身から見た位置
    • 3D座標
  • ワールド座標 - シーン全体から見た位置
    • 3D座標
  • ビュー座標 - カメラから見た位置
    • 3D座標
  • クリップ座標 - カメラから見える範囲内にあるかどうかを判定するための座標
    • 4D座標
  • 正規化デバイス座標(NDC) - クリップ座標を「-1〜1」の範囲に正規化した座標系
    • 2D座標
  • スクリーン座標 - 最終的な画面上の位置
    • 2D座標

これらはすべて同じオブジェクトの「位置」を表していますが、座標系や次元が異なります。これらの座標系の間の変換を座標変換といい、3Dプログラミングでは頻繁に利用されます。

ローカル座標系 → ワールド座標系(モデル変換)

ローカル座標とワールド座標

ワールド座標系とは

ワールド座標系は、シーン全体の基準となる座標系です。例えば

  • ワールド座標の原点(0,0,0)を中心として
  • ワールド座標(10,0,0)にキューブ1を配置
  • ワールド座標(0,10,0)にキューブ2を配置

といった具合に、シーン内のすべてのオブジェクトの位置関係を統一的に管理します。

ローカル座標系(モデル座標系)とは

ローカル座標系は、オブジェクト(モデル)自身を基準とした座標系です。例えば

  • あるオブジェクトがワールド座標(5,0,0)、ローカル座標(2,0,0)の位置にある場合
  • このオブジェクトは実際にはワールド座標(7,0,0)の位置に表示される

といった具合に、ローカル座標はオブジェクト自身の原点を基準とした座標系です。

また、オブジェクト同士で座標の比較を行いたい場合、ローカル座標同士では行うことができません。この時は、ローカル座標をワールド座標に変換してから比較計算を行う必要があります。

さらに、ローカル座標からワールド座標への変換をモデル変換と呼びます。 数学的には行列(Matrix4)を掛けることで実現され、平行移動、回転、拡大縮小を行うことができます。

コード例

import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';

const LocalToWorldExample = () => {
  const ref = useRef<THREE.Mesh>(null);
  
  useFrame(() => {
    if (!ref.current) return;
    
    const localPos = ref.current.position;
    // meshの親がネストしていても、正確にワールド座標を取得
    const worldPos = ref.current.getWorldPosition(new THREE.Vector3());
    
    console.log(
      "local:",
      localPos.x, localPos.y, localPos.z,  // (2, 0, 0)
      "world:",
      worldPos.x, worldPos.y, worldPos.z   // (8, 0, 0)
    );
  });
  
  return (
    <group position={[5, 0, 0]}>
      <group position={[1, 0, 0]}>
        <mesh ref={ref} position={[2, 0, 0]}>
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial color="orange" />
        </mesh>
      </group>
    </group>
  );
};

この例では、meshの最終的な位置は 5 + 1 + 2 = 8となります。

three.jsでは getWorldPositionを使うことで、そのオブジェクトの親の構造に関係なく正確なワールド座標を取得できます。

ワールド座標系 → ビュー座標系(ビュー変換)

ビュー座標のイメージ

次は、ワールド座標をビュー座標へ変換する処理をみていきます。

ビュー座標系(カメラ座標系)とは

ビュー座標系は、カメラ位置を原点にした座標系です。そのためカメラ座標系とも呼ばれます。

ワールド座標系に存在するオブジェクトの座標を「カメラから見た座標」に変換(ビュー変換)するとビュー座標系になります。

ビュー座標系では、カメラの向き(lookAt)によって、軸の向きが決まります:

  • X軸 - 右手座標系でZ×Yから計算される「右」方向
  • Y軸 - カメラの「上」方向(通常はworld up vectorから計算)
  • Z軸 - カメラからlookAtのターゲットへの方向(視線方向、Z軸負方向)

ビュー変換は、数学的にはビュー行列を掛けることで実現され、この行列は、カメラの位置と向きに基づいて計算されます。

また、多くのグラフィックスライブラリでは lookAt() という名前の関数からビュー行列を作成することができます(three.jsは該当しない)。

コード例

import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import { PerspectiveCamera } from '@react-three/drei';
import * as THREE from 'three';

function ViewExample() {
  const boxRef = useRef();
  const cameraRef = useRef();

  useFrame(() => {
    if (!boxRef.current || !cameraRef.current) return;

    // ワールド座標(boxの位置)
    const worldPosition = boxRef.current.position;
    
    // カメラのビュー行列(ワールド座標からビュー座標への変換行列)
    const viewMatrix = cameraRef.current.matrixWorldInverse;
    
    // ワールド座標をビュー座標に変換
    const viewPosition = worldPosition.clone().applyMatrix4(viewMatrix);
    
    // 5,3,5
    console.log(
      "ワールド座標:",
      `(${worldPosition.x.toFixed(2)}, ${worldPosition.y.toFixed(2)}, ${worldPosition.z.toFixed(2)})`
    );
    // 0, -1.63, -1.15
    console.log(
      "ビュー座標:",
      `(${viewPosition.x.toFixed(2)}, ${viewPosition.y.toFixed(2)}, ${viewPosition.z.toFixed(2)})`
    );
    
    cameraRef.current.lookAt(0, 0, 0);
  });

  return (
    <>
      <PerspectiveCamera
        ref={cameraRef}
        position={[5, 5, 5]}
        fov={75}
        makeDefault
      />
      
      <mesh ref={boxRef} position={[5, 3, 5]}>
        <boxGeometry args={[1, 1, 1]} />
        <meshBasicMaterial color="orange" />
      </mesh>
    </>
  );
}

位置関係

実行例の解説:

  • カメラ位置:(5, 5, 5)から原点を見ている
  • ボックス位置:(5, 3, 5) はカメラの真下2単位離れた位置

ワールド座標では:ボックスはカメラの「真下」に位置(同じX, Z座標で、Y座標だけ2単位下)

しかし、ビュー座標系では:カメラが斜め下を向いているため、この「真下」の関係が複数の軸成分に分散されます

  • Y成分:-1.63(カメラから見た「下」方向)
  • Z成分:-1.15(カメラから見た「手前」方向)
  • X成分:-0.00(カメラから見た「左右」方向はほぼゼロ)

このように、カメラの位置だけでなく、カメラの向き(lookAt)も考慮されることがわかります。

ビュー座標系 → クリップ座標系(プロジェクション変換)

※ ここでは透視投影の場合を例に説明します。

ビュー空間の状態では、カメラ視点になっただけで、カメラに映らないものも座標系に含まれています。 プロジェクション変換では、これらのカメラに映らない部分をクリップして「カメラが見ることができる範囲」を定義できるクリップ座標系に変換されます。

この「見ることができる範囲」となる6面体を視錐台(frustum)と呼びます。

視錐台

クリップ座標は「カメラが見ることができる範囲」を数学的に表現した4次元空間上の座標(x,y,z,w)です。多くの場合、w=-zの値として設定されます(カメラの向きが-z軸方向のため)。

このwの存在により、「この範囲内なら画面に映るよ!」という判定(クリッピング)をすることができるようになります。

クリップ座標への変換(プロジェクション変換)では、以下の処理を行います。

  • X,Y方向の正規化
  • Z方向の正規化(near/far対応)
  • カメラからの距離情報の保持(w)

整理すると

  • 視錐台(Frustum): 「カメラの物理的な見える範囲」を決める
  • プロジェクション変換 : その範囲を後にNDCに正規化するためのクリップ座標を得る

という関係になります。

コード例

three.jsのAPIを使うと、複雑な概念をそれほど意識することなく、ビュー座標からクリップ座標を取得できます。

useFrame(() => {
  if (meshRef.current && cameraRef.current) {
    // ワールド座標
    const worldPos = meshRef.current.position;

    // カメラのビュー行列を取得してビュー座標に変換
    const viewMatrix = cameraRef.current.matrixWorldInverse;
    const viewPos = worldPos.clone().applyMatrix4(viewMatrix);

    // プロジェクション行列を適用してクリップ座標に変換
    const projectionMatrix = cameraRef.current.projectionMatrix;
    const clipPos = viewPos.clone().applyMatrix4(projectionMatrix);

    console.log(
      "ワールド座標:", worldPos.x.toFixed(2), worldPos.y.toFixed(2), worldPos.z.toFixed(2),
      "ビュー座標:", viewPos.x.toFixed(2), viewPos.y.toFixed(2), viewPos.z.toFixed(2),
      "クリップ座標:", clipPos.x.toFixed(2), clipPos.y.toFixed(2), clipPos.z.toFixed(2)
    );
  }
});

クリップ座標系 → 正規化デバイス座標(NDC)

クリップ座標は(x, y, z, w)の形になります。そして「wで各要素を割る」(= perspective divide-透視除算と呼ぶ)ことでNDCに変換されます。

// 例: ビュー座標にprojectionMatrixを適用してクリップ座標の取得
const clipPos = viewPos.clone().applyMatrix4(camera.projectionMatrix);

// 透視除算をして正規化デバイス座標(NDC)に変換
const ndcPos = clipPos.clone().divideScalar(clipPos.w);

console.log("NDC:", ndcPos.x, ndcPos.y, ndcPos.z); // -1〜1の範囲が映る

w除算によって、遠くの物体ほど小さく見える「透視効果(perspective)」が生まれます。

// 近くの物体(カメラから距離2)
const nearObject = [2, 2, z, 2]; // w = 2(距離に比例)
const nearNDC = [2/2, 2/2] = [1, 1]; // 大きく表示

// 遠くの物体(カメラから距離10)  
const farObject = [2, 2, z, 10]; // w = 10(距離に比例)
const farNDC = [2/10, 2/10] = [0.2, 0.2]; // 小さく表示

同じサイズの物体でも、遠くにあるほどw値が大きくなり、w除算の結果として小さく表示され、遠近感を表現することができます。

📦 標準視体積(Canonical View Volume)

標準視体積

標準視体積は、表示される物体が収まる範囲を定義する標準的な立方体(WebGLの場合は-1〜1)範囲の座標です。

この立方体は、以下のような軸を持っています。

  • X軸:-1(左端)〜 1(右端)
  • Y軸:-1(下端)〜 1(上端)
  • Z軸:-1(遠い)〜 1(近い)

この範囲外の座標値をとる物体は画面に表示されません。

備考: Frustum Culling

視錐台(view frustum)の外にあるオブジェクトを描画対象から除外する処理をFrustum Cullingといいます。

// 簡単なFrustum Cullingの例
function shouldRender(object) {
  const ndcPos = transformToNDC(object.position);
  
  // NDC範囲外(-1~1範囲外)なら描画スキップ
  if (!isVisible(ndcPos)) {
    return false; // 描画しない → パフォーマンス向上!
  }
  
  return true; // 描画する
}

これにより、見えないオブジェクトの処理をスキップして、パフォーマンス向上が期待できます。

スクリーン座標への変換(ビューポート変換)

ビューポート変換は、「正規化デバイス座標(NDC)」で表された座標(-1〜1)を、実際のディスプレイ上のピクセル座標(スクリーン空間)に変換する処理です。

スクリーン座標はピクセル単位の座標系で、WebGLの場合は左下が原点(0, 0)になります。

変換の計算式

標準的な変換式は以下の通りです(左下が原点の場合):

x_screen = (x_ndc + 1) * width / 2
y_screen = (y_ndc + 1) * height / 2

例: 画面サイズが1920×1080ピクセル、NDC座標が(0.5, -0.5)の場合

  • x_screen = (0.5 + 1) × 1920 / 2 = 1.5 × 960 = 1440
  • y_screen = (-0.5 + 1) × 1080 / 2 = 0.5 × 540 = 270

よって、スクリーン座標は(1440, 270)になります。

コード例

  // クリップ座標を正規化デバイス座標(NDC)に変換
  const ndcPos = new THREE.Vector3(
    clipPos.x / clipPos.w,
    clipPos.y / clipPos.w,
    clipPos.z / clipPos.w
  );

  // クリップ座標をスクリーン座標に変換(ビューポート変換)
  const screenPos = new THREE.Vector3(
    ((ndcPos.x + 1) * window.innerWidth) / 2,
    ((ndcPos.y + 1) * window.innerHeight) / 2,
    ndcPos.z
  );

座標変換パイプライン全体のまとめ

ここまでの座標変換の流れをまとめました。

  graph TD
      subgraph "3D座標系"
          A[ローカル座標]
          B[ワールド座標]
          C[ビュー座標]
      end

      subgraph "同次座標系"
          D[クリップ座標]
      end

      subgraph "2D座標系"
          E[正規化デバイス座標 NDC<br/>-1〜1の範囲に正規化]
          F[スクリーン座標<br/>2D: 最終的な画面上の位置]
      end

      A -->|モデル変換| B
      B -->|ビュー変換| C
      C -->|プロジェクション変換| D
      D -->|透視除算| E
      E -->|ビューポート変換| F

      style A fill:#e8f4f8
      style B fill:#e8f4f8
      style C fill:#e8f4f8
      style D fill:#fff4e6
      style E fill:#f0f8e8
      style F fill:#f0f8e8

各変換には明確な役割があり、それぞれが連携して3Dオブジェクトを2D画面に表示しています。

まとめ

今回は、3Dオブジェクトが画面に表示されるまでの座標変換を辿ってみました。

これらの座標変換を理解することで、「なぜオブジェクトが表示されないのか」「カメラの設定がどう影響するのか」といった疑問が解決できるようになります。

今回の説明では数学的説明を大幅に簡略化しています。さらに深く学びたい方は、行列計算やクォータニオンといった数学的な基礎、シェーダープログラミングなども調べてみてください。

DDDDboxでは、このような3Dグラフィックスの開発に多く取り組んでおり、力を貸していただけるエンジニアさんを大募集しております! 少しでもご興味をお持ちいただけましたら、カジュアルにお話するだけでも大丈夫ですのでお気軽にご連絡ください!

中途求人ページ: https://www.amd-lab.com/recruit-list/mid-career

カジュアル面談がエントリーフォームからできるようになりました。 採用種別を「カジュアル面談(オンライン)」にして必要事項を記載の上送信してください!

エントリーフォーム: https://www.amd-lab.com/entry