拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

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

说起 Unity 里的卡通渲染,很多朋友第一反应是“不就是把光照打平、加个描边嘛”。真做起来就会发现,PBR 的金属粗糙度套路完全用不上,角色脸一转到背光就黑成一片,描边在硬边处裂成蜘蛛网,高光要么油得发腻要么糊成一片。这篇东西就是我实际从零到一做完一个 NPR 卡通角色渲染插件的完整记录,从 Ramp 色调分离、法线外扩描边到卡通高光和边缘光的取舍,都按“先讲清楚为什么、再给能直接抄的代码、最后列坑”的顺序来写。不管你是刚入行的 Unity 开发者,还是想从 PBR 转风格化渲染的技术美术,跟着走一遍应该都能在自己的项目里落地。

1. 先把 NPR 想清楚:渲染目标与技术选型

1.1 这一步到底在解决什么问题

卡通角色渲染和写实渲染的目标完全不同。PBR 追求的是“这物体在真实光照下看起来是什么样”,而 NPR(Non-Photorealistic Rendering)追求的是“这画面符不符合我要的美术风格”。一个二次元角色如果直接用 Standard Shader 渲染,皮肤会显得又油又脏,头发会反射出乱七八糟的环境光,整个角色和原画放在一起就像两个世界的人。原因很简单:卡通角色的核心视觉元素是清晰的色块边界、形状明确的高光、以及勾勒轮廓的描边,而不是微表面反射、菲涅尔衰减那一堆物理细节。

我在项目里接到这个需求时,美术只给了一张原画和一个低模。原画里角色的头发是一整块亮色,衣服阴影是硬朗的二分色,面部几乎没有明显的立体光影。如果按常规光照模型去推,脸部在侧光下马上会出现生硬的鼻影和颧骨阴影,这就不像纸片人风格了。所以技术选型的核心判断是:我要的不是“更真实的光照”,而是“可控的、有风格的明暗分配”。

NPR 在 Unity 里的实现路径有很多,常见的有基于 Ramp 纹理的色调分离、基于 MatCap 的贴图式光照、以及完全用顶点色手绘明暗的做法。我最终选择了 Ramp 纹理加法线外扩描边作为基础方案,因为它对移动端友好、参数直观、美术可以像调色阶一样修改光照风格。这篇文章会围绕这套方案展开,并且把踩过的坑一并写出来。

1.2 为什么选 Ramp 加描边作为技术起点

先对比一下主流方案,这样你就知道为什么最终选择 Ramp:

  • Toon 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.5
B边缘光强度权重头发、肩膀等需要轮廓感的位置给 1
A描边宽度抑制眼窝、嘴角附近给 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 { "RenderType"="Opaque" "Queue"="Geometry" "LightMode"="ForwardBase" } 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 图的问题还是参数的问题。我的调参顺序是这样的:

  1. 先固定基础色:把_BaseColor设为原画里角色皮肤或头发的中间调,不要直接用最亮或最暗的颜色。
  2. 再定阴影色:调整 Ramp 纹理中间阶梯的落点位置和暗部颜色,让阴影区域尽量覆盖在衣褶、下巴下方等结构合理的地方。
  3. 之后调_ShadowSmoothness:从 0.001 开始慢慢加,直到明暗交界处不再“糊”为止。
  4. 调高光:先调_SpecularThreshold确定亮斑大小,再调_SpecularSmoothness确定边缘硬度。
  5. 最后调描边:_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"会提供默认实现,但如果你用了Queue=Transparent,阴影会被跳过。我把 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 就算真正起效了。

返回列表