🦁

ディレクショナルライト・ポイントライト・スポットライトの実装

2023/12/22に公開

本記事ではライトの実装方法について紹介します。

ライトの種別

本記事で実装するライトの種別は以下のとおりです。各ライトの詳細は本文を参照してください。

  • ディレクショナルライト
  • ポイントライト
  • スポットライト

ライトカラーの設定方法

ライト実装の前に、ライトカラーの設定方法について述べておきます。
ライトカラーはRGBで表します。通常のカラーは[0, 1]の範囲で表しますが、光の場合は1を超える強さを表すことが必要です。そのため、ライト強度という別の値を用意しておき、次のようにライトカラーを表します。

\bold{c}_{light} = intensity\cdot\bold{c}_{rgb}

intensityはライト強度を表すスカラーです。
\bold{c}_{rgb}はライトカラーを[0,1]範囲のRGBで表したものになります。

ディレクショナルライト

Image from Gyazoディレクショナルライトの例

ディレクショナルライトは、方向のみをもつ光源です。平行光源とも呼ばれます。シーン中のどこであっても照らされる方向が同じになるようなライトです。よく太陽光のかわりとして用いられます。

Image from Gyazo

ディレクショナルライト設定値

ディレクショナルライトの実装に必要な設定値は以下のとおりです。

設定 値の形式
ライトカラー RGB(ベクトル)
ライト強度 スカラー
ライト方向 ベクトル

ライト方向については、ディレクショナルライトの計算に使うのではなくBRDFの計算のために使います。

ディレクショナルライトの実装

まず設定値は全て定数なので、コンスタントバッファとして定義しておきます。
レジスタの値は適当です。

ディレクショナルライトコンスタントバッファ
cbuffer CbLight : register(b1)
{
    float3 LightColor : packoffset(c0);       // ライトカラー
    float LightIntensity : packoffset(c0.w);  // ライト強度
    float3 LightForward : packoffset(c1);     // ライト方向
}

ディレクショナルライトの実装は以下のとおりです

ディレクショナルライト
float3 EvaluateDirectionalLight
(
    float3 lightColor,
    float lightIntensity
)
{
    return lightColor * lightIntensity;
}

ライトの向きと照度の関係

LightForwardが使われてないじゃないか、と思われるかもしれません。ディレクショナルライトのカラーはライト方向に依存しないため、ライトカラーの計算にLightForwardは出てきません。
じゃあどこでLightForwardを使うかというと、照度(照らされている光の強さ)の調整のために使用されます。このことについて、もう少し詳しく解説します。まずは↓の図をみてください。

Image from Gyazo上からディレクショナルライトが当たっている様子

サーフェイス(黄色のラインで表現)に対し上から垂直にディレクショナルライト(白色で表現)が当たっている様子を示したものです。レーザー光線が上から当たっているようなものだと考えてください。ディレクショナルライトが当たっている範囲をdで表現しています。

このライトを少し傾けてみます。

Image from Gyazoライトを傾けた図。照射範囲が少し広くなる

ライトを傾けてみると、光が当たっている範囲がやや広くなったことがわかるかと思います。ライトを傾けたときに光が当たっている範囲をd'としておきます。dd'の関係は次のようになります。

Image from Gyazo

\begin{align*} d&=d'\cos\theta \\ &=d'(\bold{n}\cdot\bold{l}) \end{align*}
記号 説明
\bold{n} 法線ベクトル。大きさは1
\bold{l} 光源ベクトル。大きさは1

このとき、ライトを傾けただけでライトの強さは変わっていません。しかし、照射範囲は広くなったため(d\rightarrow d')、単位面積あたりに当たっている光の強さは小さくなっていることが想像できます。実際、照度は(\bold{n}\cdot\bold{l})に比例します。つまり、ライトカラーに(\bold{n}\cdot\bold{l})を乗算した値が照度になります。ただ、今回の計算方法だと照度は法線ベクトルと光源ベクトルの内積に依存しておりライトカラーとは言えないことからEvaluateDirectionalLight関数の内部には組み込んでいません。

(\bold{n}\cdot\bold{l})項の実装

今回の実装では、最終的にライトカラーとBRDFを乗算する際に(\bold{n}\cdot\bold{l})を乗算するようにしています。
\bold{n}\bold{l}のなす角が90度を超えるような場合は(\bold{n}\cdot\bold{l})<0となり、出力カラーが0以下になるというおかしな結果を引き起こします。こういったことは現実では起こり得ませんが、実装上は考慮する必要があります。そのため、saturateを使って0,1範囲にクランプするようにします。

最終的なカラー計算
// ライトカラー
float3 lit = EvaluateDirectionalLight(
    LightColor,
    lightIntensity
);

// 出力カラー
float3 outputColor = saturate(dot(N, L)) * BRDF * lit;

(\bold{n}\cdot\bold{l})項について、今回はディレクショナルライトを例に解説しましたが、後にでてくるポイントライト・スポットライトでも同様に(\bold{n}\cdot\bold{l})項が必要になってきますので覚えておいてください。

ポイントライト

Image from Gyazoポイントライトの例

ポイントライトは、大きさがなく全ての方向に同じ強さの光を発するライトです。光は光源の位置から球状に広がっていきます。

Image from Gyazo

距離による減衰(ポイントライト)

ディレクショナルライトと違い、ポイントライトには距離による光の減衰が起こります。
距離による減衰とは、光源から離れれば離れるほど光の強さが弱くなっていく現象のことです。

※図

ポイントライトが距離によってどれくらい減衰していくかは、逆二乗の法則に従います。

\begin{align} E = \frac{I}{d^2} \end{align}
記号 説明
E 照度
I 光度
d ライトからの距離

いきなり照度や光度といった物理量をだしてしまいましたが、逆二乗の法則は光学(物理学)の分野で定義されているものなので物理量で表しました。とりあえずは、光源と照らされている箇所の距離が離れるほど、距離の2乗に反比例して暗くなっていく(照度Eが小さくなっていく)ということがわかれば大丈夫です。

ライトとの距離が0になるとき

先程でてきた逆二乗の法則の式ですが、d=0のとき分母が0になってしまい計算不可能になってしまいます。現実の世界でライトとの距離が0になるような事象を物理学でどう扱っているのかは知らないのですが、プログラム上ではd=0の場合を考慮する必要があります。

この問題の解決法はとても単純で、d^2が閾値以下にならないようにmax関数などで調整すればOKです。

0除算回避
float d = 5;
float d2 = d*d;
float min_d = 0.01;
float attenuation = 1.0f / (max(d2, min_d));

光が届く限界の距離

減衰が逆二乗の法則に従うとすると、距離が遠くなるに連れてどんどん光が弱くなっていくことがわかります。
ただし、どれだけ距離が遠くなっても光の強さが0になることはありません。強さが0になるには距離を無限大にする必要があります。

Image from Gyazo横軸が距離、縦軸が光の強さを表す

光の強さが殆ど0になっている場合は思い切って光が届いていないことにし、ライティングに関する処理をスキップして処理負荷を低減させたいところです。例えば、「距離が5m以上離れたらもう光は届いていないことにする」と決めて、ライティング計算するポイントがライトから5m以上離れていたらライティング計算処理自体をスキップする、といったことです。
こういったある範囲を決めて範囲外のものを除外する処理をカリングといいます。ライトのカリングの場合はライトカリングといったりします。

ライトカリングを実装するとき、「ライトからの距離が閾値を超えたら処理をスキップする」と単純に実装してしまうと、閾値のところで急に暗くなってしまいます。

Image from Gyazo閾値周辺で突然暗くなり、エッジが目立つ

Image from Gyazo明るさをグラフにしたもの。横軸が距離、縦軸が明るさを表す。距離が閾値(5)を超えたときに急に明るさが0になっている

閾値周辺はスムーズに暗くなっていくようにしたいです。そのため、閾値周辺でスムーズに0になっていくような関数を乗算します。この説明だとよくわからないと思いますので、次のグラフを見てください。

Image from Gyazo

横軸が0のとき縦軸が1で、横軸の値が増加するにつれてスムーズに縦軸の値が下がっていき、横軸が閾値(5)のとき縦軸が0になっています。
この関数をライトカラーに乗算してやれば閾値周辺がスムーズになりそうです。

Image from Gyazo黄色の線が乗算したもの

Image from Gyazo閾値(5)周辺を拡大

ちょっと見づらいですが、ライトカラー(赤線)に先程の青線のグラフを乗算すると、閾値周辺でスムーズに0になっていることがわかります。

この青線のグラフはReal Shading in Unreal Engine 4に掲載された関数を使用しています。

\begin{align} \bigg( 1-\frac{d^4}{r^4} \bigg)^{2\mp} \end{align}
記号 説明
d ライトからの距離
r ライトが届く距離(閾値)
x^{2\mp} xを2乗してから0,1にクランプする

こちらの関数を使って減衰をスムーズにしたものが以下になります。

Image from Gyazoエッジ部分がスムーズに暗くなっている

これで光が届く限界距離を実装することができ、ライトカリングができるようになりました。

…と言ったところで申し訳ないのですが、ここまでの議論ではライトカリングができるようになっただけであり、実際にライトカリングを実現するには「光が届いていないところは描画しない」というカリング処理を実装してやらないといけません。CPU側で各オブジェクトに対してライトが届くかどうかを判定し、届いていなければ描画処理自体をしないという処理にします。この実装については本記事では扱いませんので、ご注意ください。

ポイントライトの実装

ここまででポイントライトを実装するために必要な情報は出揃いました。一度整理してみます

設定 値の形式 備考
ライト位置 ベクトル ライト方向、減衰の計算のために必要
ライトが届く距離(閾値) スカラー ライトカリングのために必要
ライトカラー ベクトル(RGB)
ライト強度 スカラー

これらはコンスタントバッファで用意します

ポイントライトコンスタントバッファ
cbuffer CbLight : register(b1)
{
    float3 LightPosition : packoffset(c0);      // ライト位置
    float LightInvSqrRadius : packoffset(c0.w); // ライトが届く距離(2乗の逆数)
    float3 LightColor : packoffset(c1);         // ライトカラー
    float LightIntensity : packoffset(c1.w);    // ライト強度
}

ライトが届く距離について、「2乗の逆数」となっていますが、これは計算処理負荷を下げるためにやっていることです。CPU側で2乗の逆数を計算しておきコンスタントバッファに書き込みます。

(3)の式の実装は次のとおりです

(3)式
float SmoothDistanceAttenuation
(
    float squareDistance,   // ライトからの距離の2乗
    float invSqrAttRadius   // ライトが届く距離の2乗の逆数
)
{
    float factor = squareDistance * invSqrAttRadius;
    float smoothFactor = saturate(1.0f - factor * factor);
    return smoothFactor * smoothFactor;
}

距離減衰の実装は以下のとおりです

距離減衰
#define MIN_DIST (0.01)

float GetDistanceAttenuation
(
    float3 unnormalizedLightVector, // ライト位置とピクセル位置の差分
    float invSqrAttRadius          // ライトが届く距離の2乗の逆数
)
{
    float sqrDist = dot(unnormalizedLightVector, unnormalizedLightVector);
    float attenuation = 1.0f / (max(sqrDist, MIN_DIST * MIN_DIST));
    
    attenuation *= SmoothDistanceAttenuation(sqrDist, invSqrAttRadius);
    
    return attenuation;
}

以上の関数を利用し、ポイントライトを実装します

ポイントライト
float3 EvaluatePointLight(
    float3 surfacePosition,   // ピクセル位置
    float3 lightPositon,      // ライト位置
    float3 lightColor,        // ライトカラー
    float lightIntensity,     // ライト強度
    float lightInvSqrRadius   // ライトが届く距離の2乗の逆数
)
{
    float att = GetDistanceAttenuation(
        lightPositon - surfacePosition,
        LightInvSqrRadius);
    
    return lightColor * lightIntensity * att;
}

スポットライト

Image from Gyazoスポットライトの例。効果がわかりやすいようライトを傾けた状態

ポイントライトは光源の位置から光が球状広がっていきます。これに対し、スポットライトは光源の位置から円錐上に広がっていきます。

Image from Gyazoスポットライトの概念図。2Dで表現しているので扇形になっているが、3D空間上では円錐形になる

距離による減衰(スポットライト)

距離による減衰はスポットライトでも起こります。厳密にはスポットライトの立体角を用いて減衰を計算するのですが、今回は簡単化のために、ポイントライトと同じ減衰計算を使うこととします。イメージとしてはポイントライトの周囲に穴をあけた球体状の覆いを配置しているようなものでしょうか?

Image from Gyazo今回のスポットライトのイメージ

角度による減衰

スポットライトではライト方向と照射位置-光源位置の角度によって光の減衰が起こります。ひとまず下図を確認してください。

Image from Gyazoスポットライトの概念図

記号 説明
\bold{s} ライトの向きベクトル
\bold{l} 光源ベクトルの逆向きのベクトル
\theta_s \bold{s}\bold{l}のなす角
\theta_p 角度減衰が起こらない範囲を表す角
\theta_u ライトが当たる範囲を表す角

図中のベクトルは全て単位ベクトルです。
光源ベクトルは照射位置から光源の方向を正とするので、\bold{l}は光源ベクトルとは逆向きと考えます。

角度による減衰は\theta_{p}\theta_{u}の間で起こります。\theta_{p}の内側では角度による減衰は起こらず、\theta_{p}から\theta_{u}に向かって減衰が起こり、\theta_{u}の外側は照射されません。

これだけだと分かりづらいかもしれないので、実際にスポットライトを使った画像を示します。

Image from Gyazo\theta_p:45度、\theta_u:50度

こちらは\theta_p:45度、\theta_u:50度となっています。照射範囲のエッジが少しだけスムーズに減衰しているのがわかるかと思います。

Image from Gyazo\theta_p:45度、\theta_u:60度

こちらは\theta_p:45度、\theta_u:60度となっています。照射範囲の中央部分は先程の画像と変わらないですが、エッジ部分が先程より緩やかに減衰しているのがわかるかと思います。

この減衰を表す式は以下のとおりです。リアルタイムレンダリング第4版の5.2を参考にしました。

\begin{align} \bigg( \frac{\cos \theta_s - \cos \theta_u}{\cos \theta_p - \cos \theta_u} \bigg)^{\mp 2} \end{align}

x^{\mp 2}xを0,1でクランプしてから2乗するという意味です。
この式をそのまま実装すると、\theta_p = \theta_uのとき分母が0になって計算不可能になります。よって、実装上では分母をmax(cos_p - cos_u, 0.01)のような形式にします。

スポットライトの実装

それではスポットライトの実装に移ります。まずは必要な情報をまとめます。

設定 値の形式 備考
ライト位置 ベクトル ライト方向、減衰の計算のために必要
ライトが届く距離(閾値) スカラー ライトカリングのために必要
ライトカラー ベクトル(RGB)
ライト強度 スカラー
ライトの向き ベクトル 角度減衰の計算のために必要
角度減衰が起こらない範囲を表す角 スカラー 角度減衰の計算のために必要
ライトが当たる範囲を表す角 スカラー 角度減衰の計算のために必要

角度減衰の実装は以下のとおりです。

角度減衰
#define MIN_DIST (0.01)

float GetAngleAttenuation
(
    float cos_s, // ライト方向ベクトルと光源ベクトルの内積
    float cos_p, // 内側角のcos
    float cos_u // 外側角のcos
)
{
    float d = max(cos_p - cos_u, MIN_DIST);
    float t = saturate((cos_s - cos_u) / d);
    return t * t;
}

スポットライトの実装は以下のとおりです。
距離減衰については、ポイントライトの距離減衰の実装を参照してください

スポットライト
float3 EvaluateSpotLight
(
    float3 worldPos,            // ピクセル位置
    float3 lightPos,            // ライト位置
    float lightInvRadiusSq,     // ライトが届く距離の二乗の逆数
    float3 lightForward,        // ライトの向き
    float3 lightColor,          // ライトカラー
    float lightIntensity,       // ライト強度
    float lightInnerCos,        // 角度減衰が起こらない範囲を表す角
    float lightOuterCos         // ライトが当たる範囲を表す角
)
{
    float3 unnnormalizedLightVector = lightPos - worldPos;
    float3 L = normalize(unnnormalizedLightVector);
    float att = GetAngleAttenuation(dot(-L, lightForward), lightInnerCos, lightOuterCos);
    att *= GetDistanceAttenuation(unnnormalizedLightVector, lightInvRadiusSq);
    return lightColor * lightIntensity * att;
}

最終的に出力するカラー

最後に、BRDFと合わせて最終的なカラーを出力するコードを載せておきます。ただし、一部の記述を省略していますのでご注意ください。
BRDFについては前回の記事を参照してください。

// ライトカラー
float3 lit = float3(1.0, 1.0, 1.0);

// ディレクショナルライトの場合
lit = EvaluateDirectionalLight(...);

// ポイントライトの場合
lit = EvaluatePointLight(...);

// スポットライトの場合
lit = EvaluateSpotLight(...)

// 出力カラー
float3 outputColor = saturate(dot(N, L)) * BRDF * lit;
GitHubで編集を提案

Discussion