基于真实 IES 配光文件构建体积光柱(Volumetric Light Shafts)
傍晚薄雾中,路灯投射出独特的光影图案——这种视觉感受令人着迷。为了用代码还原它,笔者基于 Qt 6.11 的图形能力构建了一套实时体积光(Volumetric Lighting)系统,借助其 QML 着色器集成与 3D 渲染流水线,结合真实 IES 配光文件加以实现。
傍晚薄雾中的路灯光影图案
最终成果是一套能够实时渲染大气光柱、呈现真实光照分布规律的系统。这是笔者在 2026 年 1 月 Qt Hackathon 中的参赛项目,整个探索过程涉及光传输原理与着色器编程,因此希望在此与大家分享其实现思路。
从单次散射(Single Scattering)出发
光线穿过雾气或尘埃时,会与空气中的粒子发生碰撞并产生散射。理想情况下,应当模拟每一次碰撞——光线击中粒子、改变方向、再击中另一粒子,如此往复。这种方式称为多次散射(Multiple Scattering),效果精美但计算开销极高。
单次散射(Single Scattering)是更简洁的替代方案:只考虑光线与粒子发生一次碰撞后直接到达观察者眼睛的情形。实践证明,这一方案效果出人意料地好,尤其适用于薄雾或大气朦胧效果。

单次散射示意图:光线仅与粒子发生一次碰撞,随后直接到达观察者
背后的数学推导其实相当直接:对于视线所指向的任意点,只需确定三个量——该点距光源多远、该方向上的光照强度(IES 配光文件的作用正在于此)、以及光路中的雾气密度。三者相乘,即可得到散射强度。
IES 配光文件是什么?
IES 文件是一种人类可读的文本格式,用于存储灯具在各个方向上的实测光照强度,通常利用对称性压缩数据体积。
为使其适配 GPU 处理,笔者在 CPU 端完成文件解析,将其展开为覆盖完整球面方向的 360 × 181 二维图像,并存储于 3D 纹理中。每个纹理层可容纳一个独立的 IES 配光文件,QML 中的着色器可直接采样。这得益于 Qt 的可定制纹理数据机制。
运行时无需额外处理对称逻辑,直接进行纹理查询即可。由于 GPU 硬件插值可在实测角度之间平滑过渡,这一方案非常适合用纹理采样实现。

解析后的 IES 数据:水平与垂直方向均以1°展开,每个纹理像素(Texel)对应一个角度方向上的光强值
核心实现:体积散射着色器(Volumetric Scattering Shader)
这是整个系统最有意思的部分。对于每个像素,需要计算沿视线方向散射进摄像机的光照量。以下是笔者最终采用的实现方案:
float volumetricScatter(vec3 cameraPos, vec3 worldPos, vec3 lightPos, vec3 lightDir,
float density, float iesIndex, float omnidirectionality) {
vec3 rayDir = worldPos - cameraPos;
float rayLen = length(rayDir);
rayDir /= rayLen;
// Find the point on our view ray that's closest to the light
float t = clamp(dot(lightPos - cameraPos, rayDir), 0.0, rayLen);
vec3 closest = cameraPos + rayDir * t;
float dist = length(lightPos - closest);
vec3 lightToPoint = normalize(closest - lightPos);
// Check the IES profile to see how much light goes this direction
float directionality = min(sampleIES(lightToPoint, lightDir, iesIndex) + omnidirectionality,
1.0);
// Classic inverse square falloff with density and ray length
return (directionality * density * rayLen / (dist * dist + 0.0001));
}
实现的巧妙之处在于最近点的求取方式。与射线步进(Ray Marching)——即沿视线射线逐步采样——不同,这里仅需找到视线上距光源最近的一个点。这是一种近似,但速度快,且实际效果出色的计算方法。
平方反比定律负责处理距离衰减——光线传播时由于发散,强度会随距离的平方递减。乘以射线长度是因为更长的射线会积累更多散射;乘以密度参数则用于控制雾气的厚薄程度。
借助 3D 纹理的 2D 切片,IES 光照强度采样同样直观易用。
float sampleIES(vec3 lightDir, vec3 lightForward, float iesIndex) {
// Vertical angle (theta): angle from the forward direction
float theta = acos(dot(lightDir, lightForward));
// Build tangent space for horizontal angle calculation
vec3 right = normalize(cross(lightForward, vec3(0.0, 0.0, 1.0)));
vec3 up = cross(right, lightForward);
// Project direction onto a plane perpendicular to the forward
vec3 projectedDir = lightDir - lightForward * dot(lightDir, lightForward);
// Horizontal angle (phi): rotation around forward axis
float phi = atan(dot(projectedDir, up), dot(projectedDir, right));
// Convert to UV coordinates [0,1]
vec2 uv = vec2(phi / (2.0 * PI), theta / PI);
// Sample 3D texture (multiple IES profiles)
return textureLod(lightIES, vec3(uv, iesIndex / iesCount), 0).r;
}
投影光照与体积光照的融合
该系统同时渲染表面光照(投影光照)与体积效果:
// The fog effect
float scatter = volumetricScatter(cameraPosition, worldPos, lightPosition, lightForward,
scatterDensity, iesIndex, omnidirectionality);
vec3 volumetricLight = lightColor * lightIntensity * scatter;
// Regular surface lighting with the same IES profile
float iesIntensity = min(sampleIES(lightToSurface, lightForward, iesIndex) + omnidirectionality,
1.0);
float attenuation = 1.0 / (distanceToLight * distanceToLight + 0.0001);
vec3 projectedLight = lightColor * iesIntensity * attenuation * projectionIntensity;
// Add them up
finalColor += (projectedLight + volumetricLight) * distanceAttenuation;
最终效果是:表面上呈现清晰的投影光照图案,空气中则有柔和的体积光晕。两者均遵循相同的 IES 分布规律,因此视觉上高度统一。
投影光照图案与体积光晕的叠加效果
多光源管理
系统需要同时处理多种光源——路灯、交通灯、车辆大灯,每种光源都有各自的 IES 配光文件与属性参数。笔者采用的方案是:通过简单的球形包围盒对摄像机进行可见性判断,将所有可见(激活中)光源数据打包为纹理,并在每帧更新:
tempLights.push({
position: Qt.vector3d(x, y, z),
forward: Qt.vector3d(dx, dy, dz),
omnidirectionality: 0.0,
color: "lightblue",
intensity: 20,
scatterDensity: 0.4,
projectionIntensity: 100000.0,
iesIndex: 1,
visible: true
});
每个光源均有独立的散射密度(光雾效果强度)、投影强度(表面光照强度)与 IES 索引。例如,路灯可配置为较强的体积散射效果配合相对较弱的表面投影,而车辆大灯则可反之。
着色器通过 QML中的 ExtendedSceneEnvironment 后处理效果遍历所有光源并累加其贡献。这套光源管理方案并不复杂,在数十个光源的场景下运行良好。
构建Demo场景
与真实光传输相比,单次散射在物理上并不严谨——但其误差对于目标视觉效果而言基本无关紧要。
体积光照系统完成后,笔者希望在具体场景中加以验证,因此构建了一个配有程序化生成道路系统的摩托车竞速 Demo。道路基于 Catmull-Rom 样条曲线从控制点生成平滑弯道,路灯则沿路径等间距放置。
程序化道路几何体在每个道路采样点计算切线与副法线向量,确保路灯垂直于道路方向正确定位。每盏路灯从已加载的 IES 配光文件中随机取用一个,并从调色板中指定颜色。

Demo 截图:赛道上数十道彩色光柱扑面而来,速度感十足
笔者还接入了 Qt 6.11 新增的运动向量缓冲区(Motion Vector Buffer),作为另一个 ExtendedSceneEnvironment 后处理效果实现真实的运动模糊效果:
void MAIN() {
vec2 texcoord = TEXTURE_UV;
if (FRAMEBUFFER_Y_UP < 0.0)
texcoord.y = 1.0 - texcoord.y;
vec4 motionvector = texture(MOTION_VECTOR_TEXTURE, texcoord);
vec2 velocity = motionvector.xy;
if (FRAMEBUFFER_Y_UP > 0.0)
velocity.y = -velocity.y;
const int SAMPLES = 16;
const float BLUR_STRENGTH = 1.5;
vec4 color = vec4(0.0);
for (int i = 0; i < SAMPLES; i++)
{
float t = float(i) / float(SAMPLES - 1);
vec2 offset = velocity * t * BLUR_STRENGTH;
color += texture(INPUT, texcoord + offset);
}
color /= float(SAMPLES);
FRAGCOLOR = color;
}
运动向量记录了每个像素在帧间的位移方向,着色器据此沿该方向进行模糊处理。与体积光照结合后,光柱随运动模糊拖曳而过,雾夜飞驰的速度感油然而生。
为提升可玩性,笔者加入了影响道路曲率的难度模式:普通模式弯道平缓,困难模式弯道收紧。此外还有圈速计时与积分系统。整个项目逐渐演变成一个完整的小游戏——这出乎原计划,但也因此成为光照系统极好的压力测试场景:30 余盏灯同时激活、交通灯持续变换、运动模糊全速运行,是检验性能是否达标的真实考场。完整源代码已对外开放,欢迎查阅。