
1. 项目概述为什么NPR卡通渲染值得你投入时间如果你在Unity社区里混迹过一段时间肯定会发现一个现象那些让人眼前一亮的独立游戏Demo或者是在社交媒体上疯狂传播的“赛博菩萨”、“二次元老婆”角色展示十有八九都离不开一套精心调校的卡通渲染NPR Non-Photorealistic Rendering方案。这玩意儿早就不是大厂的专利了它已经从高不可攀的技术壁垒变成了中小团队乃至个人开发者实现风格化、打造产品差异化的核心武器。我见过太多项目模型精度一般但靠着一手漂亮的Shader直接把视觉表现力拉满在Steam新品节或者B站上成功吸引第一波流量。“从零打造”这四个字听起来有点吓人好像要从图形API开始写起。别慌我们这里说的“从零”指的是不依赖Asset Store里那些黑盒化的完整方案而是从最基础的着色器知识出发理解每一行代码背后的意图最终组装出一个属于你自己、可控可调的卡通角色渲染管线。这个过程本质上是在学习如何用代码“绘画”用光影和色彩来叙事。最终目标不是复刻某个特定的动漫风格而是让你掌握构建这种风格的工具和方法论未来无论是想做日系赛璐璐、美式厚涂还是带点水墨感的国风你都能心里有底。2. 核心思路拆解卡通渲染到底“卡”在哪里要造轮子先得知道轮子长什么样。写实渲染PBR追求的是模拟物理世界的光线行为而卡通渲染的核心思想是“归纳”和“风格化”。它主动抛弃了物理的复杂性用高度概括的视觉语言来表达。拆解开来主要攻克以下几个点2.1 色块化与硬阴影这是卡通渲染最标志性的特征。现实中的阴影有平滑的过渡软阴影而卡通片里明暗交界线往往非常锐利形成清晰的色块。在Shader里我们不会用传统的dot(N, L)法线点乘光方向这种连续函数直接计算漫反射而是会通过一个阈值函数比如step或smoothstep对其进行“离散化”处理。// 传统Lambert漫反射 float diffuse max(0, dot(normal, lightDir)); // 卡通化硬阴影二分色 float threshold 0.2; // 阈值控制明暗分界位置 float toonDiffuse step(threshold, diffuse); // 小于阈值是0暗部大于阈值是1亮部 // 更常见的三分色增加一个中间调 float ramp smoothstep(_ShadowThreshold, _ShadowThreshold _ThresholdSoftness, diffuse); float toonDiffuse lerp(_ShadowColor, _MidColor, ramp); toonDiffuse lerp(toonDiffuse, _LightColor, step(_HighlightThreshold, diffuse));这里的_ThresholdSoftness是个关键参数它允许你在硬边的基础上增加一点点平滑过渡避免阴影边缘过于生硬像剪纸。这个“软硬”的度正是不同卡通风格的味道所在。2.2 描边Outline描边的作用是强化轮廓分离角色与背景增加画面的绘画感和立体感。主流实现方法有几种各有优劣后处理描边Post-processing Outline基于整个屏幕的深度/法线信息检测边缘。优点是能统一处理所有物体实现简单。缺点是对模型细节不敏感容易在复杂场景产生杂边且无法实现“内描边”如衣服褶皱处的强调线。法线外拓描边Normal Extrusion在顶点着色器阶段沿着顶点法线方向将模型顶点向外挤出一点点然后用纯色通常是黑色渲染这个挤出的“壳”再渲染内部模型盖住它。这是角色渲染中最常用、效果最稳定的方法。轮廓边检测描边Silhouette Edge Detection在几何着色器中识别并渲染真正的轮廓边。效果最精准性能开销也最大通常用于对描边质量有极端要求的场合。对于从零开始的我们法线外拓法是性价比最高的选择。它的核心代码非常简洁// 在顶点着色器中 v2f vert (appdata v) { v2f o; float3 normal v.normal; // 可选在观察空间计算让描边宽度不受距离影响 float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, normal); float3 viewPos mul(UNITY_MATRIX_MV, v.vertex); // 沿法线方向挤出 viewPos.xyz normalize(viewNormal) * _OutlineWidth; o.vertex mul(UNITY_MATRIX_P, viewPos); o.color _OutlineColor; // 通常为固定颜色 return o; }注意直接使用模型法线外拓在硬边比如立方体边缘处描边会断裂。一个常见的技巧是将平滑后的顶点法线SmoothNormals存储在顶点色或另一套UV中描边Pass使用这套平滑法线来计算外扩就能得到连续完整的轮廓线。2.3 高光与反射风格化卡通的高光往往不是模糊的一片而是有形状的、锐利的“星形”或“条形”。我们可以用各向异性的高光模型或者更直接地——用一张纹理来采样高光形状。// 使用一个缩放和偏移后的UV来采样高光形状图 float2 uvSpec v.uv * _SpecularScale _Time.y * _SpecularSpeed; // 可以加上时间做动画 float specularMask tex2D(_SpecularShapeTex, uvSpec).r; // 计算高光强度例如Blinn-Phong模型 float3 halfDir normalize(lightDir viewDir); float NdotH dot(normal, halfDir); float specular pow(max(0, NdotH), _Gloss); // 将形状图与高光强度结合并进行阈值化 float toonSpecular step(_SpecularThreshold, specular * specularMask); float3 finalSpecular toonSpecular * _SpecularColor * lightColor;对于反射比如眼睛里的眼神光、金属装饰同样可以用类似思路用一张环境贴图Cubemap但配合一个经过处理的菲涅尔项和阈值函数做出卡通化的“亮片”效果。2.4 自阴影与细节增强真实的卡通画面并非完全平涂在暗部或转折处经常会有一些艺术化的、用于增强体积感和细节的笔触。这部分无法通过光照模型自动生成通常需要美术人员提供一张阴影色贴图Shadow Ramp Texture或遮罩贴图。阴影色贴图一张一维或二维的渐变纹理。我们使用计算出的漫反射强度dot(N, L)作为UV的横坐标去采样这张图纵坐标可以用来混合不同角度如侧光、背光的色调。这给了美术极大的控制权可以画出非常风格化的阴影色彩变化。细节遮罩比如在脸颊、关节处画上红晕在头发高光处画上条纹。这些通过另一套UV和遮罩贴图来控制在Shader里混合到最终颜色上。3. 实战构建一个基础卡通角色Shader理论说再多不如动手写一遍。我们来搭建一个包含基础色、硬阴影、法线外拓描边和风格化高光的Unity Shader。我们将使用Unity的URPUniversal Render Pipeline作为渲染管线因为它是未来趋势且内置了良好的多光源支持框架。3.1 工程与Shader框架搭建首先在Unity中创建一个URP项目。在Project窗口创建我们的Shader文件比如Custom/ToonCharacter.shader。一个完整的Shader通常包含多个Pass我们至少需要两个OutlinePass用于渲染描边。ForwardLitPass用于渲染角色本身并接收光照。我们先定义Properties块暴露给材质面板调节的参数Properties { // 基础颜色 [MainTexture] _BaseMap (Albedo, 2D) white {} [MainColor] _BaseColor (Base Color, Color) (1,1,1,1) // 阴影 _ShadowThreshold (Shadow Threshold, Range(0, 1)) 0.5 _ThresholdSoftness (Threshold Softness, Range(0, 0.5)) 0.05 _ShadowColor (Shadow Color, Color) (0.5,0.5,0.5,1) _MidColor (Mid Tone Color, Color) (1,1,1,1) // 高光 _SpecularColor (Specular Color, Color) (1,1,1,1) _Gloss (Gloss, Range(1, 256)) 32 _SpecularThreshold (Specular Threshold, Range(0, 1)) 0.9 // 描边 _OutlineWidth (Outline Width, Range(0, 0.1)) 0.03 _OutlineColor (Outline Color, Color) (0,0,0,1) // 法线 [Normal] _BumpMap (Normal Map, 2D) bump {} _BumpScale (Normal Scale, Float) 1.0 }3.2 实现描边Pass描边Pass需要放在所有不透明物体渲染之前并且关闭深度写入ZWrite Off但采用“大于”的深度比较函数ZTest Greater这样描边只会在模型本体背后渲染形成包裹效果。Pass { Name Outline Tags { LightMode SRPDefaultUnlit } // 描边Pass通常不需要复杂光照 Cull Front // 只渲染背面外扩的面 ZWrite Off ZTest Greater HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 vertex : POSITION; float3 normal : NORMAL; }; struct Varyings { float4 pos : SV_POSITION; }; float _OutlineWidth; half4 _OutlineColor; Varyings vert (Attributes v) { Varyings o; // 将顶点和法线变换到观察空间 float3 viewPos TransformObjectToView(v.vertex.xyz); float3 viewNormal normalize(mul((float3x3)UNITY_MATRIX_IT_MV, v.normal)); // 在观察空间沿法线方向外扩 viewPos viewNormal * _OutlineWidth * 0.01; // 乘一个系数控制单位 // 变换到齐次裁剪空间 o.pos TransformWViewToHClip(viewPos); return o; } half4 frag (Varyings i) : SV_Target { return _OutlineColor; } ENDHLSL }实操心得_OutlineWidth的值需要根据模型尺寸和摄像机距离微调。一个技巧是在脚本中根据摄像机距离动态调整这个值保证近处描边不会太粗远处描边不会消失。另外对于非常复杂的模型如头发丝直接外拓可能导致描边粘连这时就需要用到前面提到的“平滑法线”技术将平滑后的法线数据存储在顶点色或UV2/UV3中供描边Pass使用。3.3 实现光照Pass这是Shader的核心。我们需要继承URP的Lit着色器框架但重写光照计算部分。在URP中我们通常通过编写自定义的Lighting.hlsl函数来实现。首先在HLSLPROGRAM中引入必要的库并声明变量HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _ADDITIONAL_LIGHTS #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl // ... 变量声明与Properties对应 ... struct Varyings { float4 pos : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 viewDirWS : TEXCOORD2; float3 posWS : TEXCOORD3; // 如果需要阴影坐标 float4 shadowCoord : TEXCOORD4; };在顶点着色器中计算并传递世界空间下的法线、视角方向、位置和阴影坐标Varyings vert (Attributes v) { Varyings o; VertexPositionInputs vertexInput GetVertexPositionInputs(v.vertex.xyz); VertexNormalInputs normalInput GetVertexNormalInputs(v.normal, v.tangent); o.pos vertexInput.positionCS; o.uv TRANSFORM_TEX(v.uv, _BaseMap); o.normalWS normalInput.normalWS; o.viewDirWS GetWorldSpaceViewDir(vertexInput.positionWS); o.posWS vertexInput.positionWS; // 计算主光源阴影坐标 o.shadowCoord GetShadowCoord(vertexInput); return o; }关键在片元着色器。我们编写一个自定义的光照函数CustomLightinghalf4 frag (Varyings i) : SV_Target { // 采样纹理 half4 albedo tex2D(_BaseMap, i.uv) * _BaseColor; half3 normalTS UnpackNormalScale(tex2D(_BumpMap, i.uv), _BumpScale); // 将法线从切线空间转换到世界空间 float3 normalWS normalize(i.normalWS); // 简单起见这里忽略法线贴图变换 // 获取主光源 Light mainLight GetMainLight(i.shadowCoord); float3 lightDir mainLight.direction; float3 lightColor mainLight.color * mainLight.distanceAttenuation * mainLight.shadowAttenuation; // 1. 卡通漫反射 float NdotL dot(normalWS, lightDir); float diffuse smoothstep(_ShadowThreshold - _ThresholdSoftness*0.5, _ShadowThreshold _ThresholdSoftness*0.5, NdotL); float3 diffuseColor lerp(_ShadowColor.rgb, _MidColor.rgb, diffuse); // 进一步可以加入“亮部”颜色 diffuseColor lerp(diffuseColor, _BaseColor.rgb, step(_HighlightThreshold, NdotL)); // 2. 卡通高光 (Blinn-Phong) float3 viewDir normalize(i.viewDirWS); float3 halfDir normalize(lightDir viewDir); float NdotH dot(normalWS, halfDir); float specular pow(max(0, NdotH), _Gloss); float toonSpecular step(_SpecularThreshold, specular); float3 specularColor toonSpecular * _SpecularColor.rgb * lightColor; // 3. 环境光简单处理 float3 ambient SampleSH(normalWS) * albedo.rgb; // 4. 组合最终颜色 float3 finalColor (ambient diffuseColor * lightColor) * albedo.rgb specularColor; // 5. 处理额外逐像素光源可选用于表现边缘光、发光道具等 #ifdef _ADDITIONAL_LIGHTS uint pixelLightCount GetAdditionalLightsCount(); for (uint lightIndex 0; lightIndex pixelLightCount; lightIndex) { Light addLight GetAdditionalLight(lightIndex, i.posWS); float3 addLightDir addLight.direction; float3 addLightColor addLight.color * addLight.distanceAttenuation * addLight.shadowAttenuation; // 对附加光可以采用更简化的卡通光照计算 float addDiffuse step(0.1, dot(normalWS, addLightDir)); finalColor addDiffuse * addLightColor * albedo.rgb; } #endif return half4(finalColor, albedo.a); } ENDHLSL这个Pass的Tags需要设置为LightModeUniversalForward并且开启相应的渲染状态如Cull Back渲染正面。3.4 材质配置与场景测试创建材质球将Shader赋给它然后拖给一个角色模型。调整参数观察效果_ShadowThreshold拖动滑块观察明暗分界线的移动。_ThresholdSoftness设置为0是绝对硬边增大值会看到边缘出现平滑过渡。_OutlineWidth控制描边粗细。_SpecularThreshold和_Gloss控制高光点的出现阈值和大小。在场景中放置一个方向光旋转光源方向观察阴影和高光的变化是否符合卡通预期。尝试使用不同的基础色贴图看看阴影色_ShadowColor和中间色_MidColor与它的混合效果。4. 进阶优化与风格化技巧基础框架搭好后我们可以加入更多“风味调料”让渲染效果更专业、更风格化。4.1 使用阴影色贴图Ramp Texture这是提升质感的关键一步。准备一张一维的渐变纹理比如128x1像素横轴从左到右代表从背光到受光。在Shader中// 在Properties中添加 _RampMap (Ramp Map, 2D) white {} // 在片元着色器中替换原有的diffuseColor计算 float rampUV (NdotL * 0.5 0.5); // 将[-1,1]映射到[0,1] float3 rampColor tex2D(_RampMap, float2(rampUV, 0)).rgb; float3 diffuseColor rampColor * _BaseColor.rgb;美术可以在PS里绘制这张渐变图轻松实现任何想要的阴影色调变化比如暗部偏冷、亮部偏暖或者加入非真实的色彩跳跃。4.2 添加边缘光Rim Light边缘光能极大地增强角色的体积感和立体感在背对摄像机的轮廓边缘产生亮边。// 在Properties中添加 _RimColor (Rim Color, Color) (1,1,1,1) _RimPower (Rim Power, Range(0.1, 10)) 5 _RimThreshold (Rim Threshold, Range(0, 1)) 0.5 // 在片元着色器中计算 float rim 1.0 - saturate(dot(normalize(i.viewDirWS), normalWS)); rim smoothstep(_RimThreshold, 1.0, pow(rim, _RimPower)); float3 rimColor rim * _RimColor.rgb; // 将边缘光加到最终颜色上通常用加法混合 finalColor rimColor;4.3 实现面部特殊处理如腮红面部渲染是卡通角色的灵魂。通常我们需要额外的遮罩贴图来标记脸颊、鼻头等需要特殊渲染的区域。让美术在模型的一套UV比如UV2上在脸颊位置画上红色遮罩。在Shader中采样这张遮罩贴图。将遮罩值与一个自定义的颜色腮红色混合到最终颜色上混合模式可以是叠加Overlay或线性减淡Linear Dodge/Add。// Properties _CheekMask (Cheek Mask (R), 2D) white {} _CheekColor (Cheek Color, Color) (1,0.6,0.6,1) _CheekIntensity (Cheek Intensity, Range(0,1)) 0.3 // 在片元着色器中 float cheekMask tex2D(_CheekMask, i.uv2).r; // 假设遮罩存在uv2 float3 cheekColor _CheekColor.rgb * cheekMask * _CheekIntensity; finalColor lerp(finalColor, finalColor cheekColor, cheekMask); // 简单相加4.4 性能考量与移动端适配卡通Shader虽然视觉效果复杂但通过优化完全可以在移动端流畅运行。减少纹理采样合并贴图。例如将自发光Emission、高光遮罩Specular Mask、粗糙度Roughness打包到一张贴图的RGB通道。简化计算在片元着色器中将pow、smoothstep等函数替换为查表LUT或更简单的近似计算。对于移动端可以考虑将部分计算如漫反射阈值判断移到顶点着色器然后在片元着色器插值虽然精度有损失但能省下大量计算。减少Pass数量描边Pass是额外的绘制调用Draw Call。对于大量同屏角色可以考虑使用后处理描边或者将描边信息烘焙到顶点色中在同一个Pass里用几何着色器或顶点偏移技巧实现但这更复杂。使用Shader LOD为Shader设置不同的细节等级LOD当摄像机远离时自动切换到更简化的版本比如关闭法线贴图、简化高光计算。5. 常见问题与调试实录在实际开发中你肯定会遇到各种诡异的现象。这里记录几个我踩过的坑和解决方法。5.1 描边断裂或粗细不均问题模型边缘的描边不连续或者在关节弯曲处突然变细/变粗。原因顶点法线不连续。模型在硬边处共享顶点的法线为了平滑着色被平均化了导致外扩方向不一致。解决平滑法线这是最彻底的方案。在建模软件中或通过脚本计算一套用于描边的平滑顶点法线顶点法线该顶点所属的所有面法线的平均值然后存储在顶点色Color或额外的UV通道中。在描边Pass中使用这套平滑法线进行外扩。调整外扩方向有时使用视角空间下的法线viewNormal外扩比使用世界空间或模型空间法线更稳定。也可以尝试在挤出前对法线进行归一化normalize。模型检查确保模型没有重叠的顶点或非流形几何体。5.2 阴影接缝处出现闪烁或锯齿问题明暗交界线在摄像机移动或角色动画时出现闪烁Z-fighting或明显的锯齿。原因smoothstep函数的过渡区域太窄或者深度缓冲精度不足。解决适当增加_ThresholdSoftness的值让过渡更平滑。在计算漫反射NdotL时加入一个微小的偏移Bias避免在阈值边界因浮点数精度问题产生抖动。float diffuse smoothstep(_ShadowThreshold - _Softness, _ShadowThreshold _Softness, NdotL 0.001);考虑使用屏幕空间导数ddx/ddy或对NdotL进行简单的模糊处理来抗锯齿。5.3 高光在动画中抖动问题风格化的高光点尤其是基于纹理采样的在模型动画时剧烈抖动很不自然。原因高光形状纹理的UV是基于模型UV的当模型顶点动画如蒙皮时UV随之形变导致采样的纹理位置变化。解决使用屏幕空间UV将高光计算转移到屏幕空间。计算像素点的屏幕坐标用它来采样高光纹理。这样高光就“粘”在屏幕上了不会随模型变形而抖动。但这会失去与模型表面的关联性。使用世界空间或切线空间坐标用顶点的世界位置或切线方向来生成UV比模型UV更稳定。例如float2 uvSpec i.posWS.xz * _SpecularScale;对高光强度进行平滑对计算出的高光强度specular在时间或空间上进行平滑滤波可以缓解抖动感。5.4 与Unity后处理堆栈Post-processing的兼容性问题应用了Bloom、Color Grading等后处理效果后卡通色块边缘被模糊描边效果异常。原因后处理效果是基于整个屏幕图像处理的可能会破坏卡通Shader刻意营造的硬边。解决调整Bloom阈值提高Bloom效果的阈值Threshold避免对大面积暗部或中间调区域产生泛光只让最亮的高光部分产生Bloom。使用自定义渲染特征Render Feature在URP中可以编写自定义的Render Feature在渲染角色后、后处理前将角色渲染到一个单独的Render Target上。然后在这个RT上应用一些自定的风格化后处理如强化描边、色块化再与场景混合。这提供了最大的控制权但实现较复杂。分层渲染这是更高级的方案将角色渲染到单独的Layer并为这个Layer配置不同的后处理体积Volume实现差异化的后处理效果。调试Shader是一个需要耐心和观察力的过程。多利用Unity的Frame Debugger和RenderDoc这类工具一步步查看每个渲染Pass的输出能帮你快速定位问题是出在顶点阶段、片元阶段还是之后的混合阶段。记住卡通渲染没有绝对正确的参数一切以最终在目标设备上看到的艺术效果为准。大胆尝试反复调整直到找到最能表达你心中那个角色的光影和色彩。