Skip to main content

基于Qt 6.11的轻量级实时体积光方案

评论

基于真实 IES 配光文件构建体积光柱(Volumetric Light Shafts)

傍晚薄雾中,路灯投射出独特的光影图案——这种视觉感受令人着迷。为了用代码还原它,笔者基于 Qt 6.11 的图形能力构建了一套实时体积光(Volumetric Lighting)系统,借助其 QML 着色器集成与 3D 渲染流水线,结合真实 IES 配光文件加以实现。


IES_profile_test_Qt_Hackathon_January_2026a傍晚薄雾中的路灯光影图案

 

最终成果是一套能够实时渲染大气光柱、呈现真实光照分布规律的系统。这是笔者在 2026 年 1 月 Qt Hackathon 中的参赛项目,整个探索过程涉及光传输原理与着色器编程,因此希望在此与大家分享其实现思路。

从单次散射(Single Scattering)出发

光线穿过雾气或尘埃时,会与空气中的粒子发生碰撞并产生散射。理想情况下,应当模拟每一次碰撞——光线击中粒子、改变方向、再击中另一粒子,如此往复。这种方式称为多次散射(Multiple Scattering),效果精美但计算开销极高。

单次散射(Single Scattering)是更简洁的替代方案:只考虑光线与粒子发生一次碰撞后直接到达观察者眼睛的情形。实践证明,这一方案效果出人意料地好,尤其适用于薄雾或大气朦胧效果。

SingleScatterDiagram

单次散射示意图:光线仅与粒子发生一次碰撞,随后直接到达观察者

 

背后的数学推导其实相当直接:对于视线所指向的任意点,只需确定三个量——该点距光源多远、该方向上的光照强度(IES 配光文件的作用正在于此)、以及光路中的雾气密度。三者相乘,即可得到散射强度。

IES 配光文件是什么?

IES 文件是一种人类可读的文本格式,用于存储灯具在各个方向上的实测光照强度,通常利用对称性压缩数据体积。

为使其适配 GPU 处理,笔者在 CPU 端完成文件解析,将其展开为覆盖完整球面方向的 360 × 181 二维图像,并存储于 3D 纹理中。每个纹理层可容纳一个独立的 IES 配光文件,QML 中的着色器可直接采样。这得益于 Qt 的可定制纹理数据机制。

运行时无需额外处理对称逻辑,直接进行纹理查询即可。由于 GPU 硬件插值可在实测角度之间平滑过渡,这一方案非常适合用纹理采样实现。

 

ies_texture_slice_002

解析后的 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 分布规律,因此视觉上高度统一。

 

Screenshot 2026-01-29 130022-1投影光照图案与体积光晕的叠加效果

多光源管理

系统需要同时处理多种光源——路灯、交通灯、车辆大灯,每种光源都有各自的 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 配光文件中随机取用一个,并从调色板中指定颜色。

 

Untitled-Jan-29-2026-01-51-52-3176-PM

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 余盏灯同时激活、交通灯持续变换、运动模糊全速运行,是检验性能是否达标的真实考场。完整源代码已对外开放,欢迎查阅。


评论

Subscribe to our blog

立即体验 Qt 6.12!

点击此处下载最新版本:www.qt.io/download

Qt 6.12 LTS 现已发布,为应用程序开发者和设备制造商带来了新功能和改进。

我们正在招聘

点击此处查看所有职位空缺并关注我们的Instagram账号,了解成为#QtPeople的感受。