URP 渲染实战 / 第 18 章 溶解、全息与护盾 / 案例 18.4
案例 18.4菲涅尔边缘光入门
目标:让物体轮廓处出现一圈亮边。这是“被选中高亮”“能量体”“背光轮廓”最常用的手法,也是本章后面全息、护盾、冰面的组成部分。
原理:轮廓处 N·V 趋近于 0
在第 9 章我们讲过真实的菲涅尔效应:光以掠射角照到表面时,反射率会急剧升高。Schlick 近似写作
特效里的“菲涅尔”借用的是它的形状,而不是物理含义:从相机看过去,物体正对你的地方法线 N 与视线方向 V 几乎平行,N·V≈1;越靠近轮廓,表面越“侧”过去,N·V 越接近 0。所以 pow(1 − N·V, power) 在中间是 0、在轮廓处是 1,天然就是一圈“轮廓遮罩”。指数 power 控制这圈有多宽:power 越大,亮边越窄越锐利。
Assets/Cookbook/Ch18/Ch18Common.hlsl(节选:fresnel)
// 经验菲涅尔:视线与法线越垂直(轮廓处)值越大。power 越大,亮边越窄。 half FresnelRim(half3 normalWS, half3 viewWS, half power) { return pow(1.0h - saturate(dot(normalWS, viewWS)), power); }
边缘光叠加在正常光照之上。一个常见的改进是只在“背光侧”显示边缘光:受光面已经够亮了,再加亮边会发白;背光面加一圈亮边,正好勾出轮廓,这就是影视布光里的“轮廓光(Rim Light)”。实现上用主光方向的 N·L 做一个遮罩:
Assets/Cookbook/Ch18/FresnelRim.shader(节选:frag)
half4 frag(Varyings i) : SV_Target { half3 normalWS = normalize(i.normalWS); half3 viewWS = GetWorldSpaceNormalizeViewDir(i.positionWS); half3 color = SimpleLighting(_BaseColor.rgb, i.positionWS, normalWS, _Smoothness); half rim = FresnelRim(normalWS, viewWS, _RimPower); // 可选:只在背光一侧显示亮边(经典的“轮廓光”打法),受光侧交给正常光照 half NdotL = dot(normalWS, GetMainLight().direction); half lightMask = lerp(1.0h, smoothstep(0.2h, -0.4h, NdotL), _RimLightMask); color += _RimColor.rgb * rim * _RimIntensity * lightMask; return half4(color, 1); }
void main() { vec3 N = normalize(normalWS); vec3 viewWS = GetWorldSpaceNormalizeViewDir(positionWS); vec3 color = SimpleLighting(_BaseColor.rgb, positionWS, N, _Smoothness); float rim = FresnelRim(N, viewWS, _RimPower); // 可选:只在背光一侧显示亮边(经典的“轮廓光”打法),受光侧交给正常光照 float NdotL = dot(N, GetMainLight().direction); float lightMask = mix(1.0, smoothstep(0.2, -0.4, NdotL), _RimLightMask); color += _RimColor.rgb * rim * _RimIntensity * lightMask; fragColor = vec4(color, 1.0); }


常见坑
- 平面没有轮廓:一块平板的法线处处相同,N·V 只随视角整体变化,边缘光会“整块亮起来”或者“整块消失”。菲涅尔边缘光只适合有曲率的模型;硬边的箱子可以用法线贴图、或者改用后处理描边(第 26 章)。
- 法线没有归一化:顶点插值后的法线长度小于 1,N·V 偏小,边缘光整体偏亮。片元里先
normalize。 - 和法线贴图配合:用扰动后的法线算菲涅尔,亮边会出现细碎的纹理,有时正是想要的效果(比如冰面);想要干净的轮廓就用几何法线。
- Shader 结构:这个 Shader 的 ShadowCaster/DepthOnly 没有用
UsePass "Universal Render Pipeline/Lit/ShadowCaster"偷懒。UsePass 借来的 Pass 带着 Lit 自己的UnityPerMaterial,与本 Shader 的 CBUFFER 不一致,会让材质失去 SRP Batcher 兼容性。本章把无顶点动画的阴影/深度 Pass 写成公共文件Ch18ShadowPass.hlsl,CBUFFER 放在 SubShader 级的HLSLINCLUDE里,所有 Pass 自动共享同一份声明。
Assets/Cookbook/Ch18/Ch18ShadowPass.hlsl
// 本章不透明 Shader 共用的 ShadowCaster / DepthOnly 顶点与片元函数(无顶点动画、无裁剪)。 // 用法:在 SubShader 的 HLSLINCLUDE 里声明好 UnityPerMaterial,各 Pass 再 include 本文件。 // 这样所有 Pass 的 CBUFFER 完全一致,SRP Batcher 兼容;而 UsePass 借用 Lit 的 Pass 会带来 // 另一套 UnityPerMaterial,导致材质不兼容 SRP Batcher。 #ifndef COOKBOOK_CH18_SHADOW_PASS_INCLUDED #define COOKBOOK_CH18_SHADOW_PASS_INCLUDED #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" // Shadows.hlsl 用到 CommonMaterial.hlsl 里的 LerpWhiteTo,URP 的 Lit 是经由 LitInput 间接包含的 #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl" float3 _LightDirection; float3 _LightPosition; struct ShadowAttributes { float4 positionOS : POSITION; float3 normalOS : NORMAL; }; struct ShadowVaryings { float4 positionCS : SV_POSITION; }; ShadowVaryings ShadowVert(ShadowAttributes v) { ShadowVaryings o; float3 positionWS = TransformObjectToWorld(v.positionOS.xyz); float3 normalWS = TransformObjectToWorldNormal(v.normalOS); #if _CASTING_PUNCTUAL_LIGHT_SHADOW float3 lightDirWS = normalize(_LightPosition - positionWS); #else float3 lightDirWS = _LightDirection; #endif o.positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS)); #if UNITY_REVERSED_Z o.positionCS.z = min(o.positionCS.z, UNITY_NEAR_CLIP_VALUE); #else o.positionCS.z = max(o.positionCS.z, UNITY_NEAR_CLIP_VALUE); #endif return o; } half4 ShadowFrag(ShadowVaryings i) : SV_Target { return 0; } ShadowVaryings DepthVert(ShadowAttributes v) { ShadowVaryings o; o.positionCS = TransformObjectToHClip(v.positionOS.xyz); return o; } half4 DepthFrag(ShadowVaryings i) : SV_Target { return i.positionCS.z; } #endif
// 本章不透明 Shader 共用的 ShadowCaster / DepthOnly 顶点与片元函数(无顶点动画、无裁剪)。(GLSL 版,对应 Ch18ShadowPass.hlsl) // 用法:在 Pass 的 GLSLPROGRAM 里先 #define SHADOW_PASS 或 DEPTH_PASS,再 #include 本文件。 // HLSL 版强调所有 Pass 的 CBUFFER 一致(SRP Batcher);WebGL 没有这个概念,这里只保留顶点/片元逻辑。 // _LightDirection / _LightPosition 由引擎在画阴影前设置(Lighting.glsl 里已声明)。 #ifdef SHADOW_PASS #ifdef VERTEX void main() { vec3 positionWS = TransformObjectToWorld(positionOS.xyz); vec3 normalWS = TransformObjectToWorldNormal(normalOS); // _CASTING_PUNCTUAL_LIGHT_SHADOW 在 GLSL 版里是个 uniform vec3 lightDirWS = _CastingPunctualLightShadow > 0.5 ? normalize(_LightPosition - positionWS) : _LightDirection; vec4 positionCS = TransformWorldToHClip(ApplyShadowBias(positionWS, normalWS, lightDirWS)); // WebGL 深度不反转(UNITY_REVERSED_Z = 0),走 max 分支 positionCS.z = max(positionCS.z, UNITY_NEAR_CLIP_VALUE * positionCS.w); gl_Position = positionCS; } #endif #ifdef FRAGMENT void main() { fragColor = vec4(0.0); } #endif #endif #ifdef DEPTH_PASS #ifdef VERTEX void main() { gl_Position = TransformObjectToHClip(positionOS.xyz); } #endif #ifdef FRAGMENT void main() { fragColor = vec4(gl_FragCoord.z); } #endif #endif