【ゲーム制作】点光源の距離減衰と半球ライティングとは?|4つの減衰式を画素で検証して、環境光の「足すだけ」の罠を避ける

【ゲーム制作】点光源の距離減衰と半球ライティングとは?|4つの減衰式を画素で検証して、環境光の「足すだけ」の罠を避ける ゲーム制作

点光源の光は、遠くなるとどうやって弱まるのか。逆2乗の法則は、そのまま使ってよいのか。空の青と地面の照り返しを表現する半球ライティングは、環境光と何が違うのか。

この記事では、ゲームの点光源で必ず出てくる距離減衰と、明るさに方向性を持たせる半球ライティングを、DirectXのピクセルシェーダーで実装して比べます。4つの減衰式を同じ場面で並べ、シェーダーの式と、画面の画素値がどれだけ一致するかまで確かめました。半球ライティングでは、私の手元のプロジェクトに実際にあった、「環境光を足すだけ」の書き方が、色をどう濁らせるかも実測しています。

点光源の距離減衰とは?

距離減衰とは、

・光源から離れるほど、光が弱くなる度合いを、距離の式で決めること

です。ピクセルシェーダーでは、光源から面までのベクトルLを作り、dot(N, L)(面の向き)に「距離の係数」を掛けて明るさにします。

// ---- 直接光 ----
float3 direct = float3(0, 0, 0);
if (LightPos.w > 0.5f) {
    float3 L = LightPos.xyz - In.WorldPos;
    float d = length(L);
    L /= d;
    direct += Light.Diffuse.rgb * saturate(dot(N, L)) * Attenuation(d);
}

残りは、このAttenuation(d)(距離dのときに光がどれだけ残るか)をどう書くかです。

4つの減衰式

// 距離 d のときに、光がどれだけ残るか(0~)
float Attenuation(float d) {
    int m = (int)P0.x;
    float R = P0.y;
    float I = P0.z;
    if (m == 0) {
        return saturate(1.0f - d / R);                          // 線形。Rで丁度ゼロになる
    } else if (m == 1) {
        return I / max(d * d, 1.0e-4f);                         // 逆2乗の法則
    } else if (m == 2) {
        float x = saturate(1.0f - pow(d / R, 4.0f));            // Rで滑らかにゼロへ落とす窓
        return I * x * x / (d * d + 1.0f);
    } else if (m == 3) {
        return I / (1.0f + 0.09f * d + 0.032f * d * d);         // 定数+線形+2次の分母
    }
    return 1.0f;
}
  • 線形(範囲つき):1 - d / R。Rでぴったり0になる。計算が軽く、範囲が分かりやすい
  • 逆2乗:I / d^2。物理的に正しい減衰。ただし0にならない
  • 窓関数つき逆2乗:逆2乗に、Rで滑らかに0へ落ちる窓((1 - (d/R)^4)^2)を掛ける。物理的な減り方を保ちつつ、Rで確実に切れる。分母に1を足して、光源の真上で無限大にならないようにしている
  • 定数+線形+2次:1 / (1 + 0.09d + 0.032d^2)。固定機能パイプライン時代から使われる、係数を調整して見た目を作る方式

4つを同じ点光源(光源の高さ2.5m)で並べました。見た目の明るさが近くなるように、強さは方式ごとに決めています(線形1、逆2乗6、窓関数7、定数線形2次2.6)。

同じ点光源を線形・逆2乗・窓関数・定数線形2次の4つの減衰式で描き比べた画像
左上:線形 右上:逆2乗 左下:窓関数つき逆2乗 右下:定数+線形+2次
4つの減衰式で、距離ごとに光がどれだけ残るかを示す対数グラフ
距離ごとの「光の残り」(係数)。縦軸は対数。逆2乗と定数+線形+2次は、30mでも0にならない
光源からの距離線形(R=14)逆2乗(強さ6)窓関数(R=14, 強さ7)定数+線形+2次
1 m0.936.003.502.32
2 m0.861.501.401.99
4 m0.710.380.411.39
7 m0.500.120.120.81
10 m0.290.06000.03790.51
14 m0.000.03060.000.30
20 m0.000.01500.000.17
30 m0.000.00670.000.0800
光の残り(係数)。線形と窓関数は、Rの14mで0になる

この表で大事なのは、下のほうの行(遠い距離)です。逆2乗と定数+線形+2次は、30m離れても光が0になりません(逆2乗で0.0067、定数+線形+2次で0.08)。見た目には暗くても、光はゼロではなく、「この光がどこまで届くか」を決められません。ライトを大量に置くゲームでは、光の届く範囲(R)で確実に切れる線形か窓関数のほうが、そのライトを計算に含めるかどうかを範囲で判定できるので扱いやすくなります。

画面に映る範囲で言うと、床の非空領域のうち、明るさが40/255を超えた範囲は、線形で50.6%、逆2乗で10.3%、窓関数で11.2%、定数+線形+2次で67.3%でした。線形は「ぼんやり広く」、逆2乗と窓関数は「光の輪がくっきり」、定数+線形+2次は「広く明るい」印象になります。

式は本当に画面の色になっているか

「式を書いた」と「その式どおりに描かれた」は別の話です。そこで、光源以外の光(環境光・平行光源)を切った状態で、床の画素値を式から計算し、実際の画像と比べました。

減衰式比較した画素数最大の誤差平均の誤差
線形14300.56 / 2550.25 / 255
逆2乗13840.56 / 2550.25 / 255
窓関数13720.56 / 2550.25 / 255
定数+線形+2次12960.56 / 2550.25 / 255
窓関数(R=5)5880.56 / 2550.25 / 255
窓関数(R=20)13860.55 / 2550.25 / 255
式で予測した赤チャンネルの値と、画像の画素値の差。白飛び・真っ暗な画素と、光源の目印の球は除いた

どの式でも、誤差は最大0.56/255で、8bitの丸めの範囲でした。式どおりに描かれています。

この検証で1つ、地味にハマったことがあります。最初は誤差が最大で250近くになりました。式が間違っているのかと疑いましたが、原因は光源の目印の黄色い球が、比べる列に写り込んでいたことでした。それを除いて左右の列も足すと、今度は最大130近く出ましたが、こちらは球や箱が写り込んでいたためでした。床の画素だけを比べる、という当たり前の条件が抜けていただけです。式の検証で誤差が大きく出たときは、式より先に「比べている画素が本当に床か」を確認してください。

【重要】実際に踏んだ罠:逆2乗は「強さ」で光がすぐ破綻する

逆2乗のI / d^2は、光源の近くで値が爆発的に大きくなるのが厄介です。作例では、強さを6から40に上げただけで、こうなりました。

逆2乗の強さを上げすぎて白飛びした画像と、窓関数の範囲を狭くしすぎた画像の比較
左:逆2乗、強さ40 中:窓関数、R=5 右:窓関数、R=14(基準)
条件白飛びした画素明るさ40以上の範囲
逆2乗(強さ6)392(光源の目印だけ)10.3%
逆2乗(強さ40)54,94464.1%
画面の空以外の領域に対する割合。白飛び=いずれかの色が250以上。強さ6の392画素は光源の目印の球

強さを6.7倍にしただけで、光源の目印の球を除いてゼロだった白飛びが、約5.5万画素(54,552画素)に増え、明るい範囲は10%から64%へ広がりました。光源に近い床が真っ白に潰れ、遠くの床までほんのり明るくなっています。逆2乗は、強さを少し変えるだけで見た目が大きく変わるので、「光源に近づいたら真っ白」になっていないか、必ず近くで確認してください。

窓関数のほうも、範囲Rを5に狭めると、明るく見える範囲(40以上)は、Rが14のときの11.2%から5.0%まで縮みました(中央の画像)。Rは「見える光の広さ」を直接決めるので、光源の強さを変えずにRだけ下げると、光の輪が小さく暗くなります。強さとRは、セットで調整します。

半球ライティングとは?

半球ライティングとは、

・面の向きに応じて、上向きなら空の色、下向きなら地面の色を環境光として使う手法

です。均一な環境光(すべての面に同じ値を足す)と違い、上を向いた面は空の青っぽさを、下を向いた面は地面の照り返しの色を受けます。物の立体感が出るのに、計算はlerp1回なので、とても安いのが特徴です。

int am = (int)P0.w;
float3 hemi = lerp(GroundCol.rgb, SkyCol.rgb, N.y * 0.5f + 0.5f) * P1.z;   // 上向き=空色 下向き=地面色
float3 col;
if (am == 0) {
    col = albedo * (Light.Ambient.rgb + direct);
} else if (am == 1) {
    col = albedo * (hemi + direct);                          // 正しい: 物の色に掛ける
} else if (am == 2) {
    col = albedo * (Light.Ambient.rgb + direct) + hemi;      // 失敗例: 物の色に関係なく足すだけ
} else {
    col = albedo * direct;
}

N.y * 0.5 + 0.5は、面の向きの上下成分(-1〜1)を0〜1に直したもので、1が真上、0が真下です。

環境光を均一・半球(掛ける)・半球(足すだけ)・なしで描き比べた画像
左上:均一な環境光 右上:半球(物の色に掛ける) 左下:半球(色を足すだけ) 右下:環境光なし

【重要】実際に踏んだ罠:半球ライティングを「足すだけ」にすると、色が濁る

上のコードのam == 2が、この記事で一番伝えたい失敗例です。左下の画像を見てください。青い球は灰色っぽく、箱はほとんど灰色になり、赤い球はピンク色になっています。物の色が失われ、画面全体が青白く浮いています。

実は、この書き方は私の手元のプロジェクトの半球ライティング(HemiSphereLightingPS.hlsl)と、同じ構造でした。

▼HemiSphereLightingPS.hlsl
outDiffuse.rgb = texColor.rgb
               * (In.Diffuse.rgb * Light.Diffuse.rgb * light + Light.Ambient.rgb)
               + specular.xxx
               + hemiColor.rgb;

最後にhemiColor.rgbを、テクスチャの色に掛けずに足しています。半球の色は「空や地面から来る光」なので、本来は物の色(アルベド)で吸収されるはずです。足すだけだと、真っ黒な物にも、空の色がそのまま乗ります。

実際に、画素値で比べてみました(平行光源なし、床の市松なしの場合)。

場所均一な環境光半球(掛ける)半球(足すだけ)
青い大球の下側(法線が下向き)(9, 13, 21)(16, 21, 35)(64, 64, 77)
青い大球の上側(法線が上向き)(9, 13, 21)(14, 27, 67)(55, 77, 129)
赤い小球の下側(23, 11, 10)(41, 18, 17)(78, 62, 66)
床(22, 22, 24)(30, 50, 93)(64, 92, 157)
画素値(R, G, B)。0〜255
  • 均一な環境光:青い球の上側と下側が、同じ(9, 13, 21)。上下の差が全くない
  • 半球(掛ける):球の上側(14, 27, 67)は青が強く、下側(16, 21, 35)は暗い。赤い球は下側でも赤が保たれる(R 41 : G 18)
  • 半球(足すだけ):青い球の下側が(64, 64, 77)とほぼ灰色。赤い球の下側は(78, 62, 66)で、R対Gの比が、掛ける版の2.3対1から1.3対1まで縮み、赤みが薄れた。床も(64, 92, 157)と、掛ける版(30, 50, 93)の約2倍の明るさに浮いている

赤い球のR対Gの比は、「均一な環境光」で2.1、「半球(掛ける)」で2.3、「半球(足すだけ)」で1.3でした。掛けると物の色が保たれ、足すだけだと、色がどれも空の色に引きずられます。

修正は、1行です。半球の色を、足す側ではなく、テクスチャやアルベドに掛ける側に入れます。

// 失敗: 半球の色を、物の色に関係なく足している
col = albedo * (Light.Ambient.rgb + direct) + hemi;

// 修正: 半球を環境光として、物の色に掛ける
col = albedo * (hemi + direct);

点光源と半球ライティングのよくある失敗例と対処法

①逆2乗の強さを上げて、光源の近くが真っ白に潰れる

強さを6.7倍にしただけで、ゼロだった白飛びが約5.5万画素になりました。光源の近くの明るさは、saturateの前の値を確認して、上限を決めます。窓関数のように、分母に定数を足しておくのも手です(作例ではd*d + 1.0)。

②光の届く範囲(R)を決めずに、逆2乗をそのまま使う

30m離れても0にならず、ライトの数が増えるほど「どこまでが影響範囲か」が分からなくなります。範囲を持たせる窓関数を使います。

③環境光を「足すだけ」にして、色が濁る

半球ライティングは、必ず物の色(アルベド)に掛けます。「足す」なら、それは環境光ではなく、発光です。

注意点

  • 距離減衰は「どこまで届くか」まで決めると扱いやすい。逆2乗そのままは、0にならない
  • 式の検証は、床など、本当に比べたい画素だけを対象にする
  • 半球ライティングはアルベドに掛ける。足すだけだと色が濁る
  • 均一な環境光は上下の差が全くない(実測で同じ値)。立体感を出したいなら半球にする
  • 半球の空色と地面色は、シーンの空と地面の色に合わせると自然

この記事のポイント

  • 距離減衰は、線形・逆2乗・窓関数・定数+線形+2次の4つを実装し、式と画素値の誤差が最大0.56/255で一致することを確認した
  • 逆2乗と定数+線形+2次は、30m先でも0にならない。範囲で切りたいなら、線形か窓関数
  • 逆2乗の強さを6から40にすると、ゼロだった白飛びが約5.5万画素に。光源の近くでの明るさに注意
  • 半球ライティングはアルベドに掛ける。足すだけだと、赤い球のR対Gの比が2.3から1.3に落ちて、色が濁る

半球ライティングで、「なんとなく色が浮く」と感じたら、環境光を足しているのか、掛けているのかを見直してください。距離減衰も、式を書くだけでなく、光の届く範囲までセットで考えると、ライトの数が増えても管理しやすくなります。

関連記事