床に落ちる、光の輪(コーン)は、どう計算しているのでしょうか。Angleに角度を入れたら、真っ白になった、というトラブルも、よく起きます。スポットライトは、点光源との違いを理解すると、実装が見通せます。
この記事では、天井や懐中電灯の光のように、円錐状に光を落とすスポットライトについて、実際にDirectXのシェーダーを書いて動かしながら解説します。角度の計算自体はシンプルですが、単位を間違えると「ただの照明」になってしまう、実際に踏んだ罠も含めて紹介します。
スポットライトとは?
スポットライトとは、
・光源から円錐状の範囲にだけ光を届かせるライトの種類。円錐の外側は真っ暗、内側は明るい
です。全方向に光る点光源(ポイントライト)と違い、向きと円錐の広がり角を持っているのが特徴です。懐中電灯や舞台のスポットライト、天井の丸いダウンライトをイメージすると分かりやすいです。
実装の中身
まずC++側で、床の真上5mの位置から真下に向けてスポットライトを1つ用意します。
LIGHT light;
ZeroMemory(&light, sizeof(LIGHT));
light.Enable = TRUE;
light.Position = XMFLOAT4(0.0f, 5.0f, 0.0f, 1.0f); // 光源位置(床の真上)
light.Direction = XMFLOAT4(0.0f, -1.0f, 0.0f, 0.0f); // 光の向き(真下)
light.Diffuse = XMFLOAT4(1.0f, 1.0f, 0.9f, 1.0f);
light.Ambient = XMFLOAT4(0.04f, 0.04f, 0.05f, 1.0f); // 円の外はほぼ暗い
light.Angle = XMFLOAT4(0.35f, 0.0f, 0.0f, 0.0f); // 円錐の半頂角(ラジアン)
light.PointLightParam = XMFLOAT4(0.0f, 2.5f, 0.0f, 0.0f); // yに縁の減衰指数
SetLight(light);ポイントはAngle.xです。ここが円錐の半頂角(光の中心線から円錐の縁までの角度)で、ラジアンで指定します。ピクセルシェーダー側では、この角度を使って「そのピクセルが円錐の内側か外側か」を判定しています。
// 1) 光源からピクセルへのベクトル LV(正規化)
float3 lv = normalize(In.WorldPosition.xyz - Light.Position.xyz);
// 2) コーンの向きベクトル CV(正規化)
float3 cv = normalize(Light.Direction.xyz);
// 3) LV と CV のなす角度
float angle = acos(dot(lv, cv));
angle = abs(angle);
// なめらかなスポット減衰
// spot = saturate(1.0 - pow(angle / Light.Angle.x, PointLightParam.y))
float spot = saturate(1.0f - pow(1.0f / Light.Angle.x * abs(angle), Light.PointLightParam.y));angleが0(円錐の真ん中)に近いほどspotは1、Light.Angle.x(円錐の縁)に近づくほどspotは0に近づきます。このspotを最終的な明るさに掛け算するだけで、円錐の外は自動的に真っ暗になります。実際に描画すると、床にこういう円形の光の輪が落ちます。

【重要】実際に踏んだ罠:Angleに度数(degree)の値を入れてしまった
この記事用にコーンの広さを調整していたとき、他のエンジン(UnityのLight.spotAngleなど)で見慣れた「スポット角度は度数で指定する」という感覚のまま、Angle.xに20.0f(20度のつもり)を入れてしまいました。
// NG: 度数のつもりで20を入れてしまった(本来はラジアン)
light.Angle = XMFLOAT4(20.0f, 0.0f, 0.0f, 0.0f);ビルドして実行すると、円形の光の輪ではなく、床全体がほぼ均一に光る結果になりました。

原因は単純で、acos()が返すangleは最大でもπ(約3.14ラジアン)までしかありません。Angle.xに20を入れると、床のどのピクセルでもangle / Angle.xはほぼ0に近い値にしかならず、pow()の結果もほぼ0、つまりspot = saturate(1.0 - ほぼ0)でほとんどの場所でspotがほぼ1になってしまうのです。円錐で絞り込んでいたつもりが、実質「絞り込みなしのライト」になっていました。
対処法は単純で、度数をラジアンに変換するか、最初からラジアンで値を決めることでした。20度が欲しいならXMConvertToRadians(20.0f)(≒0.35ラジアン)を使うようにしたところ、狙い通りの円形の光の輪に戻りました。
スポットライトのよくある失敗例と対処法
①Angleに度数の値をそのまま入れてしまう
上で実演した通りです。Angle.xは必ずラジアンで渡しましょう。「度で考えたい」場合はXMConvertToRadians()などの変換関数を必ず経由するのがおすすめです。
②Light.Directionを正規化し忘れる
cv = normalize(Light.Direction.xyz)としているのは、長さが1でないベクトルが渡されるとその後のdot(lv, cv)が[-1, 1]の範囲を超えてしまい、acos()にその値を渡した瞬間に結果がNaN(非数)になることがあるためです。C++側で向きベクトルを設定するときも、必ず正規化してから渡しましょう。
③PointLightParam.y(減衰指数)を0にしてしまう
spot = saturate(1.0 - pow(base, PointLightParam.y))で、指数を0にするとpow(base, 0)は(baseがどんな値でも)常に1になるため、spotが常に0、つまり円錐の中心も含めてコーン全体が真っ暗になってしまいます。境界をやわらかくしたいときは指数を1〜3程度の範囲で調整するのが安全です。
注意点
Light.Angle.xはラジアン指定(度をそのまま入れない)- スポットの減衰は光源からピクセルへの角度ベースの計算(距離による減衰とは別物)
PointLightParam.yで境界のなめらかさを調整できる(大きいほどシャープ、0にすると真っ暗)Light.Directionは正規化してから渡す(NaN対策)
この記事のポイント
- スポットライトは光源からピクセルへの角度を計算し、円錐の縁で減衰させて作る
- 通常のポイントライティングの計算にspot項を1つ掛けるだけで実装できる
- Angleはラジアン。度数のつもりで数値を入れると円錐が消えて全体が光ってしまう(実際に踏んだ罠)
- 思った形にならないときは、まず角度の単位(度orラジアン)を疑うと早い
スポットライトは、通常のポイントライティングの計算に、spot項を1つ掛けるだけで実装できます。Angleはラジアンです。度数のつもりで数値を入れると、円錐が消えて、全体が光ってしまいます(実際に踏みました)。思った形にならないときは、まず、角度の単位を疑うと早いです。


