19. 投影とラスタライズの詳細

第2部へようこそ。
第1部(第1章〜第18章)では、CGエンジニア検定エキスパートのシラバス全体を、初心者にも分かる深さで一通り見て来ました。
第2部(第19章〜第29章)はシラバスの範囲を超えます。
第1部では概略しか描けなかった話題を、きちんと最後まで導きます。此の章の投影とラスタライズの詳細に始まり、アンチエイリアシングと透明表現、メッシュ処理と符号付き距離場、物理ベースシェーディング、モンテカルロレンダリング、リアルタイムの影と大域照明、高度なアニメーションと物理、HDRと画像処理、ボリュームレンダリング、そして最後に現代のGPUとニューラルレンダリングです。

第2部と第1部の関係はどうなっているのでしょうか?
第2部の各章は第1部で紹介した何かから出発し、其れを紹介した節にリンクで戻り、同じ用語と記法を使います。
第1部で「第2部で扱います」と書いた約束は、其の話題を受け持つ章で果たします。
導出は増えますが、スタイルは同じです。先ず直観、次に式、そして実際の数値で確かめます。
検定に合格する為に第2部は必要ありません。第30章の模擬試験が問うのはシラバスの範囲です。
但し、検定の答えの多くは、其れが何故正しいのかを知ると当たり前になります。第2部は其の為にあります。
話題がシラバスの項目に繋がる所では、「検定のポイント」で其れがどの項目かを示します。

此の章では、第1部でブラックボックスとして扱ったパイプラインの2つの箱、投影行列とラスタライザーを開けます。
第6章で「詳しい導出は第2部で行います」と約束し、第5章と第10章では奥行きの精度、リバースZ、デカール用の深度オフセットを後回しにしました。
今回の予定は次の通りです:

  • 任意の視錐台と任意の深度の規約(OpenGL、Vulkan/Direct3D、リバース)に対する透視投影行列の導出。yの反転、無限遠のファー平面、正射影も含みます。
  • 逆投影:ピクセルと其の深度からカメラ空間の位置を取り戻す方法。
  • 同次座標のクリップ空間でのクリッピング:其れが無いと何が起きるか、ニア平面でのクリッピングの計算例、ガードバンド。
  • 深度精度:精度が何処に行くか、固定小数点と浮動小数点、リバースZ、対数深度、デプスバイアス。
  • ラスタライザー:エッジ関数、トップレフトルール、固定小数点へのスナップ、タイル単位の走査、パースペクティブコレクト補間の導出、2×2クアッドでの微分、アーリーZと階層Z。

前提は線型代数の第12章(投影)、第10章(逆行列)、そして第6章の6.9節(エッジ関数)です。其れ以外は全て此処で組み立てます。

19.1 どの規約にも使える透視投影行列

行列がしなければならない事

線型代数の第12章では、対称な視錐台に対してperspectiveGlとperspectiveVkを導きました。
此処では全ての変種を一度に扱える形でやり直します。
カメラは−z方向を向いているので、カメラの前にある点の距離d=−zは正です。
行列には行ごとに1つ、合わせて4つの仕事があります:

  • 一番下の行は距離をwにコピーし、GPUの透視除算が其れで割る様にします:w=−z。
  • xとyの行は、其の除算の後に視錐台の端で丁度±1になる値を作ります。
  • zの行は、ニア平面とファー平面で決められた値になる深度を作ります。
  • そして全ては行列なので、(x,y,z,1)について線型でなければなりません。

xとyの行:非対称な視錐台

視錐台を、距離nにあるニア平面と、其の平面から切り取る長方形で表します。xはlからrまで、yはbからtまでです。
点(x,y,z)はニア平面上のxn=nx−zに投影されます(相似な三角形、第5章の5.9節)。
xn=lを−1に、xn=rを+1にしたいので、第4章の1次元のウィンドウ・ビューポート変換を使います:

xndc=2xn−(r+l)r−l=1−z(2nr−lx+r+lr−lz)

係数1−zは透視除算なので、行列は括弧の中だけを作れば良いのです:

xclip=2nr−lx+r+lr−lz,yclip=2nt−by+t+bt−bz

対称な視錐台ではr=−l、t=−bなので、zの項は消えます。
縦の視野角をθとするとt=ntan(θ/2)なので2nt−b=nt=1tan(θ/2)=cとなり、アスペクト比aを使うとxの成分はc/aです。正にperspectiveGlの成分です。

非対称な視錐台は珍しい物ではありません。VRヘッドセットは片目ごとに其れで描き、複数のモニターを並べた壁は1つの大きな視錐台の一部を各モニターに割り当て、テンポラルアンチエイリアシングは毎フレーム視錐台をピクセルの何分の一かずらします(第20章)。
NDCで像をΔだけ動かすには、xclipにΔ⋅w=−Δzを足します。つまり0行2列に−Δを入れます。
幅800ピクセルの画面で半ピクセルはΔ=2⋅0.5800=0.00125です。

zの行:深度は1/zに従うしか無い

zの行はzclip=Az+Bにしかなれません。xやyに依存してはいけませんし(同じ距離の点は全て同じ深度になる)、zと1の線型結合以外の物は入れられません。
除算の後は:

zndc=Az+B−z=−A+B⋅1d,d=−z

深度バッファの非線型性の話は此れで全部です。
AとBをどう選んでも、保存される深度は1/dのアフィン関数です。
此れは誰かが決めた設計ではなく、投影が「4×4行列を1つ掛けてから割る」事である以上、避けられない結果です。
そして良い面もあります。19.5節で見る様に、1/d、従って深度は三角形の上で画面に対して線型なので、ラスタライザーは深度を画面空間の普通の重みで補間出来るのです。

全ての深度の規約に使える1つの式

ニア平面の深度をδn、ファー平面の深度をδfにしたいとします。
条件が2つ、未知数が2つです:

z=−n:−An+Bn=δn,z=−f:−Af+Bf=δf

分母を払うと−An+B=δnnと−Af+B=δffで、2つ目から1つ目を引くと−A(f−n)=δff−δnnです。
Aを1つ目の式に戻します:

A=−δff−δnnf−n,B=nf(δn−δf)f−n

此れで、どの規約も代入1回で求まります:

規約 δn、δf A B
OpenGL −1、1 −f+nf−n −2fnf−n
Vulkan、Direct3D、Metal 0、1 −ff−n −fnf−n
リバースZ 1、0 nf−n fnf−n

最初の2行はperspectiveGlとperspectiveVkの成分です(線型代数の第12章ではVulkanの物をfn−fとfnn−fと書いていますが、同じ値です)。
3行目のリバースZはニア平面を1に、ファー平面を0にします。何故そんな事をしたいのかは19.4節で説明します。

計算例。 第6章6.2節の頂点はカメラ座標(2,1,−4)にあるのでd=4で、n=1、f=11です:

  • OpenGL:A=−1210=−1.2、B=−2210=−2.2、zclip=−1.2⋅(−4)−2.2=2.6、zndc=2.64=0.65で、深度バッファには0.65+12=0.825が入ります。
  • Vulkan:A=B=−1110=−1.1、zclip=4.4−1.1=3.3、深度は3.34=0.825で、同じ値です。
  • リバース:A=110=0.1、B=1110=1.1、zclip=−0.4+1.1=0.7、深度は0.74=0.175=1−0.825です。

一般に、リバースの深度は通常の0〜1の深度を1から引いた物と丁度等しくなります。同じ平面に、逆から番号を振っただけです。

yの反転

3つのAPIは、yがどちら向きかでも意見が分かれます。
OpenGL、Direct3D、MetalはNDCのyが上向き、Vulkanは下向きです。
Vulkanでは、yの行の符号を反転するか(perspectiveVkの−cが其れです)、yの行は其の儘にしてビューポートの高さを負にするかのどちらかで、両方はしません(線型代数の第12章の12.7節)。
反転は像を鏡に映すので、時計回りと反時計回りが入れ替わります。背面除去に使う表面の向きの設定が其れと合っているか確かめて下さい。

無限遠のファー平面

f→∞にするとどうなるでしょうか?
分子と分母をfで割り、nf→0とします:

規約 A B 距離dでの深度
OpenGL −1 −2n zndc=1−2nd
Vulkan、Direct3D −1 −n 1−nd
リバースZ 0 n nd

何も壊れません。ファー平面が無限遠に移るだけで、無限遠の点(カメラ空間で同次座標のw成分が0の方向)は丁度深度1に、リバースZなら0に来ます。
リバースの無限遠版は此の章で一番美しい行列です。zの行が定数zclip=nなので、保存される深度は丁度n/dです。
距離4の頂点でn=1なら0.25です。
無限遠の投影は第11章のZフェイル法のシャドウボリュームを頑丈にし(無限遠の蓋が切り取られない)、広い屋外のシーンにも向いています。

同じ枠組みで見る正射影

正射影の一番下の行は(0,0,0,1)なのでw=1で、何も割りません。
従ってzの行zndc=αz+βは距離に対して線型で、同じ2つの条件から次が得られます:

α=−δf−δnf−n,β=δn+αn

OpenGLではα=−2f−n、β=−1+αn=−f+nf−nで、orthographicGlの成分です。
深度が線型なら全ての距離が同じ精度を持つので、歩幅が一様な深度フォーマット(固定小数点)が一番合っています。反転しても言う程の得はありません。
其の為、太陽用の正射影のシャドウマップ(第11章)は19.4節の距離の問題に悩まされません。

19.2 逆投影

何故逆向きに辿るのか?

多くのエフェクトは深度バッファから出発し、ピクセルが3次元の何処にあるかを知る必要があります。
ディファードシェーディングは、法線や色は持つが位置は持たないGバッファでライティングをし(第17章)、画面空間のアンビエントオクルージョンは深度バッファの中を進み(第24章)、フォグや投影するデカールは各ピクセルの下の位置を必要とします。
深度から復元すれば、1ピクセルあたり12バイトの代わりに数命令で済みます。

行列を元に戻す

透視投影行列は次の形をしています:

𝐏=[sx0000sy0000AB00−10],𝐏−1=[1sx00001sy00000−1001BAB]

(掛けて確かめられます。𝐏の3行目と𝐏−1の最後の2列の積はA⋅0+B⋅1B=1と−A+B⋅AB=0です。)
𝐏−1はB≠0である限り存在し、上の全ての透視投影行列で其れは成り立ちます(Bは因数nを含みます)。

ピクセルから戻るには、先ずビューポート変換を元に戻してNDCを求め、次に𝐏−1を掛け、最後に結果のwで割ります。𝐏−1が返すのは同次座標の点だからです。

計算例。 第6章で、頂点は800×600のウィンドウ(行は上から数える)のピクセル(550,225)に、深度0.825で描かれました(OpenGL、n=1、f=11、90°、アスペクト比4/3)。
ビューポート変換を元に戻します:

xndc=2⋅550800−1=0.375,yndc=1−2⋅225600=0.25,zndc=2⋅0.825−1=0.65

sx=0.75、sy=1、A=−1.2、B=−2.2なので、𝐏−1の最後の行は(0,0,−12.2,1.22.2)です:

𝐏−1[0.3750.250.651]=[0.375/0.750.25−1−0.652.2+1.22.2]=[0.50.25−10.25]

w=0.25で割ると(2,1,−4)で、出発したカメラ座標に戻りました。
3番目の成分の−1に注目して下さい。𝐏−1は除算の前には常にz=−1を出し、w=1/dで割る事で其れが−dになります。

近道:線型な深度とレイの積

書き下すと、同じ計算は3つの式だけです。
先ず深度から距離を求めます(zndc=−A+B/dをdについて解きます):

d=Bzndc+A

此の数値ではd=−2.20.65−1.2=−2.2−0.55=4です。
次にNDCの位置からxとyを求めます。xndc=sxx/dなので:

x=xndcdsx=0.375⋅40.75=2,y=yndcdsy=1,z=−d=−4

幾何学的には、(xndcsx,yndcsy,−1)はzが−1になる様に伸縮したピクセルのレイの方向で、位置は其のレイに距離を掛けた物です。
エンジンは其のレイをピクセルごとに1回(又は全画面三角形の頂点ごとに計算してラスタライザーに補間させて)求めておき、後は距離だけを使う事がよくあります。
リバースの無限遠投影では距離が一番簡単です。深度がn/dなのでd=n/depthです。

コード

linalg.hhと同じ書き方で書いたリバースの行列とリバースの無限遠の行列(ヘッダーには含まれません)を第6章の頂点に適用し、続けてヘッダーのinverseで復元します:

#include <cstdio>
#include "linalg.hh"

// Reversed Z, depth 0..1 (Vulkan/Direct3D style, y down): near -> 1, far -> 0.
inline Mat4 perspectiveVkReversed(float fovY, float aspect, float n, float f) {
  float t = 1.0f / std::tan(fovY * 0.5f);
  Mat4 m;
  m.at(0, 0) = t / aspect;
  m.at(1, 1) = -t;
  m.at(2, 2) = n / (f - n);
  m.at(2, 3) = (f * n) / (f - n);
  m.at(3, 2) = -1.0f;
  return m;
}

// Reversed Z with the far plane at infinity: the stored depth is exactly n / distance.
inline Mat4 perspectiveVkInfiniteReversed(float fovY, float aspect, float n) {
  float t = 1.0f / std::tan(fovY * 0.5f);
  Mat4 m;
  m.at(0, 0) = t / aspect;
  m.at(1, 1) = -t;
  m.at(2, 3) = n;
  m.at(3, 2) = -1.0f;
  return m;
}

void show(const char *name, const Mat4 &p, Vec4 v) {
  Vec4 c = p * v;
  printf("%-18s clip (%g, %g, %g, %g)  z/w %g\n", name, c.x, c.y, c.z, c.w, c.z / c.w);
}

int main() {
  const float pi = 3.14159265f;
  Vec4 cam{2, 1, -4, 1}; // the vertex of chapter 6 in camera space
  show("OpenGL", perspectiveGl(pi / 2, 4.0f / 3.0f, 1, 11), cam);
  show("Vulkan", perspectiveVk(pi / 2, 4.0f / 3.0f, 1, 11), cam);
  show("reversed", perspectiveVkReversed(pi / 2, 4.0f / 3.0f, 1, 11), cam);
  show("infinite reversed", perspectiveVkInfiniteReversed(pi / 2, 4.0f / 3.0f, 1), cam);

  // back from pixel (550, 225) of an 800x600 window with depth 0.825 (OpenGL depth range 0..1)
  float px = 550, py = 225, depth = 0.825f;
  Vec4 ndc{2 * px / 800 - 1, 1 - 2 * py / 600, 2 * depth - 1, 1};
  Vec4 v = inverse(perspectiveGl(pi / 2, 4.0f / 3.0f, 1, 11)) * ndc;
  printf("P^-1 * ndc = (%g, %g, %g, %g)\n", v.x, v.y, v.z, v.w);
  printf("camera     = (%g, %g, %g)\n", v.x / v.w, v.y / v.w, v.z / v.w);
  return 0;
}

OpenGL             clip (1.5, 1, 2.6, 4)  z/w 0.65
Vulkan             clip (1.5, -1, 3.3, 4)  z/w 0.825
reversed           clip (1.5, -1, 0.7, 4)  z/w 0.175
infinite reversed  clip (1.5, -1, 1, 4)  z/w 0.25
P^-1 * ndc = (0.5, 0.25, -1, 0.25)
camera     = (2, 1, -4)

シェーダーで書くのは近道の方です。
リバースの無限遠Zを使うVulkanのレンダラーで、画面全体で0〜1になるテクスチャ座標uv(VulkanのNDCと同じくyは下向き)と、行列の対角成分の2つscale =(c/a,c)を使うと:

// depth: reversed infinite Z, so depth = n / distance
float3 viewPosFromDepth(float2 uv, float depth, float n, float2 scale) {
  float d = n / depth;                      // distance in front of the camera
  float2 ndc = uv * 2.0 - 1.0;              // Vulkan NDC, y down
  return float3(ndc.x * d / scale.x, -ndc.y * d / scale.y, -d);
}
// depth: reversed infinite Z, so depth = n / distance
vec3 viewPosFromDepth(vec2 uv, float depth, float n, vec2 scale) {
  float d = n / depth;                      // distance in front of the camera
  vec2 ndc = uv * 2.0 - 1.0;                // Vulkan NDC, y down
  return vec3(ndc.x * d / scale.x, -ndc.y * d / scale.y, -d);
}
// depth: reversed infinite Z, so depth = n / distance
float3 viewPosFromDepth(float2 uv, float depth, float n, float2 scale) {
  float d = n / depth;                      // distance in front of the camera
  float2 ndc = uv * 2.0 - 1.0;              // Vulkan NDC, y down
  return float3(ndc.x * d / scale.x, -ndc.y * d / scale.y, -d);
}

yのマイナス記号はVulkanの行列の−cを元に戻します。
何も描かれていない背景はクリアした深度0を持ち、距離が無限大になってしまうので、実際のコードでは先に其れを判定します。

19.3 同次座標のクリップ空間でのクリッピング

クリッピングが無いと何が起きるか

第6章の6.3節では、カメラの後ろまで伸びる線分をクリッピングし、先に割ると遠い方の端が間違った所に行く事を見ました。
此処では其の理由をもう少し詳しく見てから、三角形全体をクリッピングします。

原因はwによる除算です。
カメラの前の点はw=d>0です。視点を通る平面上の点はw=0で、0で割る事になります。カメラの後ろの点はw<0で、除算でxとyの符号が反転し、画面の中心を挟んだ反対側に映ります。
前の点から後ろの点への線分はw=0を通り、其処で投影は無限遠に飛んで行きます。除算の後、2つの端は本当に通る無限遠ではなく、線分が覆っていない画面の部分を通って「逆回り」に繋がれてしまいます。
投影された3つの角しか知らないラスタライザーは、間違った領域を塗ります。
除算の前にクリッピングすれば、ニアの距離よりwが小さい点は全て取り除かれるので、除算がw≤0に出会う事はありません。

ニア平面でのクリッピングの計算例

第6章のOpenGLの行列をもう一度使います(90°、アスペクト比4/3、n=1、f=11:sx=0.75、sy=1、A=−1.2、B=−2.2)。
高さy=−0.5の床の三角形がカメラの下まで伸びています:A(−2,−0.5,−5)、B(2,−0.5,−5)、C(0,−0.5,1)で、Cは視点の1単位後ろです。
各頂点はテクスチャ座標も持ちます:uA=0、uB=1、uC=0.5。
クリップ座標は:

𝐀c=(−1.5,−0.5,3.8,5),𝐁c=(1.5,−0.5,3.8,5),𝐂c=(0,−0.5,−3.4,−1)

OpenGLのニア平面はzc≥−wcなので、判定に使う符号付きの値はδ=zc+wcです:

δA=3.8+5=8.8,δB=8.8,δC=−3.4−1=−4.4

Cは外側、AとBは内側です。
サザーランド・ホッジマン法(6.3節)で辺A→B→C→Aを順に辿ります:

  • A→B:Aは内側なのでAを出力。Bも内側なので交差はありません。
  • B→C:Bを出力。Bは内側でCは外側なので、辺はt=δBδB−δC=8.813.2=23で交差します:𝐈1=𝐁c+23(𝐂c−𝐁c)=(1.5−1,−0.5,3.8−4.8,5−4)=(0.5,−0.5,−1,1)を出力。
  • C→A:Cは外側なので何も出力しません。辺はCからt=δCδC−δA=−4.4−13.2=13で交差します:𝐈2=𝐂c+13(𝐀c−𝐂c)=(−0.5,−0.5,−1,1)を出力。

三角形は四角形A,B,I1,I2になり、GPUは其れを2つの三角形として描きます。
新しい2つの頂点はzc=−wcなので、NDCの深度は−1、つまり丁度ニア平面上にあります。
属性は同じtで補間します:uI1=1+23(0.5−1)=23、uI2=0.5+13(0−0.5)=13。

クリップ空間での線型補間は属性に対して正しいのでしょうか?
正しいです。クリップ座標はカメラ座標の線型な関数(行列を1回掛けるだけ)なので、クリップ空間の線分上のパラメータtの点は、カメラ空間の線分上の同じtの点です。そして3次元の三角形の上で線型に変わる属性は、其の点で其の値を取ります。
確かめると、カメラ空間で𝐁+23(𝐂−𝐁)=(2−43,−0.5,−5+4)=(23,−0.5,−1)で、ニア平面z=−1の上にあります。
線型補間が正しくなくなるのは除算の後だけで、其れが19.5節の話題です。

割ってから、素朴な方法と比べます:

左:視点の後ろにあるCを含む床の三角形と、クリッピング後の四角形を上から見た図。右:画面上のクリッピング後の四角形と、先に割って出来る間違った三角形

クリッピング後の四角形のNDCの角は(−0.3,−0.1)、(0.3,−0.1)、(0.5,−0.5)、(−0.5,−0.5)です。足元まで伸びる床として当然、画面の下に向かって広がっています。
先にCを割ると(0−1,−0.5−1)=(0,0.5)で、中心の上に来ます。ラスタライザーは空に向かって尖った三角形を描いてしまいます。

クリッパー自体は、δを内側の判定に使い、全ての属性を同じtで補間する、第6章のサザーランド・ホッジマン法のループ其の物です。
頂点が1つ外にあれば三角形は四角形(三角形2つ)になり、2つ外にあれば三角形の儘です。
Vulkan及びDirect3Dの規約ではニア平面はzc≥0なので、符号付きの値は単にδ=zcです。リバースZではニア平面がzc=wcなのでδ=wc−zcです。

ガードバンド、深度クランプ、クリッピング無しのラスタライズ

クリッピングは新しい頂点を作り、新しい頂点には時間が掛かるので、GPUは出来るだけ避けます。
4つの側面に対しては、殆どクリッピングしません。ラスタライザーがビューポートの外のピクセルを訪れないだけです(19.5節のバウンディングボックスとタイルなら其れはタダです)。
限界はラスタライザーの数値の形式です。位置は固定小数点に変換されるので(19.5節)、頂点は画面のずっと外にあっても構いませんが、どれだけでも遠くて良い訳ではありません。
ラスタライザーがまだ表せる領域がガードバンドで、ビューポートよりずっと大きく、其れからはみ出す稀な三角形だけが幾何学的にクリッピングされます。
ニア平面は此の方法では扱えません。其処での問題は大きな座標ではなくw≤0だからです。

ニア平面とファー平面には、もう1つの選択肢があります。OpenGLとVulkanには深度クランプ(GL_DEPTH_CLAMP、VulkanのdepthClampEnable)があり、ニアとファーのクリッピングを止めて、代わりに深度を範囲内にクランプします。
シャドウマップは、光源とシャドウマップのニア平面の間にある遮蔽物を失わない為に其れを使います。遮蔽物はニア平面に「押し潰され」、其れでも影を落とします。

(19.5節のエッジ関数を同次座標の(x,y,w)から直接組み立てれば、クリッピングを全く省く事も出来ます。1997年にOlanoとGreerが述べた方法で、結果の画像は同じです。)

19.4 深度精度

精度は何処に行くか

19.1節で、保存される深度が1/dのアフィン関数である事を証明しました。
0〜1の範囲では:

D(d)=ff−n(1−nd),d(D)=nff−D(f−n)

2つ目の式、つまり逆関数は、各深度の値が何処にあるかを教えてくれます。
n=1、f=11(第6章の設定)では、保存される深度が0.1、0.2、…、0.9になる平面は次の距離にあります:

1.1,1.222,1.375,1.571,1.833,2.2,2.75,3.667,5.5

深度の値の半分は距離d(0.5)=116≈1.83までに使い切られ、最後の10%の値で5.5から11までの全てを賄わなければなりません:

距離1から11までの視錐台を横から見た図。深度バッファの値が0.1から0.9になる9枚の平面はニア平面の近くに密集し、深度バッファの範囲では同じ平面が等間隔に並ぶ

保存される値の1段は、どれだけの距離に当たるのでしょうか?
微分するとdDdd=fn(f−n)d2なので、1段ΔDは次の距離の幅になります:

Δd≈ΔD⋅(f−n)d2fn

第10章の10.3節の式です。1段は距離の2乗で大きくなり、nに反比例します。
第10章が暗黙に仮定していたのは、ΔDがどこでも同じという事です。
其れが成り立つ深度フォーマットもあれば、成り立たない物もあります。

固定小数点と浮動小数点

深度バッファには2つの系統があります:

  • 固定小数点(UNORM)フォーマットは0から2N−1までの整数kを保存し、D=k/(2N−1)を意味します。1段はどこでも同じで、16ビットならΔD=165535≈1.5⋅10−5、24ビットなら1224−1≈5.96⋅10−8です(24ビットは普通8ビットのステンシル値と一緒に詰めます)。
  • 浮動小数点フォーマット(32ビットfloat、VulkanのD32_SFLOAT)は符号、8ビットの指数、23ビットの仮数を保存します。1段は値に依存し、2eと2e+1の間では2e−23です。従って0.5と1の間では1段は2−24≈5.96⋅10−8で24ビット固定小数点と同じですが、0.001と0.002の間では約1.2⋅10−10で、0に向かって更に小さくなり続けます。

要するに、floatは相対的な精度が一定です。1段は常に値其の物の2−24から2−23倍の間です。
では2つの非線型性を組み合わせてみましょう。
通常の対応では、遠くはDが1に近い所で、1の近くではfloatは24ビット固定小数点より良くありません。
floatの余分な精度は全てD=0の近く、つまりニア平面の直ぐ前にあり、其処では1/dの対応が既に必要以上の精度を与えています。
通常のZで32ビットfloatのバッファを使うのは、8ビットの無駄です。

リバースZ

今度は対応を反転させます。ニア平面を1に、ファー平面を0にします。
1/dによって深度の1段が粗くなる遠くでは、保存される値が0に近く、其処ではfloatの1段がとても小さくなります。
2つの効果が打ち消し合います。其の度合いは、D=n/dとなるリバースの無限遠投影で正確に分かります:

|dDdd|=nd2=Dd⇒Δd=ΔDDd≈2−24d to 2−23d

距離の1段は距離の一定の割合、約6⋅10−8から1.2⋅10−7倍で、全ての距離でそうなり、もうnにすら依存しません。
(ファー平面が有限なら、同じ導出でΔd≈2−24…2−23⋅d(1−d/f)となり、fの近くでは更に少し良くなります。)
此れは距離其の物をfloatで持った時と同じ種類の精度で、望み得る最善です。

比較の計算例。 第10章の設定、n=0.1 m、f=1000 mで、隣り合う保存値の間の実際の距離です(保存値と次に表せる値を取り、両方を距離に戻して計算しました):

距離 16ビットUNORM 24ビットUNORM float32、通常 float32、リバース
1 m 0.15 mm 0.6 µm 0.6 µm 0.07 µm
10 m 15 mm 0.06 mm 0.06 mm 0.9 µm
100 m 1.55 m 6.0 mm 6.0 mm 5.8 µm
500 m 38 m 15 cm 15 cm 18 µm

1つ手で確かめましょう。
100 mで24ビット固定小数点なら:5.96⋅10−8⋅999.9⋅10021000⋅0.1=5.96⋅10−8⋅99990≈0.0060 mで、第10章の6 mmです。
リバース:D=0.1⋅900100⋅999.9≈0.0009001は2−11≈0.000488と2−10≈0.000977の間にあるので、floatの1段は2−11−23=2−34≈5.8⋅10−11です。傾きfn(f−n)d2=100999.9⋅104≈1.0⋅10−5で割ると5.8⋅10−6 mです。
100 mで24ビットより約1000倍細かく、其の差は距離と共に広がります。
グラフは範囲全体を示します(リバースの曲線のノコギリの歯はfloatの指数の段です):

深度の1段を距離に対して描いた両対数グラフ。16ビットと24ビットのUNORM、24ビットUNORMと重なる通常Zのfloat32、遥かに下にあるリバースZのfloat32

誤差の原因は保存形式だけではありません(頂点シェーダーのfloatの計算も丸め、通常の対応ではzc/wcがほぼ等しい2つの数の割り算になります)。其処でもリバースZは役に立ち、無限遠版ではzc=nが正確です。
そしてリバースのfloat深度では、ニア平面の位置はもう殆ど問題にならないので、其れを使うエンジンはニア平面を数センチメートルにする余裕があります。

設定の仕方

リバースZには、4つの事が揃っている必要があります:

  • 浮動小数点の深度フォーマット(VulkanのD32_SFLOAT、Direct3DのDXGI_FORMAT_D32_FLOAT、OpenGLのGL_DEPTH_COMPONENT32F)。
  • 19.1節のリバースの投影行列(有限でも無限遠でも)。
  • 深度バッファを1ではなく0でクリアする事。
  • 深度テストを「より小さい」ではなく「より大きい」(又は「以上」)に反転する事。

更に0〜1の深度範囲も必要です。
Vulkan、Direct3D、Metalは標準で其の範囲です。
OpenGLの標準の範囲は−1〜1で、其処で反転しても得はありません。遠くの値はfloatが粗いzndc=−1の近くに来て、保存値への変換zndc+12が其れに1を足すので、小さな差はバッファに届く前に消えてしまいます。
OpenGL 4.5からは、glClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE)でOpenGLを0〜1の範囲に切り替えられます。
深度を読む他の全ての物も反転が必要です:シャドウマップの比較、階層Z(19.5節)、ポリゴンオフセット(後述)、19.2節の「背景かどうか」の判定。
深度テストはOpenGLの第15章で自分で扱います。

対数深度について簡単に

相対精度を一定にするもう1つの方法は、距離の対数を保存する事です。例えば:

D=ln(d/n)ln(f/n)

するとdDdd=1dln(f/n)で、一様な1段ΔDは距離の1段Δd=dln(f/n)ΔDになり、やはり距離に比例します。
24ビット固定小数点、n=0.1、f=1000ではln(104)≈9.21なので、Δd≈d⋅9.21⋅5.96⋅10−8≈5.5⋅10−7d、100 mで0.055 mmです。
100 mでは通常の24ビット深度より約100倍良いのですが、リバースのfloatより約10倍悪く、代償もあります。対数は射影的な行列からは出て来ないので、深度はもう画面に対して線型ではありません。
頂点ごとに書けば大きな三角形の上で間違って補間され、フラグメントごとに書けば(gl_FragDepth、SV_Depth)19.5節のアーリーZが無効になります。
リバースのfloat深度が使えるなら、普通はそちらの方が良い選択です。

デプスバイアスとポリゴンオフセット

完璧な精度があっても、同じ平面上にある2つの面は区別出来ません。
デカール(弾痕、路面標示、ポスター)は壁の丁度上に2枚目のポリゴンとして描かれます。2つの三角形の深度は別々の頂点から別々の丸めで補間されるので、各ピクセルで勝者がでたらめに決まり、どんな距離でもZファイティングが起きます。
第11章(11.4節)のシャドウアクネは、面と、シャドウマップ内の其の面自身の深度の間で起きる同じ問題です。
ハードウェアによる対策がデプスバイアス(OpenGLではポリゴンオフセット)で、ポリゴンの全てのフラグメントの深度に値oを足します:

o=m⋅slopeFactor+r⋅constantFactor,m=max(|∂D∂x|,|∂D∂y|)

必要なら最大値でクランプします。
mはウィンドウ空間でのポリゴンの深度の傾き(1ピクセルで深度がどれだけ変わるか。仕様は2乗和の平方根で定義し、此のmaxを近似として認めています)で、rは深度フォーマットで区別出来る最小の差です。Nビットの固定小数点なら2−N(又は其の2倍まで)、floatのフォーマットなら2e−23で、eはポリゴンの深度の値の最大の指数です。
パラメータは、OpenGLではglPolygonOffset(factor, units)、VulkanではdepthBiasSlopeFactor、depthBiasConstantFactor、depthBiasClamp、Direct3DではSlopeScaledDepthBias、DepthBias、DepthBiasClampです。

何故傾きの項があるのでしょうか?
同一平面上の2つの三角形の丸めの差は、1ピクセルで深度がどれだけ速く変わるかに比例するからです。
計算例。 後の19.5節のミップマップの例と同じ床を、1 m上から約9.8 mの距離で見ます。n=0.1、f=1000、24ビットのバッファです。
画面上で1行上に行くと床の点はd=9.524 mからd=9.836 mに移るので、保存される深度は次だけ変わります:

m=D(9.836)−D(9.524)≈3.33⋅10−4 per pixel

定数項だけのr=2−24≈6⋅10−8は、其れより5,600倍小さいです。此の2つの距離の間での傾きdDdd≈1.07⋅10−3(1メートルあたり)を使うと、其の1単位はデカールを僅か0.06 mmしか動かさず、mの何分の一かの丸めの差に勝つには足りません。
傾きの係数1はデカールをm、距離にして約0.31 m動かします。此の床の1ピクセルが跨ぐ深度の幅です。
其れでちらつきは止まりますが、デカールの後ろ0.31 m以内(視線に沿って)に立っている物体はデカールに上書きされ得ます。其の為にクランプがあり、エンジンが代わりにデカールを画面空間で、19.2節の復元した位置を使って投影する事が多いのも其の為です。

符号に注意して下さい。
通常のZでは近い程小さいので、デカールには負のオフセットが要ります(glPolygonOffset(-1, -1)は定番です)。一方シャドウマップには、保存する深度を遠ざける正のオフセットが要ります。リバースZではどちらの符号も反転します。

19.5 ラスタライズの詳細

エッジ関数は線型関数

第6章の6.9節では、有向辺𝐚→𝐛のエッジ関数Eab(𝐩)=(bx−ax)(py−ay)−(by−ay)(px−ax)を紹介しました。辺の左側で正です(yは上向き)。
展開すると、ピクセルの位置の普通の線型関数です:

Eab(x,y)=αx+βy+γ,α=ay−by,β=bx−ax,γ=axby−aybx

其の零点の集合は辺を通る直線で、(α,β)は其の直線の、三角形の内側を向く法線です。
第6章の三角形A(1,1)、B(9,1)、C(3,7)では:

EBC=−6x−6y+60,ECA=6x−2y−4,EAB=8y−8

ピクセルの中心(4.5,2.5)では−27−15+60=18、27−5−4=18、20−8=12で、第6章の値です。
3つの関数を足すと、xとyの項は消えて60−4−8=48が残ります。此れはEAB(C)、つまり面積の2倍です。
3つの重心座標の和が常に1になるのは其の為です。

線型性がラスタライズを安くしています。
1ピクセル右に進むとαを足し、1ピクセル上ならβを足し、kピクセル右ならkαを足します。
行y=2.5に沿って、EBCはx=0.5で42から始まり、36、30、24、18、…と1回の足し算ずつ進みます。
1988年にJuan Pinedaが並列ハードウェア向けに提案したのが正に此の形です。各ピクセルの判定は互いに独立なので、αとβの異なる倍数を足せば、多くのピクセルを同時に判定出来ます。

共有する辺でのトップレフトルール

第6章ではトップレフトルールを述べ、1つの三角形に適用しました。
本当の役目は三角形同士の間にあるので、肝心な所で試しましょう:(1.5,0.5)から(7.5,6.5)までの6 × 6の正方形を対角線でT1=P,Q,R(右下)とT2=P,R,S(左上)に分けます。どちらも反時計回りです。
角はわざとピクセルの中心に置いてあります。各辺に7つ、対角線に7つの中心が乗る、最悪のケースです。
メッシュでは全ての辺の反対側は隣の三角形の物なので、正しい規則なら正方形に丁度面積分の36ピクセルを与える筈です。

辺を分類します(yは上向き、反時計回り:左辺は下向きに進み、上辺は水平で左向きに進みます):

  • T1:P→Qは右向き(下辺、該当せず)、Q→Rは上向き(右辺、該当せず)、R→Pは下向き(左辺、該当)。
  • T2:P→Rは上向き(右辺、該当せず)、R→Sは水平で左向き(上辺、該当)、S→Pは下向き(左辺、該当)。

共有する対角線はT1の左辺でT2の右辺なので、其の7つの中心はT1の物になり、両方の物には決してなりません。
正方形の左側と上側は自分の物、右側と下側は隣に任せます。
角さえ正しく決まります。S(左上の角)はT2の自分の物である2辺の上にあるので描かれ、P、Q、Rはどれも自分の物でない辺に少なくとも1つ乗っているので、隣の正方形に任されます。

対角線で分けた6 × 6の正方形の3通りのラスタライズ。全ての辺で以上なら56フラグメントで7つが2回描かれ、全ての辺で厳密に大きいなら20フラグメントで16個が抜け、トップレフトルールなら丁度36

数(下のプログラムと図のスクリプトによる):

  • 全て「≥ 0」:各三角形が28の中心を取り、36ピクセルの正方形に56フラグメント。対角線の7ピクセルは2回描かれます(半透明では間違いで、仕事も無駄)。
  • 全て「> 0」:10 + 10 = 20フラグメント。対角線の内部の5つの中心は誰の物でもなく(隙間)、メッシュの中では正方形の辺上の24の中心も隙間になります。
  • トップレフト:15 + 21 = 36で、全てのピクセルが丁度1回。

Vulkanの仕様が求めるのは、2つのポリゴンが共有する辺の上のサンプルが其の丁度一方に覆われる事だけで、OpenGLも同じ事を言っています。Direct3Dは其れを実現する方法としてトップレフトルールを定めています。
もう1つ細かい点として、Direct3Dは此の規則をyが下向きの自分の画面空間で定義しており、其処では「上」はyが一番小さい事を意味します。
此処や第6章の様にyが上向きなら、同じ規則は上の通りになります。

サブピクセル精度と固定小数点へのスナップ

トップレフトルールは、E=0かどうかを正確に判定出来る事と、共有する辺の2つの三角形が丁度反対の値Eab(𝐩)=−Eba(𝐩)を見る事に依存しています。
浮動小数点ではどちらも保証されません。Eabは𝐩から𝐚を引き、Ebaは𝐛を引くので、積の丸め方が違います。
32ビットfloatでの実例です:(1355.8608,648.63708)から(235.1944,120.04005)への辺と、ピクセルの中心(524.5,256.5)。
Eabの正確な値は約−0.0056です(中心は辺の僅かに右にあり、隣だけが描くべきです)。ところがfloat32ではEabは+0.03125、Ebaは+0.015625になります。
両方の三角形が正の値を見て、両方が其のピクセルを描きます。

其の為、ラスタライザーは此処でfloatを使いません。
ビューポート変換の後、各頂点の位置は固定小数点の格子にスナップされ、エッジ関数は正確な整数で評価されます。
Direct3Dは16.8固定小数点(小数部8ビット、位置は1/256ピクセル刻みで最も近い値に丸める)を要求し、Vulkanはデバイスの小数部のビット数をsubPixelPrecisionBits(4以上)で報告します。
計算例。 x=3.14159 pxの頂点は3.14159⋅256=804.247→804、つまり804256=3.140625 pxになります。動いたのは0.00097 pxで、1/256への丸めの最大誤差1512≈0.00195 pxより小さいです。
整数は大きくなります(エッジ関数は座標の差を2つ掛けるので、4096ピクセルと小数部8ビットなら積は242まで)。ガードバンドが無限に出来ない理由の1つです。

スナップには目に見える副作用もあります。
小さな三角形は面積0に潰れたり(其の時は捨てられます)、回る向きが反転したりする事さえあります。細長い三角形は少しずれます。そして各頂点は1回だけスナップされるので、同じ頂点は常に同じ格子点に来ます。此れが共有する辺に隙間を作らない鍵です。
整数を使うと、トップレフトルールは定数1つになります。辺がトップレフトでなければ其のγから1(格子の1単位)を引き、3辺全てで単純にE≥0を判定すれば良いのです。
スナップとインクリメンタルな評価を備えた其のラスタライザーで、正方形の例を描きます:

#include <cmath>
#include <cstdint>
#include <cstdio>
#include "linalg.hh"

const int SUB = 256; // 8 fractional bits: positions in 1/256 pixel

struct IPoint {
  int64_t x, y;
};

IPoint snap(Vec2 p) { return {std::llround(p.x * SUB), std::llround(p.y * SUB)}; }

// Edge function as a linear function E(x, y) = A x + B y + C (y up, counter-clockwise inside)
struct Edge {
  int64_t A, B, C;
};

Edge makeEdge(IPoint a, IPoint b) {
  Edge e{a.y - b.y, b.x - a.x, a.x * b.y - a.y * b.x};
  // top-left rule: a left edge goes down, a top edge is horizontal and goes left.
  // Other edges must not own centers with E == 0, so we subtract 1 (one unit of SUB * SUB).
  bool topLeft = b.y < a.y || (b.y == a.y && b.x < a.x);
  if (!topLeft) e.C -= 1;
  return e;
}

int rasterize(Vec2 v0, Vec2 v1, Vec2 v2, char mark, char grid[8][10]) {
  IPoint a = snap(v0), b = snap(v1), c = snap(v2);
  Edge e[3] = {makeEdge(b, c), makeEdge(c, a), makeEdge(a, b)};
  int count = 0;
  for (int y = 0; y < 8; ++y) {
    // value at the first pixel center of the row, then add A per pixel (incremental evaluation)
    int64_t px = SUB / 2, py = y * SUB + SUB / 2;
    int64_t w[3];
    for (int k = 0; k < 3; ++k) w[k] = e[k].A * px + e[k].B * py + e[k].C;
    for (int x = 0; x < 10; ++x) {
      if (w[0] >= 0 && w[1] >= 0 && w[2] >= 0) {
        grid[y][x] = mark;
        ++count;
      }
      for (int k = 0; k < 3; ++k) w[k] += e[k].A * SUB;
    }
  }
  return count;
}

int main() {
  char grid[8][10];
  for (auto &row : grid)
    for (char &ch : row) ch = '.';
  Vec2 p{1.5f, 0.5f}, q{7.5f, 0.5f}, r{7.5f, 6.5f}, s{1.5f, 6.5f};
  int n1 = rasterize(p, q, r, '1', grid); // lower-right triangle
  int n2 = rasterize(p, r, s, '2', grid); // upper-left triangle, shares the diagonal p-r
  for (int y = 7; y >= 0; --y) printf("%.10s\n", grid[y]);
  printf("triangle 1: %d, triangle 2: %d, total %d\n", n1, n2, n1 + n2);
  return 0;
}

..........
.222222...
.222221...
.222211...
.222111...
.221111...
.211111...
..........
triangle 1: 15, triangle 2: 21, total 36

走査:バウンディングボックスとタイル

ラスタライザーはどのピクセルを判定するのでしょうか?
一番単純な答えは、第6章の様に三角形のバウンディングボックス内の全てのピクセルの中心です。
太った三角形なら其れで良いのですが、細長い三角形では大損です。三角形(0,0)、(32,4)、(32,5)の面積は12|32⋅5−4⋅32|=16ピクセルで、バウンディングボックスは32⋅5=160なので、判定の10回に9回は無駄です。

其の為GPUは画面を、例えば4 × 4や8 × 8ピクセルのタイル単位で辿り、先ずタイル全体を各辺に対して判定します。
エッジ関数は線型なので、長方形の上での最大値と最小値は角の2つで取ります(どの2つかはαとβの符号だけで決まります)。
タイルごとに3つの場合があります:

  • 自明な棄却:ある辺が4つの角全てで負。タイルのどのピクセルも内側にはあり得ないので飛ばします。
  • 自明な受理:3辺全てが4つの角全てで負でない。全てのピクセルが内側なので、ピクセルごとの判定無しにタイルを塗ります。
  • 部分:其れ以外。タイルの各ピクセルを今まで通り判定します。

計算例。 4 × 4のタイル(60個)に分けた40 × 24ピクセルの画面と、面積382ピクセルの三角形A(1,1)、B(39,7)、C(13,23):

4 × 4のタイルに分けた40 × 24ピクセルの格子上の三角形ABC。20個のタイルは自明に棄却、12個は自明に受理、28個は部分で、部分タイルの中の覆われたピクセルに印を付けた

20個のタイルは角の判定1回ずつで棄却され、12個のタイル(192ピクセル)は1ピクセルも判定せずに塗られ、ピクセルごとの判定が必要なのは28個の部分タイルだけです。判定は28⋅16=448回で、バウンディングボックスなら38⋅22=836回です。
覆われたピクセルの中心は381個で、当然ながら面積382にとても近くなります。
実際のハードウェアは此れを階層的に行い(大きなタイル、次に小さなタイル)、タイルのピクセルを並列に判定します。
タイルベースのGPU(モバイルの殆ど、第17章)は更に一歩進んで、ラスタライズの前に全ての三角形を画面のタイルに振り分け、各タイルの色と深度を高速なオンチップメモリに置いた儘にします。

重心座標と属性の平面

エッジ関数を面積の2倍で割ると重心座標になります(6.9節)。エッジ関数はxとyについて線型なので、重心座標も、其れで補間する全ての属性も線型です。
三角形の上で線型に補間した値は平面v(x,y)=v0+vxx+vyyで、其の2つの傾きはエッジ関数の係数から直接求まります。
計算例。 第6章の三角形に深度DA=0.3、DB=0.5、DC=0.6を与えます。
上の係数を48で割るとλA、λB、λCの傾きになります。xについては−648、648、0、yについては−648、−248、848です。
従って:

∂D∂x=−0.125⋅0.3+0.125⋅0.5=0.025,∂D∂y=−0.125⋅0.3−248⋅0.5+848⋅0.6≈0.04167

ピクセル(4.5,2.5)で確かめます。(1,1)のDA=0.3から始めて0.3+3.5⋅0.025+1.5⋅0.04167=0.45、第6章の重みでは0.375⋅0.3+0.375⋅0.5+0.25⋅0.6=0.45です。
此の2つの傾きこそ、19.4節のポリゴンオフセットが使う物です。此の三角形ではm=max(0.025,0.04167)=0.04167です。
ハードウェアは三角形と属性ごとに1回此の様な平面を組み立て、後は足し算だけをします。

パースペクティブコレクト補間の導出

第6章と第11章では規則だけを述べました:vwと1wを画面上で線型に補間し、割る。
其の理由は次の通りです。

三角形のクリップ空間の頂点を𝐏i=(Xi,Yi,Zi,Wi)、i=1,2,3とします。
3次元の三角形上の点は、3次元の重心座標の重みαi(負でなく、∑αi=1)による組み合わせ∑αi𝐏iです。属性は3次元の三角形の上で線型に変わるので、此の重みこそが属性を正しく補間します:v=∑αivi。そして行列は線型なので、此の重みはカメラ空間でもクリップ空間でも同じです。
点の画面上の位置は、クリップ空間の位置を其のw、W=∑αiWiで割った物です:

xs=∑αiXiW

一方ラスタライザーが測るのは画面上の重みλiで、投影された角xi=Xi/Wiを使ってxs=∑λixiです:

xs=∑λiXiWi

2つを比べると∑αiWiW⋅XiWi=∑λiXiWiで、yについても同じです。
重みαiWiWの和は∑αiWiW=1で、同じ画面上の点を再現します。そして(退化していない)三角形の重心座標は一意なので:

λi=αiWiW⇒αi=λiWWi

此処で∑αi=1を使うとW∑λiWi=1、つまり:

1W=∑λi1Wi

1/wは画面上で線型です。
そして属性は:

v=∑αivi=∑λiviWi∑λi1Wi

此れが規則です。画面上で線型な2つの量∑λiviWiと∑λi1Wi、そしてピクセルごとに1回の割り算です。
深度の問題も此れで片付きます。保存される深度は−A+B⋅1W(19.1節、W=d)で、1Wのアフィン関数なので、此れも画面上で線型で、補正無しに普通のλiで補間されます。
リバースの無限遠投影では、保存される深度はnW其の物、つまり透視補正が補間する量其の物です。
一方、ビュー空間の距離W自体は画面上で線型ではありません。
(HeckbertとMoretonは1991年にテクスチャマッピングのハードウェアの為に同じ結果を導いています。)

計算例。 三角形の角のwが1、2、4で、テクスチャ座標が(u,v)=(0,0)、(1,0)、(0,1)です。
あるピクセルの画面上の重みはλ=(0.5,0.25,0.25)です。
素朴な補間ではu=0.25、v=0.25になります。
パースペクティブコレクトでは:

1w:0.5⋅1+0.25⋅12+0.25⋅14=0.5+0.125+0.0625=0.6875
uw:0.25⋅12=0.125,vw:0.25⋅14=0.0625
u=0.1250.6875=211≈0.182,v=0.06250.6875=111≈0.091,w=10.6875=1611≈1.455

3次元の重みはα=(0.5,0.125,0.0625)0.6875=(811,211,111)です。画面を広く占める手前の角が、此の点の半分ではなく、本当は811を持っているのです。
確認:∑αiwi=811+411+411=1611で、同じwです。

素朴な方法だとどう見えるでしょうか?
6 × 6の市松模様を貼った正方形の床のタイルを2つの三角形(赤い線が共有する対角線)で描き、角は手前がw=1、奥がw=4です:

2つの三角形で描いた透視投影の正方形の床のタイル。画面空間の重みでは市松模様が対角線で折れ、マス目が奥に行っても小さくならない。パースペクティブコレクト補間では列が奥に向かって縮み、線は真っ直ぐな儘

左では、各三角形が画面に平らに置かれているかの様にテクスチャが貼られます。マス目の列は奥に行っても小さくならず、2つの三角形の歪み方が違うので、線が対角線で折れ曲がります。初代PlayStationで有名な揺れです。
右では列が奥に向かって縮み、テクスチャの全ての線が真っ直ぐな儘です。射影的な写像では直線は直線でなければならないからです。

GPUは全てのvarying変数を標準でパースペクティブコレクトに補間しますが、シェーディング言語では選ぶ事も出来ます。
noperspectiveは画面上で線型に補間し(ワイヤーフレーム描画の為の三角形の辺までの距離の様な、画面空間の量に便利です)、GLSLのflatやSlang/HLSLのnointerpolationは1つの頂点の値を其の儘渡します(マテリアルIDの様な整数はflatでなければなりません):

struct VSOutput {
  float4 position : SV_Position;
  float2 uv : TEXCOORD0;                       // perspective-correct (the default)
  noperspective float2 screenUV : TEXCOORD1;   // linear on the screen
  nointerpolation uint materialId : TEXCOORD2; // one vertex's value, not interpolated
};
layout(location = 0) out vec2 uv;                      // perspective-correct (the default)
layout(location = 1) noperspective out vec2 screenUV;  // linear on the screen
layout(location = 2) flat out uint materialId;         // one vertex's value, not interpolated
struct VSOutput {
  float4 position : SV_Position;
  float2 uv : TEXCOORD0;                       // perspective-correct (the default)
  noperspective float2 screenUV : TEXCOORD1;   // linear on the screen
  nointerpolation uint materialId : TEXCOORD2; // one vertex's value, not interpolated
};

微分と2×2クアッド

第11章の11.7節では、テクスチャ座標の微分∂u∂x等からミップマップのレベルを選び、GPUは隣り合うピクセルを比べて其れを求めると述べました。
其の仕組みはこうです。
ラスタライザーはピクセルを1つだけシェーディングする事はありません。フラグメントを2 × 2のクアッドで出し、クアッドの4つのフラグメントシェーダーの起動は一緒に実行されます。
ddx(v)(Slang、HLSL)やdFdx(v)(GLSL)は、単にクアッドの右と左のピクセルのvの差で、ddy/dFdyは2つの行の差です。
クアッドの一部しか三角形に覆われていない時も、覆われていないピクセルはヘルパー起動としてシェーダーを実行します。其の結果は捨てられますが、其の値が差の計算に使われます。

計算例。 床y=−1(カメラは其の1 m上)、800 × 600の画面(アスペクト比4/3)で縦の視野角90°、1メートルあたり100テクセルのテクスチャです。
(上から数えた)ピクセルの行jはyndc=1−2(j+0.5)600で、其の下の床の点は距離d=1−yndcにあり、テクセル単位のテクスチャ座標はu=100⋅xndcd⋅43とv=100dです(19.2節を逆に辿ります)。
行330と331のピクセル400と401のクアッドでは:

ピクセル d(m) u(テクセル) v(テクセル)
(400, 330) 9.836 1.639 983.61
(401, 330) 9.836 4.918 983.61
(400, 331) 9.524 1.587 952.38
(401, 331) 9.524 4.762 952.38

従って、1ピクセルあたり∂(u,v)∂x≈(3.28,0)テクセル、∂(u,v)∂y≈(−0.05,−31.23)テクセルです。
第11章の式でρ=max(3.28,31.23)=31.23、λ=log231.23≈4.96なので、殆ど丁度ミップレベル5です。
フットプリントは幅の約31.233.28≈9.5倍の長さなので、異方性フィルタリングなら代わりにレベルlog23.28≈1.71から約10個のサンプル(16倍の異方性なら可能)を取ります。
ポリゴンオフセットの例と同じ数値です。此処では1行のピクセルが床の9.836−9.524=0.31 mを覆います。

知っておくべき結果が2つあります。
小さな三角形は高く付きます:1ピクセルの三角形でも4つの起動のクアッドが必要で、其の内3つはヘルパーです(効率25%)。LOD(詳細度)が使われる理由の1つであり、GPU駆動のレンダラーの中に小さな三角形をソフトウェアでラスタライズする物がある理由でもあります(第29章)。
そして微分には4つの起動全てが必要なので、レベルを自動で選ぶテクスチャの参照は、クアッドの一部のピクセルだけが進む分岐の中に置くべきではありません。

アーリーZと階層Z

アーリーZは、結果が変わり得ない時に深度テストをシェーダーの前に行います。シェーダーが自分で深度を書かず、フラグメントを捨てず、他の副作用も無い場合(又はGLSLのlayout(early_fragment_tests)やSlang/HLSLの[earlydepthstencil]で、プログラムが問題無いと約束した場合)です。
19.4節のフラグメントごとの対数深度の様に、フラグメントシェーダーで深度を書くと実際に性能の代償があるのは其の為です。

階層Z(Greene、Kass、Miller、1993年)は、タイル全体を一度に棄却します。
GPUは深度バッファと並べて、タイルごとに其の中に保存されている一番遠い深度(通常のZでは最大値)を持っておきます。
タイルの中で三角形のピクセルをラスタライズする前に、タイル上での三角形の一番近い深度、つまりタイルの角での深度の平面の最小値を、其の値と比べます。
計算例。 8 × 8のタイルの一番遠い保存値が0.6で、ある三角形の深度の平面はタイルの角で0.70、0.71、0.72、0.73です。
タイル内で一番近い点の0.70が既にある全ての物より後ろなので、64個のフラグメントは全て深度テストに落ち、1回の比較で其の全てを棄却出来ます。
リバースZでは「一番遠い」は最小値になり、比較も反転します。
シーンを大まかに手前から奥へ描くか、デプスプリパス(第10章)を使う事で、此の2つの早期のテストが効果を発揮します。

検定のポイント

此の章はシラバスの範囲を超えますが、分野2(投影、ビューイングパイプライン、描画パイプライン)と分野4(隠面消去、マッピング)の幾つかの項目の理由を説明しています:

  • 透視投影で保存される深度は距離に対して線型ではなく、1/dに従うので、精度はニア平面の近くに集中します(第5章、第10章)。ニア平面を遠ざけるのが一番効きます。
  • 投影後の深度は画面上では線型です。其の為、Zバッファ法は画面空間の(重心座標の)重みで深度を補間出来ます。
  • クリッピングは同次座標のクリップ座標で、透視除算の前に行います。視点の後ろの点はw≤0だからです(第6章)。
  • テクスチャ座標等の属性にはパースペクティブコレクト補間が必要です:u/wと1/wを補間し、割ります(第6章、第11章)。
  • ミップマップのレベルは、テクスチャ座標の画面空間での微分から選びます(第11章)。
  • シャドウアクネや同一平面上の面のZファイティングは、傾きに比例する部分を持つデプスバイアスで直します(第11章)。

19.6 まとめ

  • 透視投影行列は距離をwに入れます。xとyの行は視錐台のニア平面の長方形を±1に写し(非対称な視錐台ではzの項が加わる)、zの行はAz+Bにしかなれないので、深度は常に1/dのアフィン関数です。
  • 1つの式がOpenGL(−1〜1)、Vulkan/Direct3D(0〜1)、リバースZ(1〜0)を全て扱います。f→∞とすると無限遠の投影になり、リバースの無限遠版は丁度n/dを保存します。
  • 逆投影は深度からカメラ空間の位置を取り戻します。𝐏−1を掛けてwで割るか、距離d=B/(zndc+A)にピクセルのレイを掛けます。
  • クリッピングは除算の前にクリップ空間で行います。新しい頂点の属性はクリップ空間での線型補間で求め、其れは正確です。GPUはガードバンドで側面のクリッピングを避け、ニアとファーのクリッピングの代わりに深度をクランプする事も出来ます。
  • 固定小数点の深度は1段が一様、floatの深度は1段の相対的な大きさが一様です。通常のZはfloatの精度を無駄にし、floatのバッファでリバースZを使うと、距離の1段はどこでも距離の約2−24〜2−23倍になります。
  • 対数深度も相対精度を与えますが、画面上の線型性かアーリーZを壊します。デプスバイアスm⋅slope+r⋅constantは同一平面上の面を分け、シャドウアクネを防ぎます。
  • エッジ関数は線型でインクリメンタルに評価されます。トップレフトルールは共有する辺上の全てのピクセルを丁度1つの三角形に与えます。サブピクセルの格子にスナップした後の整数演算が其れを正確にします。
  • タイルを使うと多くのピクセルを一度に自明に棄却・受理出来ます。重心座標によって全ての属性は三角形上の平面になります。
  • 1/wは画面上で線型です。属性は∑λivi/wiを∑λi/wiで割った物です。深度に補正は要りません。
  • フラグメントはミップマップの為の微分を得る為に2 × 2のクアッドでシェーディングされます。アーリーZと階層Zは隠れたフラグメントをシェーディングの前に棄却します。

19.7 用語

English 日本語
projection matrix 投影行列
off-center (asymmetric) frustum 非対称な視錐台
depth range, depth convention 深度範囲
reversed Z リバースZ
infinite far plane 無限遠のファー平面
inverse projection 逆投影
depth reconstruction 深度からの位置の復元
clip coordinates クリップ座標系
homogeneous clipping 同次座標でのクリッピング
near-plane clipping ニア平面でのクリッピング
guard band ガードバンド
depth clamping 深度クランプ
depth precision 深度精度
fixed-point (UNORM) depth format 固定小数点(UNORM)の深度フォーマット
floating-point depth format 浮動小数点の深度フォーマット
logarithmic depth 対数深度
z-fighting Zファイティング
depth bias, polygon offset デプスバイアス、ポリゴンオフセット
slope-scaled depth bias スロープスケールのデプスバイアス
decal デカール
edge function エッジ関数
incremental evaluation インクリメンタルな評価
top-left rule トップレフトルール
sub-pixel precision サブピクセル精度
fixed-point snapping 固定小数点へのスナップ
bounding box traversal バウンディングボックスの走査
tiled traversal タイル単位の走査
trivial reject, trivial accept 自明な棄却、自明な受理
barycentric coordinates 重心座標
perspective-correct interpolation パースペクティブコレクト補間
helper invocation ヘルパー起動(ヘルパーインボケーション)
2×2 quad 2×2クアッド
screen-space derivatives スクリーン空間の微分
early-Z アーリーZ
hierarchical Z 階層Z(Hi-Z)

19.8 課題

  1. ★ 19.1節のzの行の一般式を使い、n=0.5、f=10.5のOpenGL、Vulkan、リバースの規約でのAとB、及び其々で距離2の点の0〜1の保存される深度を求めて下さい。(確認:リバースの値は1からVulkanの値を引いた物になり、OpenGLの値をzndc+12で変換した物はVulkanの値と等しくなります。)
  2. ★ Vulkanのレンダラーが800 × 600のウィンドウ(yは下向き)で、90°、アスペクト比4/3、n=1、f=11のperspectiveVkを使っています。ウィンドウの位置(100,450)で深度バッファの値は0.9です。19.2節の近道でカメラ空間の位置を復元し、もう一度投影して確かめて下さい。
  3. ★★ カメラ座標が(0,0,−5)、(2,0,1)、(−2,1,1)の三角形を、19.3節のOpenGLの行列(n=1、f=11)でニア平面に対してクリッピングして下さい。結果の頂点は幾つで、新しい頂点はカメラ空間とNDCの何処にありますか?
  4. ★★ n=0.5 m、f=500 mで、通常のZの24ビットUNORMバッファとリバースZのfloat32バッファについて、50 mと400 mでの距離の1段を見積もって下さい。ニア平面を0.05 mに動かすと、其々はどれだけ変わりますか?
  5. ★★ 辺がw=1、u=0の頂点からw=3、u=1の頂点まで伸びています。画面上の中点でのuはいくつで、画面上の距離のどの割合でu=0.5になりますか?画面上の位置に対するuのグラフを描いて下さい。
  6. ★★★ 4つの三角形がピクセルの中心である頂点(4.5,4.5)を共有し、(2.5,2.5)から(6.5,6.5)までの正方形の中で扇形を作っています(正方形の辺ごとに三角形1つ)。トップレフトルール(yは上向き、反時計回り)で、4つの内丁度1つが中心(4.5,4.5)を覆う事を示し、其れがどれかを求め、正方形の全てのピクセルを数えて下さい。辺ごとに適用するトップレフトルールが、共有する頂点も正しく扱えるのは何故ですか?

選択問題

問1 ★ 次のうち、ビューイングパイプラインにおけるクリッピングについて正しい物はどれか。

あ 透視除算の後は視体積が単純な立方体になるので、クリッピングは透視除算の後に行う。
い カメラの後ろにある頂点はクリップ座標のwが正なので、クリップの判定を通過する。
う OpenGLの規約では、ニア平面の判定は0≤z≤wである。
え ニア平面上に作られた新しい頂点の属性はクリップ空間での線型補間で求め、クリップ座標はカメラ座標の線型な関数なので此れは正しい。
お ガードバンドがあれば、GPUはニア平面に対してクリッピングする必要が全く無い。

問2 ★★ ニア平面0.1 m、ファー平面1000 mの透視投影を使う。次のうち、深度精度について正しい物はどれか。

あ 32ビット浮動小数点の深度バッファとリバースZ(ニアが1、ファーが0)では、保存される深度の1段に当たる距離はおおよそ距離に比例する。
い 通常の対応(ニアが0、ファーが1)では、32ビットfloatのバッファは遠くで24ビット固定小数点のバッファより約1000倍精度が高い。
う ニア平面を0.1 mから0.01 mへとカメラに近付けると、24ビット固定小数点のバッファは遠くでより精度が高くなる。
え リバースZは、OpenGLの標準の−1から1の深度範囲でも同じ様に効果がある。
お 24ビット固定小数点のバッファでは、1段に当たる距離はどの距離でも同じである。

問3 ★★ 三角形の辺が、w=1でテクスチャ座標u=1の頂点から、w=4でu=0の頂点まで伸びている。画面上で此の辺の中点における、パースペクティブコレクトなuの値はどれか。

あ 0.5
い 0.2
う 0.8
え 0.625
お 0.4

19.9 実践課題

リバースの無限遠投影を使い、1つの三角形にクリッピング、ラスタライズ、パースペクティブコレクト補間、深度からの復元を全て手で施します。
16 × 16の格子の方眼紙(1ピクセル1 cmが楽です)、定規、鉛筆、2色のペン、電卓を用意して下さい。

投影は、19.1節のリバースの無限遠の行列で、視野角90°、アスペクト比1、n=1です。OpenGLのyが上向きのウィンドウで、深度範囲は0〜1にして使います(glClipControl(GL_LOWER_LEFT, GL_ZERO_TO_ONE)の設定です)。
行列の行は(1,0,0,0)、(0,1,0,0)、(0,0,0,1)、(0,0,−1,0)なので、zc=1、wc=−zです。
クリップの判定は−w≤x,y≤wと0≤zc≤wcです。
ウィンドウは原点が左下の16 × 16ピクセルで、xw=8(xndc+1)、yw=8(yndc+1)、保存される深度はzc/wcです。
三角形のカメラ空間の頂点はA(−1,−1,−2)、B(3,−3,−4)、C(−0.5,2,0)で、テクスチャ座標はuA=0、uB=1、uC=0.5です。

  1. 投影する。 A、B、Cのクリップ座標を求め、クリップの判定をして下さい。どの頂点がどの平面で落ちますか?又、其の頂点を其のwで割る事がどの道出来ないのは何故ですか?
  2. クリッピングする。 ニア平面の符号付きの値はδ=wc−zcです。δ=0となるAC上の点I1とBC上の点I2を、クリップ座標とuと共に求めて下さい。クリッピング後の多角形をA、B、I2、I1の順に書きます。
  3. ウィンドウへ。 4つの頂点を割ってビューポート変換し、其々のウィンドウ上の位置と保存される深度を書いて下さい。方眼紙に四角形を描き、AI2に沿ってT1=(A,B,I2)とT2=(A,I2,I1)に分けます。
  4. ラスタライズする。 中心がT1の中にあるピクセルを1色目、T2の中にあるピクセルを2色目のペンで塗って下さい。中心が辺に近い時はエッジ関数を使い、丁度辺の上にあればトップレフトルールを使います。ピクセルを数え、靴紐の公式で求めた四角形の面積と比べて下さい。
  5. 補間する。 中心が(10.5,6.5)のピクセルについて、T1のエッジ関数と画面上の重みλを求めて下さい。次にuを素朴な方法とパースペクティブコレクトな方法で求め、補間した1/wと補間した保存される深度も求めます。
  6. 復元する。 ピクセルのNDCの位置と保存される深度から、カメラ空間の位置を求めて下さい(19.2節:此処では距離は1/depthです)。3次元の重みαi=λi/wi∑λj/wjを求め、其れからuを計算します。

自分で確認する方法:

  • 段階2:新しい2つの点はどちらもカメラのzが−1で、I1はAとCの丁度真ん中にあります。
  • 段階3:4つのウィンドウ上の位置は全て格子内の整数です。I1とI2の深度は1(ニア平面上にある)で、全ての深度は1/wと等しくなります。
  • 段階4:塗ったピクセルの数は面積(95ピクセル)と数ピクセルしか違わず、両方の色を持つピクセルはありません。
  • 段階5:3つのλの和は1です。此の投影では深度が1/w其の物なので、補間した深度は補間した1/wと丁度等しくなります。
  • 段階6:復元した点は三角形ABCの平面の方程式2x−9y+13z=−19を満たします。αiの和は1で、∑αiuiは段階5のパースペクティブコレクトなuと等しく、素朴な方は違います。


コメント(0)

まだコメントはありません。