凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity で URP 向けのファーシェーダを書いてみた(シェル法)

はじめに

今回は書いたことのなかったファーシェーダを試してみます。コードも書きやすいのでこれからはレガシーパイプラインではなく Universal Render Pipeline(URP)メインで書いていこうかな、と思っています(HDRP は直書きが大変すぎて趣味としては辛さが勝ったので余り触れない感じで...)。URP でのシェーダについては以下の記事をご参照ください。

tips.hecomi.com

デモ

以下の解説をします。

  • 1 Pass(ジオメトリシェーダ利用)
  • Lit シェーダ相当のライティング
  • 法線の計算
  • リムライト

f:id:hecomi:20210627184842g:plain

ダウンロード

完成プロジェクトは以下にあげてあります。

github.com

ファーシェーダ

ファーシェーダを実現する手法は幾つかあり、シェル法フィン法がよく使われるようです。

news.mynavi.jp

news.mynavi.jp

今回はシェル法をジオメトリシェーダを使ってやってみます。シェル法は法線方向に膨らませたポリゴンを何層も重ね、それぞれの層で毛生えを表現するテクスチャでクリッピングしたポイントを塗り重ねていくことで線っぽい見た目を表現する手法です。

層を作ってみる

ShaderLab

まずは Unlit でシンプルに作ってみます。頂点、ジオメトリ、フラグメントシェーダの実装は別ファイル(Fur.hlsl)に、ShaderLab の設定は Fur.shader に記述する形にします。まず、Fur.shader から見てみましょう。

Shader "Unlit/Fur"
{

Properties
{
    _BaseMap("Base Map", 2D) = "white" {}
    _FurMap("Fur Map", 2D) = "white" {}
    [IntRange] _ShellAmount("Shell Amount", Range(1, 100)) = 16
    _ShellStep("Shell Step", Range(0.0, 0.01)) = 0.001
    _AlphaCutout("Alpha Cutout", Range(0.0, 1.0)) = 0.1
}

SubShader
{
    Tags 
    { 
        "RenderType" = "Opaque" 
        "RenderPipeline" = "UniversalPipeline" 
        "IgnoreProjector" = "True"
    }

    LOD 100

    ZWrite On
    Cull Back

    Pass
    {
        Name "Unlit"
        HLSLPROGRAM
        #pragma exclude_renderers gles gles3 glcore
        #pragma multi_compile_fog
        #include "./Fur.hlsl"
        #pragma vertex vert
        #pragma require geometry
        #pragma geometry geom 
        #pragma fragment frag
        ENDHLSL
    }

    Pass
    {
        Name "DepthOnly"
        Tags { "LightMode" = "DepthOnly" }

        ZWrite On
        ColorMask 0

        HLSLPROGRAM
        #pragma exclude_renderers gles gles3 glcore
        #pragma vertex DepthOnlyVertex
        #pragma fragment DepthOnlyFragment
        #include "Packages/com.unity.render-pipelines.universal/Shaders/UnlitInput.hlsl"
        #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl"
        ENDHLSL
    }

    Pass
    {
        Name "ShadowCaster"
        Tags {"LightMode" = "ShadowCaster" }

        ZWrite On
        ZTest LEqual
        ColorMask 0

        HLSLPROGRAM
        #pragma exclude_renderers gles gles3 glcore
        #pragma target 4.5
        #pragma vertex ShadowPassVertex
        #pragma fragment ShadowPassFragment
        #include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
        #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl"
        ENDHLSL
    }
}

}

影・デプス付き(Lit シェーダを参考に DepthOnly および ShadowCaster パスを追加)の Unlit シェーダとして Pass を作っておきます。影は今回はとりあえずファーは反映されず、もとのメッシュのものを出力するようにしています。Unlit パスでは Fur.hlsl を #include し、そちらに実装を記述するようにしているので、次はそちらを見てみましょう。

頂点・ジオメトリ・フラグメントシェーダ

#ifndef FUR_HLSL
#define FUR_HLSL

#include "Packages/com.unity.render-pipelines.universal/Shaders/UnlitInput.hlsl"

int _ShellAmount;
float _ShellStep;
float _AlphaCutout;

TEXTURE2D(_FurMap); 
SAMPLER(sampler_FurMap);
float4 _FurMap_ST;

struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS   : NORMAL;
    float4 tangentOS  : TANGENT;
    float2 uv         : TEXCOORD0;
};

struct Varyings
{
    float4 vertex   : SV_POSITION;
    float2 uv       : TEXCOORD0;
    float2 uv2      : TEXCOORD1;
    float  fogCoord : TEXCOORD2;
    float  layer    : TEXCOORD3;
};

Attributes vert(Attributes input)
{
    return input;
}

void AppendShellVertex(inout TriangleStream<Varyings> stream, Attributes input, int index)
{
    Varyings output = (Varyings)0;

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);

    float3 posWS = vertexInput.positionWS + normalInput.normalWS * (_ShellStep * index);
    float4 posCS = TransformWorldToHClip(posWS);
    
    output.vertex = posCS;
    output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
    output.uv2 = TRANSFORM_TEX(input.uv, _FurMap);
    output.fogCoord = ComputeFogFactor(posCS.z);
    output.layer = (float)index / _ShellAmount;

    stream.Append(output);
}

[maxvertexcount(96)]
void geom(triangle Attributes input[3], inout TriangleStream<Varyings> stream)
{
    [loop] for (float i = 0; i < _ShellAmount; ++i)
    {
        [unroll] for (float j = 0; j < 3; ++j)
        {
            AppendShellVertex(stream, input[j], i);
        }
        stream.RestartStrip();
    }
}

float4 frag(Varyings input) : SV_Target
{
    float4 furColor = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.uv2);
    if (input.layer > 0.0 && furColor.r < _AlphaCutout) discard;

    float4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
    float3 color = baseColor;
    color = MixFog(color, input.fogCoord);

    return float4(color, 1.0);
}

#endif

ジオメトリシェーダで、入力された三角ポリゴンを _ShellAmout だけ法線方向に少し膨らませながら数を増やして層をつくっています。なお、このときレイヤも一緒に出力するようにしておきます。

フラグメントシェーダでは入力されたノイズテクスチャを _AlphaCutoutクリッピングして黒い場所を捨て白い場所だけ残します。ただし、レイヤ 0 は通常のポリゴンとしてそのまま出力するようにこの処理を省いています。あとは通常通り色を出力するとノイズの部分だけが毛のように見えるようになります。

概念としてはこんな感じで、破線がノイズテクスチャを意味します。ステップを短くして層を増やすほど毛っぽさが出ます。

f:id:hecomi:20210625000029p:plain

重ねられる層の数はジオメトリシェーダで指定する maxvertexcount に依存します。数は出力構造体の大きさによって可変で、出力する数値の合計が 1024 以下になるようにする必要があります。ここでは Varyings が 10 個の float を出力するため、最大数は 1024/10 = 102 が指定できる最大となり、一層でポリゴン分の 3 頂点を使うので、最大の層の数としては 34 層までとなります。1 枚のテクスチャ(例えば alpha チャンネルとか)に毛の生え具合もパックしてやれば 2 削減でき、128 頂点 42 層が最大になります。UV も今は _BaesMap 用と _FurMap で分けてますが一緒にして _FurScale みたいなものを用意すれば更に 2 削減できます。こうすれば 56 層まで作れますね。

結果

f:id:hecomi:20210625002505g:plain

まだクオリティはアレですが層が重なってノイズによってイガイガが出来たのがわかります。

入力するノイズはとりあえずテスト用になにか与えましょう。ここでは CC0 の以下の画像をお借りしています。

commons.wikimedia.org

見栄えを良くしてみる

フラグメントシェーダを少しいじってそれっぽく見えるように改良してみます。

float _Occlusion;

float4 frag(Varyings input) : SV_Target
{
    float4 furColor = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.uv2);

    // 先に行くほどノイズテクスチャが暗くなって細くなるようにする
    float alpha = furColor.r * (1.0 - input.layer);
    if (input.layer > 0.0 && alpha < _AlphaCutout) discard;

    float4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);

    // 根元の方を暗くする
    float occlusion = lerp(1.0 - _Occlusion, 1.0, input.layer);
    float3 color = baseColor * occlusion;

    color = MixFog(color, input.fogCoord);

    return float4(color, alpha);
}

f:id:hecomi:20210625011627g:plain

毛の視認性が上がりました。縁の方に行くにつれ層が見えやすくなってしまっていますね。これがシェル法の欠点なのでフィン法とハイブリッドにする方法もあるようです。

ノイズテクスチャを細かくすればより細かい毛も出来ます。

f:id:hecomi:20210625013927p:plain

また、alpha に適当なテクスチャを掛けてあげれば長さ制御も可能です。例えば _BaseMap を使いまわして暗いところは毛が短くなるようにしてみます。

    float4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv.xy);
    float alpha = furColor.r * (1.0 - input.layer) * baseColor.r;
    ...

f:id:hecomi:20210625015756p:plain

毛を動かす

概要

ジオメトリシェーダで生成するそれぞれの層のポリゴンを少しずつズラしてあげると毛が風でなびいたり重力で垂れ下がったりする表現が出来ます。

f:id:hecomi:20210626144106p:plain

コード

外から移動量を与え、毛先に行くほどズレが大きくなるようにコードを修正します。

// xyz: 移動方向 w: 曲がり具合
float4 _BaseMove;
// xyz: 揺れの周期
float4 _WindFreq;
// xyz: 揺れの大きさ w: ローカル座標による位相ズレ
float4 _WindMove;

void AppendShellVertex(inout TriangleStream<Varyings> stream, Attributes input, int index)
{
    Varyings output = (Varyings)0;

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);

    // 先に行くほど曲がりを大きくする
    float moveFactor = pow((float)index / _ShellAmount, _BaseMove.w);

    // 定期的な揺れ表現
    float3 posOS = input.positionOS;
    float3 windAngle = _Time.w * _WindFreq.xyz;
    float3 windMove = moveFactor * _WindMove.xyz * sin(windAngle + posOS * _WindMove.w);

    // 定常的な移動
    float3 move = moveFactor * _BaseMove.xyz;

    // シェルの方向を変える(伸びないように正規化する)
    float3 shellDir = normalize(normalInput.normalWS + move + windMove);

    float3 posWS = vertexInput.positionWS + shellDir * (_ShellStep * index);
    float4 posCS = TransformWorldToHClip(posWS);
    
    output.vertex = posCS;
    output.uv = float4(TRANSFORM_TEX(input.uv, _BaseMap), TRANSFORM_TEX(input.uv, _FurMap));
    output.fogCoord = ComputeFogFactor(posCS.z);
    output.layer = (float)index / _ShellAmount;

    stream.Append(output);
}

結果

f:id:hecomi:20210626151859g:plain

影も対応する

元の形はキープするのでそんなに違和感はないですが、パフォーマンスを犠牲にしてもきれいな影を出したい場合もあると思います。影はこれまでのコードをおおよそ流用することで対応できます。

ShaderLab

Shader "Unlit/Fur"
{
...
SubShader
{
    ...
    Pass
    {
        Name "ShadowCaster"
        Tags { "LightMode" = "ShadowCaster" }

        ZWrite On
        ZTest LEqual
        ColorMask 0

        HLSLPROGRAM
        #pragma exclude_renderers gles gles3 glcore
        #include "./Fur.hlsl"
        #pragma vertex vert
        #pragma require geometry
        #pragma geometry geom 
        #pragma fragment fragShadow
        ENDHLSL
    }
}

}

フラグメントシェーダ

void fragShadow(
    Varyings input, 
    out float4 outColor : SV_Target, 
    out float outDepth : SV_Depth)
{
    float4 furColor = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.uv.zw);
    float alpha = furColor.r * (1.0 - input.layer);
    if (input.layer > 0.0 && alpha < _AlphaCutout) discard;

    outColor = outDepth = input.vertex.z / input.vertex.w;
}

同じコードは DepthOnly パスでも利用できます。これによってデプスプリパスが有効化されているケースやデプスを利用するポストエフェクトが必要なときに役に立ちます。

結果

f:id:hecomi:20210626154145g:plain

ライティング対応

これまでは Unlit で見てきたので Lit シェーダをもとにしたシェーディングを行っていきましょう。基本は同じで、ライティングに必要なパラメタを追加でフラグメントシェーダに渡すようにし、UniversalFragmentPBR() して上げれば良い形です。この Lit シェーダの仕組みは以前以下の記事で解説を行いました:

tips.hecomi.com

ShaderLab

Shader "Lit/Fur"
{

Properties
{
    [MainColor] _BaseColor("Color", Color) = (0.5, 0.5, 0.5, 1)
    _BaseMap("Base Map", 2D) = "white" {}
    [Gamma] _Metallic("Metallic", Range(0.0, 1.0)) = 0.5
    _Smoothness("Smoothness", Range(0.0, 1.0)) = 0.5
    _FurMap("Fur Map", 2D) = "white" {}
    [IntRange] _ShellAmount("Shell Amount", Range(1, 14)) = 14
    _ShellStep("Shell Step", Range(0.0, 0.01)) = 0.001
    _AlphaCutout("Alpha Cutout", Range(0.0, 1.0)) = 0.2
    _Occlusion("Occlusion", Range(0.0, 1.0)) = 0.5
    _BaseMove("Base Move", Vector) = (0.0, -0.0, 0.0, 3.0)
    _WindFreq("Wind Freq", Vector) = (0.5, 0.7, 0.9, 1.0)
    _WindMove("Wind Move", Vector) = (0.2, 0.3, 0.2, 1.0)
}

SubShader
{
    Tags 
    { 
        "RenderType" = "Opaque" 
        "RenderPipeline" = "UniversalPipeline" 
        "UniversalMaterialType" = "Lit"
        "IgnoreProjector" = "True"
    }

    LOD 100
    ZWrite On
    Cull Back

    Pass
    {
        Name "ForwardLit"
        Tags { "LightMode" = "UniversalForward" }
        HLSLPROGRAM
        #pragma prefer_hlslcc gles
        #pragma exclude_renderers d3d11_9x
        #pragma target 2.0

        // マテリアルのキーワード
        #pragma shader_feature _NORMALMAP
        #pragma shader_feature _ALPHATEST_ON
        #pragma shader_feature _ALPHAPREMULTIPLY_ON
        #pragma shader_feature _EMISSION
        #pragma shader_feature _METALLICSPECGLOSSMAP
        #pragma shader_feature _SMOOTHNESS_TEXTURE_ALBEDO_CHANNEL_A
        #pragma shader_feature _OCCLUSIONMAP

        #pragma shader_feature _SPECULARHIGHLIGHTS_OFF
        #pragma shader_feature _ENVIRONMENTREFLECTIONS_OFF
        #pragma shader_feature _SPECULAR_SETUP
        #pragma shader_feature _RECEIVE_SHADOWS_OFF

        // URP のキーワード
        #pragma multi_compile _ _MAIN_LIGHT_SHADOWS
        #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
        #pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
        #pragma multi_compile _ _ADDITIONAL_LIGHT_SHADOWS
        #pragma multi_compile _ _SHADOWS_SOFT
        #pragma multi_compile _ _MIXED_LIGHTING_SUBTRACTIVE

        // Unity のキーワード
        #pragma multi_compile _ DIRLIGHTMAP_COMBINED
        #pragma multi_compile _ LIGHTMAP_ON
        #pragma multi_compile_fog

        #include "./FurLit.hlsl"
        #pragma vertex vert
        #pragma require geometry
        #pragma geometry geom 
        #pragma fragment frag
        ENDHLSL
    }

    Pass
    {
        Name "DepthOnly"
        ...
    }

    Pass
    {
        Name "ShadowCaster"
        ...
    }
}

}

Lit シェーダの設定をそのまま持ってきます。Properties には _Metallic_Smoothness を追加しておきます。シェーダ自体は FurLit.hlsl に書くので次はそちらを見てみましょう。

頂点・ジオメトリ・フラグメントシェーダ

ちょっと長いですが...、全文です:

#ifndef FUR_LIT_HLSL
#define FUR_LIT_HLSL

#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/SurfaceInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"

int _ShellAmount;
float _ShellStep;
float _AlphaCutout;
float _Occlusion;
float4 _BaseMove;
float4 _WindFreq;
float4 _WindMove;

TEXTURE2D(_FurMap); 
SAMPLER(sampler_FurMap);
float4 _FurMap_ST;

struct Attributes
{
    float4 positionOS : POSITION;
    float3 normalOS : NORMAL;
    float4 tangentOS : TANGENT;
    float2 texcoord : TEXCOORD0;
    float2 lightmapUV : TEXCOORD1;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float3 positionWS : TEXCOORD0;
    float3 normalWS : TEXCOORD1;
    float4 uv : TEXCOORD2;
    DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 3);
    float4 fogFactorAndVertexLight : TEXCOORD4; // x: fogFactor, yzw: vertex light
    float  layer : TEXCOORD5;
};

Attributes vert(Attributes input)
{
    return input;
}

void AppendShellVertex(inout TriangleStream<Varyings> stream, Attributes input, int index)
{
    Varyings output = (Varyings)0;

    VertexPositionInputs vertexInput = GetVertexPositionInputs(input.positionOS.xyz);
    VertexNormalInputs normalInput = GetVertexNormalInputs(input.normalOS, input.tangentOS);

    float moveFactor = pow(abs((float)index / _ShellAmount), _BaseMove.w);
    float3 posOS = input.positionOS.xyz;
    float3 windAngle = _Time.w * _WindFreq.xyz;
    float3 windMove = moveFactor * _WindMove.xyz * sin(windAngle + posOS * _WindMove.w);
    float3 move = moveFactor * _BaseMove.xyz;
    float3 shellDir = SafeNormalize(normalInput.normalWS + move + windMove);
    
    output.positionWS = vertexInput.positionWS + shellDir * (_ShellStep * index);
    output.positionCS = TransformWorldToHClip(output.positionWS);
    output.uv = float4(TRANSFORM_TEX(input.texcoord, _BaseMap), TRANSFORM_TEX(input.texcoord, _FurMap));
    output.normalWS = TransformObjectToWorldNormal(input.normalOS);
    output.layer = (float)index / _ShellAmount;

    float3 vertexLight = VertexLighting(vertexInput.positionWS, normalInput.normalWS);
    float fogFactor = ComputeFogFactor(vertexInput.positionCS.z);
    output.fogFactorAndVertexLight = half4(fogFactor, vertexLight);

    OUTPUT_LIGHTMAP_UV(input.lightmapUV, unity_LightmapST, output.lightmapUV);
    OUTPUT_SH(output.normalWS.xyz, output.vertexSH);

    stream.Append(output);
}

[maxvertexcount(42)]
void geom(triangle Attributes input[3], inout TriangleStream<Varyings> stream)
{
    [loop] for (float i = 0; i < _ShellAmount; ++i)
    {
        [unroll] for (float j = 0; j < 3; ++j)
        {
            AppendShellVertex(stream, input[j], i);
        }
        stream.RestartStrip();
    }
}

float3 TransformHClipToWorld(float4 positionCS)
{
    return mul(UNITY_MATRIX_I_VP, positionCS).xyz;
}

float4 frag(Varyings input) : SV_Target
{
    float4 furColor = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.uv.zw);
    float alpha = furColor.r * (1.0 - input.layer);
    if (input.layer > 0.0 && alpha < _AlphaCutout) discard;

    SurfaceData surfaceData = (SurfaceData)0;
    InitializeStandardLitSurfaceData(input.uv.xy, surfaceData);
    surfaceData.occlusion = lerp(1.0 - _Occlusion, 1.0, input.layer);

    InputData inputData = (InputData)0;
    inputData.positionWS = input.positionWS;
    inputData.normalWS = input.normalWS;
    inputData.viewDirectionWS = SafeNormalize(GetCameraPositionWS() - inputData.positionWS);
#if defined(_MAIN_LIGHT_SHADOWS) && !defined(_RECEIVE_SHADOWS_OFF)
    inputData.shadowCoord = TransformWorldToShadowCoord(input.positionWS);
#else
    inputData.shadowCoord = float4(0, 0, 0, 0);
#endif
    inputData.fogCoord = input.fogFactorAndVertexLight.x;
    inputData.vertexLighting = input.fogFactorAndVertexLight.yzw;
    inputData.bakedGI = SAMPLE_GI(input.lightmapUV, input.vertexSH, inputData.normalWS);

    float4 color = UniversalFragmentPBR(inputData, surfaceData);
    color.rgb = MixFog(color.rgb, inputData.fogCoord);

    return color;
}

#endif

ジオメトリシェーダを見てみると、Varyings にライティング用の法線や頂点ライティングといったパラメタが増えています。この関係で先に説明したように maxvertexcount が減るので 1 Pass では 14 層が最大になってしまっています...。より多くシェルの層を行いたい場合は 2 Pass、3 Pass といったようにパスを増やすことを検討しないとなりません。

フラグメントシェーダでは InitializeStandardLitSurfaceData() してプロパティで設定したパラメタを SurfaceData に格納したあと、InputData の各メンバに情報を詰め、UniversalFragmentPBR() でライティングや影の計算が行われます。SurfaceDataocclusion に Unlit のときは直接カラーに掛けてた分を代入しておきます(albedo にも乗算しても良いかもしれません)。

結果

f:id:hecomi:20210626234623p:plain

結果を見てみると...、ちょっと根本が暗すぎます。これは影が過度に落ちてしまっているからです。これを修正するには ShadowCater パスに手を入れます。

ShadowCaster

バイアスの調整

先程 Unlit で見た ShadowCaster でのコードはバイアスを考慮していませんでした。これにより毛を生やさないときはシャドウアクネが生じているのが見えます。

f:id:hecomi:20210626235251p:plain

なのでバイアスを掛けるようコードを修正します。

void AppendShellVertex(inout TriangleStream<Varyings> stream, Attributes input, int index)
{
    ...
    // float4 posCS = TransformWorldToHClip(posWS);

    // バイアスを適用するように修正
    float4 posCS = TransformWorldToHClip(ApplyShadowBias(posWS, normalInput.normalWS, _LightDirection));
#if UNITY_REVERSED_Z
    posCS.z = min(posCS.z, posCS.w * UNITY_NEAR_CLIP_VALUE);
#else
    posCS.z = max(posCS.z, posCS.w * UNITY_NEAR_CLIP_VALUE);
#endif
    ...
}

f:id:hecomi:20210626235346p:plain

過度に影が出なくなりました。

更にバイアスを調整

それでもちょっと影が気になる気もします。影はここでは出ないようにしたいな~という場合には更にこのバイアスの計算に手を入れます。具体的には _ShadowBias としてグローバルに設定されているところにオブジェクト単位の追加値を加算できるようにします。

float _ShadowExtraBias;

inline float3 CustomApplyShadowBias(float3 positionWS, float3 normalWS)
{
    positionWS += _LightDirection * (_ShadowBias.x + _ShadowExtraBias);
    float invNdotL = 1.0 - saturate(dot(_LightDirection, normalWS));
    float scale = invNdotL * _ShadowBias.y;
    positionWS += normalWS * scale.xxx;

    return positionWS;
}

inline float4 GetShadowPositionHClip(float3 positionWS, float3 normalWS)
{
    positionWS = CustomApplyShadowBias(positionWS, normalWS);
    float4 positionCS = TransformWorldToHClip(positionWS);
#if UNITY_REVERSED_Z
    positionCS.z = min(positionCS.z, positionCS.w * UNITY_NEAR_CLIP_VALUE);
#else
    positionCS.z = max(positionCS.z, positionCS.w * UNITY_NEAR_CLIP_VALUE);
#endif
    return positionCS;
}

void AppendShellVertex(inout TriangleStream<Varyings> stream, Attributes input, int index)
{
    ...
    float4 posCS = GetShadowPositionHClip(posWS, normalInput.normalWS);
    ...
}

_ShadowExtraBias にマイナスの値を与えてあげると次のようになります。

f:id:hecomi:20210627000715p:plain

良くなりました!この見た目だと occlusionalbedo に乗算するとより良くなる感じがします。

f:id:hecomi:20210627002246p:plain

法線を考慮する

それでもまだリアリティが足りません。よくよく見ると毛の 1 本 1 本にライティングが施されていません。これは毛のライティングとして用いる法線はもともとのポリゴン(ここではスフィア)の法線が使われてしまっているからです。そこで法線を考慮したライティングをすることにします。

法線マップ

法線情報としては _FurMap に対応した法線テクスチャを外から与えることにします。法線テクスチャは以下のサイトを利用すると簡単に作ることが出来ます:

cpetry.github.io

f:id:hecomi:20210627144400p:plain

シェーダの変更

まず、ShaderLab の Properties ブロックに法線テクスチャ用のスロットを足します:

Properties
{
    ...
    _NormalMap("Normal", 2D) = "bump" {}
    _NormalScale("Normal Scale", Range(0.0, 2.0)) = 1.0
    ...
    _FurScale("Fur Scale", Range(0.0, 10.0)) = 1.0
    ...
}

フラグメントシェーダで法線マップの読み込みを行うために、Varyingstangent を追加します。このため、ジオメトリシェーダの出力数が更に減ってしまうのと、ファー用のノイズテクスチャと法線マップで同じ UV のスケールを使いたいので、UV で float4 使っていたところを float2 になるようにするため、_FurScale を追加します。そして Varyings は以下のようにします。

struct Varyings
{
    float4 positionCS : SV_POSITION;
    float3 positionWS : TEXCOORD0;
    float3 normalWS : TEXCOORD1;
    float3 tangentWS : TEXCOORD2;
    float2 uv : TEXCOORD4;
    DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 5); // float3
    float4 fogFactorAndVertexLight : TEXCOORD6; // x: fogFactor, yzw: vertex light
    float  layer : TEXCOORD7;
};

これで構造体のサイズとしては 23 なので、maxvertexcount は 1024 / 23 ~ 44 で、最大の層数は 44 / 3 ~ 14 層です。10 層以上あるので数は十分確保できそうですね。

そしてジオメトリシェーダの中で tangentWS を渡すように修正します。

void AppendShellVertex(inout TriangleStream<Varyings> stream, Attributes input, int index)
{
    ...
    output.tangentWS = normalInput.tangentWS;
    ...
}

フラグメントシェーダの中でこれを使って法線マップから法線情報を取り出します。

float _FurScale;

TEXTURE2D(_NormalMap); 
SAMPLER(sampler_NormalMap);
float4 _NormalMap_ST;
float _NormalScale;

float4 frag(Varyings input) : SV_Target
{
    float2 furUv = input.uv / _BaseMap_ST.xy * _FurScale;
    float4 furColor = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, furUv);
    float alpha = furColor.r * (1.0 - input.layer);
    if (input.layer > 0.0 && alpha < _AlphaCutout) discard;

    float3 normalTS = UnpackNormalScale(
        SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, furUv), 
        _NormalScale);
    float sgn = input.tangentWS.w; // viewDirWS.y
    float3 bitangent = sgn * cross(input.normalWS.xyz, input.tangentWS.xyz);
    float3 normalWS = TransformTangentToWorld(
        normalTS, 
        float3x3(input.tangentWS.xyz, bitangent.xyz, input.normalWS.xyz));

    SurfaceData surfaceData = (SurfaceData)0;
    ...
    inputData.normalWS = normalWS;
    inputData.viewDirectionWS = viewDirWS;
    ...
}

結果

これで次のようになります。とてもライティングに馴染んだ見た目になりました!

f:id:hecomi:20210627024006g:plain

床もモフモフにしてみました。奥の Unlit なファーと比べると大分印象が違いますね。

f:id:hecomi:20210627152234g:plain

リムライトの追加

実際の毛は細く透ける感じがします。特に逆光時の縁は光が透けて見える感じがしますよね。次はそういったリムライト成分を追加してみたいと思います。

ShaderLab

Properties
{
    ...
    _RimLightPower("Rim Light Power", Range(0.0, 20.0)) = 6.0
    _RimLightIntensity("Rim Light Intensity", Range(0.0, 1.0)) = 0.5
    ...
}

リムライトの適用度合いは視線方向と法線方向やライト方向の内積で決めますが、この際どれくらいの範囲に適用するかコントロールするために pow() の係数として _RimLightPower を用意しておきます。

シェーダ

以下のように ApplyRimLight() を PBR の計算の後ろに入れます。URP の真骨頂である GetMainLight()GetAdditionalPerObjectLight() によって様々なライトの情報(色、向き、減衰具合、影)が簡単に取得できるので、これによって影や距離も考慮したリムライトが可能になります。

void ApplyRimLight(inout float3 color, float3 posWS, float3 viewDirWS, float3 normalWS)
{
    float viewDotNormal = abs(dot(viewDirWS, normalWS));
    float normalFactor = pow(abs(1.0 - viewDotNormal), _RimLightPower);

    Light light = GetMainLight();
    float lightDirDotView = dot(light.direction, viewDirWS);
    float intensity = pow(max(-lightDirDotView, 0.0), _RimLightPower);
    intensity *= _RimLightIntensity * normalFactor;
#ifdef _MAIN_LIGHT_SHADOWS
    float4 shadowCoord = TransformWorldToShadowCoord(posWS);
    intensity *= MainLightRealtimeShadow(shadowCoord);
#endif 
    color += intensity * light.color;

#ifdef _ADDITIONAL_LIGHTS
    int additionalLightsCount = GetAdditionalLightsCount();
    for (int i = 0; i < additionalLightsCount; ++i)
    {
        int index = GetPerObjectLightIndex(i);
        Light light = GetAdditionalPerObjectLight(index, posWS);
        float lightDirDotView = dot(light.direction, viewDirWS);
        float intensity = max(-lightDirDotView, 0.0);
        intensity *= _RimLightIntensity * normalFactor;
        intensity *= light.distanceAttenuation;
#ifdef _MAIN_LIGHT_SHADOWS
        intensity *= AdditionalLightRealtimeShadow(index, posWS);
#endif 
        color += intensity * light.color;
    }
#endif
}

float4 frag(Varyings input) : SV_Target
{
    ...
    float4 color = UniversalFragmentPBR(inputData, surfaceData);
    ApplyRimLight(color.rgb, input.positionWS, viewDirWS, input.normalWS);
    ...
    return color;
}

結果

f:id:hecomi:20210627170027g:plain

ディレクショナルライトだけでなくスポットライトなど追加のライトも含めて、いい感じにリムライトが適用されました!

おまけ

Metallic と Smoothness の変更

f:id:hecomi:20210627173725g:plain

ヒョウ柄

f:id:hecomi:20210627181110g:plain

commons.wikimedia.org

おわりに

久しぶりにシェーダで遊びましたがとても面白かったです。シェル法は実装がシンプルな割に見た目が大きく変わって楽しいですね。また、URP のライティング周りはとても書きやすくて好きです。

続き

フィン法も書きました。

tips.hecomi.com

参考

www.catalinzima.com

qiita.com