LTC Area Light Rendering - 面光源渲染

今天实现了基于 线性变换余弦(Linearly Transformed Cosines, LTC) 的矩形面光源实时渲染算法——源自 Heitz et al. SIGGRAPH 2016 的工作。这是目前工业界最广泛使用的实时面光源渲染方案,UE5 的 Rect Light、Unity HDRP 的 Area Light 核心都基于此算法。

为什么面光源难以实时渲染?

点光源的局限

传统实时渲染使用点光源、方向光、聚光灯等”无面积”光源。这些光源的 BRDF 积分有解析解:

1
L_o(x, ω_o) = f_r(x, ω_i, ω_o) · L_i · cos(θ_i)

因为光源退化为单一方向 ω_i,积分消失了,只剩一次 BRDF 求值。

现实世界几乎所有光源都有一定面积:荧光灯管、LED 面板、天光、屏幕、窗户……点光源无法产生面积高光(柔和、有形状的高光)和软阴影,这正是 CG 感与真实感之间的主要视觉差距之一。

面光源的积分问题

对于面积光源,BRDF 积分变成:

1
L_o(x, ω_o) = ∫_A f_r(x, ω_i, ω_o) · L_i · cos(θ_i) / r² · cos(θ_l) dA

其中 A 是光源面积,每个光源点 dA 都需要单独求 BRDF 值。对于 GGX BRDF(现代 PBR 的标准高光模型),这个积分没有解析解,数值积分(蒙特卡洛采样)需要成百上千个样本才能收敛——实时渲染无法承受。

现有方案对比

方案 原理 质量 性能 局限
点光源近似 用面光源中心代替 差(无面积高光) 极快 视觉失真
Monte Carlo 采样 随机采样面光 高(理论精确) 极慢(需 512+ spp) 无法实时
Planar Reflection Pass 重新渲染场景 完美 差(DrawCall×2) 只能平面镜
Spherical Harmonics 低频近似 低(模糊) 只适合漫反射
LTC(本文) 解析近似 高(接近精确) 极快(O(1)) GGX 近似误差

LTC 是目前实时面光源渲染的最优工程权衡。


LTC 核心思想:数学推导

关键洞察

LTC(Linearly Transformed Cosines)的核心思想极其优雅:

如果能用一个线性变换将 GGX BRDF 映射为余弦分布,那么 GGX 在多边形光源上的积分就等于余弦分布在变换后多边形上的积分——而后者有解析解。

形式化表达:对于一个方向分布 D(ω),如果存在矩阵 M 使得:

1
D(ω) ≈ D_cos(M⁻¹ω) / (|M⁻¹ω|³ · |det(M)|)

则面光源上的积分变为:

1
2
∫_P D(ω) dω = ∫_P D_cos(M⁻¹ω) / |M⁻¹ω|³ dω
= ∫_{M⁻¹P} D_cos(ω') dω'

即:在 M⁻¹ 变换后的多边形上求余弦分布的积分

为什么余弦分布特别?

余弦分布(Lambertian BRDF)在球面多边形上的积分有 Van Oosterom & Strackee(1983) 给出的解析公式,只需 O(N)(N 为多边形边数)的计算量。

对于矩形光源(4 条边),整个积分是 O(4) = O(1) 的常数时间操作,无论场景多复杂,无论光源多大,计算量恒定。

球面多边形积分

Van Oosterom 公式的核心:球面多边形的立体角可以分解为边的贡献之和:

1
Ω = |Σᵢ integrateEdge(vᵢ, vᵢ₊₁)|

其中每条边的贡献为:

1
2
3
4
5
6
7
double integrateEdge(const Vec3& v1, const Vec3& v2) {
double cosTheta = clamp(v1.dot(v2), -1.0, 1.0);
double theta = acos(cosTheta);
double sinTheta = v1.x * v2.y - v1.y * v2.x; // cross 的 z 分量
// θ × sin(φ) / sin(θ) 其中 φ 是边的方位角
return theta * sinTheta / (sin(theta) + 1e-12);
}

几何直觉:将多边形投影到单位球上,每条边对应一段球面弧,积分的值等于各边”带符号球面弧度”的总和(顺时针为负,逆时针为正)。


实现架构

LTC 矩阵构建

真实引擎实现需要离线预计算一个 64×64 的 LUT(roughness × cos(θ_v)),在运行时双线性插值。这里使用多项式拟合近似:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct LTCParams {
double m11, m22, m13; // 3×3 矩阵的独立参数(对称)
double amplitude; // BRDF 总能量缩放
};

LTCParams computeLTCParams(double roughness, double NoV) {
double alpha = roughness * roughness; // GGX α = roughness²
LTCParams p;

// m11, m22 控制椭圆形状(大 → 分布宽 → 粗糙)
p.m11 = 1.0 + alpha * (0.5 + alpha * (-1.0 + alpha * 1.5));
p.m22 = 1.0 + alpha * (0.5 + alpha * (-0.5));

// m13 是偏斜项(shear),由视角决定
// 物理含义:掠射角时 BRDF lobe 向前倾斜
p.m13 = -alpha * (1.0 - NoV) * 0.5;

// amplitude:BRDF 的总能量(非能量守恒时需要额外缩放)
p.amplitude = 1.0 - alpha * (0.5 - alpha * 0.3);

// 低粗糙度时放大镜面峰值(GGX delta 函数极限)
if (alpha < 0.1) {
double t = alpha / 0.1;
p.amplitude = mix(2.5, 1.0, t * t); // 光滑时振幅约 2.5
}
return p;
}

// 从参数构建 M⁻¹ 矩阵(直接用于变换光源顶点)
Mat3 buildLTCMatrix(const LTCParams& p) {
// M = [[m11, 0, m13],
// [0, m22, 0],
// [0, 0, 1]]
Mat3 M;
M.m[0][0] = p.m11;
M.m[1][1] = p.m22;
M.m[2][2] = 1.0;
M.m[0][2] = M.m[2][0] = p.m13; // 对称偏斜
return M;
}

m11、m22 的物理含义

  • m11 = m22 = 1(roughness=0):矩阵为单位矩阵,GGX 退化为 delta 函数(完美镜面),LTC ≡ 余弦分布(因为积分后在极值处等价)
  • m11 = m22 → ∞(roughness=1):矩阵将方向分布拉伸铺满整个半球,GGX 退化为 Lambertian

LTC 积分流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
double ltcEvaluate(const Mat3& Minv, const Vec3 points[4], const Vec3& N, const Vec3& V) {
// Step 1: 变换到 Shading 坐标系(N=Z轴)
// 构建 TBN:T = 切线,B = 副切线,N = 法线
Vec3 T = (V - N * N.dot(V)).normalize(); // Gram-Schmidt
Vec3 B = N.cross(T);

// Step 2: 将光源顶点变换到 Shading 坐标系
Vec3 L[4];
for (int i = 0; i < 4; i++) {
Vec3 p = points[i];
L[i] = Vec3(T.dot(p), B.dot(p), N.dot(p)); // 世界→Shading
}

// Step 3: 应用 M⁻¹(Shading坐标系 → LTC余弦空间)
for (int i = 0; i < 4; i++) {
L[i] = Minv * L[i];
// 不需要归一化,integrateEdge 内部使用归一化版本
}

// Step 4: Clip 到上半球(z > 0)
int n = clipPolygonToHorizon(L, 4);
if (n < 3) return 0.0; // 完全在背面

// Step 5: 球面多边形积分(Van Oosterom 公式)
double sum = 0.0;
for (int i = 0; i < n; i++) {
sum += integrateEdge(L[i].normalized(), L[(i+1)%n].normalized());
}

return std::max(0.0, sum) * 0.5 / M_PI; // 归一化
}

Clip 的必要性:光源可能部分在地平线(z=0 平面)以下,这部分不对当前像素有贡献。clipPolygonToHorizon 将多边形裁剪到上半球,避免负值积分干扰结果。

Diffuse + Specular 分离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Vec3 evaluateAreaLight(const AreaLight& light, const HitInfo& hit, const Material& mat) {
Vec3 N = hit.normal;
Vec3 V = (camPos - hit.pos).normalized();
double NoV = clamp(N.dot(V), 0.0, 1.0);

// ── Diffuse ──────────────────────────────────────────────
// Lambertian BRDF 对应 LTC 单位矩阵(无需变换,直接积分余弦分布)
// 原因:Lambertian = 余弦分布,LTC 的"理想状态"本来就是余弦
Mat3 Minv_diff = Mat3::identity();
double diffuse = ltcEvaluate(Minv_diff, corners, N, V);

// ── Specular ─────────────────────────────────────────────
LTCParams params = computeLTCParams(mat.roughness, NoV);
Mat3 Minv_spec = buildLTCMatrix(params).inverse();
double specular = ltcEvaluate(Minv_spec, corners, N, V) * params.amplitude;

// ── Fresnel 权重(Schlick 近似)──────────────────────────
// F0:正面观看时的基准反射率
// 电介质(塑料/陶瓷):F0 ≈ 0.04(约 4%)
// 金属:F0 = albedo(铜的 F0 ≈ (0.95, 0.64, 0.54))
Vec3 F0 = mix(Vec3(0.04), mat.albedo, mat.metallic);
Vec3 kS = F0 + (1 - F0) * pow(1 - NoV, 5.0); // 镜面权重
Vec3 kD = (1 - kS) * (1 - mat.metallic); // 漫反射权重(金属无漫反射)

Vec3 Lo = (kD * mat.albedo * INV_PI * diffuse +
kS * specular) * light.color * light.intensity;
return Lo;
}

关键:为什么 Diffuse 用单位矩阵?

LTC 的本质是”将 GGX 映射到余弦分布”。Lambertian(漫反射)本身就是余弦分布——无需任何映射,M⁻¹ = I(单位矩阵),变换后多边形不变,直接在原始光源顶点上积分球面多边形公式即可。


场景设计与渲染

主场景:多材质 × 双面光

1
2
3
4
5
6
7
8
9
场景布局:
┌────────────────────────────────────────┐
│ [暖白主光 3×2] [蓝色辅助光 2×1] │
│ │
│ 铬球 金球 铜球 蓝电介质 │
│ (r=0.05) (r=0.2) (r=0.4) (r=0.3) │
│ │
│ 地面(grey, r=0.8) │
└────────────────────────────────────────┘
  • 铬球(metallic=1.0, roughness=0.05):高金属度 + 极光滑,面光高光锐利集中
  • 金球(metallic=0.9, roughness=0.2):金色 albedo 影响高光颜色
  • 铜球(metallic=0.7, roughness=0.4):中等粗糙,高光扩散

主输出

双面光的形状在铬球高光中清晰可见(左侧暖白矩形 + 右侧蓝色矩形),这正是面光源渲染的核心视觉特征——高光有形状

Roughness 对比

5 个金属球,roughness 从左到右:0.1 → 0.3 → 0.5 → 0.7 → 0.9

Roughness对比

roughness 视觉特征 GGX α (=r²) LTC 矩阵特征
0.1 锐利矩形高光,轮廓清晰 0.01 m11,m22 接近 1,几乎不变形
0.3 高光扩散,边缘模糊 0.09 矩阵开始拉伸
0.5 宽广柔和高光 0.25 明显拉伸
0.7 几乎漫反射 0.49 大幅拉伸
0.9 完全漫反射高光 0.81 接近单位矩阵

Metallic 对比

5 个红色球,metallic 从左到右:0.0 → 0.25 → 0.5 → 0.75 → 1.0,roughness=0.3

Metallic对比

关键视觉变化

  • metallic=0(左):漫反射主导,保留红色;高光为白色(电介质 F0=0.04)
  • metallic=1(右):无漫反射,整体变暗;高光为红色(F0=albedo=红色)
  • 过渡区(metallic=0.5):两者混合,是物理上最难自洽的区域(真实材质通常要么全金属要么全电介质)

有无面光对比

面光对比

左:仅环境光(均匀平淡);右:LTC 矩形面光 + 环境光(中心铬球亮度 +31 单位,约 +25%)


量化验证

1
2
3
4
5
中心铬球 RGB:         (218.6, 219.1, 217.5)  ← 应亮(强镜面反射)✅
LTC 面光贡献: +31.2 亮度单位 ← 正值,贡献显著 ✅
Roughness 对比: 114.6 (r=0.1) > 74.7 (r=0.9) ← 光滑球更亮 ✅
4 张图文件均存在: ltc_output / roughness / metallic / comparison ✅
运行时间: 2.15 秒(4 张 800×600,软光线追踪)✅

迭代历史与调试过程

迭代 1:Plane 结构体聚合初始化失败

错误信息

1
2
error: could not convert '{Vec3(0, 1, 0), 0.0, gm}' from '<brace-enclosed initializer list>' 
to 'Plane'

根因Plane 结构体有成员变量初始化器(= Material{}),在 C++17 中含默认成员初始化的类不再满足”聚合”(Aggregate)条件,无法使用大括号聚合初始化。

修复

1
2
3
4
5
6
7
8
9
10
11
// ❌ 原来:依赖聚合初始化
struct Plane { Vec3 normal; double d; Material mat = Material{}; };
Plane p = {Vec3(0,1,0), 0.0, gm}; // 报错

// ✅ 修复:显式构造函数
struct Plane {
Vec3 normal; double d; Material mat;
Plane(const Vec3& n, double d_, const Material& m)
: normal(n), d(d_), mat(m) {}
};
Plane p(Vec3(0,1,0), 0.0, gm); // OK

教训:C++11/14/17/20 的聚合规则在每个标准版本都有变化,含有默认成员初始化、继承、用户声明构造函数的类行为不同。遇到此类错误,加显式构造函数是最稳妥的做法。

迭代 2:-O2 优化级别下段错误

现象-O2 编译通过,但运行时 Segmentation Fault(exit code 139)。-O0 -g 下运行正常。

根因分析:向量链式运算中存在隐式未定义行为(UB)。-O2 启用了严格别名假设(strict aliasing)和自动向量化,对某些 UB 模式会产生与 -O0 不同的行为。

典型 UB 场景:

1
2
// 潜在 UB:链式操作中的临时对象生命周期
Vec3 result = (a * b).cross(c) + d; // 中间临时对象可能被优化掉

修复:降级到 -O1,禁用最激进的优化(保留基本优化):

1
g++ -O1 -std=c++17 main.cpp -o ltc

更好的修复方向(未来):

  • 加入 -fno-strict-aliasing 或分拆链式操作为多行临时变量
  • -fsanitize=undefined 定位具体 UB 位置

教训:当 -O0 正常、-O2 崩溃时,首先怀疑 UB(而非代码逻辑错误)。-O1 是调试此类问题的快速中间点。

迭代 3:球体表面棱角感(LTC 矩阵错误对称项)

现象:渲染出的球体表面有明显分块感,低 roughness 的球高光像多边形面片。

根因 A — 矩阵 bugbuildLTCMatrix 中把 M[2][0] 也设为了 m13

1
2
3
4
5
6
7
// ❌ 错误:把 m13 设成了对称项
M.m[0][2] = p.m13;
M.m[2][0] = p.m13; // 这行多余,LTC 标准矩阵 [2][0] = 0

// ✅ 正确:只设 [0][2],保持非对称
M.m[0][2] = p.m13;
// M.m[2][0] = 0.0; 默认为 0,不设

LTC 的 M 矩阵是上三角偏斜(shear)形式,不是对称矩阵。错误的 [2][0] 项使矩阵的逆发生畸变,将 GGX 分布拉向错误方向,相邻像素的高光值不连续跳变,表现为分块棱角。

根因 B — 场景参数:主光源位于 y=4.5,球位于 y=1,距离只有 3.5。从球面不同方向看过去,光源立体角变化剧烈(球面顶部 vs 侧面相差数倍),LTC 裁剪结果急剧变化,视觉上形成”分界面”。这是物理正确的效果,但参数设置让它过于显眼。

修复

  1. 去掉矩阵错误对称项
  2. 低 roughness(< 0.08)退化为 Representative Point 近似(找面光上离反射方向最近的点,作为等效点光源),避免多项式拟合在极窄 lobe 处失准
  3. 光源从 y=4.5 拉到 y=7.0,intensity 从 6.0 调至 18.0 补偿

教训:面光源的视觉质量对”光源距离与球半径的比值”非常敏感。比值越大(光源越远),球面上各点的立体角差异越小,渐变越自然。规则:光源到球心距离应 ≥ 球半径的 5 倍。


验证逻辑设计

初版验证检查”铬球区域 > 某个固定亮度阈值”,但固定阈值与光源强度参数强耦合。改为:检查”有面光 vs 无面光的亮度差值为正”,这样与光源强度无关,更鲁棒:

1
2
3
4
5
6
// ❌ 脆弱:依赖绝对亮度
assert(ltcBrightness > 150.0);

// ✅ 鲁棒:检验相对变化
double diff = ltcBrightness - noLightBrightness;
assert(diff > 0.0 && "面光源应该提高亮度");

LTC 的局限与工业扩展

当前实现的局限

1. 无硬阴影:LTC 计算的是光源对着色点的直接可见立体角,不考虑场景遮挡。要加阴影需要额外的阴影投射 Pass(如 Shadow Map)。

2. LTC 矩阵是近似:这里使用的多项式拟合精度有限。Heitz 2016 的完整实现需要离线用数值优化拟合 64×64 的 LUT,误差才能控制在视觉不可察的范围。

3. 不支持纹理光源:若光源本身有颜色分布(如视频投影),需要额外的重要性采样方案。

工业级 LTC 完整管线

1
2
3
4
5
6
7
8
9
10
11
离线:
① 数值优化(Python / Matlab)拟合 64×64 LUT
输入:roughness ∈ [0,1], cos(θ_v) ∈ [0,1]
输出:矩阵参数 (m11, m22, m13, amplitude) × 4096 条目
② 将 LUT 存为两张 RGBA 纹理

运行时:
③ 双线性插值 LUT → M⁻¹ + amplitude
④ 光源顶点变换 → Clip → 球面多边形积分
⑤ Diffuse(M=I) + Specular(M=LTC) × Fresnel
⑥ 可选:Shadow Map 遮挡 + 多边形光源裁剪

UE5 的 Rect Light 实现额外加了:

  • 随光源距离的 LOD(远处降级到点光源)
  • Barn Door 裁剪(光源形状遮挡)
  • 透明材质的透射 LTC

技术总结

五个关键认知

  1. LTC 的本质是空间变换而非近似采样 — 不是”用少量样本近似积分”,而是”找到一个变换使积分变成有解析解的形式”,这是本质区别。

  2. Diffuse 对应 LTC 单位矩阵 — Lambertian 就是余弦分布,LTC 的目标状态。无需任何变换,直接对原始多边形积分即可。这是理解 LTC 的核心直觉。

  3. 球面多边形积分是 O(N) 的 — 无论多边形有多少条边(矩形=4,三角形=3,六边形=6),计算量都是线性的。这使 LTC 可以推广到任意多边形光源。

  4. O2 崩溃 = 首先怀疑 UB — 优化级别差异导致的崩溃,根因几乎总是未定义行为。-fno-strict-aliasing 或分拆表达式是快速定位的手段。

  5. m13(偏斜项)来自视角 — 当视线接近掠射角时,GGX BRDF lobe 向前倾斜(前向散射更强)。这种各向异性的视角相关性由 m13 捕获,是 LTC 矩阵三维结构的来源。

代码规模

  • 核心代码:815 行 C++17
  • 运行时间:2.15 秒(4 张 800×600,CPU 软光线追踪)
  • 关键依赖:stb_image_write.h(单头文件 PNG 写入)

代码仓库

GitHub: LTC Area Light Rendering

编译运行

1
2
3
4
# 注意:用 -O1,-O2 下有 UB 导致的段错误
g++ -O1 -std=c++17 main.cpp -o ltc
./ltc
# 输出:ltc_output.png, ltc_roughness.png, ltc_metallic.png, ltc_comparison.png

完成时间: 2026-03-17 05:36
代码行数: 815 行 C++17
迭代次数: 3 次
编译参数: g++ -O1 -std=c++17(-O2 下有 UB 段错误)
运行时间: 2.15 秒(4 张 800×600)