案例 14.10完整的 Toon Shader高阶
目标:把本章的所有部件组装成一个可直接用于项目的卡通 Shader:两层阴影色或 Ramp 贴图、卡通化的接收阴影、风格化高光、受光侧边缘光、卡通化的附加光、平坦环境光,以及支持平滑法线的等宽描边。
附加光
附加光(点光、聚光)在卡通画面里通常用作氛围光:角色走进一盏红色灯笼的范围,身上出现一块边界清晰的红色光斑。做法与主光相同,只是把“N·L × 距离衰减 × 阴影”整体做一次色阶。距离衰减先乘 4 再 saturate,是为了让光斑在衰减到很弱之前就达到阈值,形成一块完整的色斑而不是一个模糊的光圈:
// 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑 half3 ToonAdditional(half3 albedo, half3 N, Light light) { half atten = light.distanceAttenuation * light.shadowAttenuation; half x = saturate(dot(N, light.direction)) * saturate(atten * 4.0); return albedo * light.color * ToonBand(x, 0.3, _Softness + 0.02) * _AddLightStrength; }
// 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑 vec3 ToonAdditional(vec3 albedo, vec3 N, Light light) { float atten = light.distanceAttenuation * light.shadowAttenuation; float x = saturate(dot(N, light.direction)) * saturate(atten * 4.0); return albedo * light.color * ToonBand(x, 0.3, _Softness + 0.02) * _AddLightStrength; }
光源循环与第 12 章相同,同样需要处理 Forward+ 的 inputData 和额外方向光。
片元函数
half4 frag(ToonVaryings i) : SV_Target { UNITY_SETUP_INSTANCE_ID(i); half3 N = normalize(i.normalWS); half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, TRANSFORM_TEX(i.uv, _BaseMap)).rgb * _BaseColor.rgb; // ---------- 主光 + 阴影 ---------- // 接收端法线偏移:沿法线挪一点再查阴影图,消除明暗交界处的自阴影锯齿(shadow acne) float4 shadowCoord = TransformWorldToShadowCoord(i.positionWS + N * _ShadowNormalOffset); Light mainLight = GetMainLight(shadowCoord, i.positionWS, half4(1, 1, 1, 1)); half NoL = dot(N, mainLight.direction); half3 color = ToonDiffuse(albedo, NoL * 0.5 + 0.5, mainLight.shadowAttenuation) * mainLight.color; // 高光与边缘光只出现在“真正被照亮”的区域 half litMask = ToonBand((NoL * 0.5 + 0.5) * lerp(1.0, ToonBand(mainLight.shadowAttenuation, 0.5, _ShadowSoftness), _ReceiveShadow), _Threshold, _Softness); half3 H = normalize(mainLight.direction + V); half spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005) * litMask; color += spec * _SpecStrength * _SpecColor.rgb * mainLight.color; half rim = FresnelRim(N, V, _RimWidth, 0.02) * litMask; color = lerp(color, _RimColor.rgb * albedo * mainLight.color * 1.6, rim * _RimStrength); // ---------- 附加光 ---------- #if defined(_ADDITIONAL_LIGHTS) InputData inputData = (InputData)0; // Forward+ 的光源循环宏需要它 inputData.positionWS = i.positionWS; inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS); #if USE_FORWARD_PLUS for (uint li = 0; li < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); li++) color += ToonAdditional(albedo, N, GetAdditionalLight(li, i.positionWS, half4(1, 1, 1, 1))); #endif uint lightCount = GetAdditionalLightsCount(); LIGHT_LOOP_BEGIN(lightCount) color += ToonAdditional(albedo, N, GetAdditionalLight(lightIndex, i.positionWS, half4(1, 1, 1, 1))); LIGHT_LOOP_END #endif // ---------- 环境光:只取球谐常数项,整块抬亮暗部 ---------- color += SampleSH(half3(0, 0, 0)) * albedo * _AmbientStrength; return half4(MixFog(color, i.fogFactor), 1); }
vec4 frag() { vec3 N = normalize(normalWS); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); vec3 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)).rgb * _BaseColor.rgb; // ---------- 主光 + 阴影 ---------- // 接收端法线偏移:沿法线挪一点再查阴影图,消除明暗交界处的自阴影锯齿(shadow acne) vec4 shadowCoord = TransformWorldToShadowCoord(positionWS + N * _ShadowNormalOffset); Light mainLight = GetMainLight(shadowCoord, positionWS, vec4(1.0)); float NoL = dot(N, mainLight.direction); vec3 color = ToonDiffuse(albedo, NoL * 0.5 + 0.5, mainLight.shadowAttenuation) * mainLight.color; // 高光与边缘光只出现在“真正被照亮”的区域 float litMask = ToonBand((NoL * 0.5 + 0.5) * mix(1.0, ToonBand(mainLight.shadowAttenuation, 0.5, _ShadowSoftness), _ReceiveShadow), _Threshold, _Softness); vec3 H = normalize(mainLight.direction + V); float spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005) * litMask; color += spec * _SpecStrength * _SpecColor.rgb * mainLight.color; float rim = FresnelRim(N, V, _RimWidth, 0.02) * litMask; color = mix(color, _RimColor.rgb * albedo * mainLight.color * 1.6, rim * _RimStrength); // ---------- 附加光 ---------- #if defined(_ADDITIONAL_LIGHTS) // Forward / Forward+ 的光源循环在网页版里都是普通 for 循环 int lightCount = GetAdditionalLightsCount(); for (int lightIndex = 0; lightIndex < lightCount; lightIndex++) color += ToonAdditional(albedo, N, GetAdditionalLight(lightIndex, positionWS, vec4(1.0))); #endif // ---------- 环境光:只取球谐常数项,整块抬亮暗部 ---------- color += SampleSH(vec3(0.0)) * albedo * _AmbientStrength; return vec4(MixFog(color, fogFactor), 1.0); }
几个设计上的选择:
- 高光和边缘光都乘了
litMask:只在“真正被照亮”(既朝光、又不在投影里)的区域出现。站在阴影里的角色身上冒出高光和边缘光,是卡通渲染里很常见的穿帮。 - 环境光只取球谐的常数项
SampleSH(0),也就是环境的平均颜色。完整的球谐会随法线变化,在模型上产生柔和的明暗起伏,把好不容易做出来的色块又“抹圆”了。 - 边缘光的颜色乘了固有色,而不是纯白:白色的边缘光在深色物体上像发光的霓虹,固有色调制后更像被光照亮的轮廓。
描边 Pass
描边部分与案例 14.5 相同,抽成了 ToonOutlinePass.hlsl,供 ToonLit、ToonHair、ToonFace 共用;外扩法线的来源用 KeywordEnum 在原始法线、切线(物体空间平滑法线)、顶点色(切线空间平滑法线)三者间切换。线宽为 0 时片元直接 clip 掉,同一个材质就能关掉描边。
// 描边 Pass 的通用实现(ToonLit / ToonHair / ToonFace 共用)。 // 使用方需要在 UnityPerMaterial 里声明 _OutlineColor、_OutlineWidth, // 并用 shader_feature_local_vertex 声明 _OUTLINENORMAL_MESH/_TANGENT/_VERTEXCOLOR 之一(不声明时用原始法线)。 #ifndef COOKBOOK_CH14_TOON_OUTLINE_PASS_INCLUDED #define COOKBOOK_CH14_TOON_OUTLINE_PASS_INCLUDED #include "Assets/Cookbook/Ch14/ToonCommon.hlsl" struct OutlineVaryings { float4 positionCS : SV_POSITION; float fogFactor : TEXCOORD0; UNITY_VERTEX_INPUT_INSTANCE_ID }; OutlineVaryings OutlineVert(ToonAttributes v) { OutlineVaryings o; UNITY_SETUP_INSTANCE_ID(v); UNITY_TRANSFER_INSTANCE_ID(v, o); float3 n = v.normalOS; #if defined(_OUTLINENORMAL_TANGENT) n = v.tangentOS.xyz; // 物体空间平滑法线 #elif defined(_OUTLINENORMAL_VERTEXCOLOR) float3 nTS = v.color.rgb * 2.0 - 1.0; // 切线空间平滑法线 float3 bOS = cross(v.normalOS, v.tangentOS.xyz) * v.tangentOS.w; n = normalize(nTS.x * v.tangentOS.xyz + nTS.y * bOS + nTS.z * v.normalOS); #endif o.positionCS = OutlinePositionCS(v.positionOS.xyz, n, _OutlineWidth); o.fogFactor = ComputeFogFactor(o.positionCS.z); return o; } half4 OutlineFrag(OutlineVaryings i) : SV_Target { clip(_OutlineWidth - 0.01); // 线宽为 0 时不输出(方便在同一个材质上关掉描边) return half4(MixFog(_OutlineColor.rgb, i.fogFactor), 1); } #endif
// 描边 Pass 的通用实现(ToonLit / ToonHair / ToonFace 共用)。(GLSL 版,对应 Ch14/ToonOutlinePass.hlsl) // 使用方需要声明 uniform _OutlineColor、_OutlineWidth, // 并用 shader_feature_local_vertex 声明 _OUTLINENORMAL_MESH/_TANGENT/_VERTEXCOLOR 之一(不声明时用原始法线)。 #ifndef COOKBOOK_CH14_TOON_OUTLINE_PASS_INCLUDED #define COOKBOOK_CH14_TOON_OUTLINE_PASS_INCLUDED #include "Ch14/ToonCommon.glsl" VARYING float fogFactor; #ifdef VERTEX void OutlineVert() { vec3 n = normalOS; #if defined(_OUTLINENORMAL_TANGENT) n = tangentOS.xyz; // 物体空间平滑法线 #elif defined(_OUTLINENORMAL_VERTEXCOLOR) vec3 nTS = color.rgb * 2.0 - 1.0; // 切线空间平滑法线 vec3 bOS = cross(normalOS, tangentOS.xyz) * tangentOS.w; n = normalize(nTS.x * tangentOS.xyz + nTS.y * bOS + nTS.z * normalOS); #endif gl_Position = OutlinePositionCS(positionOS.xyz, n, _OutlineWidth); fogFactor = ComputeFogFactor(gl_Position.z); } void main() { OutlineVert(); } #endif #ifdef FRAGMENT vec4 OutlineFrag() { if (_OutlineWidth - 0.01 < 0.0) discard; // 线宽为 0 时不输出(方便在同一个材质上关掉描边) return vec4(MixFog(_OutlineColor.rgb, fogFactor), 1.0); } void main() { fragColor = OutlineFrag(); } #endif #endif
完整代码
// 本章的完整卡通 Shader:色阶(两层阴影色 / 可选 Ramp 贴图)+ 卡通化接收阴影 + 风格化高光 + 边缘光 // + 附加光 + 平坦环境光 + 背面外扩描边(裁剪空间等宽,可选平滑法线来源)。 Shader "Cookbook/Ch14/ToonLit" { Properties { [MainTexture] _BaseMap ("Base Map", 2D) = "white" {} [MainColor] _BaseColor ("Base Color", Color) = (1, 1, 1, 1) [Header(Shading)] _ShadeColor ("1st Shade Color", Color) = (0.7, 0.6, 0.75, 1) _ShadeColor2 ("2nd Shade Color", Color) = (0.45, 0.38, 0.55, 1) _Threshold ("1st Shade Threshold", Range(0, 1)) = 0.5 _Shade2Threshold ("2nd Shade Threshold", Range(0, 1)) = 0.2 _Shade2Strength ("2nd Shade Strength", Range(0, 1)) = 1 _Softness ("Edge Softness", Range(0, 0.3)) = 0.02 [Toggle(_RAMPMAP)] _UseRamp ("Use Ramp Map (overrides shade colors)", Float) = 0 [NoScaleOffset] _RampTex ("Ramp Map", 2D) = "white" {} [Header(Shadows)] _ReceiveShadow ("Receive Shadow", Range(0, 1)) = 1 _ShadowSoftness ("Shadow Edge Softness", Range(0, 0.5)) = 0.05 _ShadowNormalOffset ("Shadow Receiver Normal Offset (m)", Range(0, 0.1)) = 0.03 [Header(Specular)] [HDR] _SpecColor ("Specular Color", Color) = (1, 1, 1, 1) _SpecThreshold ("Specular Threshold", Range(0.5, 1)) = 0.97 _SpecStrength ("Specular Strength", Range(0, 1)) = 1 [Header(Rim)] [HDR] _RimColor ("Rim Color", Color) = (1, 0.95, 0.9, 1) _RimWidth ("Rim Width", Range(0, 1)) = 0.25 _RimStrength ("Rim Strength", Range(0, 1)) = 0.6 [Header(Ambient and Extra Lights)] _AmbientStrength ("Ambient Strength", Range(0, 1)) = 0.35 _AddLightStrength ("Additional Lights Strength", Range(0, 2)) = 1 [Header(Outline)] _OutlineColor ("Outline Color", Color) = (0.15, 0.1, 0.12, 1) _OutlineWidth ("Outline Width (px)", Range(0, 10)) = 2.5 [KeywordEnum(Mesh, Tangent, VertexColor)] _OutlineNormal ("Outline Normal Source", Float) = 0 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl" CBUFFER_START(UnityPerMaterial) float4 _BaseMap_ST; half4 _BaseColor, _ShadeColor, _ShadeColor2, _SpecColor, _RimColor, _OutlineColor; half _Threshold, _Shade2Threshold, _Shade2Strength, _Softness; half _ReceiveShadow, _ShadowSoftness, _ShadowNormalOffset; half _SpecThreshold, _SpecStrength, _RimWidth, _RimStrength; half _AmbientStrength, _AddLightStrength, _OutlineWidth; CBUFFER_END TEXTURE2D(_BaseMap); SAMPLER(sampler_BaseMap); TEXTURE2D(_RampTex); SAMPLER(sampler_RampTex); ENDHLSL Pass { Name "ToonForward" Tags { "LightMode"="UniversalForwardOnly" } HLSLPROGRAM #pragma target 3.5 #pragma vertex ToonVert #pragma fragment frag #pragma shader_feature_local_fragment _ _RAMPMAP #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile _ _ADDITIONAL_LIGHTS #pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #pragma multi_compile _ _FORWARD_PLUS #pragma multi_compile_fog #pragma multi_compile_instancing #include "Assets/Cookbook/Ch14/ToonCommon.hlsl" // 主光的色阶:输入是“半 Lambert × 卡通化后的阴影”,输出是最终的漫反射颜色 half3 ToonDiffuse(half3 albedo, half halfLambert, half shadow) { // 接收阴影卡通化:把 PCF 的软阴影值也切成硬边,再“乘进”明暗,而不是在最后乘一次 half toonShadow = lerp(1.0, ToonBand(shadow, 0.5, _ShadowSoftness), _ReceiveShadow); // 投影区域只压到“第一层阴影”的位置,而不是压到 0(那会掉进第二层阴影,比背光面还暗) half shadowedX = min(halfLambert, (_Threshold + _Shade2Threshold) * 0.5); half x = lerp(shadowedX, halfLambert, toonShadow); #if defined(_RAMPMAP) return albedo * SAMPLE_TEXTURE2D(_RampTex, sampler_RampTex, float2(clamp(x, 0.01, 0.99), 0.5)).rgb; #else half band1 = ToonBand(x, _Threshold, _Softness); half band2 = ToonBand(x, _Shade2Threshold, _Softness); half3 shade = lerp(albedo * _ShadeColor2.rgb, albedo * _ShadeColor.rgb, lerp(1.0, band2, _Shade2Strength)); return lerp(shade, albedo, band1); #endif } // 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑 half3 ToonAdditional(half3 albedo, half3 N, Light light) { half atten = light.distanceAttenuation * light.shadowAttenuation; half x = saturate(dot(N, light.direction)) * saturate(atten * 4.0); return albedo * light.color * ToonBand(x, 0.3, _Softness + 0.02) * _AddLightStrength; } half4 frag(ToonVaryings i) : SV_Target { UNITY_SETUP_INSTANCE_ID(i); half3 N = normalize(i.normalWS); half3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, TRANSFORM_TEX(i.uv, _BaseMap)).rgb * _BaseColor.rgb; // ---------- 主光 + 阴影 ---------- // 接收端法线偏移:沿法线挪一点再查阴影图,消除明暗交界处的自阴影锯齿(shadow acne) float4 shadowCoord = TransformWorldToShadowCoord(i.positionWS + N * _ShadowNormalOffset); Light mainLight = GetMainLight(shadowCoord, i.positionWS, half4(1, 1, 1, 1)); half NoL = dot(N, mainLight.direction); half3 color = ToonDiffuse(albedo, NoL * 0.5 + 0.5, mainLight.shadowAttenuation) * mainLight.color; // 高光与边缘光只出现在“真正被照亮”的区域 half litMask = ToonBand((NoL * 0.5 + 0.5) * lerp(1.0, ToonBand(mainLight.shadowAttenuation, 0.5, _ShadowSoftness), _ReceiveShadow), _Threshold, _Softness); half3 H = normalize(mainLight.direction + V); half spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005) * litMask; color += spec * _SpecStrength * _SpecColor.rgb * mainLight.color; half rim = FresnelRim(N, V, _RimWidth, 0.02) * litMask; color = lerp(color, _RimColor.rgb * albedo * mainLight.color * 1.6, rim * _RimStrength); // ---------- 附加光 ---------- #if defined(_ADDITIONAL_LIGHTS) InputData inputData = (InputData)0; // Forward+ 的光源循环宏需要它 inputData.positionWS = i.positionWS; inputData.normalizedScreenSpaceUV = GetNormalizedScreenSpaceUV(i.positionCS); #if USE_FORWARD_PLUS for (uint li = 0; li < min(URP_FP_DIRECTIONAL_LIGHTS_COUNT, MAX_VISIBLE_LIGHTS); li++) color += ToonAdditional(albedo, N, GetAdditionalLight(li, i.positionWS, half4(1, 1, 1, 1))); #endif uint lightCount = GetAdditionalLightsCount(); LIGHT_LOOP_BEGIN(lightCount) color += ToonAdditional(albedo, N, GetAdditionalLight(lightIndex, i.positionWS, half4(1, 1, 1, 1))); LIGHT_LOOP_END #endif // ---------- 环境光:只取球谐常数项,整块抬亮暗部 ---------- color += SampleSH(half3(0, 0, 0)) * albedo * _AmbientStrength; return half4(MixFog(color, i.fogFactor), 1); } ENDHLSL } Pass { Name "Outline" Tags { "LightMode"="SRPDefaultUnlit" } Cull Front ZWrite On HLSLPROGRAM #pragma vertex OutlineVert #pragma fragment OutlineFrag #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR #pragma multi_compile_fog #pragma multi_compile_instancing #include "Assets/Cookbook/Ch14/ToonOutlinePass.hlsl" ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 Cull Back HLSLPROGRAM #pragma vertex ShadowPassVertex #pragma fragment ShadowPassFragment #pragma multi_compile_instancing #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW // URP 自带的阴影 Pass 只在 _ALPHATEST_ON 时才读 _BaseMap,其余部分与材质无关,直接复用 #include "Packages/com.unity.render-pipelines.universal/Shaders/ShadowCasterPass.hlsl" ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R Cull Back HLSLPROGRAM #pragma vertex DepthOnlyVertex #pragma fragment DepthOnlyFragment #pragma multi_compile_instancing #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthOnlyPass.hlsl" ENDHLSL } Pass { Name "DepthNormals" Tags { "LightMode"="DepthNormals" } ZWrite On Cull Back HLSLPROGRAM #pragma vertex DepthNormalsVertex #pragma fragment DepthNormalsFragment #pragma multi_compile_instancing #include "Packages/com.unity.render-pipelines.universal/Shaders/DepthNormalsPass.hlsl" ENDHLSL } } }
// 本章的完整卡通 Shader:色阶(两层阴影色 / 可选 Ramp 贴图)+ 卡通化接收阴影 + 风格化高光 + 边缘光 // + 附加光 + 平坦环境光 + 背面外扩描边(裁剪空间等宽,可选平滑法线来源)。(GLSL 版,对应 Ch14/ToonLit.shader) Shader "Cookbook/Ch14/ToonLit" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE uniform vec4 _BaseMap_ST; uniform vec4 _BaseColor, _ShadeColor, _ShadeColor2, _SpecColor, _RimColor, _OutlineColor; uniform float _Threshold, _Shade2Threshold, _Shade2Strength, _Softness; uniform float _ReceiveShadow, _ShadowSoftness, _ShadowNormalOffset; uniform float _SpecThreshold, _SpecStrength, _RimWidth, _RimStrength; uniform float _AmbientStrength, _AddLightStrength, _OutlineWidth; uniform sampler2D _BaseMap; uniform sampler2D _RampTex; ENDGLSL Pass { Name "ToonForward" Tags { "LightMode"="UniversalForwardOnly" } GLSLPROGRAM #pragma shader_feature_local_fragment _ _RAMPMAP #define TOON_VARYINGS #include "Ch14/ToonCommon.glsl" #ifdef VERTEX void main() { ToonVert(); } #endif #ifdef FRAGMENT // 主光的色阶:输入是“半 Lambert × 卡通化后的阴影”,输出是最终的漫反射颜色 vec3 ToonDiffuse(vec3 albedo, float halfLambert, float shadow) { // 接收阴影卡通化:把 PCF 的软阴影值也切成硬边,再“乘进”明暗,而不是在最后乘一次 float toonShadow = mix(1.0, ToonBand(shadow, 0.5, _ShadowSoftness), _ReceiveShadow); // 投影区域只压到“第一层阴影”的位置,而不是压到 0(那会掉进第二层阴影,比背光面还暗) float shadowedX = min(halfLambert, (_Threshold + _Shade2Threshold) * 0.5); float x = mix(shadowedX, halfLambert, toonShadow); #if defined(_RAMPMAP) return albedo * texture(_RampTex, vec2(clamp(x, 0.01, 0.99), 0.5)).rgb; #else float band1 = ToonBand(x, _Threshold, _Softness); float band2 = ToonBand(x, _Shade2Threshold, _Softness); vec3 shade = mix(albedo * _ShadeColor2.rgb, albedo * _ShadeColor.rgb, mix(1.0, band2, _Shade2Strength)); return mix(shade, albedo, band1); #endif } // 附加光:同样做一次硬边,得到一圈有清晰边界的彩色光斑 vec3 ToonAdditional(vec3 albedo, vec3 N, Light light) { float atten = light.distanceAttenuation * light.shadowAttenuation; float x = saturate(dot(N, light.direction)) * saturate(atten * 4.0); return albedo * light.color * ToonBand(x, 0.3, _Softness + 0.02) * _AddLightStrength; } vec4 frag() { vec3 N = normalize(normalWS); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); vec3 albedo = texture(_BaseMap, TransformTex(uv, _BaseMap_ST)).rgb * _BaseColor.rgb; // ---------- 主光 + 阴影 ---------- // 接收端法线偏移:沿法线挪一点再查阴影图,消除明暗交界处的自阴影锯齿(shadow acne) vec4 shadowCoord = TransformWorldToShadowCoord(positionWS + N * _ShadowNormalOffset); Light mainLight = GetMainLight(shadowCoord, positionWS, vec4(1.0)); float NoL = dot(N, mainLight.direction); vec3 color = ToonDiffuse(albedo, NoL * 0.5 + 0.5, mainLight.shadowAttenuation) * mainLight.color; // 高光与边缘光只出现在“真正被照亮”的区域 float litMask = ToonBand((NoL * 0.5 + 0.5) * mix(1.0, ToonBand(mainLight.shadowAttenuation, 0.5, _ShadowSoftness), _ReceiveShadow), _Threshold, _Softness); vec3 H = normalize(mainLight.direction + V); float spec = ToonBand(saturate(dot(N, H)), _SpecThreshold, 0.005) * litMask; color += spec * _SpecStrength * _SpecColor.rgb * mainLight.color; float rim = FresnelRim(N, V, _RimWidth, 0.02) * litMask; color = mix(color, _RimColor.rgb * albedo * mainLight.color * 1.6, rim * _RimStrength); // ---------- 附加光 ---------- #if defined(_ADDITIONAL_LIGHTS) // Forward / Forward+ 的光源循环在网页版里都是普通 for 循环 int lightCount = GetAdditionalLightsCount(); for (int lightIndex = 0; lightIndex < lightCount; lightIndex++) color += ToonAdditional(albedo, N, GetAdditionalLight(lightIndex, positionWS, vec4(1.0))); #endif // ---------- 环境光:只取球谐常数项,整块抬亮暗部 ---------- color += SampleSH(vec3(0.0)) * albedo * _AmbientStrength; return vec4(MixFog(color, fogFactor), 1.0); } void main() { fragColor = frag(); } #endif ENDGLSL } Pass { Name "Outline" Tags { "LightMode"="SRPDefaultUnlit" } Cull Front ZWrite On GLSLPROGRAM #pragma shader_feature_local_vertex _OUTLINENORMAL_MESH _OUTLINENORMAL_TANGENT _OUTLINENORMAL_VERTEXCOLOR #include "Ch14/ToonOutlinePass.glsl" ENDGLSL } // URP 自带的阴影/深度 Pass 与材质无关,网页版用公共的同名 Pass UsePass "Cookbook/Common/SHADOWCASTER" UsePass "Cookbook/Common/DEPTHONLY" UsePass "Cookbook/Common/DEPTHNORMALS" } }


从分解图可以看出每个部件的贡献:色阶决定了画面的基调;阴影让物体“落地”,并建立起空间关系;高光和边缘光提供材质感和立体感;描边则一下子把画面拉到了“动画”的感觉上——它往往是对卡通感贡献最大的单一部件。
移动端与性能
这个 Toon Shader 的片元开销比 URP Lit 低(没有 GGX、没有环境反射探针采样),主要的额外开销来自描边 Pass:每个描边物体多一次 Draw Call 和一遍顶点处理,背面的片元绝大多数会被深度测试剔除,填充率开销不大。角色多的场景可以考虑:远处的角色关掉描边(LOD);用 shader_feature_local 把高光、边缘光等做成可剔除的变体;把 Ramp 贴图、阈值图做成很小的单通道贴图。具体收益请在目标设备上用 Frame Debugger 与 Profiler 确认。
✓ 推荐
阴影色、Ramp 交给美术定义;投影折进色阶;高光、边缘光只在受光区出现;描边用平滑法线 + 裁剪空间等宽;脸部用 SDF 阴影。✗ 避免
暗部 = 固有色 × 系数;最后再乘一次软阴影;纯白的菲涅尔边缘光;用原始硬法线外扩描边;用法线色阶算脸部阴影。"LightMode"="Always",多 Pass Shader 会被依次绘制。URP 只认识特定的 LightMode:没有 LightMode 的 Pass 被当作 SRPDefaultUnlit,而 Built-in 的 ForwardBase、ForwardAdd 在 URP 里根本不会被绘制。迁移老的卡通 Shader 时,最常见的现象就是“描边还在,但主体着色不见了”(主 Pass 还写着 ForwardBase)。