Babylon.js 9.0 完全解析 · 第 1 篇:光照系统三件套——Clustered Lighting、纹理面光源与体积光

上一篇我们盘点了 9.0 的全景。本篇深入第一个硬核专题:光照。9.0 一口气给出了三个光照新能力——Clustered Lighting(集群光照)、Textured Area Lights(纹理面光源)、Volumetric Lighting(体积光)。它们不是三个孤立的功能,而是分别回答了实时渲染中光照的三个经典难题:光源的数量、光源的形状、光的介质

引言:为什么这三个功能是一套组合拳

传统前向渲染的光照模型存在三个天花板:

  1. 数量天花板:每像素要遍历场景里所有光源,光源一多帧率就崩;

  2. 形状天花板:点光、聚光、平行光都是"零面积"的理想化光源,打不出柔和的矩形高光和真实的窗户透光;

  3. 介质天花板:光在真空中直线传播,没有雾、灰尘、烟雾中的散射,画面永远少一层氛围感。

9.0 的三件套恰好各自击穿一个天花板。下面逐个拆解,重点讲清原理——知道它怎么工作,才知道什么时候该用、怎么调参。

一、Clustered Lighting:让一千个光源流畅运行

1.1 问题:O(像素 × 光源) 的暴力循环

前向渲染中,每个像素的着色都要循环场景里的每一盏灯计算贡献——哪怕那盏灯根本照不到这个像素。200 万像素 × 1000 盏灯 = 20 亿次光照计算,这是纯粹的浪费。Clustered Lighting(也被称为 Forward+)的核心思想是:让引擎预先知道哪些灯影响当前像素,只算这些灯

1.2 经典三步与 Babylon 的"交集"变体

教科书式的集群光照分三步:

  1. 把相机视空间划分成 3D 网格(cluster);

  2. 对每个 cluster,算出影响它的光源列表;

  3. 着色时根据像素的屏幕位置和深度找到所属 cluster,只遍历该列表。

Babylon 没有完全照搬,而是采用了《使命召唤:无限战争》在 SIGGRAPH 上分享的变体:不做 3D cluster,而是把光源分别投影到"2D 屏幕 tile"(忽略深度)和"深度切片"(忽略屏幕位置)两个维度,着色时求两者的交集。这样做的好处是:2D tile 维度可以复用传统光栅化管线和 fragment shader 来完成,深度维度简单到可以直接跑在 CPU 上。

        屏幕空间 64×64 tiles          深度方向 16 slices
        ┌──┬──┬──┬──┐                ── slice 0 (近)
        │🕯 │  │🕯 │  │                ── slice 1
        ├──┼──┼──┼──┤      ∩         ── slice 2
        │  │🕯 │  │  │   求交集        ── ...
        └──┴──┴──┴──┘                ── slice 15 (远)
              ↓
     像素最终只遍历 tile ∩ slice 里的灯

1.3 Tiled 维度:用"光代理"渲染位掩码

Babylon 默认把屏幕分成 64×64 = 4096 个 tile。怎么把灯装进 tile?答案是给每盏灯渲染一个光代理(light proxy)——一个按光源 range 缩放的方形 mesh,proxy 覆盖到哪个 tile,就在哪个 tile 的位掩码(bitmask)里置上对应的位。

有意思的是,这个"置位"动作在两种后端上用了完全不同的技巧,堪称本功能最精彩的工程细节:

  • WebGPU:位掩码存在一块 横向tiles × 纵向tiles × 批次数 的 I32 storage buffer 里,proxy 的 fragment shader 用 atomic OR 操作置位。proxy 不输出任何颜色,fragment shader 纯粹为了"副作用"而执行。

  • WebGL2:没有 storage buffer,也没有原子写。Babylon 另辟蹊径,利用渲染管线的混合阶段:proxy 输出自己的位值,通过加法混合累加到一张浮点 render target 上。但浮点数无法精确表达 32 个不同的位——尾数只有 23 位,所以 WebGL 下每批最多 23 盏灯;超过就增加批次,把 render target 纵向扩展,proxy 在顶点着色器里按批次号偏移。

还有一个隐蔽的坑:保守光栅化(conservative rasterization)。默认情况下,只有像素中心被三角形覆盖时 fragment shader 才会执行,但光代理可能只擦到某个 tile 的边——这个 tile 依然需要被置位。业界标准解法是保守光栅化,可惜 WebGL 和 WebGPU 都不暴露这个特性。Babylon 的方案是在顶点着色器里把 proxy 顶点向远离中心的方向取整,保证 proxy 至少覆盖到它碰到的每个 tile。

1.4 深度维度:CPU 上的排序切片

深度聚类简单得多:把相机的 minZmaxZ 默认切成 16 片,每片记录与之相交的光源的最小/最大索引(光源每帧按到相机的距离排序,排序开销在 CPU 上可忽略),这样 fragment shader 拿到的是一段 bitmask 范围。

最终着色时的交集循环(文档给出的简化版)长这样:

for (int i = firstBatch; i <= lastBatch; i += 1) {
    uint mask = getBatchMask(i);
    if (i == firstBatch) { /* 清掉起始位之前的位 */ }
    if (i == lastBatch)  { /* 清掉结束位之后的位 */ }
    while (mask != 0u) {
        int trailing = firstTrailingBit(mask);
        mask ^= 1u << trailing;                    // 清掉已处理的位
        SpotLight light = getClusteredSpotLight(i * batchSize + trailing);
        // 计算这盏灯的光照贡献
    }
}

1.5 上手代码

Clustered Lighting 官方演示:数百盏动态光源照亮的夜间遗迹

在 Babylon 里,集群光照被设计成一种光源容器,把点光/聚光加进去即可:

// 创建灯时务必传 dontAddToScene: true,避免灯被重复加入场景
const lights: BABYLON.PointLight[] = [];
for (let i = 0; i < 500; i++) {
  const p = new BABYLON.PointLight(
    `pl${i}`,
    new BABYLON.Vector3(rand(-50, 50), rand(0, 10), rand(-50, 50)),
    scene,
    true // dontAddToScene —— 性能关键!
  );
  p.range = 15; // 给每盏灯设置合理的 range,别都顶到 maxRange
  lights.push(p);
}

// 一个容器管理所有灯
const lightContainer = new BABYLON.ClusteredLightContainer("clustered", lights, scene);

// 动态增删
lightContainer.addLight(spotLight);
lightContainer.removeLight(lights[0]);

性能提示:一次性把所有灯传给构造函数,比一盏一盏 addLight() 快得多;如果灯先被加进了场景再进容器,每帧都会有重复计算,dontAddToScene: true 是官方强调的最佳实践。

常用调参:

lightContainer.verticalTiles = 16;    // tile 数量:少 → 聚类快但每像素灯列表不准
lightContainer.horizontalTiles = 9;   //      多 → 列表准但聚类慢、显存占用高
lightContainer.depthSlices = 64;      // 深度切片数
lightContainer.maxRange = 30000;      // 灯的 range 上限,默认 16383
camera.maxZ = 100;                    // 小场景务必收紧 maxZ,让切片更密

1.6 限制与坑(重要)

  • 只支持点光和聚光;平行光、半球光仍走传统路径

  • 带阴影的灯、带投影/IES 纹理的聚光不参与聚类——因为着色前无法预知要绑哪些纹理,这类灯仍需传统方式渲染

  • 所有灯假定使用 FALLOFF_DEFAULT 衰减;PBR 材质的 physical falloff 会忽略 range,可能导致 proxy 范围小于实际影响范围产生瑕疵,需要手动调 range 或关掉 physical falloff:

for (const material of scene.materials) {
  if (material instanceof BABYLON.PBRMaterial) {
    material.usePhysicalLightFalloff = false;
    // 或者:material.useGLTFLightFalloff = true;
  }
}
  • Node Material 用户注意:必须把 view 矩阵连到 LightBlock / PBRMetallicRoughnessBlock 的 view 输入;WebGPU 下还必须用 shaderLanguage: BABYLON.ShaderLanguage.WGSL 创建材质,否则 GLSL 转 WGSL 会因 storage buffer 不支持而报错

二、Textured Area Lights:把任意图片变成光源

2.1 面光源为什么难

点光源的高光是一个点,而现实中窗户、灯箱、LED 屏投在高光地面上的是一块有形状的亮斑。数学上,面光源对着色点的贡献是对光源可见立体角的积分——这个积分对 GGX 这样的复杂 BRDF 没有解析解,蒙特卡洛采样又太贵。

2.2 LTC:把 GGX"掰"成余弦分布

实时渲染的破局点是 Eric Heitz 等人 2016 年提出的 LTC(Linearly Transformed Cosines,线性变换余弦)。思想非常优雅:

  1. 余弦分布(Lambert)对任意多边形的立体角积分有解析解——可以把多边形每条边的贡献用边缘积分公式累加;

  2. 那么反过来,用一个 3×3 线性变换矩阵 M 把余弦瓣"捏"成 GGX 瓣的形状,GGX 对多边形的积分就等价于"逆变换后的多边形"对余弦分布的积分

  3. 矩阵 M 只取决于粗糙度和视角(N·V),可以预先算好存进查找表。

工程上就是两张 64×64 的 LUT 纹理LTC1 存逆矩阵 M⁻¹ 的四个参数,LTC2 存 GGX 归一化幅度、Fresnel 项和地平线裁剪的 form factor 校正。着色时查表重建矩阵、变换多边形顶点、累加四条边的边缘积分——全程无循环采样,一次解析求值。这就是 Babylon 的 RectAreaLight(8.0 引入)背后的原理。

2.3 9.0 的新能力:emissionTexture

Textured Area Lights:全息标牌纹理作为矩形面光源的发光内容,注意光在墙面上的彩色投射

8.0 的 RectAreaLight 只能发均匀颜色的光。9.0 给它加上了发光纹理:任意图片都可以成为矩形面光源的发光内容,实现彩色玻璃投影、LED 面板、电影级布光等效果,且保持物理正确的发光。

但 LTC 的解析积分建立在"整个多边形 radiance 均匀"的假设上,引入纹理后假设被打破。Babylon 的解法是预处理:把发光纹理预先转换成 LTC 框架下可被着色器高效采样的数据形式。这就是 emissionTexture 必须经过预处理才能使用的原因。

官方给了两条路径:

  • 运行时处理(原型用):AreaLightTextureTools 工具类,1024/2048 尺寸的纹理处理一次需要数秒

  • 离线处理(生产推荐):官方在线工具 Babylon Texture Tools 的 "Area Light" 页签,拖入 PNG 点 Render,产出的纹理直接可用

// 工具实例可复用,处理多张纹理
const textureProcessor = new BABYLON.AreaLightTextureTools(engine);

const light = new BABYLON.RectAreaLight(
  "areaLight",
  new BABYLON.Vector3(0, 1, 0),
  2, 2, // 宽、高
  scene
);

const emissionTexture = new BABYLON.Texture("stained-glass.png", scene);
// 预处理后的纹理才能赋给 emissionTexture
light.emissionTexture = await textureProcessor.processAsync(emissionTexture);

2.4 使用注意

  • RectAreaLight 向自身 -Z 方向发光,类本身没有 direction 参数,需要挂到 TransformNode 上做旋转

  • StandardMaterial 对面光源的响应用的是 roughness 而非 specular power,想调出正确高光必须设置 roughness

  • 目前不支持阴影——官方表示还没找到好的实现方案,暂无路线图。需要遮挡效果时,可以配合烘焙 AO 或假阴影贴片

  • 序列化场景时,由于预处理纹理没有源 URL,像素会以 base64 内嵌进场景文件,注意文件体积

三、Volumetric Lighting:真正穿过介质的光

3.1 先分清新旧两套"体积光"

Babylon 早就有个 VolumetricLightScatteringPostProcess,但那是屏幕空间后处理:从光源的屏幕位置向外做径向模糊,模拟"看向太阳时的光柱"。它有两个硬伤——光源移出屏幕效果就消失,且完全没有三维介质概念。

9.0 的新体积光是真·三维参与介质散射,但有一个重要前提:它只以 Frame Graph 任务的形式提供FrameGraphVolumetricLightingTask),不能用传统后处理管线挂载。

3.2 原理:挤出光体积

新体积光的实现基于 GPU Zen 1 收录的文章《Participating media using extruded light volumes》。它不走昂贵的 ray marching,思路是:

  1. 从 shadow map 挤出体积:利用灯的 shadow map 深度信息,构建一个代表"光能到达的区域"的体积 mesh(FrameGraphLightingVolumeTask 负责生成);

  2. 双面渲染求路径长度:渲染这个体积 mesh 时,对每条视线,背面深度减正面深度就是光线穿过介质的路径长度;

  3. 解析积分散射:沿路径解析计算单次散射——透射率用 extinction 系数的指数衰减,散射方向分布用 Henyey-Greenstein 相函数(phaseG 即 HG 的 g 参数,控制前向/后向散射倾向)。

性能差异也藏在这里:WebGPU 下用 compute shader 直接从 shadow map 更新体积顶点;WebGL2 没有这条路,只能把 shadow map 回读到 CPU 再更新——所以 WebGL 下必须降低体积更新频率(每 4 帧一次)和网格细分度。

3.3 上手代码:Frame Graph 任务链

Volumetric Lighting:体积光在工业场景中形成的真实光柱,基于 WebGPU compute shader 实现

// 前置:已创建 shadowGeneratorTask 和 renderTask(物体渲染任务)

// 任务 1:从阴影生成器挤出光体积 mesh
const lightingVolumeTask = new BABYLON.FrameGraphLightingVolumeTask(
  "lightingVolume", frameGraph
);
lightingVolumeTask.shadowGenerator = shadowGeneratorTask;

const lightVolume = lightingVolumeTask.lightingVolume;
// WebGPU 每帧更新 + 高细分;WebGL 降频 + 低细分
lightVolume.frequency = isWebGPU ? 1 : 4;
lightVolume.tesselation = isWebGPU ? 1024 : 256;
frameGraph.addTask(lightingVolumeTask);

// 任务 2:体积光渲染
const volumetricLightingTask = new BABYLON.FrameGraphVolumetricLightingTask(
  "volumetricLighting", frameGraph,
  true // enableExtinction
);
volumetricLightingTask.targetTexture = renderTask.outputTexture;
volumetricLightingTask.depthTexture = renderTask.outputDepthTexture;
volumetricLightingTask.camera = camera;
volumetricLightingTask.lightingVolumeMesh = lightingVolumeTask.outputMeshLightingVolume;
volumetricLightingTask.light = light;
volumetricLightingTask.lightPower = new BABYLON.Color3(0.8, 0.8, 0.8);
volumetricLightingTask.extinction = new BABYLON.Vector3(0.01, 0.01, 0.03); // RGB 三通道独立衰减
volumetricLightingTask.phaseG = 0.05; // 相函数 g:接近 0 各向同性,接近 1 强前向散射
frameGraph.addTask(volumetricLightingTask);

调参直觉:extinction 控制介质的"浓稠度"(蓝色通道给高一点就是经典的丁达尔蓝调),phaseG 控制光柱的方向感——正对光源看时光柱是否明显增强。

四、三件套协同:一个实战配置思路

功能解决的问题典型场景主要开销
Clustered Lighting光源数量城市夜景、走廊灯带、技能特效聚类 pass + 每像素位掩码遍历
Textured Area Lights光源形状窗户、灯箱、摄影棚柔光箱每灯一次 LTC 解析求值
Volumetric Lighting光的介质森林丁达尔、舞台灯、地下设施体积 mesh 更新 + 全屏体积 pass

一个"夜雨都市"场景的典型搭配:路灯/霓虹灯牌(数百盏)进 ClusteredLightContainer 管性能;橱窗和广告牌用带 emissionTexture 的 RectAreaLight 管质感;主角登场时的聚光灯柱用 Frame Graph 体积光管氛围。三者互不冲突,各自的作用域和开销清晰。

五、小结与下篇预告

光照三件套标志着 Babylon.js 的光照体系从"够用"跨进"现代":集群光照把 Forward+ 带进了 Web 双后端,LTC 面光源补上了形状真实感,挤出体积法则用巧妙的几何技巧绕开了 ray marching 的成本。

值得注意的是,体积光已经强制依赖 Frame Graph——这不是偶然,而是官方释放的明确信号:复杂渲染效果的载体正在从后处理链迁移到 Frame Graph。下一篇我们就拆解这个 9.0 正式转正的核心基础设施:Frame Graph 渲染管线——DAG 如何组织渲染任务、为什么能省 40% 显存、以及如何把默认管线一步步改造成自定义管线。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值