はじめに
URP でのファーシェーダの実装について、シェル法とフィン法について見てきました。
最後は直接ジオメトリシェーダで毛ポリゴンを生成する方法を試してみます。
デモ
ライティング
3 手法の比較
左から順に、シェル法、フィン法、今回の毛ポリゴン生成です。
ダウンロード
概要
シェル法では外殻を重ねていって毛を作り出しました。テクスチャで毛の密度が制御できたり、既存のポリゴン形状を意識しなくてもフサフサが作れ、また増やす層の数で見た目とパフォーマンスのトレードオフも調整しやすいものです。一方で横から見ると層が見えてしまいドット感が見えてしまうため長い毛が作れないのが最大の難点でした。
フィン法は毛ヒレを表面に生やしていってヒレに毛のテクスチャを貼り付けることで毛を表現する手法でした。毛のテクスチャでいろいろな毛のタイプが作れ、ツンツンやモジャモジャも表現できます。毛ヒレなので横から見ても破綻しないのがメリットですが、一方で垂直方向から見るとヒレの板感が目立ってしまうデメリットもあります。ヒレを垂直に見えないように傾ければそれほど問題とはならないかもしれません。シェル法とハイブリッドで使われることが多いようですが、前回はフィン法のみである程度見えるようにテッセレーションをつかって毛ヒレの数を増やしていました。そのためポリゴンのサイズが揃っていないと疎密が激しくなってしまうデメリットもあります。
今回は実際にボリュームのある毛自体のポリゴンを生成し、垂直に見ても横から見ても破綻しないようなものを試してみたいと思います。フィン法のときと同じくテッセレーションでポリゴンを増やすのでポリゴンの疎密の影響を受けやすい、ポリゴンが多くなるといったデメリットもありますが、フィン法と同じように長くしても破綻しない毛が作れると思います。
毛ポリゴンの生成
ポリゴン生成について
次のようにジオメトリシェーダでポリゴンを複数ジョイントを持つ三角錐に変換します。
コード
ShaderLab の方については前回・前々回のものとだいたい同じなので省きます。
#include "Packages/com.unity.render-pipelines.universal/Shaders/UnlitInput.hlsl" float _FurLength; int _FurJoint; float _Occlusion; float _RandomDirection; struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct Varyings { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; float fogCoord : TEXCOORD1; float factor : TEXCOORD2; }; Attributes vert(Attributes input) { return input; } void AppendVertex(inout TriangleStream<Varyings> stream, float3 posOS, float2 uv, float factor) { Varyings output; output.vertex = TransformObjectToHClip(posOS); output.uv = TRANSFORM_TEX(uv, _BaseMap); output.fogCoord = ComputeFogFactor(output.vertex.z); output.factor = factor; stream.Append(output); } [maxvertexcount(53)] void geom(triangle Attributes input[3], inout TriangleStream<Varyings> stream) { float2 prevUv0 = input[0].uv; float2 prevUv1 = input[1].uv; float2 prevUv2 = input[2].uv; float3 prevPos0OS = input[0].positionOS.xyz; float3 prevPos1OS = input[1].positionOS.xyz; float3 prevPos2OS = input[2].positionOS.xyz; float3 line01OS = prevPos1OS - prevPos0OS; float3 line02OS = prevPos2OS - prevPos0OS; float3 faceNormalOS = SafeNormalize(cross(line01OS, line02OS)); float2 topUv = (prevUv0 + prevUv1 + prevUv2) / 3; faceNormalOS += rand3(topUv) * _RandomDirection; faceNormalOS = SafeNormalize(faceNormalOS); float3 topPosOS = (prevPos0OS + prevPos1OS + prevPos2OS) / 3 + faceNormalOS * _FurLength; float3 posInterp0OS = topPosOS - prevPos0OS; float3 posInterp1OS = topPosOS - prevPos1OS; float3 posInterp2OS = topPosOS - prevPos2OS; float2 uvInterp0 = topUv - prevUv0; float2 uvInterp1 = topUv - prevUv1; float2 uvInterp2 = topUv - prevUv2; float delta = 1.0 / _FurJoint; float prevFactor = 0.0; for (int j = 0; j < _FurJoint; ++j) { float nextFactor = prevFactor + delta; float3 nextPos0OS = prevPos0OS + posInterp0OS * delta; float3 nextPos1OS = prevPos1OS + posInterp1OS * delta; float3 nextPos2OS = prevPos2OS + posInterp2OS * delta; float2 nextUv0 = prevUv0 + uvInterp0 * delta; float2 nextUv1 = prevUv1 + uvInterp1 * delta; float2 nextUv2 = prevUv2 + uvInterp2 * delta; AppendVertex(stream, nextPos0OS, nextUv0, nextFactor); AppendVertex(stream, prevPos0OS, prevUv0, prevFactor); AppendVertex(stream, nextPos1OS, nextUv1, nextFactor); AppendVertex(stream, prevPos1OS, prevUv1, prevFactor); AppendVertex(stream, nextPos2OS, nextUv2, nextFactor); AppendVertex(stream, prevPos2OS, prevUv2, prevFactor); AppendVertex(stream, nextPos0OS, nextUv0, nextFactor); AppendVertex(stream, prevPos0OS, prevUv0, prevFactor); prevFactor = nextFactor; prevPos0OS = nextPos0OS; prevPos1OS = nextPos1OS; prevPos2OS = nextPos2OS; prevUv0 = nextUv0; prevUv1 = nextUv1; prevUv2 = nextUv2; stream.RestartStrip(); } AppendVertex(stream, prevPos0OS, prevUv0, prevFactor); AppendVertex(stream, prevPos1OS, prevUv1, prevFactor); AppendVertex(stream, topPosOS, topUv, 1.0); AppendVertex(stream, prevPos2OS, prevUv2, prevFactor); AppendVertex(stream, prevPos0OS, prevUv0, prevFactor); stream.RestartStrip(); } float4 frag(Varyings input) : SV_Target { float4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); color *= _BaseColor; color.rgb = MixFog(color.rgb, input.fogCoord); color *= lerp(1.0 - _Occlusion, 1.0, input.factor); return color; }
結果
ボリュームのあるポリゴンで毛が出来ているので側面も正面もきれいに見えます。シェル法でやったときのようにテッセレーションによって頂点数が増えてその分重くはなりますが...、ちょっとしたデモなら使い勝手は良さそうです。
風で動かしてみる
シェル / フィン法の記事と同じ用に風で動かしてみます。
コード
ジオメトリシェーダを書き換えます、ちょっと長くなってしまいました...。
[maxvertexcount(53)] void geom(triangle Attributes input[3], inout TriangleStream<Varyings> stream) { float3 startPos0OS = input[0].positionOS.xyz; float3 startPos1OS = input[1].positionOS.xyz; float3 startPos2OS = input[2].positionOS.xyz; float2 prevUv0 = input[0].uv; float2 prevUv1 = input[1].uv; float2 prevUv2 = input[2].uv; float2 topUv = (prevUv0 + prevUv1 + prevUv2) / 3; float2 uvInterp0 = topUv - prevUv0; float2 uvInterp1 = topUv - prevUv1; float2 uvInterp2 = topUv - prevUv2; float3 prevPos0OS = startPos0OS; float3 prevPos1OS = startPos1OS; float3 prevPos2OS = startPos2OS; float3 line01OS = prevPos1OS - prevPos0OS; float3 line02OS = prevPos2OS - prevPos0OS; float3 faceNormalOS = SafeNormalize(cross(line01OS, line02OS)); faceNormalOS += rand3(topUv) * _RandomDirection; faceNormalOS = SafeNormalize(faceNormalOS); float3 startCenterPosOS = (prevPos0OS + prevPos1OS + prevPos2OS) / 3; float3 topPosOS = startCenterPosOS + faceNormalOS * _FurLength; float3 startCenterPosWS = TransformObjectToWorld(startCenterPosOS); float3 faceNormalWS = TransformObjectToWorldNormal(faceNormalOS, true); float3 windAngle = _Time.w * _WindFreq.xyz; float3 windMoveWS = _WindMove.xyz * sin(windAngle + startCenterPosWS * _WindMove.w); float3 baseMoveWS = _BaseMove.xyz; float3 movedFaceNormalWS = faceNormalWS + (baseMoveWS + windMoveWS); float3 movedFaceNormalOS = TransformWorldToObjectNormal(movedFaceNormalWS, true); float3 topMovedPosOS = startCenterPosOS + movedFaceNormalOS * _FurLength; float prevFactor = 0.0; float delta = 1.0 / _FurJoint; for (int j = 0; j < _FurJoint; ++j) { float nextFactor = prevFactor + delta; float moveFactor = pow(abs(nextFactor), _BaseMove.w); float3 posInterp0OS = lerp(topPosOS, topMovedPosOS, moveFactor) - startPos0OS; float3 posInterp1OS = lerp(topPosOS, topMovedPosOS, moveFactor) - startPos1OS; float3 posInterp2OS = lerp(topPosOS, topMovedPosOS, moveFactor) - startPos2OS; float3 nextPos0OS = startPos0OS + posInterp0OS * nextFactor; float3 nextPos1OS = startPos1OS + posInterp1OS * nextFactor; float3 nextPos2OS = startPos2OS + posInterp2OS * nextFactor; float2 nextUv0 = prevUv0 + uvInterp0 * delta; float2 nextUv1 = prevUv1 + uvInterp1 * delta; float2 nextUv2 = prevUv2 + uvInterp2 * delta; AppendVertex(stream, nextPos0OS, nextUv0, nextFactor); AppendVertex(stream, prevPos0OS, prevUv0, prevFactor); AppendVertex(stream, nextPos1OS, nextUv1, nextFactor); AppendVertex(stream, prevPos1OS, prevUv1, prevFactor); AppendVertex(stream, nextPos2OS, nextUv2, nextFactor); AppendVertex(stream, prevPos2OS, prevUv2, prevFactor); AppendVertex(stream, nextPos0OS, nextUv0, nextFactor); AppendVertex(stream, prevPos0OS, prevUv0, prevFactor); prevFactor = nextFactor; prevPos0OS = nextPos0OS; prevPos1OS = nextPos1OS; prevPos2OS = nextPos2OS; prevUv0 = nextUv0; prevUv1 = nextUv1; prevUv2 = nextUv2; stream.RestartStrip(); } AppendVertex(stream, prevPos0OS, prevUv0, prevFactor); AppendVertex(stream, prevPos1OS, prevUv1, prevFactor); AppendVertex(stream, topMovedPosOS, topUv, 1.0); AppendVertex(stream, prevPos2OS, prevUv2, prevFactor); AppendVertex(stream, prevPos0OS, prevUv0, prevFactor); stream.RestartStrip(); }
結果
ライティング
次はライティングです。
コード
長くなってしまいすみません...(実際はこれに加えてテッセレーションのコードもあります)。なお、前回、前々回で見てきたシャドウバイアスやリムライトのコードは共通化した上で既に入れてあります(詳しくは完成プロジェクトをご参照ください)。
#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" #include "./Param.hlsl" #include "../Common/Common.hlsl" struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 texcoord : TEXCOORD0; float2 lightmapUV : TEXCOORD1; }; struct Varyings { float4 positionCS : SV_POSITION; float3 positionWS : TEXCOORD0; float3 normalWS : TEXCOORD1; float2 uv : TEXCOORD3; DECLARE_LIGHTMAP_OR_SH(lightmapUV, vertexSH, 4); float4 fogFactorAndVertexLight : TEXCOORD5; // x: fogFactor, yzw: vertex light float factor : TEXCOORD6; }; Attributes vert(Attributes input) { return input; } void AppendVertex( inout TriangleStream<Varyings> stream, float3 posOS, float3 normalOS, float2 uv, float2 lightmapUV, float factor) { Varyings output = (Varyings)0; VertexPositionInputs vertexInput = GetVertexPositionInputs(posOS); output.positionCS = vertexInput.positionCS; output.positionWS = vertexInput.positionWS; output.normalWS = TransformObjectToWorldNormal(normalOS, true); output.uv = uv; output.factor = factor; float3 vertexLight = VertexLighting(output.positionWS, output.normalWS); float fogFactor = ComputeFogFactor(output.positionCS.z); output.fogFactorAndVertexLight = half4(fogFactor, vertexLight); OUTPUT_LIGHTMAP_UV(lightmapUV, unity_LightmapST, output.lightmapUV); OUTPUT_SH(output.normalWS, output.vertexSH); stream.Append(output); } float3 CalcNormal(float3 up, float3 center, float3 pos) { float3 centerToPos = pos - center; float3 right = cross(centerToPos, up); return SafeNormalize(cross(up, right)); } [maxvertexcount(51)] void geom(triangle Attributes input[3], inout TriangleStream<Varyings> stream) { float3 startPos0OS = input[0].positionOS.xyz; float3 startPos1OS = input[1].positionOS.xyz; float3 startPos2OS = input[2].positionOS.xyz; float2 prevUv0 = TRANSFORM_TEX(input[0].texcoord, _BaseMap); float2 prevUv1 = TRANSFORM_TEX(input[1].texcoord, _BaseMap); float2 prevUv2 = TRANSFORM_TEX(input[2].texcoord, _BaseMap); float2 topUv = (prevUv0 + prevUv1 + prevUv2) / 3; float2 uvInterp0 = topUv - prevUv0; float2 uvInterp1 = topUv - prevUv1; float2 uvInterp2 = topUv - prevUv2; float2 prevLightmapUv0 = input[0].lightmapUV; float2 prevLightmapUv1 = input[1].lightmapUV; float2 prevLightmapUv2 = input[2].lightmapUV; float2 topLightmapUv = (prevLightmapUv0 + prevLightmapUv1 + prevLightmapUv2) / 3; float2 lightmapUvInterp0 = topLightmapUv - prevLightmapUv0; float2 lightmapUvInterp1 = topLightmapUv - prevLightmapUv1; float2 lightmapUvInterp2 = topLightmapUv - prevLightmapUv2; float3 prevPos0OS = startPos0OS; float3 prevPos1OS = startPos1OS; float3 prevPos2OS = startPos2OS; float3 line01OS = prevPos1OS - prevPos0OS; float3 line02OS = prevPos2OS - prevPos0OS; float3 faceNormalOS = SafeNormalize(cross(line01OS, line02OS)); faceNormalOS += rand3(topUv) * _RandomDirection; faceNormalOS = SafeNormalize(faceNormalOS); float3 startCenterPosOS = (prevPos0OS + prevPos1OS + prevPos2OS) / 3; float3 topPosOS = startCenterPosOS + faceNormalOS * _FurLength; float3 startCenterPosWS = TransformObjectToWorld(startCenterPosOS); float3 faceNormalWS = TransformObjectToWorldNormal(faceNormalOS, true); float3 windAngle = _Time.w * _WindFreq.xyz; float3 windMoveWS = _WindMove.xyz * sin(windAngle + startCenterPosWS * _WindMove.w); float3 baseMoveWS = _BaseMove.xyz; float3 movedFaceNormalWS = faceNormalWS + (baseMoveWS + windMoveWS); float3 movedFaceNormalOS = TransformWorldToObjectNormal(movedFaceNormalWS, true); float3 topMovedPosOS = startCenterPosOS + movedFaceNormalOS * _FurLength; float3 prevCenterPosOS = startCenterPosOS; float3 prevNormal0OS = CalcNormal(faceNormalOS, prevCenterPosOS, prevPos0OS); float3 prevNormal1OS = CalcNormal(faceNormalOS, prevCenterPosOS, prevPos1OS); float3 prevNormal2OS = CalcNormal(faceNormalOS, prevCenterPosOS, prevPos2OS); float prevFactor = 0.0; float delta = 1.0 / _FurJoint; for (int j = 0; j < _FurJoint; ++j) { float nextFactor = prevFactor + delta; float moveFactor = pow(abs(nextFactor), _BaseMove.w); float3 lerpMovedTopPosOS = lerp(topPosOS, topMovedPosOS, moveFactor); float3 posInterp0OS = lerpMovedTopPosOS - startPos0OS; float3 posInterp1OS = lerpMovedTopPosOS - startPos1OS; float3 posInterp2OS = lerpMovedTopPosOS - startPos2OS; float3 nextPos0OS = startPos0OS + posInterp0OS * nextFactor; float3 nextPos1OS = startPos1OS + posInterp1OS * nextFactor; float3 nextPos2OS = startPos2OS + posInterp2OS * nextFactor; float3 nextCenterPosOS = (nextPos0OS + nextPos1OS + nextPos2OS) / 3; float3 movedFaceNormalOS = SafeNormalize(nextCenterPosOS - prevCenterPosOS); float3 nextNormal0OS = CalcNormal(movedFaceNormalOS, nextCenterPosOS, nextPos0OS); float3 nextNormal1OS = CalcNormal(movedFaceNormalOS, nextCenterPosOS, nextPos1OS); float3 nextNormal2OS = CalcNormal(movedFaceNormalOS, nextCenterPosOS, nextPos2OS); float2 nextUv0 = prevUv0 + uvInterp0 * delta; float2 nextUv1 = prevUv1 + uvInterp1 * delta; float2 nextUv2 = prevUv2 + uvInterp2 * delta; float2 nextLightmapUv0 = prevUv0 + lightmapUvInterp0 * delta; float2 nextLightmapUv1 = prevUv1 + lightmapUvInterp1 * delta; float2 nextLightmapUv2 = prevUv2 + lightmapUvInterp2 * delta; AppendVertex(stream, nextPos0OS, nextNormal0OS, nextUv0, nextLightmapUv0, nextFactor); AppendVertex(stream, prevPos0OS, prevNormal0OS, prevUv0, prevLightmapUv0, prevFactor); AppendVertex(stream, nextPos1OS, nextNormal1OS, nextUv1, nextLightmapUv1, nextFactor); AppendVertex(stream, prevPos1OS, prevNormal1OS, prevUv1, prevLightmapUv1, prevFactor); AppendVertex(stream, nextPos2OS, nextNormal2OS, nextUv2, nextLightmapUv2, nextFactor); AppendVertex(stream, prevPos2OS, prevNormal2OS, prevUv2, prevLightmapUv2, prevFactor); AppendVertex(stream, nextPos0OS, nextNormal0OS, nextUv0, nextLightmapUv0, nextFactor); AppendVertex(stream, prevPos0OS, prevNormal0OS, prevUv0, prevLightmapUv0, prevFactor); prevFactor = nextFactor; prevPos0OS = nextPos0OS; prevPos1OS = nextPos1OS; prevPos2OS = nextPos2OS; prevCenterPosOS = nextCenterPosOS; prevNormal0OS = nextNormal0OS; prevNormal1OS = nextNormal1OS; prevNormal2OS = nextNormal2OS; prevUv0 = nextUv0; prevUv1 = nextUv1; prevUv2 = nextUv2; prevLightmapUv0 = nextLightmapUv0; prevLightmapUv1 = nextLightmapUv1; prevLightmapUv2 = nextLightmapUv2; stream.RestartStrip(); } AppendVertex(stream, prevPos0OS, prevNormal0OS, prevUv0, prevLightmapUv0, prevFactor); AppendVertex(stream, prevPos1OS, prevNormal1OS, prevUv1, prevLightmapUv1, prevFactor); AppendVertex(stream, topMovedPosOS, movedFaceNormalOS, topUv, topLightmapUv, 1.0); AppendVertex(stream, prevPos2OS, prevNormal2OS, prevUv2, prevLightmapUv2, prevFactor); AppendVertex(stream, prevPos0OS, prevNormal0OS, prevUv0, prevLightmapUv0, prevFactor); stream.RestartStrip(); } float4 frag(Varyings input) : SV_Target { SurfaceData surfaceData = (SurfaceData)0; InitializeStandardLitSurfaceData(input.uv, surfaceData); surfaceData.occlusion = lerp(sqrt(abs(1.0 - _Occlusion)), 1.0, input.factor); //surfaceData.albedo *= surfaceData.occlusion; InputData inputData = (InputData)0; inputData.positionWS = input.positionWS; inputData.normalWS = SafeNormalize(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); #if 1 float4 color = UniversalFragmentPBR(inputData, surfaceData); ApplyRimLight(color.rgb, inputData.positionWS, inputData.viewDirectionWS, inputData.normalWS); color.rgb += _AmbientColor.rgb; color.rgb = MixFog(color.rgb, inputData.fogCoord); #else float4 color = float4(inputData.normalWS, 1.0); #endif return color; }
結果
法線をソフトエッジ的な感じですがポリゴン面に対して真面目に算出してみたところ以下のようになりました。
うーん、実際は毛は透けるので不透明オブジェクトとしてのライティングは変になってしまいますね...。なのでフィン法でもやったように見た目がよろしくなるよう法線はフェイクで与えて上げる必要がありそうです。
法線の改善
法線は毛の立体感を出すために計算したものと元のポリゴンをマージする方式にしました。元のポリゴン(= 根本の法線)を使うと透明感のあるライティングになります。毛の立体感に関しては先程のコードとは変え、現在のジョイントの中心と一つ前のジョイントの中心の中間点から各頂点へ伸ばした線を採用することにしました。ジョイント数が増えるほど毛の横向き具合が強くなってしまうというパラメタの調整しにくさは出てしまいますが...、概ね良い見た目になりました。
void geom(...) { ... // _RandomDirection でいじる前の法線を保存 float3 origFaceNormalOS = faceNormalOS; ... float3 prevCenterPosOS = startCenterPosOS; float3 prevNormal0OS = origFaceNormalOS; float3 prevNormal1OS = origFaceNormalOS; float3 prevNormal2OS = origFaceNormalOS; ... for (int j = 0; j < _FurJoint; ++j) { ... float3 basePosOS = (nextCenterPosOS + prevCenterPosOS) / 2; float3 movedNormal0OS = SafeNormalize(nextPos0OS - basePosOS); float3 movedNormal1OS = SafeNormalize(nextPos1OS - basePosOS); float3 movedNormal2OS = SafeNormalize(nextPos2OS - basePosOS); float3 nextNormal0OS = SafeNormalize(lerp(origFaceNormalOS, movedNormal0OS, _NormalFactor)); float3 nextNormal1OS = SafeNormalize(lerp(origFaceNormalOS, movedNormal1OS, _NormalFactor)); float3 nextNormal2OS = SafeNormalize(lerp(origFaceNormalOS, movedNormal2OS, _NormalFactor)); ... } float3 topNormalOS = SafeNormalize(topMovedPosOS - prevCenterPosOS); topNormalOS = SafeNormalize(lerp(faceNormalOS, topNormalOS, _NormalFactor)); ... }
結果
と頑張りましたが正直元の法線を使い、フェイクシャドウ(根本に行くほど暗くなるよう _Occlusion
を大きくする)を適用するだけでも透明感のある良い見た目になるのでこれで良いかもしれません…(計算量もコードも減りますし)。
調整結果
パラメタをもろもろ調整してあげると次のような感じになります。
おわりに
見た目のインパクトは前回のフィン法の方がありましたね。ただポリゴンがある分、毛単位の何かしらの処理やシェーディングのしやすさは今回のほうが上かなと思います。次回は毛をオブジェクトの動き(位置移動、回転、アニメーション)に合わせて動かすところをやってみようかなと思います。