【ゲーム制作】シャドウマップとは?|影の縞・浮き・ギザギザの原因を、バイアスと解像度の実測で解決

【ゲーム制作】シャドウマップとは?|影の縞・浮き・ギザギザの原因を、バイアスと解像度の実測で解決 ゲーム制作

影を付けたら、床や球がシマシマ模様になった。バイアスを増やしたら、今度は影が物体から浮いてしまった。影の縁がギザギザなのは、解像度を上げれば直るのか。

この記事では、リアルタイムの影の定番手法、シャドウマップを、DirectX11のシェーダーで実装します。仕組みの説明に加えて、影で必ずハマる深度バイアスの調整を、バイアスと解像度を振って実測しました。「なんとなく数字を変えたら直った」ではなく、どの値から縞が消えるのか、大きくしすぎるとどうなるのか、PCFを足すと何が起きるのかを、数字と画像で見ていきます。

シャドウマップとは?

シャドウマップとは、

・光の位置から見た「一番手前にある面までの距離」を1枚のテクスチャ(深度マップ)に記録しておき、カメラから描くときに、それと自分の距離を比べて影かどうかを決める手法

です。手順は2パスです。

  1. 1パス目(深度パス):光の位置にカメラを置いて、シーンを描く。色は要らないので、深度だけを書く
  2. 2パス目(本描画):カメラから普通に描く。ピクセルごとに、自分の位置を光の視点に変換して、深度マップの値と比べる。深度マップのほうが手前(小さい)なら、光が遮られている=影

光の視点から見た深度マップは、こうなっています。深度が同じ場所を線で結んでいます(地形図の等高線のようなものです)。

光の視点から見たシャドウマップの深度を等高線つきで表示した画像
深度マップ(解像度1024)。中央に球の丸い輪郭と、箱・棒のシルエットが見える

平行光源なので、光の視点は「正射影」です。画面の斜めの縞は、床が光に対して斜めに傾いているために、深度がゆるやかに変化していることを表しています。実は、この斜めの縞がこの記事の主役です。

実装:深度マップを作る

まず、深度マップのテクスチャを作ります。同じメモリを「書き込む深度」と「シェーダーで読むテクスチャ」の2通りで見るため、フォーマットはTYPELESSにして、ビューで型を決めます。

▼ShadowMapDemo.cpp
// 同じメモリを2通りに見るので、テクスチャは TYPELESS で作り、ビューで型を決める。
D3D11_TEXTURE2D_DESC td = {};
td.Width = td.Height = res;
td.MipLevels = 1;
td.ArraySize = 1;
td.Format = DXGI_FORMAT_R32_TYPELESS;
td.SampleDesc.Count = 1;
td.Usage = D3D11_USAGE_DEFAULT;
td.BindFlags = D3D11_BIND_DEPTH_STENCIL | D3D11_BIND_SHADER_RESOURCE;
if (FAILED(GetDevice()->CreateTexture2D(&td, NULL, &shadowTex))) return false;

D3D11_DEPTH_STENCIL_VIEW_DESC dv = {};
dv.Format = DXGI_FORMAT_D32_FLOAT;
dv.ViewDimension = D3D11_DSV_DIMENSION_TEXTURE2D;
if (FAILED(GetDevice()->CreateDepthStencilView(shadowTex, &dv, &shadowDsv))) return false;

D3D11_SHADER_RESOURCE_VIEW_DESC sv = {};
sv.Format = DXGI_FORMAT_R32_FLOAT;
sv.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
sv.Texture2D.MipLevels = 1;
if (FAILED(GetDevice()->CreateShaderResourceView(shadowTex, &sv, &shadowSrv))) return false;

1パス目では、色のレンダーターゲットを外して、深度だけを書きます。ピクセルシェーダーも要りません。

▼ShadowMapDemo.cpp
if (shadowOn || view >= 1) {
    if (timing) timerShadow.Begin();
    for (int i = 0; i < rep; i++) {
        ctx->OMSetRenderTargets(0, NULL, shadowDsv);            // 色は無し、深度だけ
        D3D11_VIEWPORT vp = {};
        vp.Width = (FLOAT)res;
        vp.Height = (FLOAT)res;
        vp.MaxDepth = 1.0f;
        ctx->RSSetViewports(1, &vp);
        ctx->ClearDepthStencilView(shadowDsv, D3D11_CLEAR_DEPTH, 1.0f, 0);

        ctx->RSSetState(rsShadow);
        SetDepthEnable(true);
        ctx->IASetInputLayout(layoutDepth);
        ctx->VSSetShader(vsDepth, NULL, 0);
        ctx->PSSetShader(NULL, NULL, 0);                        // ピクセルシェーダー無し
        DrawScene(true);
    }

頂点シェーダーは、頂点を光の座標へ変換するだけです。

float4 VSDepth(VS_IN In) : SV_Position {
    float4 w = mul(In.Position, World);
    return mul(w, LightVP);
}

実装:本描画で影を判定する

2パス目のピクセルシェーダーが、影の判定の本体です。

float ShadowFactor(float3 wpos, float3 N, float ndl) {
    if (SParam2.y < 0.5f) return 1.0f;

    // 法線方向に少し持ち上げてから光の視点へ(自己影の縞を減らす手法)
    float3 p = wpos + N * SParam2.x;
    float4 lp = mul(float4(p, 1.0f), LightVP);
    float3 ndc = lp.xyz / lp.w;
    float2 uv = float2(ndc.x * 0.5f + 0.5f, -ndc.y * 0.5f + 0.5f);
    float depth = ndc.z;
    if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f || depth > 1.0f) return 1.0f;

    // 光に対して斜めの面ほど、深度の食い違いが大きいので、バイアスを増やす
    float bias = SParam.x + SParam.y * (1.0f - ndl);

    int r = (int)SParam.w;
    float sum = 0.0f;
    float cnt = 0.0f;
    for (int y = -r; y <= r; y++) {
        for (int x = -r; x <= r; x++) {
            float stored = gShadow.SampleLevel(gPoint, uv + float2(x, y) * SParam.z, 0);
            sum += (depth - bias <= stored) ? 1.0f : 0.0f;
            cnt += 1.0f;
        }
    }
    return sum / cnt;
}

ポイントは次の3つです。

  • ワールド座標を光のViewProjectionで変換し、UV(0〜1)と深度を出す
  • depth - bias <= stored:自分の深度からbiasを引いて、深度マップの値以下なら光が当たる
  • バイアスは、固定値+面の傾きに応じた値(SParam.x + SParam.y * (1 - ndl))。斜めの面ほど、深度の食い違いが大きいため

biasが、この記事の主役です。なぜ必要なのかを、次の実験で確かめましょう。

【重要】実際に踏んだ罠①:バイアスを0にすると、床の半分が影になる

バイアスを付けずに描くと、こうなります。

影なし、バイアス0、バイアス0.01、適切なバイアスの4枚。バイアス0では床と球が縞模様になる
左上:影なし 右上:バイアス0 左下:バイアス0.01 右下:バイアス0.03+傾き0.03(解像度はすべて1024)

右上の画像では、床も球も、細かいシマシマ模様になっています。これがシャドウアクネです。床には何も影を落とすものがないのに、床自身が自分の影を作ってしまっています。

原因は、深度マップの解像度が有限だからです。深度マップの1マス(テクセル)は、床の上では一定の広さを持ちます。床は光に対して斜めなので、そのマスの範囲の中でも、床の深度は少しずつ変わります。マスには「その範囲の代表の深度」しか記録されず、マスの中の一部のピクセルは、記録された深度より奥にあることになります。すると、自分自身に「遮られている」と誤判定されて、縞になります。

どれだけ壊れるのかを数字にしました。影を落とすものがない床だけの場面で、床のうち影と判定された割合を、バイアスと解像度を振って測っています。正常なら0%です。

解像度ごとに、バイアスを増やすと床のニセの影が消える様子を示すグラフ
バイアスを増やしたときの、床のニセの影の割合。解像度が低いほど、必要なバイアスが大きい
解像度1テクセルの幅バイアス00.010.020.050%になる最小の値
2560.102 m49.6%42.5%35.5%14.3%0.1
5120.051 m49.6%35.5%21.4%0.0%0.05
10240.025 m49.6%21.4%0.8%0.0%0.05(0.02で0.8%)
20480.013 m49.6%0.7%0.0%0.0%0.02(0.01で0.7%)
40960.006 m49.7%0.0%0.0%0.0%0.01(0.005で0.7%)
床だけの場面で、影と判定された床の割合。試した値(0, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2)の中での最小値

バイアス0では、解像度に関係なくほぼ半分(約50%)の床が影になりました。そして「縞が消えるのに必要なバイアス」は、解像度が2倍になるたびに、ほぼ半分になっています。1テクセルの幅(光の視点の範囲26mを解像度で割った値)と比べると、おおよそ1〜2テクセル分のバイアスが必要、というのが今回の場面の目安でした(この比は、光と床の角度で変わります)。

【重要】実際に踏んだ罠②:解像度を変えたら、同じバイアスで縞が戻った

もう一つ、地味で危ない罠があります。バイアスを0.02に固定したまま、解像度だけを変えて、正解(解像度8192)の影とのズレを比べました。

解像度バイアス0.02のまま画面のうち、正解と違う影になった割合
256全然足りない24.57%(正解の影の面積の約7倍)
512足りない4.85%(正解の影の面積の約1.5倍)
1024足りている0.05%
バイアス0.02+傾き0.03で固定。正解=解像度8192。正解の影の面積は、画面の空以外の3.3%

1024で調整したバイアスのまま、負荷を減らそうと解像度を落とすと、影の面積の約7倍もの範囲が、間違った影になりました(256)。シャドウマップの解像度は、あとから変えたくなるパラメーターです。バイアスは「1テクセルの幅」に比例させると、解像度を変えても壊れにくくなります(今回の実験は、バイアス=テクセル幅の1.2倍で解像度を振っています)。

【重要】実際に踏んだ罠③:バイアスを増やしすぎると、影が浮く

縞が出るので、バイアスを大きくしていくと、今度は逆の問題が出ます。

バイアスを大きくすると影が物体から離れていくピーターパンニングの4枚比較
バイアスを増やしたときの影(解像度2048)。左上が適正、右下がバイアス4.0
バイアス(m)影の面積(画面のうち)正解の影に対する割合
0.053.34%100.0%
0.53.32%99.7%
1.03.29%98.6%
2.03.07%92.1%
3.02.42%72.6%
4.00.93%27.8%
解像度2048。正解の影の面積=3.33%

バイアスが小さいうちは、影の面積はほとんど変わりません(0.5mでも99.7%)。しかし、バイアスが1m、2m、3mと大きくなるにつれて、影がじわじわ痩せていきます。3mでは、細い棒の影が消え、球の影が球から離れて浮きます。4mでは、影は正解の28%しか残っていません。これがピーターパンニング(物体が地面から浮いて見える現象)です。

大事なのは、影が浮き始める前に、縞は消えることです。今回の場面(解像度2048)では、縞は0.02で消えて、影の面積が明確に減るのは1.5m以上でした。その間に、ちょうどよい幅があります。「解像度を上げるより、バイアスで調整するほうが効く」と思い込みがちですが、大きくしすぎると別の破綻が来ます。

バイアス以外の対策:傾き・法線オフセット・カリング

バイアスを固定値にするより、「傾き」や「法線方向へのずらし」を使うと、余裕が広がります。解像度1024で、正解の影とのズレ(画面のうち何%か)を測りました。

方法ズレ
何もしない(バイアス0)46.15%
バイアス0.005だけ32.92%
バイアス0.005 + 傾き0.032.80%
バイアス0.005 + 傾き0.060.04%
バイアス0 + 法線オフセット0.030.07%
バイアス0 + 法線オフセット0.060.13%
バイアス0.05だけ0.04%
解像度1024。正解は解像度8192。ズレは、影かどうかの判定が違ったピクセルの割合
  • 傾きを足す:バイアスが0.005と小さくても、傾き(slope)を0.06に上げると、ズレが0.04%まで下がった
  • 法線オフセット:深度ではなく、判定に使う位置を、面の法線方向に少し持ち上げる。バイアス0でもズレ0.07%
  • 1つずつ手で決めるより、面の向きに合わせるほうが、余裕が出る

ハードウェアのバイアス(ラスタライザー)

深度パスの側にも、ラスタライザーのバイアスがあります。同じ解像度1024で、バイアス0の状態から試しました。

設定(DepthBias, SlopeScaledDepthBias)ズレ
0, 046.15%
1000, 039.25%
5000, 011.92%
20000, 00.12%
0, 2.00.03%
1000, 2.00.03%
深度パスのラスタライザー設定。深度はD32_FLOAT。ズレは画面のうち影の判定が違った割合

DepthBiasは、値がいくつなら効くのかが見当がつきません(1000でまだ39%、5000で12%、20000でようやく0.1%)。一方、SlopeScaledDepthBiasは2.0を入れるだけで0.03%まで下がりました。深度の形式(D32_FLOATなど)によって効き方が変わるので、DepthBiasよりも、傾きに比例する側から試すのがおすすめです。

前面カリング:バイアス0でも縞が出ない

もう一つ、有名な手法が、深度パスで前面カリングにして、物体の裏側の面を深度マップに書く方法です。バイアス0のまま、カリングだけ変えました。

深度パスのカリングバイアス0バイアス0.02
なし46.15%0.86%
背面(通常)46.15%0.86%
前面0.03%0.03%
解像度1024。正解との判定のズレ(画面のうち何%か)

前面カリングにすると、バイアス0でもズレは0.03%でした。床は、片面の板なので、裏側の面(前面カリングで残る面)を持たず、深度マップにそもそも書かれません。その結果、床が自分自身に影を落とす問題が消えます。球や箱のように「閉じた立体」の影は、裏側の面が深度マップに残るので、ちゃんと影になります。

ただし、この方法には注意が必要です。閉じていない板(木の葉、フェンスなど)は、影を落とせなくなるはずです(今回の実験では、閉じていない板は使っていないので、確かめていません)。

【重要】実際に踏んだ罠④:PCFを入れたら、縞が戻った

影の縁のギザギザを消すために、PCF(Percentage Closer Filtering、まわりの複数点を調べて影の割合を平均する)を入れる人は多いでしょう。実際に、低い解像度(256)だと、影の縁がガタガタです。

解像度256で、PCFなし、PCFありでバイアス不足、PCFありでバイアスを増やした場合の拡大比較
上:PCFなし(ギザギザ) 中:PCF 5x5、バイアスはそのまま 下:PCF 5x5、バイアスを約3倍に(まだ少し縞が残る)

中の画像を見てください。影の縁は滑らかになりましたが、床と球と箱に、細かい縞が戻ってしまいました。PCFは、まわりの複数のテクセル(5x5なら、上下左右2マス先まで)を調べます。床は斜めなので、離れたマスほど深度がずれていて、バイアスが足りなくなります。

数字で見ます。床だけの場面で、床が平均して何%暗くなってしまったか(正常なら0%)を、バイアスをテクセル幅の倍数にして測りました。この値は、解像度に関係なく、ほぼ同じでした。

PCFの大きさバイアス=1.2テクセル2テクセル3テクセル
なし0.0%0.0%0.0%
3x313.9%2.8%0.0%
5x521.8%14.0%4.9%
床だけの場面で、影を落とすものがないのに、床の明るさが平均で何%下がったか。解像度1024(256、512でもほぼ同じ値)

PCFなしなら1.2テクセルで足りるバイアスが、3x3では3テクセル、5x5では3テクセルでもまだ4.9%暗いままでした。PCFの範囲が広いほど、バイアスも大きくしないと縞が戻ることが分かります。

この罠は、影の縁の「ギザギザ度」だけを見ていると、なかなか気づけません。私も最初は、ギザギザを気にして、縞が戻っていることに気づきませんでした。PCFを足したら、バイアスも見直すのが鉄則です。

なお、影のズレを「影かどうか」の2択で数えると、この薄い縞は漏れてしまいます(判定は、明るさが6割未満かどうか)。だから、上の表では「明るさの平均の下がり方」で測り直しました。

解像度とGPU時間

解像度を上げれば、ギザギザは減りますが、その分、深度パスが重くなります。深度パスの時間と、本描画の時間を、解像度とPCFの大きさごとに測りました。

シャドウマップの解像度とPCFの大きさごとのGPU時間を示すグラフ
RTX 3050 Laptop、1280x720、この作例のシーン(球・箱・棒・床)での時間
解像度深度パス本描画 PCFなしPCF 3x3PCF 5x5
5123.3 μs27.8 μs44.7 μs82.1 μs
10249.1 μs28.1 μs46.8 μs89.3 μs
204828.5 μs30.3 μs54.2 μs111.9 μs
4096109.3 μs39.5 μs92.9 μs182.9 μs
1回あたりのGPU時間(マイクロ秒)。各条件3回の平均。絶対値はGPUと場面で変わるので、比を見るのが目安
  • 深度パスは、解像度が2倍(面積で4倍)になるたびに、2.8〜3.8倍になった。4096は512の約33倍
  • PCFは、本描画に効く。5x5は、PCFなしの約3倍(512の場合)
  • 解像度を4096にするより、1024〜2048にして、バイアスとPCFを調整するほうが軽い(今回の実測から)

この作例は物が少ないので、時間はどれも小さい値です。ゲームでは、深度パスはシーン内の全物体を描き直すので、物が多いほど、解像度の影響は大きくなります。

影の縁の正確さも、解像度で測りました(バイアスは、テクセル幅の1.2倍に合わせています)。影の面積に対する、正解(解像度8192)と違う影のピクセルの割合です。

解像度バイアスPCFなしPCF 3x3
2560.1225.0%4.5%
5120.0613.8%2.5%
10240.0302.8%1.1%
20480.0152.5%0.6%
40960.0082.2%0.3%
正解の影の面積に対する、判定が違ったピクセルの割合。PCF 3x3は、上の表のとおり床がわずかに暗くなる分(バイアスが足りない)は数えていない

解像度を上げるほど、影の縁の誤差は小さくなります(256の約5%が、4096で約2%)。ただ、この表は「影かどうか」の判定だけです。PCFの列は、床の縞の問題(バイアス不足)を数えていないので、実際の見た目は、この数字ほど良くありません。

シャドウマップのよくある失敗例と対処法

①影を付けたら、床や物体がシマシマになる

シャドウアクネです。バイアスを、まず1〜2テクセル分から試します。それでも出るなら、傾きに応じたバイアス(slope)か、法線オフセット、前面カリングを足します。

②縞が消えるまでバイアスを上げたら、影が物体から浮いた

ピーターパンニングです。バイアスは、縞が消える最小の値付近にとどめます。固定値を増やすより、傾き・法線オフセットで補います。

③解像度を変えたら縞が戻った、PCFを入れたら縞が戻った

バイアスは、1テクセルの幅と、PCFの範囲に比例して必要になります。解像度やPCFを変えたら、バイアスも、テクセル幅に比例させて再調整します。

注意点

  • バイアスはシャドウマップの1テクセルの幅を基準に考える(今回の場面では1〜2テクセル分)
  • 大きくしすぎると、影が浮く(3m以上でひどくなった)
  • PCFを足したらバイアスも増やす(5x5は3テクセルでもまだ縞が残った)
  • 前面カリングは強力だが、閉じていない板の影に注意(今回は未検証)
  • 深度パスは解像度の2乗で重くなる。まず1024〜2048から
  • 比較用に「高解像度の正解画像」を1枚作っておくと、調整が数字でできる

まとめ

  • シャドウマップは、光から見た深度を1枚のテクスチャに記録して、カメラから描くときに比べる2パスの手法
  • バイアス0では、影を落とすものがない床の約50%が影になる。縞が消えるバイアスは、解像度が2倍になるたびに半分になる(1〜2テクセル分)
  • バイアスを大きくしすぎると影が浮く。4mでは、影が正解の28%に痩せた
  • PCFを足すとバイアス不足で縞が戻る。5x5は3テクセルでも平均4.9%暗い
  • 深度パスは解像度の2乗で重くなる(4096は512の約33倍)。PCFは本描画を重くする

シャドウマップの調整では、バイアスという1つの数字が、縞にも浮きにも直結します。影を落とすものがない場面で縞の割合を測る。高解像度の正解と比べる。この2つの測り方を持っておくと、「なんとなく」の調整から抜け出せます。

関連記事