20. アンチエイリアシングと透明表現

第1部では、2つの問題が半分だけ解決されたまま残っていました。
第2章では、シーンを1ピクセルに1回標本化するとジャギー、モアレ、ちらつく輪郭が生まれる事を見ました。
第6章では、GPUが其れに対してMSAAと言う安価な工夫を使う事に触れました。
第10章では、アルファブレンディングが描画順に依存し、半透明の物体の並べ替えは物体同士が交わった途端に破綻する事を見ました。
どちらも、詳しい話は第2部に先送りしていました。

此の章で其の約束を果たします。
前半はリアルタイムのアンチエイリアシングです。再構成フィルタ、スーパーサンプリング、MSAAの詳細、ポストプロセス手法のMLAA・FXAA・SMAA、時間方向アンチエイリアシング(TAA)、そして鏡面反射のエイリアシングを扱います。
後半はリアルタイムの透明表現です。並べ替え、アンダー演算、デプスピーリング、ピクセルごとの連結リスト(第10章のAバッファのGPU版)、重み付きブレンドによる順序非依存の半透明を扱います。

何れも検定のシラバスの範囲を超えますが、2つのシラバス項目を掘り下げる物です。
アンチエイリアシング(第2章と第6章)と、アルファブレンディングによる半透明(第10章)です。
数学は軽めで、重み付き平均、over演算子、そして投影行列が1つ出て来るだけです。
ラスタライザー其の物(エッジ関数、トップレフトルール、2×2のクアッド)は第19章で扱います。
此処では、其れが何処で標本化するかだけが問題です。

20.1 リアルタイムレンダリングのエイリアシング

2.6節の規則を思い出して下さい。
1ピクセルに1サンプルなら、画像が持てるのは最大で2ピクセルに1周期までで、其れより細かい物は粗い模様に化けて戻って来ます。
リアルタイムのフレームは、此の規則を4つの違う場所で破り、其々に別の対策があります:

  • 幾何学的エイリアシング:三角形の辺は急な段差なので、シルエットや交差線が階段になり、細い三角形(電線、フェンス)はサンプルの間に落ちてちらつきます。
  • シェーディングのエイリアシング:フラグメントシェーダー自身が、平らな三角形の内側でさえ、ピクセルより細かい模様を作ります。代表例は鏡面反射のエイリアシングで、凸凹した光沢面のハイライトがキラキラと瞬きます(20.7節)。
  • テクスチャのエイリアシング:縮小されたテクスチャです。ミップマップと異方性フィルタリング(第11章の11.7節)が前もって殆ど解決しています。
  • 時間方向のエイリアシング:カメラが1ピクセル未満だけ動くと、階段の形がフレームごとに変わり、輪郭が這う様に動き、細部がちらつきます。

2.7節の2つの正攻法は今も有効です。
プリフィルタリング(ミップマップがテクスチャに対して行う様に)か、サンプルを増やして後でフィルタで1つにまとめるかです。
任意のシェーダーをピクセル全体で積分するのは難しいので、此の章の殆どはサンプルを増やす為の工夫です。
1フレームの中で1ピクセルあたりに増やす(SSAA、MSAA)か、複数のフレームにまたがって増やす(TAA)かです。
ポストプロセス手法は例外で、完成した画像から足りないサンプルを推測します。

20.2 再構成フィルタ

1ピクセルに複数のサンプルを取ったら、フィルタで其れを1つの値にまとめます。
Alvy Ray Smithの有名な1995年のメモ「A Pixel Is Not a Little Square」は、ピクセルは小さな正方形ではなく点のサンプルだ、と指摘しています。
其の考え方では、ピクセルの値を求める事は、サンプルから連続した画像を再構成し、其れをピクセル中心で評価する事です。
フィルタk(d)は、各サンプルciを中心からの距離di(ピクセル単位)で重み付けします:

cpixel=∑ik(di)ci∑ik(di)

重みの合計で割るので、一様な領域は其の色のまま保たれます。
よく使うフィルタは次の4つです(2次元ではxとyの両方向に適用します):

  • ボックスフィルタ:|d|≤0.5ならk(d)=1、其れ以外は0。ピクセル内のサンプルを等しく扱い、ハードウェアのMSAAのリゾルブが行うのも此れです。切れ目が急なので、エイリアシングが少し漏れます。
  • テントフィルタ:k(d)=max(0,1−|d|)。中心に近いサンプルほど重く、隣のピクセルのサンプルも少しだけ数えます。
  • ガウシアンフィルタ:k(d)=e−d2/(2σ2)。σ=0.5なら約1.5ピクセルまで届きます。エイリアシングにはとても強く、少しぼやけます。
  • ミッチェル・ネトラバリフィルタ(MitchellとNetravali、1988年):半径2の3次式で、パラメータB、Cを持ちます。推奨値のB=C=13はぼけとリンギングの折衷です。1〜2ピクセルの所に小さな負のローブがあり、輪郭を少しだけシャープにします。

ピクセル中心からの距離に対する4つの再構成フィルタ。ボックスはマイナス0.5から0.5まで1、テントは1で0まで直線的に下がり、シグマ0.5のガウスは滑らかな釣鐘形、ミッチェルは0.89が最大で1と2の間で僅かに0を下回る

ミッチェルフィルタは、|d|<1でk(d)=16((12−9B−6C)|d|3+(−18+12B+6C)d2+(6−2B))、1≤|d|<2では次の式です:

k(d)=16((−B−6C)|d|3+(6B+30C)d2+(−12B−48C)|d|+(8B+24C))

B=C=13なら、k(0)=6−2/36=89≈0.889、k(0.5)≈0.535、k(1)=118≈0.056、k(1.5)≈−0.035で、カーネル全体の積分は丁度1です。
図では各フィルタを面積1に揃えて描いているので、ガウシアンの山は約0.8になっています。式は重みの合計で割るので、定数倍は結果に影響しません。

計算例:ボックスとテント。
1次元の列で、各ピクセル中心から±0.25の位置に2つずつサンプルがあるとします。
ピクセル0の中心は0、ピクセル1の中心は1なので、サンプルは−0.75,−0.25,0.25,0.75,1.25,1.75にあります。
x=0.6に輪郭があると、サンプルの値は1,1,1,0,0,0です(輪郭の左が白、右が黒)。
ボックスでは、ピクセル0は−0.25と0.25を平均して1+12=1、ピクセル1は0.75と1.25を平均して0です。
1ピクセルに2サンプルあっても、急な段差のままです。
テントでは、ピクセル0は距離0.75、0.25、0.25、0.75のサンプルを重み0.25、0.75、0.75、0.25(合計2)で数えます:

c0=0.25⋅1+0.75⋅1+0.75⋅1+0.25⋅02=0.875,c1=0.25⋅1+0.75⋅0+0.75⋅0+0.25⋅02=0.125

輪郭の位置がピクセルの値に現れる様になり、輪郭がゆっくり動くと値も跳ばずに滑らかに変わります。
代償は少しのぼけです。
オフラインのレンダラーではフィルタを選べますが、リアルタイムのエンジンは殆どハードウェアのリゾルブのボックスフィルタを使うか、TAA(20.6節)に時間方向のフィルタリングを任せます。

20.3 スーパーサンプリング(SSAA)

大きく描いてから縮める

2.7節のスーパーサンプリング(SSAA)は、1ピクセルあたりN個のサンプルの其々について、パイプライン全体(カバレッジ、深度テスト、フラグメントシェーダー)を実行し、フィルタで1つにまとめます。
幾何学的、シェーディング、テクスチャのエイリアシングを一度に解決しますが、ピクセルの仕事は丁度N倍になります。

計算例。 1920×1080は2073600ピクセルです。
4× SSAAなら8294400サンプルで、其々がシェーダーの実行、色、深度を持ちます。
色4バイト(RGBA8)と深度・ステンシル4バイトなら8294400⋅8=66355200バイト、約63.3 MiBで、アンチエイリアシング無しの15.8 MiBの4倍です。
ピクセルの仕事が10 msなら約40 msになり、60 fpsには届きません。
SSAAはオフライン、スクリーンショット、又は新しいハードウェアで動かす古いゲームで使われます(1080pの画面の為に4Kで描くのもSSAAです)。

格子配置と回転格子

サンプルの数と同じ位、其の位置が大事です。
2×2の格子配置は、あたし達が一番気付き易い、ほぼ垂直とほぼ水平の輪郭に弱いのです。

ピクセルを通るx = 0.7のほぼ垂直な輪郭。2×2の格子配置ではサンプルのxが2種類しか無いので4つ中2つが内側で、3段階しか表せない。4×標準配置の回転格子では4つのサンプルのxが全部違い、4つ中3つが内側で、5段階を表せる

計算例。 x=0.7の垂直な輪郭は、ピクセルの70%を覆います。
格子配置のサンプルはx=0.25と0.75にしか無いので、輪郭が横に動いても0,24,1の3段階しか出せず、此処では24=0.5です。
次の節の4×標準配置の様な回転格子(RGSS)はx=0.125,0.375,0.625,0.875を持つので5段階を踏み、此処では34=0.75と、0.7にずっと近くなります。
一般に、行も列も全て違うN個のサンプル(チェス盤で互いに取り合わないN個のルークの様な配置)は、垂直と水平の輪郭に対してN+1段階を与えますが、N×Nの格子配置はN+1段階しか与えません。
其の為、GPUのサンプル配置は格子ではなく、散らばった様に見えます。

20.4 マルチサンプルアンチエイリアシング(MSAA)

考え方

普通のフレームでは、エイリアシングの殆どは三角形の辺から来ます。
一方、三角形の内側のシェーディングはゆっくり変わります(テクスチャはミップマップでプリフィルタされています)。
マルチサンプルアンチエイリアシング(MSAA)は此の事を利用します:

  • カバレッジと深度は、1ピクセルあたりN個のサンプル点で判定し、各サンプルは自分の深度、ステンシル、色を持ちます。
  • フラグメントシェーダーはピクセルごと・三角形ごとに1回だけ実行し、其の色を、三角形が覆い深度テストに通った全てのサンプルに書き込みます。
  • 最後にリゾルブが、N個のサンプルの色を平均して最終的なピクセルにします。

三角形の内側のピクセルは、1回のシェーダー実行でN個の同じサンプルを得ます。
2つの三角形の境目のピクセルではシェーダーが2回走り、リゾルブがカバレッジに応じて色を混ぜます。
深度がサンプルごとにあるので、三角形同士が貫通する交差線もアンチエイリアスされます。
第10章で約束した事です。

サンプルパターン

VulkanとDirect3Dは、2、4、8、16サンプルの標準サンプル位置を定義していて、殆ど全てのデスクトップGPUが対応しています(Vulkanでは制限値standardSampleLocationsで分かります)。
原点をピクセルの左上の角、yを下向きにしたピクセル座標で、4×の配置は18の倍数の上の回転格子です:

サンプル x y
0 0.375 0.125
1 0.875 0.375
2 0.125 0.625
3 0.625 0.875

左:4×標準配置の4つのサンプル位置と、右上の角を切り取る輪郭を持つ1ピクセル。三角形Aがサンプル0、2、3とピクセル中心を、三角形Bがサンプル1を覆う。右:8×標準配置の8つのサンプル位置

計算例:1ピクセルと2つの三角形

図の輪郭は直線x−y=0.35で、三角形Bはx−y>0.35の部分です。

  1. カバレッジ。 x−yはサンプル0で0.25、サンプル1で0.5、サンプル2で−0.5、サンプル3で−0.25です。0.35を超えるのはサンプル1だけなので、Bがサンプル1を、Aがサンプル0、2、3を覆います。ビットマスク(ビットiがサンプルi)ではAが11012、Bが00102です。
  2. シェーディング。 フラグメントシェーダーは2回走ります。Aはオレンジ(0.9,0.6,0.1)、Bは青(0.1,0.3,0.8)になったとします。
  3. 書き込み。 サンプル0、2、3にオレンジ、サンプル1に青が入ります。
  4. リゾルブ。 3⋅(0.9,0.6,0.1)+(0.1,0.3,0.8)4=(2.8,2.1,1.1)4=(0.7,0.525,0.275)です。

Bの本当の取り分は、(0.35,0)、(1,0)、(1,0.65)の三角形で、面積は12⋅0.652=0.21125です。
従って正確な色は0.78875A+0.21125B≈(0.731,0.537,0.248)です。
MSAAの見積もり0.25は0.04ずれていますが、中心の1サンプルなら0と答え、ピクセルは完全にオレンジになっていました。

シェーダーは何処で走るか:セントロイドサンプリング

Bのシェーダーは1回走りましたが、場所はピクセル中心(0.5,0.5)で、此れはBの外です。
其の為、属性は外挿されています。
アトラスの1区画の端を越えて外挿されたテクスチャ座標は、隣の画像を読んでしまい、輪郭に色の付いた継ぎ目を残す事があります。
セントロイドサンプリングは、中心が覆われていない時、覆われたサンプル(又は覆われたサンプルの重心)で属性を評価します。
GLSLではcentroid修飾子(centroid in vec2 uv;)、SlangとHLSLではcentroid補間修飾子です。
但し、隣のピクセル同士が違う位置で評価する事があるので、微分(dFdx、ddx)の精度が落ちます。

メモリとコストの計算

MSAAが節約するのはシェーディングで、メモリではありません。
1920×1080の4× MSAAは、4× SSAAと同じ63.3 MiBのサンプルバッファに加え、1サンプルのリゾルブ先2073600⋅4=8294400バイト(7.9 MiB)が要ります。
ピクセルの8%が2つの三角形の境目にあるなら、シェーダーは約2073600⋅1.08≈2239488回走り、SSAAの8294400回の約3.7分の1です。
GPUはMSAAのバッファを圧縮もします(全サンプルが同じピクセルは、1つの値と印だけで済ませます)。
此れはメモリ帯域を節約します。
タイルベースのモバイルGPU(第17章の17.4節)はサンプルをチップ上のタイルメモリに置き、リゾルブ済みのピクセルだけを書き出すので、4× MSAAは帯域の面では殆ど只です。
隠れたコストが1つあります。
シェーダーは2×2ピクセルのクアッド単位で走り(第19章)、小さな三角形にたくさん触られたピクセルは三角形ごとに1回シェーダーを走らせます。
其の為、細かいメッシュではMSAAは8%の見積もりよりずっと高く付きます。

MSAAが直さない物

ピクセルごとに1回しかシェーディングしないので、MSAAはシェーディングのエイリアシングには何もしません。
図は、縞の間隔が1.45ピクセル、つまりナイキストの限界の2ピクセルより細かい三角形です。

細かい斜めの縞の三角形を4通りに描いた物。1ピクセルに1サンプルでは輪郭がギザギザで、縞は偽の粗い模様になる。4× MSAAは輪郭を滑らかにするが、内側の偽の模様は残る。4× SSAAは両方を滑らかにする。256サンプルの参照画像では内側は殆ど一様な色になる

4× MSAAは輪郭を滑らかにしますが、内側には1サンプルと同じ偽の模様が残り、4× SSAAは其の殆どを消します。
其れが問題になる時は、MSAAをサンプルシェーディングに切り替えられます。
VulkanではsampleShadingEnableとminSampleShading、OpenGLではglMinSampleShadingを使うか、シェーダーでgl_SampleID(GLSL)やSV_SampleIndex(Slang、HLSL)を読むだけで済みます。
MSAAのハードウェアの上のSSAAで、コストもSSAAと同じです。

もう1つの穴はアルファテストされた形状です。
葉やフェンスは、テクスチャを貼った四角形として描かれ、シェーダーはアルファが0.5未満のフラグメントを捨てます。
捨てるかどうかはピクセルごとに1回決まるので、8× MSAAでも葉の輪郭はエイリアシングしたままです。

アルファトゥカバレッジ

アルファトゥカバレッジは、シェーダーのアルファをカバレッジマスクに変換します。
アルファαは、N個のサンプルの内、約αN個を覆います。
計算例。 4× MSAAでα=0.6なら0.6⋅4=2.4なので2サンプルが覆われ、リゾルブ後のピクセルは葉が半分、背景が半分です。
α=0.9なら3.6≈4サンプルで、完全に不透明です。
フィルタされたアルファが1〜2ピクセルで1から0へ下がる葉の輪郭では、カバレッジも滑らかに下がるので、輪郭がアンチエイリアスされます。
しかもサンプルは普通の不透明な面と同じく深度を書くので、並べ替えは要りません。
4サンプルでは5段階しか無い(ハードウェアは段差を隠す為にマスクをディザリングする事が多い)ので、切り抜きの輪郭には向きますが、本物のガラスには向きません。
VulkanではalphaToCoverageEnable、OpenGLではglEnable(GL_SAMPLE_ALPHA_TO_COVERAGE)で有効にします。

CSAAとEQAA

カバレッジは1サンプル1ビットで済みますが、色と深度はずっと高く付きます。
其の為、NVIDIAのCSAA(GeForce 8800、2006年)とAMDのEQAA(Radeon HD 6900、2010年)は、色よりも多くのカバレッジサンプルを持ちました。
例えばカバレッジ16ビットに対して色は4つで、16×に近い段階を4×に近いコストで得ます。
此れらはAPIの機能ではなくドライバのオプションでした。
残った考え方は、「輪郭が何処にあるか」と「何色か」を分ける事です。

MSAAとディファードシェーディング

ディファードシェーディング(第17章の17.4節のディファードレンダリング)は、MSAAの取引を壊します。
Gバッファはサンプルを保たなければならず(境目のピクセルは2つの材質を持ちます)、ライティングのパスは境目のピクセルのサンプルを1つずつ照らさなければなりません。
ライティングの前に法線や材質を平均するのは間違いだからです。
計算例。 第17章の1ピクセル20バイトのGバッファ(1920×1080で41.5 MB)は、4× MSAAでは1920⋅1080⋅4⋅20=165888000バイト、約165.9 MBになり、毎フレーム書いて読みます。
しかもライティングのパスは、まず境目のピクセルを見つけて、其処だけサンプルごとに照らす必要があります。
実際に此れを製品に載せたエンジンもありますが、此のメモリと複雑さが、ディファードのエンジンがポストプロセスや時間方向の手法へ移った大きな理由です。
フォワードレンダラー(VRでよく使われます)なら、MSAAは安いままです。

実際の使い方

VulkanではVK_SAMPLE_COUNT_4_BITの様なサンプル数で画像を作り、パイプラインにrasterizationSamplesを設定し、リゾルブアタッチメントかvkCmdResolveImageでリゾルブします。
OpenGLではマルチサンプルのフレームバッファに描き、glBlitFramebufferでリゾルブします。
此れはOpenGLシリーズの第20章で自分で行います。
ハードウェアのリゾルブは保存された値を其のまま平均するので、線型のHDRの値では明るいハイライトが平均を支配します。
其の為、丁寧なエンジンはサンプルごとにトーンマッピングするか、自前のリゾルブシェーダーを書きます。

20.5 ポストプロセスアンチエイリアシング

考え方

ポストプロセスアンチエイリアシングは、1ピクセル1サンプルの完成した画像に対して働きます。
階段を見つけ、其の裏にある滑らかな輪郭を推測し、境目の各ピクセルを、其の輪郭が覆ったはずの割合で隣のピクセルと混ぜます。
コストはフルスクリーンのパス1〜3回で、ディファードシェーディングでも使え、追加のサンプルは要りません。
2.7節で注意した通り、1ピクセル1サンプルで既に失われた物は取り戻せません。
ピクセルより細い電線は途切れたままです。

MLAA

モルフォロジカルアンチエイリアシング(MLAA、Alexander Reshetov、Intel、2009年)が此の系統の始まりで、元々はCPUで動いていました:

  1. 輪郭を見つける:色の差が閾値を超える隣同士のピクセルの間に印を付けます。
  2. 形を見つける:輪郭を辿り、階段の各段を、両端の曲がり方で分類します(論文のL、Z、Uの形)。
  3. 混ぜる:段の中点を通る直線として輪郭を再構成し、各ピクセルを、其の直線が切り取る面積の分だけ、輪郭の反対側のピクセルと混ぜます。

4ピクセルの長さの段を持つ図形のエイリアシングした階段、垂直な段の中点を通る直線として推定した辺、そして各ピクセルのブレンドの重み。段の前半では直線の下で0.375、0.125、後半では直線の上で0.125、0.375

計算例。 図形の境界は4ピクセルごとに1ピクセル上がります。
MLAAは垂直な段の中点(0,0.5)と(4,1.5)を結ぶので、最初の段での輪郭はh(x)=0.5+x4です。
一方、エイリアシングした境界は高さ1にあります。
各ピクセルの重みは、其の幅の上で2本の線に挟まれた面積です:

  • ピクセル0:∫01(1−h(x))dx=0.5−18=0.375。直線は境界より下なので、此の図形のピクセルは背景の色を37.5%受け取ります。
  • ピクセル1:∫12(1−h(x))dx=0.5−38=0.125。
  • ピクセル2:此処からは直線が境界より上なので、上の背景のピクセルが図形の色を∫23(h(x)−1)dx=58−0.5=0.125だけ受け取ります。
  • ピクセル3:∫34(h(x)−1)dx=78−0.5=0.375。

本当の輪郭が本当に直線y=0.5+x4だったなら、此れらはエリアサンプリング(第2章のプリフィルタ)が出す覆い具合と完全に一致します。
MLAAは、1サンプルの画像からアンチエイリアスされた輪郭を復元したのです。
本当の輪郭が曲線や細い物だった時は推測が外れ、其処からアーティファクトが生まれます。

FXAA

FXAA(fast approximate anti-aliasing、Timothy Lottes、NVIDIA。ホワイトペーパーの日付は2009年で、広く使われたFXAA 3と3.11は2011年に公開されました)は、安価な1回のピクセルシェーダーのパスで、ルーマだけを見ます。
ルーマは例えば第3章の3.6節のY′=0.299R′+0.587G′+0.114B′で、緑のチャンネルだけで代用する事も出来ます:

  1. ピクセルと上下左右の4つの隣の局所コントラスト(ルーマの最大から最小を引いた物)を求めます。閾値未満ならピクセルには何もしません。殆どのピクセルは此処で終わります。
  2. 輪郭が水平か垂直かを決め、両方向に輪郭を辿って、階段の此の段が何処で終わるかを探します。
  3. 段の上でのピクセルの位置から、輪郭を横切る方向のサブピクセルのずれを求め、其の位置で画像をバイリニアフィルタリングで読みます。1回のテクスチャ読み込みで、ピクセルと隣を混ぜられます。

判定の計算例。 中心0.8、左0.8、上0.8、右0.1、下0.1なら、コントラスト0.8−0.1=0.7は典型的な閾値の約0.1を大きく超えるので、此のピクセルは処理されます。
ルーマが0.62〜0.65の空はコントラストが0.03なので、飛ばされます。
FXAAはどんなレンダラーにも組み込めるので急速に広まりましたが、細かいテクスチャや文字もぼかすので、ユーザーインターフェースを描く前に実行する必要があります。

SMAA

SMAA(enhanced subpixel morphological anti-aliasing、Jorge Jimenez、Jose Echevarria、Tiago Sousa、Diego Gutierrez、Eurographics 2012)は、MLAAをGPU向けに3つのパスで改良した物です:

  1. ルーマ、色又は深度による輪郭検出。局所コントラスト適応付きで、周りの輪郭に比べて強い輪郭だけを残すので、テクスチャの細部を守ります。
  2. ブレンドの重みの計算。各輪郭に沿って段の両端を探し、両端までの距離と端の形を添え字にして、前もって計算したエリアテクスチャから重みを引きます。斜めの輪郭は別に扱います。
  3. 其の重みによる近傍ブレンド。

MLAAより角を鋭く保ち、FXAAより正確です。
派生版は、サブピクセルの細部の為に2× MSAA(S2x)、時間方向の成分(T2x)、又は両方(4x)を加えます。

3つとも同じ死角を持っています。
サブピクセルの細部は途切れたままで、動きがあると検出される階段が変わるので輪郭はやはりちらつき、テクスチャや文字を輪郭と間違える事があります。
最初の2つは、正にTAAが狙う所です。

20.6 時間方向アンチエイリアシング(TAA)

サンプルを時間に広げる

60 fpsでは、連続するフレームは殆ど同じです。
TAAは1フレームに1ピクセル1つのサンプルを、毎フレームピクセル内の違う位置で取り、各ピクセルを以前の値と混ぜます。
8フレーム後には、止まった画像は1つ分のコストで実質8サンプルを持ちます。
此のアイデアは「amortized supersampling」(Yang、Nehab、Sander、Sitthi-amorn、Lawrence、Hoppe、2009年)として発表され、Brian Karisの「High Quality Temporal Supersampling」(Unreal Engine 4、SIGGRAPH 2014)の様な講演を通じてゲームの標準になりました。
3つの部品から成ります。ジッタ、再投影、履歴の棄却です。

TAAのデータの流れ。ジッタ付きで描いた現在のフレームを履歴とブレンドする。履歴は前の出力で、モーションベクトルで再投影され、現在の3×3近傍の最小・最大にクランプされてから、α = 0.1でブレンドされる。出力は次のフレームの履歴になる

ハルトン列によるジッタ

ジッタは、毎フレーム変わるサブピクセルのずれ𝐣nで投影全体をずらします。
ずれは、数フレームだけでもピクセルを均等に覆うべきです。
乱数のずれは固まりがちですが、低食い違い量列は仕組み上広がります。
よく使うのは、基数2と3のハルトン列です。
其の値hb(i)はiのラディカルインバース(radical inverse)で、iをb進数で書き、其の桁を小数点の後ろに鏡写しにした物です。
計算例。 6=1102を鏡写しにすると0.0112=14+18=0.375、6=203は0.023=29≈0.2222なので、点6は(0.375,0.2222)です。
最初の8点と、ピクセル単位のジッタ𝐣=(h2−0.5,h3−0.5):

i 2進数のi h2(i) 3進数のi h3(i) ジッタ(ピクセル)
1 1 0.5 1 0.3333 (0, −0.1667)
2 10 0.25 2 0.6667 (−0.25, 0.1667)
3 11 0.75 10 0.1111 (0.25, −0.3889)
4 100 0.125 11 0.4444 (−0.375, −0.0556)
5 101 0.625 12 0.7778 (0.125, 0.2778)
6 110 0.375 20 0.2222 (−0.125, −0.2778)
7 111 0.875 21 0.5556 (0.375, 0.0556)
8 1000 0.0625 22 0.8889 (−0.4375, 0.3889)

1ピクセル内の、基数2と3のハルトン列の最初の16点。1〜8は塗り潰して番号付き、9〜16は中抜きで、最初の8点の隙間を埋めている

新しい点は、残っている大きな隙間に落ち、9〜16の点は最初の8点の間の穴を埋めます。
エンジンは其の内8個か16個を繰り返し使い、ピクセルの角(0,0)になる添え字0は飛ばします。
低食い違い量列は、第23章のモンテカルロ積分で又出て来ます。

投影をずらす。 幅Wピクセルの画面でのjxピクセルのずれは、2単位の幅を持つNDCでは2jxWです。
此れを透視除算の後のxcwcに足すには、除算の前のxcに2jxWwcを足せば良いのです。
此れは正に、平行移動行列が同次座標のベクトルに行う事です(線型代数シリーズ第9章):

𝐏′=𝐓(2jxW,2jyH,0)𝐏

透視投影でも平行投影でも同じ様に使えます。
線型代数シリーズのヘッダーを使い、点を投影して確かめます:

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

// Radical inverse: the i-th point of the Halton sequence in base b.
float halton(int i, int b) {
  float f = 1.0f, r = 0.0f;
  while (i > 0) {
    f /= b;
    r += f * (i % b);
    i /= b;
  }
  return r;
}

// Sub-pixel jitter for frame n (cycle of 8), in pixels, in [-0.5, 0.5).
Vec2 jitter(int frame) {
  int i = frame % 8 + 1;  // skip index 0, which is (0, 0)
  return {halton(i, 2) - 0.5f, halton(i, 3) - 0.5f};
}

// Shift the whole image by j pixels: translate clip space by 2j / size in NDC.
Mat4 jitteredProjection(const Mat4 &proj, Vec2 j, int width, int height) {
  return translate({2.0f * j.x / width, 2.0f * j.y / height, 0.0f}) * proj;
}

// Window coordinates of a view-space point (pixel units, OpenGL convention).
Vec2 toPixels(const Mat4 &proj, Vec3 p, int width, int height) {
  Vec4 c = proj * Vec4{p.x, p.y, p.z, 1.0f};
  return {(c.x / c.w * 0.5f + 0.5f) * width, (c.y / c.w * 0.5f + 0.5f) * height};
}

int main() {
  const int W = 1920, H = 1080;
  Mat4 proj = perspectiveGl(std::numbers::pi_v<float> / 3.0f, float(W) / H, 0.1f, 100.0f);
  Vec3 p{0.0f, 0.0f, -5.0f};  // lands on the image center
  Vec2 base = toPixels(proj, p, W, H);
  printf("unjittered: (%.3f, %.3f)\n", base.x, base.y);
  for (int frame = 0; frame < 8; ++frame) {
    Vec2 j = jitter(frame);
    Vec2 q = toPixels(jitteredProjection(proj, j, W, H), p, W, H);
    printf("frame %d: jitter (%+.3f, %+.3f)  moved by (%+.3f, %+.3f)\n",
           frame, j.x, j.y, q.x - base.x, q.y - base.y);
  }
}

unjittered: (960.000, 540.000)
frame 0: jitter (+0.000, -0.167)  moved by (+0.000, -0.167)
frame 1: jitter (-0.250, +0.167)  moved by (-0.250, +0.167)
frame 2: jitter (+0.250, -0.389)  moved by (+0.250, -0.389)
frame 3: jitter (-0.375, -0.056)  moved by (-0.375, -0.056)
frame 4: jitter (+0.125, +0.278)  moved by (+0.125, +0.278)
frame 5: jitter (-0.125, -0.278)  moved by (-0.125, -0.278)
frame 6: jitter (+0.375, +0.056)  moved by (+0.375, +0.056)
frame 7: jitter (-0.438, +0.389)  moved by (-0.438, +0.389)

投影された点は丁度ジッタの分だけ動き、𝐓はNDC全体をずらすので、他の全ての点も同じだけ動きます。
perspectiveVkでも其のまま動きます。
VulkanではNDCのyもフレームバッファのyも下向きだからです。

蓄積:指数移動平均

TAAは古いフレームを8枚保存する代わりに、履歴の画像を1枚だけ持ち、指数移動平均で更新します:

𝐡n=α𝐜n+(1−α)𝐡n−1

𝐜nはジッタ付きの新しいフレームで、αは普通0.1程度です。
フレームn−kは、最終的に重みα(1−α)kを持ちます。
α=0.1での計算例:

  • 現在のフレームと其の前の4フレームの重みは0.1、0.09、0.081、0.0729、0.0656です。
  • 各フレームの値が分散σ2のノイズを持つなら(ジッタが変わる度に境目のピクセルが内と外を行き来する場合)、平均の分散はα2−ασ2=0.11.9σ2≈0.0526σ2で、2−αα=19フレームを等しく平均したのと同じです。
  • 急な変化の後、nフレームで新しい値の1−0.9nに届きます。5フレームで0.41、10フレームで0.65、22フレームで0.90で、60 fpsなら約3分の1秒です。

αを小さくすると滑らかになりますが反応が遅くなり、動きの中ではゴーストとぼけになります。
此れがTAAの中心的なトレードオフです。

モーションベクトルによる再投影

物が動くと、ピクセル𝐩の履歴は別の物を映しています。
其の為、レンダラーはピクセルごとにモーションベクトル𝐦を書き出します。
見えている面が前のフレームからどれだけ動いたかで、各頂点を今のフレームと前のフレームの行列(どちらもジッタ無し)で変換し、画面上の位置の差を取って求めます。
TAAは、其の面が前にあった場所𝐩−𝐦で履歴を読みます。
此れが再投影(リプロジェクション)です。
計算例。 𝐩=(640.5,360.5)、𝐦=(3.2,−1.5)ピクセルなら、履歴は(637.3,362.0)で読みます。
ピクセル中心の間なので、バイリニアかバイキュービック(Catmull–Rom)のフィルタで読みます。
此の再標本化は毎フレーム少しずつぼかし、ぼけが積み重なるので、TAAの画像はシャープ化のパス無しではソフトに見えます。

履歴の棄却:近傍クランプ

再投影は、ディスオクルージョン(前のフレームでは隠れていた面)、照明やテクスチャが変わった時、正しいモーションベクトルの無いエフェクトで失敗します。
其の間違った履歴を混ぜると、動く物体の後ろに薄れて行く尾が残ります。
此れがゴースト(ゴースティング)です。
標準的な対策は近傍クランプです。
現在の3×3の近傍が「今此処であり得る色」を教えてくれるので、ブレンドの前に履歴を其の9色の最小と最大の間にクランプします。

計算例(1チャンネル)。
近傍の値は0.20,0.25,0.30,0.22,0.28,0.35,0.24,0.40,0.55、中心はc=0.28で、最小0.20、最大0.55です。
再投影した履歴はh=0.90で、立ち去った明るい物体が残した物です。
クランプ無しでは0.1⋅0.28+0.9⋅0.90=0.838で、明るいゴーストです。
0.55にクランプすると0.1⋅0.28+0.9⋅0.55=0.523で、ゴーストはすぐに消えます。
分散クリッピング(Marco Salvi、2016年)はもっときつく、近傍の平均μと標準偏差σを使ってμ±γσに切り詰めます。
此処ではμ=0.31、σ≈0.104なので、γ=1なら履歴は約0.414に切り詰められます。
クランプは普通YCoCg(ルーマと2つの色差)で行います。
RGBより箱が色にぴったり合うからです。
代償はちらつきです。
ジッタした一部のフレームにしか現れないサブピクセルの細部は、毎フレーム範囲を変えてしまいます。

TAAのリゾルブの核心部分です(モーションベクトルはUV単位):

// One pixel of a TAA resolve. motion is in UV units (this frame minus last frame).
float3 taaResolve(Texture2D<float4> current, Texture2D<float4> history, SamplerState linearClamp,
                  int2 pixel, float2 uv, float2 motion, float alpha) {
  uint w, h;
  current.GetDimensions(w, h);
  int2 size = int2(w, h);
  float3 c = current.Load(int3(pixel, 0)).rgb;
  float3 lo = c;
  float3 hi = c;
  for (int y = -1; y <= 1; ++y) {
    for (int x = -1; x <= 1; ++x) {
      int2 q = clamp(pixel + int2(x, y), int2(0, 0), size - 1);
      float3 n = current.Load(int3(q, 0)).rgb;
      lo = min(lo, n);
      hi = max(hi, n);
    }
  }
  float3 past = history.SampleLevel(linearClamp, uv - motion, 0).rgb;
  past = clamp(past, lo, hi);
  return lerp(past, c, alpha);
}
// One pixel of a TAA resolve. motion is in UV units (this frame minus last frame).
vec3 taaResolve(sampler2D current, sampler2D history, ivec2 pixel, vec2 uv, vec2 motion,
                float alpha) {
  ivec2 size = textureSize(current, 0);
  vec3 c = texelFetch(current, pixel, 0).rgb;
  vec3 lo = c;
  vec3 hi = c;
  for (int y = -1; y <= 1; ++y) {
    for (int x = -1; x <= 1; ++x) {
      ivec2 q = clamp(pixel + ivec2(x, y), ivec2(0), size - 1);
      vec3 n = texelFetch(current, q, 0).rgb;
      lo = min(lo, n);
      hi = max(hi, n);
    }
  }
  vec3 past = texture(history, uv - motion).rgb;
  past = clamp(past, lo, hi);
  return mix(past, c, alpha);
}
// One pixel of a TAA resolve. motion is in UV units (this frame minus last frame).
float3 taaResolve(Texture2D<float4> current, Texture2D<float4> history, SamplerState linearClamp,
                  int2 pixel, float2 uv, float2 motion, float alpha) {
  uint w, h;
  current.GetDimensions(w, h);
  int2 size = int2(w, h);
  float3 c = current.Load(int3(pixel, 0)).rgb;
  float3 lo = c;
  float3 hi = c;
  for (int y = -1; y <= 1; ++y) {
    for (int x = -1; x <= 1; ++x) {
      int2 q = clamp(pixel + int2(x, y), int2(0, 0), size - 1);
      float3 n = current.Load(int3(q, 0)).rgb;
      lo = min(lo, n);
      hi = max(hi, n);
    }
  }
  float3 past = history.SampleLevel(linearClamp, uv - motion, 0).rgb;
  past = clamp(past, lo, hi);
  return lerp(past, c, alpha);
}

lerp(past, c, alpha)もmix(past, c, alpha)も(1−α)past+αc、つまり上の移動平均を計算します。
実際の実装は、YCoCg、バイキュービックでの履歴の読み込み、動きに応じたα、シャープ化を加えます。

長所、アーティファクト、アップスケーリング

TAAはフルスクリーンのパス1回程度で、全ての種類のエイリアシング(輪郭、シェーディング、鏡面反射、サブピクセルの細部)を扱います。
更に、スクリーンスペースのアンビエントオクルージョンやレイトレーシングの反射の様に、1フレームでは疎にしか標本化されないエフェクトもきれいにします(第24章)。
其の為、現代のエンジンはTAAを前提に作られています。
典型的なアーティファクトは、ゴースト、ぼけ、細い物のちらつき、そしてモーションベクトルの無い物(パーティクル、スクロールするテクスチャ)の滲みです。

サンプルを時間に広げると、出力が入力と同じ解像度である必要はなくなります。
低い解像度でジッタ付きで描き、大きな履歴に蓄積すれば良いのです。
此れが時間方向アップスケーリングで、執筆時点ではAMDのFSR 2以降、NVIDIAのDLSS 2以降、IntelのXeSSの原理です。
DLSS、XeSS、FSR 4は手書きの棄却規則の一部又は全部をニューラルネットワークに置き換えており、FSR 2と3は手書きの規則の儘です。
第29章で扱います。

20.7 鏡面反射のエイリアシング

法線マップを貼った光沢面は、此処までの全ての手法を打ち負かします。
1ピクセルの中で凸凹は様々な方向を向いているのに、シェーダーはミップマップの1つのレベルから1つの法線しか見ないので、ハイライトは有るか無いかのどちらかになり、カメラが動くと瞬きます。
多くの違う法線の平均は短いベクトルです。
其れを正規化すると、鋭いハイライトを持つ滑らかな鏡を表してしまいますが、ピクセルの下の面は本当は粗く、ハイライトは広く暗いはずです。

Toksvigの手法(Michael Toksvig、NVIDIA、2004年)は、此の短さを情報として使います。
平均した正規化前の法線を𝐧aとすると、ブリン・フォンの指数s(第10章)をToksvig係数で小さくします:

ft=11+s(1‖𝐧a‖−1),s′=fts

計算例。 (0.6,0,0.8)と(−0.6,0,0.8)を平均すると𝐧a=(0,0,0.8)、長さは0.8です。
s=64ならft=11+64⋅(1.25−1)=117≈0.0588、s′=6417≈3.76です。
とても広く暗いハイライトで、凸凹の面を遠くから見た時の見え方です。
法線が揃っている所では‖𝐧a‖=1なので、何も変わりません。
物理ベースの材質(第22章)では、同じ考えがラフネスフィルタリングになります。
各ミップレベルのラフネスを、其のレベルが平均した法線の分散だけ大きくするのです。
法線のスクリーンスペースの微分から、ピクセルごとにラフネスを広げるエンジンもあります。
教訓は、テクスチャだけでなく材質をプリフィルタする事です。

検定のポイント

第2章と第6章のアンチエイリアシングに繋がる点です:

  • プリフィルタリング(エリアサンプリング、ミップマップ)は標本化の前に高い周波数を取り除き、ポストフィルタリング(スーパーサンプリング、MSAA、TAA)は多く標本化してから後でフィルタをかけます。
  • SSAAは全てのサンプルをシェーディングします。MSAAはカバレッジと深度をサンプルごとに判定しますが、シェーディングはピクセルごと・三角形ごとに1回です。輪郭と交差線は滑らかになりますが、シェーディングとアルファテストのエイリアシングは残ります。
  • ポストプロセスAAは完成した画像に対して働き(安価、サブピクセルの細部は戻せない)、TAAはジッタしたフレームを再投影と履歴のクランプで蓄積します(ゴーストとぼけが其のアーティファクト)。

20.8 リアルタイムの透明表現:問題を振り返る

物体単位と三角形単位の並べ替え

10.12節では、over演算子C=αsCs+(1−αs)Cdが順番に依存する事を見ました。
半透明の面は、不透明な面の後に、深度の書き込みを無効にして奥から手前へ混ぜなければなりません。
順番はピクセルごとに正しくなければならないのに、GPUは三角形を届いた順に描きます。
エンジンは出来る範囲で並べ替えます:

  • 物体単位:各物体の中心の距離で、毎フレーム並べ替えます。安価ですが、交わる物体や循環して重なる物体(10.2節)、大きな物体では間違えます。
  • 三角形単位:例えば、幾つかの視線方向に沿って前もって並べたインデックスバッファを持ち、今の視線に一番近い物を選びます。ガラスの物体1つには良いですが、三角形同士が交わる所ではやはり間違えます。
  • 裏面を先に:閉じた物体の裏面を描いてから表面を描くと、凸な形の内側では正しい順番になります。

どれも正確ではありません。
順序非依存の半透明(OIT)とは、結果が送られた順番に依存しない手法の事です。

乗算済みアルファとアンダー演算

OITの手法の幾つかは、手前から奥へ合成します。
乗算済みアルファ(第14章の14.10節)では、フラグメントを(α𝐜,α)として保存し、overは保存した色を其のまま足す𝐂=αs𝐜s+(1−αs)𝐂dになります。
GPUではブレンド係数(ONE、ONE_MINUS_SRC_ALPHA)です。
手前から奥へ合成する時は、蓄積した乗算済みの色𝐂(0から始める)と、其れまでの手前の全ての透過率T(1から始める)を持ちます:

𝐂←𝐂+Tαi𝐜i,T←T(1−αi)

最後に背景を足します:𝐂+T𝐂bg。
此れがアンダー演算です。
第10章のガラスでの計算例です。
青(0,0,1)が赤(1,0,0)の手前にあり、どちらもα=0.5、背景は白です。
青で𝐂=(0,0,0.5)、T=0.5、赤で𝐂=(0,0,0.5)+0.25(1,0,0)=(0.25,0,0.5)、T=0.25、背景が0.25(1,1,1)を足して(0.5,0.25,0.75)です。
10.12節の「赤が先、次に青」の結果と完全に同じです。
アンダーとoverはリストの反対の端から始めるだけで同じ絵になり、手前から奥へならTが殆ど0になった所で打ち切れます。

共通の例

以下の節では、1つのピクセルに3つのフラグメントが此の順に届く例を使います。
背景は白です:

到着順 色 α 距離
1 赤 (1, 0, 0) 0.4 4
2 青 (0, 0, 1) 0.8 6
3 緑 (0, 1, 0) 0.5 3

手前から奥へ(緑、赤、青)並べてアンダーで合成します:

  • 緑:𝐂=0.5(0,1,0)=(0,0.5,0)、T=0.5
  • 赤:𝐂=(0,0.5,0)+0.5⋅0.4(1,0,0)=(0.2,0.5,0)、T=0.5⋅0.6=0.3
  • 青:𝐂=(0.2,0.5,0)+0.3⋅0.8(0,0,1)=(0.2,0.5,0.24)、T=0.3⋅0.2=0.06
  • 背景:(0.2,0.5,0.24)+0.06(1,1,1)=(0.26,0.56,0.30)

此れが正確な答えです。
届いた順にoverで混ぜると(0.1,0.56,0.46)になり、青過ぎます。
奥にある殆ど不透明な青いガラスを、赤の上に混ぜてしまったからです。

20.9 デプスピーリング

デプスピーリング(Cass Everitt、NVIDIA、2001年)は、半透明の形状を何度も描き、1パスに1層ずつ「剥がす」事で、深度テストだけでピクセルごとの順番を求めます:

  • パス1:普通の深度テストで描きます。深度バッファには、ピクセルごとに一番手前の半透明の面が残ります。此れがレイヤー1です。
  • パスk:前のレイヤーの深度をテクスチャに入れて、もう一度描きます。シェーダーは、前のレイヤーより奥に無いフラグメントを捨てます。残りの中から、普通の深度テストが一番手前を残します。此れがレイヤーkです。
  • 各パスの後、新しいレイヤーを其れまでのレイヤーの下に(手前から奥へ)合成します。
  • フラグメントが1つも出ないパス(オクルージョンクエリで数えます)か、決めた回数で止めます。

不透明な形状を先に描き、其の深度もパスを制限するので、其の陰に隠れた物は剥がされません。

計算例。 共通の例で、フラグメントは毎パス赤、青、緑の順に届きます:

パス 捨てる条件:深度 ≤ 赤 (4) 青 (6) 緑 (3) レイヤー アンダー後のC T
1 (無し) 書き込み(バッファは空) 失敗 (6 > 4) 勝ち (3 < 4) 緑、3 (0, 0.5, 0) 0.5
2 3 書き込み 失敗 (6 > 4) 捨てる (3 ≤ 3) 赤、4 (0.2, 0.5, 0) 0.3
3 4 捨てる (4 ≤ 4) 書き込み 捨てる 青、6 (0.2, 0.5, 0.24) 0.06
4 6 捨てる 捨てる 捨てる 無し:停止

背景を足すと(0.2,0.5,0.24)+0.06⋅(1,1,1)=(0.26,0.56,0.30)で、間違った順番で送られた形状から、並べ替えた結果が得られました。

重なり合う3枚の半透明の四角形、緑、赤、他の2枚を貫通する傾いた青のデプスピーリング。最初の3枚はパス1、2、3で剥がしたレイヤーで、各ピクセルに残っている一番手前の面を示す。最後の1枚は市松模様の上の合成結果

図では傾いた青の四角形が他の2枚を貫通しているので、各四角形の内側でレイヤーの順番が変わります。
物体単位の並べ替えでは正しく出来ませんが、デプスピーリングはピクセルごとに正しく出来ます。
コストはパスの数です。
半透明のレイヤーが8枚なら、全ての半透明の形状を8回描き、更に何も見つからない1回が要ります。
デュアルデプスピーリング(Louis BavoilとKevin Myers、NVIDIA、2008年)は、深度の最小/最大ブレンドで手前と奥を同じパスで剥がすので、N層が約N2+1パス、8層なら5パスで済みます。
デプスピーリングは、速さより正確さが大事な所(CAD、科学的可視化)や、速い手法を確かめる為の正解として今も使われています。

20.10 ピクセルごとの連結リスト:GPU上のAバッファ

第10章の10.7節のAバッファは、ピクセルごとにフラグメントのリストを持ち、並べ替えてから合成します。
2010年頃から、GPUはシェーダーのアトミック操作を使って、此の様なリストを1パスで作れる様になりました(Jason Yang、Justin Hensley、Holger Grün、Nicolas Thibieroz、「Real-Time Concurrent Linked List Construction on the GPU」、2010年)。
必要なバッファは3つです。
先頭ポインタ画像(ピクセルごとに32ビットの添え字1つ、毎フレーム「終端」の値でクリア)、ノードプール(色、深度、次のノードの添え字を持つノードの大きなバッファ1つ)、そして使ったノードのカウンタです。

ピクセルごとの連結リスト。ピクセルPが5を持つ3×2の先頭ポインタ画像と、全ピクセルで共有する6個のノードのプール。ピクセルPのリストはノード5(緑、深度3)からノード2(青、深度6)、ノード0(赤、深度4)、終端へと続き、他のピクセルのノードと入り混じっている

半透明のフラグメントは其々、どんな順番でも、何千もの他のフラグメントと並列に、次の事を行います:

  1. index = atomicAdd(counter, 1)でノードを確保します。古い値を返して1増やすので、2つのフラグメントが同じ添え字を得る事はありません(プールが一杯ならフラグメントを捨てます)。
  2. oldHead = atomicExchange(head[pixel], index)で、其のピクセルのリストの先頭に繋ぎます。
  3. ノードに色、深度、next = oldHeadを書き込みます。

リストは到着の逆順になります。
図では、ピクセルPのフラグメントは赤、青、緑の順に届き、ノード0、2、5を得ました(其の間に他のピクセルが1、3、4を取りました)。
従ってPのリストは5(緑、3)→ 2(青、6)→ 0(赤、4)→ 終端です。
不透明な形状を先に描き、半透明のシェーダーは早期深度テスト(GLSLではlayout(early_fragment_tests) in;、SlangとHLSLでは[earlydepthstencil])を使うべきです。
隠れたフラグメントがノードを確保しない様にする為です。

struct FragmentNode {
  uint color;   // premultiplied RGBA, 8 bits per channel
  float depth;
  uint next;    // index of the next node, or END
};

static const uint END = 0xFFFFFFFF;

RWTexture2D<uint> heads;                 // cleared to END every frame
RWStructuredBuffer<FragmentNode> nodes;  // the shared node pool
RWStructuredBuffer<uint> counter;        // counter[0] = nodes used, cleared to 0

uint packColor(float4 c) {
  uint4 u = uint4(saturate(c) * 255.0 + 0.5);
  return u.r | (u.g << 8) | (u.b << 16) | (u.a << 24);
}

void insertFragment(uint2 pixel, float4 premul, float depth, uint capacity) {
  uint index;
  InterlockedAdd(counter[0], 1, index);
  if (index >= capacity)
    return;                              // pool full: drop the fragment
  uint oldHead;
  InterlockedExchange(heads[pixel], index, oldHead);
  FragmentNode n;
  n.color = packColor(premul);
  n.depth = depth;
  n.next = oldHead;
  nodes[index] = n;
}
struct FragmentNode {
  uint color;   // premultiplied RGBA, 8 bits per channel
  float depth;
  uint next;    // index of the next node, or END
};

const uint END = 0xFFFFFFFFu;

layout(set = 0, binding = 0, r32ui) uniform coherent uimage2D heads;      // cleared to END
layout(set = 0, binding = 1, std430) buffer Nodes { FragmentNode nodes[]; };
layout(set = 0, binding = 2, std430) buffer Counter { uint counter; };   // cleared to 0

uint packColor(vec4 c) {
  uvec4 u = uvec4(clamp(c, 0.0, 1.0) * 255.0 + 0.5);
  return u.r | (u.g << 8) | (u.b << 16) | (u.a << 24);
}

void insertFragment(ivec2 pixel, vec4 premul, float depth, uint capacity) {
  uint index = atomicAdd(counter, 1u);
  if (index >= capacity)
    return;                              // pool full: drop the fragment
  uint oldHead = imageAtomicExchange(heads, pixel, index);
  nodes[index].color = packColor(premul);
  nodes[index].depth = depth;
  nodes[index].next = oldHead;
}
struct FragmentNode {
  uint color;   // premultiplied RGBA, 8 bits per channel
  float depth;
  uint next;    // index of the next node, or END
};

static const uint END = 0xFFFFFFFF;

RWTexture2D<uint> heads;                 // cleared to END every frame
RWStructuredBuffer<FragmentNode> nodes;  // the shared node pool
RWStructuredBuffer<uint> counter;        // counter[0] = nodes used, cleared to 0

uint packColor(float4 c) {
  uint4 u = uint4(saturate(c) * 255.0 + 0.5);
  return u.r | (u.g << 8) | (u.b << 16) | (u.a << 24);
}

void insertFragment(uint2 pixel, float4 premul, float depth, uint capacity) {
  uint index;
  InterlockedAdd(counter[0], 1, index);
  if (index >= capacity)
    return;                              // pool full: drop the fragment
  uint oldHead;
  InterlockedExchange(heads[pixel], index, oldHead);
  FragmentNode n;
  n.color = packColor(premul);
  n.depth = depth;
  n.next = oldHead;
  nodes[index] = n;
}

其の後、フルスクリーンのリゾルブパスが各リストを辿り、ノードを小さなローカル配列(例えば最大16個)にコピーし、深度で並べ替え(こんなに短いリストには挿入ソートが一番速いです)、不透明な画像の上に合成します。
ピクセルPでは、緑、青、赤の順に読み、緑、赤、青に並べ替え、20.8節と同じ(0.26,0.56,0.30)を得ます。

メモリの計算。 ノード1つは4+4+4=12バイトです。
1920×1080で、半透明のフラグメントがピクセルあたり平均4個なら、プールは2073600⋅4=8294400ノード、8294400⋅12=99532800バイト、約94.9 MiBが要り、更に先頭ポインタに7.9 MiBが要ります。
本当の問題は、数が前もって分からない事です。
カメラの前のパーティクルは、1フレームで1ピクセルに50個のフラグメントを作る事もあり、溢れたプールはフラグメントを捨てて穴を残します。
エンジンは最悪の場合に合わせて確保するか、溢れた後にプールを大きくするか、ピクセルごとに一番手前のk個だけを残します(kバッファ)。
kバッファは、ピクセルのフラグメントシェーダーの実行順を保証する新しいハードウェア機能(Direct3D 12のラスタライザー順序ビュー、Vulkanのフラグメントシェーダーインターロック)で実用的になりました。
連結リストは、何も捨てられない限り、1回の形状のパスで正確です。

20.11 重み付きブレンドによる順序非依存の半透明

正確さを諦める

正確な手法には、多くのパスか、上限の無いメモリが要ります。
重み付きブレンドOIT(WBOIT、Morgan McGuireとLouis Bavoil、Journal of Computer Graphics Techniques、2013年)は逆の道を取ります。
1パス、レンダーターゲット2枚、固定のメモリ、そして順番を全く持たない近似です。

正確な結果の内、2つは順番に依存しません。
背景がどれだけ隠れるか1−∏i(1−αi)と、背景の寄与𝐂0∏i(1−αi)です。
順番に依存するのは、覆われた部分の中の色の混ざり方だけです。
WBOITは其の混ざり方を重み付き平均で置き換えます:

𝐂f=∑iαi𝐜iwi∑iαiwi(1−∏i(1−αi))+𝐂0∏i(1−αi)

重みはwi=w(zi,αi)>0です。
和と積は可換なので、固定機能のブレンディングがどんな順番でも蓄積出来ます:

  • 蓄積ターゲット(RGBA16F、0でクリア、ブレンドONE、ONE):各フラグメントが(αi𝐜iwi,αiwi)を足します。
  • リビーリッジターゲット(R8かR16F、1でクリア、ブレンドZERO、ONE_MINUS_SRC_COLOR):各フラグメントがαiを書き、ブレンドが保存された値に1−αiを掛けます。
  • フルスクリーンの合成パスが和を割り、リビーリッジを背景の重みとして、平均を不透明な画像の上に混ぜます。

不透明な形状に対する深度テストは有効のまま、深度の書き込みは無効のままです。
wi=1なら色は不透明度で平均されるだけです。
論文の洞察は、wを距離と共に小さくして、現実と同じく近い面が支配する様にした事です。
提案された重み関数の1つは、ビュー空間の距離zに対して次の通りです:

w(z,α)=α⋅max(10−2,min(3×103,1010−5+(z/5)2+(z/200)6))

16ビット浮動小数点のターゲットと0.1〜500の距離に合わせて調整されています。
此の例の距離では10−5と(z/200)6の項は無視出来るので、w≈α⋅10(z/5)2です。

計算例

共通の例です。
赤α=0.4が距離4、青α=0.8が6、緑α=0.5が3、背景は白です。
リビーリッジは0.6⋅0.2⋅0.5=0.06なので、レイヤーは0.94を覆い、並べ替えた結果と完全に同じです。

w=1の場合、平均は色をαiで重み付けします:

0.4(1,0,0)+0.8(0,0,1)+0.5(0,1,0)1.7≈(0.235,0.294,0.471)
𝐂f≈0.94⋅(0.235,0.294,0.471)+0.06(1,1,1)≈(0.281,0.336,0.502)

正確な答えは(0.26,0.56,0.30)でした。
手前にある緑が支配するべきなのに、一番不透明な青が支配しています。

深度の重みの場合:緑は100.62≈27.78、赤は100.82=15.625、青は101.22≈6.944です。
α(wの中)と、もう一度α(乗算済みの色)を掛けると、取り分は緑0.25⋅27.78≈6.944、赤0.16⋅15.625=2.5、青0.64⋅6.944≈4.444、合計13.889です:

2.5(1,0,0)+6.944(0,1,0)+4.444(0,0,1)13.889≈(0.18,0.50,0.32)
𝐂f≈0.94⋅(0.18,0.50,0.32)+0.06(1,1,1)≈(0.229,0.530,0.361)

ずっと近くなり、チャンネルごとの最大の誤差は0.224から0.061に下がります。
重みは、レイヤーが深さ方向に散らばっていて、あまり不透明でない時に上手く行く経験則です。
どんな重み付き平均も、「手前のレイヤーが奥のレイヤーを隠す」事は表せません。

同じ3枚の四角形を市松模様の上に4通りに合成した物。物体単位の並べ替えは、傾いた青が手前に来る所で間違える。ピクセル単位の並べ替えは正確な参照。重み1のWBOITは重なりが青過ぎる。深度の重みのWBOITは参照に近い

画像全体では、チャンネルごとの平均誤差は、物体単位の並べ替えが約0.018ですが、大きな間違った領域に集中しています(最大0.48)。
w=1は0.029、深度の重みは0.020で、薄く広がっています(最大は其々0.32と0.22)。
WBOITは物体の順番が入れ替わっても絵が飛びません。
決してちらつかない誤差は、飛ぶ正しい絵より普通は目立たないので、ゲームのパーティクル、煙、草木、ガラスによく使われます。

struct OitOutput {
  float4 accum : SV_Target0;     // blend ONE, ONE; cleared to 0
  float revealage : SV_Target1;  // blend ZERO, ONE_MINUS_SRC_COLOR; cleared to 1
};

// premul = (alpha * color, alpha); viewDepth = distance from the camera
OitOutput oitAccumulate(float4 premul, float viewDepth) {
  float d = viewDepth / 5.0;
  float w = premul.a * clamp(10.0 / (1e-5 + d * d + pow(viewDepth / 200.0, 6.0)), 1e-2, 3e3);
  OitOutput o;
  o.accum = premul * w;
  o.revealage = premul.a;
  return o;
}
layout(location = 0) out vec4 outAccum;       // blend ONE, ONE; cleared to 0
layout(location = 1) out float outRevealage;  // blend ZERO, ONE_MINUS_SRC_COLOR; cleared to 1

// premul = (alpha * color, alpha); viewDepth = distance from the camera
void oitAccumulate(vec4 premul, float viewDepth) {
  float d = viewDepth / 5.0;
  float w = premul.a * clamp(10.0 / (1e-5 + d * d + pow(viewDepth / 200.0, 6.0)), 1e-2, 3e3);
  outAccum = premul * w;
  outRevealage = premul.a;
}
struct OitOutput {
  float4 accum : SV_Target0;     // blend ONE, ONE; cleared to 0
  float revealage : SV_Target1;  // blend ZERO, ONE_MINUS_SRC_COLOR; cleared to 1
};

// premul = (alpha * color, alpha); viewDepth = distance from the camera
OitOutput oitAccumulate(float4 premul, float viewDepth) {
  float d = viewDepth / 5.0;
  float w = premul.a * clamp(10.0 / (1e-5 + d * d + pow(viewDepth / 200.0, 6.0)), 1e-2, 3e3);
  OitOutput o;
  o.accum = premul * w;
  o.revealage = premul.a;
  return o;
}

合成パスは、色accum.rgbmax(accum.a,10−4)とアルファ=リビーリッジrを出力し、ブレンド係数(ONE_MINUS_SRC_ALPHA、SRC_ALPHA)で混ぜます。
此れで(1−r)⋅average+r⋅background、つまり上の式になります。
2つのターゲットは違うブレンド設定が必要ですが、現在のAPIは全て其れを許しています。

20.12 其の他の手法と比較

更に3つの手法を、名前だけでも知っておく価値があります:

  • モーメントベースOIT(Cedrick Münstermann、Stefan Krumpen、Reinhard Klein、Christoph Peters、2018年):最初のパスで、各ピクセルで吸収(−ln(1−α))が深さ方向にどう分布しているかのモーメントを幾つか保存します。2番目のパスで、其処から各フラグメントの手前の透過率を再構成し、其れで合成します。WBOITと同じく固定のメモリですが、並べ替えた結果にずっと近くなります。
  • 確率的透明(Eric Enderton、Erik Sintorn、Peter Shirley、David Luebke、2010年):αを、面がMSAAの各サンプルを覆う確率として扱い(ランダムなマスクによるアルファトゥカバレッジ)、サンプルごとの普通の深度テストを使います。順序非依存で平均すれば正しいですが、十分なサンプルやフレームを平均するまではノイズが出ます。
  • レイトレーシング:ハードウェアのレイトレーシングなら、各レイに沿って半透明の交点を順番に見つけ、屈折も出来ます(第12章)。但し、重いパーティクルのエフェクトにはまだ高過ぎます。

手法 形状のパス数 メモリ 正確? 主な用途
物体単位の並べ替え 1 追加無し いいえ(交差、循環) 殆どのエンジンの標準
デプスピーリング レイヤー数 + 1 深度バッファ2枚、色 はい CAD、可視化、参照画像
デュアルデプスピーリング 約 レイヤー数/2 + 1 最小/最大深度、色 はい 同上、より速い
ピクセルごとの連結リスト 1 + リゾルブ フラグメントと共に増える はい(プールが溢れなければ) 髪、ガラス、可視化
重み付きブレンドOIT 1 + 合成 レンダーターゲット2枚 いいえ(色の混ざり方が近似) パーティクル、煙、草木
モーメントベースOIT 2 + 合成 モーメントのターゲット数枚 いいえ(近い) ゲーム、可視化
確率的透明 1以上、MSAA付き MSAAのバッファ 平均すれば(ノイズあり) 髪、草木、TAAと併用

エンジンは此れらを組み合わせます。
標準は物体単位の並べ替え、パーティクルや髪にはOIT、水の入ったグラスの様な一部の物には専用の工夫、と言った具合です。
ブレンディングと並べ替えは、OpenGLシリーズの第16章で自分で設定します。

検定のポイント

第10章の半透明と第14章の合成に繋がる点です:

  • overは順番に依存します。半透明の面は不透明な面の後に、深度テストは有効、深度の書き込みは無効で、奥から手前へ描きます。加算ブレンディングは順番に依存しません。
  • アンダーで手前から奥へ(𝐂←𝐂+Tαi𝐜i、T←T(1−αi))合成しても、overで奥から手前へ合成したのと同じ結果になります。
  • Aバッファ(GPU版はピクセルごとの連結リスト)とデプスピーリングは正確です。重み付きブレンドOITは覆う割合1−∏(1−αi)は正確ですが、色の混ざり方は近似です。

20.13 まとめ

  • リアルタイムのエイリアシングは、形状、シェーディング、テクスチャ(ミップマップで対処)、時間から来ます。対策はプリフィルタリングか、サンプルを増やして再構成フィルタ(ボックス、テント、ガウス、ミッチェル・ネトラバリ)でまとめる事です。
  • SSAAは全てのサンプルをシェーディングし、N倍のコストで全てを直します。回転した配置は、ほぼ垂直とほぼ水平の輪郭にN+1段階を与えます。
  • MSAAはカバレッジと深度をN個の標準サンプル位置で判定しますが、シェーディングはピクセルごと・三角形ごとに1回で、最後にリゾルブします。輪郭と交差線は滑らかになりますが、シェーディングとアルファテストのエイリアシングは残ります(サンプルシェーディングとアルファトゥカバレッジが助けになります)。メモリはSSAAと同じで、ディファードシェーディングでは高く付きます。
  • MLAA、FXAA、SMAAは完成した画像から階段を見つけ、推測した輪郭に沿って混ぜます。安価ですが、サブピクセルの細部が見えず、動きに弱いです。
  • TAAはハルトン列で投影をジッタさせ、モーションベクトルで履歴を再投影し、現在の近傍にクランプし、指数移動平均で混ぜます。ゴーストとぼけを代償に全てのエイリアシングを扱い、時間方向アップスケーラーの土台です。
  • 鏡面反射のエイリアシングは材質をプリフィルタして直します(Toksvig係数、ラフネスフィルタリング)。
  • 透明表現にはピクセルごとの順番が要ります。並べ替えは近似で、デプスピーリング(レイヤーごとに1パス)とピクセルごとの連結リスト(1パス、上限の無いメモリ)は正確、重み付きブレンドOITは1パスで固定のメモリ、色の混ざり方は近似ですが安定しています。

20.14 用語

English 日本語
geometric aliasing 幾何学的エイリアシング
shading aliasing / specular aliasing シェーディングのエイリアシング/鏡面反射のエイリアシング
temporal aliasing 時間方向のエイリアシング
reconstruction filter 再構成フィルタ
box filter / tent filter ボックスフィルタ/テントフィルタ
Gaussian filter ガウシアンフィルタ
Mitchell–Netravali filter ミッチェル・ネトラバリフィルタ
supersampling (SSAA) スーパーサンプリング
ordered grid / rotated grid 格子配置/回転格子
multisample anti-aliasing (MSAA) マルチサンプルアンチエイリアシング
standard sample locations 標準サンプル位置
coverage, coverage mask カバレッジ(被覆率)、カバレッジマスク
resolve リゾルブ
centroid sampling セントロイドサンプリング
sample-rate shading サンプルシェーディング
alpha to coverage アルファトゥカバレッジ
post-process anti-aliasing (MLAA, FXAA, SMAA) ポストプロセスアンチエイリアシング
luma, local contrast ルーマ、局所コントラスト
temporal anti-aliasing (TAA) 時間方向アンチエイリアシング(TAA)
jitter ジッタ
Halton sequence, low-discrepancy sequence ハルトン列、低食い違い量列
exponential moving average 指数移動平均
history 履歴
motion vector モーションベクトル
reprojection 再投影(リプロジェクション)
disocclusion ディスオクルージョン
neighborhood clamping, variance clipping 近傍クランプ、分散クリッピング
ghosting ゴースト(ゴースティング)
temporal upscaling 時間方向アップスケーリング
Toksvig factor, roughness filtering Toksvig係数、ラフネスフィルタリング
order-independent transparency (OIT) 順序非依存の半透明(OIT)
under operator アンダー演算
transmittance 透過率
depth peeling, dual depth peeling デプスピーリング、デュアルデプスピーリング
per-pixel linked list ピクセルごとの連結リスト
atomic operation アトミック操作
weighted blended OIT 重み付きブレンドOIT
accumulation, revealage 蓄積、リビーリッジ(背景の見える割合)
moment-based OIT モーメントベースOIT
stochastic transparency 確率的透明(ストキャスティック透明)

20.15 課題

  1. ★ 2560×1440のフレームが、サンプルごとにRGBA16Fの色(8バイト)と32ビットの深度(4バイト)を持つ8× MSAAを使います。サンプルバッファは何バイト、何MiBで、1サンプルのRGBA16Fのリゾルブ先はどれだけ加わりますか? 8× SSAAなら1フレームにフラグメントシェーダーを何回実行しますか?
  2. ★ ハルトン列の点9〜12(基数2と3)を桁の鏡写しで求め、ピクセル単位のジッタと、2560×1440の画面でのNDCの平行移動量を求めて下さい。ハルトン列の図に書き込み、最初の8点の隙間を埋める事を確かめて下さい。
  3. ★★ 段の長さが8ピクセルの階段があります。MLAAの計算例に倣って、段の中点を通る輪郭を再構成し、1段の8ピクセルのブレンドの重みを求めて下さい。重みが左右対称で、図形の総面積が保たれる事を確かめて下さい。
  4. ★★ TAAが0.1の代わりにα=0.2を使います。最後の4フレームの重み、等しく平均した場合に相当するフレーム数、新しい値の90%に届くまでのフレーム数を求めて下さい。次に、ジッタしたフレームの半分にしか映らない細い電線に、近傍クランプが何をするかを説明して下さい。
  5. ★★★ 乗算済みの2つのレイヤーを手前から奥へアンダーで合成した結果が、奥から手前へoverで合成した結果と等しい事を式で示して下さい。次に、全てのレイヤーが同じ色なら重み付きブレンドOITが正確になる事を示し、大きく間違える2レイヤーの例(殆ど不透明な手前のレイヤーと、違う色の奥のレイヤー)を作って下さい。w=1での誤差を計算し、深度の重みで其れがどう変わるかを述べて下さい。

選択問題

問1 ★ 次のうち、MSAAについて正しい物はどれか。

あ MSAAはサンプルごとにフラグメントシェーダーを1回実行するので、4× MSAAのシェーディングのコストは4× SSAAと同じである。
い MSAAは、三角形の内側の鏡面反射のエイリアシングや其の他のシェーディングのエイリアシングも取り除く。
う 4× MSAAで大きくなるのは深度バッファだけで、カラーバッファはMSAA無しと同じメモリで済む。
え MSAAは、追加のメモリも追加のライティングの仕事も無しに、ディファードシェーディングで使える。
お MSAAは1ピクセルの複数のサンプル点でカバレッジと深度を判定するが、フラグメントシェーダーは普通、其のピクセルを覆う三角形ごとに1回だけ実行する。

問2 ★ 次のうち、時間方向アンチエイリアシング(TAA)について正しい物はどれか。

あ TAAは毎フレーム、1ピクセルに複数のサンプルを描き、其のフレームの中で平均する。
い TAAは毎フレーム違うサブピクセルのずれで投影をずらし、現在のフレームを、モーションベクトルで再投影した履歴と混ぜる。
う TAAは、カメラだけが動く限り、モーションベクトルを必要としない。
え 履歴を現在の近傍の色の範囲にクランプするのは、履歴の重みを増やしてより多くのフレームを平均する為である。
お FXAAの様なポストプロセス手法と違い、TAAは決してゴーストやぼけを生まない。

問3 ★★ あるピクセルに、赤(1,0,0)と青(0,0,1)の2つの半透明のフラグメントがあり、どちらもα=0.5で、背景は黒(0,0,0)である。重みを全てw=1とした重み付きブレンドOITの結果の色はどれか。

あ (0.375, 0, 0.375)
い (0.25, 0, 0.5)
う (0.5, 0, 0.25)
え (0.5, 0, 0.5)
お (0.75, 0, 0.75)

20.16 実践課題

1つのピクセルを手でアンチエイリアスし、合成する。
方眼紙、定規、色鉛筆、電卓を用意して下さい。
1つのピクセルを、1サンプルの描画、4× MSAA、8フレームのTAAのジッタで追い掛け、次に半透明のピクセルを3通りに合成し、全てを正確な答えと比べます。

ピクセルは単位正方形で、原点は左上の角、yは下向きです。
2つの三角形が直線y=0.2+0.5xで接しています。
三角形Bは直線の上の部分(y<0.2+0.5x)、三角形Aは下の部分です。
Aはオレンジ(0.9,0.6,0.1)、Bは青(0.1,0.3,0.8)です。

  1. 描く。 ピクセルを10 cmの正方形で描き、(0,0.2)から(1,0.7)への直線を引き、Bを塗ります。ピクセル中心と、20.4節の4×標準配置の4つのサンプル位置に0〜3の番号を付けて印を付けて下さい。
  2. 正確な覆い具合。 Bの面積(台形です)と、面積で重み付けしたピクセルの正確な色を求めて下さい。
  3. 1サンプル。 ピクセル中心を覆う三角形を決め、1ピクセル1サンプルでの色を書いて下さい。
  4. 4× MSAA。 各サンプルをy<0.2+0.5xで判定し、2つのカバレッジマスクを4ビットの数で書き、フラグメントシェーダーが何回走るかを答え、リゾルブした色を求め、Bのシェーダーがセントロイドサンプリング有りと無しで何処で評価されるかを述べて下さい。
  5. TAA。 最初の8つのハルトン点(基数2と3)を桁の鏡写しで自分で求め、ピクセルに書き込み、Bに入る数を数え、8つの1サンプルの結果を等しい重みで平均して下さい。
  6. 半透明、並べ替えあり。 別のピクセルに、黒い背景の上で3つの半透明のフラグメントが、黄(1,1,0)が距離5、シアン(0,1,1)が距離2、マゼンタ(1,0,1)が距離3の順に届きます。αは全て0.5です。奥から手前へoverで、手前から奥へアンダーで(各フラグメントの後の𝐂とTを書きます)、そして到着順にoverで合成して下さい。
  7. デプスピーリングと連結リスト。 到着順に対して、20.9節の様なパスの表を作って下さい。次に、3回の挿入後(到着順にノード0、1、2)の先頭ポインタとノードプールを描き、リゾルブが読む順番を書いて下さい。
  8. WBOIT。 w=1での重み付きブレンドOITの結果を求め、並べ替えた結果と比べて下さい。式の何処が正確で、何が失われましたか?

自分で確かめる方法:

  • 手順2のBの面積は、両端での直線の高さの平均0.2+0.72です。正確な色はAとBの間にあり、Aに近くなります。
  • 手順4では、2つのマスクを合わせると4ビット全てが立ち、重なるビットはありません。MSAAの色は、サンプル数で重み付けしたAとBの平均です。
  • 手順5では、8つのxの値は全て違う116の倍数で、Bの覆い具合の見積もりは、1サンプルの結果よりずっと手順2に近くなります。
  • 手順6では、overとアンダーが一致し、残った透過率は0.53=0.125です。到着順の結果は異なります。
  • 手順7では、レイヤーは手前から順に出て来て、最後のレイヤーの次のパスは何も見つけず、リストは到着の逆順に読まれます。
  • 手順8では、1−∏(1−αi)が手順6の1−Tと等しく、3つの色が等しい重みで平均されるので、WBOITの色は灰色になります。


コメント(0)

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