【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セクションで確認できます。
続けて、使用しているRender Listを見つけます
次に、Add Renderer Featureをクリックして新しいRenderer Featureを追加できます。
新しく作成したRenderer Featureでは、LightModeTagsを追加することで、マルチパスレンダリングを実現できます。Overridesは「None」を選択してください。これは後のステンシルテストの部分で使用します。
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);
参照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;
}
直接光照(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;
}