【Unity】Z-Fightingと計算誤差【RenderDoc】

2023/09/10に公開

はじめに

同じ位置にポリゴンが存在する場合に、以下のようなちらつきが発生することがあります。
これをZ-Fighting(Zファイティング)と呼びます。

今回、あえてZ-Fightingを発生させ、Z-Fightingが発生する理由について考察してみたいと思います。

環境

  • Windows 10
  • Unity 2020.3.33f1

Z-Fightingの発生条件

Z-Fightingは、以下のような理由によって発生すると言われています。

  • 面がカメラから遠く離れており、深度値の精度が低下して、値が区別できなくなる場合
  • メッシュ内の面が物理的に重なり合う場合

https://learn.microsoft.com/ja-jp/azure/remote-rendering/overview/features/z-fighting-mitigation

Chapter1. Z-Fightingを発生させてみる

どういった状況でZ-Fightingが発生するかを調べてみます。

実験1. カメラを(0, 0, 1)に向ける

カメラを(0, 0, 1)方向に向けます。
白いメッシュは画面中央に配置します。
茶色のメッシュは白いメッシュとぴったり重なるように配置します。 (少しだけ横にずらします)

GameViewを見てみると、Z-Fightingは起きていません

実験2. FOVを変化させる

カメラのFOVを変化させてみましたが、Z-Fightingは発生しません。

実験3. カメラの座標を変える

カメラを移動させてみます。
Z-Fightingは起きませんが、カメラをZ方向に動かしたとき、ごく稀にちらつきます。

実験4. カメラを回転させる

カメラを回転させてみると、Z-Fightingが発生します。

考察

以下のようにカメラをY軸回転させた場合、画面の左側の方が深度値が低く、画面右側の方が深度値が大きくなります。

画面のピクセルごとの深度値に違いが生まれているため、Z-Fightingが起きると考えられます。

Chapter2. RenderDocで深度値を解析する

カメラのY軸回転を1°にした状態のZ-FightingをRenderDocで解析してみます。

RenderDocのPixel History という機能を利用することで、fragmentシェーダーの描画の履歴を見ることができます。

以下のことが分かります。

  1. 白いピクセルの深度値は 0.0798815563 になっており、画面の深度値 0.0 より大きいので Zテスト成功 (画面に白色と深度値が書きこまれる)
  2. 茶色ピクセルの深度値は 0.0798815489 になっており、画面の深度値0.0798815563 より小さいので Zテスト失敗)
Pixel Historyの起動方法

Pixel Historyは以下の方法で起動します。

  1. Texture Viewr上で、デバッグしたいところを右クリック
  2. Historyボタンをクリック

Pixel History の深度値の表示桁数を増やす方法

Settingsから桁数を設定できます

ZTest LEqual

Unityのシェーダー上では ZTest LEqual (シェーダー出力の深度値が画面の深度値より小さければZテスト成功) という指定になっています。
参考 : https://docs.unity3d.com/ja/2020.3/Manual/SL-ZTest.html

ZTest LEqual

「深度値が小さいほうがZテスト成功じゃないの?」という疑問が出てくるかもしれませんが、
DirectX系ではNearの深度値が大きく、Farの深度値が小さいため、シェーダー出力の深度値が画面の深度値より大きいとZテストに成功します。
参考 : https://docs.unity3d.com/ja/2019.4/Manual/SL-PlatformDifferences.html

Chapter3. 深度値が計算される過程について

画面のピクセルの深度値は、以下のような過程を経て計算されます。

  1. 頂点シェーダー : メッシュの頂点座標 -> クリップ空間の座標
  2. ラスタライザ : クリップ空間の座標 -> ピクセルの深度

この計算過程で誤差が発生します。

1. 頂点シェーダーの深度値の誤差

頂点座標は、MVP座標変換によってクリップ空間の座標 SV_POSITION へと変換されます。

o.vertex = UnityObjectToClipPos(v.vertex); // SV_POSITION の計算

頂点の深度値は SV_POSITION.z / SV_POSITION.w で計算されます。

頂点座標SV_POSITIONを計算する時、内部的には行列演算が行われており、誤差が生まれます。
そのため、深度値 SV_POSITION.z / SV_POSITION.w にも誤差が生まれます。

2. 描画ピクセルの深度値の計算

画面の描画ピクセルの深度値は、三角形の3つの頂点の深度の加重平均で求まります。

D_{ピクセル} = A \cdot D_{頂点0} + B \cdot D_{頂点1} + C \cdot D_{頂点2}

A, B, C は重心座標(三角形の面積比)です (A + B + C = 1)

頂点の深度値は誤差を含んでいるので、ピクセルの深度値にも誤差が伝搬します。

RenderDocで深度値を見る

1. Z-Fightingが発生しないケース

カメラの回転がゼロの場合、Z-Fightingが起きていません。
このケースでの頂点座標を見てみます。


白色メッシュ


茶色メッシュ

SV_POSITION(クリップ空間の頂点座標) の z成分 や w成分 はすべて同じ値になっていました。

Pixel History を見てみると、白色メッシュと茶色メッシュは同じ深度値を書きこんでいます。
ここの深度値は、メッシュのどこのピクセルを見ても、変わらず同じ値になっています。

先ほど、ピクセルの深度値は頂点の深度値の線形補間(加重平均)で求められると説明しました。

D_{ピクセル} = A \cdot D_{頂点0} + B \cdot D_{頂点1} + C \cdot D_{頂点2} (A + B + C = 1)

今回のケースでは、すべての頂点の深度値が等しいので、頂点の深度値はそのままピクセルの深度値となります。

D_{ピクセル} = (A + B + C) \cdot D_{頂点0} = D_{頂点0}

2. Z-Fightingが発生するケース

カメラのY軸を1度回転させたとき、Z-Fightingが起きました。
このケースでの頂点座標を見てみます。


白色メッシュ


茶色メッシュ

深度値を見やすく表にまとめた見たものが以下になります。

白いメッシュの深度値 ( SV_POSITION.z/SV_POSITION.w ) は、茶色メッシュの深度値と違う値になっています。 (誤差があります)

まとめ

今回の検証から、以下のことが言えそうです。

  • MVP座標変換によって算出される頂点座標にはわずかな誤差があるため、頂点から深度情報を計算する際にも誤差が生まれる
  • 2つのポリゴン頂点の深度値に差がある場合、ラスタライズ(加重平均)で計算されるピクセル深度にも誤差が生まれるため、Z-Fightingが起きる
  • 今回のケースでは シェーダーが出力する深度値に 1.0 \times 10^{-8} 程度の誤差が生まれていました。 (カメラをY軸1°回転させた場合)

補足

デプスバッファには深度値の逆数が格納されており、遠景になるほど精度がおちてZ-Fightingが起きやすくなります (DirectX 環境の場合)
参考 : https://developer.nvidia.com/content/depth-precision-visualized

おまけ. 頂点シェーダーのアセンブリを読んでみる

Mesh Viewer で頂点を右クリックし、Debug this Vertex を選択することで、頂点シェーダーをデバッグできます。

頂点シェーダーのデバッガーでは、頂点シェーダーアセンブリをステップ実行できたり、シェーダー変数の中身の値を見ることができます。

頂点シェーダーのアセンブリが実行される過程での頂点座標の値がどのように変化するか見てみました。

今回は、白いメッシュの頂点座標を見てみます。

レジスタの定義

ここでは、シェーダー変数やシェーダーの入出力を定義しています。
レジスタ v0 には頂点座標 (x, y, z)が格納されます。
レジスタ o1 には、SV_POSITION が格納されます。

      dcl_constantbuffer cb0[3], immediateIndexed
      dcl_constantbuffer cb1[4], immediateIndexed
      dcl_constantbuffer cb2[21], immediateIndexed
      dcl_input v0.xyz
      dcl_input v1.xy
      dcl_output o0.xy
      dcl_output_siv o1.xyzw, position
      dcl_temps 2

レジスタの中身の確認

入力レジスタ (dcl_constantbufferやdcl_inputで定義されたレジスタ) は Constants & Resources タブで確認できます。

出力レジスタ (dcl_outputで定義されたレジスタ) は、 High-level Variablesで確認できます。

dcl_tempsやdcl_outputで定義されたレジスタは、Variable Valuesタブで確認できます。
r0, r1 は計算の途中結果を保存しておくためのtemporaryな変数で、o0, o1 は出力レジスタです

テクスチャ座標の変換処理

0行目は mad 命令を実行しています。

   0: mad o0.xy, v1.xyxx, cb0[2].xyxx, cb0[2].zwzz

o0はTEXCOORDであるため、シェーダー内の以下の処理に対応していると考えられます。

o.uv = TRANSFORM_TEX(v.uv, _MainTex);

頂点座標の変換処理

1 ~ 8行目では、mul,add,mad命令が実行され、結果がo1レジスタに格納されます。

   1: mul r0.xyzw, v0.yyyy, cb1[1].xyzw
   2: mad r0.xyzw, cb1[0].xyzw, v0.xxxx, r0.xyzw
   3: mad r0.xyzw, cb1[2].xyzw, v0.zzzz, r0.xyzw
   4: add r0.xyzw, r0.xyzw, cb1[3].xyzw
   5: mul r1.xyzw, r0.yyyy, cb2[18].xyzw
   6: mad r1.xyzw, cb2[17].xyzw, r0.xxxx, r1.xyzw
   7: mad r1.xyzw, cb2[19].xyzw, r0.zzzz, r1.xyzw
   8: mad o1.xyzw, cb2[20].xyzw, r0.wwww, r1.xyzw

o1レジスタはSV_POSITIONであるため、シェーダーの以下の処理に対応すると考えられます。

o.vertex = UnityObjectToClipPos(v.vertex);

値の変化を観察してみる

アセンブリをステップ実行したときのレジスタの値の変化を表にまとめてみます。

1~4行目 (モデル変換)

1 ~ 4 行目では、モデル行列 unity_ObjectToWorld との演算が行われていると考えられます。

   1: mul r0.xyzw, v0.yyyy, cb1[1].xyzw
   2: mad r0.xyzw, cb1[0].xyzw, v0.xxxx, r0.xyzw
   3: mad r0.xyzw, cb1[2].xyzw, v0.zzzz, r0.xyzw
   4: add r0.xyzw, r0.xyzw, cb1[3].xyzw

Unityのシェーダーでは、以下に対応します。

mul(unity_ObjectToWorld, float4(pos, 1.0))

r0レジスタには頂点座標とまったく同じ値が格納されます。

r0.x r0.y r0.z r0.w
-0.50 -0.50 -3.0616168006E-17 1.00
値が変化しない理由について

メッシュのTransformは、平行移動・回転・スケールを行わない設定になっています。

そのため、cbuffer1には、単位行列の値が入るので、頂点の変換が行われないことになります。

5行目

5 ~ 8 行目では、行列 UNITY_MATRIX_VP との演算が行われていると考えられます。

   5: mul r1.xyzw, r0.yyyy, cb2[18].xyzw
   6: mad r1.xyzw, cb2[17].xyzw, r0.xxxx, r1.xyzw
   7: mad r1.xyzw, cb2[19].xyzw, r0.zzzz, r1.xyzw
   8: mad o1.xyzw, cb2[20].xyzw, r0.wwww, r1.xyzw

Unityのシェーダーでは、以下に対応します。

mul(UNITY_MATRIX_VP, ***)

Unity上のVP行列との関係

cbuffer2 には UNITY_MATRIX_VPの各成分が入っています。

FrameDebugger上で見れるVP行列とは、行と列が入れ替わっています。

Unity(C#側)の行列は行優先ですが、シェーダー側では列優先になっているため、このような入れ替わりが発生しています。
参考 : https://edom18.hateblo.jp/entry/2019/01/04/153205

出力

最終的にo1レジスタには以下が格納されます。
これがSV_POSITIONになります。

o1.x o1.y o1.z o1.w
-0.7736954689 1.2071067095 0.2988926172 3.9906647205

Discussion