准备工作做得差不多了,下面我们来一步步实现毛发材质。
我们首先用简单的顶点着色器和片元着色器实现,之后使用几何着色器与细分曲面着色器进行优化。
首先是生成shell外壳,之前介绍过,方法就是将顶点沿其法线方向扩展一段距离。由于可能要多次使用相同的函数和结构体,我们将其定义在一个头文件中。
// FurShader.cginc
#include "UnityCG.cginc"
struct vertexInput
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
struct vertexOutput
{
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
float _FurLength;
sampler2D _MainTex;
float4 _MainTex_ST;
// BaseVertexShader
vertexOutput baseVert(vertexInput i)
{
vertexOutput o;
o.vertex = UnityObjectToClipPos(i.vertex);
o.uv = TRANSFORM_TEX(i.uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(i.normal);
o.worldPos = mul(unity_ObjectToWorld, i.vertex).xyz;
return o;
}
// BaseFragmentShader
fixed4 baseFrag(vertexOutput i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}
// FurVertexShader
vertexOutput furVert(vertexInput i)
{
vertexOutput o;
float3 pos = i.vertex.xyz + i.normal * _FurLength * FURSTEP;
o.vertex = UnityObjectToClipPos(float4(pos, 1.0));
o.uv = TRANSFORM_TEX(i.uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(i.normal);
o.worldPos = mul(unity_ObjectToWorld, i.vertex).xyz;
return o;
}
主要在于毛发的顶点着色器,我们将其的顶点沿法线方向挤出一定距离。Shader文件中:
Shader "Unlit/Fur"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_FurLength("Fur Length", Range(0,1)) = 0.2
}
SubShader
{
Pass
{
CGPROGRAM
#define FURSTEP 0.00
#pragma vertex baseVert
#pragma fragment baseFrag
#include "FurShader.cginc"
ENDCG
}
Pass
{
CGPROGRAM
#define FURSTEP 0.1
#pragma vertex furVert
#pragma fragment baseFrag
#include "FurShader.cginc"
ENDCG
}
}
}
我们定义了两个Pass,第一个正常渲染,第二个扩展一定距离。请看一下下面的对比图:
上面两张图是同一摄像机位置下的毛发长度分别为0和1的效果,可以看到相当于是往外扩张了一段距离。
不过这并不是我们想要的效果,我们可以将渲染的shell外壳的透明度进行一定的随机排布,使用一张噪声图:
然后,添加噪声纹理变量,编写新的毛发片元着色器:
fixed4 furFrag(vertexOutput i) : SV_Target
{
fixed alpha = tex2D(_FurNoiseTex, i.uv).r;
fixed3 col = tex2D(_MainTex, i.uv).rgb;
return fixed4(col, alpha);
}
注意用噪声图控制shell外壳的透明度。
在shader文件中,我们要开启对应的Tags,方便渲染透明物体:
Tags { "Queue" = "Transparent" "IgnoreProjector" = "True" "RenderType" = "Transparent" }
接着,在第二个Pass中,我们要关闭深度写入,以及设置混合操作:
Pass
{
ZWrite off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#define FURSTEP 0.1
#pragma vertex furVert
#pragma fragment furFrag
#include "FurShader.cginc"
ENDCG
}
效果如下:
不过这还远远不够达到毛发的效果,我们可以先暴力求解,逐次复制添加第二个Pass,并每次增加FURSETP
的值,直到为1,这么做之后效果如下:
上图中毛发长度设为1。
可以看到有一点毛发的效果,但不够真实,我们可以添加一点逐层渐变的效果。我们可以声明一个密度属性来逐Pass递减透明度值:
fixed4 furFrag(vertexOutput i) : SV_Target
{
fixed alpha = tex2D(_FurNoiseTex, i.uv).r;
fixed3 col = tex2D(_MainTex, i.uv).rgb - pow(1 - FURSTEP, 3) * 0.1;
alpha = clamp(alpha - pow(FURSTEP, 2) * _FurDensity, 0, 1);
return fixed4(col, alpha);
}
我们的主要操作是让shell外壳的颜色随扩展距离三次方递减,同时透明度值由密度控制,随距离二次方递减,并限制在0-1内。
效果:然后我们可以添加一个变量来控制透明度噪声:
fixed alpha = tex2D(_FurNoiseTex, i.uv * _NoiseMultiplier).r;
同时还可以添加一个变量来为毛发添加重力:
float4 gravity = float4(0, -1, 0, 0) * (1 - _Rigidness);
pos += clamp(mul(unity_ObjectToWorld, gravity).xyz, -1, 1) * pow(FURSTEP, 3) * _FurLength;
接下来解决一下光照问题,我们可以使用边缘光照来改善毛发显示:
fixed4 furFrag(vertexOutput i) : SV_Target
{
fixed alpha = tex2D(_FurNoiseTex, i.uv * _NoiseMultiplier).r;
fixed3 col = tex2D(_MainTex, i.uv).rgb - pow(1 - FURSTEP, 3) * 0.1;
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos);
half rim = 1.0 - saturate(dot(viewDir, i.worldNormal));
col += pow(rim, _RimPower);
alpha = clamp(alpha - pow(FURSTEP, 2) * _FurDensity, 0, 1);
return fixed4(col, alpha);
}
显然,暴力求解会耗费许多的性能,上图中我使用了大概10个pass,可以发现效果会有分层感,提高到20多个Pass可能会有改善,不过这过于繁杂了,我们可以尝试使用几何着色器来进行优化。
这里的思路是,几何着色器输入一个三角形,针对每个顶点生成多层三角形,同时为了保证能够逐层衰减透明度值,这里我添加了一个索引属性来代表三角形所在层数,下面是几何着色器实现:
[maxvertexcount(MAXCOUNT)]
void furGeom(triangle vertexOutput IN[3], inout TriangleStream<geometryOutput> triStream)
{
geometryOutput o;
for (int i = 1; i <= _Iteration; i++)
{
for(int j = 0; j < 3; j++)
{
float3 pos = IN[j].vertex.xyz + IN[j].normal * _FurLength * i * 0.1;
float4 gravity = float4(0,-1,0,0) * (1 -_Rigidness);
pos += clamp(mul(unity_ObjectToWorld,gravity).xyz, -1, 1) * pow(i * 0.1, 3) * _FurLength;
o.vertex = UnityObjectToClipPos(float4(pos, 1.0));
o.uv = TRANSFORM_TEX(IN[j].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(IN[j].normal);
o.worldPos = mul(unity_ObjectToWorld, IN[j].vertex).xyz;
o.index = i;
triStream.Append(o);
}
triStream.RestartStrip();
}
}
这样的话,就可以减少shader中pass的数量:
Shader "Unlit/Fur"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_FurNoiseTex ("Texture", 2D) = "white" {}
_FurLength("Fur Length", Range(0,1)) = 0.2
_FurDensity("Fur Density", Range(0.1,10)) = 0.2
_NoiseMultiplier("Multiplier", Range(0,10)) = 1
_RimPower("Rim Power", Range(1, 256)) = 16
[IntRange]_Iteration("Fur Iteretion", Range(5, 20)) = 10
}
SubShader
{
Tags { "Queue" = "Transparent" }
Pass
{
CGPROGRAM
#define MAXCOUNT 0
#pragma vertex baseVert
#pragma fragment baseFrag
#include "FurShader.cginc"
ENDCG
}
Pass
{
ZWrite off
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#define MAXCOUNT 60
#pragma vertex furVert
#pragma geometry furGeom
#pragma fragment furFrag
#include "FurShader.cginc"
ENDCG
}
}
}
效果:
使用了shell外壳后,我们可以尝试添加鳍状体来增加毛发的细节,鳍状体的生成在一个额外的pass中。
生成鳍状体基本的思路是在每个输入三角形的基础上,在某一条边或中间挤出一个四边形,我们可以使用细分曲面着色器控制鳍状体的密度。对于鳍状体,为了模拟毛发,我们也会为其添加细分,同时会让其有弯曲的效果。最后,我们还会研究如何为鳍状体与shell外壳添加跟随物体的动力学效果。
按照之前英伟达白皮书和相关论文中的解释,需要挤出鳍状体的边是剪影边,即一个背朝观察者的三角形和一个朝向观察者的三角形的重合边。我们在几何着色器中判断这一点,然后挤出四边形。
几何着色器实现如下:
[maxvertexcount(4)]
void finsGeom(lineadj vertexOutput IN[4], inout TriangleStream<geometryOutput> triStream)
{
geometryOutput o;
// triangle's normals
//float3 N1 = normalize(cross(IN[0].worldPos - IN[1].worldPos, IN[3].worldPos - IN[1].worldPos));
//float3 N2 = normalize(cross(IN[2].worldPos - IN[1].worldPos, IN[0].worldPos - IN[1].worldPos));
float3 N1 = normalize(cross(IN[0].vertex.xyz - IN[1].vertex.xyz, IN[3].vertex.xyz - IN[1].vertex.xyz));
float3 N2 = normalize(cross(IN[2].vertex.xyz - IN[1].vertex.xyz, IN[0].vertex.xyz - IN[1].vertex.xyz));
N1 = UnityObjectToWorldNormal(N1);
N2 = UnityObjectToWorldNormal(N2);
// triangles's barycentric
float3 barycentric1 = (IN[0].worldPos + IN[1].worldPos + IN[3].worldPos) / 3;
float3 barycentric2 = (IN[0].worldPos + IN[1].worldPos + IN[2].worldPos) / 3;
// viewDir
float3 viewDir1 = normalize(_WorldSpaceCameraPos.xyz - barycentric1);
float3 viewDir2 = normalize(_WorldSpaceCameraPos.xyz - barycentric2);
// if silhouette
float eyeDotN1 = dot(viewDir1, N1);
float eyeDotN2 = dot(viewDir2, N2);
if(eyeDotN1 * eyeDotN2 < 0 || abs(eyeDotN1) < _FinThreshold || abs(eyeDotN2) < _FinThreshold)
{
o.vertex = UnityObjectToClipPos(IN[0].vertex);
o.uv = TRANSFORM_TEX(IN[0].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(IN[0].normal);
o.worldPos = IN[0].worldPos;
o.index = 0;
triStream.Append(o);
o.vertex = UnityObjectToClipPos(IN[1].vertex);
o.uv = TRANSFORM_TEX(IN[1].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(IN[1].normal);
o.worldPos = IN[1].worldPos;
o.index = 1;
triStream.Append(o);
fixed3 pos = IN[0].vertex.xyz + IN[0].normal * _FinLength;
o.vertex = UnityObjectToClipPos(fixed4(pos, 1.0));
o.uv = TRANSFORM_TEX(IN[0].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(IN[0].normal);
o.worldPos = mul(unity_ObjectToWorld, float4(pos, 1.0)).xyz;
o.index = 2;
triStream.Append(o);
pos = IN[1].vertex.xyz + IN[1].normal * _FinLength;
o.vertex = UnityObjectToClipPos(fixed4(pos, 1.0));
o.uv = TRANSFORM_TEX(IN[1].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(IN[1].normal);
o.worldPos = mul(unity_ObjectToWorld, float4(pos, 1.0)).xyz;
o.index = 3;
triStream.Append(o);
triStream.RestartStrip();
}
}
虽然鳍状体可以设置的很短来避免看出来,但稍长的话就会明显看出是一个一个的四边形,我们可以使用细分曲面着色器来增加鳍状体的数量:
// patchConstantFunction
tessellationFactors patchConstantFunction(InputPatch<vertexInput, 3> patch)
{
tessellationFactors f;
f.edge[0] = _TessellationUniform;
f.edge[1] = _TessellationUniform;
f.edge[2] = _TessellationUniform;
f.inside = _TessellationUniform;
return f;
}
// FinsHullShader
[UNITY_domain("tri")]
[UNITY_outputcontrolpoints(3)]
[UNITY_outputtopology("triangle_cw")]
[UNITY_partitioning("integer")]
[UNITY_patchconstantfunc("patchConstantFunction")]
vertexInput finsHull(InputPatch<vertexInput, 3> patch, uint id : SV_OUTPUTCONTROLPOINTID)
{
return patch[id];
}
// FinsDomainShader
[UNITY_domain("tri")]
vertexOutput finsDomain(tessellationFactors factors, OutputPatch<vertexInput, 3> patch, float3 barycentricCoordinates : SV_DOMAINLOCATION)
{
vertexInput i;
#define MY_DOMAIN_PROGRAM_INTERPOLATE(fieldName) i.fieldName = \
patch[0].fieldName * barycentricCoordinates.x + \
patch[1].fieldName * barycentricCoordinates.y + \
patch[2].fieldName * barycentricCoordinates.z;
MY_DOMAIN_PROGRAM_INTERPOLATE(vertex)
MY_DOMAIN_PROGRAM_INTERPOLATE(normal)
//MY_DOMAIN_PROGRAM_INTERPOLATE(uv)
return finsVert(i);
}
这是非常常规的写法,通过一个属性来控制细分程度。
最后的效果(这里就不用那个球了)
效果不是那么好调。
-----ps----
经过我的调试,发现修改生成鳍状体的顶点的世界法线为某一面法线会有不错的效果。更新的几何着色器:
// FinsGeometryShader
[maxvertexcount(4)]
void finsGeom(lineadj vertexOutput IN[4], inout TriangleStream<geometryOutput> triStream)
{
geometryOutput o;
// triangle's normals
//float3 N1 = normalize(cross(IN[0].worldPos - IN[1].worldPos, IN[3].worldPos - IN[1].worldPos));
//float3 N2 = normalize(cross(IN[2].worldPos - IN[1].worldPos, IN[0].worldPos - IN[1].worldPos));
float3 N1 = normalize(cross(IN[0].vertex.xyz - IN[1].vertex.xyz, IN[3].vertex.xyz - IN[1].vertex.xyz));
float3 N2 = normalize(cross(IN[2].vertex.xyz - IN[1].vertex.xyz, IN[0].vertex.xyz - IN[1].vertex.xyz));
float3 worldN1 = UnityObjectToWorldNormal(N1);
float3 worldN2 = UnityObjectToWorldNormal(N2);
// triangles's barycentric
float3 barycentric1 = (IN[0].worldPos + IN[1].worldPos + IN[3].worldPos) / 3;
float3 barycentric2 = (IN[0].worldPos + IN[1].worldPos + IN[2].worldPos) / 3;
// viewDir
float3 viewDir1 = normalize(_WorldSpaceCameraPos.xyz - barycentric1);
float3 viewDir2 = normalize(_WorldSpaceCameraPos.xyz - barycentric2);
// if silhouette
float eyeDotN1 = dot(viewDir1, worldN1);
float eyeDotN2 = dot(viewDir2, worldN2);
if(eyeDotN1 * eyeDotN2 < 0 || abs(eyeDotN1) < _FinThreshold || abs(eyeDotN2) < _FinThreshold)
{
o.vertex = UnityObjectToClipPos(IN[0].vertex);
o.uv = TRANSFORM_TEX(IN[0].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(N2);
o.worldPos = IN[0].worldPos;
o.index = 0;
triStream.Append(o);
o.vertex = UnityObjectToClipPos(IN[1].vertex);
o.uv = TRANSFORM_TEX(IN[1].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(N2);
o.worldPos = IN[1].worldPos;
o.index = 0;
triStream.Append(o);
fixed3 pos = IN[0].vertex.xyz + N2 * _FinLength;
o.vertex = UnityObjectToClipPos(fixed4(pos, 1.0));
o.uv = TRANSFORM_TEX(IN[0].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(N2);
o.worldPos = mul(unity_ObjectToWorld, float4(pos, 1.0)).xyz;
o.index = 1;
triStream.Append(o);
pos = IN[1].vertex.xyz + N2 * _FinLength;
o.vertex = UnityObjectToClipPos(fixed4(pos, 1.0));
o.uv = TRANSFORM_TEX(IN[1].uv, _MainTex);
o.worldNormal = UnityObjectToWorldNormal(N2);
o.worldPos = mul(unity_ObjectToWorld, float4(pos, 1.0)).xyz;
o.index = 1;
triStream.Append(o);
triStream.RestartStrip();
}
}
同时,为鳍状体使用和shell外壳类似的边缘光照:
// FinsFragmentShader
fixed4 finsFrag(geometryOutput i) : SV_Target
{
fixed alpha = tex2D(_FurNoiseTex, i.uv * _NoiseMultiplier).r;
fixed3 col = tex2D(_MainTex, i.uv).rgb- pow(1 - (i.index +1) * 0.1, 3) * 0.1;
fixed3 viewDir = normalize(_WorldSpaceCameraPos.xyz - i.worldPos);
half rim = 1.0 - saturate(dot(viewDir, i.worldNormal));
col += pow(rim, _RimPower);
alpha = clamp(alpha - pow((i.index+1) * 0.1, 2) * _FurDensity, 0, 1);
return fixed4(col, alpha);
}
效果:
注意鳍状体不要太长,要小于shell外壳的长度。
之后,我会尝试为毛发添加一些动力学,即让其跟随物体运动。我找到比较好的方法后会发文分享。
更新
目前找到了一个可行的办法来添加一点毛发的动力学。
主要做法在于获得模型在前一帧的模型变换矩阵,来获得其点在前一帧的世界位置。
接下来我们为相关结构体添加之前世界位置的属性:
struct vertexOutput
{
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
float3 previousWorldPos : TEXCOORD3;
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 normal : NORMAL;
};
struct geometryOutput
{
float3 worldNormal : TEXCOORD1;
float3 worldPos : TEXCOORD2;
float3 previousWorldPos : TEXCOORD4;
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
int index : TEXCOORD3;
};
然后我们可以在shell外壳的几何着色器中获得一个顶点运动的方向,将重力方向减去该运动方向就可以得到顶点随物体运动方向的一个模拟,我们可以编写一个C#脚本获得当前模型的模型矩阵,并将其传入材质中:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SendMatrix : MonoBehaviour
{
Matrix4x4 previousModelMatrix;
Matrix4x4 currentModelMatrix;
public Material material;
void Start()
{
currentModelMatrix = transform.localToWorldMatrix;
previousModelMatrix = currentModelMatrix;
}
void FixedUpdate()
{
Renderer rend = GetComponent<Renderer>();
previousModelMatrix = currentModelMatrix;
currentModelMatrix = transform.localToWorldMatrix;
material.SetMatrix("_PreviousFrameModelMatrix", previousModelMatrix);
material.SetMatrix("_CurrentFrameInverseModelMatrix", currentModelMatrix.inverse);
material.SetMatrix("_CurrentFrameModelMatrix", currentModelMatrix);
Debug.Log(previousModelMatrix);
}
}
经我的测试,由于某些原因,外部得到的模型矩阵与自动传入shader的模型矩阵好像存在一定的差距,可能是帧率的原因,因此我自己传入了三个矩阵,只用于顶点运动方向的计算,其余时刻仍使用自动传入shader的模型矩阵计算相关位置。几何着色器中:
float3 pos = IN[j].vertex.xyz + IN[j].normal * _FurLength * i * 0.1;
float4 gravity = float4(0,-1,0,0) * (1 -_Rigidness);
float4 vertexMotionDir = float4(mul(_CurrentFrameModelMatrix, IN[j].vertex).xyz - IN[j].previousWorldPos, 0) * (1 -_Rigidness);
float4 MotionDir = gravity - vertexMotionDir;
pos += clamp(mul(_CurrentFrameInverseModelMatrix, MotionDir).xyz, -1, 1) * pow(i * 0.1, 3) * _FurLength;
当然,这实现的只是最简单的动态,只是让毛能够随物体动,但更真实的效果需要体现其的轻柔材质,这需要一定的运动缓冲,即得到平滑曲线的运动效果,这个目前还在研究中。
项目代码已经更新,欢迎大家查阅。
项目的github地址放这里,大家可以查阅:https://github.com/Dragon-Baby/Hair-Material
网友评论