GameTech/URP 渲染实战

案例 6.2自己计算衰减:为什么一定要有平滑窗口进阶

目标:不调用 GetAdditionalLight(),直接读取 URP 上传的光源数组,自己计算距离和角度衰减;对比平方反比、线性和 URP 三种衰减,看清“窗口”在剔除中的作用。

Assets/Cookbook/Ch06/FalloffCompare.shader(节选:punctual)
// 不用 GetAdditionalLight(),直接读 URP 上传的光源数组,自己算衰减。
// URP 14 在所有平台都走常量数组(USE_STRUCTURED_BUFFER_FOR_LIGHT_DATA 恒为 0)
half3 PunctualLight(uint loopIndex, float3 positionWS, half3 normalWS)
{
#if USE_FORWARD_PLUS
    int i = int(loopIndex);                       // Forward+:循环变量就是全局光源下标
#else
    int i = GetPerObjectLightIndex(loopIndex);    // Forward:先查“本物体的第几盏灯”对应的全局下标
#endif
    float4 lightPos   = _AdditionalLightsPosition[i];     // w = 0 表示方向光
    half3  color      = _AdditionalLightsColor[i].rgb;
    half4  attenParam = _AdditionalLightsAttenuation[i];  // x = 1/range²,zw = 聚光角度参数
    half4  spotDir    = _AdditionalLightsSpotDir[i];

    float3 lightVector = lightPos.xyz - positionWS * lightPos.w;
    float  d2 = max(dot(lightVector, lightVector), HALF_MIN);
    half3  L = half3(lightVector * rsqrt(d2));

#if defined(_FALLOFF_INVERSESQUARE)
    float atten = rcp(d2);                                         // 物理上的平方反比,没有窗口
#elif defined(_FALLOFF_LINEAR)
    float atten = saturate(1.0 - sqrt(d2 * attenParam.x));         // 1 - d/range
#else
    float atten = DistanceAttenuation(d2, attenParam.xy);          // URP:平方反比 × 平滑窗口
#endif
    atten *= AngleAttenuation(spotDir.xyz, L, attenParam.zw);      // 聚光锥;点光的参数让它恒为 1

    return color * atten * saturate(dot(normalWS, L));
}
// 不用 GetAdditionalLight(),直接读 URP 上传的光源数组,自己算衰减。
// (网页版的光源数组与 URP 14 同名:_AdditionalLightsPosition / Color / Attenuation / SpotDir)
vec3 PunctualLight(int loopIndex, vec3 positionWS, vec3 normalWS)
{
    // Forward+:循环变量就是全局光源下标;Forward:先查“本物体的第几盏灯”对应的全局下标。
    // HLSL 版用 #if USE_FORWARD_PLUS 区分;网页版的 GetPerObjectLightIndex 在 Forward+ 下原样返回
    int i = GetPerObjectLightIndex(loopIndex);
    vec4 lightPos   = _AdditionalLightsPosition[i];     // w = 0 表示方向光
    vec3 color      = _AdditionalLightsColor[i].rgb;
    vec4 attenParam = _AdditionalLightsAttenuation[i];  // x = 1/range²,zw = 聚光角度参数
    vec4 spotDir    = _AdditionalLightsSpotDir[i];

    vec3  lightVector = lightPos.xyz - positionWS * lightPos.w;
    float d2 = max(dot(lightVector, lightVector), HALF_MIN);
    vec3  L = lightVector * inversesqrt(d2);

#if defined(_FALLOFF_INVERSESQUARE)
    float atten = 1.0 / d2;                                        // 物理上的平方反比,没有窗口
#elif defined(_FALLOFF_LINEAR)
    float atten = saturate(1.0 - sqrt(d2 * attenParam.x));         // 1 - d/range
#else
    float atten = DistanceAttenuation(d2, attenParam.xy);          // URP:平方反比 × 平滑窗口
#endif
    atten *= AngleAttenuation(spotDir.xyz, L, attenParam.zw);      // 聚光锥;点光的参数让它恒为 1

    return color * atten * saturate(dot(normalWS, L));
}
  1. 下标换算。和 GetAdditionalLight 内部一样:Forward 下循环变量是“本物体的第几盏灯”,要用 GetPerObjectLightIndex 查出全局下标;Forward+ 下循环变量本身就是全局下标。
  2. 方向光与点光统一处理。_AdditionalLightsPosition 的 w 分量对方向光为 0、对点光为 1,所以 lightPos.xyz − positionWS × lightPos.w 对方向光得到方向本身,对点光得到“从表面指向光源”的向量。这是 URP 的一个小技巧,省掉了分支。
  3. Range 不需要单独上传。attenParam.x 就是 1/Range²,线性衰减需要的 d/Range 用 sqrt(d2 * attenParam.x) 即可。
图 6-5同一盏点光(Range = 2.5),俯视。从左到右:① 纯平方反比 + Forward+:光照在 tile 边界处被齐刷刷切断,出现锯齿状的方块边缘;② 纯平方反比 + Forward:地面这个物体整个都分到了这盏灯,光一直延伸到画面外,永不结束;③ 线性衰减:中心太暗、边缘太亮,光斑像一块发灰的圆片;④ URP 的平滑衰减:近处与平方反比一致,在 Range 处自然消失

图 6-5 的前两格揭示了“窗口”的真正意义。剔除——无论是 Forward 的每物体列表,还是 Forward+ 的 tile/Z-Bin 掩码——都是保守的、粗粒度的:它只保证“Range 范围内的像素一定会算这盏灯”,却不保证“Range 范围外的像素一定不算”。如果衰减在 Range 处不为 0,那么剔除粒度就会直接暴露在画面上:Forward+ 下是 tile 形状的硬边,Forward 下则是物体一进一出范围时整块的亮度跳变。只有让衰减在 Range 处平滑归零,剔除才是“隐形”的。

图 6-6聚光灯,Spot Angle = 70°,Inner Spot Angle 从左到右为 5°、45°、68°。内角越接近外角,光斑边缘越硬
调参建议灯光的 Range 不要设得“刚好够”,而要让平方反比在 Range 附近已经足够暗,这样窗口只做最后的收尾,衰减看起来最自然;Range 也不要无谓地大,它直接决定了这盏灯参与多少像素的计算。Intensity 与 Range 是两个独立旋钮:前者决定亮度,后者决定影响范围和性能。