本文为个人整理的笔记。

NahidaBase片元着色器编写
准备上下文
- 获取主光源
Light light = GetMainLight(i.shadowCoord);
准备常用向量
注:以下准备的向量均需要在世界空间,且向量的起点均需要是物体表面
- 解包法线贴图
- 从法线贴图中解析出切线空间法线
- 法线贴图导入的类型是
bump,它的信息存放在ag通道 normalMap.ag乘2减1,得到法线xy分量- z分量根据勾股定理
- 法线贴图导入的类型是
- 为得到世界空间法线:对切线空间法线应用TBN矩阵变换
float4 normalMap = tex2D(_NormalMap, i.uv);
float3 normalTS = float3(normalMap.ag * 2 - 1, 0);
normalTS.z = sqrt(saturate(1 - dot(normalTS.xy, normalTS.xy)));
float3 N = normalize(mul(normalTS, float3x3(i.tangentWS, i.bitangentWS, i.normalWS)));
- 为得到视角向量:使用视图矩阵的逆矩阵
UNITY_MATRIX_I_V,变换相机空间位置向量的反向量(i.positionVS * (-1)) - 光线向量:
light.direction就是从物体表面指向光源的向量 - 半角向量:
nomalize(L + V);
float3 V = normalize(mul((float3x3)UNITY_MATRIX_I_V, i.positionVS * (-1)));
float3 L = normalize(light.direction);
float3 H = normalize(L + V);
准备常用点乘
- NoL:Lambert
- NoH:Blinn-Phong
- NoV:Fresnel
float NoL = dot(N, L);
float NoH = dot(N, H);
float NoV = dot(N, V);
准备一个采样MatCap的UV
- 把世界空间法线变换到相机空间,只需取其xy分量,从[-1,1]映射到[0,1]
- 其实就是法线在屏幕上的朝向
float3 normalVS = normalize(mul((float3x3)UNITY_MATRIX_V, N));
float2 matcapUV = normalVS.xy * 0.5 + 0.5;
baseColor
可以预设一些参数方便我们后续调色
这里沿用MMD的调色方案:基础纹理、卡通纹理、球面纹理。其中卡通纹理和球面纹理是MatCap纹理
float4 baseTex = tex2D(_BaseTex, i.uv);
float4 toonTex = tex2D(_ToonTex, matcapUV);
float4 sphereTex = tex2D(_SphereTex, matcapUV);
baseColor初始值为环境光颜色- 加上漫反射颜色,与环境光颜色0.6:0.4混合,钳制一下
- 乘以基础纹理
- 乘以卡通纹理(使法线朝下的区域变暗)
- 乘或者加上球面纹理
- 上述都给一个参数进行混合处理
float3 baseColor = _AmbientColor.rgb;
baseColor = saturate(lerp(baseColor, baseColor + _DiffuseColor.rgb, 0.6));
baseColor = lerp(baseColor, baseColor * baseTex.rgb, _BaseTexFac);
baseColor = lerp(baseColor, baseColor * toonTex.rgb, _ToonTexFac);
baseColor = lerp(lerp(baseColor, baseColor * sphereTex.rgb, _SphereTexFac), lerp(baseColor, baseColor + sphereTex.rgb, _SphereTexFac), _SphereMulAdd);
使用魔法补充细节
下图为魔法贴图的RGBA四通道。
可见:R通道为高光枚举、G通道为光影、B通道为高光细节图、A通道灰度值代表Ramp枚举

Ramp枚举阴影色
下图为Ramp图,共10行。
上5行较暖,为白天阴影色;下5行较冷,为夜晚阴影色

- 先列出A通道的5个枚举值:0.0、0.3、0.5、0.7、1.0
- Ramp图的行数与枚举值并不是按照同样的顺序对应的,比如皮肤枚举值为1.0,但在Ramp图里为第2行而不是第5行
- 我们以A通道枚举值顺序为准。所以属性中需要用5个参数定义5个枚举值分别对应哪一行
- 得到白天Ramp的每一行的v坐标
- 若以左上角为uv原点,则用
目标行数/10.0 - 0.05得到v坐标。最后采样时记得要用1减去。
- 若以左上角为uv原点,则用
float4 ilm = tex2D(_ILM, i.uv);
float matEnum0 = 0.0;
float matEnum1 = 0.3;
float matEnum2 = 0.5;
float matEnum3 = 0.7;
float matEnum4 = 1.0;
float ramp0 = _RampMapRow0 / 10.0 - 0.05;
float ramp1 = _RampMapRow1 / 10.0 - 0.05;
float ramp2 = _RampMapRow2 / 10.0 - 0.05;
float ramp3 = _RampMapRow3 / 10.0 - 0.05;
float ramp4 = _RampMapRow4 / 10.0 - 0.05;
- 用
step和lerp配合判断枚举了白天哪一行- 方法:用
step(ilm.a, (matEnum3 + matEnum4)/2)判断Ramp值接近枚举3还是枚举4,如果Ramp值接近matEnum3则返回1。将结果作为权重用lerp插值ramp4和ramp3。 - 由大到小重复用上述方法判断大小
- 方法:用
- 夜晚的值,直接在上一步判断出的白天v值上加0.5即可
float dayRampV;
dayRampV = lerp(ramp4, ramp3, step(ilm.a, (matEnum3 + matEnum4) / 2));
dayRampV = lerp(dayRampV, ramp2, step(ilm.a, (matEnum2 + matEnum3) / 2));
dayRampV = lerp(dayRampV, ramp1, step(ilm.a, (matEnum1 + matEnum2) / 2));
dayRampV = lerp(dayRampV, ramp0, step(ilm.a, (matEnum0 + matEnum1) / 2));
float nightRampV = dayRampV + 0.5;
光影
看魔法图的G通道。
- 黑色的部分:是完全的暗部不受光影影响,一般是衣服或者头发里的褶皱部分,叫它黑色阴影。
- 0.5灰色部分:被光照射视为亮部,没有光照射视为暗部,叫它灰色阴影。
- 白色部分:是完全的亮部,也不受光照影响。
先看比较复杂的==灰色阴影==,这部分需要求RampUV的U分量用来采样Ramp图。
- 通常用lambert区分亮面和暗面
- 但是考虑到Ramp图的变化都集中在右边,lambert在那一块几乎全是白色,可采样的额度很小
- 这里要用Half-Lambert,也就是Lambert的优化版本:
- 注:通常来说,也会把半兰伯特的这个幂系数作为一个参数控制
- 这里要用Half-Lambert,也就是Lambert的优化版本:
- 把半兰伯特smoothstep一下,上下界自己感受(此处用[0.2,0.4])
- 回顾smoothstep(a,b,x)的作用:如果x处于[min,max]则返回[0,1]之间的平滑值,否则钳制在0或1。此函数常用于贴图采样,因为返回的结果是[0,1]之间的
- 为避免采样到边界以外,需要给它一把钳子,把它用clamp(x,a,b)钳制在[0.003,0.997]之间,否则可能超出边界
- 此时就可以拼接灰色阴影区域的白天和夜晚的Ramp UV
- 由于之前假设UV原点是左上角,因此V要用1减一下。
float lambert = max(0, NoL);
float halflambert = pow(lambert * 0.5 + 0.5, 2);
float lambertStep = smoothstep(0.423, 0.450, halflambert);
float rampClampMin = 0.003;
float rampClampMax = 0.997;
float rampGrayU = clamp(smoothstep(0.2, 0.4, halflambert), rampClampMin, rampClampMax);
float2 rampGrayDayUV = float2(rampGrayU, 1 - dayRampV);
float2 rampGrayNightUV = float2(rampGrayU, 1 - nightRampV);

- 黑色阴影的V坐标跟灰色阴影的一样。U坐标直接用钳子的最小值就行
- 同上,拼接黑色阴影区域的白天和夜晚的Ramp UV
isDay:用光向量的y分量,来判断白天还是夜晚,映射到[0,1],后面用来插值- 正式采样灰色阴影和黑色阴影的Ramp颜色。
- 使用
isDay插值夜晚UV和白天UV的_RampTex采样结果 - 注意!必须把Ramp贴图的Mipmap关掉!否则离远了会采样到近邻层颜色!
- 使用
float rampDarkU = rampClampMin;
float2 rampDarkDayUV = float2(rampDarkU, 1 - dayRampV);
float2 rampDarkNightUV = float2(rampDarkU, 1 - nightRampV);
float isDay = (L.y + 1) / 2;
float3 rampGrayColor = lerp(tex2D(_RampTex, rampGrayNightUV), tex2D(_RampTex, rampGrayDayUV), isDay);
float3 rampDarkColor = lerp(tex2D(_RampTex, rampDarkNightUV), tex2D(_RampTex, rampDarkDayUV), isDay);
接下来是混合。
baseColor乘以rampColor乘以我们自己设的_ShadowColor,得到最终的两个暗部的颜色。- 先用Half-Lambert插值混合灰色阴影和亮部
- 需要在前面再定义一个
float lambertStep = smoothstep(0.423, 0.450, halflambert);,用于插值。这个数值范围就是阴影大过渡区域的范围,这里使用[0.423,0.450]范围
- 需要在前面再定义一个
- 把魔法的g通道乘以2,再把超过1的部分用saturate截断。
- 此时0为黑色部分,1为灰色部分和常亮部分,用来在黑色阴影和diffuse之间插值混合
- 把魔法的g通道减0.5再乘以2,再把超出[0,1]的部分用saturate截断。
- 此时0为黑色部分和灰色部分,1为常亮部分,用来在diffuse和baseColor之间插值混合
- 调整每个材质的
_ShadowColor属性,还原渲染效果
float3 grayShadowColor = baseColor * rampGrayColor * _ShadowColor.rgb;
float3 darkShadowColor = baseColor * rampDarkColor * _ShadowColor.rgb;
float3 diffuse = 0;
diffuse = lerp(grayShadowColor, baseColor, lambertStep);
diffuse = lerp(darkShadowColor, diffuse, saturate(ilm.g * 2));
diffuse = lerp(diffuse, baseColor, saturate(ilm.g - 0.5) * 2);

高光
有了阴影细节,还缺少高光和金属细节来体现材质的质感。高光分为金属高光和非金属高光。
看魔法图的r通道,它是高光的枚举。较亮的是金属。
再看魔法图的b通道,它是高光的形状或者说细节。
- 以Blinn-Phong模型为基础高光
- 写法:先用
step(0,NoL)判断是否被光照射,乘上pow(max(0, NoH), _SpecExpon)
- 写法:先用
- 先做非金属高光(注意,是float3,便于之后插值混合颜色)
- 先判断是否高光:对blinnPhong取反,如果魔法b通道(高光形状)大于该值,则为高光区域
- 乘上魔法r通道(高光强度),再乘上我们自己添加的高光强度
_KsNonMetallic
- 金属高光(也是float3)
- 直接用blinnPhong乘以魔法b通道(高光形状)
- 再乘以lambertStep,让它根据光的方向有个衰减,只有lambertStep大于0才有高光。当然也可以自己调整一下,这里把它的下限加0.2
- 光滑的非金属表面是不吸收颜色的,但是金属会吸收颜色。所以金属颜色是要再乘以baseColor的!
- 再乘上自定义强度
_KsMetallic
- 魔法r通道为1的是金属,step提取出金属区域
isMetal - 把非金属和金属高光用
isMetal混合,得到最终高光
float blinnPhong = step(0, NoL) * pow(max(0, NoH), _SpecExpon);
float3 nonMetallicSpec = step(1.04 - blinnPhong, ilm.b) * ilm.r * _KsNonMetallic;
float3 metallicSpec = blinnPhong * ilm.b * (lambertStep * 0.8 + 0.2) * baseColor * _KsMetallic;
float isMetal = step(0.82, ilm.r);
float3 specular = lerp(nonMetallicSpec, metallicSpec, isMetal);

- 金属材质还有一种镜面反光细节
- 这里直接用一张MatCap纹理来补充。跟卡通纹理和球形纹理一样,用matcapUV来采样它
- 乘上baseColor
- 用
isMetal,在0和上述结果之间插值
float3 metallic = lerp(0, tex2D(_MetalTex, matcapUV).r * baseColor, isMetal);

最终混合
- 最后把阴影(diffuse)、高光(specular)、金属(metallic)加起来,构成反照率(albedo)
- 处理alpha透明通道
float alpha = _Alpha * baseTex.a * toonTex.a * sphereTex.a;- 不对,这个baseTex.a应该是发光元件而不是透明度吧?
- 用
max(IsFacing, _DoubleSided)判断是否渲染当前面- 其中
IsFacing是片元着色器额外传入的参数:bool IsFacing : SV_IsFrontFace _DoubleSided为是否双面
- 其中
- 再用min与alpha比取最小值
- 截断到[0,1]
- 组合 albedo 和 alpha,得到最终颜色 col
- 剔除 alpha 值小于 0.5 的片元
- 加上雾效:
col.rgb = MixFog(col.rgb, i.fogCoord);
float3 albedo = diffuse + specular + metallic;
float alpha = _Alpha * toonTex.a * sphereTex.a;
alpha = saturate(min(max(IsFacing, _DoubleSided), alpha));
float4 col = float4(albedo, alpha);
clip(col.a - 0.5);
col.rgb = MixFog(col.rgb, i.fogCoord);

NahidaFace脸部片元着色器编写
脸部shader比身体简单不少,没有复杂的ramp采样和高光。
准备上下文
- 跟身体一样,准备好上下文、MatCapUV、基础颜色。直接copy过来
float3 N = normalize(i.normalWS);
float3 V = normalize(mul((float3x3)UNITY_MATRIX_I_V, i.positionVS * (-1)));
float3 L = normalize(light.direction);
float NoV = dot(N, V);
float3 normalVS = normalize(mul((float3x3)UNITY_MATRIX_V, N));
float2 matcapUV = normalVS.xy * 0.5 + 0.5;
// baseColor
float4 baseTex = tex2D(_BaseTex, i.uv);
float4 toonTex = tex2D(_ToonTex, matcapUV);
float4 sphereTex = tex2D(_SphereTex, matcapUV);
float3 baseColor = _AmbientColor.rgb;
baseColor = saturate(lerp(baseColor, baseColor + _DiffuseColor.rgb, 0.6));
baseColor = lerp(baseColor, baseColor * baseTex.rgb, _BaseTexFac);
baseColor = lerp(baseColor, baseColor * toonTex.rgb, _ToonTexFac);
baseColor = lerp(lerp(baseColor, baseColor * sphereTex.rgb, _SphereTexFac), lerp(baseColor, baseColor + sphereTex.rgb, _SphereTexFac), _SphereMulAdd);
- 确保脸部阴影颜色与皮肤保持一致,也需要Ramp图上采样Ramp颜色
- 直接指定采样哪一行就行,皮肤一般是第2行
- 行数除以10减去0.05得到V坐标
- U坐标直接取钳子的最小值,防止采样到边界
- Unity里还要把V坐标反向
- 混合白天和夜晚
float rampV = _RampRow / 10 - 0.05;
float rampClampMin = 0.003;
float2 rampDayUV = float2(rampClampMin, 1 - rampV);
float2 rampNightUV = float2(rampClampMin, 1 - (rampV + 0.5));
float isDay = (L.y + 1) / 2;
float3 rampColor = lerp(tex2D(_RampTex, rampNightUV).rgb, tex2D(_RampTex, rampDayUV), isDay);
面部SDF
用SDF图区分亮部暗部。这个网上讲得不少,但实际做的时候很多细节问题没有处理好,这里彻底讲一遍。
下图为少女体型的面部SDF图。

==思想==:求得一个全脸统一的光照值,然后SDF图中大于这个光照值的部分即为亮部、小于即为暗部。
==流程简述==:先计算出光照在角色脑袋水平面上的投影方向,然后用这个光方向点乘指向脑袋右方的向量rightVec,再acos一下得到均匀角度变化,以左右脸中线为分界线,映射到1~0~1的范围,根据照射的是右脸还是左脸决定正向还是反向采样sdf图,最后用step(mixValue,mixSdf)(也就是SDF贴图大于mixValue时为1,对应直射分界线时最亮)求得sdf,记得让后脑勺直接变黑。

- 需要一个指向脑袋右方的向量
_RightVector和一个指向脑袋前方的向量_ForwardVector,作为材质参数传进来- 坑①:有的人可能会直接用模型空间float3(0,0,1)、float3(1,0,0),用矩阵
UNITY_MATRIX_M变化到世界空间。但如果角色头部骨骼的初始旋转变换不是(0,0,0),或者角色有转头动作,就会出错。 - 注意!模型空间是整个Mesh Renderer的空间,骨骼不会影响模型空间位置!因此,需要把骨骼的旋转变换同步传递给shader。
- 坑①:有的人可能会直接用模型空间float3(0,0,1)、float3(1,0,0),用矩阵

float3 forwardVec = _ForwardVector;
float3 rightVec = _RightVector;
坑②:有的人会只取forwardVec.xz和L.xz分量计算点积,即只在水平面上计算,这会导致人物在跳水、翻跟斗时forwardVec.xz和L.xz朝向一致而误判为照亮!就算按照接下来的写法写也不能直接取光向量的L.xz分量,因为如果人物向前向后倾,由于缺乏对人物头部指向方向的认知,相当于(L.x, 0, L.z)永远不变,但rightVec一直在变化,直接dot(L, rightVec)计算,会导致无法预料的错误。
正确的方法是:得到光向量在forwardVec、rightVec组成的平面上的投影向量,用这个当作新的光向量计算。(我们做脸部左右分边的 SDF 阴影,本身只有左右方向上变化,只需要光照在「脸部左右环绕方向」的信息,完全不需要光照「上下俯仰」的信息)


所以应该这样做:
- Unity是左手坐标系。为得到上向量
upVector,先用右向量叉乘前向量 - 要得到光向量在上向量上的投影向量
LpU- 公式:
- 公式:
- 再用灯光向量减去这个投影向量,得到灯光向量在前向量和右向量组成的平面上的投影向量
LpHeadHorizon- 在这个空间下计算SDF,无论脑袋怎么转都不会出错了

float3 upVector = cross(rightVec, forwardVec);
// float3 LpU = length(L) * (dot(L, upVector) / (length(L) * length(upVector))) * (upVector / length(upVector));
float3 LpU = dot(L, upVector) / pow(length(upVector), 2) * upVector; // 化简上式
float3 LpHeadHorizon = L - LpU;
把新得到的LpHeadHorizon作为新的灯光向量做后续计算。
- 拿右向量
rightVec点乘灯光向量,要先规格化 - 做反余弦,得到光线和脸右侧的夹角。除以pi,得到[0,1]的映射值
- 此时[0,0.5]时光线照在右侧,[0.5,1]时光线照在左侧
- Q:为什么要做反余弦而不是直接用点乘结果,点乘结果范围不是已经是[-1,1]了吗?A:因为我们想做角度均匀变化,比如说0度时值为0、90度时值为0.5,那45度时值就应该为0.5,但点乘结果其实是cos值,需要反余弦才能变均匀。
- 用
step(value, 0.5)来区分光线照在左脸还是右脸,得到exposeRight- 这个
exposeRight只会有两个值:光照在右脸为1,光照在左脸为0
- 这个
float pi = 3.14159265358979323;
float value = acos(dot(normalize(LpHeadHorizon), normalize(rightVec))) / pi;
// 0 ~ 0.5, expose right, 0.5 ~ 1, expose left
float exposeRight = step(value, 0.5); // 光在右脸为1,光在左脸为0
sdf图大于我们的映射值时,应该判定为亮。
上方视频 即为我们对映射值的期望,在正中央是为0,sdf图全大于这个值,脸全亮。
- 需要把刚才的映射值
value重新映射一下,从 0~0.5~1 映射到 1~0~1- 为实现这个映射,可以先求
valueR(从[0,1]映射到[1,-1])、valueL(从[0,1]映射到[-1,1]),再从左右分界线结合一下,也就是用exposeRight插值混合左和右,得到value在[0,1]范围内值为 1~0~1,称之为mixValue。参考下图 - 注意,这个value是全脸统一的值!到时候SDF灰度比它大就是亮部
- 为实现这个映射,可以先求

// right: 1 ~ 0
float valueR = pow(1 - value * 2, 3);
// left: 0 ~ 1
float valueL = pow(value * 2 - 1, 3);
float mixValue = lerp(valueL, valueR, exposeRight);
参考上图。当光照在右脸时,伦勃朗光会出现在左脸。SDF上的伦勃朗光在右脸。
因此,以伦勃朗光的位置为基准,判定正向还是反向采样SDF贴图。
- 当伦勃朗光在右脸时,光照在左脸,正向采样SDF
- 当伦勃朗光在左脸时,光照在右脸,把u方向反过来采样
- 由于同时只能从一遍进行采样,所以用
exposeRight插值混合- 注意,顺序是右和左!因为光照在右脸时,伦勃朗光在左脸,应当反向采样
float sdfRenbrandLeft = tex2D(_SDF, float2(1 - i.uv.x, i.uv.y)).r;
float sdfRenbrandRight = tex2D(_SDF, i.uv).r;
float mixSdf = lerp(sdfRenbrandRight, sdfRenbrandLeft, exposeRight); // 判断采样方向,同时只会从一个方向采样!
- 最后比较
mixValue和SDF灰度值,灰度值大于value的就是亮面 - 由于value是用 acos 求得,而acos得不到大于180度的值,所以得不到光照在后脑勺时的情况。参考上图。
- 很简单,只要在最后让光照在脑袋后时sdf为0就行。
- 方法:用
step(0, dot(normalize(LpHeadHorizon), normalize(forwardVec)))得出光照在脑袋前还是脑袋后,然后用它插值0和sdf就好了
float sdf = step(mixValue, mixSdf);
sdf = lerp(0, sdf, step(0, dot(normalize(LpHeadHorizon), normalize(forwardVec))));
- 由于阴影在光线近直射的时候变化较快,脸部几乎很难全亮,所以在映射
value到valueR和valueL后,再用pow变三次方或四次方,让它在靠近0的时候更靠近0
最后,需要传入骨骼旋转变换的参数,有两种办法。
法一:
直接用C#脚本获取head骨骼的Transform,通过观察控制柄朝向,得出前向和右向。
// 获取头部骨骼的世界空间方向
Vector3 worldForward = headBone.up;
Vector3 worldRight = - headBone.forward;
Debug.Log(worldForward + ", " + worldRight);
// 传给 Shader 变量
faceMaterial.SetVector("_ForwardVector", worldForward);
faceMaterial.SetVector("_RightVector", worldRight);
法二:
在head骨骼下创建2个空物体,用来定位前向量和后向量,在Global坐标系下,分别往前和往右移动,再用C#脚本通过减法求得前向和右向。

最终混合
阴影遮罩贴图:Avatar_Tex_Face_Shdow
R、G通道是一样的都是阴影遮罩。白色代表受sdf控制的区域,黑色代表不受sdf控制。
A通道控制阴影亮度,黑色代表完全阴影,白色代表常亮。
- 采样
_ShadowTex - sdf直接乘上g通道
- 用a通道插值sdf和1
float4 shadowTex = tex2D(_ShadowTex, i.uv);
sdf *= shadowTex.g;
sdf = lerp(sdf, 1, shadowTex.a);
- 最终的阴影颜色 = 基础颜色 × ramp颜色 × 我们自定义的阴影颜色
- 最终的漫反射 = lerp(阴影颜色, 基础颜色, sdf)
float3 shadowColor = baseColor * rampColor * _ShadowColor.rgb;
float3 diffuse = lerp(shadowColor, baseColor, sdf);
float3 albedo = diffuse;
float alpha = _Alpha * toonTex.a * sphereTex.a;
alpha = saturate(min(max(IsFacing, _DoubleSided), alpha));
float4 col = float4(albedo, alpha);
clip(col.a - 0.5);
col.rgb = MixFog(col.rgb, i.fogCoord);
return col;

描边
卡通人物身上都是大块大块的颜色,如果没有描边,看上去会觉得很多部位都连在一起。
需要用轮廓边来勾画相同色块下不同部位的分界。
Unity直接多写一个Pass用来画描边
- 剔除正面
- 把顶点沿着法线方向进行偏移
- 注意!偏移量要乘上顶点色a通道,原神的模型会把描边强度存储在顶点色a通道
- 描边颜色可以像之前一样枚举ilm.a,定义5个颜色
Pass
{
Name "DrawOutline"
Tags {
"RenderPipeline" = "UniversalPipeline"
"RenderType" = "Opaque"
}
Cull Front
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
half3 normal : NORMAL;
half4 tangent : TANGENT;
half4 color : COLOR;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 positionCS : SV_POSITION;
float fogCoord : TEXCOORD1;
};
CBUFFER_START(UnityPerMaterial)
sampler2D _BaseTex;
float4 _BaseTex_ST;
sampler2D _ILM;
float4 _OutlineMapColor0;
float4 _OutlineMapColor1;
float4 _OutlineMapColor2;
float4 _OutlineMapColor3;
float4 _OutlineMapColor4;
float _OutlineOffset;
CBUFFER_END
v2f vert (appdata v)
{
v2f o;
// VertexPositionInputs vertexInput = GetVertexPositionInputs(v.vertex.xyz + v.normal.xyz * _OutlineOffset * v.color.a);
VertexPositionInputs vertexInput = GetVertexPositionInputs(v.vertex.xyz + v.tangent.xyz * _OutlineOffset * v.color.a);
o.uv = TRANSFORM_TEX(v.uv, _BaseTex);
o.positionCS = vertexInput.positionCS;
o.fogCoord = ComputeFogFactor(vertexInput.positionCS.z);
return o;
}
float4 frag (v2f i, bool IsFacing : SV_IsFrontFace) : SV_TARGET
{
float4 ilm = tex2D(_ILM, i.uv);
float matEnum0 = 0.0;
float matEnum1 = 0.3;
float matEnum2 = 0.5;
float matEnum3 = 0.7;
float matEnum4 = 1.0;
float4 color = lerp(_OutlineMapColor4, _OutlineMapColor3, step(ilm.a, (matEnum3 + matEnum4) / 2));
color = lerp(color, _OutlineMapColor2, step(ilm.a, (matEnum2 + matEnum3) / 2));
color = lerp(color, _OutlineMapColor1, step(ilm.a, (matEnum1 + matEnum2) / 2));
color = lerp(color, _OutlineMapColor0, step(ilm.a, (matEnum0 + matEnum1) / 2));
float3 albedo = color.rgb;
float4 col = float4(albedo, 1);
col.rgb = MixFog(col.rgb, i.fogCoord);
return col;
}
ENDHLSL
}
直接描边会在一些锐利的末端产生明显断裂,需要平滑法线。

可以写一个简单的平滑法线C#脚本,把同位置的顶点法线直接相加,把结果存到切线里,然后在描边Pass里用切线扩顶点。
也可以做更好的加权平滑法线。
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class SmoothNormal : MonoBehaviour
{
private void Awake()
{
Mesh mesh = GetComponent<SkinnedMeshRenderer>().sharedMesh;
IEnumerable<IEnumerable<KeyValuePair<Vector3, int>>> groups = mesh.vertices.Select((vertex, index) => new KeyValuePair<Vector3, int>(vertex, index)).GroupBy(pair => pair.Key);
Vector3[] normals = mesh.normals;
Vector4[] smoothNormals = normals.Select((normal, index) => new Vector4(normal.x, normal.y, normal.z)).ToArray();
foreach (IEnumerable<KeyValuePair<Vector3, int>> group in groups)
{
if (group.Count() == 1)
{
continue;
}
Vector3 smoothNormal = Vector3.zero;
foreach (KeyValuePair<Vector3, int> pair in group)
{
smoothNormal += normals[pair.Value];
}
smoothNormal.Normalize();
foreach (KeyValuePair<Vector3, int> pair in group)
{
smoothNormals[pair.Value] = new Vector4(smoothNormal.x, smoothNormal.y, smoothNormal.z);
}
}
mesh.tangents = smoothNormals;
}
}
屏幕空间边缘光
==思想==:获得当前屏幕坐标,按观察空间法线方向偏移一段距离,分别采样当前位置的深度和偏移位置的深度,如果深度差大于最小深度差阈值(代表偏移位置不是衣服),则有边缘光,可以拿深度差作为强度。
- 使用NDC坐标进行透视除法得到屏幕UV坐标
- 采样当前深度,转换成线性深度
- 原神的边缘光只出现在角色左右两侧,上下没有,只需要做水平偏移
- 为获得偏移方向,取观察空间法线的x分量,大于0取1、小于0取-1
- 乘偏移像素量(边缘光宽度)
- 除以屏幕宽度,得到屏幕UV偏移量
- 如果想要近大远小,可以除以当前深度或深度的平方,max到1防止变小数偏移过大
- y分量不需要偏移
- 采样偏移位置的深度,转换成线性深度
- 求深度差,钳制在[0,1]
- 在前面设置最小深度差阈值、强度、最大值
- 用阈值判断是否有边缘光,乘上深度差乘强度,钳制在[0,最大值]
......
float3 albedo = diffuse + specular + metallic;
float rimOffset = 6;
float rimThreshold = 0.03;
float rimStrength = 0.6;
float rimMax = 0.3;
float2 screenUV = i.positionNDC.xy / i.positionNDC.w; // 透视除法计算屏幕UV
float rawDepth = SampleSceneDepth(screenUV); // 非线性深度
float linearDepth = LinearEyeDepth(rawDepth, _ZBufferParams); // 变成线性深度(单位:米)
float2 screenOffset = float2(lerp(-1, 1, step(0, normalVS.x)) * rimOffset / _ScreenParams.x / max(1, pow(linearDepth, 2)), 0);
float offsetDepth = SampleSceneDepth(screenUV + screenOffset);
float offsetLinearDepth = LinearEyeDepth(offsetDepth, _ZBufferParams);
float rim = saturate(offsetLinearDepth - linearDepth); // 深度差
rim = step(rimThreshold, rim) * clamp(rim * rimStrength, 0, rimMax);
由于是用深度差计算的,因此观感上有一种半透明的效果,就像能看到后面的东西一样。
如果觉得边缘太硬,也可以用菲涅尔软化一下。
- 设置菲涅尔幂值、钳子
- 计算菲涅尔:
1 - saturate(NoV),做幂次 - 用钳子算出最终菲涅尔(从[0,1]映射到[fresnelClamp, 1]):
fresnel * fresnelClamp + (1 - fresnelClamp) - 最后用滤色混合albedo
float fresnelPower = 6;
float fresnelClamp = 0.8;
float fresnel = 1 - saturate(NoV);
fresnel = pow(fresnel, fresnelPower);
fresnel = fresnel * fresnelClamp + (1 - fresnelClamp);
albedo = 1 - (1 - rim * fresnel) * (1 - albedo);

投影
Unity里可以很方便地获取ShadowMap,直接在shadowColor用light.shadowAttenuation插值就可以。
float3 diffuse = 0;
diffuse = lerp(grayShadowColor, baseColor, lambertStep);
diffuse = lerp(darkShadowColor, diffuse, saturate(ilm.g * 2));
diffuse = lerp(darkShadowColor, diffuse, light.shadowAttenuation); // 添加这一句
diffuse = lerp(diffuse, baseColor, saturate(ilm.g - 0.5) * 2);
可以把nonMetallicSpec和metallicSpec也都乘上light.shadowAttenuation。
float3 nonMetallicSpec = step(1.04 - blinnPhong, ilm.b) * ilm.r * _KsNonMetallic * light.shadowAttenuation;
float3 metallicSpec = blinnPhong * ilm.b * (lambertStep * 0.8 + 0.2) * baseColor * _KsMetallic * light.shadowAttenuation;

后处理
最后来点内置全局屏幕后处理配方。

发表回复