贴图分析

Alt text
LightMap.r :⾼光类型Layer,根据值域选择不同的⾼光类型(eg:BlinPhong 裁边视⾓光)
LightMap.g :阴影AO ShadowAOMask
LightMap.b :BlinPhong⾼光强度Mask SpecularIntensityMask
LightMap.a :Ramp类型Layer,根据值域选择不同的Ramp
VertexColor.g :Ramp偏移值,值越⼤的区域 越容易”感光”(在⼀个特定的⾓度,偏移光照明暗)
VertexColor.a :描边粗细,这里的信息丢失了

角色shader还原

漫反射

ramp原理

漫反射分层,对ramp的使用。Ramp原理,在卡通渲染里面Ramp是很常用的,原理其实也非常简单,先算一个半兰伯特,然后把Ramp的某一行(uv.x) 映射到半兰伯特上。而这张Ramp可以做一个从最软到最硬的一个渐变,如果效果需要我们甚至可以再用顶点色或者贴图上去控制(uv.y)阴影在不同区域的软硬变化。
Alt text
我们不单只可以映射到半兰伯特上,还可以映射到phong,GGX, kajiya-kay, Fresnel, 等等等的光照模型。只要你算出一个灰度渐变,你想映射某一组颜色上去都是可以的。
一般的Ramp长这个样子

一些不一样的ramp
Alt text

通过控制2个颜色之间插值像素的多少得到明暗边缘的硬过渡或者是软过渡![image.png]Alt text

看一下原神的ramp,大小为256*20像素。一共有10条Ramp,分别对应着白天和夜晚,根据Lightmap.a通道中的不同值域确定使用的层,以不同的值域表现不同的材质。
Alt text

漫反射还原

Alt text
根据LightMap.a的灰度区分不同的材质
灰度1.0 : 皮肤质感/头发质感(头发的部分是没有皮肤的)
灰度0.7 : 丝绸/丝袜
灰度0.5 : 金属/金属投影
灰度0.3 : 软的物体
灰度0.0 : 硬的物体
Alt text
半兰伯特截断0.5-1.0的值将0大于0.5的值全部变为1.0然后乘以固有阴影

1
halfLambert = smoothstep(0.0,0.5,halfLambert) * shadowAOMask;

Alt text
Alt text
明暗交界处的ramp
采样白天ramp,用半兰伯特作为U,Lightmap.a(rampLayerMask)作为V采样

1
float3 lightRamp = tex2D(_RampMap, float2(halfLambert, rampLayerMask * 0.45 + 0.55));//把范围映射到(0.55-1.0)乘以0.45为了采样ramp的中间避免错误

Alt text
给固有阴影区域添加ramp变化

1
2
float3 baseColShadowed = lerp(baseCol * lightRamp, baseCol, shadowAOMask);//将固有阴影区域乘以ramp
baseColShadowed = lerp(baseCol, baseColShadowed, _ShadowRampLerp);

Alt text
非常重要的baseColBright与baseColDark的遮罩,用来混合亮部与暗部

1
float IsBrightSide = shadowAOMask * step(_LightThreshold, halfLambert);//明暗区域遮罩

Alt text
Diffuse完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
float4 LightMap = tex2D(_LightMap, i.uv);
//提取各通道信息
float specularLayerMask = LightMap.r;//高光类型Layer
float shadowAOMask = LightMap.g;//阴影AO遮罩
float specularShapeMask = LightMap.b;//高光形状遮罩
float rampLayerMask = LightMap.a;//Ramp类型Layer
float rampOffsetMask = i.vertexCol.g;//Ramp偏移值,值越大的区域月容易“感光”(在一个特定角度,偏移光照的明暗)
float halfLambert = NdotL * 0.5 + 0.5;
shadowAOMask = 1.0 - smoothstep(saturate(shadowAOMask), 0.2, 0.55);//平滑固有阴影,去除杂色
halfLambert = smoothstep(0.0,0.5,halfLambert) * shadowAOMask;
//采样白天ramp
float3 lightRamp = tex2D(_RampMap, float2(halfLambert, rampLayerMask * 0.45 + 0.55));//把范围映射到(0.55-1.0)乘以0.45为了采样ramp的中间避免错采
//采样夜晚ramp
//float3 darkRamp = tex2D(_RampMap, float2(halfLambert, rampLayerMask * 0.45));//把范围映射到(0-0.45)
float3 baseCol = tex2D(_BaseMap, i.uv);
float3 baseColShadowed = lerp(baseCol * lightRamp, baseCol, shadowAOMask);//将固有阴影区域乘以ramp
baseColShadowed = lerp(baseCol, baseColShadowed, _ShadowRampLerp);
float IsBrightSide = shadowAOMask * step(_LightThreshold, halfLambert);//明暗区域遮罩
float3 diffuse = lerp(lerp(baseColShadowed, baseCol * lightRamp, _RampLerp) * _DarkIntensity, baseColShadowed * _BrightIntensity, IsBrightSide * _RampIntensity * 1.0) * _CharacterIntensity;

面部SDF阴影

Alt text
原神面部阴影的过渡十分平滑,使用了一张阈值图进行控制,如何采样这张阈值图呢?先来分析一下,观察游戏我们可以看到根据光源旋转角度的不同,面部阴影可以从左到右过渡,也可以从右到左过渡,这样我们就需要判断何时将采样的UV的U轴进行反向,将左右翻转进行采样。

Alt text
Alt text
Alt text
Alt text
上图中的曰(ceita)角需要加上右边的90度,画的时候没画上。
首先我们需要构造一个4x4的矩阵这个矩阵为
(-1,0,0,0
0,1,0,0
0,0,1,0
0,0,0,1)
第一行为X轴,正负控制着阴影变化方向是否正确,第二行为Y轴,确定朝向向上的单位向量,第三行为Z轴,代表这我们的角色朝向。我们需要将这个矩阵与灯光向量进行矩阵乘法,其数学含义是将灯光向量沿着YoZ平面进行对称。角色朝向是固定的,根据上图可知曰(ceita)角使我们需要求得角度,这样就可以将亮部与暗部按照角度进行混合。这里我们需要取归一化后的Ldir.xz进行atan2(反正切),经过angle = atan2(Ldir.x,Ldir.z)计算可以得到弧度,我们需要把他转为角度(乘以1/2 PI * 360)方便后面的计算。
若Ldir与Forward的夹角>=0 && <180需要将器remap到0-1之间,反之若>180则需要映射到1-0之间,将这个值定义为value,之后我们需要判断什么时候用0-1,什么时候用1-0,即是否需要对阈值图进行左右翻转,很明显我们的条件是角度>180。
然后我们再用step依据value对阈值图进行步进得到遮罩混合亮部与暗部就完成了。
Alt text

SDF效果优化

考虑到SDF的数据内容,其实它相比尺寸,他对精度的要求更高一些。
所以我们可以切换成更高的色深来储存贴图数据,但是可以使用更小的贴图尺寸来储存。
以下操作来自上海的同事给的参考:

  1. 在Photoshop中打开SDF图
  2. 图像/模式/32位通道
  3. 滤镜/模糊/高斯模糊-半径2像素,确定
  4. 保存贴图,格式为exr(注意,只有exr可以保存各通道16位以上)
  5. 导入到Unity中之后,贴图选择不压缩,但是可以在保持效果下尽可能降低尺寸。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
float4 Left     = mul(unity_ObjectToWorld, float4(-1, 0, 0, 0));//左这个正负决定阴影的变化方向是否正确  根据unity坐标轴书写
float4 Up = mul(unity_ObjectToWorld, float4(0, 1, 0, 0));//上
float4 Forward = mul(unity_ObjectToWorld, float4(0, 0, 1, 0));//前

float4x4 XYZ = float4x4(Left, Up, Forward, float4(0, 0, 0, 1));//构造单位矩阵
float4 Light = mul(XYZ, float4(-ldirWS.xyz, 0));//关于YZ平面对称
Light .xz = normalize(Light.xz);
float angle = atan2(Light.x, Light.z);//反正切求弧度
float angle001 = angle * InvHalfPi + 0.5;//弧度转角度 +0.5平滑变化曲线,使得阴影变换不过与迅速
float angle360 = angle001 * 360.0;
float value = 0.0;
if(angle360 >=0 && angle360 <180)
{
//若角度大于等于0小与180,将角度映射到0-1
value = remap(angle360, 0, 180, 0, 1);
}
else
{
//若角度大于等于180,将角度映射到1-0
value = remap(angle360, 180, 360, 1, 0);
}
float needFlip = angle360 > 180;//布尔值0/1 用于判断使用原uv采样SDF图还是U向翻转后的uv进行采样SDF图
float4 faceSDFMap = tex2D(_FaceSDFMap, lerp(i.uv, float2(1-i.uv.x, i.uv.y), needFlip));
float faceSDFMask = 1.0 - tex2D(_FaceSDFMaskMap, i.uv).a;//采样面部的遮罩
float faceLight = step(value, faceSDFMap.g) * faceSDFMask;//按照阈值进行步进
float3 faceDiffuse = lerp(baseCol * _ShadowColor, baseCol, faceLight);//面部diffuse

高光

通用高光

依据Lightmap.b选择不同的高光
Alt text
可以看到除了头部,身体部分大致按灰度分了3个高光层,根据在PS中吸取颜色可以得知分层按灰度氛围100-150、150-250、250以上(基本是金属)。前2个高光采用的是裁边高光。
100-150的高光部分:
Alt text
150-250:保持常亮,勾勒金属的一些边缘
Alt text
250以上:采用BlinnPhong高光,做一些特殊化的处理
Alt text
最后混合的高光效果
Alt text
对于裁边高光的做法,一般是
stepSpecular = step(1 - _StepSpecularWidth, saturate(NdotV)) *_StepSpecularIntensity;
高光会随着视角进行变化,同时可以配合贴图控制高光的形状。
Alt text
BlinnPhong高光就是对NdotH做pow处理并且需要乘以一个200-300的值得到硬边缘

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
float metalMap = saturate(tex2D(_MetallicaMap, mul((float3x3)UNITY_MATRIX_V, ndirWS).xy * 0.5f + 0.5f ).r);
metalMap = step(_MetalMapV, metalMap) * _MetalMapIntensity;
float3 specular = 0.0;
float3 stepSpecular1 = 0.0;
float3 stepSpecular2 = 0.0;
specularLayerMask = pow(specularLayerMask, 1 / 2.2); //图⽚格式全部去掉勾选SRGB转到线性空间
float specularLayer = specularLayerMask * 255;
//不同的⾼光层 LightMap.b ⽤途不⼀样
//裁边⾼光 (⾼光在暗部消失)
if (specularLayer > 100 && specularLayer <= 150)
{
stepSpecular1 = step(1 - _StepSpecularWidth1, saturate(NdotV)) *_StepSpecularIntensity1;
stepSpecular1 *= baseCol;
// return Red;
}
//裁边⾼光 (stepSpecular2常亮,无视明暗)
if (specularLayer > 150 && specularLayer < 250)
{
//转到线性空间
float stepSpecularMask = step(200, pow(specularShapeMask, 1 / 2.2) * 255);
stepSpecular1 = step(1 - _StepSpecularWidth2, saturate(NdotV)) *_StepSpecularIntensity2;
stepSpecular2 = step(1 - _StepSpecularWidth3 * 5, saturate(NdotV)) * stepSpecularMask * _StepSpecularIntensity3;
stepSpecular1 = lerp(stepSpecular1, 0, stepSpecularMask);
stepSpecular2 *= baseCol * 0.6;
stepSpecular1 *= baseCol * 0.6;
// return Red;
}
if (specularLayer >= 250)
{
specular = saturate(pow(saturate(NdotH), 1 * _SpecularExp) * specularShapeMask *_SpecularIntensity * 300);
specular = max(0, specular);
float specularAdjustMask = pow(specularShapeMask, 7); //提取出最亮的一部分金属Mask
float3 specularAdjustCol = specularAdjustMask * baseCol;
specular += metalMap;
specular *= baseCol * 0.6;
specular += specularAdjustCol;
}

头发高光

原神的头发渲染,高光在亮部出现,也会在暗部出现部分,有一部分在暗部则会消失
Alt text
Alt text

1
2
3
4
float specularRange = step(1- _HairSpecularRange, saturate(NdotH));//BlinnPhong变种
float viewRange = step(1- _HairSpecularViewRange, saturate(NdotV));//根据视角裁剪掉一些高光
float3 hairSpecular = specularShapeMask * _HairSpecularIntensity * specularRange * viewRange;//乘以头发高光的形状
hairSpecular = max(0.0, hairSpecular);

我们需要高光在暗部消失,所以我们需要借用漫反射里面制作的亮部与暗部的Mask
hairSpecular *= IsBrightSide;这样暗部的一部分高光就会消失。

边缘光

菲尼尔边缘光的变种,添加了Lambert项进行控制,以区分左侧与右侧
Alt text
Alt text

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float lambertF = NdotL;
float lambertD = max(0, -lambertF);//左侧边缘光
lambertF = max(0, lambertF);//右侧边缘光
float rim = 1 - saturate(NdotV);//菲尼尔
//左侧亮部边缘光
float rimDot = saturate(pow(rim, _RimPow));//收紧菲尼尔
rimDot = _EnableLambert * lambertF * rimDot + (1 - _EnableLambert) * rimDot;//为菲尼尔加上lambert控制
float rimIntensity = smoothstep(0, _RimSmooth, rimDot);//平滑过渡
float4 Rim = _EnableRim * saturate(pow(rimIntensity, 5)) * _RimColor.rgba * float4(baseCol.rgb, 1.0);
//右侧暗部边缘光
rimDot = saturate(pow(rim, _DarkSideRimPow));
rimDot = _EnableLambert * lambertD * rimDot + (1 - _EnableLambert) * rimDot;
rimIntensity = smoothstep(0, _DarkSideRimSmooth, rimDot);
float4 RimDS = _EnableRimDS * saturate(pow(rimIntensity, 5)) * _DarkSideRimColor * float4(baseCol.rgb, 1.0);
float4 rimLight = Rim + RimDS;

屏幕空间边缘光

https://www.bilibili.com/read/cv11841147

自发光

源自BaseMap的A通道有一张Mask图,简单乘以一个颜色(开了HDR)加在finalColor上就好
Alt text
就是这两部分,一个宝石,一个神瞳
Alt textAlt text

1
float3 emission = tex2D(_BaseMap, i.uv).a * _EmissiomColor;

Alt textAlt text

描边

相机空间下的法线外扩,并用顶点色的a通道控制描边,通用Pass

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Pass
{
Cull Front //声明前向剔除
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv0 : TEXCOORD0;
float3 normal : NORMAL;
float4 color : COLOR;
};

struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
float4 vertexCol : TEXCOORD1;
};

sampler2D _BaseMap;
float _OutLineWidth;
float4 _OutLineColor;

v2f vert (appdata v)
{
v2f o;
//相机空间法线外扩描边
float3 posVS = UnityObjectToViewPos(v.vertex).xyz; //将顶点位置转换到相机空间
float3 ndirWS = UnityObjectToWorldNormal(v.normal); //将法线从模型空间转到世界空间
float3 ndirVS = mul((float3x3)UNITY_MATRIX_V, ndirWS); //将法线从世界空间转到相机空间
posVS += ndirVS * _OutLineWidth * 0.001 * v.color.a; //法线外扩的控制,用定点色控制描边的宽度
o.pos = mul(UNITY_MATRIX_P, float4(posVS, 1.0)); //将顶线位置从相机空间转到裁剪空间
o.uv = v.uv0;
o.vertexCol = v.color;
return o;
}

float4 frag (v2f i) : SV_Target
{
//描边颜色的Trick,类似于tongmapping对比度的压暗
float3 baseCol = tex2D(_BaseMap,i.uv).rgb;
float maxComponent = max(max(baseCol.r, baseCol.g), baseCol.b) - 0.004;
float saturatedCol = step(maxComponent.rrr, baseCol) * baseCol;
saturatedCol = lerp(baseCol, saturatedCol, 0.6);
float3 outlineCol = 0.8 * saturatedCol * baseCol * _OutLineColor;
return float4(outlineCol, 1.0);
}
ENDCG
}

最后效果

Alt text
Alt text

时间久远,留个纪念,欢迎交流_(:з」∠)_