原文链接
正文
序言
原神上线也半年了,卡通渲染水平属实一流。见了很多截帧分析的文章,也有很多人讨论原神的角色渲染。
但是目前还没看过整个角色 Shader 还原得比较好,讲解得比较详细的文章。因此比较擅长拾人牙慧的本不可燃垃圾,就把自己的 Shader 分享出来,详细地分析一下原神的角色渲染吧。
由于时间精力有限,并没有去逆向原神的 Shader,仅从视觉效果角度去还原,因此也并未 100% 还原,今后有空会补完。使用渲染管线为URP。
注:即使去掉 BaseMap 以外所有的贴图,适当调参后,本文的 Shader 也可以做到不错的卡通渲染效果。请不要执着于模型和贴图资源。
- 去掉 BaseMap 以外所有额外贴图的渲染效果
贴图
以 刻晴 为例,以下为需要用到的贴图(来源于大佬):
- ① RGBA 通道的身体 BaseMap
- ② RGB 通道的身体 BaseMap
- ③ RGBA 通道的身体 LightMap
- ④ 身体 ShadowRamp
- ⑤ 面部 BaseMap
- ⑥ 头发 BaseMap
- ⑦ RGBA 通道的头发 LightMap
- ⑧ 头发 ShadowRamp
- ⑨ 面部阴影 Mask
- ⑩ 金属光泽 Map
本次还原并未用到
- ② RGB 通道的身体 BaseMap
- ⑨ 面部阴影 Mask
- ⑩ 金属光泽 Map 。
部分变量命名为本人随意命名。仅分析重点的 片元着色器。
基础的卡通光照
- ③ LightMap 的
G通道:阴影权重
使用了【罪恶装备】的 LightMap 方案,随光照变化的一级阴影(ShallowShadowColor),不随光照变化的固定二级阴影(DarkShadowColor)。因此不能简单使用 HalfLambert 后 step 去区分明暗部分。网络上可见 【崩坏3】 的渲染分析,经过适当修改(也可以不修改)即可用于原神的角色 LightMap。
- Lambert 光照模型:Max(0, Dot(NormalDir, LightDir));
- HalfLambert 光照模型:Max(0, Dot(NormalDir, LightDir)* 0.5 + 0.5);
采样 BaseMap 和 LightMap,确定最初的阴影颜色 ShadowColor 和 DarkShadowColor 。
half4 baseColor = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv.xy);
half4 LightMapColor = SAMPLE_TEXTURE2D(_LightMap, sampler_LightMap, input.uv.xy);
half3 ShadowColor = baseColor.rgb * _ShadowMultColor.rgb;
half3 DarkShadowColor = baseColor.rgb * _DarkShadowMultColor.rgb;
以下大致为 【崩坏3】 使用的计算 一级 阴影颜色 ShallowShadowColor 的算法(已被我修改过)。
//崩坏3 原始算法
SWeight = LightMapColor.g * input.color.r;
float SFactor = 1.0f - step(0.5f, SWeight);
float2 halfFactor = SWeight * float2(1.2f, 1.25f) + float2(-0.1f, -0.125f);
SWeight = SFactor * halfFactor.x + (1.0f - SFactor) * halfFactor.y;
SWeight = floor((SWeight + input.lambert) * 0.5 + 1.0 - _ShadowArea);
SFactor = step(SWeight, 0);
ShadowColor.rgb = SFactor * baseColor.rgb + (1 - SFactor) * ShadowColor.rgb;
经分析得知,可能考虑到采样贴图时的 精度误差,或是为了 迷惑逆向 Shader 的人,设计了这样复杂的算法。本人觉得不大必要,因此修改为下方的代码,效果差不多。
//如果 SFactor = 0, ShallowShadowColor 为一级阴影色,否则为 BaseColor。
float SWeight = (LightMapColor.g * input.color.r + input.lambert) * 0.5 + 1.125;
float SFactor = floor(SWeight - _ShadowArea);
half3 ShallowShadowColor = SFactor * baseColor.rgb + (1 - SFactor) * ShadowColor.rgb;
由于希望可选择是否固定 二级 阴影颜色DarkShadowColor,因此二级阴影颜色如下计算。
//如果 SFactor = 0, DarkShadowColor 为二级阴影色,否则为一级阴影色。
SFactor = floor(SWeight - _DarkShadowArea);
DarkShadowColor = SFactor * (_FixDarkShadow * ShadowColor + (1 - _FixDarkShadow) * ShallowShadowColor) + (1 - SFactor) * DarkShadowColor;
这样阴影颜色的计算基本完成了,但是阴影边缘过于锐利,可以使用 smoothstep 进行平滑。
// 平滑阴影边缘
half rampS = smoothstep(0, _ShadowSmooth, input.lambert - _ShadowArea);
half rampDS = smoothstep(0, _DarkShadowSmooth, input.lambert - _DarkShadowArea);
ShallowShadowColor.rgb = lerp(ShadowColor, baseColor.rgb, rampS);
DarkShadowColor.rgb = lerp(DarkShadowColor.rgb, ShadowColor, rampDS);
所有准备完成,该计算最终的片元使用哪一级阴影的颜色了。
//如果 SFactor = 0, FinalColor 为二级阴影,否则为一级阴影。
SFactor = floor(LightMapColor.g * input.color.r + 0.9f);
half4 FinalColor;
FinalColor.rgb = SFactor * ShallowShadowColor + (1 - SFactor) * DarkShadowColor;
至此 【崩坏3】 的 LightMap 阴影计算大功告成! 咦……不好意思,忘了是要还原原神的角色 Shader 了,应该使用 ShadowRamp 的贴图才对。但……多一种选择不是更好吗?
RampShadow
- 原神角色的 ShadowRamp 贴图
- ③ LightMap 的
Alpha通道:RampAreaMask
经分析得知,③ LightMap 的 Alpha 通道存储了 5 种信息,Alpha 值大致对应 Ramp 贴图内的材质/颜色如下。
0: hard/emission/specular/silk77: soft/common128: metal179: tights255: skin
而 Ramp 图中存储了 10 行颜色,前 5 行为暖色调阴影,后 5 行为冷色调阴影,分别对应游戏内 白天 和 晚上 的 RampShadow 。
需要使用 HalfLambert 进行横向采样,将 10 行颜色数据全部采样后存储为一个数组。
X 轴 应当避免采样至贴图最最右边,否则会出现黑线,
Y 轴 应当在每一行的尽量中间,避免精度问题。
- 左:
_RampShadowRange - 0.003后采样的效果 右:直接采样的效果
我不理解:可是代码写的却是
(1.0 / _RampShadowRange - 0.003)
//关键的 X 轴 rampValue,控制采样的范围,至于为什么这样写,你一定能看懂。
float rampValue = input.lambert * (1.0 / _RampShadowRange - 0.003);
//Y 轴为固定数值
half3 ShadowRamp1 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.95)).rgb;
half3 ShadowRamp2 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.85)).rgb;
half3 ShadowRamp3 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.75)).rgb;
half3 ShadowRamp4 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.65)).rgb;
half3 ShadowRamp5 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.55)).rgb;
half3 CoolShadowRamp1 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.45)).rgb;
half3 CoolShadowRamp2 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.35)).rgb;
half3 CoolShadowRamp3 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.25)).rgb;
half3 CoolShadowRamp4 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.15)).rgb;
half3 CoolShadowRamp5 = SAMPLE_TEXTURE2D(_RampMap, sampler_RampMap, float2(rampValue, 0.05)).rgb;
half3 AllRamps[10] = {
ShadowRamp1, ShadowRamp2, ShadowRamp3, ShadowRamp4, ShadowRamp5, CoolShadowRamp1, CoolShadowRamp2, CoolShadowRamp3, CoolShadowRamp4, CoolShadowRamp5
};
根据材质面板内填写的 ③ LightMap 的 Alpha 值与 Ramp 列数的对应关系,使用 step 和 abs 判断 Alpha 值误差是否在一定范围内(我选 10)后,将 5 个 Ramp 色彩进行组合得到最终的 Ramp 色彩数据,并再次使用 _RampShadowRange 和 HalfLambert 得到你想要的 RampShadow。
half3 skinRamp = step(abs(LightMapColor.a * 255 - _RampArea12.x), 10) * AllRamps[_RampArea12.y]; // CoolShadowRamp2
half3 tightsRamp = step(abs(LightMapColor.a * 255 - _RampArea12.z), 10) * AllRamps[_RampArea12.w]; // CoolShadowRamp5
half3 softCommonRamp = step(abs(LightMapColor.a * 255 - _RampArea34.x), 10) * AllRamps[_RampArea34.y]; // CoolShadowRamp1
half3 hardSilkRamp = step(abs(LightMapColor.a * 255 - _RampArea34.z), 10) * AllRamps[_RampArea34.w]; // CoolShadowRamp3
half3 metalRamp = step(abs(LightMapColor.a * 255 - _RampArea5.x), 10) * AllRamps[_RampArea5.y]; // CoolShadowRamp4
// 组合 5 个 Ramp,得到最终的 Ramp 阴影,并根据 rampValue 与 BaseColor 结合。
half3 finalRamp = skinRamp + tightsRamp + metalRamp + softCommonRamp + hardSilkRamp;
rampValue = step(_RampShadowRange, input.lambert);
half3 RampShadowColor = rampValue * baseColor.rgb + (1 - rampValue) * finalRamp * baseColor.rgb;
ShadowColor = RampShadowColor;
DarkShadowColor = RampShadowColor;
游戏内的 Ramp 阴影会随着时间切换为白天和夜晚,分别为 1~5 行和 6~10 行。具体可以使用 C# 传输时间值,或者 根据光照方向计算时间或角度,使用
step或者lerp,将上方存储的AllRamps[0~4]分别切换为AllRamps[5~9]即可完成 RampShadow 色彩的切换。由于时间精力问题,我并未去实现,感兴趣的可以自己试一试。
面部阴影
在还原原神的面部阴影渲染之前,先介绍下我想到的一个 小技巧,这个技巧偶然间发现也被【Tales of Arise】使用,有兴趣、懂日语的话可以看看他们的技术分享。
由于面部阴影受光照角度影响极易产生 难看的阴影,因此可以虑将光照固定成水平方向,再微调面部法线即可得到比较舒适的面部阴影。
_FixLightY=0即可将光照方向固定至水平。
float3 fixedlightDirWS = normalize(float3(lightDirWS.x, _FixLightY, lightDirWS.z));
lightDirWS = _IgnoreLightY ? fixedlightDirWS: lightDirWS;
原神面部阴影的还原细节不多赘述,可以参考 @黑魔姬 的文章。
黑魔姬:神作面部阴影渲染还原238 赞同 · 28 评论文章
在此我做了一些改进。由于直接使用会产生头发阴影和面部 阴影交错 的问题,需要对光照方向进行偏移。但直接在采样得到的 Face ShadowMap 数据上 ±Offset 等操作,会导致光照进入边缘时产生 阴影跳变。因此采用 旋转偏移光照 的方式。只需要构建一个 XZ 平面上的旋转矩阵即可。
而光照在正前时,由于 Face ShadowMap 的曲线变化问题,会导致 阴影变化 过快,因此需要修改 Face ShadowMap 的曲线,使其在中间部分趋于平缓,使用 pow 函数即可做到, pow(0.15~0.3) 之间效果最佳。
// FaceShadowMap
#if ENABLE_FACE_SHADOW_MAP
// 计算光照旋转偏移
float sinx = sin(_FaceShadowOffset);
float cosx = cos(_FaceShadowOffset);
float2x2 rotationOffset = float2x2(cosx, -sinx, sinx, cosx);
float3 Front = unity_ObjectToWorld._12_22_32;
float3 Right = unity_ObjectToWorld._13_23_33;
float2 lightDir = mul(rotationOffset, mainLight.direction.xz);
//计算 xz 平面下的光照角度
float FrontL = dot(normalize(Front.xz), normalize(lightDir));
float RightL = dot(normalize(Right.xz), normalize(lightDir));
RightL = - (acos(RightL) / PI - 0.5) * 2;
//左右各采样一次 FaceShadowMap 的阴影数据存于 lightData
float2 lightData = float2(SAMPLE_TEXTURE2D(_FaceShadowMap, sampler_FaceShadowMap, float2(input.uv.x, input.uv.y)).r,
SAMPLE_TEXTURE2D(_FaceShadowMap, sampler_FaceShadowMap, float2(-input.uv.x, input.uv.y)).r);
//修改 lightData 的变化曲线,使中间大部分变化速度趋于平缓。
lightData = pow(abs(lightData), _FaceShadowMapPow);
//根据光照角度判断是否处于背光,使用正向还是反向的 lightData。
float lightAttenuation = step(0, FrontL) * min(step(RightL, lightData.x), step(-RightL, lightData.y));
half3 FaceColor = lerp(ShadowColor.rgb, baseColor.rgb, lightAttenuation);
FinalColor.rgb = FaceColor;
#endif
其他
高光
- ③ LightMap 的
R通道:高光强度
③ LightMap 的 R 通道用于控制高光强度,非 0 部分为 Blinn-Phong 高光。最大值 255 即纯白部分为 Blinn-Phong 加上 金属高光,需要使用到下方的 金属光泽贴图,使用方式疑似 MatCap。
- 金属光泽贴图
本次并未还原金属光泽贴图的效果,今后会补上。
- ③ LightMap 的
B通道:高光 Mask 和一些细节?
边缘光
原神的边缘光可以看出使用的应该是 深度边缘光,详情参考 @喵刀Hime 的文章:
喵刀Hime:【JTRP】屏幕空间深度边缘光 Screen Space Depth Rimlight210 赞同 · 17 评论文章
我嫌麻烦所以没加进去,只使用了普通的公式类似 菲涅尔反射 的边缘光,并用 正向反向 Lambert 进行了 Mask。
// Rim Light
float lambertF = dot(mainLight.direction, input.normalWS);
float lambertD = max(0, -lambertF);
lambertF = max(0, lambertF);
float rim = 1 - saturate(dot(viewDirWS, input.normalWS));
/// @note 正向
float rimDot = pow(rim, _RimPow);
rimDot = _EnableLambert * lambertF * rimDot + (1 - _EnableLambert) * rimDot;
float rimIntensity = smoothstep(0, _RimSmooth, rimDot);
half4 Rim = _EnableRim * pow(rimIntensity, 5) * _RimColor * baseColor;
Rim.a = _EnableRim * rimIntensity * _BloomFactor;
/// @note 反向
rimDot = pow(rim, _DarkSideRimPow);
rimDot = _EnableLambert * lambertD * rimDot + (1 - _EnableLambert) * rimDot;
rimIntensity = smoothstep(0, _DarkSideRimSmooth, rimDot);
half4 RimDS = _EnableRimDS * pow(rimIntensity, 5) * _DarkSideRimColor * baseColor;
RimDS.a = _EnableRimDS * rimIntensity * _BloomFactor;
自发光 & Bloom
原神使用了 ① ⑥ RGBA 通道 BaseMap 的 alpha 通道作为自发光 Mask,经后处理达到 Bloom 的效果。此外我们也可以在 高光 和 边缘光 区域自行增加自发光,修改 Alpha 的值达到这些区域 Bloom 的效果。
最后还剩一张贴图,看名字就知道是做什么的了,面部阴影 Mask,是否使用影响不是很关键。
描边
描边就是比较常规的 BackFace 外扩描边,直接复制粘贴 Colin 大佬的即可:
ColinLeung-NiloCat | UnityURPToonLitShaderExample。
此外原神使用了 ③ LightMap 的 Alpha 通道(也有可能是顶点色)制作了彩色的描边,仅限皮肤区域,可以参考上方 RampShadow 中贴图信息的处理方式自行添加。
顶点色的
Alpha通道控制描边粗细,RGB通道暂未分析出用来做了什么很特殊的效果。
在还原原神角色渲染的基础上,也可以自行增加一些有意思的东西。如我增加了自动着色、全彩色描边,本次的还原中并未加入。以下为完整 Shader 的 Github 链接:
ashyukiha | GenshinCharacterShaderZhihuVer