1717512806
2024-06-04 13:42:03
トピック
透視投影マトリックスの逆 Z に切り替えると、なぜ劇的な効果が得られるのかを説明します。
モチベーション
リバース Z は、私が長い間実装したいと思っていたものです。何が必要かを理解し、それを導入することで改善された結果 (一言で言えば、Z ファイティングが減る、またはなくなる) を直接観察したかったのです。そうすることで、浮動小数点形式 (IEEE 754) の奇妙さを十分に理解するようになり、この発見の旅は共有する価値があると思いました。
この記事は 2 部構成です。最初の部分では、逆 Z 投影マトリックスを作成する 1 つの方法 (新しいバリエーションを多数作成せずに) と、それを単純なグラフィックス シーンで使用する方法を簡単に説明します。2 番目では、通常の Z と逆 Z の違いがなぜそれほど劇的なのかを説明し、浮動小数点数に関して私が完全に理解していなかったいくつかの洞察を掘り下げます。
逆Z透視投影
どうやって
従来の OpenGL 透視投影マトリックスはこれに似ています (わずかな違いは多数あります)。
mat4 perspective_opengl_rh(
const float fovy, const float aspect, const float n, const float f)
{
const float e = 1.0f / std::tan(fovy * 0.5f);
return {e / aspect, 0.0f, 0.0f, 0.0f,
0.0f, e, 0.0f, 0.0f,
0.0f, 0.0f, (f + n) / (n - f), -1.0f,
0.0f, 0.0f, (2.0f * f * n) / (n - f), 0.0f};
}
上記の行列は右手座標系を生成します(慣例により、OpenGLのチュートリアル/例では右手座標系が使用されているようですが、3行目を次のように更新すれば左手座標系でも同様に簡単に使用できます)。 0.0f, 0.0f, (f + n) / (f - n), 1.0f 代わりに、結果の深度値は -1 から +1 にマッピングされます。
従来の DirectX 透視投影マトリックスは次のようになります (ここでもわずかな違いが多数あります)。
mat4 perspective_direct3d_lh(
const float fovy, const float aspect, const float n, const float f)
{
const float e = 1.0f / std::tan(fovy * 0.5f);
return {e / aspect, 0.0f, 0.0f, 0.0f,
0.0f, e, 0.0f, 0.0f,
0.0f, 0.0f, f / (f - n), 1.0f,
0.0f, 0.0f, (f * n) / (n - f), 0.0f};
}
上記の行列は左手座標系を生成します(ただし、右手座標系を使用することもできますが、唯一の違いは3行目が 0.0f, 0.0f, f / (n - f), -1.0f)、結果の深度値は 0 から 1 にマッピングされます。
これらの関数の「逆」バージョンを作成したり、 near そして far 関数を呼び出すときにパラメータを指定すると、逆 Z 対応行列を生成する新しい行列を作成できます。
注:
mat_mulここで参照される関数は、引数を左から右に乗算します (左の行列が常に最初に適用されます)。
mat4 reverse_z(const mat4& perspective_projection)
{
constexpr mat4 reverse_z {1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, -1.0f, 0.0f,
0.0f, 0.0f, 1.0f, 1.0f};
return mat_mul(perspective_projection, reverse_z);
}
OpenGL マトリックスの場合、まず深度範囲を -1 から 1 ではなく 0 から 1 にマッピングする必要があるため、逆 Z マトリックスを適用する前に同様のアプローチを使用できます。
// map from -1 to 1 to 0 to 1
mat4 normalize_unit_range(const mat4& perspective_projection)
{
constexpr mat4 normalize_range {1.0f, 0.0f, 0.0f, 0.0f,
0.0f, 1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 0.5f, 0.0f,
0.0f, 0.0f, 0.5f, 1.0f};
return mat_mul(perspective_projection, normalize_range);
}
これらを一緒に構成するには、次のようにします…
const mat4 perspective_projection = perspective_opengl_rh(
radians(60.0f), float(width) / float(height), near, far);
const mat4 reverse_z_perspective_projection =
reverse_z(normalize_unit_range(perspective_projection));
標準のパースペクティブ マトリックスを作成し、深度マッピングを -1 から 1 から 0 から 1 に更新します (このためにマトリックスが行っているのは、0.5 を掛けて 0.5 を加算することだけです)。次に、z/深度値を反転して、0 から 1 ではなく 1 から 0 にします。このアプローチを使用すると、さまざまなグラフィックス API と規則のパースペクティブ投影マトリックスの組み合わせ爆発を回避できます (注: Metal は DirectX と同じであり、Vulkan は OpenGL 投影マトリックスを取得し、深度を前と同じように 0 から 1 に再マッピングしてから、さらに Y 軸を反転することで作成できます。これは、Vulkan ではクリップ空間の Y 軸が下を向いているために必要です)。
mat4 perspective_vulkan_rh(
const float fovy, const float aspect, const float n, const float f)
{
constexpr mat4 vulkan_clip {1.0f, 0.0f, 0.0f, 0.0f,
0.0f, -1.0f, 0.0f, 0.0f,
0.0f, 0.0f, 0.5f, 0.0f,
0.0f, 0.0f, 0.5f, 1.0f};
return mat_mul(perspective_opengl_rh(fovy, aspect, n, f), vulkan_clip);
}
注: 上記のアプローチは非常に独断的なものであり、同じ効果を実現する方法は他にも複数あります。たとえば、シェーダーで y を反転したり、Vulkan でビューポートの高さを負の値に設定したりしても同様に機能します。逆 z を使用すると、通常の投影マトリックスを使用するときに、近い引数と遠い引数の順序を反転するだけで済みます。何らかの理由で私は上記のアプローチを好みますが、自分にとって最善のものを行ってください。また、他のアプローチも考慮してください。
それが完了したら、使用しているグラフィックAPIでさらに3つのことを確認する必要があります(OpenGLでは4つ)。
- クリア深度を 0 にします (通常の 1 ではありません)。
- 深度テストを設定する より大きい (ない 少ない いつものように)。
- 浮動小数点深度バッファを使用していることを確認してください(例:
GL_DEPTH_COMPONENT32F、DXGI_FORMAT_D32_FLOAT_S8X24_UINT、MTLPixelFormat.depth32Float等。) - (OpenGLの場合)必ず
glClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE);OpenGL が深度範囲が -1 から 1 ではなく 0 から 1 であることを認識できるように設定されます。
注: 効果と設定方法を示す簡単なプロジェクトについては、この記事の最後にあるリンクを参照してください。
なぜ
理解する なぜ 逆Zは通常のZよりもはるかに優れているため、浮動小数点数の表現方法を詳しく見てみる必要があります。32ビット浮動小数点数の場合、符号ビット(1ビット)、指数(8ビット)、仮数(23ビット)があります。深度バッファでは、可能な限り高い精度を得るのが理想的ですが、実際には 非常識な 浮動小数点数の精度は 0 に近くなります。最近までこの程度の大きさを十分に理解していませんでしたが、数字を見たときは驚きました (しゃれではありません)。
2つの正の浮動小数点数(または0)を整数に変換すると(C++20の bitcast または memcpy (もしそれが利用できない場合は)一方を他方から減算すると、その 2 つの値の間にいくつの異なる数値が存在するかがわかります。0 に近い数値でこれを行うと、興味深い結果が得られます。
int representableValues(float from, float to)
{
uint32_t fromi;
memcpy(fromi, from, sizeof(float));
uint32_t toi
memcpy(toi, to, sizeof(float));
return toi - fromi;
}
printf("0.0f - 0.5f: %dn", representableValues(0.0f, 0.5f));
printf("0.5f - 1.0f: %dn", representableValues(0.5f, 1.0f));
// output
0.0f - 0.5f: 1056964608
0.5f - 1.0f: 8388608
0.0f - 1.0f: 1065353216
計算してみると、0.0から1.0までの範囲のうち、およそ 0.79% 表現可能な値はすべて0.5から1.0の間であり、 99.21% 0.0 から 0.5 の間です。0 に近いほど精度が高くなることは知っていましたが、その程度は十分に理解していなかったと思います。
余談:確認のため
representableValues一貫性を保つために、便利なstd::nextafterは、次の表現可能な値を返します。fromの方向にto遅いが正確なチェックは次のように記述できます。
int count = 0;
float from = 0.0f, to = 1.0f;
while (from to) {
from = std::nextafter(from, to);
count++;
}
printf("0.0f - 1.0f: %dn", count);
人数、個数、総数 8388608 も重要です。2 の次の累乗 (以下同様) を取り、一方を他方から減算すると (この場合は 2.0 と 1.0)、どのような数値が得られると思いますか…
printf("1.0f - 2.0f: %dn", representableValues(1.0f, 2.0f));
printf("2.0f - 4.0f: %dn", representableValues(2.0f, 4.0f));
printf("4.0f - 8.0f: %dn", representableValues(4.0f, 8.0f));
....
// output
1.0f - 2.0f: 8388608
2.0f - 4.0f: 8388608
4.0f - 8.0f: 8388608
...
技術的には、
representableValues(1.0f, std::nextafter(2.0f, 0.0f));半開範囲(通常は[) では、最終値は含めません。代わりに、8388608 から 1 を引くこともできます。, )
8388608 について他に何か気付いたことはありますか? 実は、これは 2^23 で、浮動小数点表現の仮数部のビット数です。指数 (8 ビット) は 2 の累乗のみを表し、仮数部のビットは 2 つの差を埋めるために使用されます。問題は、2 の累乗が増加すると、使用できる値は依然として 8388608 – 1 しかなく、数値が増加すると、それらの間のジャンプが増加し、精度が失われることです。
指数では、最上位ビットが設定されていない場合、すべての組み合わせは 2 の負の累乗を表します。最上位ビット以外が設定されている場合は、値 1 になります。その他の組み合わせ (最上位ビットが設定されている) は、2 の正の累乗になります。指数の範囲は -126 から 127 です。指数の選択ごとに、その範囲の数値を表すために仮数に固定のビット セットがあります。注目すべき重要な点は、有効な指数の大部分が 0 から 1 の範囲をカバーしていることです。
// exponent bits examples
[0][1][1][1][1][1][1][1] == 2^0 // 1
[1][0][0][0][0][0][0][0] == 2^1 // 2
[0][1][0][1][0][0][1][1] == 2^-44 // 5.68434188608e-14
利用できることがわかっている 7 ビット (指数の最上位ビットを除く) を仮数の 23 ビットと組み合わせると、0.0 から 2.0 の間で表現できる 2^30 の値 (1073741824) が得られます。そこから 8388608 を引くと、なんと 1065353216 になります。これは、0.0 から 1.0 の間で表現できる値の数であることが以前からわかっているはずです。さらに 8388608 を引くと、0.0 から 0.5 の間の値の数が得られます。残りの数字の数がわかっているので、0.0 から 0.5 の間で利用できる精度がどれだけあるかがわかります。
浮動小数点数とその表現方法をより深く理解するには、このすばらしいオンライン ツールを試してください。このツールを使用すると、浮動小数点数のさまざまなビットを対話的に設定したり、特定の数値が何に対応するかを確認したりできます。とても参考になります。 https://www.h-schmidt.net/FloatConverter/IEEE754.html。
何について話してたっけ?
ああ、そう、Zを逆にするんだ!浮動小数点数について少し話したあと、Zを逆にする話に戻り、なぜこの変更がこれほど大きな改善につながるのかを考えてみましょう。ビュー空間の深度(Z)値をクリップ空間に変換し、正規化されたデバイス座標(NDC)に変換する式を見てみましょう。先ほど見た投影行列( perspective_direct3d_lh 1) z 部分を乗算すると次のようになります…
これらの用語は、
perspective_direct3d_lh– (f / (f - n)そして(f * n) / (n - f)m22 と m23 から (行、列の順序)。
次に、透視除算を実行します (上記の行列乗算とは別に)。

これにより、NDC での正規化された深度値がわかります。余裕のあるニア クリップ プレーンを 1.0、ファー クリップ プレーンを 100.0 に選択し、この式に入力値としてたとえば 5.0 を与えると、興味深い結果が得られます。深度値は 0.808 です。つまり、通常の z を使用していて、クリップ プレーンに近い値がある場合でも、0 に近い値はすべて使用できないため、すでにかなりの精度が完全に破棄されています。より代表的な数値を入力すると、状況はさらに悪化します。ニア クリップ プレーンが 0.01、ファー クリップ プレーンが 1000.0 の場合、ビュー空間深度値 5.0 を使用すると、0.998 になります。つまり、5.0 から 1000 までの正規化された深度を表すために残っている一意の値は 33554 個だけであり、これはあまり良い結果ではありません。
1.0 付近の値がこのように密集する理由は、非線形のパースペクティブ除算によるものです。パースペクティブ投影マトリックスは、現在の深度 (z とも呼ばれる) 値を取得して変換し、現在の深度で割ると、近いクリップ プレーンでは 0.0、遠いクリップ プレーンでは 1.0 になります (その範囲内の値の場合は 0.0 から 1.0 の間の値になります)。深度値が増加すると、z と 1/z の間の非線形関係により、z の値が小さい場合は曲線がゆっくりと大きくなりますが、z が増加すると 1/z が指数関数的に大きくなり、精度がすべてである 0 から急速に離れていきます。 このグラフ 近い平面と遠い平面を変更した場合の曲線への影響を確認します。近い平面を変更すると、より顕著な効果があることに注目してください。以下に、遠いクリップ平面を 100、近いクリップ平面をそれぞれ 5.0、1.0、0.1 に設定した場合の透視変換を示す 3 つのスナップショットを示します。

しかし、逆 Z を使用すると、状況は大幅に改善されます。反転した方程式は次のようになります…

ちなみにこれは…と同等です。

ニア クリップ プレーンが 1.0、ファー クリップ プレーンが 100.0 の場合、入力値が 5.0 で、深度値は約 0.192 になります。ニア クリップ プレーンを 0.01 に、ファー クリップ プレーンを 1000 に設定すると、ビュー空間の深度値 5.0 は約 0.00199 になります。ただし、この場合、990014121 の一意の深度値が可能であり、通常の z の 29500 倍の改善が図られているのは驚くべきことです。z を反転すると、範囲全体で精度が平滑化される効果があります (0.0 から 0.5 の間に多くの精度が存在するという事実を利用して、より多くの値がそこに配置されるようにします)。z を反転しないと、精度は、それほど精度が必要ないニア クリップ プレーンに前倒しされます。
これの驚くべき点は、パフォーマンスやメモリを犠牲にすることなく、このような追加の精度と忠実度の向上が得られることです。これは通常、コンピュータサイエンスやソフトウェアエンジニアリングでは決して起こりません(解決策はなく、トレードオフがあるだけだ – トーマス・ソウェル)。 ただし、逆 Z になるとそうではないようです。
参考文献
この投稿は、さまざまな角度から逆 Z を取り上げている Web 上の優れた一連の記事からヒントを得ました。さらに詳しく知りたい方のために、最も優れた記事のいくつかを以下に簡単にリストアップしました。
これを実際に視覚化するには、効果を示すために OpenGL で作成された小さなテスト シーンを以下に示します。
下のMetalバージョンもありますが、上のOpenGLデモのすべての機能がまだ備わっていません。
これらの計算の感触をつかむには、これらの小さなインタラクティブな Desmos リンクを確認してください (すべてが何かをプロットするわけではありませんが、近いクリッピング プレーンと遠いクリッピング プレーン、入力深度値を変更して出力を確認することで、試してみると便利です)。
そして最後に、前述した浮動小数点計算機 (これはどれだけ高く評価しても足りません!)
ボーナスセクション
zに適用される透視投影を計算するための方程式の導出

#リバースZそしてそれがなぜ素晴らしいのか