理论支线:LightAttenuation 衰减

Point lights  点光源

概述

点光源的距离衰减控制了光照强度如何随距离变化。现代游戏引擎主要使用两种方法:

  • 经典三参数公式(已过时,仅用于教学)
  • 物理衰减 + 窗口函数(现代主流,Unity/UE使用)

经典三参数衰减公式(历史方法)

⚠️ 注意:现代引擎(Unity URP/HDRP、Unreal Engine)已不再使用此公式。 \(\begin{equation} F_{att} = \frac{1.0}{K_c + K_l * d + K_q * d^2} \end{equation}\)

常数项 $K_c$

$K_c$ = 1.0,通常设置为 1.0 作用:防止分母为0,确保在距离为0时不会得到无穷大的亮度。

为什么是1.0?

当 d = 0 时: Fatt = 1.0 / (1.0 + 0 + 0) = 1.0 意味着在光源位置,衰减系数为1(没有衰减)

如果 $K_c < 1$,比如 0.5: 当 d = 0 时: Fatt = 1.0 / 0.5 = 2.0 ❌ 这会让光源位置的亮度增强2倍,不符合物理!

线性项 $K_l$

典型值:0.09, 0.07, 0.045 等 Kl = 0.09 作用:提供中等距离的线性衰减。 效果: 距离 d = 5: 线性贡献 = 0.09 * 5 = 0.45

二次项 $K_q$

典型值:0.032, 0.017, 0.0075 等 Kq = 0.032 作用:提供远距离的快速衰减。 距离 d = 10: 二次贡献 = 0.032 10² = 3.2 距离 d = 20: 二次贡献 = 0.032 20² = 12.8 关键特性

  • 距离小时:$d^2$ 很小,二次项贡献小
  • 距离大时:$d^2$ 急剧增大,二次项主导衰减

设计取值思路

方法1:基于目标距离反推参数

核心思路 先确定你想要的”光照范围”,然后反推参数。

假设你想要光源在距离 d = 50 时衰减到某个阈值(比如5%亮度),可以这样计算:

1
2
3
4
5
6
7
目标:在 d = 50 时,Fatt = 0.05

0.05 = 1.0 / (Kc + Kl * 50 + Kq * 50²)
0.05 = 1.0 / (Kc + 50*Kl + 2500*Kq)

反推:
Kc + 50*Kl + 2500*Kq = 20

但这是一个方程,三个未知数,所以需要额外约束。

常用约束条件

约束1:固定 Kc = 1.0
1
2
3
理由:保证在光源位置(d=0)亮度为100%
简化方程:1.0 + 50*Kl + 2500*Kq = 20
          50*Kl + 2500*Kq = 19
约束2:定义”半强度距离”

假设你希望在 d = 13 时亮度衰减到50%:

1
2
3
4
5
6
7
8
9
10
11
12
0.5 = 1.0 / (1.0 + 13*Kl + 169*Kq)
1.0 + 13*Kl + 169*Kq = 2
13*Kl + 169*Kq = 1  ... (方程A)

同时满足范围约束:
50*Kl + 2500*Kq = 19  ... (方程B)

解这个二元一次方程组:
(A): Kl = (1 - 169*Kq) / 13
代入(B): 50*(1 - 169*Kq)/13 + 2500*Kq = 19
        ...
得到:Kq  0.44, Kl  0.35

方法2:查表

光照距离 Kc Kl Kq 用途场景
7 1.0 0.7 1.8 手电筒、小灯泡
13 1.0 0.35 0.44 台灯、壁灯
20 1.0 0.22 0.20 房间吊灯
32 1.0 0.14 0.07 大厅灯光
50 1.0 0.09 0.032 路灯
65 1.0 0.07 0.017 广场灯
100 1.0 0.045 0.0075 体育场灯光
160 1.0 0.027 0.0028 大型场景
200 1.0 0.022 0.0019 室外环境
325 1.0 0.014 0.0007 超大场景
600 1.0 0.007 0.0002 天空光

经典公式的问题

❌ 致命缺陷

  1. 没有明确的Range参数
    • 美术师无法直接控制”光照范围是多少米”
    • 只能通过调整三个晦涩的参数间接影响
    • 需要反复测试才能达到想要的效果
  2. 预设参数表太死板
    • 表中没有的范围(如18米)需要手动计算
    • 参数之间相互耦合,改一个影响全部
    • 工作流程繁琐低效
  3. 无法做性能优化
    • 理论上衰减永远不为0(延伸到无穷远)
    • GPU无法提前剔除超出范围的光源
    • 即使距离1000米也要计算
  4. 无法做空间剔除
    • CPU端无法判断光源是否影响视锥体
    • 所有光源都要传给GPU处理

这就是为什么现代引擎全部放弃了这个公式!

基于物理的简化公式

平方反比定律(真实物理)

现实世界中,光照遵循:

\[I = \frac{I_0}{d^2}\]

其中:

  • $I$ = 衰减后的光照强度
  • $I_0$ = 光源初始强度
  • $d$ = 距离光源的距离 也就是
1
2
float distanceSqr = distance * distance;
float physicalAttenuation = 1.0 / max(distanceSqr, 0.01 * 0.01);

但游戏中直接用会有问题:

  • d=0 处会无穷大
  • 没有艺术控制范围的能力

关联点光源生效半径

对于整个衰减公式中有一点没有说明,就是关联点光源的生效半径最上面的经典三参数衰减公式随着时代更替,实际上游戏引擎现在根本不用!! 而是采用平方反比定律(真实物理)的衰减。可是当前物理公式不论各多远都会计算也就是无限接近于0,这会导致很多性能浪费,所以需要进行截断操作。

现代引擎实现:物理衰减 + 窗口函数

设计哲学

现代引擎采用两阶段设计:

1
2
完整衰减 = 物理衰减 × 窗口函数(范围截断)
         = (1/d²)  ×  Window(d, R, n)

优点: ✓ 基于物理,视觉真实 ✓ 有明确的Range参数(艺术控制) ✓ 可提前剔除(性能优化) ✓ 边界平滑过渡(无硬边)

窗口函数(衰减截断)

这里对范围进行截断,截断操作就是叫窗口函数或者衰减函数也行,最简单的线性窗口就是到了半径之后就是截断,但是会导致硬边

1
float window = saturate(1.0 - distance / range); 

问题:在边界处导数不连续(有视觉”硬边”)

为了更加平滑的截断,在引擎中则是使用下方的窗口函数公式

\[\text{Window}(d, R, n) = \left[\text{saturate}\left(1 - \left(\frac{d}{R}\right)^n\right)\right]^2\]

其中 d是距离,R是半径 图像如下 所以最终是窗口函数和物理衰减来进行混合,也就是$physicalAtten * windowAtten$ 在引擎中通常使用4次方来作为衰减,性能和效果比较合适,过小的话衰减过快,边界会很明显。过大衰减范围增加,计算量会增大 \(\text{Window}(d, R, n) = \left[\text{saturate}\left(1 - \left(\frac{d}{R}\right)^4\right)\right]^2\) 根据艺术需求:

  • 卡通风格 → n=2(明确边界)
  • 写实风格 → n=6(柔和过渡)
  • 魔法效果 → n=8~10(力场感)

衰减函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 正确的衰减函数
float CalculateAttenuation(float3 worldPos)
{
    // 1. 距离平方
    float3 L = _LightPos - worldPos;
    float distSqr = dot(L, L);
    
    // 2. 物理衰减
    float physicalAtten = 1.0 / max(distSqr, 0.01 * 0.01);
    
    // 3. 窗口函数(无分支)
    float rangeSqr = _LightRange * _LightRange;
    float normDistSqr = distSqr / rangeSqr;
    float normDistQuad = normDistSqr * normDistSqr;
    float windowBase = saturate(1.0 - normDistQuad);
    float windowAtten = windowBase * windowBase;
    
    // 4. 组合
    return physicalAtten * windowAtten;

Unity实际使用的代码(Lighting.hlsl)

$\text{distanceAttenuation.x} = \frac{1}{R^2}$

$\text{fadeDistance} = 0.64 \times R^2$

$\text{distanceAttenuation.y} = \frac{-R^2}{0.64R^2 - R^2}$

1
2
3
4
5
distanceAttenuation.x = 1.0f / (lightRange * lightRange);

float lightRangeSqr = lightRange * lightRange;
float fadeDistance = 0.8f * 0.8f * lightRangeSqr; // 80% 的范围开始衰减 
distanceAttenuation.y = -lightRangeSqr / (fadeDistance - lightRangeSqr);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
float DistanceAttenuation(float distanceSqr, half2 distanceAttenuation)
{
    float lightAtten = rcp(distanceSqr); // rcp(x) = 1/x
    
    #if SHADER_HINT_NICE_QUALITY
        // 高质量:n=4
        half factor = distanceSqr * distanceAttenuation.x;
        half smoothFactor = saturate(1.0h - factor * factor);
        smoothFactor = smoothFactor * smoothFactor;
    #else
        // 标准质量:线性窗口(从80% range开始衰减)
        half smoothFactor = saturate(
            distanceSqr * distanceAttenuation.x + 
            distanceAttenuation.y
        );
    #endif
    
    return lightAtten * smoothFactor;
}

完整对应表

数学符号 Unity 代码 说明
$d$ distance 距离(通常不直接计算,避免sqrt)
$d^2$ distanceSqr 距离平方
$R$ lightRange 光源范围
$R^2$ rangeSqr 范围平方
$\frac{1}{R^2}$ distanceAttenuation.x CPU预计算
$\frac{d^2}{R^2}$ factor 归一化距离平方
$\left(\frac{d^2}{R^2}\right)^2$ factor * factor 四次方项(用平方表示)
$\left(\frac{d^2}{R^2}\right)^2$ 1.0h - factor * factor 窗口函数基础
$\text{saturate}(…)$ saturate(...) 钳制到[0,1]
$[…]^2$ smoothFactor * smoothFactor 最后平方

Spotlight 聚光灯

基本概念

聚光灯 = 点光源 + 方向限制 + 角度衰减 点光源:向所有方向发光 聚光灯:只向一个锥形区域发光 从图中可以看到:

参数 说明 图中标注
LightDir 片元指向光源的向量 蓝色线(从fragment到light)
SpotDir 聚光灯照射的方向(某个轴归一化) 红色线(从light向下)
Phi (φ) 外锥角(Outer Cone) 蓝色锥体边缘
Theta (θ) LightDir 和 SpotDir 的夹角 绿色标注

核心判断逻辑

片元是否在聚光灯照射范围内?

只要片元的光向量夹角小于等于椎体角度的一半就行,但是直接使用角度需要使用反三角函数计算量很大,而角度最大也就是$\frac{\pi}{2}$,这表示cos肯定是正数1~0,角度越大cos越小,使用cos来比较更好的办法,这和相机的裁剪原理也是类似

1
2
3
4
5
6
7
8
9
// 计算夹角的余弦值
float cosTheta = dot(normalize(LightDir), normalize(-SpotDir));

// 判断是否在锥内
if (cosTheta > cos(Phi)) {
    // 在聚光灯照射范围内
} else {
    // 在聚光灯外,不受光照影响
}

衰减组成

聚光灯衰减 = 距离衰减(和点光源相同)+ 角度衰减

距离衰减

1
2
3
4
5
6
7
8
9
10
float DistanceAttenuation(float distanceSqr, half2 distanceAttenuation)
{
    float lightAtten = rcp(distanceSqr);  // 1/d²
    
    half factor = distanceSqr * distanceAttenuation.x;
    half smoothFactor = saturate(1.0h - factor * factor);
    smoothFactor = smoothFactor * smoothFactor;
    
    return lightAtten * smoothFactor;
}

角度衰减

聚光灯的形状是怎样?

在中心区域应该会被完全照亮,然后再向边缘衰减,并非和点光源一样的直接衰减,所以我们需要在这个范围内构建衰减 Unity 使用两个角度来实现平滑过渡: InnerAngle (内锥角):完全照亮区域 OuterAngle (外锥角):光照边界

线性插值的基本思路 我们想要一个函数,满足:

  \(f(\theta) =\begin{cases} 1.0 & \text{if } \theta \leq \phi_{\text{inner}} \\ \text{线性递减} & \text{if } \phi_{\text{inner}} < \theta < \phi_{\text{outer}} \\ 0.0 & \text{if } \theta \geq \phi_{\text{outer}} \end{cases}\)

标准线性插值公式

在两个端点之间线性插值:

\[f(x) = \frac{x - x_{\text{min}}}{x_{\text{max}} - x_{\text{min}}}\]

这给出:

  • 当 $x = x_{\text{min}}$ 时,$f(x) = 0$
  • 当 $x = x_{\text{max}}$ ​ 时,$f(x) = 1$

但我们需要反过来

对于聚光灯,我们希望:

  • 当$\theta = \phi_{\text{inner}}$ (小角度)时,衰减 = 1.0(亮)
  • 当$\theta = \phi_{\text{outer}}$ (大角度)时,衰减 = 0.0(暗)

所以公式应该是:

\[f(\theta) = \frac{\phi_{\text{outer}} - \theta}{\phi_{\text{outer}} - \phi_{\text{inner}}}\]

所以最终角度衰减公式: 线性插值得到的是直线过渡会产生硬边缘截断,为了更自然的视觉效果,对结果进行平方:

\[\text{AngleAttenuation} = \left[\text{saturate}\left(\frac{\cos\theta - \cos\phi_{\text{outer}}}{\cos\phi_{\text{inner}} - \cos\phi_{\text{outer}}}\right)\right]^2\]

其中:

  • $\theta$= LightDir 和 SpotDir 的夹角
  • $\phi_{\text{inner}}$​ = 内锥角
  • $\phi_{\text{outer}}​$ = 外锥角

平方是性能和效果的最佳平衡:

指数 中点值 指令数 效果 使用
0.50 0 线性,太硬
0.25 1 适中 ✓ Unity/UE
0.125 2 过于激进 特殊效果
t⁴ 0.0625 2 极窄光束 激光效果

衰减混合

最终角度衰减公式是这样 \(\text{AngleAttenuation} = \left[\text{saturate}\left(\frac{\cos\theta - \cos\phi_{\text{outer}}}{\cos\phi_{\text{inner}} - \cos\phi_{\text{outer}}}\right)\right]^2\) 放进 shader 后,每个像素至少需要:

  1. cosInnercosOuter(通常从角度算 cos)

  2. cosInner - cosOuter

  3. (cosθ - cosOuter)

  4. 一次除法

  5. saturate

除法是 GPU 上的慢指令(尤其是老架构 / mobile) 所以为了优化,会把公式拆分使用MAD也就是乘法加法,在GPU运行更快,$\cos\theta$是必须片元计算的,其它两个参数就在灯光设置时交给cpu算了 \(A = \text{saturate} \left( \cos\theta \cdot \frac{1}{\cos\phi_{inner} - \cos\phi_{outer}} - \frac{\cos\phi_{outer}}{\cos\phi_{inner} - \cos\phi_{outer}} \right)\)

1
atten = saturate(SdotL * spotAttenuation.x + spotAttenuation.y);

最终的衰减就是 $DistanceAttenuation * AngleAttenuation$

Unity Shader 代码参考

1
2
3
float3 lightToPixel = normalize(WorldPos - LightPos);
float3 spotDir = normalize(SpotLightDirection);
float cosTheta = dot(spotDir, lightToPixel);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
half AngleAttenuation(half3 spotDirection, half3 lightDirection, half2 spotAttenuation)
{
    // Spot Attenuation with a linear falloff can be defined as
    // (SdotL - cosOuterAngle) / (cosInnerAngle - cosOuterAngle)
    // This can be rewritten as
    // invAngleRange = 1.0 / (cosInnerAngle - cosOuterAngle)
    // SdotL * invAngleRange + (-cosOuterAngle * invAngleRange)
    // If we precompute the terms in a MAD instruction
    
    // 步骤1:计算 cos(θ)
    half SdotL = dot(spotDirection, lightDirection);
    
    // 步骤2:线性插值(MAD 优化)
    // 等价于:(SdotL - cosOuter) / (cosInner - cosOuter)
    half atten = saturate(SdotL * spotAttenuation.x + spotAttenuation.y);
    
    // 步骤3:平方平滑
    return atten * atten;
}

聚光灯阴影性能

移动端优化建议:

  • 限制聚光灯数量(≤ 3-5 个)
  • 使用较小的 Range
  • 避免重叠的聚光灯
  • 使用 Light Culling

聚光灯阴影比点光源便宜,但比方向光昂贵:

光源类型 Shadow Maps 性能 备注
Directional 4 cascades 覆盖整个场景
Spot 1 张 只覆盖锥体
Point 6 张(Cubemap) 6个方向

优化建议:

  • 只对主要聚光灯开启阴影
  • 使用合适的 Shadow Resolution(512-1024)
  • 考虑使用烘焙阴影



    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • URP - RendererFeature :ScreenSpaceOutline
  • 平滑法线处理 - 八面体映射
  • Lv.3 Unity主线:一个简单的PBRShader
  • 理论支线:直接光漫反射与GGX高光的混合问题
  • 理论支线:PBR - 基于图像的照明( image based lighting-IBL)
  • # #