【ゲーム制作】Zファイティングとは?|2枚の面がちらつく原因を実測して、本当に効く対策だけ選ぶ

【ゲーム制作】Zファイティングとは?|2枚の面がちらつく原因を実測して、本当に効く対策だけ選ぶ ゲーム制作

遠くの地面に置いた模様が、赤や緑にジリジリとちらつく。壁に貼ったポスターの絵が、カメラを動かすと、消えたり出たりする。nearを大きくすればよいと聞くけれど、本当に直るのでしょうか。

2枚の面が同じ位置に重なったときに起きる、Zファイティングです。ただし、「原因と対策はこれです」と並べるだけの説明はしません。DirectXで2枚の巨大な面を実際に置き、距離ごとに「どちらが勝ってしまったか」を、ピクセル単位で数えて、よく言われる対策のうち、何が本当に効くのかを確かめました。意外な結果もあったので、その過程ごと紹介します。

Zファイティングとは?

Zファイティングとは、

・奥行き(Z値)がほとんど同じ2枚の面が重なったとき、どちらが手前かをGPUが決めきれず、ピクセルごとに勝者がバラバラになってしまう現象

です。名前のとおり、2枚の面がZ値で「戦って」しまい、勝ったほうの色が出ます。結果として、画面には縞模様やギザギザ、カメラを動かすとチラチラ動くノイズが出ます。

Zファイティングで2枚の面が赤と緑の細い縞になっている画面
実際に再現した画面。手前(下)は緑が勝っているが、奥にいくほど赤と緑が細かい縞になる

この画面は、赤い床の0.00001m(0.01mm)だけ上に緑の床を重ねて撮ったものです。緑が上にあるので、本来はすべて緑になるはずです。手前では正しく緑ですが、奥へ行くほど赤が混ざり、さらに奥では赤が勝つようになります。

なぜ起きる? 深度バッファの「目盛り」の粗さ

GPUは、ピクセルを描くたびに「今そこに描かれているものより手前か?」を深度バッファという別の画像と比べて判断します。この深度バッファは有限のビット数(よく使うのは24bit)で、1ピクセルにつき約1,677万通りの値しか持てません。

ここで問題になるのが、Z値は距離に対して均等に割り振られていないことです。透視投影後の深度値は、ニア(near)とファー(far)を使って次のように決まります。

depth = far * (z - near) / (z * (far - near))

// 24bitの深度の「1目盛り」が、何メートルの差に当たるか
// Δz ≒ z * z * (far - near) / (near * far * 2^24)

式を見ると、目盛りの幅は距離zの2乗に比例し、nearに反比例して大きくなります。つまり、遠くほど、そしてnearを小さくするほど、深度の目盛りは粗くなります。実際に計算した値をグラフにしました(far=1000)。

24bit深度バッファの1目盛りが距離とnearによってどう変わるかを示すグラフ
24bit深度の1目盛りが何メートルに当たるか(計算値)。縦軸は対数
距離near=0.1のときnear=1のとき
10 m約0.06 mm約0.006 mm
100 m約6 mm約0.6 mm
500 m約15 cm約1.5 cm
1000 m約60 cm約6 cm
far=1000、24bit整数の深度バッファで、上の式から計算した値

near=0.1のとき、500m先では約15cm刻みでしか奥行きを区別できません。この範囲に入る2枚の面は、GPUから見ると「同じ深さ」です。これがZファイティングの正体です。

実験:距離ごとに、どちらが勝つかを数える

理屈は分かりましたが、実際にどのくらいの距離から何%くらい壊れるのかは、式だけでは想像しにくいものです。そこで、次のような実験を組みました。

  • 赤い床(y=0)と、その真上に隙間gapだけ浮かせた緑の床を重ねる。緑が上なので、正解はすべて緑
  • 画面の行ごとに「赤と緑のピクセルのうち、赤(=間違い)の割合」を数え、行が指す地面までの距離で区間ごとに平均する
  • 深度バッファの丸め方の偶然に左右されないよう、カメラの横位置を6通りずらして平均する
  • 遠くまで行を分けて測るため、視野角は12度の望遠、カメラの高さは3m、見下ろし5度に固定

実験用の設定は次のとおりです。深度ステートと射影行列だけを抜き出しています。リバースZは、射影行列でnearとfarを入れ替えるだけで作れる点に注目してください。

▼ZFightDemo.cpp(深度ステート)
D3D11_DEPTH_STENCIL_DESC dd = {};
dd.DepthEnable = TRUE;
dd.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
dd.DepthFunc = D3D11_COMPARISON_LESS;
GetDevice()->CreateDepthStencilState(&dd, &dssNormal);
dd.DepthFunc = D3D11_COMPARISON_GREATER;      // リバースZは「大きいほど手前」
GetDevice()->CreateDepthStencilState(&dd, &dssReverse);
▼ZFightDemo.cpp(射影行列)
// リバースZは、ニアとファーを入れ替えた射影行列を作るだけでよい
XMMATRIX proj = XMMatrixPerspectiveFovLH(
    XMConvertToRadians(fovDeg), (float)SCREEN_WIDTH / (float)SCREEN_HEIGHT,
    rev ? farZ : nearZ, rev ? nearZ : farZ);
SetProjectionMatrix(proj);

計測した環境は、GeForce RTX 3050 Laptop GPU、Debugビルド、解像度1280×720です。

結果1:隙間を縮めると、手前から壊れ始める

2枚の面の隙間を縮めるほどZファイティングが手前から始まることを示すグラフ
gapを変えたときの、赤(間違い)の割合。near=0.1、24bit深度、標準Z

隙間が小さいほど、壊れ始める距離が手前に来ています。0.001mの隙間なら200m台まで平気ですが、0.00001mだと、測定の最手前(17m付近)ですでに7割が壊れていました。ここで気になるのが「壊れ始める距離」の目安です。予想式と実測を並べてみます。

gap式で出した開始距離実測(10%を超えた最初の区間)
0.001約559 m約269 m
0.0003約168 m約182 m
0.0001約56 m約406 m(※)
0.00003約17 m約24 m
0.00001約5.6 m約17 m(測定範囲の最手前)
式: 開始距離 ≒ gap × near × 2^24 ÷ カメラの高さ。near=0.1、カメラの高さ3m

式はgap × near × 2^24 ÷ カメラの高さという形で、桁は合っています。一方で(※)のgap=0.0001は、式の予想より大幅に遠くまで壊れませんでした。この点は後の「未解決のこと」で正直に書きます。

式に「カメラの高さ」が入っているのは、床を浅い角度で見るほど、同じ隙間が視線方向の奥行きの差としては大きく増幅されるからです。隙間gapの2枚の水平面は、距離zの位置で視線方向にgap × z ÷ カメラの高さだけ奥行きがずれて見えます。この実験では、まさにこの増幅のおかげで、gap=0.01のような「1cm浮かせる」設定では測定範囲で何も起きませんでした。

結果2:nearを遠ざけるほど、壊れる場所は奥へ追いやられる

ニアクリップを遠ざけるほどZファイティングが遠くへ追いやられることを示すグラフ
nearを変えたときの、赤(間違い)の割合。gap=0.0001、24bit深度、標準Z

nearを0.01から0.03、0.1と上げるたびに、壊れる領域が奥へ後退します。near=10にすると、測った範囲(750mまで)では一度も壊れませんでした。式のnearが効いている、ということです。ただし、nearを大きくするとカメラのすぐ前のものが切れて見えなくなるので、ゲームによっては選べない対策です。

【重要】実際に踏んだ罠:よく聞く対策を全部試したら、効くのは1つだけだった

「Zファイティングはnearを上げるか、D32_FLOATにするか、リバースZにすればいい」という話は、ネットでよく見かけます。しかし、私が実際に試したところ、1つずつ単体で入れてもほとんど直らないものがありました。条件をそろえて(gap=0.00001)、全部を並べて測りました。

対策ごとの割合を比べたグラフ。リバースZとD32_FLOATの組み合わせだけが0%
対策ごとの、赤(間違い)の割合。gap=0.00001
  • D32_FLOATにしただけ:何もしないときと完全に同じ数値でした。深度を32bit浮動小数点にしても、標準Zでは効果ゼロです(グラフでは黒い線が赤い線を隠しています)
  • リバースZ + D24:これも改善しませんでした
  • near 0.1→1:壊れ始めは40mあたりまで後退しましたが、300m前後にはまた100%に戻ります
  • near 0.1→10:270m付近までは完全に直りますが、その先(400m台)からは崩れ始めます
  • リバースZ + D32_FLOAT:全距離で0%。壊れたピクセルが1つも見つかりませんでした

なぜD32_FLOATだけでは効かないのか。浮動小数点は「0に近い値ほど細かく、1に近い値ほど粗い」という性質があります。標準Zでは、遠くの深度値がまさに1に近い側に集まります。そこは24bit整数と同じくらい粗いのです。だから、数値が完全に一致しました。

リバースZは、nearとfarを入れ替えて遠くの深度値を0付近に置く方式です。浮動小数点が細かい0付近に遠景が集まるので、精度がまるごと救われます。逆に、整数のD24は0付近も1付近も等間隔なので、リバースZにしても得るものがありません。効くのは「リバースZ」と「浮動小数点の深度」を両方そろえたときだけです。

深度バイアスも試した:符号を逆にすると100%壊れる

もう一つの定番が、ラスタライザーの深度バイアス(DepthBias)です。上に重ねたほうの面を描くときだけ、深度をわずかに手前へずらします。

▼ZFightDemo.cpp(ラスタライザー)
D3D11_RASTERIZER_DESC rd = {};
rd.FillMode = D3D11_FILL_SOLID;
rd.CullMode = D3D11_CULL_NONE;
rd.DepthClipEnable = TRUE;
GetDevice()->CreateRasterizerState(&rd, &rsPlain);
rd.DepthBias = bias;
rd.SlopeScaledDepthBias = slope;
GetDevice()->CreateRasterizerState(&rd, &rsBias);

ここで私はまずbias = 50、slope = 2.0と正の値を入れました。「バイアスを足せば効くだろう」と思ったからです。結果は全距離で100%、つまり全部の面が赤(下)になりました。深度バイアスは値が大きいほど奥へずれるので、上の面がさらに奥に押しやられ、下の面が勝ってしまったのです。

符号を負にすると、-1の時点で0%になりました(-3、-10、-30、-100、-300、-1000まですべて0%)。この実験では隙間が0.00001mと小さかったので、たった1目盛り分手前に寄せるだけで足りたのです。

もっと現実的な例:道路の白線が消える

平面が2枚という実験は極端に見えるかもしれません。そこで、より現実的な場面でも確かめました。幅12m、長さ900mの灰色の道路に、白い中央線(長さ3mの破線を6mおき)と左右の白線(幅15cm)を隙間0(完全に同じ高さ)で重ねたものです。ゲームで「道路にテクスチャ付きの板を貼っただけ」の場面を想定しています。

道路の白線がZファイティングで消える様子と、対策後の比較
左上:正解(1cm浮かせた場合) 右上:何もしない 左下:nearを1にした 右下:深度バイアスを使った

白いピクセルの数を、隙間を空けて描いた正解の画像と比べた結果です。

条件白線の残り(正解=100%)
何もしない(隙間0、near=0.1)21%
D32_FLOATにしただけ21%
nearを1にした1%
隙間を1cmにした100%
隙間を5cmにした99%
深度バイアス(bias=-200、slope=-2)101%
リバースZ + D32_FLOAT99%
深度バイアスの符号を逆(+200、+2)0%
白いピクセルの総数を、隙間を空けて描いた正解画像と比べた割合

何もしないと、白線は2割しか残りません。D32_FLOATにしても同じです。そして意外だったのがnearを1に上げたら1%まで悪化したことです。隙間0だと、2枚の面のZ値が数学的に同じなので、勝者は丸め誤差の運で決まります。nearを変えると、その運がたまたま悪い側に転んだのだと考えています(この点は原因を突き止めたわけではなく、推測です)。運任せの対策は、対策とは呼べません。

確実に効いたのは、隙間を空ける、深度バイアスをかける、リバースZ + D32_FLOATの3つでした。なお、リバースZ + D32_FLOATでも白線が完全には戻らなかった(約99%)のは、遠くの細い線が画面上で1ピクセル未満になって単に描かれなかった分だと思われます(こちらも未検証)。

Zファイティングが「ちらつく」理由

もう一つ、実験で分かったことがあります。カメラを横にたった1mm動かしただけで、赤と緑の縞の位置が変わりました。

カメラを1mm動かしただけで赤と緑の縞の位置が変わる拡大画像
カメラを横に1mm動かすと、矢印の2行だけ勝敗が入れ替わった(gap=0.00003)

この床の上の574,720ピクセルのうち、色が変わったのは2,560ピクセル(0.45%)、行にしてちょうど2行でした。この床は水平で、カメラは真横に動かしただけなので、数学的には各ピクセルの奥行きは変わらないはずです。それでも勝敗が変わったのは、頂点を座標変換するときの浮動小数点の丸めがカメラ位置で変わり、丸めの境目にいた行だけが反転したためだと考えています(推測です)。床は水平なので、勝敗は画面の行ごとにそろって変わります。これが、動かすとチラチラ光る理由です。

未解決のこと:gap=0.0001だけ壊れ方が飛んでいる

隙間を変えた実験で、gap=0.0001は式の予想(56m)よりずっと遠く(400m台)まで壊れませんでした。さらに、0.00005から0.00014までの隙間はほぼ同じ結果(400m付近で27%、600m付近で86%)になる平らな区間があります。同じ条件を再実行しても全く同じ数値が出たので、偶然のばらつきではありません。おそらく頂点座標の浮動小数点の丸めが関係していると見ていますが、確かめていません。「式で予想できる」と書きましたが、あくまで目安だと受け止めてください。

Zファイティングのよくある失敗例と対処法

①nearを小さくしすぎている

「近くのものが切れるのが嫌だから」とnear = 0.01にしていませんか? 上のグラフのとおり、near=0.01は手前の17m付近でも約7割が壊れています。nearは、ゲームで許せる範囲でできるだけ大きくするのが基本です。カメラがキャラクターから離れる三人称視点なら、0.1〜1はすぐ試せます。

②D32_FLOATにしただけで安心する

深度を32bit浮動小数点にしても、標準Zのままなら何も変わりません。実測では、何もしないときと数値が完全に一致しました。D32_FLOATは、必ずリバースZ(nearとfarの入れ替え + 深度比較をGREATERに + 深度クリア値を0に)とセットで使います。

③深度バイアスの符号を逆にする

私は実際にやりました。標準Zでは、手前に寄せたいなら負の値です。リバースZにするとさらに向きが変わる(値が大きいほど手前になる)ので、切り替えるときはバイアスの符号も見直します(この向きの入れ替えは今回の実験では測っていません)。

注意点

  • Zファイティングは「距離」×「隙間」×「near」で決まる。まずこの3つを疑う
  • 同じ高さに面を重ねる(隙間0)場合、勝敗は運。少しでも隙間を空けるほうが確実
  • D32_FLOATはリバースZとセットでないと効かない
  • 深度バイアスは手前に寄せる向きを確認する。標準Zなら負
  • 地面に貼るデカール、道路の線、壁のポスター、水面の下の地形など、「同じ位置に重ねたくなる場面」で起きやすい

結論

  • Zファイティングは、深度バッファの目盛りの粗さが原因で、遠くほど、nearが小さいほど粗くなる
  • 実測では、D32_FLOATだけ・リバースZ + D24 は効果なし。効いたのはリバースZ + D32_FLOATだけ(全距離で0%)
  • nearを上げると効くが限界があり、隙間0の場面では逆に悪化することもある
  • 実用的には、隙間を空けるか、深度バイアスを負の値で使うのが手軽

実測では、D32_FLOATだけ、またはリバースZとD24の組み合わせでは、効果がありませんでした。効いたのは、リバースZとD32_FLOATの組み合わせだけです(全距離で0%)。nearを上げると効きますが、限界があり、隙間が0の場面では、逆に悪化することもあります。実用的には、隙間を空けるか、深度バイアスを負の値で使うのが手軽です。

関連記事