前言
本文初发布于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 51 using UnityEngine;using UnityEngine.Rendering;using UnityEngine.Rendering.Universal;public class CelHairShadow_Test : ScriptableRendererFeature { class CustomRenderPass : ScriptableRenderPass { public override void Configure (CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor ) { } public override void Execute (ScriptableRenderContext context, ref RenderingData renderingData ) { } public override void FrameCleanup (CommandBuffer cmd ) { } } CustomRenderPass m_ScriptablePass; public override void Create ( ) { m_ScriptablePass = new CustomRenderPass(); m_ScriptablePass.renderPassEvent = RenderPassEvent.AfterRenderingOpaques; } 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 [System.Serializable ] public class Setting { public LayerMask hairLayer; public LayerMask faceLayer; [Range(1000, 5000) ] public int queueMin = 2000 ; [Range(1000, 5000) ] public int queueMax = 3000 ; 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 Shader "Custom/UnlitShaderExample" { Properties { _BaseMap ("Example Texture" , 2 D) = "white" {} _BaseColor ("Example Colour" , Color) = (0 , 0.66 , 0.73 , 1 ) } SubShader { Tags { "RenderType" ="Opaque" "RenderPipeline" ="UniversalRenderPipeline" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START (UnityPerMaterial) float4 _BaseMap_ST; float4 _BaseColor; CBUFFER_END ENDHLSL Pass { Name "Example" Tags { "LightMode" ="UniversalForward" } HLSLPROGRAM #pragma vertex vert #pragma fragment frag 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; 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 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 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 Shader "Custom/HairShadowSoild_Test" { SubShader { Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalRenderPipeline" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" ENDHLSL Pass { Name "FaceDepthOnly" Tags { "LightMode" = "UniversalForward" } ColorMask 0 ZTest LEqual ZWrite On HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct a2v { float4 positionOS: POSITION; }; struct v2f { float4 positionCS: SV_POSITION; }; v2f vert (a2v v) { v2f o; o.positionCS = TransformObjectToHClip (v.positionOS.xyz); return o; } half4 frag (v2f i) : SV_Target { return (0 , 0 , 0 , 1 ); } ENDHLSL } Pass { Name "HairSimpleColor" Tags { "LightMode" = "UniversalForward" } Cull Off ZTest LEqual ZWrite Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag 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 { return float4 (1 , 1 , 1 , 1 ); } ENDHLSL } } }
那么让我们把这个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 public int soildColorID = 0 ; public ShaderTagId shaderTag = new ShaderTagId("UniversalForward" ); public Setting setting; FilteringSettings filtering; FilteringSettings filtering2; public CustomRenderPass (Setting setting ) { this .setting = setting; 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 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 public override void Configure (CommandBuffer cmd, RenderTextureDescriptor cameraTextureDescriptor ) { int temp = Shader.PropertyToID("_HairSoildColor" ); RenderTextureDescriptor desc = cameraTextureDescriptor; cmd.GetTemporaryRT(temp, desc); soildColorID = temp; ConfigureTarget(temp); ConfigureClear(ClearFlag.All, Color.black); } public override void Execute (ScriptableRenderContext context, ref RenderingData renderingData ) { var draw1 = CreateDrawingSettings(shaderTag, ref renderingData, renderingData.cameraData.defaultOpaqueSortFlags); draw1.overrideMaterial = setting.material; draw1.overrideMaterialPassIndex = 0 ; context.DrawRenderers(renderingData.cullResults, ref draw1, ref filtering); var draw2 = CreateDrawingSettings(shaderTag, ref renderingData, renderingData.cameraData.defaultOpaqueSortFlags); draw2.overrideMaterial = setting.material; draw2.overrideMaterialPassIndex = 1 ; context.DrawRenderers(renderingData.cullResults, ref draw2, ref filtering2); }
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; #if _IsFace float4 positionSS: TEXCOORD3; #endif }; TEXTURE2D (_HairSoildColor); SAMPLER (sampler_HairSoildColor); v2f vert (a2v v) { v2f o; #if _IsFace o.positionSS = ComputeScreenPos (positionInputs.positionCS); #endif return o; } half4 frag (v2f i) : SV_Target { #if _IsFace float2 scrPos = i.positionSS.xy / i.positionSS.w; float4 scaledScreenParams = GetScaledScreenParams (); float3 viewLightDir = normalize (TransformWorldToViewDir (mainLight.direction)); float2 samplingPoint = scrPos + _HairShadowDistace * viewLightDir.xy * float2 (1 / scaledScreenParams.x, 1 / scaledScreenParams.y); float hairShadow = 1 - SAMPLE_TEXTURE2D (_HairSoildColor, sampler_HairSoildColor, samplingPoint).r; ramp *= hairShadow; #else ramp *= shadow; #endif }
此时调整_HairShadowDistace的值便可获得类似如此的结果
基本的效果已经有了,但是还是存在一点问题。
一些改良
以NDC.w调整采样距离
如果只是这样采样的话,会发现如果摄像机离人物的脸比较近,则阴影采样距离会显得很小,如图
而离得远了,采样距离又会显得很大
显然,这并不是我们想看到的结果,那要如何调整呢?
读者是否觉得这种情况似曾相识?
没错,了解卡通渲染的朋友可能会觉得这跟解决Back facing描边粗细的问题十分相似,
使用NDC来调整back facing描边,是一大著名的方案。
只是back facing描边存在的问题是“近大远小”,而我们这个是“近小远大”。
不妨试试看?
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 struct v2f { #if _IsFace float4 positionSS: TEXCOORD3; float posNDCw: TEXCOORD4; #endif }; v2f vert (a2v v) { #if _IsFace o.posNDCw = positionInputs.positionNDC.w; o.positionSS = ComputeScreenPos (positionInputs.positionCS); #endif return o; } half4 frag (v2f i) : SV_Target { #if _IsFace float2 scrPos = i.positionSS.xy / i.positionSS.w; float4 scaledScreenParams = GetScaledScreenParams (); float3 viewLightDir = normalize (TransformWorldToViewDir (mainLight.direction)) * (1 / i.posNDCw) ; float2 samplingPoint = scrPos + _HairShadowDistace * viewLightDir.xy * float2 (1 / scaledScreenParams.x, 1 / scaledScreenParams.y); float hairShadow = 1 - SAMPLE_TEXTURE2D (_HairSoildColor, sampler_HairSoildColor, samplingPoint).r; ramp *= hairShadow; #else }
此时我们在较近距离也可以看到明显的阴影区域了
中等距离也能看见较为明显的阴影
较远时阴影就会消隐至看不见了。
那么这一问题也基本解决了。
当然,这并不是最完美的方法,或许美术会觉得近处阴影范围太大,中等距离阴影范围太小等……可以在这个基础上用曲线函数再调整一下采样距离,精益求精,争取获得最好的效果。
解决特定角度错误采样的问题
当我们摄像机的角度与脸的正前方基本垂直时,我们会发现角色脸部出现了非常诡异的现象,如图
究其原因,是由于脸部错误采样到了它“后面”的头发,而显然这种情况是我们不愿看到的。
如图,脸部错误采样了两个红框圈出的头发。
那咋办呢……这也没法深度检测,又没深度图。
确实是没有深度图,不过我们可以手动来进行“深度检测”。
我们只要在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 42 Pass { Name "HairSimpleColor" Tags { "LightMode" = "UniversalForward" } Cull Off ZTest LEqual ZWrite Off HLSLPROGRAM #pragma vertex vert #pragma fragment frag 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #if _IsFace float2 scrPos = i.positionSS.xy / i.positionSS.w; float4 scaledScreenParams = GetScaledScreenParams (); float3 viewLightDir = normalize (TransformWorldToViewDir (mainLight.direction)) * (1 / i.posNDCw) ; float2 samplingPoint = scrPos + _HairShadowDistace * viewLightDir.xy * float2 (1 / scaledScreenParams.x, 1 / scaledScreenParams.y); float depth = i.positionCS.z * 0.5 + 0.5 / i.positionCS.w; float hairDepth = SAMPLE_TEXTURE2D (_HairSoildColor, sampler_HairSoildColor, samplingPoint).g; float depthCorrect = depth < hairDepth + 0.001 ? 0 : 1 ; float hairShadow = 1 - SAMPLE_TEXTURE2D (_HairSoildColor, sampler_HairSoildColor, samplingPoint).r; hairShadow = lerp (0 , 1 , depthCorrect); ramp *= hairShadow; #else
此时可以回过头来看shader
1 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 2 3 4 float mask = smoothstep (1.6 , 1.51 , i.positionWS.y);float depthCorrect = depth * mask < hairDepth + 0.001 ? 0 : 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
感谢您阅读完本文,若您认为本文对您有所帮助,可以将其分享给其他人;若您发现文章对您的利益产生侵害,请联系作者进行删除。