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直線間の最短距離となります