前言
本文初发布于zhihu
https://zhuanlan.zhihu.com/p/232450616
众所周知,在卡通渲染领域有着许许多多的Trick,而角色的脸部作为阿宅们关注的重点,Trick自然也少不了。
在各种插画、动画中,角色头发常常在角色脸上投下与头发形状相似的阴影,但这个效果如果使用ShadowMap来实现的话,对ShadowMap的精度要求大到有些不现实。
一些例图:
笔者近日在实现卡渲时发现,如果没有这个刘海投影,总感觉还差点味。
此时的脸部阴影是使用软阴影做的,可见效果并不是很好
而使用本文所讲述的方法可以产生较好的效果:
(脸部法线笔者并没有进行球形映射或者其他什么修改,所以普通的自阴影部分或许有些不美观,各位只关注刘海投影即可)
效果实现原理
其实效果的原理相当简单,简略来说就一句话:
首先生成一个头发的纯色buffer,然后在渲染角色脸部的时候对这个纯色buffer做采样取得阴影区域即可。
本文目录为:
- 使用Render Feature生成纯色buffer
- 渲染脸部时对这个纯色buffer进行采样
- 一些改良
- 美中不足
- 结语
- Github范例工程链接
本文内容不多,因为只涉及这一个细节效果,不会谈及整个卡渲模型,如果读者对整体的卡通渲染模型,推荐阅读:
https://zhuanlan.zhihu.com/p/109101851
注
笔者所用Unity版本为2019.4.6f1,URP 7.3.1
笔者经验甚少,才浅学疏,难以避免文中出现错误,还请大家不吝斧正,只求轻喷。
使用Render Feature生成纯色buffer
以Create/Rendering/URP/Render Feature新建一个Render Feature。
打开之后能看见是这样的: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
51using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
public class CelHairShadow_Test : ScriptableRendererFeature
{
class CustomRenderPass : ScriptableRenderPass
{
// This method is called before executing the render pass.
// It can be used to configure render targets and their clear state. Also to create temporary render target textures.
// When empty this render pass will render to the active camera render target.
// You should never call CommandBuffer.SetRenderTarget. Instead call <c>ConfigureTarget</c> and <c>ConfigureClear</c>.
// The render pipeline will ensure target setup and clearing happens in an performance manner.
public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor)
{
}
// Here you can implement the rendering logic.
// Use <c>ScriptableRenderContext</c> to issue drawing commands or execute command buffers
// https://docs.unity3d.com/ScriptReference/Rendering.ScriptableRenderContext.html
// You don't have to call ScriptableRenderContext.submit, the render pipeline will call it at specific points in the pipeline.
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
}
/// Cleanup any allocated resources that were created during the execution of this render pass.
public override void FrameCleanup(CommandBuffer cmd)
{
}
}
CustomRenderPass m_ScriptablePass;
public override void Create()
{
m_ScriptablePass = new CustomRenderPass();
// Configures where the render pass should be injected.
m_ScriptablePass.renderPassEvent = RenderPassEvent.AfterRenderingOpaques;
}
// Here you can inject one or multiple render passes in the renderer.
// This method is called when setting up the renderer once per-camera.
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
renderer.EnqueuePass(m_ScriptablePass);
}
}
对32行及之后的内容我们基本可以忽略,只需要修改一处:
将第39行的renderPassEvent从AfterRenderingOpaques修改为BeforeRenderingOpaques.
这是为了保证我们在渲染脸部时,这个纯色Buffer已存在。
然后在类的开头写上我们的Setting:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18[ ]
public class Setting
{
//标记头发模型的Layer
public LayerMask hairLayer;
//标记脸部模型的Layer
public LayerMask faceLayer;
//Render Queue的设置
[ ]
public int queueMin = 2000;
[ ]
public int queueMax = 3000;
//使用的Material
public Material material;
}
public Setting setting = new Setting();
然后到Setting文件夹(默认的Rendering List所存放的路径)下,通过Add Render Feature添加我们刚刚新建的这个Render Feature
现在是这样的,我们可以先添加Layer
并设置Hair Layer和Face Layer
(实际上眼睛也需要设置为Face,这些没意思的图就不多发了
我们先把Material给填上吧,
新建一个Shader——由于URP没有默认的Shader模板,因此在此我给各位提供一个网上找来的模板,直接复制粘贴覆盖原有的Shader即可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
54
55
56
57
58
59
60
61
62
63
64// Example Shader for Universal RP
// Written by @Cyanilux
// https://cyangamedev.wordpress.com/urp-shader-code/
Shader "Custom/UnlitShaderExample" {
Properties {
_BaseMap ("Example Texture", 2D) = "white" {}
_BaseColor ("Example Colour", Color) = (0, 0.66, 0.73, 1)
}
SubShader {
Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalRenderPipeline" }
HLSLINCLUDE
CBUFFER_START(UnityPerMaterial)
float4 _BaseMap_ST;
float4 _BaseColor;
CBUFFER_END
ENDHLSL
Pass {
Name "Example"
Tags { "LightMode"="UniversalForward" }
HLSLPROGRAM
struct a2v {
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
struct v2f {
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float4 color : COLOR;
};
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
v2f vert(a2v v) {
v2f o;
//VertexPositionInputs positionInputs = GetVertexPositionInputs(input.positionOS.xyz);
//o.positionCS = positionInputs.positionCS;
// Or this :
o.positionCS = TransformObjectToHClip(v.positionOS.xyz);
o.uv = TRANSFORM_TEX(v.uv, _BaseMap);
o.color = v.color;
return o;
}
half4 frag(v2f i) : SV_Target {
half4 baseMap = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, i.uv);
return baseMap * _BaseColor * i.color;
}
ENDHLSL
}
}
}
以其新建Material并拖到RenderFeature里
那么回想一下我们的步骤,我们要用Render Feature渲染一个头发纯色图,那么很简单,我们只需要Return (1,1,1,1)即可,反正只要个颜色嘛!
是吗?
当然没有这么简单。如果只是这样画的话,就会绘制角色头后面的头发,无法拿到刘海的形状。
我们之前设置了一个Face Layer,就是为了在绘制头发纯色之前,先写入脸部的深度,之后在绘制头发时进行深度测试即可获取刘海形。
如此便说明我们要在这个Shader中写两个Pass,一个是给脸部写入深度用,一个是绘制头发用的,两个都很简单
1 | Shader "Custom/HairShadowSoild_Test" |
那么让我们把这个Material搁一旁,开始正式动工Render Feature吧
在CustomRenderPass类中添加以下内容1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21 //用于储存之后申请来的RT的ID
public int soildColorID = 0;
public ShaderTagId shaderTag = new ShaderTagId("UniversalForward");
public Setting setting;
FilteringSettings filtering;
FilteringSettings filtering2;
//新的构造方法
public CustomRenderPass(Setting setting)
{
this.setting = setting;
//创建queue以用于两个FilteringSettings的赋值
RenderQueueRange queue = new RenderQueueRange();
queue.lowerBound = Mathf.Min(setting.queueMax, setting.queueMin);
queue.upperBound = Mathf.Max(setting.queueMax, setting.queueMin);
filtering = new FilteringSettings(queue, setting.faceLayer);
filtering2 = new FilteringSettings(queue, setting.hairLayer);
}
前面的每个变量我们之后都会用到,目前还只是声明一下.
然后给CustomRenderPass创建一个构造方法,其中便使用到我们的Setting
此时会有一个报错,因为下面在构造CustomRenderPass时用的还是无参的老方法,加个setting即可。
FilteringSettings笔者个人理解为一个过滤器,更直白来说,它可以帮助我们选择我们想要渲染的物体,
而为了选择物体,我们需要加一些条件,比如这个(些)物体的Render Queue,Layer等,这样Unity就会找到符合这些条件的物体,并用于之后的渲染。
1 | public override void Configure(CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor) |
RenderTextureDescriptor的设置其实可以更自定义,比如只用屏幕一半分辨率的Texture,或者用RGB565之类,这里就不展开讲了。
此时使用Frame Debugger即可看到我们所渲染出的这个buffer(因为这个凯露模型的衣服和头发是同一网格,便使用同一材质,所以我们能在buffer中看见衣服也被渲染了)。
那么,让我们进入下一个阶段吧。
渲染脸部时对这个纯色buffer进行采样
笔者虽然自己在URP实现了Cel Shading,奈何本人学疏才浅,Shader写得有些不堪入目、杂乱无章,不适合拿出来给各位展示,因此笔者就提供一些资料供各位参考,还请见谅。
一是前文所分享的2173大佬的文
还有一个不得不提的当然是Colin大佬的URP Toon Lit Shader:
https://github.com/ColinLeung-NiloCat/UnityURPToonLitShaderExample
如果你并不会书写URP中的Shader,笔者建议学习此文:
https://cyangamedev.wordpress.com/2020/06/05/urp-shader-code/
那么让我们回归正题,
既然我们已经拥有绘制了头发的buffer,要怎么采样它呢?
本文的方法是使用View Space的Light Direction
比如当环境的光照方向是这样时,便会有如红箭头方向的采样,
得到的就会是与下图类似的结果。
我在Shader中声明了Keyword “_IsFace”,来标记是否为脸,产生Shader的变体(Variant)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
54
55
56
57
58
59
60
61 struct v2f
{
float4 positionCS: SV_POSITION;
float2 uv: TEXCOORD0;
float3 positionWS: TEXCOORD1;
float3 normal: TEXCOORD2;
float4 positionSS: TEXCOORD3;
};
TEXTURE2D(_HairSoildColor);
SAMPLER(sampler_HairSoildColor);
v2f vert(a2v v)
{
v2f o;
//......
o.positionSS = ComputeScreenPos(positionInputs.positionCS);
//......
return o;
}
half4 frag(v2f i): SV_Target
{
//......
//face shadow
//计算该像素的Screen Position
float2 scrPos = i.positionSS.xy / i.positionSS.w;
//获取屏幕信息
float4 scaledScreenParams = GetScaledScreenParams();
//计算View Space的光照方向
float3 viewLightDir = normalize(TransformWorldToViewDir(mainLight.direction));
//计算采样点,其中_HairShadowDistace用于控制采样距离
float2 samplingPoint = scrPos + _HairShadowDistace * viewLightDir.xy * float2(1 / scaledScreenParams.x, 1 / scaledScreenParams.y);
//若采样点在阴影区内,则取得的value为1,作为阴影的话还得用1 - value;
float hairShadow = 1 - SAMPLE_TEXTURE2D(_HairSoildColor, sampler_HairSoildColor, samplingPoint).r;
//将作为二分色依据的ramp乘以shadow值
ramp *= hairShadow;
//若不是脸,直接将ramp乘以Shadow map的采样值,Shadow map的计算在此文非重点,姑且略过
ramp *= shadow;
//......
}
此时调整_HairShadowDistace的值便可获得类似如此的结果
基本的效果已经有了,但是还是存在一点问题。
一些改良
以NDC.w调整采样距离
如果只是这样采样的话,会发现如果摄像机离人物的脸比较近,则阴影采样距离会显得很小,如图
而离得远了,采样距离又会显得很大
显然,这并不是我们想看到的结果,那要如何调整呢?
读者是否觉得这种情况似曾相识?
没错,了解卡通渲染的朋友可能会觉得这跟解决Back facing描边粗细的问题十分相似,
使用NDC来调整back facing描边,是一大著名的方案。
只是back facing描边存在的问题是“近大远小”,而我们这个是“近小远大”。
不妨试试看?
1 | struct v2f |
此时我们在较近距离也可以看到明显的阴影区域了中等距离也能看见较为明显的阴影
较远时阴影就会消隐至看不见了。
那么这一问题也基本解决了。
当然,这并不是最完美的方法,或许美术会觉得近处阴影范围太大,中等距离阴影范围太小等……可以在这个基础上用曲线函数再调整一下采样距离,精益求精,争取获得最好的效果。
解决特定角度错误采样的问题
当我们摄像机的角度与脸的正前方基本垂直时,我们会发现角色脸部出现了非常诡异的现象,如图
究其原因,是由于脸部错误采样到了它“后面”的头发,而显然这种情况是我们不愿看到的。如图,脸部错误采样了两个红框圈出的头发。
那咋办呢……这也没法深度检测,又没深度图。
确实是没有深度图,不过我们可以手动来进行“深度检测”。
我们只要在buffer中写入头发的深度,不就可以在渲染脸部时进行两者的深度比较了吗?
不过……深度值咋算的来着?
对深度图中的深度值如何计算不清楚的读者可以参考这篇文章:
https://www.jianshu.com/p/6eb1f4501126
这边直接贴出结论:
Pndc = Pclip / w;
d = Pndc.z * 0.5 + 0.5;
因此我们将用于RenderFeature的Shader的第二个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
42Pass
{
Name "HairSimpleColor"
Tags { "LightMode" = "UniversalForward" }
Cull Off
ZTest LEqual
ZWrite Off
HLSLPROGRAM
struct a2v
{
float4 positionOS: POSITION;
};
struct v2f
{
float4 positionCS: SV_POSITION;
};
v2f vert(a2v v)
{
v2f o;
VertexPositionInputs positionInputs = GetVertexPositionInputs(v.positionOS.xyz);
o.positionCS = positionInputs.positionCS;
return o;
}
half4 frag(v2f i): SV_Target
{
float depth = i.positionCS.z * 0.5 + 0.5 / i.positionCS.w;
return float4(1, depth, 0, 1);
}
ENDHLSL
}
我们在buffer的g通道中写入了头发的depth,在采样时进行比较即可
当然,我们也要用同样的方法计算脸部的depth值。
1 |
|
此时可以回过头来看shader1
2
3
4//这行的采样已经不再必要
//float hairShadow = 1 - SAMPLE_TEXTURE2D(_HairSoildColor, sampler_HairSoildColor, samplingPoint).r;
float hairShadow = lerp(0, 1, depthCorrect);
我们在下一行就用lerp(0, 1, depthCorrect)把上一行的采样值覆盖掉了。
究其原因,是因为r通道原本就是只“输出一个值以标记这个像素是头发”,而我们将深度写入g通道时,就隐性地完成了这一步。因此,我们buffer的r通道已经失去意义了。
因此我们在RenderFeature的shader将写入r通道的部分舍去,产出一个只写有深度的buffer即可。
相信这部分的修改各位一定都会,就不加以赘述了。
同样的,还记得我们之前哪一步跟深度有关吗?
没错,之前我们有一次要写入脸部深度,为的就是不让脸部接收到来自“后面”的头发的阴影。
既然我们已经在buffer中写入头发的深度了,那么使用脸部与头发的深度对比,完全能够规避这个问题,于是乎我们Face写入深度的那一次渲染也是可以舍弃的。这样一来就更加节省资源了。
或许有的朋友要问了,“那为什么不一开始就往最合理的方向写文章呢?你这样让我改来改去不是很烦”。
笔者其实不仅仅是想分享一个技术,同时也想跟大家分享一下自己创作的思路、一个想法不断被完善的过程。
每一个想法的实现都不是一帆风顺的,都会经历数次删删改改,个人认为学习这一过程其实比直接学习所谓最终效果更有价值。
那么,我们终于解决了之前的鼻梁错误阴影问题。
……但我们真的取得完全胜利了吗?
美中不足
上一张图,如果读者仔细看的话,会发现其实额头部分的一些阴影也被消除了,如下图红圈圈出的部分。
这又是什么原因呢?
正是因为我们消除了脸部“后面”的头发的投影,但是实际上这种投影是完全可能发生的。
也就是说,之前解决“鼻梁阴影”的方法还是过于一刀切了,将一些可能实际存在的状况也否决,也就导致一些情况下会出错了。
而这种情况又要怎么解决?
或许我们要给脸部加一个Mask,比如额头上不采用深度检测,而鼻梁上采用。
笔者比较懒,就暂且使用positionWS了,其实原理上应该使用positionOS或者上个贴图。
1 | //...... |
mask
中途截断的Shadow显然不怎么好看……
可惜笔者并没有想到更好的方法来解决这个问题,或许画一个Mask是最优解?
但仅是这种方法已经足以应付绝大部分情况了加mask之前的效果
加了mask之后
咱们的Mask并没有木大,今后也要继续做加把劲骑士。
那么最后,放上一个灯光和人物都在转的效果视频吧
结语
在此必须要感谢某waifu群的群友们,他们肯于抽出时间回答我这种图形学萌新的问题,并给出不少有意义的建议,如果没有你们就没有这篇文章,实在感谢。
虽然这个方法仍然存在一些瑕疵,但是个人认为已经能够使用了,如果各位有建议的话,欢迎踊跃评论交流~
Github
https://github.com/FlowingCrescent/CelShadingWithFringeShadow_URP
参考资料
https://zhuanlan.zhihu.com/p/109101851
https://github.com/ColinLeung-NiloCat/UnityURPToonLitShaderExample
https://cyangamedev.wordpress.com/2020/06/05/urp-shader-code/
https://zhuanlan.zhihu.com/p/144209981
https://www.jianshu.com/p/6eb1f4501126
感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。