URP 原神纳西妲角色渲染还原(养乐多佬教程)

本文为个人整理的笔记。

NahidaBase片元着色器编写

准备上下文

  1. 获取主光源
Light light = GetMainLight(i.shadowCoord);

准备常用向量
注:以下准备的向量均需要在世界空间,且向量的起点均需要是物体表面

  1. 解包法线贴图
  2. 从法线贴图中解析出切线空间法线
    1. 法线贴图导入的类型是 bump,它的信息存放在ag通道
    2. normalMap.ag乘2减1,得到法线xy分量
    3. z分量根据勾股定理

      x2+y2+z2=1

      算出来,记得先saturate再开方

  3. 为得到世界空间法线:对切线空间法线应用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)));
  1. 为得到视角向量:使用视图矩阵的逆矩阵UNITY_MATRIX_I_V,变换相机空间位置向量的反向量(i.positionVS * (-1)
  2. 光线向量:light.direction就是从物体表面指向光源的向量
  3. 半角向量: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);

准备常用点乘

  1. NoL:Lambert
  2. NoH:Blinn-Phong
  3. NoV:Fresnel
float NoL = dot(N, L);
float NoH = dot(N, H);
float NoV = dot(N, V);

准备一个采样MatCap的UV

  1. 把世界空间法线变换到相机空间,只需取其xy分量,从[-1,1]映射到[0,1]
    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);
  1. baseColor初始值为环境光颜色
  2. 加上漫反射颜色,与环境光颜色0.6:0.4混合,钳制一下
  3. 乘以基础纹理
  4. 乘以卡通纹理(使法线朝下的区域变暗)
  5. 乘或者加上球面纹理
  6. 上述都给一个参数进行混合处理
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行较冷,为夜晚阴影色

  1. 先列出A通道的5个枚举值:0.0、0.3、0.5、0.7、1.0
    1. Ramp图的行数与枚举值并不是按照同样的顺序对应的,比如皮肤枚举值为1.0,但在Ramp图里为第2行而不是第5行
    2. 我们以A通道枚举值顺序为准。所以属性中需要用5个参数定义5个枚举值分别对应哪一行
  2. 得到白天Ramp的每一行的v坐标
    1. 若以左上角为uv原点,则用目标行数/10.0 - 0.05得到v坐标。最后采样时记得要用1减去。
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;
  1. steplerp配合判断枚举了白天哪一行
    1. 方法:用step(ilm.a, (matEnum3 + matEnum4)/2)判断Ramp值接近枚举3还是枚举4,如果Ramp值接近matEnum3则返回1。将结果作为权重用lerp插值ramp4和ramp3。
    2. 由大到小重复用上述方法判断大小
  2. 夜晚的值,直接在上一步判断出的白天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图。

  1. 通常用lambert区分亮面和暗面
  2. 但是考虑到Ramp图的变化都集中在右边,lambert在那一块几乎全是白色,可采样的额度很小
    1. 这里要用Half-Lambert,也就是Lambert的优化版本:

      (Lambert×0.5+0.5)2

      注意,半兰伯特是有平方的!我们通常会把它落了!面试少了平方的全都不通过!

    2. 注:通常来说,也会把半兰伯特的这个幂系数作为一个参数控制
  3. 把半兰伯特smoothstep一下,上下界自己感受(此处用[0.2,0.4])
    1. 回顾smoothstep(a,b,x)的作用:如果x处于[min,max]则返回[0,1]之间的平滑值,否则钳制在0或1。此函数常用于贴图采样,因为返回的结果是[0,1]之间的
    2. 为避免采样到边界以外,需要给它一把钳子,把它用clamp(x,a,b)钳制在[0.003,0.997]之间,否则可能超出边界
  4. 此时就可以拼接灰色阴影区域的白天和夜晚的Ramp UV
    1. 由于之前假设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);

  1. 黑色阴影的V坐标跟灰色阴影的一样。U坐标直接用钳子的最小值就行
  2. 同上,拼接黑色阴影区域的白天和夜晚的Ramp UV
  3. isDay:用光向量的y分量,来判断白天还是夜晚,映射到[0,1],后面用来插值
  4. 正式采样灰色阴影和黑色阴影的Ramp颜色。
    1. 使用isDay插值夜晚UV和白天UV的_RampTex采样结果
    2. 注意!必须把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);

接下来是混合。

  1. baseColor 乘以 rampColor 乘以我们自己设的 _ShadowColor,得到最终的两个暗部的颜色。
  2. 先用Half-Lambert插值混合灰色阴影和亮部
    1. 需要在前面再定义一个 float lambertStep = smoothstep(0.423, 0.450, halflambert);,用于插值。这个数值范围就是阴影大过渡区域的范围,这里使用[0.423,0.450]范围
  3. 把魔法的g通道乘以2,再把超过1的部分用saturate截断。
    1. 此时0为黑色部分,1为灰色部分和常亮部分,用来在黑色阴影和diffuse之间插值混合
  4. 把魔法的g通道减0.5再乘以2,再把超出[0,1]的部分用saturate截断。
    1. 此时0为黑色部分和灰色部分,1为常亮部分,用来在diffuse和baseColor之间插值混合
  5. 调整每个材质的_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通道,它是高光的形状或者说细节。

  1. 以Blinn-Phong模型为基础高光
    1. 写法:先用step(0,NoL)判断是否被光照射,乘上pow(max(0, NoH), _SpecExpon)
  2. 先做非金属高光(注意,是float3,便于之后插值混合颜色)
    1. 先判断是否高光:对blinnPhong取反,如果魔法b通道(高光形状)大于该值,则为高光区域
    2. 乘上魔法r通道(高光强度),再乘上我们自己添加的高光强度_KsNonMetallic
  3. 金属高光(也是float3)
    1. 直接用blinnPhong乘以魔法b通道(高光形状)
    2. 再乘以lambertStep,让它根据光的方向有个衰减,只有lambertStep大于0才有高光。当然也可以自己调整一下,这里把它的下限加0.2
    3. 光滑的非金属表面是不吸收颜色的,但是金属会吸收颜色。所以金属颜色是要再乘以baseColor的!
    4. 再乘上自定义强度_KsMetallic
  4. 魔法r通道为1的是金属,step提取出金属区域isMetal
  5. 把非金属和金属高光用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);

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

最终混合

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

准备上下文

  1. 跟身体一样,准备好上下文、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);

  1. 确保脸部阴影颜色与皮肤保持一致,也需要Ramp图上采样Ramp颜色
    1. 直接指定采样哪一行就行,皮肤一般是第2行
    2. 行数除以10减去0.05得到V坐标
    3. U坐标直接取钳子的最小值,防止采样到边界
    4. Unity里还要把V坐标反向
    5. 混合白天和夜晚
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,记得让后脑勺直接变黑。

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

float3 forwardVec = _ForwardVector;
float3 rightVec = _RightVector;

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

所以应该这样做:

  1. Unity是左手坐标系。为得到上向量upVector,先用右向量叉乘前向量
  2. 要得到光向量在上向量上的投影向量LpU
    1. 公式:

      |L|×cos<L,upVector>×upVector|upVector|=|L|×LupVector|L|×|upVector|×upVector|upVector|=(LupVector)×upVector|upVector|2
  3. 再用灯光向量减去这个投影向量,得到灯光向量在前向量和右向量组成的平面上的投影向量LpHeadHorizon
    1. 在这个空间下计算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作为新的灯光向量做后续计算。

  1. 拿右向量rightVec点乘灯光向量,要先规格化
  2. 做反余弦,得到光线和脸右侧的夹角。除以pi,得到[0,1]的映射值
    1. 此时[0,0.5]时光线照在右侧,[0.5,1]时光线照在左侧
    2. Q:为什么要做反余弦而不是直接用点乘结果,点乘结果范围不是已经是[-1,1]了吗?A:因为我们想做角度均匀变化,比如说0度时值为0、90度时值为0.5,那45度时值就应该为0.5,但点乘结果其实是cos值,需要反余弦才能变均匀。
  3. step(value, 0.5)来区分光线照在左脸还是右脸,得到exposeRight
    1. 这个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图全大于这个值,脸全亮。

  1. 需要把刚才的映射值value重新映射一下,从 0~0.5~1 映射到 1~0~1
    1. 为实现这个映射,可以先求valueR(从[0,1]映射到[1,-1])、valueL(从[0,1]映射到[-1,1]),再从左右分界线结合一下,也就是用exposeRight插值混合左和右,得到value在[0,1]范围内值为 1~0~1,称之为mixValue。参考下图
    2. 注意,这个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贴图。

  1. 当伦勃朗光在右脸时,光照在左脸,正向采样SDF
  2. 当伦勃朗光在左脸时,光照在右脸,把u方向反过来采样
  3. 由于同时只能从一遍进行采样,所以用exposeRight插值混合
    1. 注意,顺序是右和左!因为光照在右脸时,伦勃朗光在左脸,应当反向采样
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);    // 判断采样方向,同时只会从一个方向采样!
  1. 最后比较mixValue和SDF灰度值,灰度值大于value的就是亮面
  2. 由于value是用 acos 求得,而acos得不到大于180度的值,所以得不到光照在后脑勺时的情况。参考上图。
    1. 很简单,只要在最后让光照在脑袋后时sdf为0就行。
    2. 方法:用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))));
  1. 由于阴影在光线近直射的时候变化较快,脸部几乎很难全亮,所以在映射valuevalueRvalueL后,再用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通道控制阴影亮度,黑色代表完全阴影,白色代表常亮。

  1. 采样_ShadowTex
  2. sdf直接乘上g通道
  3. 用a通道插值sdf和1
float4 shadowTex = tex2D(_ShadowTex, i.uv);
sdf *= shadowTex.g;
sdf = lerp(sdf, 1, shadowTex.a);
  1. 最终的阴影颜色 = 基础颜色 × ramp颜色 × 我们自定义的阴影颜色
  2. 最终的漫反射 = 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用来画描边

  1. 剔除正面
  2. 把顶点沿着法线方向进行偏移
    1. 注意!偏移量要乘上顶点色a通道,原神的模型会把描边强度存储在顶点色a通道
  3. 描边颜色可以像之前一样枚举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;
    }
}

屏幕空间边缘光

==思想==:获得当前屏幕坐标,按观察空间法线方向偏移一段距离,分别采样当前位置的深度和偏移位置的深度,如果深度差大于最小深度差阈值(代表偏移位置不是衣服),则有边缘光,可以拿深度差作为强度。

  1. 使用NDC坐标进行透视除法得到屏幕UV坐标
  2. 采样当前深度,转换成线性深度
  3. 原神的边缘光只出现在角色左右两侧,上下没有,只需要做水平偏移
    1. 为获得偏移方向,取观察空间法线的x分量,大于0取1、小于0取-1
    2. 乘偏移像素量(边缘光宽度)
    3. 除以屏幕宽度,得到屏幕UV偏移量
    4. 如果想要近大远小,可以除以当前深度或深度的平方,max到1防止变小数偏移过大
    5. y分量不需要偏移
  4. 采样偏移位置的深度,转换成线性深度
  5. 求深度差,钳制在[0,1]
  6. 在前面设置最小深度差阈值、强度、最大值
  7. 用阈值判断是否有边缘光,乘上深度差乘强度,钳制在[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. 设置菲涅尔幂值、钳子
  2. 计算菲涅尔:1 - saturate(NoV),做幂次
  3. 用钳子算出最终菲涅尔(从[0,1]映射到[fresnelClamp, 1]):fresnel * fresnelClamp + (1 - fresnelClamp)
  4. 最后用滤色混合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);

可以把nonMetallicSpecmetallicSpec也都乘上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;

后处理

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

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

目录