このノートでは Metal でのシャドウマッピングと、ほぼ全員が踏むいくつかの落とし穴を扱います。
基本の考え方
シャドウマッピングは一文で言えます。光源の視点からシーンの深度を描画する。 そしてメイン描画のとき、各フラグメントをライト空間へ変換し、その深度をシャドウマップに記録された深度と比較します。より遠ければ何かが間を遮っていた、つまり影の中だということです。
したがって二パスのアルゴリズムになります。
パス 1:深度パス
必要なのは深度アタッチメントだけで、カラーは要りません。
Swiftlet shadowDescriptor = MTLRenderPassDescriptor()
shadowDescriptor.depthAttachment.texture = shadowMap
shadowDescriptor.depthAttachment.loadAction = .clear
shadowDescriptor.depthAttachment.storeAction = .store // メインパスが読む
shadowDescriptor.depthAttachment.clearDepth = 1.0
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: shadowDescriptor)!
encoder.setRenderPipelineState(shadowPipelineState) // 頂点関数のみ
encoder.setDepthStencilState(depthStencilState)
ここで storeAction が .store でなければならない点に注意してください。前回述べた「後続のパスが読むのでないかぎり」の、まさにその例外です。
シャドウ用パイプラインではフラグメント関数を完全に省略できます(fragmentFunction = nil)。必要なのは深度だけだからです。
Swiftlet pd = MTLRenderPipelineDescriptor()
pd.vertexFunction = library.makeFunction(name: "shadow_vertex")
pd.fragmentFunction = nil
pd.depthAttachmentPixelFormat = .depth32Float
これで深度パスは大幅に速くなります。メインパスよりはるかに安いのはこのためです。
頂点関数がすることは一つだけです。
MSLvertex float4 shadow_vertex(VertexIn in [[stage_in]],
constant float4x4 &lightMVP [[buffer(11)]])
{
return lightMVP * in.position;
}
lightMVP は光源のビュー・プロジェクションにモデル行列を掛けたものです。ディレクショナルライトは平行投影(光線が平行)、ポイントライトは透視投影で、立方体の六面すべてを描く必要があります。
パス 2:比較
メインパスでは、頂点関数がライト空間の位置を追加で出力します。
MSLout.shadowPosition = uniforms.lightViewProjection * posWS;
フラグメント関数では除算し、[0,1] の UV に写して比較します。
MSLfloat3 proj = in.shadowPosition.xyz / in.shadowPosition.w; // 平行投影なら w は 1 だが除算は残す
float2 uv = proj.xy * 0.5 + 0.5;
uv.y = 1.0 - uv.y; // Metal のテクスチャ原点は左上
float closest = shadowMap.sample(shadowSampler, uv).r;
float current = proj.z; // Metal の深度は既に [0,1]
float shadow = current > closest ? 0.0 : 1.0;
Metal 固有の細部が二つ。テクスチャの y 軸を反転する必要があること(NDC は中心が原点で y が上向き、テクスチャは左上が原点で y が下向き)、そして深度を [-1,1] から [0,1] へ写し直す必要はないこと。Metal の NDC 深度はもともと [0,1] なので、OpenGL のチュートリアルから写した proj.z * 0.5 + 0.5 はここでは誤りです。
シャドウマップのサンプラーには .clampToEdge を使い、シャドウマップの外側は影ではないとして扱う必要があります。そうしないと、光源の視錐台の外側の世界全体が真っ黒になります。
シャドウアクネと Peter Panning
上のコードをそのまま動かすと、あらゆる面に自己遮蔽の縞が現れます。シャドウアクネです。
原因はシャドウマップの解像度が有限であることです。1 テクセルが面の小さな領域を覆い、その領域内の全点が一つの記録深度を共有する一方、実際の深度は連続的に変化します。結果として半分の点が「記録より遠い」と判定され、誤って影にされます。
直接的な対処はバイアスです。
MSLfloat bias = 0.005;
float shadow = current - bias > closest ? 0.0 : 1.0;
ただし定数バイアスは対症療法です。面が傾くほど 1 テクセルがまたぐ深度の幅は広がり、必要なバイアスも大きくなります。そこで傾斜に応じたバイアスを使います。
MSLfloat bias = max(0.05 * (1.0 - dot(n, l)), 0.005);
バイアスを大きくしすぎると、もう一方の破綻が現れます。Peter Panning——物体が自分の影から目に見えて離れ、宙に浮いて見える現象です。名前は影をなくした少年の物語に由来します。
より根本的な対処は、深度パスで裏面だけを描くことです。
Swiftencoder.setCullMode(.front)
記録される深度は物体の裏側のもの、つまり照らされた面より物体の厚みぶん奥になるため、自己遮蔽は消え、バイアスも不要になります。ただしこれが有効なのは閉じた中身のある物体だけです。片面の壁や葉のカードは影を完全に失います。
実務では両方を組み合わせます。閉じたジオメトリには裏面描画、薄いカードには傾斜バイアスを使います。
ハードウェアによる比較
先ほどの current > closest の比較は、テクスチャユニットに任せられます。しかもハードウェア PCF が無償で付いてきます。Metal では比較サンプラーで公開されています。
Swiftlet sd = MTLSamplerDescriptor()
sd.compareFunction = .lessEqual
sd.minFilter = .linear
sd.magFilter = .linear
shadowSampler = device.makeSamplerState(descriptor: sd)
MSLdepth2d<float> shadowMap [[texture(1)]];
sampler_comparison shadowSampler [[sampler(1)]];
float shadow = shadowMap.sample_compare(shadowSampler, uv, current - bias);
sample_compare は 4 回の比較を行い、深度ではなく比較結果をバイリニア補間して、0 から 1 の柔らかい縁を直接返します。順序が重要です。深度を補間してから比較するのは誤りで、比較してから補間するのが正しい。
PCF:縁を柔らかくする
sample_compare 一回では 1 ピクセル分しか柔らかくなりません。本当に柔らかい影を得るには、近傍で複数回サンプリングして平均します。これが PCF(percentage-closer filtering)です。
MSLfloat pcf(depth2d<float> map, sampler_comparison s, float2 uv, float ref) {
float sum = 0.0;
float texel = 1.0 / 2048.0;
for (int y = -1; y <= 1; y++) {
for (int x = -1; x <= 1; x++) {
sum += map.sample_compare(s, uv + float2(x, y) * texel, ref);
}
}
return sum / 9.0;
}
3×3 が品質とコストの一般的な折衷です。さらに柔らかくしたいときは、カーネルを大きくするよりポアソンディスクサンプリングをピクセルごとの回転と組み合わせるほうが有効です。サンプル数は少なく、誤差は帯ではなくノイズとして現れます。そしてノイズは TAA が取り除いてくれます。
カスケードシャドウマップ
一枚のシャドウマップで視錐台全体を覆うと、近景の解像度が悲惨になります。足元の草が、200 m 先の丘と同じテクセル密度になるわけで、明らかに配分が誤っています。
カスケードシャドウマップ(CSM)は視錐台を深度で分割し、各区間に専用のシャドウマップを与えます。もっとも手前のマップは狭い範囲だけを覆うため、実効解像度が高くなります。
Plain Text|--cascade 0--|-----cascade 1-----|----------cascade 2----------|
0 10m 40m 200m
分割距離は通常、対数分割と一様分割の混合(practical split scheme)を使います。純粋な対数分割では手前にカスケードが集中しすぎるためです。
フラグメントはビュー空間の深度でカスケードを選びます。
MSLuint cascade = 0;
for (uint i = 0; i < cascadeCount - 1; i++) {
if (viewDepth > cascadeSplits[i]) cascade = i + 1;
}
float shadow = pcf(shadowMaps, s, projectToCascade(posWS, cascade), ref);
カスケードには特徴的な問題が二つあります。継ぎ目が見えること(カスケード間の狭い帯でブレンドすれば解決します)と、シャドウスイミング(カメラが動くと影の縁がちらつく。光源の平行投影の範囲が変わり続けるため)です。後者の標準的な対処は、光源投影の中心をシャドウマップのテクセル格子にスナップさせることです。これでサンプル点がワールド空間で固定されます。
次回は遅延レンダリングを扱います。