ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity卡通渲染NPR实战:从Ramp色调分离到描边高光完整指南

Unity卡通渲染NPR实战:从Ramp色调分离到描边高光完整指南 说起 Unity 里的卡通渲染很多朋友第一反应是“不就是把光照打平、加个描边嘛”。真做起来就会发现PBR 的金属粗糙度套路完全用不上角色脸一转到背光就黑成一片描边在硬边处裂成蜘蛛网高光要么油得发腻要么糊成一片。这篇东西就是我实际从零到一做完一个 NPR 卡通角色渲染插件的完整记录从 Ramp 色调分离、法线外扩描边到卡通高光和边缘光的取舍都按“先讲清楚为什么、再给能直接抄的代码、最后列坑”的顺序来写。不管你是刚入行的 Unity 开发者还是想从 PBR 转风格化渲染的技术美术跟着走一遍应该都能在自己的项目里落地。1. 先把 NPR 想清楚渲染目标与技术选型1.1 这一步到底在解决什么问题卡通角色渲染和写实渲染的目标完全不同。PBR 追求的是“这物体在真实光照下看起来是什么样”而 NPRNon-Photorealistic Rendering追求的是“这画面符不符合我要的美术风格”。一个二次元角色如果直接用 Standard Shader 渲染皮肤会显得又油又脏头发会反射出乱七八糟的环境光整个角色和原画放在一起就像两个世界的人。原因很简单卡通角色的核心视觉元素是清晰的色块边界、形状明确的高光、以及勾勒轮廓的描边而不是微表面反射、菲涅尔衰减那一堆物理细节。我在项目里接到这个需求时美术只给了一张原画和一个低模。原画里角色的头发是一整块亮色衣服阴影是硬朗的二分色面部几乎没有明显的立体光影。如果按常规光照模型去推脸部在侧光下马上会出现生硬的鼻影和颧骨阴影这就不像纸片人风格了。所以技术选型的核心判断是我要的不是“更真实的光照”而是“可控的、有风格的明暗分配”。NPR 在 Unity 里的实现路径有很多常见的有基于 Ramp 纹理的色调分离、基于 MatCap 的贴图式光照、以及完全用顶点色手绘明暗的做法。我最终选择了 Ramp 纹理加法线外扩描边作为基础方案因为它对移动端友好、参数直观、美术可以像调色阶一样修改光照风格。这篇文章会围绕这套方案展开并且把踩过的坑一并写出来。1.2 为什么选 Ramp 加描边作为技术起点先对比一下主流方案这样你就知道为什么最终选择 RampToon Ramp渐变纹理映射把光线方向和法线的点积映射到一张水平渐变纹理上。优点是换色流程简单、光照风格完全可控缺点是阴影形状偏“贴图化”需要花时间做出一张好 Ramp 图。MatCap材质捕获把模型法线重映射到屏幕空间然后采一张预烘焙的球形光照图。效果特别适合脸部但方向光响应弱光照角度变化时很容易“穿帮”。直接手绘明暗顶点色 / 贴图控制精度最高但建模和贴图阶段工作量巨大不适合快速迭代。我选 Ramp 还有一个实际原因项目需要支持动态光源角色会从站着不动变成在场景里跑动光照方向会变化。MatCap 在这种情况下几乎不可用顶点色方案又太依赖模型精度。Ramp 方案下同一张纹理可以让角色在不同角度下都保持风格统一的明暗变化美术只需调 Ramp 的阶梯位置和颜色不需要改模型。描边方案我也做了对比。几何外扩描边把顶点沿法线方向推出一点Cull Front 渲染纯色是最常用且稳定的性能可控、不依赖深度纹理、适合角色这种轮廓清晰的物体。屏幕空间深度描边虽然轮廓更细、更准但需要额外的深度相机开销激烈战斗场景里还会出现闪烁。后处理边缘检测倒是有风格化余地但边缘会提取到场景里的桌子、石头对角色轮廓的针对性不够。所以最终选了几何外扩作为主方案完整代码在第五节里。1.3 环境与模型准备工程环境我用的是 Unity 2018.4 LTS 加内置渲染管线代码写的是 ShaderLab 加 CGPROGRAM所以旧项目能直接拖进去用。如果你用的是 URP思路完全一样只是把Lighting.cginc换成 HLSL 的 URP 光照函数后面我单独提一下迁移注意点。模型准备是很多人会忽略的一步。卡通角色对法线极其敏感我推荐在导入模型时就把法线处理平滑在 3D 建模软件里对模型整体进行“法线平滑”处理尤其是头发、裙子这类容易产生硬边的部位。如果没有条件改模型也可以把 Unity 导入设置里的Normals从Import改成Calculate配合光滑度调整但效果通常不如建模软件里直接润色好。另外强烈建议给模型的顶点色烘焙好区域信息后面做描边宽度控制和面部光照开关都要用到这个技巧在第 4.2 节详细说。颜色空间建议用 Linear不然 Ramp 的阶梯过渡在 Gamma 空间下会偏暗卡通色块会显得脏。2. 核心实现之一Toon Ramp 色调分离2.1 从兰伯特光照到 Ramp 纹理映射所有卡通光照的本质都是“简化后的兰伯特光照”。传统兰伯特模型里漫反射强度等于法线方向和光线方向的点积公式是diffuse NdotL dot(normal, lightDir)范围在 -1 到 1 之间负值代表背面。PBR 管线里这个值会被继续拿来算光照响应但在 NPR 里我们要做的第一步就是把它映射到 Ramp 纹理的 U 坐标上。具体做法是rampU NdotL * 0.5 0.5把 -1 到 1 的范围均匀映射到 0 到 1然后直接tex2D(_RampTex, float2(rampU, 0.5)).r采样纹理的红色通道作为漫反射缩放系数。这里 Ramp 纹理如果是 8x1 像素的横条从左到右分别是暗部、中间调、亮部几个色块采样结果天然就会形成阶梯状色带也就是卡通渲染最标志性的“二分阴影”效果。为什么用 Ramp 纹理而不是直接写step(0.5, NdotL)因为美术经常需要微调阴影的落点位置和暗部的色调直接改代码里的数值效率太低。Ramp 纹理的本质就是一张可编程的颜色查找表美术在 PS 里拉一拉色阶Ramp 边缘更锐利还是更圆润、阴影色偏蓝还是偏紫全部能直观控制。我在项目里固定用一张 64x1 的 Ramp 图既保留过渡细节也免得采样浪费带宽。2.2 阶梯色阶与平滑过渡第一次把 Ramp 贴上去时你大概率会得到非常生硬的明暗交界线——这不是 bug是特性。日系卡通很多就是要这种硬切效果。但如果你做的是带有一点厚涂感的角色比如《塞尔达》那种生硬色阶会显得廉价这时需要在 Ramp 图里保留 1-2 个像素的过渡渐变带或者用smoothstep(_RampEdge, _RampEdge _RampSmooth, rampU)对采样后的结果再做一次软化。我的做法是同时保留两种控制手段Ramp 纹理负责“整体风格”Shader 里加了一个_ShadowSmoothness参数负责“局部软化”。具体代码里就是用smoothstep(_ShadowSmoothness, 1 - _ShadowSmoothness, rampFactor)再压一遍这样美术在同一张贴图下也能快速对比“硬切版”和“柔边版”的效果。需要提醒的是Ramp 纹理导入设置一定要关掉 Mipmap 生成并设为 Clamp 模式否则多级纹理会把相邻色阶混成灰色卡通色块会变得糊糊的这个坑我踩过好几次。2.3 用半兰伯特代替纯兰伯特纯兰伯特光照下背光面NdotL 为负会被直接判为 0模型暗部会死黑一片。日系卡通角色很少出现完全死黑的区域哪怕是背光面也会保留一丁点环境色或反光。解决办法是改用半兰伯特公式halfLambert NdotL * 0.5 0.5。这个公式会把原本 0 到 1 的有效区域扩展到全部背面相当于把光照范围人为拉大角色背光时依然保留一定的体积感。在 Ramp 纹理上我也会专门把最左端几个像素画成带饱和度的暗部色而不是纯黑。比如女角色的裙子阴影色调成深蓝紫头发阴影调成暗棕色这样整个角色阴影区域不会死气沉沉。半兰伯特的第二个好处是它天然适合处理卡通模型中常见的“法线翻转”问题——低模角色有时法线方向怪怪的纯兰伯特会让某些部位意外发黑半兰伯特把范围撑开后至少所有部位都有基础亮度不至于一眼就穿帮。3. 核心实现之二描边系统3.1 法线外扩描边的原理与 Pass 写法描边是卡通角色最重要的轮廓线相当于原画里的勾线。法线外扩描边的原理很直接在一个单独的 Pass 里把模型顶点的位置沿法线方向向外推一段距离然后把整个模型渲染成纯黑色或指定描边色。因为这段 Pass 设置了Cull Front也就是剔除正面只渲染背面所以只有轮廓边缘的那一圈会被看到正面完全不受影响。具体实现上有两种外扩方式。一种是在观察空间外扩把法线从模型空间转换到观察空间把顶点也转换到观察空间然后viewPos viewNormal * _OutlineWidth最后用UnityViewToClipPos投影。这种方式的好处是不受模型缩放的非等比拉伸影响描边宽度在视觉上更一致。另一种是直接在裁剪空间把坐标往外偏pos.xy normal.xy * width * pos.w这种写法描边宽度会随距离变化比较适合做后期风格的强调效果。我主推观察空间外扩代码如下Pass { Tags { LightMode Always } Cull Front CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; }; float _OutlineWidth; float4 _OutlineColor; v2f vert(appdata v) { v2f o; float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); float3 viewPos UnityObjectToViewPos(v.vertex); // 用顶点色A通道控制局部描边宽度后面会细讲 float width _OutlineWidth * (1.0 - v.color.a); float3 viewOutlinePos viewPos viewNormal * width; o.pos UnityViewToClipPos(viewOutlinePos); return o; } fixed4 frag(v2f i) : SV_Target { return _OutlineColor; } ENDCG }有一个细节需要特别说明UNITY_MATRIX_IT_MV这个矩阵是“模型-观察矩阵的逆转置”用它变换法线才不会出现非等比缩放下的法线偏差。如果你图省事直接乘UNITY_MATRIX_MV角色一旦被压扁或拉长描边会歪得惨不忍睹。3.2 硬边裂缝问题与平滑法线修复法线外扩描边最经典的翻车现场就是“描边裂缝”。比如角色头发末梢、裙子褶皱这类地方建模时往往做了硬边处理相邻两个三角面的法线方向差异极大。顶点沿各自的法线向外推时共享边两侧的顶点会往完全不同的方向走原本封闭的轮廓线就裂开一条缝露出里侧的黑色描边穿帮。这个问题在非封闭模型上尤其严重。解决思路有三个。第一在建模软件里对模型整体做一次“平滑着色”把法线角度差在 30 度以内的硬边全部融合成平滑过渡这是最治本的办法。第二用顶点色 A 通道做局部宽度遮罩把容易撕裂的位置调成零宽度相当于人工把这个位置的描边“摘掉”再靠后面其他 Pass 补一点轮廓光。第三修改模型顶点流把描边 Pass 单独使用一套“平滑法线数据”常见的做法是把平滑后的法线烘焙到TEXCOORD1里描边 Pass 只读取这套法线而不使用NORMAL语义。这个做法对性能影响很小但对美术建模流程有要求。我在项目里的折中方案是先用建模软件把法线平滑好再用顶点色把眼窝、嘴唇下方等需要弱化描边的位置打上标记。这里分享一个快速验证技巧你可以临时代码里直接return v.color.a作为颜色输出用颜色亮度直观检查顶点色的分布确认描边宽度为 0 的区域是不是你想要的位置。这个方法比盲猜通道值效率高得多。3.3 描边宽度距离补偿另一个高频问题是描边在近景时粗得像马赛克、远景时细得像头发丝。这是因为观察空间外扩的描边宽度是绝对的屏幕偏移离相机越远投影后它占屏幕的比例就越小。要实现相对稳定的观感需要加一个距离系数。我当时写的是在描边 Pass 的顶点函数里计算distance length(_WorldSpaceCameraPos.xyz - worldPos)然后乘一个映射系数float fade saturate((_OutlineDistanceFadeEnd - distance) / (_OutlineDistanceFadeEnd - _OutlineDistanceFadeStart)); fade 1.0 - fade; // 远距离时描边变宽或按项目需求反向更稳妥的做法是让描边宽度在屏幕空间保持恒定也就是描边和深度成比例viewNormal * width * viewPos.z。这个方法本质上是在观察空间里让外扩量与深度成正比最终投影后屏幕宽度几乎不变。代价是角色离相机很近时描边会显得过粗所以还要再配一个距离上限避免镜头怼脸时描边糊住眼睛。我的常见参数是_OutlineWidth 0.02距离补偿范围 5 到 15 米之间线性衰减在大部分镜头距离下都能保持稳定粗细。4. 核心实现之三卡通高光与边缘光4.1 Blinn-Phong 高光的卡通化卡通高光和 PBR 高光最大的区别在于“形状干净”。PBR 的高光是连续微小变化的卡通高光则是一个边界明确的亮斑。传统 Blinn-Phong 里高光强度由NdotH决定H是光线方向与视线方向的半向量。为了卡通化我们要做的是把这个连续值变成阶跃值做法是在NdotH上做smoothstep(threshold - smooth, threshold smooth, NdotH)超过阈值才显示高光。我项目里的高光参数一般设置为_SpecularThreshold 0.85、_SpecularSmoothness 0.02这样高光是一块非常锐利的小亮区很接近日系动漫眼睛或头发上的那种块状反光。如果要做成更柔和一点的赛璐璐风格就把_SpecularSmoothness调到 0.1 左右让高光边缘有轻微的过度。另外不要忘记把高光范围用pow(NdotH, _SpecularPower)先压一压再做 Step否则高光会出现在整个模型的朝向面上形状会很难看。角色头发上那几道标志性的白色高光带就是通过这个局部高光加顶点色遮罩做出来的。4.2 用顶点色做区域控制只靠一个全局参数是没法做出角色细节的。比如同一张脸上头发要有高光、眼白不能有高光、嘴唇最好有柔和反光同一条裙子上褶皱凸起处描边应该粗一点凹陷处描边要细甚至消失。这些需求全用一种贴图控制会非常繁琐而顶点色最适合做这种“局部权重”控制。我把顶点色的四个通道利用到了极致顶点色通道控制内容使用方式R面部/布料特殊光照区域开关全局值为 1面部用特殊法线方向计算光照G高光强度权重头发、眼睛方向给 1皮肤适当给 0.3~0.5B边缘光强度权重头发、肩膀等需要轮廓感的位置给 1A描边宽度抑制眼窝、嘴角附近给 0避免描边脏脸这段代码在第 3.1 节已经用到了width _OutlineWidth * (1.0 - v.color.a)。高光部分则在片元函数里让spec * i.color.g边缘光直接乘i.color.b。这种“一张顶点色控制全角色细节”的做法在美术侧非常受欢迎因为不难理解改起来也快。4.3 边缘光与面部阴影处理边缘光Rim Light是卡通渲染中增强轮廓感的第二法宝。它的原理是观察视线方向V与法线方向N的夹角越大贡献越强也就是pow(1 - dot(N, V), 边缘光强度)。我用一个_RimColor参数控制边缘光颜色和强度并乘上顶点色 B 通道做区域控制让只有头发、肩膀这些“需要从背景里跳出来”的部位才有边缘光。面部阴影是卡通角色渲染里最微妙的部分。日系原画里角色的脸通常不接受太强的方向性阴影最多在刘海下面有一小片投影。如果直接用法线做兰伯特光照侧脸一定会出现鼻影和颧骨阴影毫无二次元感。我通常会加一个开关当顶点色 R 通道标记为“面部区域”时用固定方向代替真实法线去计算光照比如fixed3 faceNormal normalize(fixed3(0, 1, 0))。这样脸部会一直保持比较平的整体受光只有光线从正上方照到时才会有微弱的明暗变化。这个方法虽然不是完全严谨的做法但在大多数面向观众的角色模型上效果比 PBR 那套物理光照自然得多。5. 完整 Shader 与参数调节实录5.1 可直接用的完整 Shader 代码到这里所有组件都齐了。下面给一份我在项目里实际使用的基础版完整代码结构包括主 Pass漫反射 Ramp 卡通高光 边缘光、描边 Pass法线外扩和 ShadowCaster 回退。为了保持代码简短我把多光源支持和自定义 ShadowCaster 先省略了基础光照足够覆盖大多数场景。Shader Custom/ToonCharacter { Properties { _BaseColor (Base Color, Color) (1,1,1,1) _RampTex (Ramp Texture, 2D) white {} _ShadowSmoothness (Shadow Smoothness, Range(0.001, 0.5)) 0.1 _SpecularColor (Specular Color, Color) (1,1,1,1) _SpecularThreshold (Specular Threshold, Range(0, 1)) 0.85 _SpecularSmoothness (Specular Smoothness, Range(0.001, 0.2)) 0.02 _RimColor (Rim Color, Color) (0.5,0.5,0.5,1) _RimPower (Rim Power, Range(0.5, 8)) 2.0 _OutlineColor (Outline Color, Color) (0.1,0.1,0.1,1) _OutlineWidth (Outline Width, Range(0, 0.1)) 0.02 } SubShader { Tags { RenderTypeOpaque QueueGeometry LightModeForwardBase } LOD 200 Pass { Tags { LightMode ForwardBase } Cull Back CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc #include AutoLight.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; float4 color : COLOR; LIGHTING_COORDS(3, 4) }; sampler2D _RampTex; float4 _BaseColor; float _ShadowSmoothness; float4 _SpecularColor; float _SpecularThreshold; float _SpecularSmoothness; float4 _RimColor; float _RimPower; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv v.uv; o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; o.color v.color; TRANSFER_VERTEX_TO_FRAGMENT(o); return o; } fixed4 frag(v2f i) : SV_Target { fixed3 n normalize(i.worldNormal); fixed3 l normalize(_WorldSpaceLightPos0.xyz); fixed ndotl dot(n, l); // 面部区域用固定法线计算避免侧面阴影 if (i.color.r 0.5) { ndotl dot(normalize(fixed3(0, 1, 0)), l); } fixed3 v normalize(_WorldSpaceCameraPos.xyz - i.worldPos); fixed3 h normalize(l v); // Ramp 漫反射 float rampU ndotl * 0.5 0.5; fixed atten LIGHT_ATTENUATION(i); rampU * atten; fixed rampFactor tex2D(_RampTex, float2(rampU, 0.5)).r; rampFactor smoothstep(_ShadowSmoothness, 1 - _ShadowSmoothness, rampFactor); fixed3 diffuse _BaseColor.rgb * rampFactor * _LightColor0.rgb; // 卡通化高光 fixed ndoth dot(n, h); fixed specSoft smoothstep( _SpecularThreshold - _SpecularSmoothness, _SpecularThreshold _SpecularSmoothness, ndoth ); fixed3 specular _SpecularColor.rgb * specSoft * i.color.g * _LightColor0.rgb; // 边缘光 fixed fresnel pow(1 - saturate(dot(n, v)), _RimPower); fixed3 rim _RimColor.rgb * fresnel * i.color.b; fixed3 col diffuse specular rim; return fixed4(col, 1.0); } ENDCG } Pass { Tags { LightMode Always } Cull Front CGPROGRAM #pragma vertex vert #pragma fragment frag #include UnityCG.cginc struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float4 color : COLOR; }; struct v2f { float4 pos : SV_POSITION; }; float _OutlineWidth; float4 _OutlineColor; v2f vert(appdata v) { v2f o; float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); float3 viewPos UnityObjectToViewPos(v.vertex); float width _OutlineWidth * (1.0 - v.color.a); float3 viewOutlinePos viewPos viewNormal * width; o.pos UnityViewToClipPos(viewOutlinePos); return o; } fixed4 frag(v2f i) : SV_Target { return _OutlineColor; } ENDCG } FallBack Diffuse } }代码里我刻意保留了一个if (i.color.r 0.5)的面部特殊处理分支。这样的写法在 GPU 上会有小的分支开销但现代设备基本无感而且调试时非常直观。如果你想更规范一点可以用#pragma multi_compile加 Keyword 控制是否开启面部固定法线但普通项目里不用过早优化。5.2 材质面板与调参顺序Shader 挂到材质后初学者最容易犯的错是“看到哪个参数顺眼就拖哪个”结果颜色越调越花最后分不清是 Ramp 图的问题还是参数的问题。我的调参顺序是这样的先固定基础色把_BaseColor设为原画里角色皮肤或头发的中间调不要直接用最亮或最暗的颜色。再定阴影色调整 Ramp 纹理中间阶梯的落点位置和暗部颜色让阴影区域尽量覆盖在衣褶、下巴下方等结构合理的地方。之后调_ShadowSmoothness从 0.001 开始慢慢加直到明暗交界处不再“糊”为止。调高光先调_SpecularThreshold确定亮斑大小再调_SpecularSmoothness确定边缘硬度。最后调描边_OutlineWidth从 0.03 开始以角色在游戏相机距离下轮廓线大约 1~2 个像素宽为参考。调参过程中要养成一个习惯每次只改一个参数然后截图对比。NPR 渲染的视觉效果和参数之间的关系非常不线性Ramp 图稍微改一两个像素的色阶过渡整个画面气质都会变一次改多个参数会让后续调整失去依据。5.3 性能与兼容性优化要点如果你的目标是移动端有几个点要特别留意。第一描边 Pass 会让角色绘制两遍顶点数翻倍一个一万面角色会在描边环节变成两万面。想要省性能的话描边 Pass 可以把模型切成低面数版本或者用LOD过渡。第二Ramp 纹理尽量设置成压缩格式尺寸控制在 64x1 或 128x1不然移动端的纹存和带宽浪费得不偿失。第三计算worldNormal和worldPos时开销不低如果你的角色只是放在场景里静态运行完全可以在顶点着色里用UnityObjectToWorldNormal后直接把法向量传给片元函数别再在片元里 normalize 不该归一化的量。第四半精度浮点移动端 Shader 里建议把float改成half能明显减少 ALU 指令周期。URP 迁移方面主要区别是把内置管线的LightMode标签换成UniversalForward把Lighting.cginc里的_LightColor0、LIGHT_ATTENUATION换成 URP 的GetMainLight()和阴影采样函数其他逻辑几乎不用改。我自己做过一次迁移大概半小时就完成了所以不用被“只能在内置管线用”这种想法困住。6. 常见问题与排查技巧实录6.1 材质变紫红怎么办这是所有 Shader 新手都会遇到的场面材质球变成一片紫红色运行时模型也是亮紫色。紫红色是 Unity 的 shader 编译失败提示色。最常见的诱因是代码里多了中文标点、括号没有配对或者用了我这边代码里if分支后在部分旧设备上不被支持。排查方法很简单打开 Console 窗口看报错行号和原因。我遇到过一次比较隐蔽的情况是 Ramp 纹理的导入格式被设成了 NormalMap采样结果完全错误但编译器不报错。如果你看到模型颜色偏紫偏蓝、纹理效果诡异先去检查纹理导入设置是不是Default模式。6.2 描边断裂、描边粗细不均匀描边断裂的根因绝大多数是模型法线不平滑尤其是低模角色。你可以先把描边宽度调大一点如果断裂的位置呈现明显的“V字开口”就基本可以确定是法线问题。处理办法是回建模软件里给模型整体加平滑组或者用第三方工具把法线平滑后重新导入。描边粗细不均匀的原因则是顶点色 A 通道分布有误或者距离补偿参数设置不合理。快速定位方式是把描边 Pass 的片元输出临时改成return v.color.a这样你能直接看到顶点色通道的明暗分布。如果你发现描边在某个部位突然消失先怀疑顶点色里的 0 值区域而不是改 Shader。6.3 阴影丢失、阴影边缘生硬如果你在场景里开了主光源的实时阴影但角色没有接受到任何阴影多半是FallBack Diffuse没有生效或者光模式不对。ShadowCaster Pass 是 Unity 阴影映射的核心FallBack Diffuse会提供默认实现但如果你用了QueueTransparent阴影会被跳过。我把 SubShader 的Queue设成Geometry并保留 Fallback就是为了让阴影系统正常工作。阴影边缘生硬则是卡通渲染的另一个典型问题。解决思路有二一是靠 Ramp 纹理和_ShadowSmoothness把片元里的明暗过渡软化二是调大主光源 Shadow 的Bias避免阴影在模型表面产生自阴影的条状噪点。StormyGames 的做法是让角色接受光照时完全不采样角色自身的阴影贴图但这样战斗里贴近地面时角色会“飘”起来我建议还是保留阴影只是把阴影强度调低到 0.4 左右。6.4 移动端性能与发热问题如果角色在移动端跑起来发热严重优先级最高的优化项是按顺序检查帧率、顶点数量、纹理带宽、Shader ALU。描边 Pass 是最容易被忽视的耗电大户一万面角色加描边后会增加接近一倍的顶点处理压力。第二个常见问题是角色使用了多张 1024 以上的贴图而 Ramp 图反而只有 64 像素贴图带宽全浪费在无关紧要的贴图上。第三个问题是片元函数里频繁调用normalize和pow这两个函数在移动端 GPU 上是出了名的慢。把能挪到顶点的运算全部挪到顶点着色函数里在大多数设备上能把帧耗时降低三分之一。把描边 Pass 里得法线变换放到顶点之前先做一次归一化也能减少一个normalize的调用。我的实际经验是移动端性能开销从高到低分别是描边 Pass 顶点数、阴影采样次数、纹理带宽、Shader 里的数学函数。按这个顺序去优化比到处看 GPU 厂家文档高效得多。我自己在实际项目中最深的体会是NPR 不是“把参数往死里压”而是“把风格往死里扣”。很多开发者在调色时总想着把阴影调淡、高光调软结果画面越来越接近半写实最后四不像。正确思路是先定好风格参考图把 Ramp 图的色阶位置和颜色当成硬性目标模型的光照细节必须为这套色阶让步。等哪天你发现角色在场景里一出来观众第一眼不是看 PBR 细节而是看“这角色很有风格”这套 Shader 就算真正起效了。
返回列表