URP 渲染实战 / 第 15 章 更多风格化(NPR) / 案例 15.1
案例 15.1Gooch 冷暖着色入门
目标:在技术插图风格里,让背光面依然清楚可读——不是变黑,而是变“冷”。
原理:1998 年 Gooch 等人在研究技术插图时发现,插画师很少用纯黑表现背光面,因为黑色会和轮廓线、阴影混在一起,形体反而看不清。他们的做法是用色相变化表达朝向:受光面偏暖(黄、橙),背光面偏冷(蓝、紫)。人眼对冷暖的感知天然带有空间感——暖色前进、冷色后退——所以只靠色相也能读出体积。
模型本身非常简单。先构造冷暖两个端点颜色:纯冷色 b(蓝)加上一部分物体本色 kd,纯暖色 y(黄)加上一部分本色:
再用重映射到 0..1 的 N·L 在两者之间插值:
这里的 (1 + N·L)/2 和 Half-Lambert 长得一样,但目的不同:Half-Lambert 是为了让暗部不死黑,Gooch 是为了让整个 −1..1 的范围都有意义——完全背光(N·L = −1)的点取纯冷色,完全受光的点取纯暖色,中间是一条连续的色相过渡。这也是为什么 GetNprLight 不截断 N·L:一旦 saturate,背光面全部变成 0,冷色端就丢了一半信息。
完整代码:
Assets/Cookbook/Ch15/Gooch.shader
// Gooch 冷暖着色:用“冷→暖”的色相变化代替“暗→亮”的明度变化来表达朝向, // 暗部不再是死黑,形体在技术插图里依旧清楚。附带白色高光与背面外扩描边。 Shader "Cookbook/Ch15/Gooch" { Properties { _BaseColor ("Base Color (k_d)", Color) = (0.8, 0.25, 0.2, 1) _CoolColor ("Cool Color (b)", Color) = (0.0, 0.05, 0.45, 1) _WarmColor ("Warm Color (y)", Color) = (0.45, 0.4, 0.0, 1) _Alpha ("Alpha (cool mix)", Range(0, 1)) = 0.25 _Beta ("Beta (warm mix)", Range(0, 1)) = 0.55 _SpecPower ("Specular Power", Range(4, 256)) = 48 _SpecStrength ("Specular Strength", Range(0, 1)) = 0.8 _ShadowCool ("Shadow pushes to cool", Range(0, 1)) = 0.6 _OutlineWidth ("Outline Width", Range(0, 0.05)) = 0.012 _OutlineColor ("Outline Color", Color) = (0.05, 0.05, 0.08, 1) } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } HLSLINCLUDE #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" CBUFFER_START(UnityPerMaterial) half4 _BaseColor, _CoolColor, _WarmColor, _OutlineColor; half _Alpha, _Beta, _SpecPower, _SpecStrength, _ShadowCool; float _OutlineWidth; CBUFFER_END ENDHLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } HLSLPROGRAM #pragma vertex NprVert #pragma fragment frag #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN #pragma multi_compile_fragment _ _SHADOWS_SOFT _SHADOWS_SOFT_LOW _SHADOWS_SOFT_MEDIUM _SHADOWS_SOFT_HIGH #pragma multi_compile_fog #include "Assets/Cookbook/Ch15/NPRCommon.hlsl" half4 frag(NprVaryings i) : SV_Target { float3 N = normalize(i.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); NprLight L = GetNprLight(i.positionWS, N); // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色 half3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb; half3 kWarm = _WarmColor.rgb + _Beta * _BaseColor.rgb; // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷” half t = (1.0h + L.ndotl) * 0.5h; // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充) t *= lerp(1.0h - _ShadowCool, 1.0h, L.shadow); half3 col = lerp(kCool, kWarm, t); // 3) 白色高光:Blinn-Phong,只在受光面出现 float3 H = normalize(L.direction + V); half spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0, L.ndotl); col += spec; col = MixFog(col, i.fogFactor); return half4(col, 1); } ENDHLSL } // 背面外扩描边(第 14 章讲过的方法,这里只作为技术插图的黑色轮廓线) Pass { Name "Outline" Tags { "LightMode"="SRPDefaultUnlit" } Cull Front HLSLPROGRAM #pragma vertex vert #pragma fragment frag struct A { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; float4 vert(A v) : SV_POSITION { float4 positionCS = TransformObjectToHClip(v.positionOS.xyz); float3 normalCS = mul((float3x3)UNITY_MATRIX_VP, TransformObjectToWorldNormal(v.normalOS)); // 在裁剪空间沿法线外扩,乘 w 抵消透视除法,使线宽与距离无关 float2 offset = normalize(normalCS.xy) * _OutlineWidth * positionCS.w; offset.x *= _ScreenParams.y / _ScreenParams.x; // 纠正宽高比 positionCS.xy += offset; return positionCS; } half4 frag() : SV_Target { return _OutlineColor; } ENDHLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 HLSLPROGRAM #pragma vertex NprShadowVert #pragma fragment NprNullFrag #pragma multi_compile_vertex _ _CASTING_PUNCTUAL_LIGHT_SHADOW #include "Assets/Cookbook/Ch15/NPRCommon.hlsl" ENDHLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R HLSLPROGRAM #pragma vertex NprDepthVert #pragma fragment NprNullFrag #include "Assets/Cookbook/Ch15/NPRCommon.hlsl" ENDHLSL } } }
// Gooch 冷暖着色:用“冷→暖”的色相变化代替“暗→亮”的明度变化来表达朝向,(GLSL 版,对应 Ch15/Gooch.shader) // 暗部不再是死黑,形体在技术插图里依旧清楚。附带白色高光与背面外扩描边。 Shader "Cookbook/Ch15/Gooch" { SubShader { Tags { "RenderType"="Opaque" "RenderPipeline"="UniversalPipeline" "Queue"="Geometry" } GLSLINCLUDE uniform vec4 _BaseColor, _CoolColor, _WarmColor, _OutlineColor; uniform float _Alpha, _Beta, _SpecPower, _SpecStrength, _ShadowCool; uniform float _OutlineWidth; ENDGLSL Pass { Name "ForwardLit" Tags { "LightMode"="UniversalForwardOnly" } GLSLPROGRAM #define NPR_VARYINGS #include "Ch15/NPRCommon.glsl" #ifdef VERTEX void main() { NprVert(); } #endif #ifdef FRAGMENT vec4 frag() { vec3 N = normalize(normalWS); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); NprLight L = GetNprLight(positionWS, N); // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色 vec3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb; vec3 kWarm = _WarmColor.rgb + _Beta * _BaseColor.rgb; // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷” float t = (1.0 + L.ndotl) * 0.5; // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充) t *= mix(1.0 - _ShadowCool, 1.0, L.shadow); vec3 col = mix(kCool, kWarm, t); // 3) 白色高光:Blinn-Phong,只在受光面出现 vec3 H = normalize(L.direction + V); float spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0.0, L.ndotl); col += spec; col = MixFog(col, fogFactor); return vec4(col, 1.0); } void main() { fragColor = frag(); } #endif ENDGLSL } // 背面外扩描边(第 14 章讲过的方法,这里只作为技术插图的黑色轮廓线) Pass { Name "Outline" Tags { "LightMode"="SRPDefaultUnlit" } Cull Front GLSLPROGRAM #ifdef VERTEX void main() { vec4 positionCS = TransformObjectToHClip(positionOS.xyz); vec3 normalCS = mat3(UNITY_MATRIX_VP) * TransformObjectToWorldNormal(normalOS); // 在裁剪空间沿法线外扩,乘 w 抵消透视除法,使线宽与距离无关 vec2 offset = normalize(normalCS.xy) * _OutlineWidth * positionCS.w; offset.x *= _ScreenParams.y / _ScreenParams.x; // 纠正宽高比 positionCS.xy += offset; gl_Position = positionCS; } #endif #ifdef FRAGMENT void main() { fragColor = _OutlineColor; } #endif ENDGLSL } Pass { Name "ShadowCaster" Tags { "LightMode"="ShadowCaster" } ZWrite On ZTest LEqual ColorMask 0 GLSLPROGRAM #define NPR_SHADOW_PASS #include "Ch15/NPRCommon.glsl" ENDGLSL } Pass { Name "DepthOnly" Tags { "LightMode"="DepthOnly" } ZWrite On ColorMask R GLSLPROGRAM #define NPR_DEPTH_PASS #include "Ch15/NPRCommon.glsl" ENDGLSL } } }
关键代码讲解:
Assets/Cookbook/Ch15/Gooch.shader(节选:frag)
half4 frag(NprVaryings i) : SV_Target { float3 N = normalize(i.normalWS); float3 V = GetWorldSpaceNormalizeViewDir(i.positionWS); NprLight L = GetNprLight(i.positionWS, N); // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色 half3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb; half3 kWarm = _WarmColor.rgb + _Beta * _BaseColor.rgb; // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷” half t = (1.0h + L.ndotl) * 0.5h; // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充) t *= lerp(1.0h - _ShadowCool, 1.0h, L.shadow); half3 col = lerp(kCool, kWarm, t); // 3) 白色高光:Blinn-Phong,只在受光面出现 float3 H = normalize(L.direction + V); half spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0, L.ndotl); col += spec; col = MixFog(col, i.fogFactor); return half4(col, 1); }
vec4 frag() { vec3 N = normalize(normalWS); vec3 V = GetWorldSpaceNormalizeViewDir(positionWS); NprLight L = GetNprLight(positionWS, N); // 1) 冷暖两端:纯冷/暖色 + 一部分物体本色 vec3 kCool = _CoolColor.rgb + _Alpha * _BaseColor.rgb; vec3 kWarm = _WarmColor.rgb + _Beta * _BaseColor.rgb; // 2) 用 (1 + N·L) / 2 在两端之间插值:背光面不是“没有光”,而是“偏冷” float t = (1.0 + L.ndotl) * 0.5; // 落在投影里的部分往冷端推一些(原始 Gooch 模型没有阴影,这是工程上的补充) t *= mix(1.0 - _ShadowCool, 1.0, L.shadow); vec3 col = mix(kCool, kWarm, t); // 3) 白色高光:Blinn-Phong,只在受光面出现 vec3 H = normalize(L.direction + V); float spec = pow(saturate(dot(N, H)), _SpecPower) * _SpecStrength * L.shadow * step(0.0, L.ndotl); col += spec; col = MixFog(col, fogFactor); return vec4(col, 1.0); }
- 原始的 Gooch 模型没有投影。如果直接用上面的公式,落在别的物体阴影里的表面依然是暖色,看起来像“没有阴影”。代码里用
t *= lerp(1 - _ShadowCool, 1, shadow)把阴影区往冷端推一截,这是一个工程上的补充,不属于原论文。 - 高光用的是普通 Blinn-Phong,但颜色固定为白色、只出现在受光面(
step(0, ndotl))。技术插图里的高光是“告诉你这是个光滑曲面”的符号,不需要物理正确。 - 第二个 Pass 的描边沿用第 14 章的背面外扩法,LightMode 是
SRPDefaultUnlit,URP 的不透明绘制会自动画它。

调参建议冷暖端点不必是蓝和黄。想要“傍晚”的感觉可以用紫—橙,想要清冷的科技感可以用青—白。α、β 越大,本色越占主导,冷暖感越弱;两者都很小时,所有物体会变成同一种冷暖渐变,本色几乎消失。实践中 α 取 0.2~0.4、β 取 0.4~0.6 比较平衡。
常见坑立方体的描边在棱角处会断开(图 15-3 右图的方块)。原因是立方体每个面的顶点各有一套法线,同一个角上的三个顶点沿不同方向外扩,就裂开了。第 14 章的“平滑法线烘焙”就是为了解决这个问题。