このノートでは基本的なライティングモデルと、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 反射と屈折》で触れます。
次回はこれらのパラメータを整理するマテリアルを扱います。