
我到现在还记得第一次在项目里看到模型材质全变成紫红色那种手忙脚乱的感觉。在Unity里紫红色材质基本就一个含义——运行时找不到对应Shader。而这件事在角色渲染上尤其常见因为角色Shader往往不是Unity自带的Standard而是团队自研的、带描边、带Ramp贴图、带各种自定义属性的专用Shader。只要Shader丢失、平台不兼容、或者变体没打进去紫红色就会一片片冒出来。这篇文章就聊聊Unity内置渲染管线Built-in Render Pipeline下的常见角色Shader。我会从原理拆解讲到具体实现再带上我这些年实际踩过的坑以及一套能直接用的卡通角色Shader示例。适合两类人看一类是做二次元、风格化、卡通渲染方向的Unity开发另一类是即将面试或者刚接手别人项目、被一堆Pass和Tag搞得一头雾水的朋友。1. 为什么还要聊Built-in角色Shader1.1 Built-in到底是什么为什么还有人用很多人一听“Built-in”就觉得是老古董。确实Unity官方从2019开始就在推URP和HDRP新项目基本都建议直接上URP。但现实是市面上依然有极大量的存量游戏、教程项目、插件资源是基于Built-in渲染管线写的。很多商业游戏项目运行了四五年没精力把整套渲染管线翻到URP很多老资历美术外包团队交付的模型、Shader、材质球也全部基于Built-in。另外还有个使用场景被很多人忽略MR、数字孪生、多媒体互动这类项目往往对画质要求不高但对兼容性要求很高。Built-in在这类项目里的表现异常稳定它不需要你在Render Pipeline Asset里做各种配置开箱即用。这也是为什么直到Unity 6Built-in依然作为官方默认管线之一被保留。说回角色Shader。在Built-in里写角色Shader本质上就是用ShaderLabCg/HLSL去控制网格的每一个顶点和像素。它位于GPU可编程管线的核心位置和URP下的写法不同但很多思路是通的。1.2 角色Shader到底在解决什么问题一个普通场景里的箱子、地面用Standard Shader拖个贴图、调个光滑度就够了。但角色不一样尤其是风格化角色玩家视角几乎一直盯着角色看渲染上的任何粗糙都会被放大。角色Shader要解决的核心问题可以拆成四块漫反射层面让角色受光后呈现出干净、有层次的颜色渐变而不是物理渲染里那种灰蒙蒙的质感。光照情绪通过Ramp贴图或自定义光照模型把光影的“转折”控制在特定位置塑造卡通、唯美、硬朗等不同气质。轮廓与剪影二次元角色如果没有清晰的轮廓线头发的分片、脸部的转折就会糊成一片。性能与体积角色Shader不能太重尤其在移动端一个角色Shader可能同时被几百个NPC使用。这四点听起来多落到Built-in里其实只需要几个固定的Pass和少量的变量声明就能完成。接下来我把每一块拆开讲透。2. 角色Shader的核心技术拆解2.1 漫反射从Lambert到Half Lambert再到Ramp贴图Built-in里角色Shader的第一个技术点就是漫反射模型。默认的Lambert模型直接用法线和光源方向点乘结果大于0就是亮面、小于0就是暗面过渡非常锐利且物理正确。但物理正确不等于好看角色渲染要的是“可控”。于是有人引入Half Lambert也就是把NdotL的值从[-1,1]映射到[0,1]公式是half hLambert dot(worldNormal, lightDir) * 0.5 0.5;这样做的好处是暗部的信息不再直接归零阴影过渡更柔和。很多日系角色Shader都基于这个公式做二次映射。再进一步就是Ramp贴图。Ramp贴图本质上是一张一维的渐变图横轴是光照强度纵轴是最终颜色。在做卡通渲染时我们不需要物理上准确的明暗变化而是希望阴影边界干脆利落地断开。做法就是在采样光照强度后把这个值作为UV的U坐标去查Ramp贴图half rampValue tex2D(_RampTex, half2(hLambert, 0.5)).r;这张Ramp贴图如果做成两阶的“硬切”角色阴影就会呈现赛璐璐风格做成三阶甚至更多阶就能模拟从亮部到暗部的柔和过渡。你甚至可以给脸部单独准备一张特殊的Ramp贴图把阴影控制在发际线和下颌线附近这样无论光源怎么旋转脸都不会出现难看的“脏影”。2.2 高光反射高光形状、Matcap与视线依赖漫反射定的是基调高光定的是材质感。角色头发上那一条凌厉的高光用物理的GGX高光是很难做出来的因为物理高光会受微表面参数影响很难控制成一条“带子”。在Built-in中最常用的高光方案有两种。第一种是Blinn-Phong加指数控制。公式很简单half spec pow(max(0, dot(normal, halfDir)), _SpecularPower);_SpecularPower开大高光就收窄开小高光就散开。虽然Blinn-Phong现在被很多人嫌弃物理不正确但在卡通角色上“不正确”反而成了优点——你只需要调一个参数就能得到理想的高光形状。第二种是Matcap也叫球面环境映射。Matcap方案是把一张预先烘焙好的、表现特定光照效果的法线球贴图在Unity里可以使用Clamp模式也可以用CameraPath等工具烘焙直接用法线的视图空间坐标去采样。它完全不依赖场景里的真实光源方向所以无论场景灯光怎么摆角色身上的高光和阴影始终保持一致这在很多二次元MMO项目里是默认方案。Matcap还有个隐藏好处它天然支持“无光照烘焙”也就是说角色即使放在纯黑场景里也能通过Matcap表现体积感。2.3 轮廓线、面部阴影与边缘光的细节处理角色Shader跟普通Shader最大的区别就是那些“表情级”的细节。轮廓线这块业界最经典的做法是双Pass法。第一个Pass只渲染背面的三角形并把顶点沿法线方向向外推开一点形成一圈黑色外轮廓。第二个Pass正常渲染正面模型。这套做法优点是实现简单、兼容面广缺点是如果模型法线本身有问题或者网格太碎描边会出现断裂或粗细不均。面部阴影更准确地说叫“脸颊SDF阴影”。二次元角色如果需要根据光影自动在面颊位置压出阴影通常会准备一张SDF贴图在片元Shader里根据光照方向判断该用哪一档阴影色。SDF的原理是让阴影边界带一个可控的过渡宽度效果比叠贴图干净得多。边缘光也经常出现在角色Shader里。做法是用视线方向和法线方向的点乘提取出模型侧面的“轮廓区域”再乘上一个颜色。在Built-in里一般写成half rim 1.0 - saturate(dot(worldNormal, viewDir)); rim pow(rim, _RimPower);这个效果能让角色从背景里“弹”出来尤其用在暗色调场景中成本很低但视觉提升明显。2.4 ForwardBase、多光源与阴影的取舍Built-in管线的角色Shader默认跑在Forward渲染路径下。这时候要注意场景里有几个灯光就会有几个额外的Pass参与计算。主平行光走ForwardBase点光源和聚光灯走ForwardAdd。如果角色Shader里没有写ForwardAdd的Pass那么角色就只会接收主平行光其他光源一概不受影响。开发时经常有这种情况场景里放了一盏暖色的补光灯结果角色皮肤完全没反应看起来死气沉沉。新手容易怀疑是灯的问题其实是角色Shader本身只有ForwardBase。除了多光源阴影也是角色Shader绕不开的环节。在Built-in的Forward路径中要接收阴影必须开启#pragma multi_compile_fwdbase然后在结构体里加SHADOW_COORDS在顶点Shader里用TRANSFER_SHADOW在片元里乘上SHADOW_ATTENUATION。这一套宏在URP里已经改头换面但在Built-in里还是老写法。3. 实操从零搭一个可用的卡通角色Shader3.1 完整Shader示例我直接给出一套可在Built-in管线中运行的双Pass卡通角色Shader。这套Shader覆盖了描边、Half Lambert、Ramp贴图、边缘光四个核心效果代码经过简化适合直接拿去改。Shader Custom/ToonCharacter { Properties { _MainTex (Main Texture, 2D) white {} _Color (Tint Color, Color) (1,1,1,1) _RampTex (Ramp Texture, 2D) white {} _OutlineColor (Outline Color, Color) (0.05,0.05,0.05,1) _OutlineWidth (Outline Width, Range(0.001, 0.08)) 0.02 _RimColor (Rim Light Color, Color) (0.8,0.7,0.6,1) _RimPower (Rim Light Power, Range(0.5, 8.0)) 2.5 _SpecularColor (Specular Color, Color) (1,1,1,1) _SpecularPower (Specular Power, Range(1, 128)) 32 } SubShader { Tags { RenderTypeOpaque QueueGeometry } LOD 200 // Pass 1: Outline Pass { Name Outline Cull Front CGPROGRAM #pragma vertex vertOutline #pragma fragment fragOutline #include UnityCG.cginc float _OutlineWidth; fixed4 _OutlineColor; struct a2v { float4 vertex : POSITION; float3 normal : NORMAL; }; struct v2f { float4 pos : SV_POSITION; }; v2f vertOutline(a2v v) { v2f o; float4 pos UnityObjectToClipPos(v.vertex); float3 worldNormal UnityObjectToWorldNormal(v.normal); float3 viewNormal mul((float3x3)UNITY_MATRIX_IT_MV, worldNormal); float2 offset TransformViewToProjection(viewNormal.xy); pos.xy offset * pos.z * _OutlineWidth; o.pos pos; return o; } fixed4 fragOutline(v2f i) : SV_Target { return _OutlineColor; } ENDCG } // Pass 2: ForwardBase Pass { Name ForwardBase Tags { LightModeForwardBase } CGPROGRAM #pragma vertex vert #pragma fragment frag #pragma multi_compile_fwdbase #include UnityCG.cginc #include Lighting.cginc sampler2D _MainTex; float4 _MainTex_ST; sampler2D _RampTex; fixed4 _Color; fixed4 _RimColor; float _RimPower; fixed4 _SpecularColor; float _SpecularPower; struct appdata { float4 vertex : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 pos : SV_POSITION; float3 worldNormal : TEXCOORD1; float3 worldPos : TEXCOORD2; SHADOW_COORDS(3) }; v2f vert(appdata v) { v2f o; o.pos UnityObjectToClipPos(v.vertex); o.uv TRANSFORM_TEX(v.uv, _MainTex); o.worldNormal UnityObjectToWorldNormal(v.normal); o.worldPos mul(unity_ObjectToWorld, v.vertex).xyz; TRANSFER_SHADOW(o) return o; } fixed4 frag(v2f i) : SV_Target { fixed3 worldNormal normalize(i.worldNormal); fixed3 lightDir normalize(_WorldSpaceLightPos0.xyz); fixed3 viewDir normalize(_WorldSpaceCameraPos.xyz - i.worldPos); // Half Lambert - Ramp half hLambert dot(worldNormal, lightDir) * 0.5 0.5; half ramp tex2D(_RampTex, half2(hLambert, 0.5)).r; // Main texture albedo fixed3 albedo tex2D(_MainTex, i.uv).rgb * _Color.rgb; // Diffuse fixed3 diffuse albedo * _LightColor0.rgb * ramp; // Specular fixed3 halfDir normalize(lightDir viewDir); half ndoth max(0, dot(worldNormal, halfDir)); fixed3 specular _SpecularColor.rgb * _LightColor0.rgb * pow(ndoth, _SpecularPower); // Rim light half rim 1.0 - saturate(dot(worldNormal, viewDir)); rim pow(rim, _RimPower); fixed3 rimColor _RimColor.rgb * rim; // Shadow attenuation fixed shadow SHADOW_ATTENUATION(i); fixed3 finalColor diffuse * shadow specular rimColor; return fixed4(finalColor, 1.0); } ENDCG } } FallBack Diffuse }这个Shader已经能在一个默认Unity场景里跑出不错的卡通角色效果前提是做好两件事给_RampTex放一张合适的渐变图例如从亮到暗的三阶渐变模型的法线必须是平滑的否则描边会断。3.2 描边Pass的两种写法对比我刚代码里用的是屏幕空间扩张描边也就是把法线投影到屏幕空间后在xy方向上做偏移。这种方式的好处是描边宽度基本不随相机距离变化近看远看粗细比较一致。代价是屏幕分辨率变化时描边像素宽度会抖动需要结合模型大小做些参数微调比较麻烦。另一种常见写法是物体空间描边直接在顶点Shader里让顶点沿法线方向偏移o.pos UnityObjectToClipPos(float4(v.vertex.xyz v.normal * _OutlineWidth, 1));这种写法实现最简单描边会随着模型放大而放大、缩小而缩小很适合等比例角色。但角色头部如果离相机很近描边看起来会特别粗角色走远描边又会细到看不见。所以用哪种完全取决于项目里角色的镜头尺度。如果角色常年半身特写推荐屏幕空间如果是“小人对对碰”风格物体空间足够。不要迷信哪个更好用项目数据说话。3.3 材质参数与Ramp贴图制作建议材质球上的参数千万不要默认。例如_MainTex很多美术直接拖一张没有勾选sRGB的贴图进来颜色会偏灰。美术规格通常要求主贴图用sRGB、漫反射贴图勾选sRGB而像Ramp贴图、SDF贴图则应该设为Linear避免混合结果出现脏色。Ramp贴图的制作看起来简单但网上能找到的渐变图大多不对。合格的做法是用Shader Forge或者PhotoShop画一张宽度512像素、高度2像素的渐变条横向从左到右依次是阴影色、过渡色、亮部色。通过调整过渡色在横轴的位置就能控制角色阴影“软”还是“硬”。如果你希望更细的阴影过渡可以在过渡区加点噪声抖动模拟手绘笔触的颗粒感。很多人不知道Ramp贴图还可以配合高光一起用。把高光写入Ramp贴图的G通道然后在片元Shader里根据高光强度采样不同颜色就能做出“高光带颜色”“阴影带反光”的效果。这种做法可以大幅减少Shader中的分支和运算移动端很实用。3.4 指令数、合批和移动端注意点没人希望角色Shader把GPU时间吃成“一帧顶一秒”。在Built-in管线里角色Shader有几个埋雷点渐变采样Ramp贴图比计算物理光照便宜得多这正是它适合移动端的原因。分支指令frag里如果写if (_EnableDetail)这种动态分支在移动GPU上会造成严重的分支发散。尽量用Lerp代替。纹理采样数主贴图、Ramp贴图、法线贴图、Matcap一个角色Shader采样四张贴图是常态。在低端机上贴图采样次数会直接影响带宽。我建议在低配Android机型上只保留主贴图和Ramp贴图其余效果通过开关参数控制。描边Pass不可避免会增加一次顶点处理但通常只有几百个顶点影响不大。如果你用UWA或者RenderDoc去测角色Shader重点看两个指标draw call和overdraw。双Pass意味着每个角色至少两个draw call这是卡通渲染的固有成本合批时要额外小心。角色身上如果挂了太多材质比如头发是一套、身体是一套、脸是一套那么三个材质就有六个draw call。尽量把参数整合进同一套Shader通过材质参数切换而不是通过挂多个Material去控制。4. 问题排查与踩坑实录4.1 紫红色材质的快速定位流程开篇提到紫红色材质我把它的问题定位流程整理成一套可复用的排查顺序先在Game视图点击紫红色模型查看右上角Inspector里的Shader名称和状态。如果Shader显示“Unlit/Color”或者干脆是空说明材质球上默认Shader丢失重新指定即可。如果Shader名称正确但模型依然紫红检查Console窗口有没有Shader编译报错。如果只在部分手机上紫红大概率是平台宏或变体没打进去去Player Settings里检查Graphics API并确认Shader Variants是否在构建时被剔除。有个很容易被忽略的点Shader如果用了#pragma multi_compile但没有加入对应变体或者构建时被Variant Stripping误删运行时就找不到合法变体最终表现为紫红色。我见过很多项目最后排查发现是Strip掉的变体比留下的还多。4.2 Shader变体膨胀与关键词管理Built-in里角色Shader经常混用多套宏比如_MAIN_LIGHT_SHADOWS、_ADDITIONAL_LIGHTS、_RIM_ENABLE、_SPECULAR_ENABLE。每开启一个multi_compile关键词生成的变体数量会指数膨胀。一个只写了三四个multi_compile开关的Shader构建后可能悄悄生成上百个变体包体和个人开发机的编译压力都吃不消。我自己的习惯是能用#pragma shader_feature就不用#pragma multi_compile。区别在于shader_feature的变体在没有材质使用时会被自动剔除而multi_compile的所有变体都会被保留。如果你的效果开关是固定给某几个角色用的用shader_feature更省构建空间。同时建议在Project Settings里开启“Variant Strip”并手动配置变体收集器。这一步能显著减小AB包体积尤其角色Shader数量多的时候。4.3 多光源、阴影与雾效的影响在Built-in前向渲染下场景每多一盏非平行光角色Shader如果没有ForwardAdd Pass角色就“不吃”这个光。如果你希望角色只在主光下保持干净效果就不要在场景里加第二盏动态灯或者给额外灯光配上对应的ForwardAdd Pass。如果角色阴影没有正确显示先检查Receive Shadows是否打开再看Shader里SHADOW_ATTENUATION的位置。阴影衰减的采样值应该在光照计算之前获取乘到diffuse上的位置不要写在高光和边缘光后面否则角色阴影会莫名其妙把高光也压掉。雾效也是一个经典坑。Built-in的雾效在Shader里是自动启用的如果你在场景里开启了Fog但自定义角色Shader没有写雾效相关代码角色就会和场景雾效脱节出现模型边缘硬切的现象。解决办法是在片元末尾加UNITY_APPLY_FOG(i.fogCoord, finalColor);同时记得让Unity的渲染设置使用线性雾时两边颜色一致。4.4 常见问题速查表现象可能原因排查建议材质紫红色Shader丢失或变体被剔除检查Inspector和Console确认变体状态描边断裂模型法线非平滑导入时法线模式改为Calculate并焊接顶点描边过粗/过细使用了错误描边方案近景用屏幕空间扩张远镜用物体空间角色不吃场景额外光源缺少ForwardAdd Pass要么补ForwardAdd要么场景只用主平行光阴影脏或偏白光照衰减位置写错把SHADOW_ATTENUATION乘在diffuse阶段高光太夸张且没有层次指数设置过大或缺少高光贴图配合高光贴图的A通道降低强度脸部有脏阴影脸部Ramp贴图控制不当单独给脸部一张三阶Ramp贴图阴影值下移移动端卡顿发热采样过多或动态分支太多用Lerp替代if减少贴图采样数5. 从Built-in迁移到URP时的注意事项做技术栈迁移之前先明确一点URP不是Built-in的“上位替代”它只是另一种渲染架构。如果你把Built-in的CGPROGRAM直接塞进URP的Shader里大概率会遇到着色器编译失败。原因在于URP使用HLSL通过#include Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl引入自己的库很多UnityCG.cginc中的变量和函数在URP里不存在。角色Shader从Built-in往URP迁最容易踩的坑有三个一是_WorldSpaceLightPos0。这个老变量在URP里被_MainLightPosition替代取值方式完全不同二是UNITY_MATRIX_MVP等内置矩阵被SRP Batcher里面的内置常量替换三是Pass TagURP需要在SubShader里声明LightMode为UniversalForward而不是ForwardBase。举个例子同样取主光方向// Built-in老写法 fixed3 lightDir normalize(_WorldSpaceLightPos0.xyz); // URP新写法 #if defined(_MAIN_LIGHT_SHADOWS) real3 lightDir GetMainLight(TransformWorldToShadowCoord(i.positionWS)).direction; #else real3 lightDir GetMainLight().direction; #endif这个差异几乎决定了所有老Shader迁移时的改造成本。另一个迁移问题是SRP Batcher。URP非常适合使用SRP Batcher它要求Shader的CBUFFER布局能匹配。如果你的角色Shader还是老的Properties风格触发了Unknown类型的CBUFFERSRP Batcher就直接绕过你。为了让效果正常且不损失合批内部变量要包在CBUFFER_START(UnityPerMaterial)里。还有光照探针和反射探针的接入方式也不同。Built-in里是UNITY_LIGHTMAP_SHADOW_ENCODING那一套URP里换成了网格采样的宏调用方式差别很大。好在Unity提供了一个叫“Render Pipeline Converter”的自动化转换工具但转换出的结果我只建议作为基底最后效果一定要美术逐材质量查。所以如果你考虑把角色Shader从Built-in迁到URP我建议业务侧把这项工作看作一次“重写”而不是“替换”。在动手前先想想项目是不是真的需要URP。不需要的话别折腾。最后再分享一个小技巧如果你决定继续留在Built-in有一个我特别推荐的小插件Shader Variant Stripper。它会扫描场景中实际使用的材质球反推出需要的Shader变体直接砍掉所有未使用的组合。在很多老项目里这一步能把AB包体缩小5%到15%几乎零成本。另外在项目里维护一套“角色Shader参数规范文档”也很值钱。把主贴图、Ramp贴图、描边宽度、边缘光颜色这些参数的合理范围写清楚美术能自己调出七八成满意的效果技术就不用天天被喊去救火。角色Shader在Built-in里做了五六年我的体会是它不追新但沉淀了大量能复用的思路。你在这里学到的Half Lambert、Ramp控制、双Pass描边、Matcap换到URP里稍微改改还是同一套底层逻辑。基础扎实了管线怎么换都不慌。