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