DDDDbox Tech Blog

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

react-three-fiberで3D開発してたらZ-Fightingにハマった話

こんにちは、DDDDbox事業部で日々開発を行っている鈴木です。 最近、3Dビューでの画面のちらつき(Z-Fighting)について悩むことがあったため、今回遭遇した事象とその対策を整理しました。

Z-Fightingとは

最初に少し前提となる話となりますが、3D空間は通常x,y,zの3つの軸で表しますよね。

学校で習う軸の取り方では、y軸が奥行き、z軸が垂直方向のことが多いですが、three.jsをはじめとする3Dグラフィクスでは、z軸がカメラ奥行き、y軸が垂直方向を表します。 これは、2Dスクリーンではx, y軸がそれぞれ水平垂直方向を表していましたが、3Dスクリーンで奥行きを表すためにz軸が追加されたためと思われます。 なお、この軸の取り方はカメラを基準とした、「カメラ空間」や「ビュー空間」という言い方がされます。

僕が3Dグラフィクスに触れてすぐのときは、なぜ「Z-Fighting」という言葉が使われるのか不思議だったのですが、これはカメラ空間におけるz軸の競合を意味しているからなんですね!

2つの軸の取り方

さて、Z-Fightingとは、3Dシーンの中で重なり合った面の表示がチラついたりして見える現象のことです。

例えば、こんな感じで同一形状でない2つの平面が同じ位置に重なっていると...

// こんなコードを書くとZ-Fightingが発生しやすい
<group position={[0, 2, 0]}>
  <mesh position={[0, 0, 0]}>
    <planeGeometry args={[2, 1]} />
    <meshBasicMaterial color="white" />
  </mesh>

  <mesh position={[0, 0, 0]}>
    <planeGeometry args={[1.5, 0.5]} />
    <meshBasicMaterial color="orange" />
  </mesh>
</group>

THE Z-Fighting

このようなチラつきが発生します。この現象をZ-Fightingと呼びます。

なぜZ-Fightingが起きるのか?

3Dグラフィックスでは、深度値(深度バッファやZバッファという言い方もする)という仕組みを使って「カメラ空間の中でどのオブジェクトがカメラの手前にあるか」を判断する深度テストを実施しています。

各3Dオブジェクト毎にカメラからの距離(深度値)を記録しておき、より近いオブジェクトのピクセルだけを画面に描画することで、正しい奥行き関係を実現します。つまり、深度テストに勝ったものが描画されることになります。

Z-Fightingを引き起こす主なケースは以下の2つです。

  1. 深度値の精度不足: 深度値には有限の精度があるので、近接した深度値で同一になってしまった場合
  2. 深度値の重なり: 物理的にほぼ同じ位置に2つの面が存在しており、深度値が同じになる場合

誰がカメラに映るか戦っている様子

Z-Fightingに近しい現象とパターン

実際にさまざまなパターンで3Dオブジェクトを配置していると、チラつきは発生していないが手前に描画されてほしいオブジェクトが描画されない、といったことがあります。

全く同一形状のオブジェクトの重なり

先の説明で、2つの平面が同じ位置に重なっていると、Z-Fightingが発生するという例を挙げましたが、下記のように全くの同一形状の平面にすると、Z-Fightingが発生しなくなるように見えます。

<group>
  <mesh position={[0, 0, 0]}>
    <planeGeometry args={[2, 2]} />
    <meshBasicMaterial color="blue" transparent opacity={0.7} />
  </mesh>
  <mesh position={[0, 0, 0]}>
-     <planeGeometry args={[2, 2.1]} />
+     <planeGeometry args={[2, 2]} />
    <meshBasicMaterial color="red" transparent opacity={0.7} />
  </mesh>
</group>

この完全に重なった平面の場合は、後から追加されたものが前面に描画され、チラつきは起こりません。 形状が少しでも異なると、深度値も微妙に変わってしまい、Z-Fightingが発生するようです。

透明オブジェクトの重なり

たとえば、meshBasicMaterialを使って透明なオブジェクトを設定し、それらを重ねると、色が混ざってしまうことがあります。

<group position={[0, 0, 0]}>
  <mesh position={[0, 0, 0]}>
    <planeGeometry args={[2, 2]} />
    <meshBasicMaterial color="blue" transparent opacity={0.7} />
  </mesh>
  <mesh position={[0, 0, 0]}>
    <planeGeometry args={[2, 2]} />
    <meshBasicMaterial color="red" transparent opacity={0.7} />
  </mesh>
</group>

透明でない場合は、先の例のように完全に同一形状だと後から追加されたものが前面に描画され、チラつきませんが、透明な場合は、色が混ざるという結果になります。

透明であっても、形状がわずかでも違うと、チラつきが発生します。

<group position={[0, 0, 0]}>
  <mesh position={[0, 0, 0]}>
    <planeGeometry args={[2, 2]} />
    <meshBasicMaterial color="blue" transparent opacity={0.7} />
  </mesh>
  <mesh position={[0, 0, 0]}>
-     <planeGeometry args={[2, 2]} />
+     <planeGeometry args={[2, 2.1]} />
    <meshBasicMaterial color="red" transparent opacity={0.7} />
  </mesh>
</group>

near/farクリップ面の影響

カメラの設定で特に重要なのがnearfarの値です:

// ❌ 範囲が広すぎる
<Canvas camera={{ near: 0.001, far: 10000 }}>

// ✅ 必要最小限の範囲に絞る
<Canvas camera={{ near: 0.1, far: 100 }}>

nearfarの差が大きいほど、同じ深度値を広範囲に割り当てることになるため、深度精度が低下してZ-Fightingが起きやすくなります。

OrthographicCameraは遠近感がないため、例えば都市全体や長い鉄道模型のような巨大シーンを俯瞰する用途にも使われます。

しかしカメラをシーン全体が入る遠方に置き、さらに細部を見るためズーム(OrthographicCameraのleft/rightなどのプロパティで視体積を狭める)すると、カメラ位置自体は遠いままなのでNear/Far間隔も大きく、深度の有効桁が不足しがちです。

Z-Fighting対策

1. 物理的に離す

最もシンプルで確実な対策は、重なっているオブジェクト同士をわずかに離すことです:

// ✅ 物理的に離して配置
<mesh position={[0, 0, 0]}>
  <planeGeometry args={[2, 2]} />
  <meshBasicMaterial color="red" />
</mesh>
<mesh position={[0, 0, 0.01]}> {/* わずかに手前に */}
  <planeGeometry args={[2, 2]} />
  <meshBasicMaterial color="green" />
</mesh>

2. polygonOffsetを利用する

マテリアルのpolygonOffset機能を使って、描画時の深度計算時にオフセットを加えることができます:

// ✅ polygonOffsetで深度を調整
<mesh>
  <planeGeometry args={[2, 2]} />
  <meshBasicMaterial 
    color="green"
    polygonOffset={true}
    polygonOffsetFactor={-1}
  />
</mesh>

注意: polygonOffsetはポリゴン(面)には有効ですが、線(Line)やポイントには効果がありません。

3. カメラのnear/farを調整する

先の例でも触れましたが、シーンに必要な最小限の範囲にカメラの描画範囲を設定します:

// ✅ 適切なnear/far設定
<Canvas camera={{ near: 0.1, far: 100, position: [0, 0, 5] }}>
  {/* シーン内容 */}
</Canvas>

4. depthTest/depthWriteの活用

特定のオブジェクトについて深度テストを制御することで競合を回避できます:

// ✅ 常に手前に描画したいUI要素
<mesh>
  <planeGeometry args={[1, 1]} />
  <meshBasicMaterial 
    color="yellow"
    depthTest={false} // 深度テストを無効化
  />
</mesh>

// ✅ 深度値に影響を与えない
<mesh>
  <planeGeometry args={[1, 1]} />
  <meshBasicMaterial 
    color="cyan"
    depthWrite={false} // 深度書き込みを無効化
  />
</mesh>

depthWriteがわかりづらいですが、深度値に書き込むかどうかを指定するプロパティです。 「深度値に書き込まない」= 「深度テストに参加しない(描画されない)」と思いがちですが、そうではありません

たとえばdepthwriteをfalseに設定すると、そのオブジェクトは深度テストにはもちろん参加し、もし深度テストに勝って描画されることになった場合、そのオブジェクト以外のものも同時に描画されることになります(そのオブジェクト以外のものも存在した場合)。

5. renderOrderで透明オブジェクトの描画順序を制御

renderOrderプロパティを設定すると、描画順序を明示的に制御できます。

// ✅ 描画順序を明示的に指定
<mesh renderOrder={1}> {/* 後に描画される */}
  <planeGeometry args={[2, 2]} />
  <meshBasicMaterial color="green" transparent opacity={0.7}/>
</mesh>
<mesh renderOrder={0}> {/* 先に描画される */}
  <planeGeometry args={[2, 2]} />
  <meshBasicMaterial color="red" transparent opacity={0.7}/>
</mesh>

ただし、私の経験上、不透明オブジェクトにはrenderOrderを指定する必要はないように思います。

公式ドキュメントの renderOrder の箇所には、不透明オブジェクトと透明オブジェクトに対して別々に指定することができると書かれています。 これを読んで不透明オブジェクトにも適用してみたのですが、特に描画結果に影響を与えることはありませんでした。

これは、不透明オブジェクトの描画 -> 透明オブジェクトの描画 -> 深度テストという順番で処理が行われるため、 不透明オブジェクトを表示する場合、renderOrderを操作して、描画順を(不透明オブジェクトの中で)後に指定しても、結局そのあとの深度テストで勝ったもの1つが描画されるため、renderOrderは特に影響を与えないためと考えられます。 不透明オブジェクトを表示する == 勝者が一人 ということですね。

透明オブジェクトを表示する場合、深度テストで勝つものが1つではなく複数存在しうる(透明オブジェクトであればそれと重ねて別のオブジェクトを写すことができる)ので、renderOrderを指定する意味があります。

上記のコードは透明オブジェクトですが、この場合は renderOrder によって色のブレンドのされ方がかわります。

6. カスタムシェーダーの活用

three.jsでは onBeforeCompile という既存のMaterialのシェーダーを部分的に変更できる仕組みがあるので、これを使ってZ-Fightingを回避することもできます。

        <group position={[0, 2, 0]}>
          <mesh position={[0, 0, 0]}>
            <planeGeometry args={[2, 1]} />
            <meshBasicMaterial color="white" />
          </mesh>

          <mesh position={[0, 0, 0]}>
            <planeGeometry args={[1.5, 0.5]} />
            <meshBasicMaterial
              color="orange"
+              onBeforeCompile={(shader) => {
+                shader.vertexShader = shader.vertexShader.replace(
+                  "#include <project_vertex>",
+                  `
+                  #include <project_vertex>
+                  // Z値を少しだけ手前にずらし、このオブジェクトが常に前面に描画されるようにする
+                  gl_Position.z -= 0.001 * gl_Position.w;
+                  `
+                );
+              }}
            />
          </mesh>
        </group>

カスタムシェーダーの詳細については別の機会に記事にしたいと思います!

まとめ

今回は現在までに遭遇しているZ-Fighting事象とその対策をまとめてみました。

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

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

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

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

カメラ設定の基本ガイド:Three.jsとReact Three Fiber

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

弊社では3D表現の技術として、Three.jsおよびreact-three-fiberを使用することが多いのですが、最近カメラの設定周りでひっかかったところがあったため、特にOrthographic Cameraについてまとめ直してみました。

Perspective Camera

日本語だと透視投影カメラといいます。人間の目の見え方を模倣するように設計されているため、現実世界と同等の見せ方をしたい場合に使われます。 カメラの近くにあるオブジェクトは大きく見え、カメラより遠くにあるオブジェクトはより小さく見えます。

よくある3DのアクションゲームなどではこのPerspective Cameraを使い、現実世界の見え方に近づけて、臨場感を高めています。

なにかしらの3D表現するときは、基本的にこちらのPerspective Cameraを使うことが多いと思われます。

Perspective Cameraの見え方

Orthographic Camera

日本語だと並行投影カメラや直交投影カメラといいます。人間の目の見え方を模倣することを目的とはしていません。 このカメラは、遠近感を排除し、オブジェクトの描画サイズはカメラからの距離に依存しない、平行な線は距離によらず平行なまま(収束しない)という特徴があります。

Perspective Cameraでは、カメラの近くにあるオブジェクトは大きく見え、カメラより遠くにあるオブジェクトはより小さく見えます。

一方、Orthographic Cameraでは、カメラの近くにあるオブジェクトも、カメラの遠くにあるオブジェクトも、同じ大きさで表示を行うため、正確な相対的なサイズの判断が可能です。 Perspective Cameraと異なり、平行線はずっと平行なままで描画されます。

この不思議な性質は、建築設計の業務にとっては非常にメリットがあります。

建築モデルやCADモデルの投影図ではむしろ遠近感が不要なことが多く、建物の平面図・立面図・断面図などで、壁、窓、ドアなどの建築要素の寸法を正確に比較できるという点は非常に好都合です。

AutoCADやRevitなどの主要な建築設計ソフトウェアは、Perspective CameraのようなパースビューとOrthographic Cameraのような平行投影ビューを双方ともサポートしています。

Orthographic Cameraは建築設計だけでなく、視覚的な深さが必要ないパズルゲームなどでもよく使用されますね。 DDDDboxでもこちらのOrthographic Cameraをメインで使用しています。

Orthographic Cameraの見え方

比較

Perspective CameraとOrthographic Cameraの違いを表でまとめます。

特徴 Perspective Camera Orthographic Camera
投影方法 透視投影 平行投影
距離によるサイズ変化 あり(近くのものは大きく、遠くのものは小さく) なし(オブジェクトの大きさは距離に依存しない)
主な用途 現実世界に近い表現、3Dゲーム、映像制作 設計図、2Dゲーム、図面表示
視錐台 台形ピラミッド(錐台) 直方体

なお、視錐台というのは、カメラで写す有限空間のことです。 現実世界では地平線の向こうまで写すことができますが、カメラではその範囲を制限する必要があります。 その範囲形状を視錐台といい、視錐台の内部にあるオブジェクトがカメラで写されることになります。

Perspective Cameraの視錐台

Three.jsのOrthographic Camera

Three.jsのOrthographicCameraコンストラクタnew THREE.OrthographicCamera(left, right, top, bottom, near, far) というシグネチャです。 これらの値は、zoom値が1のときのカメラの視錐台の左端、右端、上端、下端、近端、遠端の値を指定します。

const camera = new THREE.OrthographicCamera( width / - 2, width / 2, height / 2, height / - 2, 0.1, 2000 );
scene.add( camera );

また、後述しますが、OrthographicCameraには、zoom プロパティがあり、この値を操作することでズームイン・ズームアウトを行うことができます。

Perspective Cameraの場合は遠近感があるため、カメラ位置を移動させることによってもズームイン・ズームアウトを行うことができますが、OrthographicCameraの場合はズームイン・ズームアウトを行うためには zoom プロパティを操作する必要があります。

ズームイン・ズームアウトを行うと、内部的にカメラの視錐台の左端、右端などの値が再計算されて、視錐台のサイズが変化します。

react-three-fiber/dreiのOrthographicCamera

reactの宣言的UIの手法でThree.jsを扱うには、react-three-fiberを使用します。 react-three-fiberにはさらに@react-three/dreiというサポートライブラリがあるため、宣言的な書き方のサポートが充実しています。

この中でdreiのOrthographicCameraは、Three.jsのOrthographicCameraをラップしたもので、より直感的に設定できるようになっていますが、Three.jsのOrthographicCameraとは挙動が異なるのでその点は注意が必要です(筆者はここで少しひっかかりました)。

Three.jsのOrthographicCameraは、カメラの視錐台の左端、右端、上端、下端、近端、遠端の値を明示的に指定するものでした。 dreiのOrthographicCameraは、デフォルトでは画面サイズに合わせてカメラの視錐台の左端、右端、上端、下端の値を自動調整するので、これらの値は特に設定の必要がありません。

例えばディスプレイサイズが 1920x1200 の場合、視錐台もこの大きさになります。 つまり見える範囲が広いので、例えば以下のようなサイズ1の立方体をSceneに追加したとき、zoom=1のままだと非常に小さく描画されます。

    <mesh position={[0, 0, 0]}>
      <boxGeometry args={[1, 1, 1]} />
      <meshStandardMaterial color={color} />
    </mesh>

zoom値が合っていない場合

この場合は、zoom値を上げることにより、視錐台の大きさが再調整され、サイズ1の立方体が画面に全体が映る様になります。

また、manualモードを使用すると、自動調整を無効化し、明示的にカメラの視錐台の左端、右端、上端、下端の値を設定することもできます。 この場合は 視錐台のパラメータの変更後に updateProjectionMatrix を都度呼ぶ必要があります。

OrthographicCameraの比較

比較項目 Three.js @react-three/drei
使用方法 手続き的 宣言的
カメラ作成 new (left, right, top, bottom, near, far) <OrthographicCamera makeDefault /> のようなJSX構文
視錐台 明示的に left, right, top, bottom, near, far を指定 自動で画面サイズに応じて調整(manual オプションで明示的指定も可能)
ズームの実現方法 camera.zoom を操作後に updateProjectionMatrix() を呼ぶ必要あり zoom をpropsで渡すだけでよい(自動で updateProjectionMatrix() が呼ばれる)
画面サイズへの対応 自動ではない。リサイズ時は手動で再計算が必要 自動で追従

カメラにオブジェクトが映らない?

react-three-fiberを使用してアプリケーションを開発していると、しばしばカメラにオブジェクトが映らないという問題に遭遇します。 OrthographicCamera使用時にも起こりがちな、代表的な原因と対策をまとめます。

近端 / 遠端(near / far)による描画範囲外

視錐台の値の中で、近端 / 遠端(near / far)による描画範囲外は意識から外れやすいです。 たとえば、camera.near = 1 の場合カメラから1未満の距離にあるオブジェクトは見えませんし、camera.far = 2000 ならそれより遠い距離にあるオブジェクトは見えません。 特にThree.jsのデフォルトでは near=0.1, far=2000 なので、大規模なシーンではfarを大きくする必要があります。

また、オブジェクトの一部でもnear/farクリップ外にある場合、そのオブジェクトは描画されないことがあります。 カメラを極端に近づけすぎて、オブジェクトを通り過ぎてしまった場合などです。

描画されない問題が発生したときは、対象オブジェクトの位置や大きさを確認し、視錐台の範囲に収まっているか確認しましょう。

映らない例

カメラの向き

単純な原因ですが、カメラがオブジェクトとは全く別の方向を向いていると、オブジェクトが映りません。

react-three-fiberを使っていると、マウス操作でPanやズームなどをいい感じに行ってくれる dreiのOrbitControls を使うことが多いかと思いますが、 OrbitControlsはカメラの向きの制御も行っています。

もしこのOrbitControlsがないとカメラの向きが原点以外に設定される可能性があります。 デフォルトではz軸の負の方向にカメラが向きます。

makeDefault

react-three-fiber環境では、複数のカメラを配置できるため、どのカメラを使うのか明示する必要があります。 makeDefault を明示的に指定し、描画するカメラを指定します。 もしシーン内で複数カメラを切り替える場合は、状態管理やrefを用いてsetDefaultCamera(camera)相当の処理を行う必要があります。 適切にカメラがアクティブになっているか確認しましょう。

ズーム値が合っていない

先に述べた特にOrthographic Cameraの場合、zoom値をある程度大きくするか、オブジェクトをある程度大きく描画しないと、デフォルトでは画面にちゃんと映るくらいの大きさでは描画されません。

とりあえずは <OrbitControls /> をSceneに追加してあげて、ズームすることで対象オブジェクトを画面に映すことができるか確認するのがよさそうです。

camera.updateProjectionMatrix()

manualモードにしている場合、camera.updateProjectionMatrix() を呼び忘れている可能性があります。


カメラにオブジェクトが映らない場合はまずクリップ面の設定と視野の範囲、カメラの向き・位置、アクティブなカメラの設定を疑うと良さそうです。

まとめ

Three.jsのカメラの基本的な概念と、react-three-fiberでの使用方法、OrthographicCameraの設定方法、オブジェクトが映らない場合の原因と対処方法をまとめました。

3D表現のプログラミングにはカメラの設定が欠かせないため、この記事がお役に立てば幸いです。

AMDlabでは、開発に力を貸していただけるエンジニアさんを大募集しております! 少しでもご興味をお持ちいただけましたら、カジュアルにお話するだけでも大丈夫ですのでお気軽にご連絡ください!

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

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

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