GameTech/URP 渲染实战

案例 18.4菲涅尔边缘光入门

目标:让物体轮廓处出现一圈亮边。这是“被选中高亮”“能量体”“背光轮廓”最常用的手法,也是本章后面全息、护盾、冰面的组成部分。

原理:轮廓处 N·V 趋近于 0

在第 9 章我们讲过真实的菲涅尔效应:光以掠射角照到表面时,反射率会急剧升高。Schlick 近似写作

F=F0+(1−F0)(1−N·V)5

特效里的“菲涅尔”借用的是它的形状,而不是物理含义:从相机看过去,物体正对你的地方法线 N 与视线方向 V 几乎平行,N·V≈1;越靠近轮廓,表面越“侧”过去,N·V 越接近 0。所以 pow(1 − N·V, power) 在中间是 0、在轮廓处是 1,天然就是一圈“轮廓遮罩”。指数 power 控制这圈有多宽:power 越大,亮边越窄越锐利。

视线 N·V≈1 N·V≈0(轮廓) N·V≈0.7 N·Vrim 01 power=1power=3power=8
图 18-6左:球面上不同位置的 N·V;右:rim = (1−N·V)power 随 N·V 的变化,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);
}
// 经验菲涅尔:视线与法线越垂直(轮廓处)值越大。power 越大,亮边越窄。
float FresnelRim(vec3 normalWS, vec3 viewWS, float power)
{
    return pow(1.0 - 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);
}
图 18-7菲涅尔边缘光的指数,从左到右 power = 1、2、4、8。power=1 时几乎整个球都在发光,power=8 时只剩细细一圈。
图 18-8光从右侧低角度照来。左:边缘光一圈都有;右:用 N·L 遮罩后只保留背光(左侧)的轮廓光,受光侧交给正常光照。

常见坑

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