GameTech/URP 渲染实战

案例 5.9经典光照函数库与实验台进阶

目标:把本章的模型整理成一个可复用的 HLSL 函数库,统一接口和约定;再做一个用关键字自由组合漫反射与高光的“实验台” Shader,方便对比和选型。

函数库

把散落在各个 Shader 里的公式收拢到一个 include 文件里,需要先统一约定,否则组合时很容易出错。这里的约定和 URP 保持一致:所有函数返回值已经乘过 N·L(或它的替代项),并且不除以 π;高光函数统一使用归一化版本。

Assets/Cookbook/Ch05/ClassicLighting.hlsl
// 案例 5.9 经典光照模型函数库。
// 约定(与 URP 一致):所有函数的返回值都已经乘过 N·L(或它的替代品),
// 而且没有除以 π——URP 的漫反射写作 albedo * lightColor * N·L,高光的归一化系数因此也整体乘了 π。
#ifndef COOKBOOK_CH05_CLASSIC_LIGHTING_INCLUDED
#define COOKBOOK_CH05_CLASSIC_LIGHTING_INCLUDED

float DiffuseLambert(float3 N, float3 L)
{
    return saturate(dot(N, L));
}

float DiffuseHalfLambert(float3 N, float3 L, float power)
{
    return pow(dot(N, L) * 0.5 + 0.5, power);
}

// 能量守恒的 Wrap:w = 0 时等于 Lambert
float DiffuseWrap(float3 N, float3 L, float w)
{
    return saturate((dot(N, L) + w) / ((1.0 + w) * (1.0 + w)));
}

// Oren-Nayar 定性模型(无三角函数版本),sigma 为微表面坡度的标准差(弧度)
float DiffuseOrenNayar(float3 N, float3 L, float3 V, float sigma)
{
    float NdotL = saturate(dot(N, L));
    float NdotV = saturate(dot(N, V));
    float s2 = sigma * sigma;
    float A = 1.0 - 0.5 * s2 / (s2 + 0.33);
    float B = 0.45 * s2 / (s2 + 0.09);
    float s = dot(L, V) - NdotL * NdotV;
    return NdotL * (A + B * max(0.0, s) / max(max(NdotL, NdotV), 1e-4));
}

// 归一化 Phong:(n+2)/2 · (R·V)^n · N·L
float SpecularPhong(float3 N, float3 L, float3 V, float n)
{
    float RdotV = saturate(dot(reflect(-L, N), V));
    return (n + 2.0) * 0.5 * pow(RdotV, n) * saturate(dot(N, L));
}

// 归一化 Blinn-Phong:(n+8)/8 · (N·H)^n · N·L
float SpecularBlinnPhong(float3 N, float3 L, float3 V, float n)
{
    float3 H = SafeNormalize(L + V);
    return (n + 8.0) * 0.125 * pow(saturate(dot(N, H)), n) * saturate(dot(N, L));
}

// 用 0~1 的“光滑度”驱动高光指数,映射与 URP SimpleLit 相同:smoothness=0 → 2,=1 → 2048
float ShininessFromSmoothness(float smoothness)
{
    return exp2(10.0 * smoothness + 1.0);
}

#endif
// 案例 5.9 经典光照模型函数库(GLSL 版,对应 Ch05/ClassicLighting.hlsl)。
// 约定(与 URP 一致):所有函数的返回值都已经乘过 N·L(或它的替代品),
// 而且没有除以 π——URP 的漫反射写作 albedo * lightColor * N·L,高光的归一化系数因此也整体乘了 π。
#ifndef COOKBOOK_CH05_CLASSIC_LIGHTING_INCLUDED
#define COOKBOOK_CH05_CLASSIC_LIGHTING_INCLUDED

float DiffuseLambert(vec3 N, vec3 L)
{
    return saturate(dot(N, L));
}

float DiffuseHalfLambert(vec3 N, vec3 L, float power)
{
    return pow(dot(N, L) * 0.5 + 0.5, power);
}

// 能量守恒的 Wrap:w = 0 时等于 Lambert
float DiffuseWrap(vec3 N, vec3 L, float w)
{
    return saturate((dot(N, L) + w) / ((1.0 + w) * (1.0 + w)));
}

// Oren-Nayar 定性模型(无三角函数版本),sigma 为微表面坡度的标准差(弧度)
float DiffuseOrenNayar(vec3 N, vec3 L, vec3 V, float sigma)
{
    float NdotL = saturate(dot(N, L));
    float NdotV = saturate(dot(N, V));
    float s2 = sigma * sigma;
    float A = 1.0 - 0.5 * s2 / (s2 + 0.33);
    float B = 0.45 * s2 / (s2 + 0.09);
    float s = dot(L, V) - NdotL * NdotV;
    return NdotL * (A + B * max(0.0, s) / max(max(NdotL, NdotV), 1e-4));
}

// 归一化 Phong:(n+2)/2 · (R·V)^n · N·L
float SpecularPhong(vec3 N, vec3 L, vec3 V, float n)
{
    float RdotV = saturate(dot(reflect(-L, N), V));
    return (n + 2.0) * 0.5 * pow(RdotV, n) * saturate(dot(N, L));
}

// 归一化 Blinn-Phong:(n+8)/8 · (N·H)^n · N·L
float SpecularBlinnPhong(vec3 N, vec3 L, vec3 V, float n)
{
    vec3 H = SafeNormalize(L + V);
    return (n + 8.0) * 0.125 * pow(saturate(dot(N, H)), n) * saturate(dot(N, L));
}

// 用 0~1 的“光滑度”驱动高光指数,映射与 URP SimpleLit 相同:smoothness=0 → 2,=1 → 2048
float ShininessFromSmoothness(float smoothness)
{
    return exp2(10.0 * smoothness + 1.0);
}

#endif

注意 Half-Lambert 函数本身没有乘 N·L:它的整个意义就是用 0.5·N·L + 0.5 去代替 N·L。这也是为什么约定里要写“或它的替代项”。ShininessFromSmoothness 与 URP Simple Lit 采用同样的映射,这样同一套贴图在两个 Shader 之间切换时手感一致。

实验台 Shader

Assets/Cookbook/Ch05/ClassicLab.shader(节选:frag)
half4 frag(Varyings input) : SV_Target
{
    half3 albedo = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, input.uv).rgb * _BaseColor.rgb;
    float3 N = normalize(input.normalWS);
    float3 V = GetWorldSpaceNormalizeViewDir(input.positionWS);
    Light mainLight = GetMainLight();
    float3 L = mainLight.direction;
    half3 radiance = mainLight.color * mainLight.distanceAttenuation;

#if defined(_DIFFUSE_HALFLAMBERT)
    float d = DiffuseHalfLambert(N, L, 2.0);
#elif defined(_DIFFUSE_WRAP)
    float d = DiffuseWrap(N, L, _Wrap);
#elif defined(_DIFFUSE_ORENNAYAR)
    float d = DiffuseOrenNayar(N, L, V, _Roughness);
#else
    float d = DiffuseLambert(N, L);
#endif

    float n = ShininessFromSmoothness(_Smoothness);
#if defined(_SPECULAR_PHONG)
    float s = SpecularPhong(N, L, V, n);
#elif defined(_SPECULAR_BLINNPHONG)
    float s = SpecularBlinnPhong(N, L, V, n);
#else
    float s = 0.0;
#endif

    half3 color = (albedo * d + _SpecColor.rgb * s) * radiance;
    color += albedo * SampleSH(N) * _AmbientStrength;
    return half4(color, 1);
}
void main()
{
    vec3 albedo = texture(_BaseMap, uv).rgb * _BaseColor.rgb;
    vec3 N = normalize(normalWS);
    vec3 V = GetWorldSpaceNormalizeViewDir(positionWS);
    Light mainLight = GetMainLight();
    vec3 L = mainLight.direction;
    vec3 radiance = mainLight.color * mainLight.distanceAttenuation;

#if defined(_DIFFUSE_HALFLAMBERT)
    float d = DiffuseHalfLambert(N, L, 2.0);
#elif defined(_DIFFUSE_WRAP)
    float d = DiffuseWrap(N, L, _Wrap);
#elif defined(_DIFFUSE_ORENNAYAR)
    float d = DiffuseOrenNayar(N, L, V, _Roughness);
#else
    float d = DiffuseLambert(N, L);
#endif

    float n = ShininessFromSmoothness(_Smoothness);
#if defined(_SPECULAR_PHONG)
    float s = SpecularPhong(N, L, V, n);
#elif defined(_SPECULAR_BLINNPHONG)
    float s = SpecularBlinnPhong(N, L, V, n);
#else
    float s = 0.0;
#endif

    vec3 color = (albedo * d + _SpecColor.rgb * s) * radiance;
    color += albedo * SampleSH(N) * _AmbientStrength;
    fragColor = vec4(color, 1.0);
}

两个 KeywordEnum 分别生成 4 个和 3 个关键字,用 shader_feature_local_fragment 声明,共 12 种组合,只有实际被材质用到的组合才会进入构建。

坑:用脚本切换 KeywordEnum在 Unity 2022 中,new Material(shader) 时会按属性的默认值自动打开对应的关键字(例如默认值 0 会打开 _DIFFUSE_LAMBERT)。之后如果只调用 EnableKeyword("_DIFFUSE_WRAP"),两个关键字会同时打开,最终用哪个变体取决于代码里 #if 的先后顺序,表现为“改了没反应”。正确做法是同时设置属性值、打开目标关键字、关闭同组其它关键字。本书配图脚本里的 SetKeywordEnum() 辅助函数就是做这件事的。
图 5-21同一个实验台 Shader 的四种组合。从左到右:Lambert + Phong(蓝色塑料);Half-Lambert + Blinn-Phong(暗部通透的橙色);Wrap + Blinn-Phong(柔和的皮肤色);Oren-Nayar + 无高光(粗陶)

怎么选

模型相对开销观感典型用途
Lambert1 次点积标准、交界线清晰一切漫反射的默认选择,PBR 的漫反射项
Half-LambertLambert + 1 次乘加(+ 平方)暗部通透、对比低卡通、角色、无 GI 的移动端
Wrap(守恒)Lambert + 1 次除法交界线柔和,可染色皮肤、蜡、植物叶片的廉价近似
Oren-Nayar3 次点积 + 1 次除法粗糙、正面光下平坦岩石、陶土、粗布、月面
Minnaert2 次 pow边缘可亮可暗天鹅绒、绒毛、风格化
Phongreflect + pow圆形高光历史意义,少用
Blinn-Phongnormalize + pow掠射时高光拉长,更真实经典高光的默认选择,Simple Lit