图形引擎实战:卡通风格人物渲染技术总结
本次角色渲染使用了Unity3D引擎,采用的URP管线。在介绍之前先上效果图,以下分别截了四个不同光照方向下的角色表现:
正如字体风格会赋予文字不同的感觉,不同风格的渲染也同样有它们各自的情感和含义。
A、背景介绍
这次我们主要谈一下实时卡通风格渲染,它属于风格化渲染(Stylized Rendering)范畴,传统的卡通渲染由外而内,大致包含两部分,外(内)描边和表面着色,而表面着色通用的表现方法主要有两种,一种称为硬着色(Hard Shading),即用简单明了的两种颜色来表示卡通角色光照区域和阴影区域,如下:
另一种叫作色调分离(Posterization),不同于硬着色,它是一种屏幕后处理技术,意在将连续的颜色变化离散为几种不同的色调,一般用于处理复杂的光照环境,效果如图:
卡通渲染不同于真实渲染,又源于真实渲染,主要是对真实光影效果特征逐一抽离然后再加工,比如用画风各异的天使环效果来表现头发的各向异性高光,用硬边夸张的边缘光效果来体现菲涅尔效应等。
以下部分会对角色中涉及的技术点进行总结和整理。
B、描边
用于处理描边的方法大体分类为五种:
- 图像层面(基于颜色、深度、法线的屏幕空间算法等)
- 贴图层面(本村线等)
- 几何层面(基于几何/计算着色器的顶点生成式描边)
- 模型层面(外扩顶点法线等)
- 光照层面(计算出类似边缘光的效果)
以上方法各有各的应用场景,针对此角色只测试了基于法线外扩的方法和后处理边缘检测的方法,后处理方法因为不能快捷方便的区分出需要描边的物体,并且性能消耗略高,就被淘汰使用了。
在法线或者顶点外扩的方法中会多绘制一次物体,这个被重复绘制的物体需要比正常绘制的物体大一圈且被其遮挡住才行,并且一般情况下是为每种着色Shader添加一个描边Pass,而不是多使用一个描边材质,这里对描边Pass设置前面剔除的方式,深度测试设置为小于等于来确保背面颜色只在描边的地方显示,至于如何外扩可以有两种办法,一个是顶点外扩,即对顶点直接进行缩放,但是这种办法会带来一个直接的问题就是描边不等宽,这点部分取决于模型的顶点。另一种就是顶点沿法线方向外扩,这种办法对于一些边缘圆滑的物体来说表现会很好,但是对于边角比较规整且有大角度变化的地方会产生描边断裂的问题,当然也可以解决,就是在引擎外部将模型法线平滑后作为顶点色来使用。
最终采用的是法线的方法,这种描边方法还需要兼顾到两个问题,一个是不同观察距离的情况下屏幕上描边的粗细看起来应该相差无几,另一个是针对相机不同的FOV情况下屏幕上的描边粗细应该尽可能相似。
因此法线外扩选择在世界空间中完成,对于透视相机来说可以通过使用视空间顶点Z值和相机FOV来解决上面提到的问题:
float ComputeOutlineFixValue(float positionVS_Z)
{
//problem.1
float fixedValue = abs(positionVS_Z);
fixedValue = saturate(fixedValue);
//problem.2
//114.59f ·= 2.0 * (180 / 3.1415926)
fixedValue *= atan(1.0f / unity_CameraProjection._m11) * 114.59f;
return fixedValue * _fixedValueScaleFactor;//_fixedValueScaleFactor is some small number
}
positionWS += normalWS * _OutlineWidth * input.color.r * ComputeOutlineFixValue(positionVS.z);
注意一下正交相机的情况与这里不同,就无需兼顾FOV的问题了,去除掉上述ComputeOutlineFixValue函数中problem.2的处理就可以。
除此之外,针对譬如头发等模型,还需要一个深度偏移值来处理拥有复杂远近关系的描边,可以存在顶点颜色通道里进行控制,也可以通过在顶点着色器中使用模型层面的全局偏移值来达到类似效果:
float4 ClipPosZOffset(float4 positionCS, float viewSpaceZOffset)
{
float modifiedPositionVS_Z = - positionCS.w - viewSpaceZOffset;
float modifiedPositionCS_Z = modifiedPositionVS_Z * UNITY_MATRIX_P[2].z + UNITY_MATRIX_P[2].w;
positionCS.z = modifiedPositionCS_Z * positionCS.w / (-modifiedPositionVS_Z);
return positionCS;
}
描边效果如图所示:
除了上面所提到的,描边也可以通过采样噪声图来控制描边粗细来实现艺术化的效果,这里就不细致展开了。
C. 表面着色
首先交代一下,测试角色所使用的着色方法以及技术取舍都是有背景约束的,比如在TOD(Time Of Day)昼夜系统下的平行光方向和颜色都在实时变化,面部的阴影如何保持全天候都有可控表现,不会出现某一光照方向下阴影被拉长的问题,以及昼夜环境下的角色如何做到亮度适中,不会出现白天过曝夜晚过暗的情况等,这些可能都是首先考虑的问题。
c1. 头发着色
头发的处理相对常规一些,网格模型为了兼容描边使用的是具有体积的Mesh片,光影着色主要由三部分组成,分别是颜色色阶处理、边缘光效果以及天使环效果。
对于当前角色头发来讲,一层色阶(明暗)处理就已经能够达到预期了,只要确保可以留给美术充分的调整空间即可,如使用自定义参数来修整明暗边界的偏移(_ShadeColorOffset)以及柔化程度(_ShadeColorFeather):
half3 HairColorGrade(half3 baseColor, float NdotL, float shadowAtten)
{
float shadingGrade = NdotL * saturate(shadowAtten);
shadingGrade = saturate((1.0 + (_ShadeColorOffset - _ShadeColorFeather - shadingGrade) / _ShadeColorFeather));
return lerp(baseColor, baseColor * _ShadeColor, shadingGrade);
}
效果如下:
另一个边缘光(Rim)效果这里统一说一下,后面身体和面部的Rim和这里的实现相同,是通过法向量和视向量的夹角来判定Rim强度,这里乘上衰减后只对直接光照区表现Rim,Shader代码如下:
half3 ToonRim(half3 NormalDir, half3 ViewDir, half3 LightDir, half3 LightColor, float shadowAtten)
{
float rimArea = 1.0 - saturate(dot(NormalDir, ViewDir));
rimArea *= (0.5 * dot(NormalDir, LightDir) + 0.5) * saturate(shadowAtten);
return _RimColor * LightColor * smoothstep(1 - _RimOffset, 1 - _RimOffset + _RimFeatherLevel * _RimOffset, rimArea);
}
添加边缘光效果后:
各向异性高光使用的是比较常用的Kajiya-Kay模型,使用头发切线T来代替法线N计算光照,同时根据需求,添加了两套参数分别控制两层高光圈:
half3 AnisoSpecular(half3 N, half3 T, half3 V, half3 L, half2 uv)
{
const half shiftTex = tex2D(_SpecularShift, TRANSFORM_TEX(uv, _SpecularShift)) - 0.5f;
const half3 t1 = normalize(T + N * (_PrimaryShift + shiftTex));
const half3 t2 = normalize(T + N * (_SecondaryShift + shiftTex));
half3 specular = half3(0.0, 0.0, 0.0);
specular += _PrimaryColor * KajiyaKayModel(t1, V, L, _PrimaryWidth, _PrimaryBrightness);
specular += _SecondaryColor * KajiyaKayModel(t2, V, L, _SecondaryWidth, _SecondaryBrightness);
return specular;
}
以上函数中使用了Specular Shift噪声贴图,旨在对高光沿发丝方向进行扰动:
添加各向异性高光效果后:
较卡通风格的各向异性高光做法一般会选择按照头发模型正面展开后手绘一张高光图,然后使用第二套UV来采样,只不过这种天使环效果不会随相机移动而移动。
c2. 身体着色
身体部分包含皮肤和衣服的渲染,目前是使用统一的Shader来进行处理,用Mask图来区分,皮肤用了简单的漫反射颜色,并留出用来调整阴影颜色的变量来与后面要说的面部阴影的颜色对齐。衣服部分的光照计算也相当简单,直接光照计算模型是使用微改后的Cook-Torrance的BRDF,漫反射分量就是简单的Lambert,高光部分使用的是基于物理的法线分布函数。间接光的漫反射部分是用的球谐函数,间接光高光部分采的场景内的ReflectionProbe Cube图。然后配合着上面说过的Rim光和描边,就基本可以达到预期。
因为身体阴影部分表现不出盔甲金属材质效果,并且仅有间接光的情况也达不到预期,就额外添加了背面光照计算,就是将平行光照方向反向后再计算一次高光,之后使用阴影衰减系数来对背面光与直接光照进行插值。
以下是关闭和开启背面光的效果比对:
c3. 面部着色
对于角色面部的处理是最细小和繁杂的,并且面部阴影的质量需要达到比较高的程度,因此可能会在不同的阴影方法中动态切换或组合,当前的方案除了使用系统的级联阴影(Cascade Shadow Map, CSM)还会结合面部距离场阴影(Facial Signed Distance Field Shadow, FSDFS)和自阴影贴图(Custom Self Shadow Map, CSSM),无论场景如何,CSM和FSDFS都会默认使用,然后视角色距离主相机的远近程度或上层逻辑选择性使用CSSM。
依据这样的逻辑,面部Shader中的编译宏可以按照如下定义:
#pragma multi_compile _ DEFAULT_SHADOW DEFAULT_CUSTOM_SHADOW
以上三者首先接触的是FSDFS,使用此技术可以融入一些具有艺术风格的阴影表现,这个是CSM和CSSM所做不到的,同样也可以避免为了达到较高质量阴影效果而选择平滑面部法线的繁重工作量。
面部距离场阴影会用到一张SDF图,美术制作流程上需要先手绘出0至180度间每隔固定角度下的面部阴影图,然后将这些序列图进行距离场插值处理后生成一张阈值图,具体的做法和插值方法可参照其它关于SDF面部阴影的文章。按照当前角色面部模型UV展开,SDF图如下:
此图采样出来可以直接作为线性空间内计算使用的数据,因此应关闭sRGB,其次SDF贴图的合理大小视使用情况而定,如果阴影边缘进行了柔化插值处理,贴图最低至128*128就能提供不错的表现,如果光影交界处较硬,大小需要导出至256*256以上才可以保证不会有明显的锯齿,贴图格式建议使用单通道,半精度以上,不需要开启贴图压缩功能。在Shader里对SDF图采样时可能会用到采样器,过滤模式选择Bilinear是比较平衡的,既不会失去表现又保证了效率在可控范围内。
Shader中的对这张图的使用也非常关键,主要思路是拿到面部模型在世界空间中的前向量和左向量,然后通过判断左向量和光照方向的夹角来决定是否反转用于采样SDF图的UV中的U(横向分量),这样做的前提是面部展开后是轴对称的,然后将前向量与光照方向的夹角转换至0-1范围,再与SDF图采样结果进行比较来获得阴影衰减系数,具体Shader函数代码如下:
float FSDFS(float2 uv, half3 lightDir)
{
half2 Lnorm = normalize(lightDir.xz);
half LdotF = dot(Lnorm, normalize(_faceForwardDirWS.xz));
uv.x = (1.0 - uv.x) + (2.0 * uv.x - 1.0) * step(0, dot(Lnorm, normalize(_faceLeftDirWS.xz)));
float shadowThreshold = SAMPLE_TEXTURE2D(_SDFMap, sampler_SDFMap, uv);
return smoothstep(0.0f, _ShadowEdgeSmoothFactor, saturate(shadowThreshold - acos(LdotF) / 3.1415926f));
}
上面_faceForwardDirWS和_faceLeftDirWS两个变量指代的是世界空间中的面部前向量和面部左向量,需要从脚本中更新传入。经过测试后,在角色播放跑动的动画时在Shader中使用unity_ObjectToWorld矩阵对面部模型本地空间的前(左)向量做空间变换的方法不可行,原因在于脸部Skinned Mesh Renderer的Root Bone Transform是使用的Neck变换组件,旋转不符合预期。
因此可以在面部Game Object下添加两个子类Game Object,分别作为前锚点和左锚点,在脚本Update函数中拿到Transform组件上的世界空间位置分别与面部Transform.Position作差获得_faceForwardDirWS和_faceLeftDirWS。
仅使用距离场阴影的面部在不同光照角度下的表现如下:
接下来就需要考虑角色头发给面部投射的高质量自阴影该如何实现了,大体思路是光照方向上额外渲染一张头发的ShadowMap,具体来说就是在每个角色实例根部上挂一个脚本,在启用脚本时:
- 创建渲染阴影使用的正交相机
- 创建阴影贴图
- 缓存子类中头发(Caster)和面部(Receiver)的渲染材质
- RenderPipelineManager.beginCameraRendering关联渲染自阴影函数
- 面部材质Shader中阴影相关的编译宏启用DEFAULT_CUSTOM_SHADOW,禁用DEFAULT_SHADOW
注:这里是自定义的宏,代表既会使用系统的级联阴影也会使用自阴影,与之相对的还有DEFAULT_SHADOW,代表只使用系统阴影图,距离场阴影因为默认开启,这里不需要考虑。
在禁用脚本时:
- 将渲染自阴影行为从RenderPipelineManager.beginCameraRendering中移除
- 销毁阴影贴图
- 销毁正交相机
- 面部材质Shader中阴影相关的编译宏禁用DEFAULT_CUSTOM_SHADOW,启用DEFAULT_SHADOW
在构造正交相机相关的投影矩阵时,我们还需要计算能够包围住头发的最小球体的半径大小,并以此来构建出投影范围,这样会尽可能的将头发深度信息在阴影图中的占比提高,并提升阴影质量。
将投影范围可视化后,见下面左图黄框,光照是正对着头顶斜上方打过来,渲染出的阴影图如下方右图所示:
头发投下的阴影效果基本可以达到预期,
这种办法因为跟随场景光照方向,在一些特殊角度下,阴影可能会被拉扯的很长,影响表现,如下图所示:
可能还会需要一张额外的额头Mask图来避免阴影拉扯过于不自然,因此在取舍之后决定使用固定方向光照,不再追随场景光源,这一点改动总体来说利大于弊,只需要在角色预制件上调整好固定的偏移角度,就不再需要关心之后如何动态关联光照,并保证了阴影的可控表现。
到这里CSSM相关的就结束了,但是细心的人可能会发现一个问题,就是头发究竟有没有渲到系统的CSM中去?也许我在这里可以只采样CSSM来表现头发投给面部的阴影效果,但是如果只这么办的话,其它物体投给面部的阴影效果该如何体现出来?
这里可以有几种解决办法,一个最易想到的就是将头发从CSM渲染队列中剔除出去,但是这样的话管理起来比较麻烦,需要为头发添加额外的标记,还有一种办法就是头发还会正常渲染CSM,但面部Shader使用阴影坐标对CSM采样之前会对其z分量进行适当偏移,这个偏移值视情况(取决于角色尺寸以及CSM首层距离配置参数)而定,这里取0.01左右:
#if defined(TOONFACE_SHADING) && defined(DEFAULT_CUSTOM_SHADOW)
shadowCoord.z += _faceShadowBias;
#endif
shadowAttenDefault = MainLightRealtimeShadow(shadowCoord);
这样保证不会在CSM中采到头发的阴影,但是会有更远距离的其它遮挡物的投影,至于CSM、CSSM和SDF三者如何融合,取两次min就好:
shadowAtten = min(CSM, min(CSSM, SDF));
结合效果如下图所示:
再之后面部就还有两处地方需要简单说明一下,一个是眼睛的处理,目前是与面部共用同一个Shader,用Mask图做了区分,计算上直接使用的Albedo颜色,高光点是画到贴图上的。另一处是鼻尖描边的处理,直接使用法线描边,效果不理想,因此参考罪恶装备的做法,在鼻尖添加了一个片,这样不同角度下都可以有不错的效果,只不过需要通过顶点颜色来控制法线外扩描边的粗细,避免与贴片冲突。
D. 性能分析
目前版本的角色渲染涉及到CPU这边处理逻辑的内容较少,主要集中在GPU,且渲染流程上的添加改动也只局限于自阴影的渲染,因此不需要额外考虑某个流程造成渲染阻塞(或者称作填充率)的问题。
这里主要从角色渲染材质Shader指令数、GPU耗时(单次DrawCall耗时统计)、贴图美术资源三方面入手来进行衡量。
首先使用Render Doc对小米11安卓机进行截帧,统计了连续十帧下角色各部分卡通渲染绘制GPU平均耗时(单位为微秒)与相同Mesh下采用通用Lit/Simple Lit绘制GPU平均耗时的比对结果,见下表:
注:同样的相机角度下且保证模型贴图都一致
渲染自阴影的Draw Call GPU耗时平均为30.37微秒,角色描边GPU耗时平均为235.31微秒。目前单角色卡通渲染的GPU耗费都在一个可控的范围内,后期可能需要进一步优化模型面数,不需要当前这样精细。依旧在相同真机上,接下来测试场景中多个角色实例(视野内可见10个实例)的情况,首先关闭垂直同步设置,在均带有自阴影贴图的情况下是在36 FPS至38 FPS间徘徊,不带有自阴影的情况下为39FPS至42FPS,使用Lit/Simple Lit分别是38FPS至42FPS与41FPS至45FPS。因此对于是否渲染自阴影的上层逻辑要控制好,把自阴影图最大数量控制在一个合理的范围内,之后可以尝试拿Projector来实现。
通过在unity中选择current graphics device来编译角色Shader,可以获取到指令数信息,因为shader变体有很多,这里只列举Toon Shading(包含头发皮肤以及身体)的前向渲染管线下的光照着色计算平均情况和最差情况。
普通Lit Shader的平均指令数也在300至400之间,因此以上指令数对于卡通渲染Shader来说还有待优化。
目前角色除去上面已经说过的面部SDF贴图和头发的Shift贴图之外仅用到了一张Albedo贴图以及一张法线图,然后将法线图B通道四等分后来存材质属性的参数,RG通道来存法线的两个分量,好在法线Z分量一般趋近于1,才使得两通道还原出的效果与原有三通道相差不大,但在节省了内存占用的同时会造成Shader内多次采样的问题。同时在制作时尽可能使得贴图大小为2的倍数,这样在提交GPU前不会有补齐的操作,节省了效率。
E. 后期优化方向
整体看下来,这版角色渲染还有很多需要改进提升的地方,就比如面部的风格和头发与身体的风格还有待协调等,表现上的一些小细节诸如上面提到的描边效果通过预存顶点色控制ZOffset或者使用平滑后的法线,以及头发高光使用高光图的方案等都可以视情况添加。
除了表现细节的优化之外,还会涉及大量的性能内存优化,下面会简要的进行一下整理,正式优化工作需要在确定了最终角色美术风格后进行。
从材质Shader入手,大体上先做如下的分类,可能会覆盖不全面,之后如果遇到会再更新添加:
- 浮点数精度。除去位置深度UV等信息使用float类型外,其余都尽可能使用half或fixed类型
- 浮点数计算。如面部SDF阴影中的距离判断无可避免的话就使用step代替if,一些空间变换相关的矩阵乘法尽可能避免Shader内多个矩阵相乘,如果需要就遵循矩阵乘向量的方式,然后尽可能使用向量乘积求和的方式展开来优化。尽量避免使用pow函数、log函数、反三角函数等消耗性能的函数,如果确实需要可以给定一个粗糙的查找表来代替实时计算
- Shader LOD。添加不同LOD等级下的SubShader
- Shader Pass。精简multi_compile编译宏,尽可能使用shader_feature来代替
- 材质贴图资源。确保贴图大小、压缩格式、mipmap以及可读可写配置合理。通过使用mipmap提高贴图采样Cache命中率
- 像素剔除。尽可能避免Clip(Discard),在一些移动端GPU上此操作会更昂贵
从模型以及组件上来说,尽量减少同个角色上Skinned Mesh Renderer的数量,当且仅当只有一个Skinned Mesh Renderer的时候Unity会进行动作优化以及可见性裁剪。
除了角色材质着色器上一些需要关心的,还有一点要说的是overdraw的问题,overdraw会带来冗余计算以及带宽的占用与浪费。应该合理使用合批,尽管大面积使用合批可能会减少DrawCall数量以及CPU负载,但因为单次Drawcall下无法再区分渲染顺序,所以可能会引起严重的overdraw问题,另外一点是尽可能的减少在角色上使用半透明材质,在通常不写入ZBuffer的情况下会造成相当多的冗余计算,后期效果同样是造成此类问题的凶手之一,至少在移动平台上应尽量避免使用。
欢迎加入我们!
感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com
#游戏引擎##搜狐畅游##求职##校招##技术美术#