T.TAO
ブログへ戻る
/9 min read/Graphics Engine

Metal #7 ライティング

#ComputerGraphics#GraphicsEngine#Metal

このノートでは基本的なライティングモデルと、Metal で複数の光源をどう構成するかを扱います。

Lambert 拡散反射

理想的な拡散面は、入射光をあらゆる方向へ等しく散乱させます。面に届くエネルギーは入射方向と法線のなす角の余弦に比例します。これが Lambert の余弦則です。

MSLfloat3 n = normalize(in.normalWS);
float3 l = normalize(-light.direction);     // 光源へ向かう方向
float  ndotl = saturate(dot(n, l));
float3 diffuse = baseColor * light.color * ndotl;

max(dot, 0) ではなく saturate を使うのは習慣の問題で、両者は等価です。重要なのはクランプすること自体です。裏向きの面では ndotl が負になり、放置すると負の色が生まれ、HDR パイプラインを通って黒い破綻として伝播します。

Phong と Blinn-Phong のスペキュラ

Phong は反射ベクトルと視線のなす角からハイライトを計算します。

MSLfloat3 r = reflect(-l, n);
float  spec = pow(saturate(dot(r, v)), shininess);

Blinn-Phong は反射ベクトルの代わりにハーフベクトル(ライトと視線の二等分線)を使います。

MSLfloat3 h = normalize(l + v);
float  spec = pow(saturate(dot(n, h)), shininess * 4);

Blinn-Phong のほうがよく使われます。理由は二つ。第一に normalize(l + v)reflect よりわずかに安価です。第二に、より重要な点として、ライトと視線がともに浅い角度に近づくと Phong の dot(r, v) は早々に負になり、ハイライトが切り落とされて鋭い縁が残ります。Blinn-Phong にはこの不連続がなく、浅い角度でのハイライト形状は実測に近いものになります。

shininess の指数は両モデルで等価ではありません。経験的に、同程度の大きさのハイライトを得るには Blinn は Phong の 2〜4 倍の値を必要とします。

三種類のライト

C// 共有ヘッダ内
typedef enum { LightTypeDirectional, LightTypePoint, LightTypeSpot } LightType;

typedef struct {
    vector_float3 position;      // point / spot
    vector_float3 direction;     // directional / spot
    vector_float3 color;
    float         intensity;
    float         range;         // point / spot
    float         innerCone;     // spot。余弦値で保持
    float         outerCone;
    uint          type;
} Light;

ディレクショナルライトは位置を持たず方向だけを持ち、光線はどこでも平行です。太陽や月に使います。減衰はありません。

ポイントライトは一点からあらゆる方向へ放射し、距離に応じて減衰します。

スポットライトはポイントライトに円錐の制限を加えたものです。円錐の縁は滑らかにしないと境界が激しくジャギります。

MSLfloat  cosAngle = dot(normalize(fragToLight), normalize(-light.direction));
float  cone     = smoothstep(light.outerCone, light.innerCone, cosAngle);

smoothstep の二つの境界が逆順(outer が先)になっている点に注意してください。余弦は角度が大きくなるほど小さくなるためです。

減衰

物理的に正しい減衰は逆二乗です。

MSLfloat d    = length(light.position - in.positionWS);
float atten = 1.0 / (d * d);

ただしそのまま使うと実務上の問題が二つあります。光源のすぐ近くで 1/d² が無限大に発散すること、そして理論上は光がゼロに達しないため、すべての光源をシーン全体で評価しなければならないことです。

実用的な形は、range で強度が滑らかにゼロへ達するよう窓関数を加えます。

MSLfloat d      = length(light.position - in.positionWS);
float ratio  = d / light.range;
float window = saturate(1.0 - ratio * ratio * ratio * ratio);
float atten  = window * window / (d * d + 1.0);

+ 1.0 が近傍の特異点を取り除き、四乗の窓が range において値と導関数の両方を連続にします。導関数が不連続だと、物が動いたときに明るさの飛びとして見えてしまいます。

明示的な range があればカリングも可能になります。CPU あるいはコンピュートシェーダーで、光源の影響球がオブジェクト(または画面タイル)と交差するかを判定し、交差しなければ完全に飛ばせます。これがあらゆるタイル/クラスタレンダラーの基礎です。

複数光源のループをどこに置くか

もっとも素直な方法は、フラグメント関数ですべての光源をループすることです。

MSLfragment float4 fragment_lit(VertexOut in [[stage_in]],
                             constant Light *lights [[buffer(13)]],
                             constant uint  &lightCount [[buffer(14)]])
{
    float3 n = normalize(in.normalWS);
    float3 v = normalize(cameraPosition - in.positionWS);
    float3 color = ambient * baseColor;

    for (uint i = 0; i < lightCount; i++) {
        color += shade(lights[i], n, v, in.positionWS, baseColor);
    }
    return float4(color, 1);
}

これがシングルパスフォワードです。単純で、半透明にも MSAA にも対応します。ただしコストは O(オブジェクト数 × 光源数) で、各フラグメントが近くにない光源まですべて走査します。数十灯を超えるとスケールしなくなります。

代替案は次のとおり。

  • マルチパスフォワード — 光源ごとに 1 パスを加算ブレンドで重ねる。旧 API 時代の手法で、いまはほぼ使われません。ジオメトリを N 回処理することになります。
  • 遅延レンダリング — 幾何属性を G-Buffer に書き、スクリーン空間で光源ごとにシェーディングする。計算量は O(オブジェクト数 + 光源数 × 被覆ピクセル) になります。《Metal #11 遅延レンダリング》を参照。
  • タイル/クラスタフォワード(Forward+) — コンピュートパスで光源を画面タイル(または視錐台内の 3 次元クラスタ)に割り当て、各フラグメントは自分のタイルのリストだけを走査します。半透明と MSAA への対応を保ったまま、タイルあたりの光源数を扱える範囲に抑えられます。Apple Silicon の TBDR アーキテクチャでは、ハードウェアがもともとタイル単位で動作しているため、この方向は特に自然です。

環境光と次の一歩

上のコードの ambient * baseColor は考えうるもっとも粗い近似です。あらゆる方向から一様な光が届くと仮定しています。影が真っ黒になるのは防げますが、完全な作り物です。実際の環境光は方向によって変わります。

最初の改善はヘミスフィアライティングです。法線が上を向いているか下を向いているかに応じて、空の色と地面の色を補間します。

MSLfloat  up      = n.y * 0.5 + 0.5;
float3 ambient = mix(groundColor, skyColor, up);

たった一行ですが、定数よりはるかに良くなります。その先には irradiance map や球面調和関数があり、これらは image-based lighting の領域です。《Metal #15 反射と屈折》で触れます。

次回はこれらのパラメータを整理するマテリアルを扱います。

このシリーズの記事

Metal
  1. 01Metal #0 Swift 復習
  2. 02Metal #1 初期化
  3. 03Metal #2 レンダリングパイプライン
  4. 04Metal #3 頂点関数
  5. 05Metal #4 フラグメント関数
  6. 06Metal #5 テクスチャ
  7. 07Metal #6 カメラと操作
  8. 08Metal #7 ライティング
  9. 09Metal #8 マテリアル
  10. 10Metal #9 レンダーパス
  11. 11Metal #10 シャドウ
  12. 12Metal #11 遅延レンダリング
  13. 13Metal #12 パーティクルシステム
  14. 14Metal #13 テッセレーション
  15. 15Metal #14 ポストプロセス
  16. 16Metal #15 反射と屈折
  17. 17Metal #16 アニメーション
  18. 18Metal #17 レイトレーシング(一)レンダリングアルゴリズム
  19. 19Metal #18 レイトレーシング(二)影とライティング
  20. 20Metal #19 レイトレーシング(三)性能最適化
  21. 21Metal #21 [付録] Compute Shader
  22. 22Metal #22 [付録] SwiftUI における Metal