ARTICLE DETAIL

资讯详情

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

Unity卡通渲染Shader手写实战:NPR光照、描边与调优全解析

Unity卡通渲染Shader手写实战:NPR光照、描边与调优全解析 1. NPR卡通渲染到底是什么1.1 从风格化到技术方案做游戏这么多年真正让我觉得“角色味道对了”的瞬间往往不是来自贴图有多精细而是来自光照模型选得有多准。PBR这套物理光照能还原金属、皮肤、布料的真实感但用在二次元角色上反而容易“脏”——因为二次元的审美核心是“平面感”和“信息性”阴影不是物理结果而是用来表达体积、方向、情绪的工具。NPRNon-Photorealistic Rendering非真实感渲染就是为这类需求服务的。Unity里做NPR卡通角色渲染本质上就三件事把漫反射量化成几个清晰的色阶、把高光压成硬边的圆形块、再用稳定的描边把轮廓“框”出来。听起来简单落地时坑特别多。这篇文章我会从零手写一套完整的卡通角色Shader把每一步的原理和踩过的坑都讲清楚适合已经有Unity基础、但第一次接触自定义Shader的TA或客户端开发也适合想从ShaderGraph跳出来、真正掌握着色器逻辑的美术同学。1.2 卡通渲染的核心三要素我把NPR角色渲染拆成三根支柱量化光照漫反射从连续渐变变成两段或三段式阴影最暗处不直接变黑而是用环境光或阴影色代替模拟二次元画手常用的“黑白灰分层”。轮廓线角色边缘必须有清晰、粗细一致的黑色描边而不是PBR那种靠“菲涅尔环境光”形成的模糊亮边。内结构线衣服褶皱、脸部轮廓、头发分片的交界处也要有明显的线条感这部分通常靠贴图绘制而不是实时算出来。这套思路在《崩坏3》《原神》《赛博朋克2077》里的不同角色身上都有体现只是风格参数不同。真正写起来Shader代码量不大难点全在参数调优和引擎兼容上。所以下面我会先讲光照模型怎么选再讲描边怎么做最后给一套完整可跑的代码。2. 光照模型选型与基础搭建2.1 为什么不用PBRPBR核心是BRDF也就是把光的反射拆成漫反射、镜面反射、微表面遮蔽等物理因素。它的阴影变化连续高光有个很长的拖尾对金属和皮肤很真实。但卡通角色要的是“一眼看清朝向”阴影边缘必须干脆利落高光最好是像塑料玩具那样的圆形亮点。PBR里那套微表面法线分布函数算出来的高光形状是椭圆且边缘柔和的还需要基础色贴图、法线贴图、粗糙度贴图、AO贴图四张图配合美术成本直接翻倍。NPR反而可以简化基础色一张图最多加个阴影色/Ramp图再配合顶点色控制渐变就能出很好的效果。Unity引擎也好第三方工具也好都提供了手动改Shader的入口上手难度其实比调一堆PBR参数更低。我见过很多团队一开始用Standard Shader改几个参数做“伪NPR”结果一到灯光角度变化就破功阴影边缘是软的角色身上的光斑带着PBR那种高光散开的物理感怎么看都像“3D渲染图加了滤镜”。所以想走卡通渲染趁早自己写光照最省心。2.2 漫反射的段式量化Ramp图还是硬边step漫反射量化的核心是NdotL法线和光源方向的点积。原本是[-1,1]连续值NPR要做的是把这个连续值映射成两三个离散档位。实现方式有两种方案实现优点缺点Ramp图用NdotL作为UV坐标采样一张渐变色阶图美术可精确控制每一档颜色能画出渐变过渡多一次纹理采样需要维护贴图资源硬边step用step或saturate配合阈值切分性能好逻辑直观颜色档位不够灵活阈值不好调我个人倾向移动端用step小团队用Ramp图。因为Ramp图本质上就是一个二维lookup表美术把“亮面”“灰面”“暗面”画成三块色带光照变了也只显色带效果最稳定。而step代码简单但阴影边界偶尔会有轻微“锯齿感”需要靠smoothstep做过渡否则切得太硬会闪。一段典型的硬边漫反射是这样的half diff saturate(dot(N, L)); half toon smoothstep(_ShadowStep, _ShadowStep _ShadowSmooth, diff); half3 ramp lerp(_ShadowColor, _BaseColor, toon);这里_ShadowStep是硬边阈值_ShadowSmooth是过渡软化范围。为什么要加smoothstep因为硬切会让同一个平面上有些像素在阈值左边、有些在右边渲染时出现一像素宽的抖动尤其旋转视角时特别明显。用smoothstep留一点点过渡带视觉上仍然是清晰分阶但不会闪烁。2.3 高光Cel Blinn-Phong卡通高光不需要物理真实性要的是“形状可控”。Blinn-Phong的半程向量公式已经够用只需要把连续的高光用step切碎。half spec pow(saturate(dot(N, H)), _Shininess); spec smoothstep(_SpecThreshold, _SpecThreshold _SpecSmooth, spec); half3 specColor _SpecColor * spec;这里有两个实用细节_Shininess越大高光范围越小_SpecThreshold越高高光越难出现。很多二次元风格喜欢在眼睛、头发、金属配饰上分别用不同高光强度和阈值所以我把这些参数都暴露在材质面板上一个Shader够用。需要注意Blinn-Phong的高光形状是跟着法线走的如果角色模型法线平滑度不好高光边缘会有棱角感。调的时候可以配合Unity导入设置里的“法线平滑”选项一起观察。3. 描边实现两种主流方案对比3.1 Inverted Hull描边倒置外扩法描边最常见的做法是“Inverted Hull”使用模型背面作为描边本体把顶点沿法线方向外扩一段距离同时剔除正面、只渲染背面输出纯色。这样做的好处是描边永远贴合模型轮廓而且粗细可以随顶点色控制。原理不复杂但有一个关键坑模型必须没有“内部面”。如果角色模型是多个网格拼在一起的或者袖口、领口有未闭合的边界外扩后这些内部的边界线也会露出来看起来像描边穿模。所以这个方案对美术建模规范要求很高模型必须严格闭合。Shader核心大概是这样// Pass 1: Outline Cull Front void vert(appdata v, inout v2f o) { float3 labelPos v.vertex.xyz normalize(v.normal) * _OutlineWidth * v.color.r; o.pos UnityObjectToClipPos(labelPos); } fixed4 frag() : SV_Target { return fixed4(_OutlineColor.rgb, 1); }注意我这里用了v.color.r作为描边宽度权重。这样美术可以在顶点绘制软件里把身体描边调细、把头发或靴子描边调粗比全局一个宽度灵活得多。另外normalize(v.normal)必不可少不归一化的话缩放模型后描边粗细会变。3.2 屏幕空间描边方案另一种常用做法是基于深度或法线做屏幕后处理描边。简单说就是计算像素周围深度/法线的不连续若差异大则判断为边缘输出黑色。这个方案适合整个场景轮廓比如物体和背景交界的地方效果很炫酷但用在卡通角色上有个致命问题描边会脱离模型受相机角度和后处理分辨率影响出现锯齿状粗细不均。实战中我见过很多团队在早期用了屏幕空间描边觉得“真方便”后来发现角色身上一些法线变化大的区域比如头发和脸的接缝被描得又粗又黑完全不受控最后还是改回Inverted Hull。所以我建议角色描边老老实实用顶点外扩法屏幕空间描边留给剪影、半透明物品或者怪物AOE特效。3.3 两种方案的取舍与经验维度Inverted Hull屏幕空间描边描边粗细控制可按顶点、物体调整稳定与像素密度相关不稳定模型要求必须闭合不能有开放边缘无要求性能多一个Pass多一次Draw Call后处理全屏开销风格适配卡通角色绝配适合全局特效你如果做的是Q版或赛璐璐角色闭眼选Inverted Hull。但记得必须在Shader里加上Cull Front和ZWrite On否则描边会被角色自身其他部分遮挡或者描边深度不对导致穿模。写完后最好用Frame Debugger逐个Pass看过一遍确认描边真的画在正确位置。4. 完整Shader实战逐步拆解4.1 URP还是内置管线环境准备现在Unity新项目基本都是URPUniversal Render Pipeline。URP下写Shader有两种方式ShaderLab里面嵌HLSL代码或者用Shader Graph。但注意URP的内置Shader变量和内置渲染管线不完全一样比如获取主平行光的方向内置管线用_WorldSpaceLightPos0URP里要用GetMainLight()函数。为了兼容性我下面的示例基于URP 12Unity 2021 LTS但思路同样适用于内置管线。如果你用的是Unity 2022或更新的Unity 6需要确认URP版本。URP 14以后把很多光照API内置到了Lighting.hlsl里比如GetMainLight、GetAdditionalLight。写代码时一定要#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl然后调用UniversalFragmentPBR之类的内置函数不对我们手写NPR不需要调用PBR函数只需取Light数据。环境准备就三步创建URP项目或者给现有项目安装URP Package。在Project Settings里把Graphics Scripts Settings中的Render Pipeline Asset设置为你的URP Pipeline Asset。确认目标平台移动端DX11还是OpenGL ES 3.0不同平台插值精度有差异。4.2 核心Pass代码卡通光照描边下面是一套完整可用的URP Toon Shader框架。核心是两个Pass第一个Pass画描边第二个Pass画角色本体。Shader Custom/ToonCharacter { Properties { [MainTexture] _BaseMap(Base Map, 2D) white {} _ShadowColor(Shadow Color, Color) (0.5, 0.5, 0.5, 1) _ShadowStep(Shadow Step, Range(0, 1)) 0.5 _ShadowSmooth(Shadow Smooth, Range(0.001, 0.1)) 0.02 _SpecColor(Spec Color, Color) (1, 1, 1, 1) _SpecThreshold(Spec Threshold, Range(0, 1)) 0.6 _SpecSmooth(Spec Smooth, Range(0.001, 0.1)) 0.05 _Shininess(Shininess, Range(1, 128)) 32 _RimColor(Rim Color, Color) (1, 1, 1, 1) _RimPower(Rim Power, Range(0, 8)) 3 _OutlineColor(Outline Color, Color) (0, 0, 0, 1) _OutlineWidth(Outline Width, Range(0, 5)) 0.5 } SubShader { Tags { RenderPipeline UniversalPipeline RenderType Opaque } // Pass 1: Outline Pass { Name OUTLINE Cull Front ZWrite On Tags { LightMode SRPDefaultUnlit } HLSLPROGRAM #pragma vertex vert_outline #pragma fragment frag_outline #include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float4 vertexColor : COLOR; }; struct Varyings { float4 positionCS : SV_POSITION; }; CBUFFER_START(UnityPerMaterial) half _OutlineWidth; half4 _OutlineColor; CBUFFER_END Varyings vert_outline(Attributes input) { Varyings output; float3 normalWS TransformObjectToWorldNormal(input.normalOS); float3 outlineOffset normalize(normalWS) * _OutlineWidth * saturate(input.vertexColor.r); float3 positionWS TransformObjectToWorld(input.positionOS.xyz) outlineOffset; output.positionCS TransformWorldToHClip(positionWS); return output; } half4 frag_outline(Varyings input) : SV_Target { return half4(_OutlineColor.rgb, 1); } ENDHLSL } // Pass 2: Toon Lighting Pass { Name FORWARD Tags { LightMode UniversalForward } Cull Back HLSLPROGRAM #pragma vertex vert #pragma fragment frag #include Packages/com.unity.render.pipelines.universal/ShaderLibrary/Lighting.hlsl struct Attributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; float2 uv : TEXCOORD0; }; struct Varyings { float4 positionCS : SV_POSITION; float3 normalWS : TEXCOORD0; float2 uv : TEXCOORD1; float3 positionWS : TEXCOORD2; }; CBUFFER_START(UnityPerMaterial) half _ShadowStep, _ShadowSmooth; half4 _ShadowColor, _SpecColor, _RimColor; half _SpecThreshold, _SpecSmooth, _Shininess, _RimPower; CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); Varyings vert(Attributes input) { Varyings output; output.positionCS TransformObjectToHClip(input.positionOS.xyz); output.normalWS TransformObjectToWorldNormal(input.normalOS); output.positionWS TransformObjectToWorld(input.positionOS.xyz); output.uv input.uv; return output; } half4 frag(Varyings input) : SV_Target { half4 albedo SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv); half3 N normalize(input.normalWS); Light mainLight GetMainLight(); half3 L normalize(mainLight.direction); half NdotL dot(N, L); half toonDiff smoothstep(_ShadowStep, _ShadowStep _ShadowSmooth, NdotL); half3 diffuse lerp(_ShadowColor.rgb * albedo.rgb, albedo.rgb, toonDiff); half3 H normalize(L normalize(_CameraPositionXYZ - input.positionWS)); half spec pow(saturate(dot(N, H)), _Shininess); half showSpec smoothstep(_SpecThreshold, _SpecThreshold _SpecSmooth, spec); half3 specular showSpec * _SpecColor.rgb; half3 viewDir normalize(_CameraPositionXYZ - input.positionWS); half fresnel pow(1 - saturate(dot(N, viewDir)), _RimPower); half3 rim fresnel * _RimColor.rgb; half3 finalColor (diffuse specular rim) * mainLight.color; return half4(finalColor, albedo.a); } ENDHLSL } } FallBack Off }这套代码已经能跑出一个非常基础的卡通角色黑色描边、两段式阴影、圆形高光、简单边缘光。但别急着复制粘贴。里面有一个_CameraPositionXYZ变量是伪代码URP里请使用GetWorldSpaceViewDir(input.positionWS)或者_WorldSpaceCameraPos.xyz这个我会在下一节详细修正。4.3 阴影与阴影采样让角色融入场景上面的代码只能处理主光源直接照射。如果场景里没有平行光或者角色站在阴影里你会看到角色像凭空发光一样亮。要解决就得接收阴影同时还要给角色自身投射阴影。URP里接收阴影需要在Pass中加#pragma multi_compile _ _SHADOWS_SOFT然后在frag里获取阴影坐标float4 shadowCoord TransformWorldToShadowCoord(input.positionWS); half shadowAtten MainLightRealtimeShadow(shadowCoord);然后卡通阴影是两级shadowAtten只有0和1但你想做一个“半阴影色”让角色比背景稍微亮一点可以把阴影衰减传到漫反射计算里或者在纯影区域加上环境光。代码调整如下half shadowAtten MainLightRealtimeShadow(shadowCoord); shadowAtten lerp(0.6, 1.0, shadowAtten); // 柔化阴影 half NdotL saturate(dot(N, L)) * shadowAtten; half toonDiff smoothstep(_ShadowStep, _ShadowStep _ShadowSmooth, NdotL); half3 diffuse lerp(_ShadowColor.rgb * albedo.rgb, albedo.rgb, toonDiff);这里有个工程细节如果阴影是实时阴影贴图角色模型较小时阴影贴图像素不够阴影边界会抖。可以考虑把渲染阴影贴图的分辨率调高或者给角色单独使用一套距离更近的阴影贴图。但移动端谨慎使用多阴影相机的开销。4.4 材质参数和皮肤、头发、衣物效果微调同一个Shader包出的角色要做到“皮肤软、衣服硬、头发亮”只需要调参数皮肤_ShadowStep设高一点0.6左右_ShadowSmooth设小一点高光小且锐利_Shininess高边缘光弧线要柔和。头发常见做法是额外添加一张高光贴图或者利用UV顺着发丝方向加上一层渐变高光用_SpecThreshold控制成一条条亮线。衣服阴影可以多阶梯通常在Ramp图里加一个“中间调”色阶让布料有更多颜色层次。这段逻辑可以把toonDiff从2档扩展到3档也就是在lerp中间再加一层mid值。我自己习惯给角色至少准备两个材质球一个给身体和衣服用一个给皮肤用。因为皮肤的阴影色偏红润粉色衣服阴影色偏冷蓝灰色共用一套参数没法独立调色。这只是参数层面的微调真要做脸部阴影比如二次元角色脸部前面的刘海阴影需要额外用一张面部阴影贴图或者写一个特殊的脸部头发遮罩Pass这篇文章先不展开后面可以单独聊。5. 进阶细节与多种表现技巧5.1 边缘光Rim Light的卡通化上面代码里的边缘光用的是标准Fresnel——pow(1 - NdotV, _RimPower)。这个公式在高画质PC下没什么问题但移动端有个坑因为法线插值精度低边缘光经常出现“斑驳”的光带看起来像脏渍。解决办法是给边缘光加一个阈值让光带只在特定轮廓范围内出现half fresnel pow(1 - saturate(dot(N, viewDir)), _RimPower); fresnel smoothstep(_RimThreshold, _RimThreshold _RimSmooth, fresnel); half3 rim fresnel * _RimColor.rgb;_RimThreshold控制光带的宽度。二次元杏涩常喜欢非常细的一条亮边比如耳朵或头发边缘通过这个阈值可以直接压出1到2像素的效果。另外如果角色背后是暗色背景边缘光会被背景吃掉可以考虑在摄像机脚本里根据角色主光源方向给边缘光增加偏色。5.2 顶点色控制描边宽度和阴影色前面代码里用了顶点色红通道控制描边宽度。这个方法特别实用美术在Blender里给角色顶点刷色腮红区域顶点色设为0.3头发尖端设为0.9刻线部分设为0.2角色轮廓立刻有了节奏感。但注意顶点色差值和顶点法线插值一样都是线性插值所以如果相邻顶点颜色差太大描边宽度也会出现梯形渐变需要美术控制颜色过渡。除了描边宽度顶点色还能作为“遮罩”控制哪些区域不受阴影影响比如脸部和眼部要避免大块阴影可以直接用顶点色设为0然后让frag里shadowMask input.vertexColor.a参与光照混合。这比画贴图快而且不用单独占用一张图的分量。5.3 如何让描边在VR和URP下稳定VR项目的描边是个痛点因为两只眼睛各自渲染一个图像Inverted Hull的描边如果宽度和视角挂钩会导致左右眼宽度不一致产生立体感错误。解决办法是用顶点外扩方向而不是屏幕空间法线方向并把描边宽度的单位设为“世界空间固定长度”而不是像素。上面的代码里_OutlineWidth是一个世界空间长度这样VR下基本稳定。URP下则要注意描边Pass的LightMode标签。我给描边Pass写了Tags { LightMode SRPDefaultUnlit }这个标签能确保描边不参与阴影传递也不会被URP的双向反射剔除。很多新手会遇到角色描边时有时无多半是LightMode标签没配对导致描边Pass被URP优化掉了。6. 常见问题与排查技巧实录6.1 描边断裂、法线不一致怎么排查Inverted Hull描边最常见的异常是描边在角色某个局部变得特别粗甚至飞出去。原因通常是模型法线不连续比如统一法线算法没开启或者模型里有多余的孤立面。排查方法很简单打开Unity的Import Settings在Model标签页下看看“Normals Tangents”是否Toggle为Import法线模式建议选择Calculate。如果模型法线已经导入但外扩后会穿邦考虑在模型制作软件里对角色全选所有面执行“Merge by Distance”清除内部重叠顶点。在Shader的描边Pass里临时把输出颜色改成“normalWS * 0.5 0.5”就可以直接看到法线是否正确这是TA们最常用的调试手段。如果描边只是个别片段缺失比如头发刘海和脸部交界处没有线那基本是背面剔除的方向问题。因为Inverted Hull依赖Cull Front但有些模型法线方向反了导致外扩方向不对。可以用双面渲染Cull Off临时测试但正式项目建议直接修模型法线。6.2 性能优化移动端卡通渲染的取舍卡通渲染看起来“低配”实际上并不便宜。多了一个描边Pass就多了一个Draw Call加上阴影采样、边缘光计算在低端手机上很容易上浮到2ms。我做了几个项目的性能优化总结出以下清单手段效果代价用step代替Ramp图采样少一次纹理采样阴影颜色过渡不够丰富把边缘光Pass合并到主Pass省一个Pass代码可读性下降关闭阴影接收的软阴影减少采样压力边界略生硬移动端禁用描边Pass改用屏幕空间后处理省模型Draw Call描边质量降级启用URP的SRP Batcher大量材质共用时可以合并Draw Call材质数量太多时收益有限所以移动端我通常会建议阴影用2档step边缘光用阈值切描边宽度控制在3像素以内主光源阴影贴图分辨率降到2048。效果最接近PC同时还能保证60帧。6.3 与动画、骨骼联动时的注意事项卡通角色的衣服和配饰经常绑定骨骼这会导致描边顶点外扩方向在骨骼运动时产生突变。比如袖子旋转时法线方向跟着翻转描边会像“撕裂”一样闪。解决方法是不要依赖实时法线做外扩而是使用模型导入时生成的“平滑法线数据”烘焙到顶点色或UV通道里。这个技术叫“法线贴出血”具体做法是在DCC工具里对模型做面法线平均值计算然后存入顶点色RBG通道Shader里直接用顶点色代替normalOS。我还在项目中遇到过角色表情变动时眼部或嘴部区域描边粗细不一致。这多半是因为面部模型在BlendShape差值时法线也跟着变而顶点色没有跟随。解决思路是把面部描边固定在骨骼或额外的一个空物体上而不是依赖网格顶点。或者干脆面部不做描边只保留身体描边二次元角色很多时候脸部不需要黑色外框。6.4 Debug工具和常见报错推荐几个工具Frame DebuggerUnity内置、RenderDoc抓取单帧渲染、ShaderLab Inspect查看Prefab里Shader变体。如果看到“Pass has no vertex shader”这类报错先检查#pragma指令是否匹配#pragma vertex vert必须和函数名一致。还有URP下如果使用了LightMode标签但没包含对应的头文件经常报“undeclared identifier _WorldSpaceCameraPos”。我的建议是统一用GetWorldSpaceViewDir这样既兼容URP也不容易写错。另外一个坑是CBUFFER_START(UnityPerMaterial)内的变量必须和Properties里的名字严格对应。如果你在Properties里写_OutlineWidth但在CBUFFER里漏了它材质面板调节不会生效而且URP的SRP Batcher有时会直接跳过这个材质球导致画面不变。检查方法很简单选中材质点击Shader的下拉菜单选择“Select Shader”在材质头的Inspector里看是否有“No SRP Batcher”提示。最后再分享一个小技巧这套Shader我从很多项目里磨过来现在给新同事演示时我一定会提醒他们先把阴影阈值调得极端一点比如_ShadowStep0.1和_ShadowStep0.9各渲染一次。这种极端参数能让你一眼看出光照模型的梯度行为也能快速分辨Ramp图和step方案的差异。NPR的精髓不在Shader写得多复杂而在阴影切割的位置、描边宽度的节奏、高光形状的干净程度——这三个参数调顺了角色立刻就“二次元”起来。如果你打算继续深挖下一步建议研究脸部特殊阴影用贴图控制鼻翼、下巴的阴影形状和发丝高光沿着切线方向拉伸高光UV。这两块都是NPR角色渲染最容易出彩的细节也最能体现一个TA或者说图形程序员的水平。希望这篇实战记录对你有用至少能让你少踩几个我踩过的坑。
返回列表