凹みTips

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

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

はじめに

前回の記事ではポリゴンを何層も重ねて毛を表現するシェル法URP 向けに実装するのを試しました。

tips.hecomi.com

今回はフィン法を試してみようと思います。色々実験しながら書くのでちょっと煩雑になるかもしれませんがご了承ください。

デモ

スクリーンショット

f:id:hecomi:20210724004425g:plain

シェル法との比較

f:id:hecomi:20210723184822g:plain

Youtube

コード

前回と同じプロジェクトに追加しました。

github.com

フィン法

フィン法はポリゴンの表面に垂直方向に立つポリゴンをたくさん追加し、ここに毛のテクスチャを貼り付けることで毛っぽさを表現する手法です。フィン = (魚とかの)ひれ をたくさん植えていくイメージですね。

news.mynavi.jp

シェル法の弱点は側面でシェルの層が見えてしまう点でしたが、逆にフィン法では側面はきれいに見えるものの垂直方向から見たときに板ポリが見えてしまいます。そこで、シェル法で垂直方向の毛を、フィン法で側面の毛を生やすハイブリッドな方法も取られるようです。

本記事では基本的にはフィン法のみでもいい感じに見えるように色々やっていきたいと思います。

フィンを植えてみる

今回も前回同様、ジオメトリシェーダを使って元のポリゴンには手を入れずに動的に毛を生やす手法を検討します。ジオメトリシェーダを使ったフィンの植え方は色々ある(line ですべての辺に生やす、 lineadj で 2 つの面の法線とカメラ方向を比較してエッジを検出など)ようですが、とりあえず元のポリゴンも描きたいので triangle を使って元のポリゴンの頂点も出力しつつ適当に生やしてみましょう。

ShaderLab

Shader "Fur/Fin/Unlit"
{

Properties
{
    [Header(Basic)][Space]
    [Toggle(DRAW_ORIG_POLYGON)]_DrawOrigPolygon("Draw Original Polygon", Float) = 0
    [MainColor] _BaseColor("Color", Color) = (0.5, 0.5, 0.5, 1)
    _BaseMap("Base Map", 2D) = "white" {}

    [Header(Fur)][Space]
    _FurMap("Fur Map", 2D) = "white" {}
    _AlphaCutout("Alpha Cutout", Range(0.0, 1.0)) = 0.2
    _FinLength("Fin Length", Range(0.0, 1.0)) = 0.1
    _FaceViewProdThresh("Fin Direction Threshold", Range(0.0, 1.0)) = 0.1
    _Occlusion("Occlusion", Range(0.0, 1.0)) = 0.3
}

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

    ZWrite On
    Cull Off

    Pass
    {
        Name "Unlit"

        HLSLPROGRAM
        #pragma exclude_renderers gles gles3 glcore
        #pragma multi_compile_fog
        #pragma multi_compile _ DRAW_ORIG_POLYGON
        #pragma vertex vert
        #pragma require geometry
        #pragma geometry geom 
        #pragma fragment frag
        #include "./FurFinUnlit.hlsl"
        ENDHLSL
    }
}

}

フィンの長さやテクスチャに加え、元のポリゴンを描画するかどうかフラグ(DRAW_ORIG_POLYGON)や、カメラ垂直向きの場合に生成をスキップするかのしきい値_FaceViewProdThresh)などもパラメタにしておきます。

HLSL

#ifndef FUR_FIN_UNLIT_HLSL
#define FUR_FIN_UNLIT_HLSL

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

float _FaceViewProdThresh;
float _FinLength;
float _AlphaCutout;
float _Occlusion;

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

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

struct Varyings
{
    float4 vertex : SV_POSITION;
    float2 uv : TEXCOORD0;
    float fogCoord : TEXCOORD1;
    float3 finUv : TEXCOORD2;
};

Attributes vert(Attributes input)
{
    return input;
}

inline float3 GetViewDirectionOS(float3 posOS)
{
    float3 cameraOS = TransformWorldToObject(GetCameraPositionWS());
    return normalize(posOS - cameraOS);
}

void AppendFinVertex(inout TriangleStream<Varyings> stream, in Attributes input, float3 posOS, float3 normalOS, float2 finUv)
{
    Varyings output;

    posOS += normalOS * (finUv.y * _FinLength);
    output.vertex = TransformObjectToHClip(posOS);
    output.uv = TRANSFORM_TEX(input.uv, _BaseMap);
    output.fogCoord = ComputeFogFactor(output.vertex.z);
    output.finUv = finUv;

    stream.Append(output);
}

[maxvertexcount(7)]
void geom(triangle Attributes input[3], inout TriangleStream<Varyings> stream)
{
#ifdef DRAW_ORIG_POLYGON
    for (int i = 0; i < 3; ++i)
    {
        Varyings output;
        output.vertex = TransformObjectToHClip(input[i].positionOS.xyz);
        output.uv = TRANSFORM_TEX(input[i].uv, _BaseMap);
        output.fogCoord = ComputeFogFactor(output.vertex.z);
        output.finUv = float2(-1.0, -1.0);
        stream.Append(output);
    }
    stream.RestartStrip();
#endif

    float3 posOS0 = input[0].positionOS.xyz;
    float3 posOS1 = input[1].positionOS.xyz;
    float3 posOS2 = input[2].positionOS.xyz;

    float3 lineOS01 = posOS1 - posOS0;
    float3 lineOS02 = posOS2 - posOS0;

    float3 normalOS = normalize(cross(lineOS01, lineOS02));
    float3 centerOS = (posOS0 + posOS1 + posOS2) / 3;

    float3 viewDirOS = GetViewDirectionOS(centerOS);
    float eyeDotN = dot(viewDirOS, normalOS);
    if (abs(eyeDotN) > _FaceViewProdThresh) return;

    float3 lineCenterOS = (lineOS01 + lineOS02) / 2;
    float3 posOS3 = posOS0 + lineCenterOS;

    AppendFinVertex(stream, input[0], posOS0, normalOS, float2(0.0, 0.0));
    AppendFinVertex(stream, input[1], posOS3, normalOS, float2(1.0, 0.0));
    AppendFinVertex(stream, input[0], posOS0, normalOS, float2(0.0, 1.0));
    AppendFinVertex(stream, input[1], posOS3, normalOS, float2(1.0, 1.0));
    stream.RestartStrip();
}

float4 frag(Varyings input) : SV_Target
{
    float4 furColor = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.finUv);
    if (input.finUv.z == 0 && furColor.a < _AlphaCutout) discard;

    float4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv);
    color *= _BaseColor;
    color *= furColor;
    color.rgb *= lerp(1.0 - _Occlusion, 1.0, input.finUv.y);
    color.rgb = MixFog(color.rgb, input.fogCoord);
    return color;
}

#endif

次の図のように三角ポリゴンの中間に線を引き、これを面法線方向に押し出した形をフィンとしてみます。

f:id:hecomi:20210718155532p:plain

また、毛のテクスチャとしてはとりあえず雑に以下のような絵を描いてみました。ミップマップやサンプリングを考慮して白黒にせずアルファチャンネルのみ入れておくと良いです。

f:id:hecomi:20210718190619p:plain

結果

f:id:hecomi:20210718161837g:plain

エッジの方のみにしたときはパラパラとしきい値付近で出たり出なかったりがちょっと目立ちますね。もう少し丁寧にトランジションを書いてあげれば軽減はできると思います。

全ポリゴンで出した場合はやはり垂直方向が気になります。これは毛を生やす法線方向を示すテクスチャを作成し、その方向にフィンを向けるような対応をすることである程度軽減できそうです。ファー周りの UI でよくある毛を梳かすようなツールを作成してインタラクティブにこのテクスチャを生成できるツールを作ると楽しそうですね。

シェル法と合わせるとこんな感じです(本当はマルチパスを書いたほうがいいですが、ここでは雑に複数マテリアルを設定してテストしています)。

f:id:hecomi:20210718162025p:plain

f:id:hecomi:20210718162410p:plain

問題点

このままではポリゴンの形状に左右されすぎます。試しにキューブやカプセルに適用するとこんな感じになってしまいます。

f:id:hecomi:20210718163216p:plain

フィンを動的に植える方式ではどうしてもポリゴン数が鍵になるのでポリゴンが足りない場合にはポリゴンを増やすか、テッセレーションを使ったりして増やしてあげる必要があります。また、ファー用の UV が現状それぞれのフィンで (0.0, 0.0) ~ (1.0, 1.0) になってしまっているのも問題(テクスチャが間延びしている)ですが、こちらも計算である程度直せます。

問題点の修正

UV 座標の均一化

テクスチャがある程度均一に貼られている(グリッドテクスチャを貼ると各グリッドが同じくらいの面積になっている)という前提のもとで、UV 空間の距離を見てあげるとファーの密度をだいたい揃えることが出来ます。ジオメトリシェーダでフィン用の UV を与えていたところを次のように改変します。また密度操作用のパラメタ _Density も追加しておきます。

float _Density;

void geom(...)
{
    ...

    float2 uv1 = (TRANSFORM_TEX(input[1].uv, _BaseMap) + TRANSFORM_TEX(input[2].uv, _BaseMap)) / 2;
    float uvLen = length(uv0 - uv1);
    float uvOffset = length(uv0);
    float uvXScale = uvLen * _Density;

    AppendFinVertex(stream, input[0], posOS0, normalOS, float2(uvOffset, 0.0));
    AppendFinVertex(stream, input[1], posOS3, normalOS, float2(uvOffset + uvXScale, 0.0));
    AppendFinVertex(stream, input[0], posOS0, normalOS, float2(uvOffset, 1.0));
    AppendFinVertex(stream, input[1], posOS3, normalOS, float2(uvOffset + uvXScale, 1.0));
}

f:id:hecomi:20210718175707p:plain

カプセルを見てみるとシリンダ部分と半球部分の長さが均一になりました。

フィンのはやし方

テッセレーションを試す前にフィンの生やし方が均一でないのが気になるので対称になるようポリゴンあたり 3 枚にするのを試してみます。シェル法とのハイブリッド方式の場合は、この資料 にあるように側面検出をするべきかもしれませんが...、面白そうなので試してみましょう。ジオメトリシェーダを整理して次のようにしてみます。

void AppendFinVertex(inout TriangleStream<Varyings> stream, float2 uv, float3 posOS, float3 normalOS, float2 finUv)
{
    Varyings output;

    posOS += normalOS * (finUv.y * _FinLength);
    output.vertex = TransformObjectToHClip(posOS);
    output.uv = uv;
    output.fogCoord = ComputeFogFactor(output.vertex.z);
    output.finUv = finUv;

    stream.Append(output);
}

void AppendFinVertices(
    inout TriangleStream<Varyings> stream,
    Attributes input0,
    Attributes input1,
    Attributes input2,
    float3 normalOS)
{
    float3 posOS0 = input0.positionOS.xyz;
    float3 lineOS01 = input1.positionOS.xyz - posOS0;
    float3 lineOS02 = input2.positionOS.xyz - posOS0;
    float3 posOS3 = posOS0 + (lineOS01 + lineOS02) / 2;

    float2 uv0 = TRANSFORM_TEX(input0.uv, _BaseMap);
    float2 uv12 = (TRANSFORM_TEX(input1.uv, _BaseMap) + TRANSFORM_TEX(input2.uv, _BaseMap)) / 2;
    float uvOffset = length(uv0);
    float uvXScale = length(uv0 - uv12) * _Density;

    AppendFinVertex(stream, uv0,  posOS0, normalOS, float2(uvOffset, 0.0));
    AppendFinVertex(stream, uv12, posOS3, normalOS, float2(uvOffset + uvXScale, 0.0));
    AppendFinVertex(stream, uv0,  posOS0, normalOS, float2(uvOffset, 1.0));
    AppendFinVertex(stream, uv12, posOS3, normalOS, float2(uvOffset + uvXScale, 1.0));
    stream.RestartStrip();
}

[maxvertexcount(15)]
void geom(triangle Attributes input[3], inout TriangleStream<Varyings> stream)
{
#ifdef DRAW_ORIG_POLYGON
    for (int i = 0; i < 3; ++i)
    {
        Varyings output;
        output.vertex = TransformObjectToHClip(input[i].positionOS.xyz);
        output.uv = TRANSFORM_TEX(input[i].uv, _BaseMap);
        output.fogCoord = ComputeFogFactor(output.vertex.z);
        output.finUv = float2(-1.0, -1.0);
        stream.Append(output);
    }
    stream.RestartStrip();
#endif

    float3 lineOS01 = (input[1].positionOS - input[0].positionOS).xyz;
    float3 lineOS02 = (input[2].positionOS - input[0].positionOS).xyz;
    float3 normalOS = normalize(cross(lineOS01, lineOS02));
    float3 centerOS = (input[0].positionOS + input[1].positionOS + input[2].positionOS).xyz / 3;
    float3 viewDirOS = GetViewDirectionOS(centerOS);
    float eyeDotN = dot(viewDirOS, normalOS);
    if (abs(eyeDotN) > _FaceViewProdThresh) return;

    AppendFinVertices(stream, input[0], input[1], input[2], normalOS);
    AppendFinVertices(stream, input[2], input[0], input[1], normalOS);
    AppendFinVertices(stream, input[1], input[2], input[0], normalOS);
}

f:id:hecomi:20210718180631p:plain

もじゃもじゃが出来ました!

テッセレーション

密度をあげる

以下の記事を参考に、PN Triangles を使ったテッセレーションを入れてみます。距離に応じてテッセレーションファクターを変え、遠いときはテッセレーションが効かず、近づくにつれだんだんと分割が細かくなるようにします。

tips.hecomi.com

tips.hecomi.com

なお、テッセレーションの各ステージはジオメトリシェーダと頂点シェーダの間に挿入されます。

ShaderLab
Shader "Fur/Fin/Unlit"
{

Properties
{
    ...
    [Header(Tesselation)][Space]
    _TessMinDist("Tesselation Min Distance", Range(0.1, 50)) = 1.0
    _TessMaxDist("Tesselation Max Distance", Range(0.1, 50)) = 10.0
    _TessFactor("Tessellation Factor", Range(1, 50)) = 10
}

SubShader
{
   ...
    Pass
    {
        Name "Unlit"

        HLSLPROGRAM
        ...
        #pragma require tessellation tessHW
        #pragma hull hull
        #pragma domain domain
        ...
        ENDHLSL
    }
}

}
HLSL
float _TessMinDist;
float _TessMaxDist;
float _TessFactor;

struct HsConstantOutput
{
    float fTessFactor[3]    : SV_TessFactor;
    float fInsideTessFactor : SV_InsideTessFactor;
    float3 f3B210 : POS3;
    float3 f3B120 : POS4;
    float3 f3B021 : POS5;
    float3 f3B012 : POS6;
    float3 f3B102 : POS7;
    float3 f3B201 : POS8;
    float3 f3B111 : CENTER;
    float3 f3N110 : NORMAL3;
    float3 f3N011 : NORMAL4;
    float3 f3N101 : NORMAL5;
};

[domain("tri")]
[partitioning("integer")]
[outputtopology("triangle_cw")]
[patchconstantfunc("hullConst")]
[outputcontrolpoints(3)]
Attributes hull(InputPatch<Attributes, 3> input, uint id : SV_OutputControlPointID)
{
    return input[id];
}

HsConstantOutput hullConst(InputPatch<Attributes, 3> i)
{
    HsConstantOutput o = (HsConstantOutput)0;

    float distance = length(float3(UNITY_MATRIX_MV[0][3], UNITY_MATRIX_MV[1][3], UNITY_MATRIX_MV[2][3]));
    float factor = (_TessMaxDist - _TessMinDist) / max(distance - _TessMinDist, 0.01);
    factor = min(factor, 1.0);
    factor *= _TessFactor;

    o.fTessFactor[0] = o.fTessFactor[1] = o.fTessFactor[2] = factor;
    o.fInsideTessFactor = factor;

    float3 f3B003 = i[0].positionOS.xyz;
    float3 f3B030 = i[1].positionOS.xyz;
    float3 f3B300 = i[2].positionOS.xyz;

    float3 f3N002 = i[0].normalOS;
    float3 f3N020 = i[1].normalOS;
    float3 f3N200 = i[2].normalOS;
        
    o.f3B210 = ((2.0 * f3B003) + f3B030 - (dot((f3B030 - f3B003), f3N002) * f3N002)) / 3.0;
    o.f3B120 = ((2.0 * f3B030) + f3B003 - (dot((f3B003 - f3B030), f3N020) * f3N020)) / 3.0;
    o.f3B021 = ((2.0 * f3B030) + f3B300 - (dot((f3B300 - f3B030), f3N020) * f3N020)) / 3.0;
    o.f3B012 = ((2.0 * f3B300) + f3B030 - (dot((f3B030 - f3B300), f3N200) * f3N200)) / 3.0;
    o.f3B102 = ((2.0 * f3B300) + f3B003 - (dot((f3B003 - f3B300), f3N200) * f3N200)) / 3.0;
    o.f3B201 = ((2.0 * f3B003) + f3B300 - (dot((f3B300 - f3B003), f3N002) * f3N002)) / 3.0;

    float3 f3E = (o.f3B210 + o.f3B120 + o.f3B021 + o.f3B012 + o.f3B102 + o.f3B201) / 6.0;
    float3 f3V = (f3B003 + f3B030 + f3B300) / 3.0;
    o.f3B111 = f3E + ((f3E - f3V) / 2.0);
    
    float fV12 = 2.0 * dot(f3B030 - f3B003, f3N002 + f3N020) / dot(f3B030 - f3B003, f3B030 - f3B003);
    float fV23 = 2.0 * dot(f3B300 - f3B030, f3N020 + f3N200) / dot(f3B300 - f3B030, f3B300 - f3B030);
    float fV31 = 2.0 * dot(f3B003 - f3B300, f3N200 + f3N002) / dot(f3B003 - f3B300, f3B003 - f3B300);
    o.f3N110 = normalize(f3N002 + f3N020 - fV12 * (f3B030 - f3B003));
    o.f3N011 = normalize(f3N020 + f3N200 - fV23 * (f3B300 - f3B030));
    o.f3N101 = normalize(f3N200 + f3N002 - fV31 * (f3B003 - f3B300));
           
    return o;
}

[domain("tri")]
Attributes domain(
    HsConstantOutput hsConst, 
    const OutputPatch<Attributes, 3> i,
    float3 bary : SV_DomainLocation)
{
    Attributes o = (Attributes)0;

    float fU = bary.x;
    float fV = bary.y;
    float fW = bary.z;
    float fUU = fU * fU;
    float fVV = fV * fV;
    float fWW = fW * fW;
    float fUU3 = fUU * 3.0f;
    float fVV3 = fVV * 3.0f;
    float fWW3 = fWW * 3.0f;
    
    o.positionOS.xyz = float4(
        i[0].positionOS.xyz * fWW * fW +
        i[1].positionOS.xyz * fUU * fU +
        i[2].positionOS.xyz * fVV * fV +
        hsConst.f3B210 * fWW3 * fU +
        hsConst.f3B120 * fW * fUU3 +
        hsConst.f3B201 * fWW3 * fV +
        hsConst.f3B021 * fUU3 * fV +
        hsConst.f3B102 * fW * fVV3 +
        hsConst.f3B012 * fU * fVV3 +
        hsConst.f3B111 * 6.0f * fW * fU * fV, 
        1.0);
    o.normalOS = normalize(
        i[0].normalOS * fWW +
        i[1].normalOS * fUU +
        i[2].normalOS * fVV +
        hsConst.f3N110 * fW * fU +
        hsConst.f3N011 * fU * fV +
        hsConst.f3N101 * fW * fV);
    o.uv = 
        i[0].uv * fW + 
        i[1].uv * fU + 
        i[2].uv * fV;

    return o;
}

f:id:hecomi:20210722152252g:plain

密になりました!

向きのランダム感

ただどうしてもカメラ垂直方向のテッセレーション感が気になってしまうので少し方向のランダムネスを入れてみます。

float _RandomDirection;

inline float rand(float2 seed)
{
    return frac(sin(dot(seed.xy, float2(12.9898, 78.233))) * 43758.5453);
}

inline float3 rand3(float2 seed)
{
    return 2.0 * (float3(rand(seed * 1), rand(seed * 2), rand(seed * 3)) - 0.5);
}

void geom(...)
{
    ...
    normalOS += rand3(input[0].uv) * _RandomDirection;
    normalOS = normalize(normalOS);
    ...
}

f:id:hecomi:20210722160256g:plain

毛束感がありすぎる気もしますが、テッセレーションファクタで幾分か調整可能です。ノイズでランダムに散らしましたが、ここがテクスチャで制御できるようになるともう少し汎用性がありそうですね。

続・フィンの生やし方

先程、密度を上げるために一枚のポリゴンから 3 本フィンを生やすようにしましたが、テッセレーションによって増やすのであれば 1 枚のフィンのほうが見た目が良くなる気がしました…

Shader "Fur/Fin/Unlit"
{

Properties
{
    ...
    [Toggle(APPEND_MORE_FINS)]_AppendMoreFins("Append More Fins", Float) = 1
    ...
}

}

void geom(...)
{
    ...
    AppendFinVertices(stream, input[0], input[1], input[2], normalOS);
#ifdef APPEND_MORE_FINS
    AppendFinVertices(stream, input[2], input[0], input[1], normalOS);
    AppendFinVertices(stream, input[1], input[2], input[0], normalOS);
#endif
}

f:id:hecomi:20210722170041g:plain

毛を動かす

前回同様毛を風や重力で動かしてみましょう。現状作成しているフィンのポリゴンは四角であるため、しなりません。中間にポリゴンを追加し、しなるようにしてみます。ジオメトリは次にようにジグザグに追加していくイメージです。

f:id:hecomi:20210722181839p:plain

風の計算は前回とほとんど同じですが、調整しやすいようにワールドスペースで与えられるよう改良しています。

void AppendFinVertex(
    inout TriangleStream<Varyings> stream, 
    float2 uv, 
    float3 posOS, 
    float2 finUv)
{
    Varyings output;

    output.vertex = TransformObjectToHClip(posOS);
    output.uv = uv;
    output.fogCoord = ComputeFogFactor(output.vertex.z);
    output.finUv = finUv;

    stream.Append(output);
}

void AppendFinVertices(
    inout TriangleStream<Varyings> stream,
    Attributes input0,
    Attributes input1,
    Attributes input2,
    float3 normalOS)
{
    float3 posOS0 = input0.positionOS.xyz;
    float3 lineOS01 = input1.positionOS.xyz - posOS0;
    float3 lineOS02 = input2.positionOS.xyz - posOS0;
    float3 posOS3 = posOS0 + (lineOS01 + lineOS02) / 2;

    float2 uv0 = TRANSFORM_TEX(input0.uv, _BaseMap);
    float2 uv12 = (TRANSFORM_TEX(input1.uv, _BaseMap) + TRANSFORM_TEX(input2.uv, _BaseMap)) / 2;
    float uvOffset = length(uv0);
    float uvXScale = length(uv0 - uv12) * _Density;

    AppendFinVertex(stream, uv0, posOS0, float2(uvOffset, 0.0));
    AppendFinVertex(stream, uv12, posOS3, float2(uvOffset + uvXScale, 0.0));

    float3 normalWS = TransformObjectToWorldNormal(normalOS);
    float3 posWS = TransformObjectToWorld(posOS0);
    float finStep = _FinLength / _FinJointNum;
    float3 windAngle = _Time.w * _WindFreq.xyz;
    float3 windMoveWS = _WindMove.xyz * sin(windAngle + posWS * _WindMove.w);
    float3 baseMoveWS = _BaseMove.xyz;

    [loop] for (int i = 1; i <= _FinJointNum; ++i)
    {
        float finFactor = (float)i / _FinJointNum;
        float moveFactor = pow(abs(finFactor), _BaseMove.w);
        float3 moveWS = SafeNormalize(normalWS + (baseMoveWS + windMoveWS) * moveFactor) * finStep;
        float3 moveOS = TransformWorldToObjectDir(moveWS, false);
        posOS0 += moveOS;
        posOS3 += moveOS;
        AppendFinVertex(stream, uv0, posOS0, float2(uvOffset, finFactor));
        AppendFinVertex(stream, uv12, posOS3, float2(uvOffset + uvXScale, finFactor));
    }
    stream.RestartStrip();
}

// 頂点の最大数は増やしておく
[maxvertexcount(75)]
void geom(...)
{
...

f:id:hecomi:20210722183229g:plain

毛が動くようになると随分と毛感が増しますね!また、シェル法と違って毛を伸ばしても破綻しないのが面白いです。

f:id:hecomi:20210722184336g:plain

ライティング

次にライティングを処理した Lit シェーダを試してみます。基本的には前回と同じで、ShaderLab 側でいくつかライティング用のパラメタおよびキーワードを追加し、また HLSL 側でもライティングの必要なパラメタを Attributes および Varyings に追加していきます。全文を載せると長いのでそちらは実際に GitHub の方でコードを見てもらうとして、ここでは変更点のみ見ていきます。

ShaderLab

ライティング用のパラメタを幾つか追加します。また URP やライティング用のキーワードも追加しておきます。また、ShadowCaster や Depth のパスも追加しておきます。

Shader "Fur/Fin/Lit"
{

Properties
{
    ...
    _AmbientColor("AmbientColor", Color) = (0.0, 0.0, 0.0, 1)
    [Gamma] _Metallic("Metallic", Range(0.0, 1.0)) = 0.5
    _Smoothness("Smoothness", Range(0.0, 1.0)) = 0.5
    _ShadowExtraBias("Shadow Extra Bias", Range(-1.0, 1.0)) = 0.0
    ...
}

SubShader
{
    Tags 
    { 
        "RenderType" = "Opaque" 
        "RenderPipeline" = "UniversalPipeline" 
        "UniversalMaterialType" = "Lit"
        "IgnoreProjector" = "True"
    }
    ...
    Pass
    {
        Name "ForwardLit"
        Tags { "LightMode" = "UniversalForward" }

        HLSLPROGRAM
        // 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

        #pragma exclude_renderers gles gles3 glcore
        #pragma multi_compile _ DRAW_ORIG_POLYGON
        #pragma vertex vert
        #pragma require tessellation tessHW
        #pragma hull hull
        #pragma domain domain
        #pragma require geometry
        #pragma geometry geom 
        #pragma fragment frag
        #include "./FurFinLit.hlsl"
        ENDHLSL
    }

    Pass
    {
        Name "DepthOnly"
        Tags { "LightMode" = "DepthOnly" }
        ...
        #include "./FurFinShadow.hlsl"
        #include "./FurFinUnlitTessellation.hlsl"
        ENDHLSL
    }

    Pass
    {
        Name "ShadowCaster"
        Tags {"LightMode" = "ShadowCaster" }
        ...
        #define SHADOW_CASTER_PASS
        #include "./FurFinShadow.hlsl"
        #include "./FurFinUnlitTessellation.hlsl"
        ENDHLSL
    }
}

}

HLSL

長いですが、パラメタが増えた分、テッセレーションステージで必要なパラメタの受け渡しや、URP のお作法に則ったライティング処理などを書いていきます。あと、受け渡しの情報量(Varyings のサイズ)が増えたので生成するジオメトリを減らすために一つのポリゴンから生やすフィンは 1 つに固定しています。

この時点で注意するのは、現段階では法線は毛それぞれのものではなく、元となるポリゴンの法線をそのまま流用してライティングをしているという点です。ポリゴン両面から見えるように Cull Off しているためフィンの法線をうまく計算しようとすると表裏両面を描く必要が出てきます。これに関しては次の章で試します。

#ifndef FUR_FIN_LIT_HLSL
#define FUR_FIN_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"

...

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

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

Attributes vert(Attributes input)
{
    return input;
}

...
Attributes hull(...)
{
    return input[id];
}

HsConstantOutput hullConst(...)
{
    ...
    HsConstantOutput o = (HsConstantOutput)0;
}

...
Attributes domain(...)
{
    ...
    o.tangentOS = float4(normalize(
        i[0].tangentOS.xyz * fWW +
        i[1].tangentOS.xyz * fUU +
        i[2].tangentOS.xyz * fVV +
        hsConst.f3N110 * fW * fU +
        hsConst.f3N011 * fU * fV +
        hsConst.f3N101 * fW * fV), 
        i[0].tangentOS.w);
    o.texcoord = 
        i[0].texcoord * fW + 
        i[1].texcoord * fU + 
        i[2].texcoord * fV;
    o.lightmapUV = 
        i[0].lightmapUV * fW + 
        i[1].lightmapUV * fU + 
        i[2].lightmapUV * fV;
    return o;
}

void AppendFinVertex(
    inout TriangleStream<Varyings> stream, 
    float2 uv, 
    float3 posOS, 
    float3 normalOS, 
    float4 tangentOS, 
    float2 finUv)
{
    Varyings output = (Varyings)0;

    VertexPositionInputs vertexInput = GetVertexPositionInputs(posOS);
    VertexNormalInputs normalInput = GetVertexNormalInputs(normalOS, tangentOS);
    output.positionCS = vertexInput.positionCS;
    output.positionWS = vertexInput.positionWS;
    output.normalWS = normalInput.normalWS;
    output.uv = uv;
    output.finUv = finUv;

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

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

    stream.Append(output);
}

void AppendFinVertices(
    inout TriangleStream<Varyings> stream,
    Attributes input0,
    Attributes input1,
    Attributes input2)
{
    float3 normalOS0 = input0.normalOS;
    float4 tangentOS0 = input0.tangentOS;

    float3 posOS0 = input0.positionOS.xyz;
    float3 lineOS01 = input1.positionOS.xyz - posOS0;
    float3 lineOS02 = input2.positionOS.xyz - posOS0;
    float3 posOS3 = posOS0 + (lineOS01 + lineOS02) / 2;

    float2 uv0 = TRANSFORM_TEX(input0.texcoord, _BaseMap);
    float2 uv12 = (TRANSFORM_TEX(input1.texcoord, _BaseMap) + TRANSFORM_TEX(input2.texcoord, _BaseMap)) / 2;
    float uvOffset = length(uv0);
    float uvXScale = length(uv0 - uv12) * _Density;

    AppendFinVertex(stream, uv0, posOS0, normalOS0, tangentOS0, float2(uvOffset, 0.0));
    AppendFinVertex(stream, uv12, posOS3, normalOS0, tangentOS0, float2(uvOffset + uvXScale, 0.0));

    float3 dir = normalOS0;
    dir += rand3(input0.texcoord) * _RandomDirection;
    dir = normalize(dir);
    float3 dirWS = TransformObjectToWorldNormal(dir);
    float3 posWS = TransformObjectToWorld(posOS0);
    float finStep = _FinLength / _FinJointNum;
    float3 windAngle = _Time.w * _WindFreq.xyz;
    float3 windMoveWS = _WindMove.xyz * sin(windAngle + posWS * _WindMove.w);
    float3 baseMoveWS = _BaseMove.xyz;

    [loop] for (int i = 1; i <= _FinJointNum; ++i)
    {
        float finFactor = (float)i / _FinJointNum;
        float moveFactor = pow(abs(finFactor), _BaseMove.w);
        float3 moveWS = SafeNormalize(dirWS + (baseMoveWS + windMoveWS) * moveFactor) * finStep;
        float3 moveOS = TransformWorldToObjectDir(moveWS, false);
        posOS0 += moveOS;
        posOS3 += moveOS;
        AppendFinVertex(stream, uv0, posOS0, normalOS0, tangentOS0, float2(uvOffset, finFactor));
        AppendFinVertex(stream, uv12, posOS3, normalOS0, tangentOS0, float2(uvOffset + uvXScale, finFactor));
    }
    stream.RestartStrip();
}

[maxvertexcount(23)]
void geom(triangle Attributes input[3], inout TriangleStream<Varyings> stream)
{
#ifdef DRAW_ORIG_POLYGON
    for (int i = 0; i < 3; ++i)
    {
        Varyings output = (Varyings)0;

        VertexPositionInputs vertexInput = GetVertexPositionInputs(input[i].positionOS.xyz);
        VertexNormalInputs normalInput = GetVertexNormalInputs(input[i].normalOS, input[i].tangentOS);
        output.positionCS = vertexInput.positionCS;
        output.positionWS = vertexInput.positionWS;
        output.normalWS = normalInput.normalWS;
        output.uv = TRANSFORM_TEX(input[i].texcoord, _BaseMap);
        output.finUv = float2(-1.0, -1.0);

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

        OUTPUT_LIGHTMAP_UV(input[i].lightmapUV, unity_LightmapST, output.lightmapUV);
        OUTPUT_SH(output.normalWS, output.vertexSH);

        stream.Append(output);
    }
    stream.RestartStrip();
#endif

    float3 lineOS01 = (input[1].positionOS - input[0].positionOS).xyz;
    float3 lineOS02 = (input[2].positionOS - input[0].positionOS).xyz;
    float3 normalOS = normalize(cross(lineOS01, lineOS02));
    float3 centerOS = (input[0].positionOS + input[1].positionOS + input[2].positionOS).xyz / 3;
    float3 viewDirOS = GetViewDirectionOS(centerOS);
    float eyeDotN = dot(viewDirOS, normalOS);
    if (abs(eyeDotN) > _FaceViewProdThresh) return;

    AppendFinVertices(stream, input[0], input[1], input[2]);
}

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

    SurfaceData surfaceData = (SurfaceData)0;
    InitializeStandardLitSurfaceData(input.uv, surfaceData);
    surfaceData.occlusion = lerp(1.0 - _Occlusion, 1.0, max(input.finUv.y, 0.0));
    surfaceData.albedo *= surfaceData.occlusion;

    InputData inputData = (InputData)0;
    inputData.positionWS = input.positionWS;
    inputData.normalWS = input.normalWS;
    inputData.viewDirectionWS = SafeNormalize(GetCameraPositionWS() - input.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 += _AmbientColor.rgb;
    color.rgb = MixFog(color.rgb, inputData.fogCoord);

    return color;
}

#endif

ShadowCaster / Depth

Unlit のコードとほとんど同じものを書きます。テッセレーションのコードなどは共通化しておくと便利だと思います。頂点やジオメトリシェーダのコードも共通化できると思いますが、前回と同じくシャドウバイアスの計算のところだけ手を入れるので今回は愚直にそのままコピペしたコードを使います。

...

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 AppendFinVertex(
    ...
    float3 normalWS,
    ...)
{
    ...
#ifdef SHADOW_CASTER_PASS
    float3 posWS = TransformObjectToWorld(posOS);
    output.vertex = GetShadowPositionHClip(posWS, normalWS);
#else
    output.vertex = TransformObjectToHClip(posOS);
#endif
    ...
}

...

void frag(
    Varyings input, 
    out float4 outColor : SV_Target, 
    out float outDepth : SV_Depth)
{
    float4 furColor = SAMPLE_TEXTURE2D(_FurMap, sampler_FurMap, input.finUv);
    float alpha = furColor.a;
    if (alpha < _AlphaCutout) discard;
    outColor = outDepth = input.vertex.z / input.vertex.w;
}

SHADOW_CASTER_PASS というキーワードの定義で出力するクリップ空間座標のオフセットを通常(デプス)用かシャドウバイス適用のものかを分けています。

結果

f:id:hecomi:20210723020129g:plain

そのままだとセルフシャドウが汚く落ちてしまうので、_ShadowExtraBias をいじってセルフシャドウが落ちないように調整し、代わりに _Occlusion をいじって根本に行くほど色が濃くなるようにします。こうすることでいい感じのフサフサ感が得られました。

法線

先程は法線は元のポリゴン(球)のものを流用しましたが、このままでは少し球っぽさが残ってしまうので調整していきましょう。次の 2 つの方法を取り入れていこうと思います。

  1. フィンの表裏方向の法線を少しブレンドする
  2. フィンのテクスチャに対応する法線マップを適用する

表裏方向の法線のブレンド

表裏を表現するにはこれまでは Cull Off で両面書いていたものを Cull Back して片面ずつ処理する必要があります。これを行うためにジオメトリシェーダで両面ずつ生成し法線を反転させることにします。また、現状 Tangent は使っていないので Attributes および Varyings から消しておきます。

float _FaceNormalFactor;

void AppendFinVertices(
    inout TriangleStream<Varyings> stream,
    Attributes input0,
    Attributes input1,
    Attributes input2)
{
    ...
    [unroll]
    for (int j = 0; j < 2; ++j)
    {
        float3 posBeginOS = posOS0;
        float3 posEndOS = posOS3;
        float uvX1 = uvOffset;
        float uvX2 = uvOffset + uvXScale;

        [loop] 
        for (int i = 0; i <= _FinJointNum; ++i)
        {
            float finFactor = (float) i / _FinJointNum;
            float moveFactor = pow(abs(finFactor), _BaseMove.w);
            float3 moveWS = SafeNormalize(dirWS + (baseMoveWS + windMoveWS) * moveFactor) * finStep;
            float3 moveOS = TransformWorldToObjectDir(moveWS, false);
            posBeginOS += moveOS;
            posEndOS += moveOS;
            float3 dirOS03 = normalize(posEndOS - posBeginOS);
            float3 faceNormalOS = normalize(cross(dirOS03, moveOS));
            if (j == 0)
            {
                
                float3 finNormalOS = normalize(lerp(normalOS0, faceNormalOS, _FaceNormalFactor));
                AppendFinVertex(stream, uv0, posBeginOS, finNormalOS, float2(uvX1, finFactor));
                AppendFinVertex(stream, uv12, posEndOS, finNormalOS, float2(uvX2, finFactor));
            }
            else
            {
                faceNormalOS *= -1.0;
                float3 finNormalOS = normalize(lerp(normalOS0, faceNormalOS, _FaceNormalFactor));
                AppendFinVertex(stream, uv12, posEndOS, finNormalOS, float2(uvX2, finFactor));
                AppendFinVertex(stream, uv0, posBeginOS, finNormalOS, float2(uvX1, finFactor));
            }
        }

        stream.RestartStrip();
    }
}

stream に追加を生成する順を逆にして反対側の面を作るようにしています。また、球の法線とその生成された面の法線を _FaceNormalFactorブレンドして法線をいじっています。なお、if の分岐は手動でコード上で unroll すれば省けるので最適化の際は消すのが良いかと思います。

f:id:hecomi:20210723180159g:plain

スペキュラも見やすいように _Smoothness を少し上げておきました。_FaceNormalFactor を変更すると影の領域に明るいところが染み出し、スペキュラの位置が拡散するようになります。また表裏ができるのでハイライト部分をよく見ると表のみ反射して明るくなるようになってます(ちょっと透明感がない印象もありますが)。

法線マップ

法線マップも追加してみます。フィンの UV は自分でセットしているので、同様に Tangent も自分で計算してセットします。先程のブレンドした法線と直交するようにフィンの方向を選ぶようにします。

Properties
{
    ...
    [NoScaleOffset] [Normal] _NormalMap("Normal", 2D) = "bump" {}
    _NormalScale("Normal Scale", Range(0.0, 2.0)) = 0.0
    ...
}

struct Varyings
{
    ...
    float3 finTangentWS : TEXCOORD6;
};

void AppendFinVertex(
    ...
    float3 finSideDirWS)
{
    ...
    output.normalWS = TransformObjectToWorldNormal(normalOS);
    ...
    output.finTangentWS = SafeNormalize(cross(output.normalWS, finSideDirWS));
}

void AppendFinVertices(...)
{
    ...
    float3 finSideDirOS = normalize(posOS3 - posOS0);
    float3 finSideDirWS = TransformObjectToWorldDir(finSideDirOS);

    [unroll]
    for (int j = 0; j < 2; ++j)
    {
        ...

        [loop] 
        for (int i = 0; i <= _FinJointNum; ++i)
        {
            ...
            AppendFinVertex(stream, uv0, posBeginOS, finNormalOS, float2(uvX1, finFactor), finSideDirWS);
            AppendFinVertex(stream, uv12, posEndOS, finNormalOS, float2(uvX2, finFactor), finSideDirWS);
            ...
        }
        ...
    }
}

...

float4 frag(Varyings input) : SV_Target
{
    ...
    float3 viewDirWS = SafeNormalize(GetCameraPositionWS() - input.positionWS);;
    float3 normalTS = UnpackNormalScale(
        SAMPLE_TEXTURE2D(_NormalMap, sampler_NormalMap, input.finUv),
        _NormalScale);
    float3 bitangent = SafeNormalize(viewDirWS.y * cross(input.normalWS, input.finTangentWS));
    float3 normalWS = SafeNormalize(TransformTangentToWorld(
        normalTS, 
        float3x3(input.finTangentWS, bitangent, input.normalWS)));
    ...
}

入力する法線はこんな画像にしました。

f:id:hecomi:20210723182321p:plain

f:id:hecomi:20210723182213g:plain

毛の 1 本 1 本が強調されはしましたが、それほど大きな変化ではありませんでした。。透明感出したいときや画作りによっては省いても良さそうです。

リムライト

リムライトは完全に前回と同じコードがそのまま使えます。

f:id:hecomi:20210723183657g:plain

足してあげると柔らかい感じが出て良いですね。本当はもう少し全体的に透明感を出したいところですが、ひとまずこれで完成とします。

その他

ジオメトリシェーダやテッセレーションに対応していない場合

こういったプラットフォーム向け、またはジオメトリシェーダのオーバーヘッドを避けたい場合などは、事前計算によって生やしてしまう方法もあります。メッシュのコンバータをエディタ拡張として書き、パラメタを入れてポチッとすると、所望のシェルやフィンのメッシュを必要な UV 情報とともに事前生成し、ジオメトリシェーダで動かす代わりに頂点シェーダで動かす、とすることで同様の見た目を得ることが出来ます。

具体的な実装は、同様のことを行っている以下の記事が参考になるかもしれません。 tips.hecomi.com

おわりに

シェル法に引き続きフィン法も見てきました。適当に思いつくままに色々実験していった関係でだいぶシェーダが重い感じになってしまいましたが..、シェル法とは違った長い毛感が表現できて面白かったです。あとは直接毛ポリゴンを生成する方式や、モデルの動きに応じて毛が動くといったことも試してみたいですね。

参考