本文使用的渲染管线是Universal RP 14.0.9,编辑器版本是2022.3.11
素材分析
首先,我们需要准备以下素材,需要的工具以及下载地址在文章的最后。
这里举出几个较为重要的纹理的用法:
- LightMap:包含RGBA三个通道,每个通道存储不同的信息
- SDF:用来控制脸部的阴影分布,具体的细节会在之后提到
- Ramp:渐变纹理,用来控制漫反射光照的结果,常用于渲染NPR的角色或者物体。
以下是对LightMap的通道进行拆分的结果:
其中,每个LightMap的通道作用如下:
- LightMap.R:用来得到需要添加AO的区域,AO是常暗的,不会随着光线的变化而变化
- LightMap.G:根据值域存储高光区域
- LightMap.B:高光遮罩纹理,用来指定需要添加高光的区域
- LightMap.A:存储的是Ramp图的偏移值,根据亮度值来在不同光照情况(白天或者黑夜)下,选择对应的漫反射颜色。
除此之外,上面的SDF还可以当成脸部的LightMap,它的G通道可以用作眼睛的Shadow Mask,去掉我们不想要的眼睛阴影。
二分光影
从Blinn-Phong模型我们可以知道,对法线和光线的点积NdotL能够得到符合兰伯特定律的漫反射光照,此时我们可以使用Step函数将光影分为纯黑和纯白的两部分。本文使用了smoothstep函数对NdotL进行插值,这样能够得到明暗交界线过渡效果较好的二分光影。
smoothstep能够在0和1之间进行平滑的插值,下面是它的函数曲线,可以看到它不是粗暴地将0和1区分开来,而是通过样条达到缓进缓出的效果。
在Shader中,我们首先需要定义这两个参数,其中ShadowRange用于控制阴影范围,ShadowSmooth用于控制明暗交界线的过渡。
1 | _ShadowRange("Shadow Range", Float) = 0 |
需要注意的是,这里使用了一个宏SHADOW_FACE_ON
来判断渲染的物体是否是脸部,由于直接对脸部进行二分,脸部光影不是很好看,因此需要对脸部的阴影进行特殊处理。
关于宏相关的定义如下,使用这样的语句可以在Inspecti面板通过勾选来决定是否开启该宏。
1 | Properties |
然后编写函数用来得到阴影:
1 | float GetShadow(Varyings i, float3 lightDirWS) |
由于我们引入了ShadowSmooth变量来控制插值,明暗交界线不是那么生硬了,得到的阴影如下:
SDF 脸部阴影
SDF,Signed Distance Field,中文为有向距离场。有向距离场通常用单色位图数据来存储源图像中最近的黑色像素的距离,每个像素存储的不是颜色值。
下面的GIF展示的是脸部阴影图的关键帧,一般是以八位来存储的,有了关键帧之后需要通过插值算法得到一个SDF阴影图。
关于插值算法,有兴趣的可以前往以下论文进行深入地了解
卡通渲染中,使用SDF将几个关键帧插值成阴影贴图,这个阴影贴图能够根据人物的朝向以及灯光的旋转呈现对应关键帧的值,阴影贴图大概长这样:
得到了阴影贴图,我们需要设计算法,得到不同光线角度下的关键帧的值。
我们首先需要找到世界空间下的以头部为基准点的向前的方向以及右侧方向。
1
2float3 forwardDirWS = normalize(TransformObjectToWorldDir(float3(0.0,0.0,1.0)));
float3 rightDirWS = normalize(TransformObjectToWorldDir(float3(1.0,0.0,0.0)));然后我们需要用SDF阴影贴图采样两张阴影图,一个是正方向的阴影图,一个是水平翻转之后的阴影图。
正方向的阴影图适用于光线在脸部左侧的情况,水平翻转之后的图可以用作光线在右侧的情况。
1
2
3float2 faceShadowUV = float2(1 - i.uv.x, i.uv.y);
float faceShadow_left = SAMPLE_TEXTURE2D(_SDFTex, sampler_SDFTex, i.uv).r;
float faceShadow_right = SAMPLE_TEXTURE2D(_SDFTex, sampler_SDFTex, faceShadowUV).r;
右侧方向需要和光线方向进行点积,右侧方向点积的结果可以用来判断使用决定用哪张阴影图,当光线角度小于180度代表此时光线在脸部右侧,此时选择右侧阴影图。
1
2
3
4
5float FdotL = dot(forwardDirWS, lightDirWS);
float RdotL = dot(rightDirWS, lightDirWS);
// 通过RdotL决定用哪张阴影图,当主光角度大于180选择右侧阴影图。
float shadowTex = RdotL > 0 ? faceShadow_right : faceShadow_left;然后使用右侧方向点积来判断光线在左还是在右,采样对应的关键帧,底下的图片展示了采样数学式的原理。
- 光线在右侧的情况: (1 - acos(RdotL) / PI * 2)
- 光线在左侧的情况: (acos(RdotL) / PI * 2 - 1)
1
2float faceShadowThreshold = RdotL > 0 ? (1 - acos(RdotL) / PI * 2) : (acos(RdotL) / PI * 2 - 1);
float shadowFront = step(faceShadowThreshold, shadowTex);此外还要对光源在脸部背后的情况进行矫正,光源在脸部背后此时不应该出现任何的亮部,此时使用向前的方向和光线方向进行点积来判断
1
2
3float shadowBehind = step(0, FdotL);
float shadow = mul(shadowBehind, shadowFront);最后还要对眼部的阴影进行矫正,我这里使用了SDF作为LightMap,并选择它的G通道作为眼部的遮罩。
1
2
3
4// 矫正眼部阴影
float eyeShadowMask = SAMPLE_TEXTURE2D(_LightMap, sampler_LightMap, faceShadowUV).g;
float eyeMask = step(0.5, eyeShadowMask);
shadow = lerp(shadow, 1.0, eyeMask);
整合之后的代码如下:
1 | float GetFaceShadow(Varyings i, float3 lightDirWS) |
渲染结果如下,注意观察脸部的阴影:
AO
AO的区域使用LightMap的R通道采样得到,为了美观,定义一个Mask来修正脸部的暗部。此外,AO不需要特别黑,使用smoothstep函数平滑一下即可。
1 | float GetAO(Varyings i, float3 lightDirWS) |
渲染结果如下:
Ramp
需要注意的是,使用Ramp纹理之前要将它的Wrap Mode改为Clamp,否则会出现噪点或者滤波
渐变纹理最早是军团要塞2中流行起来的技术,它能够渲染具有插画风格的角色,其优势在于能够自由地控制物体的漫反射颜色,比如说我们希望人物皮肤的明暗交界线处能够带有微微的泛红的颜色,模拟次表面散射的效果,此时就可以使用渐变纹理控制此处的颜色值。下面是一张256x16的渐变贴图,它的V坐标表示不同部位的颜色值,还记得我们之前的LightMap的A通道吗,它可以用来映射不同部位的颜色。
渐变纹理通常是使用半兰伯特的值来采样UV,以下是Unity入门精要的做法,我们这里可以之间使用之前得到的Shadow的值。
1 | fixed halfLambert = dot(worldNormal,worldLightDir) * 0.5 + 0.5; |
我们这里的采样UV代码如下,使用了shadow对rampU进行采样,LightMap的A通道对rampV进行采样 。需要注意的是,(0, 0.5)的范围是白天的色彩映射,(0.5, 1)是夜晚是色彩映射,使用RampMap更改rampV需要注意区别。
1
2
3
4
5
6
7
8
9
10
11
12float2 GetRampUV(float shadow, float RampMap)
{
// 白天的情况,夜晚需要在rampV上面再加一个0.5
float rampU = shadow;
float rampV = RampMap * 0.45;
rampV = 0.1;
return float2(rampU, rampV);
}最后输出颜色值即可,代码如下:
1
2
3
4
5
6
7
8
9
10half3 GetRampColor(Varyings i, float3 lightDirWS)
{
float shadow = GetShadow(i, lightDirWS);
float RampMap = SAMPLE_TEXTURE2D(_LightMap, sampler_LightMap, i.uv).a;
float2 rampUV = GetRampUV(shadow, RampMap);
half3 rampColor = SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, rampUV);
return rampColor;
}
渲染效果如下,暗部有了更丰富的颜色变化。
各向异性高光
提取LightMap的B通道中的高光区域作为我们的高光遮罩,通过观察可以发现,高光在亮部的地方比较亮,在暗部的地方比较暗,因此这里使用了NdotV和NdotL来控制高光的强度。
1 | half3 GetSpecular(Varyings i, float3 lightDirWS) |
渲染结果如下,此时光线是在人物的右侧,因此人物的右边会更亮一些。
Emission
在我们准备的素材中,还有Highlight的贴图,可以提取它的A通道作为自发光的遮罩区域,然后声明一个值EmissionIntensity用来控制自发光的强度,代码如下:
1 | float3 GetEmission(Varyings i, half3 baseColor) |
渲染效果如下:
Blink
Unity Shader中的时间函数
https://docs.unity3d.com/Packages/com.unity.shadergraph@6.9/manual/Time-Node.html
有了自发光之后,我们可以声明一个宏BLINK_ON
来控制自发光是否开启闪烁效果,闪烁效果使用了Unity的时间函数进行插值,这里的时间函数使用了余弦函数,并且做了类似于半兰伯特的缩放,将函数的振荡从(-1,1)缩放到(0,1)
1 |
|
RimLight
参考资料
RimLight实现的算法是Back Facing Outline,算法的具体实现为:将顶点向法线移动一段距离,然后根据为以后的坐标采样相机深度图,将这个深度图的值和原来的深度进行判断,如果大于一个阈值,那么我们认为这是一个边缘。
首先声明以下的参数,OffsetMul用于控制边缘的宽度,Threshold用来决定被判断为边缘的阈值,FresnelMask则是给边缘光一个遮罩,降低边缘光在阴影处的强度值。
此外,还需要再URP的设置中打开Depth Texture以获取深度图,并且在Shader中添加一个DepthOnly的Pass,可以从Unity内置的Pass中复制一个过去。
1
2
3_OffsetMul ("RimLight Offset", Float) = 0.0012
_Threshold ("RimLight Threshold", Float) = 0.07
_FresnelMask ("RimLight Threshold", Float) = 0.7在顶点着色器中我们需要观察空间、世界空间以及归一化坐标空间的顶点位置
1
2
3
4
5VertexPositionInputs PositionInputs = GetVertexPositionInputs(v.position.xyz);
o.positionVS = PositionInputs.positionVS;
o.positionWS = PositionInputs.positionWS;
o.positionNDC = PositionInputs.positionNDC;将顶点向法线移动一段距离,直接将观察空间的position加上观察空间的normal即可,并且使用OffsetMul控制偏移的程度。
1
2
3
4
5float3 worldNormal = i.worldNormal;
float3 normalVS = TransformWorldToViewDir(worldNormal, true);
float3 positionVS = i.positionVS;
float3 samplePositionVS = float3(positionVS.xy + normalVS.xy * _OffsetMul, positionVS.z);接着获取视口空间下的位置,视口空间是将屏幕空间除以屏幕分辨率得到的
1
2float4 samplePositionCS = TransformWViewToHClip(samplePositionVS);
float4 samplePositionVP = TransformHClipToViewPortPos(samplePositionCS);然后获取当前的深度图,这里为了判断边缘的准确,使用线性深度
1
2
3
4
5
6float depth = i.positionNDC.z / i.positionNDC.w;
float linearEyeDepth = LinearEyeDepth(depth, _ZBufferParams);
float offsetDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, samplePositionVP).r;
float linearEyeOffsetDepth = LinearEyeDepth(offsetDepth, _ZBufferParams);
float depthDiff = linearEyeOffsetDepth - linearEyeDepth;
float rimIntensity = step(_Threshold, depthDiff);其实这里的rimIntensity就得到了深度值,不过我们还需要使用NdotV,使用菲涅尔边缘光来做一个遮罩
1
2
3
4float3 viewDirectionWS = SafeNormalize(GetCameraPositionWS() - i.positionWS);
float rimRatio = 1 - saturate(dot(viewDirectionWS, worldNormal));
rimRatio = pow(rimRatio, exp2(lerp(4.0, 0.0, _FresnelMask)));
rimIntensity = lerp(0, rimIntensity, rimRatio);最后可以将边缘光的颜色乘以BaseColor,可以得到比较自然的边缘光
1
2
3
4half3 rimlight = lerp(float3(0, 0, 0), float3(1, 1, 1), rimIntensity);
float4 baseColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
rimlight *= baseColor.rgb;代码整合如下:
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
29half3 GetRimLight(Varyings i)
{
float3 worldNormal = i.worldNormal;
float3 normalVS = TransformWorldToViewDir(worldNormal, true);
float3 positionVS = i.positionVS;
float3 samplePositionVS = float3(positionVS.xy + normalVS.xy * _OffsetMul, positionVS.z); // 保持z不变(CS.w = -VS.z)
float4 samplePositionCS = TransformWViewToHClip(samplePositionVS); // input.positionCS不是真正的CS 而是SV_Position屏幕坐标
float4 samplePositionVP = TransformHClipToViewPortPos(samplePositionCS);
float depth = i.positionNDC.z / i.positionNDC.w;
float linearEyeDepth = LinearEyeDepth(depth, _ZBufferParams); // 离相机越近越小
float offsetDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, samplePositionVP).r; // _CameraDepthTexture.r = input.positionNDC.z / input.positionNDC.w
float linearEyeOffsetDepth = LinearEyeDepth(offsetDepth, _ZBufferParams);
float depthDiff = linearEyeOffsetDepth - linearEyeDepth;
float rimIntensity = step(_Threshold, depthDiff);
float3 viewDirectionWS = SafeNormalize(GetCameraPositionWS() - i.positionWS);
float rimRatio = 1 - saturate(dot(viewDirectionWS, worldNormal));
rimRatio = pow(rimRatio, exp2(lerp(4.0, 0.0, _FresnelMask)));
rimIntensity = lerp(0, rimIntensity, rimRatio);
half3 rimlight = lerp(float3(0, 0, 0), float3(1, 1, 1), rimIntensity);
float4 baseColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, i.uv);
rimlight *= baseColor.rgb;
return rimlight;
}
渲染结果如下,左边是原本的边缘光,右边是添加了BaseColor的边缘光
Reflection
为墨镜添加一个菲涅尔反射项,它能够随着视角改变反射光,具体代码如下。
1 | float3 GetFresnelSchlickReflection(Varyings i, float3 lightDirWS) |
注意观察墨镜的边缘,随着视角的变换会发生反射。
Outline
Outline主要是使用了这个大佬的代码
https://github.com/ColinLeung-NiloCat/UnityURPToonLitShaderExample
Outline的效果在另一个Pass中实现,其中这个Pass需要删去Tag里面的LightMode = UniversalForwardOnly,可选地添加一个LightMode = SRPDefaultUnlit,修改后的的Pass长这样,需要Cull Front。
1 | Pass |
Outline的实现和上面的RimLight非常相似,也是将Position向Normal偏移一段距离,只是添加了相关的遮罩,这里直接放代码。重要的部分在顶点着色器,片元着色器只要输出Outline的颜色即可。
1 | Varyings Outline_Vert(Attributes v) |
渲染效果如图:
Transparent
为了渲染不透明物体,需要在原有的ToonShader的基础上新建一个Transparent Shader,然后在新文件的SubShader上添加以下几个Tag,他们是用来指定渲染队列和渲染类型的。
1 | Tags |
除此之外,还要在你的主要的Pass里面添加以下语句,这样才能成功渲染透明物体。
1 | Zwrite Off |
最后只要指定一个透明度值Alpha,在输出最终的颜色时使用这个值即可控制透明度,效果如下,注意看墨镜的部分。
颜色矫正以及后处理
使用后处理也可以调整颜色,但是会影响到场景中的其他部分,因此这里使用色相、饱和度、明度矫正输出的颜色。
1 | half3 AdjustColor(half3 color) |
最后再使用Render Feature加一个Bloom后处理即可,最终的效果如图:
写到最后,其实AO、Ramp以及Outline都有不少可以优化的地方,可以尝试让皮肤的AO更融入周围,增加白天和黑夜的Ramp设置,以及优化Outline的遮罩,等有时间了再去改改。
问题解析
Unity的URP项目中使用自定义shader导致材质消失的解决办法
https://blog.csdn.net/fish_toucher/article/details/129861360
工具链接
Ninja Ripper:获取素材的工具,具体用法请自行搜索,建议下载2.05的版本,最新的版本需要付费才能拿到解锁Token
Noesis:用来读取和转换DDS文件
https://richwhitehouse.com/index.php?content=inc_projects.php
扩展阅读
卡通渲染
罪恶装备 Strive 人物渲染还原
Unity URP 卡通渲染 原神角色渲染记录
[Unity URP] 原神人物渲染还原简单流程
Genshin Impact Character Shader Breakdown [Unity URP]
A Unity URP shader for Genshin Impact style character facial shading.
Ramp
GenshinCelShaderURP
原神Shader渲染还原解析 - RampColor
SDF
如何快速生成混合卡通光照图
how to make SDF, Shader facial anime Genshin Impact in blender
How to Smooth Anime Face Shadow in Unity URP Without Editing Face Normal
RimLight
Parallax
How to make a Parallax Shader (for stylized effects) in Blender
Outline
SimpleURPToonLitOutlineExample
https://github.com/ColinLeung-NiloCat/UnityURPToonLitShaderExample
【05】Unity URP 卡通渲染 原神角色渲染记录-Double Pass Effect: Render Feature + 平滑法线Outline