16
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Unity上でスポットライトとシャドウマップを自作してみた

Last updated at Posted at 2025-06-27

1. はじめに

初めまして
ゲーム事業部でUnityエンジニアをしている日比野です
演出や描画周りに興味があり、個人的にいろいろ作ったりしています
今回は勉強がてらスポットライトとシャドウマップを用いた影の出力をUnity上で自作してみたので、その流れをまとめていきたいと思います

2. まずはスポットライトを自作してみる

まずはスポットライトを作成していきます
手順は以下の通りです

1. 頂点またはピクセルがライトの範囲に入っているかを求める
2. 光の減衰を求める

頂点またはピクセルがライトの範囲に入っているかを求める

スポットライトには光の届く範囲と最も明るくなる範囲の二つがあり、
二つの間では光の量が減衰します
これらの範囲をPhiとThetaで設定します
UnityのlightコンポーネントではInner/OuterSpotAngleがこれにあたります

これらの値は必ずTheta <= Phiとなり、使用する際はラジアン値に変換します
PhiとThetaについて

また範囲内かを求めるためにライトの情報を送る必要があります
送られてきた情報をもとに下記のベクトルを算出し、範囲を求めます
各種ベクトルについて

  • S = スポットライトのワールド座標
  • L = スポットライトの向きの単位ベクトル
  • D = スポットライトのワールド座標から頂点のワールド座標への単位ベクトル
  • α = LとDの内積

PhiとThetaそれぞれを半分にし、cosに変換したものを算出したαと比較し位置を算出します

  • cosα > cos(Phi / 2)の場合頂点は光の範囲内にいます
  • cosα > cos(Theta / 2)の場合は最も明るくなる範囲内にいます

これにより範囲に対して頂点がどこにいるかが分かりました

光の減衰を求める

二つの範囲の間でかかる減衰を求めます
減衰にはフォールオフ(p)を使用します

間にかかる減衰は以下の式で求めることができます

\left( \frac{\cos\alpha-\cos(Phi \div 2)}{\cos(Theta\div2)-\cos(Phi\div2)} \right)^p

これでスポットライトを出力することができました

スポットライトの描画

3. いざ影の実装へ

いよいよ影の実装に入っていきます
影を出す流れは以下の通りです

1. ライトから見た深度値をシャドウマップとしてテクスチャに描きこむ
2. ライトから見た深度値とシャドウマップを比較する

ライトから見た深度値をシャドウマップとして描きこむ

影を出すにはライトから見て重なっているかの情報が必要になるため、ライトから見た深度値を求める必要があります
求めた深度値はRenderTextureに描きこみ、後から参照できるようにします

描き込み先の準備

UnityではライトのオブジェクトにCameraコンポーネントをつけることで深度値を簡単にRenderTextureに描きこむことができますが、シャドウマップの流れも理解したいため今回はCameraコンポーネントなしで実装します

というわけでレンダリング先をRTHandleとして
ScriptableRenderPassとScriptableRendererFeatureを作成していきます
RTHandleの詳しい説明は割愛しますが、簡単に説明すると解像度が違っても使いまわすことのできるRenderTextureです

まずはScriptableRendererFeatureでレンダリング先のRTHandleを作成します

private RTHandleSystem rtHandleSystem;
private RTHandle shadowMap;
// RendererFeatureが生成されたときに呼ばれる
public override void Create()
{
    if (rtHandleSystem == null)
    {
        rtHandleSystem = new RTHandleSystem();
        rtHandleSystem.Initialize(2048, 2048);
    }
    shadowMap = rtHandleSystem.Alloc((int)ShadowResolution._2048, (int)ShadowResolution._2048, depthBufferBits:DepthBits.Depth32, isShadowMap:true,name:"CustomDepth");
    Shader.SetGlobalTexture(Shader.PropertyToID("_CustomShadowMap"), shadowMap);
    customShadowRenderPass ??= new CustomLightRender();
}

次にScriptableRenderPassでレンダーターゲットを設定します
ここはUnity6とそれ以前で実装方法が異なります

public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
    base.Configure(cmd, cameraTextureDescriptor);
    // レンダーターゲットの設定
    ConfigureTarget(shadowMap);
    // ターゲットのクリア
    ConfigureClear(ClearFlag.All, Color.clear);
}

あとはScriptableRenderPassのExecute関数で指定したShaderTagに描画命令を飛ばすようにします

上記はUnity2022での実装となります
Unity6以降はRecordRenderGraphで実装する必要があります

シャドウマップを描きこむ準備

描きこみ先の準備ができたので次にシャドウマップを描きこむのに必要な情報を準備していきます
今回必要となるのはライトから見たビュー行列とプロジェクション行列、それらを掛け合わせたVP行列となります

ビュー行列

各オブジェクトをカメラから見た空間(ビュー空間)に変換するための行列です
今回はカメラからではなくスポットライトから見たビュー行列を取得します
Unityでビュー行列を取得する場合次のような処理を行います

// ビュー行列の取得
private Matrix4x4 GetViewMatrix()
{
    Matrix4x4 viewMatrix = light.transform.worldToLocalMatrix;
    // シェーダーに渡す際zは反転するため-1を掛ける
    viewMatrix.m20 *= -1;
    viewMatrix.m21 *= -1;
    viewMatrix.m22 *= -1;
    viewMatrix.m23 *= -1;
    return viewMatrix;
}

ここで重要となるのがzの値です
Scene上は左手座標系を使用しますが、Shader内では右手座標系を使用します
そのためShaderへ送るタイミングでzの値に-1を掛けて反転してあげる必要があります

プロジェクション行列

ビュー空間にある座標を正規化するための行列です

// プロジェクション行列の取得
private Matrix4x4 GetProjectionMatrix(float fov, float near, float far)
{
    float aspect = 1920.0f / 1080.0f;
    Matrix4x4 proj = Matrix4x4.Perspective(fov, aspect, near, far);
    Matrix4x4 projectionMatrix = GL.GetGPUProjectionMatrix(proj, true);
    return projectionMatrix;
}

今回はスポットライトなのでMatrix4x4.Perspectiveで透視射影行列を作成し、GL.GetGPUProjectionMatrixでGPUに渡すための補正をかけます
本来プラットフォームごとにかかる補正が違うのですがGL.GetGPUProjectionMatrixを使用することでいい感じに補正してくれます

VP行列

ビュー行列とプロジェクション行列を掛け合わせたものです
Unityではプロジェクション行列 × ビュー行列で掛けてあげる必要があります

シャドウマップを描きこむ

頂点シェーダーで深度値を描きこみます
先ほど求めたVP行列と各オブジェクトのワールド座標を掛けた値をSV_POSITIONに入れてあげるだけです

描きこんだシャドウマップはFrameDebugerで確認できます
描きこんだシャドウマップ

実際に影を出す

シャドウマップの作成できたので、いよいよ影を出します

シャドウマップのサンプリング

シャドウマップをサンプリングする際には先ほど使用したVP行列を利用します
VP行列をuv座標として使用する場合、値を0~1にしてあげる必要があるため、
各数値に対して補正をかけてあげます

その後出した値とワールド座標を掛けてあげることでuvとして使用する値を算出します

float4 shadowMapUV = mul(vpBias, float4(o.worldPos.xyz, 1.0f));
shadowMapUV.x = shadowMapUV.x / shadowMapUV.w;
shadowMapUV.y = shadowMapUV.y / shadowMapUV.w;

ここでの重要なポイントはwの値でそれぞれの値を割ることです
シャドウマップを描きこんだSV_POSITIONはフラグメントシェーダーへ渡される際に、
内部の処理でwの値で割る補正がかかるためそれに合わせる必要があります

またサンプリングする際はプラットフォームの違いにより、yの値が反転していることがあるため、それも考慮する必要があります

シャドウマップとの比較

サンプリングまで終わったのであとは比較するだけです
比較対象となる深度値はシャドウマップに描きこんだ時同様、VP行列とワールド座標を掛けたものになります
ただしここでもwの値で割る補正は必要になります

// 比較用に深度値を求める
float4 depth = mul(vp, float4(o.worldPos.xyz, 1.0f));
depth.z = depth.z / depth.w;

float4 lightDepth = SAMPLE_TEXTURE2D(_CustomShadowMap, sampler_CustomShadowMap, shadowMapUV.xy);
float diff = step(lightDepth.r, depth.z);

後はいい感じに補正してあげれば影を出力することができます

いろいろ補正した後の影

4. まとめ

というわけでスポットライトと影の実装でした
Unityではあらかじめ用意されているため自作することはほぼないかと思いますが、
今回紹介した実装も知っていて損はないと思うので興味がある方は作成してみてください


▼採用情報

レアゾン・ホールディングスは、「世界一の企業へ」というビジョンを掲げ、「新しい"当たり前"を作り続ける」というミッションを推進しています。

現在、エンジニア採用を積極的に行っておりますので、ご興味をお持ちいただけましたら、ぜひ下記リンクからご応募ください。

16
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?