3
1

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 URP Shader】崩壊:スターレイル風トゥーンシェーダー再現 学習記録1

Posted at

【Unity URP Shader】崩壊:スターレイル風トゥーンシェーダー再現 学習記録1

はじめに

これまでに多くの先人の解説記事を参考にして、何とか原神風のトゥーンシェーディングを再現してきましたが、正直なところ、シェーダーを書くのは初めてで非常に苦戦しました。実装過程や結果にも納得できない部分が多かったため、理解をさらに深める目的で、今回は『崩壊:スターレイル』風のトゥーン表現を試みました。今回もかなり苦戦しましたが、前回よりも理解が深まった実感があります。

本記事はあくまで個人の学習記録であり、著作権等の問題があれば即削除いたします。内容に誤りがある可能性もあるため、ご指摘いただけると幸いです。

実装の全体フロー

テクスチャ構成の分析

『崩壊:スターレイル』における主要キャラクターのレンダリングでは、以下のテクスチャが使用されていることが分かりました:

BaseColor:Body、Face、Hair

LightMap:Body、Face、Hair

Ramp:ColdRamp、WarmRamp

Stocking(キャラクターによっては存在しない)
BaseColor:固有色。キャラクターの基本的な色を定義するテクスチャです。
LightMap:主に陰影の細部表現。特に顔の陰影にはSDF(Signed Distance Field)技術が使われています。
Ramp:冷色系と暖色系の2種類があり、Body/Face用とHair用で分かれています。NPRにおいて陰影の色をコントロールするためのグラデーションテクスチャです。
Stocking:ストッキング表現用の専用テクスチャです。

下準備

色空間(Linear vs Gamma)

Unityにテクスチャをインポートする際、カラー空間の選択が重要になります。カラー空間にはリニア空間(Linear Space)とガンマ空間(Gamma Space)があります。これは、人間の色が非線形に感知されるためです。人の目は暗い色の変化には敏感ですが、明るい色の違いを識別するのは苦手です。このため、ほとんどのディスプレイは非線形であり、ガンマ補正が施されています。
もしチェックを入れる場合、Unityに対してこのテクスチャマップが光の計算に使用される前にsRGBデコードが必要であることを伝えます。チェックを入れない場合、それはリニアなテクスチャであり、そのRGBデータは直接光の計算に使用できることを意味します。

Wrap Mode(ラップモード)

Repeat:テクスチャ座標が1を超えた場合、その整数部分は破棄され、小数部分がサンプリングに直接使用されます。これにより、テクスチャが繰り返し表示される効果が得られます。

Clamp:テクスチャ座標が1より大きい場合は1に、0より小さい場合は0にクランプ(固定)されます。つまり、範囲外のテクスチャの色は、最も近い端の色に固定されます。

Rampマップ(グラデーションマップ)にはClampモードを選択してください。その他のテクスチャは、デフォルトのRepeatで問題ありません。

Filter Mode(フィルタモード)

テクスチャの補間モードは、主にPointと線形補間(Bilinear および Trilinear)に分けられます。

補間は、主にテクスチャのスケーリングによって発生するエイリアシング(ギザギザ)の問題を解決するために使用されます。

Render Features

URP(Universal Render Pipeline)パイプラインでマルチパスを使用するには、Renderer Featuresを設定する必要があります。

このRenderer Featuresを設定するには、Assets/Settingsディレクトリにある、現在使用しているRender Pipelineアセットを見つける必要があります。どのRender Pipelineを使用しているかわからない場合は、Project SettingsのGraphicsセクションで確認できます。
スクリーンショット 2025-06-24 205939.png
続けて、使用しているRender Listを見つけます
スクリーンショット 2025-06-24 210434.png

次に、Add Renderer Featureをクリックして新しいRenderer Featureを追加できます。

新しく作成したRenderer Featureでは、LightModeTagsを追加することで、マルチパスレンダリングを実現できます。Overridesは「None」を選択してください。これは後のステンシルテストの部分で使用します。
スクリーンショット 2025-06-24 210711.png

Shaderの実装

Shaderパラメータの定義

Properties
    {
        [Space(15.0)]
        [Header(Selection)]
        [KeywordEnum(HAIR, FACE, BODY)] _AREA("AREA", Float) = 0
        [Toggle(STOCKING_IS_ON)] _StockingOn ("StockingOn", Float) = 0.0
        [Toggle(OUTLINE_IS_ON)] _OutlineOn ("OutlineOn", Float) = 0.0
        [Space(20.0)]
        [NoScaleOffset]_diffuse( "Diffuse" , 2d) = "white"{}
        //RimLight
        [Header(Rimlight)]
        _OffsetMul("RimLight Offset", Float) = 0.2
        _Threshold ("RimLight Threshold", Float) = 0.07
        _FresnelMask ("FresnelMask", Float) = 0.2
        _RimLightPower("_RimLightPower", Float) = 0.7
        [Space(15.0)]
        [Header(LightMap)]
        [NoScaleOffset]_lightmap( "Lightmap/FaceLightmap" , 2d) = "white"{}
        //SH(球諧ライティング)の強度を制御する係数
        _IndirectLightMixBaseColor("SH Strength", Range(0, 1)) = 0.4
        //デフォルトの係数は0.5です。これは、LightmapのRチャンネルにある手描きAOシャドウの強度を制御するために使用されます。
        _IndirectLightOcclusionUsage("R-AO Shadow", Range(0, 1)) = 0.5
        //主光源のパラメーター
        //陰影の境界線と陰影のグラデーションの程度
        _ShadowRange("ShadowRange", Float) = 0
        _ShadowSmooth("ShadowSmooth", Float) = 0.1
        [Space(30.0)]
        //ramp
        [Header(Ramp)]
        [NoScaleOffset]_rampcool( "Shadow_Ramp Cool" , 2d) = "white"{}
        [NoScaleOffset]_rampwarm( "Shadow_Ramp Warm" , 2d) = "white"{}
        _ShadowRampOffset("ShadowRampOffset", Range(0, 1)) = 0.75
        [Space(20.0)]
        //Specular
        [Header(Specular)]
        _gloss("Gloss", Range(1, 256.0)) = 1
        _SpecularKsNonMetal("SpecularKsNonMetal", Float) = 0.04//非金属部分の反射率
        //金属部分に関しては、異なる金属で反射率の差が大きいため、閾値マップは金属の反射率の差異を表現するために使用されます。
        _SpecularKsMetal("SpecularKsMetal", Float) = 1//金属部分の総反射率
        _SpecularBrightness("SpecularBrightness", Float) = 4//最終的なハイライトの明るさを制御
        //Emission
        _Emission("Emission", Range(1, 10)) = 1//Body A
        [Space(20.0)]
        //Stockings
        [Header(Stockings)]
        [NoScaleOffset]_BodyStockings("Body Stockings", 2d) = "white"{}
        _StockingsTransitionPower("StockingsTransitionPower", Float) = 1
        _StockingsTransitionHardness("StockingsTransitionHardness", Float) = 0
        _StockingsTextureUsage("StockingsTextureUsage", Float) = 0.25
        [Space(20.0)]
        //色のグラデーション
        _StockingsDarkColor("StockingsDarkColor", Color) = (0, 0, 0, 1)
        _StockingsTransitionColor("StockingsTransitionColor", Color) = (0.360381, 0.242986, 0.358131, 1)
        _StockingsLightColor("StockingsLightColor", Color) = (1.8, 1.48299, 0.856821, 1)
        _StockingsTransitionThrehold("StockingsTransitionThrehold", Float) = 0.58
        [Space(20.0)]
        //OutLine
        [Header(Outline)]
        _OutlineWidth( "Outline Width" , Float) = 1.6
        _OutlineColor( "Outline Color" , color) = (0.6, 0.6, 0.6, 1.0)
        _MinOutlineWidth("Min OutlineWidth", Float) = 1
        _MaxOutlineWidth("Max OutlineWidth", Float) = 4
        _AdaptiveScale("Adaptive Scale", Float) = 0.5
    }

URPのパラメーターは、CBUFFER_STARTとCBUFFER_ENDの間に含める必要があります。

眉毛が前髪を通して半透明に見える効果を実現するために、ステンシルテストの機能を使用します。異なるステンシルを設定する必要があるため、シェーダーを分ける必要があります。そのため、共通のパラメーター宣言と関数はStarRail.hlslファイルに配置しました。

#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"  
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"  
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"CBUFFER_START(UnityPerMaterial)  
//Difuse
//AO
float _IndirectLightMixBaseColor;///SH(球諧ライティング)
float _IndirectLightOcclusionUsage;
//主光源
float _ShadowRange;//明暗交界线位置
float _ShadowSmooth;//明暗过渡位置
//RimLight
float _OffsetMul;
float _Threshold;
float _FresnelMask;
float _RimLightPower;
//Ramp
float _ShadowRampOffset;
//ハイライト
float _gloss;//blinnPhong
//反射率
float _SpecularKsNonMetal;
float _SpecularKsMetal;
float _SpecularBrightness;//明るさを制御
//Emission
float _Emission;
//stockings
float _StockingsTransitionPower;
float _StockingsTransitionHardness;
float _StockingsTextureUsage;
//色のグラデーション
float4 _StockingsDarkColor;
float4 _StockingsTransitionColor;
float4 _StockingsLightColor;
float _StockingsTransitionThrehold;
//Outline
float _OutlineWidth;
float4 _OutlineColor;
float _AdaptiveScale;
float _MinOutlineWidth;
float _MaxOutlineWidth;
CBUFFER_END  
//テクスチャ
//Diffuse
TEXTURE2D(_diffuse);  
SAMPLER(sampler_diffuse);
//Lightmap/FaceLightmap
TEXTURE2D(_lightmap);  
SAMPLER(sampler_lightmap);
//Shadow_Ramp 
TEXTURE2D(_rampcool);  
SAMPLER(sampler_rampcool);
TEXTURE2D(_rampwarm);
SAMPLER(sampler_rampwarm);
//Stockings
TEXTURE2D(_BodyStockings);
SAMPLER(sampler_BodyStockings);

ベクトル計算の準備

構造体の宣言

struct a2v
{
    float4 vertex : POSITION;  
    float2 texcoord0 : TEXCOORD0;  
    float3 normal : NORMAL;  
    float4 tangent : TANGENT;  
    float4 color : COLOR;
};

struct v2f
{
    float4 pos : SV_POSITION;  
    float2 uv0 : TEXCOORD0; 
    float3 normalDirWS : NORMAL;
    float3 viewDirWS : TEXCOORD1;
    float3 posCS : TEXCOORD2;
    float4 SmoothNormalWS : TEXCOORD3;
};

頂点シェーダーとフラグメントシェーダー

//異なる部分のレンダリングを区別するマクロの定義
#pragma shader_feature_local_fragment STOCKING_IS_ON
#pragma multi_compile _AREA_FACE _AREA_HAIR _AREA_BODY

            v2f vert (a2v v)
            {
                v2f o;
                o.pos = TransformObjectToHClip(v.vertex.xyz);  //MVP変換
                o.posCS = mul(UNITY_MATRIX_MV, v.vertex).xyz;
                o.uv0 = v.texcoord0;  //UV0の直接渡し(変換なし)
                o.normalDirWS = TransformObjectToWorldNormal(v.normal);  //ワールド空間法線
                float3 posWS = TransformObjectToWorld(v.vertex.xyz);  //ワールド頂点位置
                o.viewDirWS = normalize(_WorldSpaceCameraPos - posWS);//ワールド空間での視線方向

                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                //主光源
                Light mlight = GetMainLight();  //光源

                float3 lDirWS = normalize(mlight.direction);  //ワールド光源方向(平行光)
                float3 viewDirWS = normalize(i.viewDirWS);//ワールド空間での視線方向
                float3 nDirWS = normalize(i.normalDirWS);//ワールド空間での法線方向
                float3 hDirWS = normalize(viewDirWS + lDirWS) ;  

                
                float NdotL = dot(nDirWS, lDirWS);//Lambert
                float HalfLambert = 0.5 * NdotL + 0.5;//HalfLambert
                float NdotH = dot(nDirWS, hDirWS);
                float NdotV = dot(nDirWS, viewDirWS);
            }

間接光(環境光)

間接光は、主にAOと拡散反射光の2つの部分で構成されます。

拡散反射光

ここでの漫反射は主に、スカイボックスから放射される環境光による漫反射を指し、球諧関数(Spherical Harmonics: SH)を用いてシミュレーションします。

環境漫反射はSampleSH関数を直接使用して取得します。

float3 indirectLightCol = SampleSH(i.normalDirWS);

スクリーンショット 2025-06-24 220443.png
参照https://zhuanlan.zhihu.com/p/351289217

AO

AOは、光が届かない部分、つまり常に光が当たらない部分と理解できます。絵画においては、オブジェクトに「重い影を落とす」場所、つまり濃い色が必要な場所とも言えます。

テクスチャの分析から、LightMapのRチャンネルには手描きの陰影(AO環境光遮蔽)が格納されていることがわかります。したがって、AOを計算するためにはLightMapのRチャンネルの値を使用する必要があります。

indirectLightCol *= lerp(1, lightmap.r, _IndirectLightOcclusionUsage);

顔のAO

indirectLightCol *= lerp(1, lerp(lightmap.g, 1, step(lightmap.r, 0.5)), _IndirectLightOcclusionUsage);

step(lightmap.r, 0.5)についてですが、テクスチャの黒い部分(値が0)では、戻り値が1になります。

この場合、lerp(lightmap.g, 1, step(lightmap.r, 0.5))の戻り値は1となり、最終的な式の結果も1になります。これにより、環境光の色は変更されません。

一方、Rチャンネルのその他の部分(目、まつげ、眉毛、歯、舌、口など)では、step(lightmap.r, 0.5)は0を返します。このため、lerp(lightmap.g, 1, step(lightmap.r, 0.5))はlightmap.gとなり、デフォルトでは0.5 + lightmap.g * 0.5の値になります。

//AO
float3 GetIndirectLight(v2f i, float4 lightmap)
{
    //球諧関数
    float3 indirectLightCol = SampleSH(i.normalDirWS);
    //LightMapのRチャンネルでは、陰影のある部分が黒(値が0)として保存されます

    #ifdef _AREA_FACE
    indirectLightCol *= lerp(1, lerp(lightmap.g, 1, step(lightmap.r, 0.5)), _IndirectLightOcclusionUsage);
    #else
    indirectLightCol *= lerp(1, lightmap.r, _IndirectLightOcclusionUsage);
    #endif

    return indirectLightCol;
}

スクリーンショット 2025-06-24 224315.png

直接光照(Direct Lighting)

半ランバート(Half-Lambert)光照モデルを利用してライティング効果を得ます。ただし、顔の部分の陰影については別途処理を行う必要があります。

Body

LightMapのGチャンネル

step(1 - lightMap.g, HalfLambert)

上記の式では陰影の計算結果が得られますが、step関数の戻り値が0か1しかないため、明るい面と暗い面との境界線が非常に際立ってしまいます。

このため、smoothstep関数を使って明暗の境界線付近を柔らかく処理します。

float shadow = smoothstep(1 - lightmap.g + _ShadowRange - _ShadowSmooth,
        1 - lightmap.g + _ShadowRange + _ShadowSmooth, HalfLambert);

LightMapのRチャンネルには、対応する陰影の明るさ・暗さの値が格納されています。

shadow *= lightmap.r;

上記をまとめると、身体部分のライティング関数を得ました

//Shadow
float GetShadow(float HalfLambert, float4 lightmap)
{
    
    float shadow = smoothstep(1 - lightmap.g + _ShadowRange - _ShadowSmooth,
        1 - lightmap.g + _ShadowRange + _ShadowSmooth, HalfLambert);

    
    shadow *= lightmap.r;

    return shadow;
}

顔のSDFシャドウ

顔の陰影にはSDF技術を使用します。

このベクトル計算には、ワールド空間において、頭部を基準点とした前方方向と右方向を見つける必要があります。

float3 forwardWS = normalize(TransformObjectToWorldDir(float3(0.0,0.0,1.0)));
float3 rightWS = normalize(TransformObjectToWorldDir(float3(1.0,0.0,0.0)));
float3 upWS = cross(forwardWS, rightWS);

光ベクトルを頭部座標系の水平面に投影します。そうしないと、キャラクターが逆さまになった際に、陰影が反転してしまいます。

float3 fixedlDirWS =normalize(lDirWS - dot(lDirWS, upWS) * upWS);

テクスチャのサンプリング

float2 SDFuv = float2(sign(dot(fixedlDirWS, rightWS)), 1) * i.uv0 * float2(-1, 1);
float sdfValue = SAMPLE_TEXTURE2D(_lightmap, sampler_lightmap, SDFuv).a;

HLSLのsign関数は、値が正、負、ゼロのいずれであるか、その符号を返す非常にシンプルで実用的な数学関数です。

float2(sign(dot(fixedlDirWS, rightWS)), 1)は、UVのX軸成分を反転させるかどうかを制御します。これは、光が顔の右側に当たったとき顔のSDFマップからUVを取得し、光が左側に当たったときには横座標を反転させてUVを取得するためです。float2(-1, 1)を乗算することで、テクスチャが強制的に「ミラーリング」され、テクスチャの左右の軸が「ミラーリング」されて、常に正しい側のテクスチャをサンプリングできるようになります。

sdfThresholdを顔が点灯しうる状況の尺度として使用します。光が顔の真正面にあるほどsdfThresholdの値は大きくなり、閾値は低くなり、顔はより明るく表示されやすくなります。その後、smoothstep関数を使用して、影の移行を柔らかく処理します。

float sdfThreshold = 1 - (dot(fixedlDirWS, forwardWS) * 0.5 + 0.5);
float sdf = smoothstep(sdfThreshold - _ShadowSmooth, sdfThreshold + _ShadowSmooth, sdfValue);

最後に、lightmap.gを利用して、目など陰影が不要な部分を取り除きます。

float shadow = lerp(lightmap.g, sdf, step(lightmap.r, 0.5));
//Face SDF
float FaceShadow(v2f i, float3 lDirWS, float4 lightmap)
{

    float3 forwardWS = normalize(TransformObjectToWorldDir(float3(0.0,0.0,1.0)));向
    float3 rightWS = normalize(TransformObjectToWorldDir(float3(1.0,0.0,0.0)));
    float3 upWS = cross(forwardWS, rightWS);

    
    float3 fixedlDirWS =normalize(lDirWS - dot(lDirWS, upWS) * upWS);

    float2 SDFuv = float2(sign(dot(fixedlDirWS, rightWS)), 1) * i.uv0 * float2(-1, 1);

    float sdfValue = SAMPLE_TEXTURE2D(_lightmap, sampler_lightmap, SDFuv).a;

    float sdfThreshold = 1 - (dot(fixedlDirWS, forwardWS) * 0.5 + 0.5);

    float sdf = smoothstep(sdfThreshold - _ShadowSmooth, sdfThreshold + _ShadowSmooth, sdfValue);

    float shadow = lerp(lightmap.g, sdf, step(lightmap.r, 0.5));

    return shadow;
}

参考https://zhuanlan.zhihu.com/p/713875076
スクリーンショット 2025-06-24 230112.png

3
1
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
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?