Banner

深入 LTC 实时面光源渲染:从微积分原理到 URP 落地

💡核心思路

把昂贵的「BRDF 在球面多边形上的二维积分」,先用一个 矩阵 线性变换到一个理想的余弦分布空间;再用 Stokes 定理把面积分降维成沿多边形边的一维线积分;最后通过 预计算 LUT 把整套流水线压进一次纹理采样 + 几条 acos 调用。

LTC(Linearly Transformed Cosines)是 Eric Heitz 等人 2016 年在 SIGGRAPH 上推出的一套实时面光源算法 [1]。它在物理正确性、运行时开销、实现复杂度三者间找到了一个非常优雅的平衡点,目前已经是 Unity HDRP、Unreal Engine、以及众多自定义管线(VRChat 的 LTCGI 等)的事实标准实现。

本文沿着 「数学动机 → 数学工具 → 工程落地」 的脉络,把 2016 原始论文 + 2017 年三篇扩展(线光源 / 圆盘光源 / 椭球立体角)梳理成一份可以直接对照源码阅读的笔记。


一、引言:为什么实时面光源这么难?

在传统点光源 / 方向光中,光照贡献只需要计算 BRDF 在单一方向上的值;可是真实世界几乎不存在数学意义上的点光源 —— 灯管、灯箱、显示屏,本质上都是 面光源(Area Light)。物理正确的面光源着色,意味着我们要对一个二维空间区域上的所有光线方向求积分:

其中 是光源在着色点上半球上张成的立体角域, 是 BRDF(如 GGX)。论文 [1] 把这件事的两大难点说得很直白:

Problem 1:球面多边形上的参数球分布积分,即使最简单的分布也很难。
Problem 2:现代 PBR 材质(GGX 微表面 BRDF)的形状非常复杂 —— 各向异性拉伸、偏斜(skewness),数值积分在实时场景里成本爆炸。

LTC 的破局思路非常工程化:与其在原 BRDF 空间积分,不如把它”扭”回一个我们解析上有解的简单分布。下图是论文给出的著名示意(GGX 与近似 LTC 的逐角度对比):

GGX vs LTC 拟合对比
GGX vs LTC 拟合对比

二、数学基石:线性变换余弦分布

2.1 从一个简单的”种子”开始

LTC 的母分布是一个 clamped cosine 分布

它只是上半球的余弦衰减,朗伯漫反射的 BRDF 就是它本人。这个分布有两个关键的”好性质”:

性质 是否成立
球面多边形上的积分有解析闭式解 ✅ 这就是经典的 Lambert form factor
重要性采样有解析公式 ✅ 余弦半球采样

2.2 用矩阵 给余弦分布”动整形手术”

对任意方向向量 ,定义新分布:

其中第二项是球面变换的 Jacobian(论文附录有完整推导)。换句话说:对方向向量做线性变换,分布就被对应地拉伸 / 旋转 / 偏斜了

通过控制 的不同分量,可以独立调节:

参数 矩阵表现 几何效果
粗糙度 roughness lobe 等比例展宽 / 收紧
各向异性 anisotropy lobe 在切线方向呈椭圆形
偏斜性 skewness 含非对角项(shear) lobe 偏离法线方向倾斜

2.3 关于 “Skewness” 的一些澄清

skewness 在图形学中并不是一个统一专指的术语[2],必须看上下文。在 LTC 这里,它指的是:矩阵 非对角分量(shear / 斜切) 让分布的”轴线”不再与法线对齐 —— 这正好对应于物理上的现象:当观察方向掠射时,GGX 的高光峰值并不在镜面反射方向,而是会因为掩蔽-阴影项 偏向更靠近表面的方向。

几何上: 中的非正交成分 shear;
统计上:变换后的概率分布不再左右对称;
渲染上:高光在掠射时呈”拉长且偏移的彗尾”。

LTC 的精妙之处是 —— 这三件事可以用同一个 矩阵全部表示。

2.4 交互式可视化

下面这个 Three.js 组件复刻了原作者博客里的几张 GIF,但现在可以任意组合这三类形变。距离原点的半径 ,颜色随强度从蓝→黄→红渐变。

📐 Linearly Transformed Cosine — 实时形变可视化

M = [[1.00, 0.00, 0.00], [0.00, 1.00, 0.00], [0.00, 0.00, 1.00]]

鼠标可以拖动旋转视角;球面颜色 = 强度(蓝→白→红),黑色细线 = 重要性采样得到的 LTC 主导方向,右下角的红色立方体 = 矩阵 作用于单位立方体的视觉化。试着先调 roughness 看红色 lobe 收缩成一点,再加 skew 看它如何向一侧倾斜 —— 这正是 GGX 在 grazing angle 下的真实行为。


三、降维打击:从面积分到线积分

LTC 的第二个支柱是把球面多边形的面积分变成边的线积分。这一节先解释几何直觉,再展示对应的着色器实现。

3.1 多边形辐照度的几何直觉

Heitz 在 2017 年的报告 [3] 给出了一个非常漂亮的几何推导。结论先放:球面多边形 的辐照度(即对其漫反射的 form factor)等于:

它的几何含义是:

多边形在单位圆盘上的投影面积 可以分解为多条边各自对应的 disk sector(披萨切片)的有符号投影面积之和

每条边 定义一个角度 和一个法线 ;这个 disk sector 投影到地平面的有符号面积就是 ,再除以单位圆面积 就是辐照度贡献。

下图把这个几何分解展示得很清晰:

▶ 鼠标悬停在三角形顶点上可以拖动 (Demo)
E(P) = 边 v₁v₂ + 边 v₂v₃ + 边 v₃v₁ (有符号) 原始多边形辐照度 单位圆盘 (地平面) v₁ v₂ v₃ = 三个 disk sector 之和 +I₁₂ +I₂₃ −I₃₁ 绿=正贡献 / 红=负贡献
图示:多边形辐照度等于其边对应的有符号 disk sector 之和(Heitz 2017)

3.2 这其实是 Stokes 定理在做事

经典 Stokes 定理告诉我们:

在我们这里,原本的 球面多边形上的二维面积分 在多边形上的积分),通过寻找一个合适的向量场 满足 ,被精确地转化成 沿多边形边界的一维线积分。这与 disk sector 几何分解是同一件事在两套语言中的表述:

  • 几何派:把多边形的投影面积分解为以原点为顶点、以边为底的”扇形”贡献;
  • 分析派:用散度定理把面积分写成边界上的线积分,每条边算一次 acos + cross + dot

工程上最终落到 shader 里的 IntegrateEdge 就是这条边的解析贡献:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float3 IntegrateEdge(float3 v1, float3 v2)
{
float x = dot(v1, v2);
float y = abs(x);

// 拟合: theta / sin(theta) 的有理多项式逼近 (Hill 2016)
float a = 0.8543985 + (0.4965155 + 0.0145206*y)*y;
float b = 3.4175940 + (4.1616724 + y)*y;
float v = a / b;

float theta_sintheta = (x > 0.0) ? v
: 0.5 * rsqrt(max(1.0 - x*x, 1e-7)) - v;

return cross(v1, v2) * theta_sintheta;
}

每条边的输出是一个 3D 向量;最后把所有边的向量累加,再 dot 上 (即取 z 分量),就拿到了多边形辐照度。

1
2
3
4
5
6
float3 sum = 0.0;
sum += IntegrateEdge(L[0], L[1]);
sum += IntegrateEdge(L[1], L[2]);
sum += IntegrateEdge(L[2], L[3]);
sum += IntegrateEdge(L[3], L[0]);
float irradiance = max(0.0, sum.z); // 也可以用 form factor 形式取模 |sum| / (2π)

3.3 地平线裁剪:把光源砍到上半球

由于积分定义在 上半球,必须先把光源在地平面以下的部分剪掉。在变换后的余弦空间这一步可以非常便宜地做:

原始光源(部分在地下) z = 0 逐边裁剪 裁剪后的可见多边形 z = 0 新顶点
图示:四边形光源穿过地平线时,需要在 z=0 平面插入新顶点(最多 5 边形)

裁剪后的多边形最多变成 5 个顶点(一个四边形最多被一条直线切出 5 个新顶点的情况),shader 里通常用查表的方式分支处理:

1
2
3
4
5
6
7
8
9
10
// 论文配套实现常用的 clipping table 思路
int n = 0;
if (L[0].z > 0.0) L_clipped[n++] = L[0];
if (L[1].z > 0.0) {
if (L[0].z <= 0.0) L_clipped[n++] = (L[0]*L[1].z - L[1]*L[0].z) / (L[1].z - L[0].z);
L_clipped[n++] = L[1];
} else if (L[0].z > 0.0) {
L_clipped[n++] = (L[0]*L[1].z - L[1]*L[0].z) / (L[1].z - L[0].z);
}
// ... 对 L[2], L[3] 重复

四、扩展与进阶:2017 年的三篇增量论文

LTC 2016 主要面向多边形光源;2017 年 Heitz 团队又陆续放出三篇文章,把同一套思想推广到更多形状。

4.1 线光源(Line Lights)—— GPU Zen 2017

把”半径无限小的圆柱”近似成线段,避免对 维度积分 [4]。线积分有解析闭式:

其中

工程上的关键是 invariance 仍然成立:把线段端点用 变换后,在余弦空间用上式算出 irradiance,再乘以一个宽度因子

1
2
3
4
5
6
7
8
9
float I_ltc_line(vec3 p1, vec3 p2) {
vec3 p1o = Minv * p1;
vec3 p2o = Minv * p2;
float I_diffuse = I_diffuse_line(p1o, p2o);

vec3 ortho = normalize(cross(p1, p2));
float w = 1.0 / length(transpose(inverse(Minv)) * ortho); // 宽度因子
return w * I_diffuse;
}

适用条件(来自论文测试):

  • ✅ 圆柱半径较小、距离较远、材质较粗糙
  • ❌ 高镜面 + 大半径 + 近距离 — 此时线段近似明显失真,需要回退到圆柱 / 圆盘端帽建模

4.2 圆盘光源 & 椭球立体角 [5]

2017 论文 Analytical calculation of the solid angle subtended by an arbitrarily positioned ellipsoid 给出一个让我反复称赞的几何巧思:

任意椭球对原点张成的立体角域 把椭球用 变成单位球后取球冠 把球冠对应的圆盘再用 反变换回去得到一个椭圆。

下面是论文里的核心流程图:

椭球 M⁻¹ → 球(取球冠) → 球冠对应圆盘 圆盘 M → 等立体角椭圆
Heitz 2017:椭球 → 球 → 圆盘 → 椭圆,立体角守恒下的四步降维

这个性质在 LTC 框架里特别有用:所有”圆盘光源” / “球状光源”都可以归约为一次”椭圆光源积分”,再走 LTC 标准流程,避免对椭球做昂贵的解析积分。

4.3 球冠保形参数化(Spherical Cap Preserving)[6]

进一步推广到任意球面分布,处理分布之间的重叠 / 相交问题,是 LTC 后续做 多光源混合 / 阴影体积近似 的理论基础,本文不展开。


五、工程落地:Unity URP 中的 LTC

5.1 LUT 生成(离线预计算)

实时阶段我们不解非线性方程,只查表。需要预计算的 LUT 由两张 RGBA32F 纹理组成:

LUT 内容 取值意义
LUT_M 矩阵 的 4 个非零分量 — GGX 各向同性时只有这 4 个非零项
LUT_AmpFresnel 振幅 + 菲涅尔

索引方式:

1
2
3
4
float theta = acos(dot(viewDir, normal));
float roughness = 1.0 - smoothness;
float2 uv = float2(roughness, theta * (2.0/PI));
uv = uv * LUT_SCALE + LUT_BIAS; // 像素中心偏移,避免线性插值跨越边界

⚠️ LUT_SCALE / LUT_BIAS 的设置非常关键 —— 64×64 的 LUT 时通常是 LUT_SCALE = 63.0/64.0; LUT_BIAS = 0.5/64.0;

5.2 URP 集成的最小骨架

下面是一个在 URP 自定义 Pass 里集成 LTC 矩形面光源的关键片段(仅展示关节点):

1
2
3
4
5
6
7
8
9
10
11
// === C# 端:把光源数据塞进 CBuffer ===
public struct LTCAreaLightData {
public Vector4 positionWS;
public Vector4 right; // half-width 已乘进去
public Vector4 up; // half-height 已乘进去
public Vector4 colorIntensity;
}

cmd.SetGlobalTexture("_LTC_Matrix", ltcMatrixLUT);
cmd.SetGlobalTexture("_LTC_AmpFresnel", ltcAmpLUT);
cmd.SetGlobalConstantBuffer(ltcCB, "LTCAreaLights", 0, sizeOf);
C
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
// === Shader 端:LTC.hlsl ===
float3 LTC_Evaluate(
float3 N, float3 V, float3 P,
float3x3 Minv,
float3 points[4]) // 光源四顶点 (世界空间)
{
// 1) 构建 TBN,把光源顶点变换到 normal-aligned 空间
float3 T1 = normalize(V - N * dot(V, N));
float3 T2 = cross(N, T1);
Minv = mul(Minv, transpose(float3x3(T1, T2, N)));

float3 L[5];
L[0] = mul(Minv, points[0] - P);
L[1] = mul(Minv, points[1] - P);
L[2] = mul(Minv, points[2] - P);
L[3] = mul(Minv, points[3] - P);

// 2) 地平线裁剪
int n;
ClipQuadToHorizon(L, n);
if (n == 0) return 0.0;

// 3) 边缘积分
L[0] = normalize(L[0]); L[1] = normalize(L[1]);
L[2] = normalize(L[2]); L[3] = normalize(L[3]);
if (n == 5) L[4] = normalize(L[4]);

float3 sum = 0;
sum += IntegrateEdge(L[0], L[1]);
sum += IntegrateEdge(L[1], L[2]);
sum += IntegrateEdge(L[2], L[3]);
if (n >= 4) sum += IntegrateEdge(L[3], L[(n==5)?4:0]);
if (n == 5) sum += IntegrateEdge(L[4], L[0]);

// 4) form factor 形式(双面光源用 |sum|,单面用 max(0, sum.z))
return float3(abs(sum.z) / (2.0 * PI));
}

5.3 双重贡献:漫反射 + 镜面反射

最终的 BRDF 评估需要分两路调用:漫反射用恒等矩阵 ,镜面反射用 LUT 查到的 ,再各自乘以振幅:

1
2
3
4
5
6
float3 specular = LTC_Evaluate(N, V, P, Minv_GGX, lightVerts);
specular *= specColor * ltcAmp.x + (1 - specColor) * ltcAmp.y; // Schlick 近似

float3 diffuse = LTC_Evaluate(N, V, P, identity3x3, lightVerts);

float3 result = lightColor * lightIntensity * (specular + diffuse * baseColor);

5.4 性能数据

来自 GPU Zen 论文的测试(NVIDIA Quadro M6000,1920×1080,Sponza 场景):

光源类型 主光照 Pass 耗时
四边形 LTC 0.58 ms
线光源 LTC 0.42 ms

线光源因为只走一维线积分,比多边形便宜约 28%,对于走廊里大量灯管的场景非常划算。


六、实现踩坑清单

下面这些点是我自己集成 LTC 时反复掉过的坑:

  1. TBN 顺序Minv * transpose(TBN) 还是 transpose(TBN) * Minv 取决于你用的是行向量还是列向量约定,selfshadow 参考实现是后者。
  2. LUT 上下颠倒:原作者的预计算脚本生成的 LUT 是 沿 V 轴递增,但 Unity 的 RT 默认 V 轴朝下,记得 1.0 - uv.y 或在脚本中翻一次。
  3. 裁剪后顶点数判断:5 边形情况下要多一次 IntegrateEdge 调用,不能写死成 4 次循环。
  4. double-sided:默认 LTC 是单面光源(朝法线方向发光),双面要把 max(0, sum.z) 改为 abs(sum.z)
  5. Fresnel 拟合:LUT 第二张里的两个分量是 Schlick 近似的两个端点,公式是 F0 * f1 + (1-F0) * f2,不是 F0 + (1-F0)*pow(1-NoV, 5)

七、总结

LTC 的工程价值,本质是把”积分难点”在三个层级上各打掉一刀:

层级 难点 LTC 的对策
分布层 GGX 形状复杂 拟合到余弦空间 → 解析可积
积分层 球面多边形面积分 Stokes 定理 → 边界线积分
运行时 实时不能解非线性 预计算 到 LUT,运行时一次纹理采样

它不是数值最精确的方法,但在 “看上去对” + “够快” + “易实现” 三个维度上几乎是当下最优解。Unity HDRP 的所有 area light、Unreal 的 Rect Light、VRChat 的 LTCGI 都基于此,足以说明它在产业化上的稳健。

一个有趣的延伸思考:LTC 的”线性变换 + 解析积分”思想在很多地方都能复用,比如:

  • 光锥(cone-traced)GI 的 cone footprint 拟合
  • 法线分布在 mip 链中的 NDF prefilter
  • 体积云中 ray march 的相函数近似

如果你对这些延伸有兴趣,可以从 A Spherical Cap Preserving Parameterization for Spherical Distributions(同一团队 2017)开始,它是 LTC 在更通用球面分布上的推广。


📚 参考文献与延伸阅读

LTC 核心理论 (Theory)

[1]

Heitz E., Dupuy J., Hill S., Neubelt D. Real-Time Polygonal-Light Shading with Linearly Transformed Cosines. SIGGRAPH 2016.

[3]

Heitz E. Geometric Derivation of the Irradiance of Polygonal Lights. Research Report, Unity Technologies, 2017. (HAL: hal-01458129)

[4]

Heitz E., Hill S. Linear-Light Shading with Linearly Transformed Cosines. GPU Zen 2017, Chapter 1.

[5]

Heitz E. Analytical calculation of the solid angle subtended by an arbitrarily positioned ellipsoid to a point source. Nuclear Inst. and Methods in Physics Research, A 852 (2017): 10-14.

[6]

Dupuy J., Heitz E., Belcour L. A Spherical Cap Preserving Parameterization for Spherical Distributions. SIGGRAPH 2017.

配套开源实现

[7]

selfshadow/ltc_code — 原作者参考实现

[8]

guiqi134/LTC-Area-Lights — 完整 paper 复现

[9]

nscTechArt/URP-LTC-AreaLight — Unity 2022 URP 集成

[10]

PiMaker/ltcgi — VRChat 生产级实现