Unity卡通渲染实战:从零构建NPR角色着色器与风格化渲染管线
2026/7/22 6:45:54 网站建设 项目流程

1. 项目概述:为什么NPR卡通渲染值得你投入时间

如果你在Unity社区里混迹过一段时间,肯定会发现一个现象:那些让人眼前一亮的独立游戏Demo,或者是在社交媒体上疯狂传播的“赛博菩萨”、“二次元老婆”角色展示,十有八九都离不开一套精心调校的卡通渲染(NPR, Non-Photorealistic Rendering)方案。这玩意儿早就不是大厂的专利了,它已经从高不可攀的技术壁垒,变成了中小团队乃至个人开发者实现风格化、打造产品差异化的核心武器。我见过太多项目,模型精度一般,但靠着一手漂亮的Shader,直接把视觉表现力拉满,在Steam新品节或者B站上成功吸引第一波流量。

“从零打造”这四个字听起来有点吓人,好像要从图形API开始写起。别慌,我们这里说的“从零”,指的是不依赖Asset Store里那些黑盒化的完整方案,而是从最基础的着色器知识出发,理解每一行代码背后的意图,最终组装出一个属于你自己、可控可调的卡通角色渲染管线。这个过程,本质上是在学习如何用代码“绘画”,用光影和色彩来叙事。最终目标不是复刻某个特定的动漫风格,而是让你掌握构建这种风格的工具和方法论,未来无论是想做日系赛璐璐、美式厚涂,还是带点水墨感的国风,你都能心里有底。

2. 核心思路拆解:卡通渲染到底“卡”在哪里?

要造轮子,先得知道轮子长什么样。写实渲染(PBR)追求的是模拟物理世界的光线行为,而卡通渲染的核心思想是“归纳”和“风格化”。它主动抛弃了物理的复杂性,用高度概括的视觉语言来表达。拆解开来,主要攻克以下几个点:

2.1 色块化与硬阴影

这是卡通渲染最标志性的特征。现实中的阴影有平滑的过渡(软阴影),而卡通片里,明暗交界线往往非常锐利,形成清晰的色块。在Shader里,我们不会用传统的dot(N, L)(法线点乘光方向)这种连续函数直接计算漫反射,而是会通过一个阈值函数(比如stepsmoothstep)对其进行“离散化”处理。

// 传统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)

描边的作用是强化轮廓,分离角色与背景,增加画面的绘画感和立体感。主流实现方法有几种,各有优劣:

  1. 后处理描边(Post-processing Outline):基于整个屏幕的深度/法线信息检测边缘。优点是能统一处理所有物体,实现简单。缺点是对模型细节不敏感,容易在复杂场景产生杂边,且无法实现“内描边”(如衣服褶皱处的强调线)。
  2. 法线外拓描边(Normal Extrusion):在顶点着色器阶段,沿着顶点法线方向将模型顶点向外挤出一点点,然后用纯色(通常是黑色)渲染这个挤出的“壳”,再渲染内部模型盖住它。这是角色渲染中最常用、效果最稳定的方法。
  3. 轮廓边检测描边(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的URP(Universal Render Pipeline)作为渲染管线,因为它是未来趋势,且内置了良好的多光源支持框架。

3.1 工程与Shader框架搭建

首先,在Unity中创建一个URP项目。在Project窗口创建我们的Shader文件,比如Custom/ToonCharacter.shader。一个完整的Shader通常包含多个Pass,我们至少需要两个:

  1. OutlinePass:用于渲染描边。
  2. 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; }

关键在片元着色器。我们编写一个自定义的光照函数CustomLighting

half4 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需要设置为"LightMode"="UniversalForward",并且开启相应的渲染状态(如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 实现面部特殊处理(如腮红)

面部渲染是卡通角色的灵魂。通常我们需要额外的遮罩贴图来标记脸颊、鼻头等需要特殊渲染的区域。

  1. 让美术在模型的一套UV(比如UV2)上,在脸颊位置画上红色遮罩。
  2. 在Shader中采样这张遮罩贴图。
  3. 将遮罩值与一个自定义的颜色(腮红色)混合到最终颜色上,混合模式可以是叠加(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通道。
  • 简化计算:在片元着色器中,将powsmoothstep等函数替换为查表(LUT)或更简单的近似计算。对于移动端,可以考虑将部分计算(如漫反射阈值判断)移到顶点着色器,然后在片元着色器插值,虽然精度有损失,但能省下大量计算。
  • 减少Pass数量:描边Pass是额外的绘制调用(Draw Call)。对于大量同屏角色,可以考虑使用后处理描边,或者将描边信息烘焙到顶点色中,在同一个Pass里用几何着色器或顶点偏移技巧实现(但这更复杂)。
  • 使用Shader LOD:为Shader设置不同的细节等级(LOD),当摄像机远离时,自动切换到更简化的版本(比如关闭法线贴图、简化高光计算)。

5. 常见问题与调试实录

在实际开发中,你肯定会遇到各种诡异的现象。这里记录几个我踩过的坑和解决方法。

5.1 描边断裂或粗细不均

  • 问题:模型边缘的描边不连续,或者在关节弯曲处突然变细/变粗。
  • 原因:顶点法线不连续。模型在硬边处,共享顶点的法线为了平滑着色被平均化了,导致外扩方向不一致。
  • 解决
    1. 平滑法线:这是最彻底的方案。在建模软件中或通过脚本,计算一套用于描边的平滑顶点法线(顶点法线=该顶点所属的所有面法线的平均值),然后存储在顶点色(Color)或额外的UV通道中。在描边Pass中,使用这套平滑法线进行外扩。
    2. 调整外扩方向:有时,使用视角空间下的法线(viewNormal)外扩比使用世界空间或模型空间法线更稳定。也可以尝试在挤出前对法线进行归一化(normalize)。
    3. 模型检查:确保模型没有重叠的顶点或非流形几何体。

5.2 阴影接缝处出现闪烁或锯齿

  • 问题:明暗交界线在摄像机移动或角色动画时出现闪烁(Z-fighting)或明显的锯齿。
  • 原因smoothstep函数的过渡区域太窄,或者深度缓冲精度不足。
  • 解决
    1. 适当增加_ThresholdSoftness的值,让过渡更平滑。
    2. 在计算漫反射NdotL时,加入一个微小的偏移(Bias),避免在阈值边界因浮点数精度问题产生抖动。float diffuse = smoothstep(_ShadowThreshold - _Softness, _ShadowThreshold + _Softness, NdotL + 0.001);
    3. 考虑使用屏幕空间导数(ddx/ddy)或对NdotL进行简单的模糊处理,来抗锯齿。

5.3 高光在动画中抖动

  • 问题:风格化的高光点(尤其是基于纹理采样的)在模型动画时剧烈抖动,很不自然。
  • 原因:高光形状纹理的UV是基于模型UV的,当模型顶点动画(如蒙皮)时,UV随之形变,导致采样的纹理位置变化。
  • 解决
    1. 使用屏幕空间UV:将高光计算转移到屏幕空间。计算像素点的屏幕坐标,用它来采样高光纹理。这样高光就“粘”在屏幕上了,不会随模型变形而抖动。但这会失去与模型表面的关联性。
    2. 使用世界空间或切线空间坐标:用顶点的世界位置或切线方向来生成UV,比模型UV更稳定。例如:float2 uvSpec = i.posWS.xz * _SpecularScale;
    3. 对高光强度进行平滑:对计算出的高光强度(specular)在时间或空间上进行平滑滤波,可以缓解抖动感。

5.4 与Unity后处理堆栈(Post-processing)的兼容性

  • 问题:应用了Bloom、Color Grading等后处理效果后,卡通色块边缘被模糊,描边效果异常。
  • 原因:后处理效果是基于整个屏幕图像处理的,可能会破坏卡通Shader刻意营造的硬边。
  • 解决
    1. 调整Bloom阈值:提高Bloom效果的阈值(Threshold),避免对大面积暗部或中间调区域产生泛光,只让最亮的高光部分产生Bloom。
    2. 使用自定义渲染特征(Render Feature):在URP中,可以编写自定义的Render Feature,在渲染角色后、后处理前,将角色渲染到一个单独的Render Target上。然后在这个RT上应用一些自定的风格化后处理(如强化描边、色块化),再与场景混合。这提供了最大的控制权,但实现较复杂。
    3. 分层渲染:这是更高级的方案,将角色渲染到单独的Layer,并为这个Layer配置不同的后处理体积(Volume),实现差异化的后处理效果。

调试Shader是一个需要耐心和观察力的过程。多利用Unity的Frame Debugger和RenderDoc这类工具,一步步查看每个渲染Pass的输出,能帮你快速定位问题是出在顶点阶段、片元阶段,还是之后的混合阶段。记住,卡通渲染没有绝对正确的参数,一切以最终在目标设备上看到的艺术效果为准。大胆尝试,反复调整,直到找到最能表达你心中那个角色的光影和色彩。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询