深入 URP 渲染管线内核,解析不同光源类型的距离衰减与角度衰减算法,掌握自定义 Shader 中精确控制光照强度的方法。
01 · 基础概念
什么是光照衰减?
在物理世界中,光从光源发出后,随着传播距离的增加,其照亮表面的能力会逐渐减弱,这就是光照衰减(Light Attenuation)。正确模拟衰减效果是实现真实感渲染的关键步骤之一。
在传统渲染中,最常见的做法是使用平方反比定律:光照强度与距离的平方成反比。然而,在实时渲染中,纯粹的物理公式往往需要被修改,以便在保持视觉真实性的同时控制计算成本和行为边界。


💡 注意
URP 并不直接使用上述三项衰减公式,而是采用了一套精心设计的近似算法,在保证视觉质量的同时大幅降低 GPU 指令数。了解其底层逻辑是自定义高性能 Shader 的前提。
02 · 架构解析
URP 的光照衰减架构
URP 将光照数据以 Light 结构体的形式提供给 Shader。核心帮助函数位于 Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl 中。
整个衰减流程可以拆分为两个正交维度:
| 维度 | 适用光源 | 控制参数 | URP 内置函数 |
|---|---|---|---|
| 距离衰减 | 点光源 · 聚光灯 | Range(灯光范围) | DistanceAttenuation() |
| 角度衰减 | 聚光灯 | Inner / Outer Angle | AngleAttenuation() |
| 阴影衰减 | 所有投影光源 | Shadow Map | GetMainLight() / GetAdditionalLight() |
| 方向光 | 方向光 | 无距离概念 | (无衰减,始终 = 1) |
URP 将附加光源(Additional Lights)的参数打包到一个常量缓冲区数组中,Shader 通过 GetAdditionalLight(lightIndex, worldPos) 一次性获取已经计算好衰减系数的 Light 结构体。但如果想精细控制,就需要理解它内部的计算方式。

03 · 点光源
点光源距离衰减
URP 中点光源的距离衰减不使用原始平方反比,而是使用一个经过精心设计的平滑截断函数,确保在光源范围边界处平滑降为零,避免硬截边。
核心算法
其本质是将物理正确的平方反比与一个基于 Range 的平滑窗口函数相乘:

查看 URP 源码实现
以下是 URP ShaderLibrary 中 DistanceAttenuation 的核心实现,逐行解读:
// 点光源/聚光灯 距离衰减(URP 14 核心实现)
real DistanceAttenuation(real distanceSqr, half2 distanceAttenuation) {
// distanceAttenuation.x = 1 / range² (由 CPU 预计算传入)
// distanceAttenuation.y = 1.0 - (1/range²)^2 (平滑系数)
// 步骤1:计算归一化平方距离
real lightAtten = 1.0 / max(distanceSqr, 0.0001);
// 步骤2:计算平滑窗口因子(防止超出 range 时出现硬截边)
real factor = distanceSqr * distanceAttenuation.x;
real smoothFactor = saturate(1.0 - factor * factor);
// 步骤3:二次平方使曲线更平滑(Frostbite 技巧)
smoothFactor = smoothFactor * smoothFactor;
// 步骤4:最终结果 = 物理衰减 × 平滑截断
return lightAtten * smoothFactor;
}
⚡ 关键洞察
distanceAttenuation.x 的值 1/range² 由 CPU 端的 LightData.cs 预先计算并上传 GPU,避免在 Shader 中进行除法。这是 URP 的典型性能优化策略。
04 · 聚光灯
聚光灯角度衰减
聚光灯在点光源距离衰减的基础上,额外施加一个角度衰减(Angle/Spot Attenuation)。它控制从聚光灯轴线方向向外扩展时光照强度的过渡,分为内角(完整亮度)和外角(完全黑暗)两个区域。

角度衰减的数学推导
URP 使用向量点积计算当前像素方向与聚光灯轴线方向的夹角余弦值,再利用线性映射将余弦值转换到 [0,1] 范围,最后应用 smoothstep 过渡:

CPU 端预计算参数(C# 侧)
// CPU 端将 Spot 参数打包为 Shader 可直接使用的形式
static void GetLightAttenuationAndSpotDirection(
out float lightAttenuation,
out Vector4 lightSpotDir)
{
// 外角余弦 cosOuter = cos(outerAngle * 0.5)
float cosOuterAngle = Mathf.Cos(Mathf.Deg2Rad * outerAngle * 0.5f);
float cosInnerAngle = Mathf.Cos(Mathf.Deg2Rad * innerAngle * 0.5f);
// 线性映射系数:将 [cosOuter, cosInner] 映射到 [0, 1]
float delta = cosInnerAngle - cosOuterAngle;
float sScale = 1.0f / Mathf.Max(delta, 0.001f);
float sOffset = -cosOuterAngle * sScale;
// 打包到 lightSpotDir.w (x = scale, y = offset)
lightSpotDir = new Vector4(dir.x, dir.y, dir.z, sScale);
lightAttenuation = sOffset; // 在其他 Vector4 中存 offset
}
Shader 端角度衰减实现
// 聚光灯角度衰减函数
real AngleAttenuation(half3 spotDirection,
half3 lightDirection,
half2 spotAttenuation)
{
// 计算像素方向与聚光轴的点积(即夹角余弦)
real SdotL = dot(spotDirection, lightDirection);
// 线性映射:spotAtten.x = scale, spotAtten.y = offset
real atten = saturate(SdotL * spotAttenuation.x + spotAttenuation.y);
// 二次平方:使外边缘过渡更柔和
return atten * atten;
}
🎯 关键技巧
角度衰减使用的是余弦空间的线性插值而非角度空间的线性插值。由于余弦是非线性的,实际视觉效果上外圈会比内圈有更平滑的过渡——这正是真实聚光灯的物理特性。
05 · 自定义实现
在自定义 Shader 中完整实现
了解了内部机制后,下面给出一个完整的、可在 URP 自定义 Lit Shader 中使用的点光源 + 聚光灯衰减实现,包含手动计算衰减的完整流程:
// ━━━ 头文件引入 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
// ━━━ 自定义衰减计算函数 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
half3 CustomAdditionalLighting(float3 worldPos,
float3 worldNormal,
half3 albedo)
{
half3 result = 0;
int count = GetAdditionalLightsCount();
for (int i = 0; i < count; i++) {
// ① 获取附加光源数据(已包含衰减预计算)
Light light = GetAdditionalLight(i, worldPos);
// ② 提取距离衰减(已融合角度衰减)
half distAtten = light.distanceAttenuation;
half shadowAtten = light.shadowAttenuation;
half totalAtten = distAtten * shadowAtten;
// ③ Lambert 漫反射 NdotL
half NdotL = saturate(dot(worldNormal, light.direction));
// ④ 合并贡献:颜色 × NdotL × 总衰减 × 反照率
result += light.color * NdotL * totalAtten * albedo;
}
return result;
}
手动计算距离衰减(不依赖内置函数)
当你需要完全自定义衰减曲线(如游戏特效中使用非物理的线性衰减),可以绕过 URP 内置计算:
// ─── 自定义点光源衰减(线性 + 平滑截断)───
half CustomPointAttenuation(float3 worldPos,
float3 lightPos,
half lightRange)
{
float3 diff = lightPos - worldPos;
float dist = length(diff);
// 使用物理平方反比
half physicalAtten = 1.0 / (1.0 + dist * dist);
// 平滑截断窗口(Frostbite 风格)
half normalizedDist = dist / lightRange;
half window = saturate(1.0 - normalizedDist * normalizedDist);
window = window * window; // 二次平方使截断更顺滑
return physicalAtten * window;
}
// ─── 自定义聚光灯衰减(距离 × 角度)──────────────
half CustomSpotAttenuation(float3 worldPos,
float3 lightPos,
float3 spotDir,
half lightRange,
half cosInner,
half cosOuter)
{
// ① 距离衰减(复用点光源函数)
half distAtten = CustomPointAttenuation(worldPos, lightPos, lightRange);
// ② 计算像素到光源方向(归一化)
float3 L = normalize(lightPos - worldPos);
// ③ 角度余弦:L 与 spotDir 点积
half cosAngle = dot(spotDir, L);
// ④ 线性映射 [cosOuter, cosInner] → [0, 1]
half angleAtten = saturate((cosAngle - cosOuter) /
max(cosInner - cosOuter, 0.001));
angleAtten = angleAtten * angleAtten;
// ⑤ 最终衰减 = 距离衰减 × 角度衰减
return distAtten * angleAtten;
}
06 · 性能优化
性能优化技巧
⚠️ 移动端注意
在移动端 GPU 上,length() 函数(含 sqrt)开销较高。应尽可能使用 lengthSq = dot(diff, diff)(平方距离)延迟开根号,直到必须使用时再计算。
-
使用平方距离 — 衰减计算全程使用
distanceSqr而非distance,最终才开根号,节省 GPU 开销 -
CPU 端预计算 — 将
1/range²、cosInner/cosOuter的线性映射系数在 C# 端算好,以常量形式传入,避免 Shader 中重复计算 -
half 精度 — 衰减值最终是 [0,1] 范围,使用
half而非float,在移动端 GPU 可获得 2x 吞吐量提升 -
Forward+ / Deferred 路径 — 在 URP 14+ 中启用 Forward+,通过 Light Tile Binning 减少无效光源循环迭代次数
-
合理设置 Range — 点光源 Range 设置过大会导致更多 Tile 被该光源影响,尽量精确设置可视范围
-
Shader Variant 管理 — 启用
_ADDITIONAL_LIGHTSkeyword 才会编译附加光源循环,关闭可显著减少 Shader 变体体积 -
UNITY_LOOP 提示 — 在 PC 平台为附加光源 for 循环加
[loop]属性,防止展开;在移动端谨慎使用动态分支
光源类型衰减对比总结
| 光源类型 | 距离衰减 | 角度衰减 | 阴影 | 性能开销 |
|---|---|---|---|---|
| 方向光 | 无(始终 1) | 无 | 级联阴影 | 最低 |
| 点光源 | 平方衰减 + 平滑截断 | 无 | Cube 阴影图 | 中等 |
| 聚光灯 | 平方衰减 + 平滑截断 | 余弦线性映射 + 二次平方 | Spot 阴影图 | 较高 |
| 区域光 | 距离衰减(烘焙) | 形状遮挡(烘焙) | 仅 Lightmap | 极低(仅烘焙) |
🚀 最佳实践建议
对于大多数场景,直接使用 GetAdditionalLight(i, worldPos) 获取包含完整衰减的 Light 结构体是最优选择。只有当你需要非物理风格的特效光照(如卡通渲染、赛璐璐等)或需要在衰减之上叠加自定义效果时,才需要手动实现本文中的底层计算。

1497

被折叠的 条评论
为什么被折叠?



