1. 从“纸片人”到“立体感”:卡通渲染的视觉核心到底在哪
很多人第一次尝试在Unity里复刻卡通渲染,都会掉进同一个坑:把模型往场景里一放,加个方向光,然后开始调Ramp贴图,结果发现角色要么像糊了一层泥,要么边缘死黑一片,完全没有那种干净通透的感觉。问题出在哪儿?其实不在Ramp本身,而在于你没有理解卡通渲染的视觉核心——它本质上是一套“做减法”的渲染方案。
现实世界的物理光照是连续的、复杂的,漫反射、高光、环境反射、次表面散射等等混在一起。而卡通渲染要做的事情,是把这些连续变化的光照信息,压缩成几个离散的色阶。你可以把它想象成把一张彩色照片做“色块化”处理:原本渐变的天空变成了几块纯色,原本柔和的面部阴影变成了明确的明暗分界线。这个“分界线”的位置、形状、柔和程度,才是决定卡通渲染好不好看的关键。
在《原神》这类角色的渲染中,你会看到几个非常明显的特征:面部几乎没有阴影过渡,身体上的明暗交界线干净利落,金属部分有夸张的高光形状,边缘有一圈淡淡的轮廓光。这些效果单独拆开来看都不复杂,但组合在一起就需要一套完整的管线来支撑。我这次要做的,就是把这套管线从零搭起来,不依赖任何现成的Toon Shader包,全部用ShaderLab手写,这样你才能真正理解每一行代码在干什么。
这篇文章适合谁看?如果你已经写过简单的Surface Shader或者Vertex/Fragment Shader,知道什么是法线、什么是切线空间,但一看到卡通渲染就不知道从哪儿下手,那这篇内容就是为你准备的。我会从最基础的光照模型改造开始,一步步加上Ramp采样、面部阴影修正、边缘光、高光形状控制,最后给出一个可以直接用的完整Shader框架。整个过程我会解释每一个参数为什么这么设,而不是甩一堆代码让你自己猜。
2. 光照模型的改造:从Lambert到Half-Lambert再到自定义Ramp
2.1 为什么标准Lambert在卡通渲染里不能用
Unity内置的Lambert光照模型计算的是NdotL,也就是法线方向和光照方向的点积。这个值在0到1之间连续变化,乘以光照颜色和漫反射颜色之后,就得到了一个连续渐变的漫反射结果。问题在于,这个连续渐变在卡通渲染里是“太脏了”——明暗交界处会有一大片灰色过渡,看起来像没洗干净的脸。
有人会说,那我用Half-Lambert不就行了?Half-Lambert把NdotL从[-1,1]映射到[0,1],确实让暗部亮了一些,过渡也柔和了一些。但这恰恰是卡通渲染不需要的——我们要的是明确的明暗分界,而不是更柔和的过渡。Half-Lambert适合的是那种偏写实的、想要柔和阴影的风格,放在卡通渲染里反而会让角色看起来“灰蒙蒙”的。
所以正确的做法是:保留NdotL的计算,但在它之后插入一个Ramp采样步骤。这个Ramp贴图是一张一维的渐变图,横轴是NdotL的值,纵轴是最终输出的亮度。你可以在这张贴图上画出你想要的任何色阶分布:比如0到0.4是暗部,0.4到0.45快速过渡到亮部,0.45到1是亮部。这样NdotL连续变化的时候,采样出来的结果就是离散的色阶了。
2.2 Ramp贴图的制作要点与常见误区
Ramp贴图的制作看起来简单,其实有几个坑我踩过好几次。第一个坑是贴图的Wrap Mode。如果你把Ramp贴图的Wrap Mode设成Repeat,当NdotL稍微小于0或者大于1的时候,采样会跑到贴图的另一端,导致暗部突然变亮或者亮部突然变暗。正确的做法是设成Clamp,让超出范围的值被钳制在边缘。
第二个坑是贴图的Filter Mode。如果你想要硬边的色阶过渡,Filter Mode要设成Point;如果你想要稍微柔和一点的过渡,可以设成Bilinear。但注意,Bilinear会让过渡区域变宽,如果你在贴图上画的过渡区域本来就很窄,Bilinear可能会把它模糊掉。我一般会在贴图上画一个稍微宽一点的过渡区域,然后用Bilinear采样,这样既保留了硬边感,又不会出现锯齿。
第三个坑是贴图的颜色空间。Unity默认的Color Space是Gamma,但如果你在Linear空间下工作,Ramp贴图的采样结果会经过一次Gamma校正,导致颜色变亮。解决办法是把Ramp贴图的sRGB选项关掉,让它作为线性数据参与计算。这个细节在官方文档里很少提,但实际项目中如果不注意,就会导致暗部亮度对不上。
下面是一个典型的Ramp采样代码片段:
// 计算NdotL float NdotL = dot(normalize(i.normal), normalize(_WorldSpaceLightPos0.xyz)); // 映射到0-1范围 float rampUV = NdotL * 0.5 + 0.5; // 采样Ramp贴图 float3 rampColor = tex2D(_RampTex, float2(rampUV, 0.5)).rgb; // 应用到漫反射 float3 diffuse = _LightColor0.rgb * _BaseColor.rgb * rampColor;这段代码里,rampUV的计算用的是NdotL * 0.5 + 0.5,而不是直接NdotL。这是因为NdotL的范围是[-1,1],而UV坐标的范围是[0,1],所以需要做一次映射。如果你用的是Half-Lambert,那NdotL本身就在[0,1]范围内,就不需要这个映射了。但如前所述,卡通渲染一般不用Half-Lambert。
2.3 多光源环境下的Ramp处理策略
在实际场景中,角色往往不止受到一个方向光的影响。Unity的Forward渲染路径下,每个逐像素光源都会调用一次ForwardBase或ForwardAdd Pass。如果你在ForwardAdd里也做Ramp采样,那多个光源的Ramp结果叠加起来,就会导致暗部被多次提亮,最终效果完全失控。
我的做法是:只在ForwardBase Pass里做Ramp采样,ForwardAdd Pass里用普通的Lambert或者直接不做漫反射。因为卡通渲染的核心光源通常只有一个主方向光,其他光源更多是作为补光或者环境光存在,不需要它们也产生硬边的明暗分界。如果你确实需要第二个光源也产生卡通效果,那可以考虑把两个光源的方向和颜色合并成一个虚拟主光源,在Shader外部计算好再传进来。
还有一种情况是点光源或者聚光灯。这些光源的方向是逐像素变化的,NdotL的计算结果也会逐像素变化,Ramp采样出来的色阶边界会变得扭曲。对于这种情况,我一般会把点光源的漫反射贡献直接砍掉,只保留它的高光贡献。因为卡通渲染的漫反射本来就是风格化的,少一个点光源的漫反射并不会让画面变差,反而能让明暗关系更干净。
3. 面部阴影的单独处理:为什么你的角色脸上总是脏兮兮的
3.1 面部阴影和身体阴影的本质区别
如果你直接把身体上的Ramp逻辑套到脸上,大概率会得到一个“阴阳脸”——鼻子一侧亮、另一侧暗,颧骨下方有一块三角形的阴影,下巴下面还有一圈黑边。这是因为人脸是一个复杂的曲面,法线方向变化剧烈,NdotL的计算结果在面部区域会产生很多高频变化。而卡通渲染追求的是干净的面部,不需要这些细节。
《原神》这类游戏的做法是:面部阴影不依赖NdotL,而是依赖光照方向在水平面上的投影。具体来说,就是把光照方向投影到角色的本地空间,然后根据投影的角度来决定面部的明暗。这样无论面部法线怎么变化,只要光照方向不变,面部的明暗分界线就是一条固定的线。
这个思路听起来简单,但实现起来有几个细节要注意。首先,你需要把光照方向转换到角色的本地空间,而不是世界空间。因为角色会旋转,如果用在世界空间计算的光照方向,角色一转,脸上的明暗分界线就会跟着转,看起来就像光在动而不是人在转。其次,你需要一张面部阴影的Mask贴图,用来控制哪些区域参与明暗计算,哪些区域永远保持亮部(比如额头、鼻梁)。
3.2 用SDF贴图实现面部阴影的平滑过渡
单纯用角度来判断明暗,会得到一个硬边的分界线,看起来像戴了一个面具。为了让分界线稍微柔和一点,同时保持卡通感,可以用一张SDF(Signed Distance Field)贴图。这张贴图记录了每个像素到明暗分界线的距离,采样之后可以得到一个平滑的过渡值。
制作SDF贴图的方法有很多,我一般是在Photoshop里画好明暗分界线,然后导出成灰度图,再用一个脚本把它转换成SDF。转换的原理是:对于每个像素,找到最近的边界像素,计算距离,然后归一化到0-1范围。Unity的Texture Import Settings里有一个“Generate Distance Field”选项,可以直接把灰度图转成SDF,省去了自己写脚本的麻烦。
有了SDF贴图之后,面部阴影的计算就变成了:
// 把光照方向转到本地空间 float3 lightDirLocal = mul((float3x3)unity_WorldToObject, _WorldSpaceLightPos0.xyz); // 计算水平角度 float lightAngle = atan2(lightDirLocal.x, lightDirLocal.z); // 映射到0-1范围 float angleUV = lightAngle / (2 * PI) + 0.5; // 采样SDF贴图 float faceShadow = tex2D(_FaceShadowTex, float2(angleUV, 0.5)).r; // 用SDF值来控制明暗 float3 faceColor = lerp(_ShadowColor.rgb, _BaseColor.rgb, faceShadow);这段代码里,lightAngle的计算用的是atan2(x, z),而不是atan2(x, y)。这是因为面部的明暗分界线主要受水平方向的光照影响,垂直方向的光照(比如从头顶打下来的光)对面部阴影的影响很小。如果你用atan2(x, y),那光照从头顶打下来的时候,面部会突然变暗,这显然不对。
3.3 面部阴影的常见问题与调试技巧
面部阴影最容易出现的问题就是左右不对称。这是因为SDF贴图通常是左右对称的,但光照方向转到本地空间之后,左右的角度计算可能会出现偏差。解决办法是在计算lightAngle之前,先把lightDirLocal.x取绝对值,这样左右两侧的计算结果就是对称的。但注意,取绝对值之后,你还需要根据lightDirLocal.x的符号来决定是采样SDF贴图的左半边还是右半边。
另一个常见问题是面部阴影在角色转头时闪烁。这通常是因为atan2在角度接近±π的时候会出现跳变,导致SDF采样值突变。解决办法是在角度接近±π的时候做一个平滑过渡,或者直接用dot乘积来代替atan2。用dot的话,计算的是光照方向和面部正前方的夹角余弦值,虽然精度不如atan2,但胜在稳定,不会出现跳变。
调试面部阴影的时候,我建议先把SDF贴图的采样结果直接输出成颜色,看看明暗分界线的位置对不对。如果分界线位置不对,就调整SDF贴图的绘制;如果分界线位置对但过渡太硬,就调整SDF贴图的模糊程度。这个调试过程可能需要反复几次,但一旦调好,后面换角色模型的时候只需要重新画SDF贴图就行了,Shader代码不用动。
4. 边缘光与高光:让角色从背景中“跳”出来的关键
4.1 边缘光的三种实现方式与选型建议
边缘光(Rim Light)是卡通渲染里提升立体感最有效的手段之一。它的原理很简单:在模型的边缘区域叠加一层额外的颜色,让角色看起来像是被一圈光包围着。实现方式主要有三种:
第一种是基于NdotV的边缘光。计算法线和视线方向的点积,点积越小说明越靠近边缘,然后把这个值取反,乘以边缘光颜色。这种方式的优点是计算简单,缺点是边缘光的宽度会随着视角变化而变化——正对着看的时候边缘光很窄,斜着看的时候边缘光很宽。
第二种是基于屏幕空间深度的边缘光。在屏幕空间里对深度图做边缘检测,把检测到的边缘叠加到角色上。这种方式的优点是边缘光宽度固定,不受视角影响,缺点是需要额外的屏幕空间Pass,性能开销较大,而且和场景中的其他物体边缘容易混淆。
第三种是基于顶点法线扩展的边缘光。在顶点着色器里把顶点沿着法线方向向外扩展一点,然后在片元着色器里判断哪些像素属于扩展出来的部分。这种方式的优点是边缘光宽度完全可控,缺点是会改变模型的轮廓,对于法线不连续的模型(比如头发)效果不好。
我一般用第一种方式,因为它的性能最好,而且通过调整参数可以模拟出接近第二种方式的效果。具体做法是:把NdotV映射到一个可控的范围内,然后用smoothstep来控制边缘光的硬度和宽度。比如:
float NdotV = dot(normalize(i.normal), normalize(i.viewDir)); float rim = 1.0 - saturate(NdotV); rim = smoothstep(_RimThreshold, _RimThreshold + _RimWidth, rim); float3 rimColor = _RimColor.rgb * rim * _RimIntensity;这里_RimThreshold控制边缘光的起始位置,_RimWidth控制边缘光的宽度,_RimIntensity控制边缘光的强度。这三个参数需要配合调整,才能得到自然的效果。
4.2 高光形状的控制:从Blinn-Phong到各向异性
卡通渲染的高光和写实渲染的高光有本质区别:写实渲染的高光是一个模糊的光斑,而卡通渲染的高光是一个明确的形状。这个形状可以是圆形、椭圆形、甚至是自定义的图案。要实现这种效果,就不能用标准的Blinn-Phong模型,因为它的高光衰减是连续的,没有明确的边界。
我的做法是:先用Blinn-Phong计算出高光的强度,然后用一个阈值把它二值化,再用smoothstep做一点柔化。这样得到的高光就是一个有明确边界的形状了。如果你想要更复杂的高光形状,可以把高光强度映射到一张贴图的UV上,然后采样那张贴图。比如头发的高光通常是一条横向的带状区域,就可以用一张横向渐变的高光贴图来实现。
对于金属材质的高光,还需要考虑各向异性。各向异性高光的计算需要用到切线方向,公式比各向同性高光复杂一些,但核心思想是一样的:把高光强度映射到一个可控的范围内,然后做二值化处理。Unity的Standard Shader里已经有各向异性的实现,你可以参考它的代码,把高光部分替换成卡通风格的处理。
4.3 边缘光和高光的叠加顺序与混合模式
边缘光和高光都是叠加在基础颜色之上的,但它们的叠加顺序会影响最终效果。我一般的顺序是:先算漫反射,再叠加高光,最后叠加边缘光。这样做的原因是,高光通常位于模型的正面区域,而边缘光位于模型的边缘区域,两者的重叠区域很小,所以顺序影响不大。但如果你的边缘光强度很高,先叠加边缘光再叠加高光,高光可能会被边缘光冲淡。
混合模式方面,高光一般用Additive或者Screen模式,边缘光一般用Additive模式。Additive模式会让颜色变亮,适合表现发光效果;Screen模式会让颜色变亮但不会过曝,适合表现柔和的光晕。你可以根据实际效果选择合适的混合模式。
还有一个细节是边缘光的深度测试。如果你希望边缘光只在角色被遮挡的地方出现(比如角色站在墙后面,只露出一个轮廓),那就需要开启深度测试,并且把边缘光的渲染队列设成Transparent。但这样做的性能开销会大一些,而且边缘光可能会被其他透明物体遮挡。我一般只在特定场景下才开启这个功能,平时还是用普通的Opaque渲染。
5. 完整Shader框架的搭建与参数调试指南
5.1 Shader属性面板的参数分组与默认值设定
一个可用的卡通渲染Shader,属性面板里至少有二十几个参数。如果全部堆在一起,调试的时候会非常痛苦。我的做法是按功能分组,用[Header]属性来分隔。比如:
Properties { [Header(Base)] _BaseColor ("Base Color", Color) = (1,1,1,1) _BaseMap ("Base Map", 2D) = "white" {} [Header(Ramp)] _RampTex ("Ramp Texture", 2D) = "white" {} _RampThreshold ("Ramp Threshold", Range(0,1)) = 0.5 _RampSmoothness ("Ramp Smoothness", Range(0,0.5)) = 0.1 [Header(Face)] _FaceShadowTex ("Face Shadow SDF", 2D) = "white" {} _FaceShadowOffset ("Face Shadow Offset", Range(-1,1)) = 0 [Header(Rim)] _RimColor ("Rim Color", Color) = (1,1,1,1) _RimThreshold ("Rim Threshold", Range(0,1)) = 0.7 _RimWidth ("Rim Width", Range(0,1)) = 0.2 _RimIntensity ("Rim Intensity", Range(0,5)) = 1.0 [Header(Specular)] _SpecColor ("Specular Color", Color) = (1,1,1,1) _SpecThreshold ("Specular Threshold", Range(0,1)) = 0.8 _SpecSmoothness ("Specular Smoothness", Range(0,0.5)) = 0.05 _SpecIntensity ("Specular Intensity", Range(0,5)) = 1.0 }这样在材质面板里,参数会按组折叠,调试的时候一目了然。默认值的设定也很重要:_RampThreshold默认0.5,_RampSmoothness默认0.1,_RimThreshold默认0.7,_RimWidth默认0.2。这些默认值是我经过多次测试之后觉得比较通用的,你可以根据自己的模型和场景微调。
5.2 顶点着色器与片元着色器的数据传递
在顶点着色器里,除了常规的position、normal、uv之外,我还需要传递切线方向(用于各向异性高光)和视线方向(用于边缘光)。视线方向可以在顶点着色器里计算,也可以在片元着色器里计算。在顶点着色器里计算的好处是省性能,坏处是对于大面积的模型(比如角色的身体),顶点插值出来的视线方向可能不够准确。我一般是在顶点着色器里计算视线方向,然后在片元着色器里重新归一化。
struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 tangent : TANGENT; float2 uv : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 normal : TEXCOORD1; float3 tangent : TEXCOORD2; float3 viewDir : TEXCOORD3; float3 worldPos : TEXCOORD4; }; v2f vert (appdata v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.uv; o.normal = UnityObjectToWorldNormal(v.normal); o.tangent = UnityObjectToWorldDir(v.tangent.xyz); o.viewDir = WorldSpaceViewDir(v.vertex); o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz; return o; }这里注意WorldSpaceViewDir返回的是从顶点指向摄像机的方向,而不是从摄像机指向顶点的方向。在计算边缘光的时候,我们需要的是从顶点指向摄像机的方向,所以直接用这个值就行。如果你不小心用了反方向,边缘光会出现在模型的正中间而不是边缘。
5.3 调试过程中的参数联动与常见异常排查
调试卡通渲染Shader的时候,最常见的异常是整体过暗或过亮。这通常是因为Ramp贴图的颜色空间不对,或者光照强度设置有问题。排查方法是:先把Ramp贴图替换成一张纯白贴图,看看基础光照是否正常。如果正常,再换回Ramp贴图,逐步调整RampThreshold和RampSmoothness。
第二个常见异常是边缘光出现在模型内部。这通常是因为法线方向不对,或者视线方向计算错误。排查方法是:把边缘光的输出直接连接到Albedo,看看边缘光的位置是否和预期一致。如果边缘光出现在模型内部,检查法线是否被正确归一化,视线方向是否指向摄像机。
第三个常见异常是面部阴影左右颠倒。这通常是因为SDF贴图的左右方向和光照方向的左右方向不一致。排查方法是:在场景里放一个方向光,从左边照过来,看看面部的亮部是否在左边。如果亮部在右边,把SDF贴图水平翻转一下,或者在Shader里把lightDirLocal.x取反。
第四个常见异常是高光形状不对。这通常是因为高光阈值的设置有问题。如果高光太大,把_SpecThreshold调高;如果高光太小,把_SpecThreshold调低。如果高光边缘太硬,把_SpecSmoothness调大;如果高光边缘太软,把_SpecSmoothness调小。
6. 性能优化与多平台适配的实战经验
6.1 移动端与PC端的Shader变体管理
卡通渲染Shader在移动端和PC端的性能表现差异很大。移动端的GPU通常不支持复杂的数学运算,比如atan2、pow、sin、cos这些函数在移动端上开销很大。如果你的Shader里用了这些函数,在移动端上可能会掉帧。
我的做法是:为移动端单独写一个简化版的Shader,把atan2替换成dot乘积,把pow替换成乘法,把sin/cos替换成查表或者近似公式。比如面部阴影的计算,在PC端用atan2可以得到精确的角度,在移动端可以用dot(lightDirLocal, float3(0,0,1))来近似,虽然精度差一些,但性能提升很明显。
另外,Shader变体的数量也要控制。每增加一个#pragma multi_compile或者#pragma shader_feature,变体数量就会翻倍。我一般只保留必要的变体,比如SHADOWS_SHADOWMASK、LIGHTMAP_ON、DIRLIGHTMAP_COMBINED这些。对于卡通渲染来说,阴影和光照贴图的支持是必须的,但其他的变体可以砍掉。
6.2 贴图合并与采样次数的精简
卡通渲染Shader通常需要采样多张贴图:基础色贴图、Ramp贴图、面部SDF贴图、高光贴图、边缘光贴图等等。每多一次采样,就会多一次内存访问,在移动端上尤其明显。我的做法是:把可以合并的贴图合并到一张贴图的不同通道里。比如把Ramp贴图放在基础色贴图的Alpha通道里,把面部SDF贴图放在高光贴图的Alpha通道里。这样一次采样就可以得到多个信息,减少了采样次数。
但注意,合并贴图之后,贴图的压缩格式可能会受到影响。比如基础色贴图通常用DXT1或者ASTC压缩,如果把Ramp贴图放在Alpha通道里,就需要用DXT5或者ASTC带Alpha的格式,压缩率会降低。所以合并贴图之前要权衡一下:是省采样次数重要,还是省显存重要。对于移动端来说,采样次数通常是瓶颈,所以合并贴图是值得的。
6.3 实际项目中的性能测试与调优流程
在实际项目中,我一般会按以下流程做性能测试和调优:
第一步,在目标设备上跑一个基准场景,记录帧率、GPU耗时、DrawCall数量。第二步,把卡通渲染Shader替换成最简单的Unlit Shader,再跑一次基准场景,看看性能差异有多大。如果差异很大,说明Shader本身的开销是瓶颈。第三步,逐步注释掉Shader里的各个功能模块(边缘光、高光、面部阴影),看看哪个模块的开销最大。第四步,针对开销最大的模块做优化,比如降低采样次数、简化数学运算、减少变体数量。第五步,重复第二步到第四步,直到性能达到目标。
这个流程看起来简单,但实际操作中需要注意:每次只改一个变量,否则无法确定是哪个改动导致了性能变化。另外,测试的时候要关闭其他无关的后台进程,确保测试结果的准确性。
7. 从Shader到完整角色渲染管线的延伸思考
7.1 描边效果的实现方案对比
描边是卡通渲染的另一个重要组成部分,但它的实现方式和Shader本身关系不大,更多是渲染管线层面的问题。常见的描边方案有三种:第一种是背面扩展法,把模型背面沿着法线方向扩展一圈,用纯色渲染,正面正常渲染。这种方案的优点是简单,缺点是对于法线不连续的模型(比如头发、裙摆)效果不好,扩展出来的背面可能会穿插到正面。第二种是屏幕空间边缘检测,在屏幕空间里对法线或者深度做边缘检测,把检测到的边缘画成描边。这种方案的优点是描边宽度均匀,缺点是性能开销大,而且容易把场景中其他物体的边缘也画出来。第三种是顶点着色器扩展法,在顶点着色器里把顶点沿着法线方向向外扩展,然后在片元着色器里判断哪些像素属于扩展出来的部分。这种方案介于前两种之间,性能和效果都比较平衡。
我一般用第一种方案,因为它的性能最好,而且对于大多数角色模型来说,法线不连续的区域并不多。如果遇到头发或者裙摆的描边问题,可以单独给这些部位设置一个较小的扩展距离,或者干脆不描边。
7.2 阴影贴图的接收与投射处理
卡通渲染的角色通常需要接收场景的阴影,但接收阴影之后,阴影的边缘会变得模糊,和卡通渲染的硬边风格不搭。解决办法是:对阴影贴图的采样结果做一次二值化处理,把模糊的阴影边缘变成硬边。具体做法是:先用SHADOW_ATTENUATION得到阴影衰减值,然后用smoothstep做一次阈值处理,把衰减值映射到0或1。这样阴影的边缘就变硬了,和卡通渲染的风格统一了。
但注意,二值化处理会让阴影的边缘出现锯齿。解决办法是:在二值化之前,先对阴影贴图做一次模糊,或者用dither技术来打散锯齿。我一般会在二值化之后,再用一个很小的smoothstep做一次柔化,这样既保留了硬边感,又不会出现明显的锯齿。
7.3 角色渲染的未来扩展方向
这套Shader框架搭好之后,还可以往几个方向扩展。第一个方向是加入次表面散射,让角色的皮肤在强光下有一种透光感。次表面散射的实现方式有很多,简单一点可以用pow(1 - NdotL, power)来模拟,复杂一点可以用预积分的皮肤贴图。第二个方向是加入头发的高光各向异性,让头发的高光沿着发丝方向拉伸,而不是一个圆形的光斑。第三个方向是加入表情驱动的渲染变化,比如角色生气的时候,面部的阴影会加重,眼睛的高光会变亮。这些扩展方向都需要在现有的Shader框架上做增量开发,而不是推倒重来。
我在实际项目中的体会是:卡通渲染的Shader代码本身并不复杂,复杂的是参数调试和效果打磨。同一个Shader,参数调得好就是《原神》级别的效果,调得不好就是十年前的手游水平。所以不要指望抄一份代码就能出效果,一定要花时间在引擎里反复调试,观察每一个参数变化对最终画面的影响。这个过程没有捷径,但一旦你掌握了参数之间的联动关系,后面再做类似的项目就会快很多。
最后分享一个小技巧:在调试Ramp贴图的时候,可以把Ramp贴图的采样结果直接输出到屏幕的右上角,作为一个实时的预览窗口。这样你调整RampThreshold和RampSmoothness的时候,可以立刻看到Ramp曲线的变化,不用反复切换材质面板和场景视图。这个技巧在调试面部SDF贴图的时候同样适用,可以省下不少来回切换的时间。