Unity风格化渲染实战:卡通与素描渲染核心原理与URP实现

📅 2026/8/9 6:17:26
Unity风格化渲染实战:卡通与素描渲染核心原理与URP实现
1. 项目概述从写实到风格化的艺术表达在Unity的世界里我们早已习惯了追求极致的真实感——PBR材质、全局光照、体积雾一切都为了让虚拟世界无限逼近现实。但有时候艺术恰恰需要跳出“真实”的框架。你是否想过如何让一个3D角色看起来像从漫画书里走出来或者让整个场景呈现出铅笔素描的质感这就是非真实感渲染Non-Photorealistic Rendering NPR的魅力所在。NPR或者说风格化渲染其核心目标并非模拟物理世界而是模拟艺术家的笔触和风格。它通过一系列算法和技术将三维模型渲染成具有特定艺术风格如卡通、素描、水彩、油画的二维图像。这种技术在游戏、动画、影视和交互艺术中应用广泛从《塞尔达传说旷野之息》的清新卡通到《大神》的水墨风格再到许多独立游戏中独特的视觉呈现都离不开NPR技术的支撑。本章我们将聚焦于Unity中两种最经典且实用的NPR风格卡通渲染和素描渲染。我会带你从原理出发手把手实现核心的着色器效果并分享在实际项目中优化和调试这些风格化效果的经验与坑点。无论你是想为你的独立游戏增添独特的视觉标签还是希望在技术美术的道路上深入探索这篇文章都将提供一套可直接落地的实战方案。2. 卡通渲染核心原理与实现方案卡通渲染常被称为Cel Shading或Toon Shading其视觉特征非常鲜明色块分明、阴影锐利、带有轮廓线。它通过简化光影的连续过渡用几个清晰的色阶来表现明暗关系从而营造出类似手绘卡通或动画赛璐珞片的效果。2.1 光影的阶跃化实现色块分明感真实感渲染中漫反射光照的计算结果通常用兰伯特模型即表面法线与光线方向点积NdotL是一个从0到1的连续值。卡通渲染的第一步就是把这个连续的光照强度“离散化”。核心思路在片元着色器中我们将计算出的NdotL值映射到一个有限的、预先定义好的颜色阶梯上。例如我们可以定义三个阶梯亮部、中间调、暗部。// 在Fragment Shader中 float NdotL dot(normalize(i.normal), normalize(_WorldSpaceLightPos0.xyz)); float ramp 0; // 方法1使用阶梯函数Step Function实现硬过渡 if (NdotL 0.6) ramp 1.0; // 亮部 else if (NdotL 0.3) ramp 0.5; // 中间调 else ramp 0.0; // 暗部 // 方法2使用平滑阶梯函数Smoothstep实现略带柔和的过渡更常用 ramp smoothstep(_RampThreshold - _RampSmooth, _RampThreshold _RampSmooth, NdotL); // 通常我们会准备一张一维渐变纹理Ramp Texture根据NdotL采样 // float2 uv_ramp float2(NdotL * 0.5 0.5, 0.5); // 将[-1,1]映射到[0,1]的纹理UV // fixed3 rampColor tex2D(_RampTex, uv_ramp).rgb;实操心得直接使用if-else或step函数虽然简单但边缘会过于生硬在角色转动时可能出现阴影“跳动”的现象。更优的做法是使用一张一维渐变纹理Ramp Texture进行采样。这张纹理从左到右定义了从暗到亮的颜色变化你可以在Photoshop中精心绘制控制每个色阶的宽度和过渡。这样不仅效果更可控还能通过更换纹理快速切换不同的卡通色调风格比如日系明亮风或美系厚重风。高光处理卡通风格的高光通常不是柔和的光晕而是锐利的、形状规则的高光点或高光条。我们可以采用类似的方法对高光强度进行阈值化处理。// 计算高光这里用简化版Blinn-Phong float3 viewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos.xyz); float3 halfDir normalize(lightDir viewDir); float NdotH dot(normalize(i.normal), halfDir); float specular pow(max(0, NdotH), _Gloss); // 对高光进行阶跃化 float specularRamp step(_SpecularThreshold, specular); fixed3 specularColor specularRamp * _SpecularColor.rgb;通过调整_SpecularThreshold和_Gloss控制高光范围你可以得到从一个小圆点到一道细长高光的不同效果这对于表现卡通角色的头发、金属材质至关重要。2.2 轮廓描边的五大算法与选型轮廓线是卡通渲染的灵魂它能将物体从背景中清晰地分离出来强化其二维感。实现轮廓线的方法很多各有优劣需要根据项目需求和性能预算进行选择。2.2.1 基于法线-视线夹角的后处理描边这是最常用、性能相对较好的一种屏幕空间方法。其原理是在边缘处物体表面法线方向与摄像机视线方向近乎垂直。第一遍渲染将场景的法线信息和深度信息渲染到一张纹理NormalDepth Buffer中。第二遍后处理对屏幕图像进行全屏采样。对于每个像素采样其周围几个像素如使用Sobel算子的法线和深度值。边缘检测如果相邻像素间的法线差异或深度差异超过某个阈值则认为该像素位于轮廓边缘。// 片段着色器示例后处理 fixed4 frag (v2f i) : SV_Target { // 采样当前像素和周围像素的法线、深度 float3 normalCenter tex2D(_CameraNormalsTexture, i.uv).rgb; float depthCenter LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv)); // 使用Roberts交叉算子进行简单边缘检测 float2 offsets[4] {float2(1,1), float2(-1,1), float2(1,-1), float2(-1,-1)} * _OutlineWidth / _ScreenParams.xy; float edge 0; for (int j 0; j 4; j) { float3 normalSample tex2D(_CameraNormalsTexture, i.uv offsets[j]).rgb; float depthSample LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv offsets[j])); // 计算法线差异和深度差异 float normalDiff dot(normalCenter, normalSample); float depthDiff abs(depthCenter - depthSample); edge (1 - normalDiff) * _NormalSensitivity; edge depthDiff * _DepthSensitivity; } edge saturate(edge); // 如果edge大于阈值则输出轮廓色 return lerp(tex2D(_MainTex, i.uv), _OutlineColor, step(_EdgeThreshold, edge)); }优点实现简单能捕捉到所有物体的轮廓包括因遮挡产生的内部轮廓。缺点描边宽度均匀且容易受到屏幕分辨率影响在物体运动快速时可能产生闪烁。对透明物体的处理也比较麻烦。2.2.2 基于背面膨胀的几何描边这是一种在模型空间操作的经典方法利用两个Pass渲染。第一个Pass渲染背面轮廓剔除正面Cull Front将模型顶点沿其法线方向向外膨胀一定距离并用纯色通常是黑色渲染这个膨胀后的背面。这样就形成了一个比原模型稍大的“壳”。第二个Pass正常渲染正面剔除背面Cull Back正常渲染模型本身覆盖掉内部的大部分轮廓色只留下边缘部分。// Pass 1: 渲染轮廓 Cull Front ZTest Less // 确保轮廓在模型前面 CGPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline v2f_outline vert_outline (appdata v) { v2f_outline o; // 将顶点沿法线方向挤出 float3 pos v.vertex.xyz v.normal * _OutlineWidth; o.pos UnityObjectToClipPos(float4(pos, 1.0)); o.color _OutlineColor; return o; } fixed4 frag_outline (v2f_outline i) : SV_Target { return i.color; } ENDCG // Pass 2: 正常渲染模型略优点描边稳定宽度可控且是真正的几何描边不受屏幕分辨率影响。缺点每个需要描边的物体都需要两个Draw Call对性能有影响。在模型法线不均匀或存在硬边时膨胀可能产生缝隙或断裂。对于复杂模型如头发、树叶效果可能不理想。2.2.3 基于轮廓边检测的几何描边这是一种更精确的几何方法它直接识别并渲染模型网格中真正的“轮廓边”。轮廓边的定义是共享这条边的两个三角形一个面向摄像机一个背向摄像机。实现这种方法的Shader比较复杂通常需要在CPU端或几何着色器中预处理模型找出所有可能的轮廓边然后在渲染时根据视角动态判断并渲染这些边。Unity的某些资源商店插件采用了类似思路。优点能生成非常干净、准确的单像素轮廓线风格极简。缺点实现复杂性能开销大且对于动态变化的模型如蒙皮动画需要每帧更新轮廓边数据实用性较低。选型建议对于移动端或性能敏感的项目基于法线-视线的后处理描边是平衡效果与性能的首选。对于主机或PC项目追求稳定高质量的轮廓基于背面膨胀的几何描边更可靠但务必做好模型法线的平滑处理。对于特定风格如仅需关键结构线的静态场景可以考虑轮廓边检测。3. 素描风格渲染的模拟与实现素描风格渲染模拟的是铅笔或炭笔在纸上的绘画效果其特征包括连续的排线、笔触质感、明暗通过线条密度表现以及纸张纹理。3.1 色调艺术图与笔触纹理的应用素描渲染的核心思想是用线条的疏密而非颜色的深浅来表现明暗。Praun等人提出的色调艺术图Tonal Art Maps, TAM是这一领域的经典方法。TAM本质上是一组预先绘制的、代表不同明暗程度的笔触纹理。实现流程准备TAM纹理集你需要准备一系列例如6-8张灰度纹理。从最亮的几乎全白只有零星几点到最暗的线条密集近乎全黑。这些纹理上的笔触应该是随机的、手绘感的。计算表面亮度在Shader中像往常一样计算模型表面的光照亮度NdotL考虑环境光等得到一个0到1之间的值。纹理选择与混合根据计算出的亮度值决定使用哪一张或哪两张TAM纹理并进行混合。例如亮度值0.3可能对应使用第三张纹理亮度值0.35可能对应在第三张和第四张纹理之间进行线性插值。叠加纸张纹理最后将采样到的笔触纹理与一张纸张底纹纹理进行叠加通常使用Multiply混合模式以增强手绘的真实感。// 在Fragment Shader中 float brightness CalculateLighting(i); // 计算得到亮度值范围[0,1] // 将亮度映射到纹理索引 float texIndex brightness * (_TAMCount - 1); float blendFactor frac(texIndex); // 获取小数部分用于混合 int index1 floor(texIndex); int index2 min(index1 1, _TAMCount - 1); // 采样两张相邻的TAM纹理 fixed4 stroke1 tex2D(_TAMTextures[index1], i.uv * _Tiling); fixed4 stroke2 tex2D(_TAMTextures[index2], i.uv * _Tiling); // 根据亮度混合 fixed4 strokeColor lerp(stroke1, stroke2, blendFactor); // 叠加纸张纹理 fixed4 paperColor tex2D(_PaperTex, i.worldPos.xz * _PaperScale); fixed4 finalColor strokeColor * paperColor; // 还可以根据视角或世界坐标对UV进行轻微扰动避免纹理重复感注意事项TAM纹理的绘制质量直接决定最终效果。笔触的方向最好有一定变化避免过于规律。此外i.uv如果使用模型UV在UV展开不好的模型上会导致笔触扭曲。一个更好的做法是使用屏幕空间坐标或世界空间坐标如i.worldPos.xz作为纹理坐标这样笔触会“贴”在物体表面随摄像机移动而移动更像手绘效果。3.2 屏幕空间笔划生成与扰动除了使用预制的TAM我们还可以尝试在屏幕空间动态生成素描线条这能带来更动态、更风格化的效果。一种常见方法是利用屏幕空间的后处理结合噪声和边缘检测信息来模拟素描排线。边缘信息沿用或改进卡通渲染中提到的后处理边缘检测得到场景的轮廓线和重要的内部结构线如折痕。生成排线利用屏幕空间噪声纹理如Perlin噪声和场景深度/法线信息生成方向性的线条。例如可以根据世界坐标的Y值决定线条是水平还是垂直排布。明暗控制将计算出的场景亮度图作为蒙版控制线条的密度。越暗的地方采样噪声纹理的阈值越低从而显示出更多的线条越亮的地方线条越少甚至没有。// 后处理Shader片段示例 fixed4 frag (v2f i) : SV_Target { fixed4 sceneColor tex2D(_MainTex, i.uv); float depth LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv)); float3 normal tex2D(_CameraNormalsTexture, i.uv).rgb; // 1. 获取边缘强度 float edge GetEdgeStrength(i.uv); // 2. 生成基于世界坐标的线条方向 float2 worldUV GetWorldSpaceUV(i.uv, depth); float noise tex2D(_NoiseTex, worldUV * _NoiseScale).r; // 将噪声转化为方向性线条例如sin波 float line sin(worldUV.y * _LineFrequency _Time.y * _LineSpeed) * 0.5 0.5; line step(noise, line); // 用噪声阈值化线条 // 3. 结合亮度控制线条可见性 float luminance Luminance(sceneColor.rgb); float lineVisibility (1.0 - luminance) * _LineDensity; // 越暗线条越密 line line * lineVisibility; // 4. 结合边缘和内部线条 float finalSketch max(edge, line); // 最终颜色用线条强度去lerp原色和笔触色如棕色 fixed3 finalColor lerp(sceneColor.rgb, _StrokeColor.rgb, finalSketch); // 混合纸张纹理 finalColor * tex2D(_PaperTex, i.uv * _PaperScale).rgb; return fixed4(finalColor, sceneColor.a); }这种方法灵活性极高可以通过调整噪声纹理、频率、速度等参数创造出从工整的工程制图到狂放的速写等多种素描风格。4. 实战在URP中构建完整的卡通角色Shader了解了原理我们动手在Unity的通用渲染管线URP中构建一个功能完整的卡通角色Shader。URP的Shader编写结构与内置管线有所不同更模块化。4.1 URP Lit Shader框架与属性定义首先在Unity中创建一个Unlit Shader或从URP Lit Shader模板修改因为它给我们最大的控制权。我们定义所需属性Properties { [MainTexture] _BaseMap(Albedo (RGB), 2D) white {} [MainColor] _BaseColor(Base Color, Color) (1,1,1,1) // 卡通色阶 _RampTex(Ramp Texture, 2D) white {} _RampSmooth(Ramp Smoothness, Range(0, 0.5)) 0.1 _ShadowColor(Shadow Color, Color) (0.5,0.5,0.5,1) // 高光 _SpecularColor(Specular Color, Color) (1,1,1,1) _SpecularThreshold(Specular Threshold, Range(0,1)) 0.9 _SpecularGloss(Specular Gloss, Range(1, 256)) 64 // 轮廓线背面膨胀法 _OutlineColor(Outline Color, Color) (0,0,0,1) _OutlineWidth(Outline Width, Range(0, 0.1)) 0.03 // 边缘光Rim Light _RimColor(Rim Color, Color) (1,1,1,1) _RimPower(Rim Power, Range(0.1, 10)) 5 _RimThreshold(Rim Threshold, Range(0,1)) 0.5 }4.2 编写顶点与片元着色器逻辑我们需要两个SubShader Pass一个用于渲染轮廓一个用于渲染模型本身。Pass 0: 轮廓Pass这个Pass非常简单只做顶点膨胀和输出固定颜色。SubShader { Tags { RenderTypeOpaque RenderPipelineUniversalPipeline } // Pass 0: Outline Pass Pass { Name Outline Tags { LightModeSRPDefaultUnlit } // 使用一个不被光照影响的LightMode Cull Front // 剔除正面渲染背面 HLSLPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl float _OutlineWidth; half4 _OutlineColor; struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct Varyings { float4 positionHCS : SV_POSITION; }; Varyings vert_outline(Attributes IN) { Varyings OUT; VertexPositionInputs vertexInput GetVertexPositionInputs(IN.positionOS.xyz); // 将法线从物体空间转换到观察空间View Space再进行挤出效果更稳定 VertexNormalInputs normalInput GetVertexNormalInputs(IN.normalOS); float3 normalVS TransformWorldToViewDir(normalInput.normalWS); // 在观察空间挤出顶点 float3 posVS TransformWorldToView(vertexInput.positionWS); posVS normalize(normalVS) * _OutlineWidth; // 转换到齐次裁剪空间 OUT.positionHCS TransformWViewToHClip(posVS); return OUT; } half4 frag_outline(Varyings IN) : SV_Target { return _OutlineColor; } ENDHLSL }Pass 1: 主着色Pass这个Pass包含完整的卡通光照计算。// Pass 1: Main Toon Shading Pass Pass { Name ForwardLit Tags { LightModeUniversalForward } Cull Back ZTest LEqual ZWrite On HLSLPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS #pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl // ... 属性变量声明同上 ... struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 texcoord : TEXCOORD0; }; struct Varyings { float4 positionHCS : SV_POSITION; float2 uv : TEXCOORD0; float3 normalWS : TEXCOORD1; float3 positionWS : TEXCOORD2; float3 viewDirWS : TEXCOORD3; }; Varyings vert(Attributes IN) { Varyings OUT; VertexPositionInputs vertexInput GetVertexPositionInputs(IN.positionOS.xyz); VertexNormalInputs normalInput GetVertexNormalInputs(IN.normalOS); OUT.positionHCS vertexInput.positionCS; OUT.uv TRANSFORM_TEX(IN.texcoord, _BaseMap); OUT.normalWS normalInput.normalWS; OUT.positionWS vertexInput.positionWS; OUT.viewDirWS GetWorldSpaceNormalizeViewDir(vertexInput.positionWS); return OUT; } half4 frag(Varyings IN) : SV_Target { // 采样基础纹理 half4 baseColor SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv) * _BaseColor; // 获取主光源 Light mainLight GetMainLight(TransformWorldToShadowCoord(IN.positionWS)); float3 lightDir mainLight.direction; float3 lightColor mainLight.color; // 计算漫反射 (NdotL) float NdotL dot(normalize(IN.normalWS), lightDir); NdotL NdotL * 0.5 0.5; // 映射到[0,1] // 使用Ramp纹理进行阶跃化 float2 rampUV float2(NdotL, 0.5); half3 rampColor SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, rampUV).rgb; // 计算漫反射颜色 half3 diffuse baseColor.rgb * rampColor * lightColor; // 计算高光 (Blinn-Phong) float3 viewDir normalize(IN.viewDirWS); float3 halfDir normalize(lightDir viewDir); float NdotH dot(normalize(IN.normalWS), halfDir); float specular pow(saturate(NdotH), _SpecularGloss); specular step(_SpecularThreshold, specular); // 阈值化 half3 specularColor specular * _SpecularColor.rgb * lightColor; // 计算边缘光 (Rim Light) float rim 1.0 - saturate(dot(viewDir, normalize(IN.normalWS))); rim smoothstep(_RimThreshold, 1.0, rim); rim pow(rim, _RimPower); half3 rimColor rim * _RimColor.rgb; // 合并所有光照成分 half3 finalColor diffuse specularColor rimColor; // 应用阴影URP的阴影是屏幕空间这里简单乘上阴影衰减 half shadowAttenuation mainLight.shadowAttenuation; finalColor * shadowAttenuation; // 添加环境光 half3 ambient SampleSH(IN.normalWS); finalColor baseColor.rgb * ambient; return half4(finalColor, baseColor.a); } ENDHLSL } }这个Shader已经具备了卡通渲染的核心要素基于Ramp纹理的阶跃漫反射、阈值化高光、轮廓线以及增强立体感的边缘光。你可以将其应用到角色模型上并通过调整Ramp纹理和各项参数来获得理想的卡通效果。5. 性能优化与常见问题排查风格化渲染虽然视觉效果独特但在性能上也可能带来挑战尤其是在移动平台或需要渲染大量角色的场景中。5.1 移动端NPR性能优化策略精简计算避免复杂光照模型卡通渲染通常不需要计算复杂的镜面反射BRDF。使用简单的NdotL和阈值判断即可。简化轮廓算法在移动端优先考虑基于法线的后处理描边。虽然效果可能略逊于几何描边但它是一个全屏效果Draw Call不随物体数量增加而增加。如果必须用几何描边确保轮廓Pass尽可能简单只做顶点变换和输出颜色。慎用屏幕空间效果素描风格的后处理排线生成对性能消耗较大。可以考虑将其简化为在物体空间使用一张平铺的、带有方向性的噪声纹理来模拟。利用Shader变体与多级细节LOD为不同性能等级的设备准备不同复杂度的Shader变体。例如低配设备关闭高光、边缘光使用更简单的2阶色阶。为远处的模型使用更简单的Shader甚至切换到非NPR的简单漫反射Shader。纹理优化Ramp纹理和TAM纹理通常尺寸很小如128x1或256x256确保它们被正确压缩如ASTC。将多个角色的基础色、高光遮罩等纹理合并到一张图集Atlas中减少纹理采样次数。批处理与GPU Instancing确保使用相同Shader和材质的角色模型能够进行动态批处理或GPU Instancing。这需要Shader支持并在属性中声明[PerRendererData]的材质属性要谨慎使用。5.2 常见视觉问题与修复方案轮廓线断裂或闪烁问题使用背面膨胀法时在模型的硬边或法线不连续处轮廓线会出现断裂。排查检查模型的法线。在3D建模软件中确保使用了平滑着色Smooth Shading或者导入Unity后勾选模型的“平滑法线”选项。对于无法平滑的硬边可以考虑在Shader中在挤出轮廓前对顶点法线进行简单的平滑处理如取相邻面的平均法线但这需要在顶点数据中提供更多信息。临时方案轻微增加_OutlineWidth或切换到后处理描边。色阶过渡生硬出现“带状”瑕疵问题卡通色阶之间过渡不自然在模型曲面上能看到明显的颜色分界线。排查检查Ramp纹理。确保它不是由几个纯色块组成而应该是有轻微平滑过渡的渐变。在Shader中使用smoothstep函数或在采样Ramp纹理时进行双线性过滤。调整增加_RampSmooth参数的值让阶梯函数有一个平滑过渡区间。轮廓线在特定角度消失或异常问题当摄像机与模型表面平行时基于法线-视线夹角的轮廓线可能消失背面膨胀的轮廓线可能穿帮。排查与解决法线-视线法这是其固有缺陷。可以尝试结合深度差检测作为补充确保在轮廓处深度变化剧烈也能被识别。背面膨胀法确保轮廓Pass的深度测试ZTest设置为Less或LEqual并且模型本身的渲染Pass的深度写入ZWrite是开启的。有时需要让轮廓线稍微有一点深度偏移Offset以避免深度冲突Z-fighting。素描线条随摄像机抖动问题使用世界坐标或屏幕坐标生成素描线条时线条会随着摄像机微小移动而剧烈抖动很不自然。解决对用于采样噪声纹理的UV坐标进行抖动Jitter处理。可以每帧添加一个微小的、基于时间的随机偏移或者使用一个稳定的噪声源。更高级的做法是使用屏幕空间微分ddx/ddy或fwidth来估算当前像素在屏幕上的变化率并据此调整线条的粗细或可见性使其在运动时更稳定。透明物体的NPR渲染问题问题半透明物体如头发、玻璃的轮廓线渲染错误或者内部无法正确呈现卡通色阶。解决这是一个复杂问题。通常需要为透明物体单独编写Shader。对于轮廓可能需要将透明物体的渲染顺序调整到不透明物体之后并小心处理深度。对于着色透明物体的光照计算可能需要考虑透射光简单的NdotL可能不够需要更复杂的模型或将其作为特殊情况处理。实现非真实感渲染是一个不断权衡艺术效果与技术实现的过程。没有一种方案是完美的最好的方案永远是那个最契合你项目艺术风格、性能预算和技术目标的方案。从最基本的卡通色阶和轮廓线开始逐步叠加高光、边缘光、特殊纹理等细节并时刻在真机上测试性能表现你就能打造出既独特又高效的风格化视觉体验。