魔改PBR渲染管线支持大量灯光场景
前言
其实本来只打算写一下我是如何通过自定义材质来实现特定场景下突破 UBO 数量限制的,但是写着写着发现很有必要捋清楚 Babylonjs 内部着色器预编译的机制,不然连我自己都不清楚我为啥要这么做🙈。
虽然我之前确实对 Babylonjs 的源码做过一些分析记录,但总体比较零散,完全无法在脑海中形成一个系统性的、清晰的流程。所以这次我就把 Babylonjs 的仓库 clone 到本地,使用 Cursor 将其作为本地知识库进行详细的源码分析,得到了多份分析报告文档,基于这些文档我才对 Babylonjs 内部一些机制有了更详细和全面的了解。
而这些报告文档我都放到了 Github 上,感兴趣的可以去看一看,我觉得从里面确实可以学到不少关于 Babylonjs 源码的思路。
需求背景
我前不久独自开发了一个 在线3D展览 的项目,该项目的主要需求就是渲染展厅模型及展厅中的大量展品,也包含大量用来给展品打光的聚光灯。
为什么要魔改渲染管线?
因为在 Babylonjs 中,对材质产生影响的每个灯光信息都会单独被封装成一个 UBO(Uniform Buffer Object),这种做法可以高效地实时传送灯光信息;但由于 UBO 所占用的 GPU 缓冲内存是很宝贵的,因此 WebGL/WebGPU 等规范都对每个着色器所使用的 UBO 最大数量进行了限制,且根据操作系统和显卡的不同,该最大数量也有不同。具体来说,WebGL 和 WebGPU 都提供了 API 来获取当前运行环境下的 UBO 最大数量;WebGL 可以通过 MAX_VERTEX_UNIFORM_BLOCKS
参数来获取:
1 | const glContext = canvas.value.getContext('webgl2')! |
而 WebGPU 则可以通过 adapter 实例来获取 [1]:
1 | const adapter = await navigator.gpu.requestAdapter() |
不过比较奇怪的是,按理说显卡的显存越多可以分配的 UBO 数量会越多?但在 WebGL2 中,我用的 Windows10 + 3070Ti(12 个)居然还比不上 mbp2020 的核显(15 个)多😅:
顺便一提,WebGL/WebGPU 规范对于不同平台下 UBO 最大数量的最低值为 12;基于该限制,结合上面提到的需求背景就不难发现该需求在 Babylonjs 的默认渲染管线下,很容易超出 UBO 的数量限制,从而导致运行时报错无法正常渲染;不同的规范报错信息略有不同:
- WebGL:
Error: VERTEX shader uniform block count exceeds GL_MAX_VERTEX_UNIFORM_BUFFERS (12)
- WebGPU:
The number of uniform buffers (13) in the Vertex stage exceeds the maximum per-stage limit (12).
所以为了能够实现上述需求,我不得不去寻求一个可以不触发 UBO 最大数量限制且同时能够渲染大量灯光的渲染方案。
背景知识:UBO
A Buffer Object that is used to store uniform data for a shader program is called a Uniform Buffer Object. They can be used to share uniforms between different programs, as well as quickly change between sets of uniforms for the same program object.
用于存储着色器程序统一数据的缓冲对象称为统一缓冲对象。它们可以用于在不同程序之间共享统一变量,以及快速切换同一程序对象的统一变量集。The term “Uniform Buffer Object” refers to the OpenGL buffer object that is used to provide storage for uniforms. The term “uniform blocks” refer to the GLSL language grouping of uniforms whose storage come from buffer objects.
“统一缓冲对象”一词指的是 OpenGL 缓冲对象,用于提供统一变量的存储。而“统一块”一词则指的是来自缓冲对象的统一变量的 GLSL 语言分组。[2]
潜在解决方案
对于上面的提到的问题,存在一些潜在的解决方案 [3],比如:
- 延迟渲染/延迟着色
- 灯光烘焙
- 自定义材质/着色器
- 利用渲染目标纹理分批处理灯光
延迟渲染(Deferred Rendering)
实际上在计算机图形业界,延迟渲染是一个很不错的解决方案。但是目前主流的 Web 3D 渲染框架(three.js 和 Babylonjs)都没有支持延迟渲染管线,不过 Babylonjs 社区有一个小组倒是一直在推进延迟渲染的探索 [4](P.s: 这个小组的讨论帖很有意思,虽然进度有点慢,但到 2025-04 为止已经持续讨论了近三年):
图中上面那位就是 Babylonjs 的核心开发者之一,而下面那位就是凭借自己的兴趣爱好一直在 Babylonjs 基础上持续开发延迟渲染管线的老哥 (?)。至于延迟渲染是什么,推荐一篇文章(作者已故,悲)——【《Real-Time Rendering 3rd》 提炼总结】(七) 第七章续 · 延迟渲染(Deferred Rendering)的前生今世 - 知乎。
在计算机图形学中,延迟渲染 ( Deferred Rendering) ,即延迟着色(Deferred Shading),是将着色计算延迟到深度测试之后进行处理的一种渲染方法。延迟着色技术的最大的优势就是将光源的数目和场景中物体的数目在复杂度层面上完全分开,能够在渲染拥有成百上千光源的场景的同时依然保持很高的帧率,给我们渲染拥有大量光源的场景提供了很多可能性。
如果我要自行在 Babylonjs 中实现延迟渲染管线,那就不是魔改了,几乎是再造一个渲染引擎了😂。所以基于现状,我放弃了延迟渲染方案。
灯光烘焙
灯光烘焙技术简单地来说就是提前把物体表面的每个像素点上的光照信息预渲染到一个贴图中,称之为光照贴图(Lightning Map);得到预渲染的光照贴图,实际渲染这些物体时就不用在着色器内部计算实时的光照了(也就不需要通过 UBO 传输灯光信息了),直接从贴图中获取光照颜色值即可。
因此灯光烘焙对于纯静态场景来说,是一种不错的选择,而我所做的展馆项目恰好就是一个纯静态的渲染场景——即场景内的灯光和物体在创建后就不会发生任何改变。然而,从我基于 Blender 对相对复杂的展厅模型进行灯光烘焙的实际体验来看,光照贴图也是有一些缺点的,比如:
模型比较大时(主要就是展厅模型本身),导致三角面数量很多,同时物体的表面积也会很大;这样在展开UV时,UV 与物体表面的映射就会密度特别大,很挤,从而导致贴图的精度大大降低,因此物体基于光照贴图渲染时会出现很明显的明暗交界线以及暗纹等现象:
上图就是对某个展厅模型进行了光照贴图为 2k(即 2048 x 2048)大小 + 最大采样数为 1024 的光线追踪灯光烘焙结果;哪怕把光照贴图改成 4k 这些现象依然很明显,但是 2k 大小的光照贴图的文件大小就已经来到 20M 左右了。
当然,针对这种问题,也不是没有改进方法;比如把大的物体模型拆分成多个很小的物体,这样就可以确保很小的一个表面就能单独使用一块 2k~4k 大小的高精度光照贴图了,但这样带来的贴图内存占用就会变得特别大了😂,而且建模和烘焙相关的工作量也会变多。
利用渲染目标纹理分批处理灯光
这种方案实际上在一开始我并没有想到,只是最近我在 Cursor 中让 Agent 帮我基于 Babylonjs 仓库中的源码进行“大量聚光灯场景下 UBO 数量限制突破方案”的可行性研究时,它所提到的一种方案,我看了之后理论上确实可以算是一种方案。
[!quote]- Cursor Agent 总结的方案
原理
将灯光分成多个批次,每个批次渲染到一个渲染目标纹理中,然后在最终组合阶段合并所有批次的结果。
实现步骤
- 创建多个渲染目标:为每批灯光创建一个渲染目标纹理
- 分批渲染:对每批灯光进行独立渲染
- 合并结果:使用后处理效果合并所有批次的结果
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60 // 创建多个渲染目标
const lightBatchCount = Math.ceil(spotLights.length / maxLightsPerBatch);
const lightBatchTextures = [];
for (let i = 0; i < lightBatchCount; i++) {
const rtt = new BABYLON.RenderTargetTexture(
"lightBatch" + i,
{width: 1024, height: 1024},
scene,
{
generateMipMaps: false,
generateDepthBuffer: true,
generateStencilBuffer: false
}
);
// 设置该批次使用的灯光
const batchStart = i * maxLightsPerBatch;
const batchEnd = Math.min((i + 1) * maxLightsPerBatch, spotLights.length);
const batchLights = spotLights.slice(batchStart, batchEnd);
// 配置RTT
rtt.onBeforeRender = function() {
// 暂时隐藏不在此批次的灯光
for (let j = 0; j < spotLights.length; j++) {
if (j < batchStart || j >= batchEnd) {
spotLights[j].setEnabled(false);
} else {
spotLights[j].setEnabled(true);
}
}
};
rtt.onAfterRender = function() {
// 恢复所有灯光状态
for (let j = 0; j < spotLights.length; j++) {
spotLights[j].setEnabled(true);
}
};
lightBatchTextures.push(rtt);
}
// 创建后处理效果合并所有批次结果
const postProcess = new BABYLON.PostProcess(
"lightBatchCombiner",
"lightBatchCombiner",
["lightBatchCount"],
lightBatchTextures.map((_, idx) => "lightBatch" + idx),
1.0,
camera
);
postProcess.onApply = function(effect) {
effect.setInt("lightBatchCount", lightBatchCount);
for (let i = 0; i < lightBatchCount; i++) {
effect.setTexture("lightBatch" + i, lightBatchTextures[i]);
}
};后处理着色器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 varying vec2 vUV;
uniform sampler2D lightBatch0;
uniform sampler2D lightBatch1;
// ... 更多批次
uniform int lightBatchCount;
void main(void) {
vec4 finalColor = vec4(0., 0., 0., 1.);
// 合并所有批次的结果
if (lightBatchCount > 0) finalColor += texture2D(lightBatch0, vUV);
if (lightBatchCount > 1) finalColor += texture2D(lightBatch1, vUV);
// ... 更多批次
gl_FragColor = finalColor;
}优势
- 适用于大量灯光场景
- 不需要修改材质或着色器
- 实现相对简单
局限性
- 每批次渲染会导致场景被多次绘制,影响性能
- 需要管理多个渲染目标
- 可能导致某些复杂效果(如反射)的计算不准确
正如 Agent 在报告中所说的,该方案最大的问题就是一帧内要渲染多个 Render Target,这会导致比较严重的性能问题,尤其是 Web 环境中的图形 API 相比操作系统底层的图形 API 来说增加了一层转换的损耗。
自定义材质/着色器
没错,我最终选择的方案就是自定义了一个 PBR 材质,在着色器源码中硬编码当前材质需要的所有灯光信息,而且这种修改是完全基于 Babylonjs 内部的渲染管线流程和着色器预编译机制的,因此总体修改量并不算特别多。
听着好像挺轻松的,不过这一切都需要建立在掌握 Babylonjs内部渲染管线和着色器预编译流程之上,因此当初我花了不少的时间去研究源码才找到上述的方法。
然而,最近我试着把 Babylonjs 仓库 clone 下来作为一个本地仓库让 Cursor 基于源码进行各种分析(这样 Cursor 就可以把 Babylonjs 的源码作为本地知识库进行索引了),效果确实出乎我的意料,几分钟就能根据源码整理出一个系统性的研究报告;当初要是有 Cursor,我就用不着花那么多时间研究源码了😂。
Babylonjs PBR 材质的着色器源码预编译流程
需要注意的是,这里的预编译指的是通过材质的各种参数来控制拼接生成不同的着色器源码,需要跟 GLSL/HLSL 这类着色器语言内的预处理进行区分,预处理则是着色器语言进行元编程的一个语法。
简单的来说,Babylonjs 内部通过把 PBR 材质本身需要用到的参数通过 #define
指令转为宏,然后通过 #ifdef/#ifndef
等指令判断宏的值来启用不同的计算代码,这样就可以确保最终的着色器代码不会有冗余的部分。
flowchart TD A[材质参数设置] --> B[材质准备过程] B --> C[定义预处理] C --> D[着色器代码组装] D --> E[着色器编译] E --> F[着色器绑定和参数传递] subgraph 材质预处理 B end subgraph 着色器定义生成 C --> C1[纹理定义生成] C --> C2[灯光定义生成] C --> C3[环境定义生成] C2 --> C2A[处理每个光源] C2A --> C2B[生成光照类型定义] C2A --> C2C[生成阴影定义] end subgraph 着色器构建 D E end subgraph 渲染阶段 F --> F1[绑定材质参数] F --> F2[绑定灯光参数] F --> F3[绑定环境参数] end
灯光信息的预编译
由于我要把所有的灯光信息硬编码到着色器源码中,因此我需要知道 PBR 材质中是如何传入灯光相关的信息,以及如何控制 UBO 数据传入着色器中(因为硬编码之后就需要移除着色器内的 UBO 相关定义,如果没有定义但渲染时还在 Program 中绑定 Uniform Buffer 就会导致着色器编译失败)。
在 Babylonjs 内部编译着色器源码之前,都会提前把每个灯光对哪些网格(Mesh)进行影响计算出来,这样这些网格的 lightSources
属性中就会记录对它有影响的灯光列表;Babylonjs 中创建的灯光默认对当前场景中的所有网格都是有影响的(这里的影响只是代表在网格材质着色器中需要对该灯光进行计算,不代表该灯光会一定照射到当前网格的表面上),但是也可以通过设置灯光的 includedOnlyMeshes/excludedMeshes/includeOnlyWithLayerMaskexcludeWithLayerMask
等属性来手动控制灯光影响范围,从而提高渲染性能;关于 Babylonjs 内部详细的灯光剔除流程,可以参考 Babylon.js灯光剔除机制源码分析.md。
基于当前网格的 lightSources
属性记录的灯光列表,在预编译着色器时就会把这些灯光信息转换为对应类型的自定义结构、宏变量和 Uniform Buffer。
灯光在着色器内的自定义 UBO 结构
1 | // packages/dev/core/src/Shaders/ShadersInclude/lightUboDeclaration.fx |
从上述源码不难看出,Babylonjs 通过灯光的编号 {X}
来定义该灯光的 UBO 结构,同时根据具体灯光类型如 SPOTLIGHT{X}
、POINTLIGHT{X}
和 HEMILIGHT{X}
等来添加该类型灯光特有的属性,最后就得到了一个类型为 Light{X}
的 light{X}
变量。
Uniform Buffer 绑定传输机制
Babylonjs 内部有一个 UniformBuffer
类,该类是专门用来管理 UBO 数据的绑定、传输和更新相关的逻辑;而每个灯光内部正好都有一个 UniformBuffer 的实例用于管理灯光 UBO 的数据,通过内部的 _bindLight 方法绑定到着色器程序中(Babylonjs 内部是 Effect 类的实例):
1 | public _bindLight(lightIndex: number, scene: Scene, effect: Effect, useSpecular: boolean, receiveShadows = true): void { |
可以看到上述代码直接绑定了着色器源码中的 Light{X}
,而这个 Light{X}
就是 WebGL2 着色器语法中 Uniform Block 的名称,而 Uniform Block 的具体语法如下 [5]:
1 | uniform <BlockName> { |
因此着色器源码中的 light{X}
就是 Uniform Block 的实例名称,当定义了实例名称之后就可以用实例名称来访问 Uniform Block 中的具体数据了。
值得注意的是,Babylonjs 内部在绑定 UBO 数据时会检查着色器源码中对应的 Uniform Block 声明是否存在,不存在就不会进行后续的绑定和传输,不然会引起着色器编译失败。
Babylonjs 着色器预编译内部机制
在分析源码时,我发现 Babylonjs 在进行着色器预编译的过程中,设计了一些独特的机制来提高着色器预编译的效率,比如:
- 着色器编程语言中的自定义语法:
#include<...>
指令 - IncludesShadersStore、ShadersStore
#include<...>
指令
Babylonjs 实际上通过 #include<...>
指令给着色器编程语言加上了一套简单的模块系统(因为 GLSL 这类着色器编程语言没有设计模块相关的语法),而这个指令又有一些变体,详细的语法如下 [6]:
- 基本包含语法:
#include<文件名>
,文件名不需要带文件后缀 - 带参数替换的包含语法:
#include<文件名>(参数1,替换值1,参数2,替换值2,...)
- 迭代式包含语法:
#include<文件名>[起始索引..结束索引]
迭代语法内部处理机制
以 #include<__decl__lightFragment>[0..maxSimultaneousLights]
为例,处理流程如下:
- 识别出包含文件名为
__decl__lightFragment
- 处理
__decl__
前缀,根据是否支持 Uniform Buffer Objects(UBO) 决定使用哪个版本的声明文件 - 解析迭代范围
[0..maxSimultaneousLights]
,从配置参数中获取maxSimultaneousLights
的实际值 - 加载对应的着色器包含文件
- 对该文件进行迭代处理,为每个索引值 (从 0 到 maxSimultaneousLights-1) 生成一个副本
- 在每个迭代副本中,替换所有的
{X}
占位符为当前索引值 - 将所有生成的代码片段连接起来,替换原始的包含指令
1 | // 迭代式包含指令处理的关键代码片段 |
__decl__ 文件名前缀
__decl__
是 Babylonjs 内部的一个特殊的文件名前缀,当识别到这个前缀时,其内部会把文件名(__decl__XXX
)自动替换为:
XXXDeclaration
XXXUboDeclaration
:当 XXX 为 XXXFragment 或为 XXXVertex 时
参数替换
除了迭代处理,Babylonjs 的包含指令系统还支持参数替换,这对于生成通用着色器代码模板非常有用:
1 | // 示例:带参数替换的包含指令 |
这会将包含文件中的 _DEFINENAME_
替换为 ALBEDO
,将 _VARYINGNAME_
替换为 Albedo
,从而为特定纹理类型生成专用代码。
结合迭代和参数替换功能,Babylonjs 能够高效地生成复杂但高度优化的着色器代码,这是其 PBR 渲染系统性能和灵活性的关键因素。
ShadersStore 与 IncludesShadersStore
这两个是挂载在 Effect
类上的静态属性,用于注册着色器文件,其值为一个普通对象,key 为着色器文件名,value 就是着色器源码文本:
1 | // Effect类中的定义(简化) |
他们两者的区别就是,IncludesShadersStore 中注册的着色器源码内不能使用 #include<...>
指令,大概是被设计专门存放那些可以被用于导入的着色器文件吧(无副作用);而 ShadersStore 中注册的着色器源码中则可以使用 #include<...>
指令来导入那些在 IncludesShadersStore 注册过的着色器文件。
魔改方案
为什么我要花那么大的篇幅去讲 Babylonjs 内部的着色器源码预编译流程呢?因为只有熟悉了这些流程,就知道我这种魔改方案背后的原理了。
我的核心思路就是直接继承 Babylonjs 内的 PBR 材质类,实现一个自定义的 PBR 材质类,然后把场景中需要使用硬编码灯光的材质全部替换成自定义的 PBR 材质;听起来很简单是不是?实际上这些工作花了我很多的时间才打通整个流程😂……
主要的工作就是重写了两个跟灯光 UBO声明相关的着色器源码以及自定义 PBR 材质的顶点着色器和片元着色器(也是在原有的 PBR 材质着色器源码上进行了略微的修改),然后在 IncludesShadersStore 和 ShadersStore 中进行覆盖和注册。
整个的魔改方案涉及到的细节还是很多的,以至于我自己在过了几个月后再看之前自己写的代码也有点迷糊,于是乎我让 Cursor 帮我进行了分析总结:
得到了一篇质量很不错的分析报告,所以下面我就把这篇报告的内容直接放进来(偷个懒🙈):
简介
本文档对 CustomPBRMaterial 进行详细分析,探讨其对 Babylon.js 默认 PBR 渲染管线所做的调整,这些调整所使用的 Babylon.js 内部机制,以及这些调整的目的和实现方式。
核心功能与目的
CustomPBRMaterial 是一个继承自 PBRMaterial 的自定义材质类,其主要目的是解决在大量灯光(特别是聚光灯)场景下的渲染问题,具体包括:
- 灯光筛选:允许材质选择性地响应场景中的灯光,而不是默认响应所有灯光
- 自定义着色器:使用自定义的顶点着色器和片段着色器,以适应特定的渲染需求
- 材质复用:提供从现有 PBRMaterial 创建 CustomPBRMaterial 的机制,保留原有材质的属性
- 灯光硬编码:通过将灯光信息直接硬编码到着色器中,避免通过 UBO 传输,解决大量灯光场景的 UBO 数量限制问题
这些调整主要针对 3D 展览类场景,通常这类场景包含大量固定聚光灯,容易超出 WebGL/WebGPU 的 UBO(Uniform Buffer Objects)数量限制。
渲染流程
flowchart TD A[场景中的PBRMaterial] --> B[转换为CustomPBRMaterial] B --> C[设置有效光源] C --> D[准备渲染] D --> E[customShaderNameResolve] E --> F[筛选光源] F --> G[应用自定义着色器] G --> H[材质渲染] L[场景中的灯光] --> L1[setFixedLight] L1 --> L2[修改prepareLightSpecificDefines] L2 --> L3[生成固定灯光信息] L3 --> L4[存储到Effect.IncludesShadersStore] L4 --> L5[修改灯光Define标志] L5 --> E subgraph 材质转换与复用 A --> A1[检查材质Map] A1 -->|找到已存在| A2[复用已有材质] A1 -->|不存在| A3[创建新材质] A3 --> A4[复制可写属性] A4 --> A5[添加到材质Map] end subgraph 光源管理 C --> C1[静态导入灯光] C --> C2[动态创建灯光] C --> C3[validLights集合] end subgraph 固定灯光硬编码 L3 --> LS1[getFixedLightInfo] LS1 -->|点光源| LS2[getFixedPointLightInfo] LS1 -->|聚光灯| LS3[getFixedSpotLightInfo] LS2 --> LS4[getLightCommonInfo] LS3 --> LS4 LS4 --> LS5[getFixedLightSourceCode] LS5 --> L4 end subgraph 着色器处理 E --> E1[处理defines] E1 --> E2[禁用非有效灯光] E2 --> E3[加载自定义着色器] end
实现分析
1. 灯光管理机制
CustomPBRMaterial 实现了一套灯光筛选机制,通过静态属性和实例方法共同管理:
1 | /** key为灯光name,value为define name */ |
每个材质实例维护自己的 validLights
集合,用于记录哪些灯光会影响该材质:
1 | public addValidLight(name: string) { |
这种设计允许:
- 区分导入模型自带的灯光和手动添加的灯光
- 每个材质可以独立控制哪些灯光会影响它
- 通过静态映射表关联灯光名称和着色器定义
2. 自定义着色器机制
CustomPBRMaterial 利用 Babylon.js 的着色器自定义机制,通过以下方式实现:
2.1 自定义着色器名称解析
1 | public customShaderNameResolve = ( |
这个方法是 Babylon.js 材质系统的钩子函数,在着色器编译前被调用,用于:
- 筛选定义:检查所有与灯光相关的定义(如
LIGHT0
、SPOTLIGHT1
等),并禁用那些不在validLights
集合中灯光对应的定义 - 注册着色器:将自定义的顶点和片段着色器代码注册到 Babylon.js 的着色器存储中
- 返回着色器名称:返回一个自定义的着色器名称,告诉 Babylon.js 使用这个着色器
2.2 自定义着色器代码
CustomPBRMaterial 使用了外部导入的自定义着色器代码:
1 | import customePBRVertexShader from '@/render/shader/pbr.vertex.fx?raw' |
这些着色器文件基于 Babylon.js 的标准 PBR 着色器,但可能包含针对特定渲染需求的自定义修改,如片段着色器中包含了 FixedLightInfo
的引用:
1 | // light declaration |
3. 材质转换与复用机制
CustomPBRMaterial 提供了从现有 PBRMaterial 创建新实例的静态方法:
1 | static fromPBR(pbr: PBRMaterial, addAutoCreateLights = false): CustomPBRMaterial { |
这个方法实现了:
- 材质缓存:通过静态 Map 实现材质复用,避免重复创建
- 属性复制:复制原 PBR 材质中的可写属性到新材质
- 光源管理:可选择性地添加自动创建的灯光到新材质的有效灯光集合
4. 优化 UBO 数量限制的辅助函数
文件开头的 removeLightBuffers
函数是一个辅助函数,用于从 uniformBuffers 数组中移除所有与灯光相关的 uniformBuffer:
1 | function removeLightBuffers(uniformBuffers: string[]) { |
虽然该函数在当前代码中未被直接调用,但其目的是帮助减少 UBO 的使用,这与材质的整体设计目标一致。
技术实现细节
1. 类名重写
自定义材质重写了 getClassName
方法,返回“PBRMaterial”而非“CustomPBRMaterial”:
1 | public getClassName(): string { |
这是为了保持与 Babylon.js 调试系统的兼容性,允许在调试器中像调整普通 PBR 材质一样调整自定义材质。
2. 错误处理
材质包含基本的错误处理机制:
1 | this.onError = (effect, errors) => { |
这有助于诊断着色器编译过程中的问题。
3. 固定灯光硬编码机制
CustomPBRMaterial 系统中最关键的性能优化之一是将灯光信息直接硬编码到着色器中,而不是通过 UBO 动态传递。这种方法在 light.ts
中实现,主要包括以下几个关键函数:
3.1 setFixedLight 函数
setFixedLight
函数是整个固定灯光机制的入口点,它修改了灯光实例的 prepareLightSpecificDefines
方法:
1 | export function setFixedLight(light: Light) { |
关键步骤解析:
- 保存原始方法:首先保存灯光原始的
prepareLightSpecificDefines
方法 - 重写方法:用新的实现替换原方法,新方法会先调用原方法,然后执行自定义逻辑
- 记录灯光映射:将灯光索引和名称的映射关系记录到
CustomPBRMaterial.lightMap
中 - 设置固定灯光标志:在 defines 中设置
LIGHT_FIXED${lightIndex}
标志 - 避免重复添加:检查是否已经添加过该灯光的信息,避免重复
- 生成灯光信息:调用
getFixedLightInfo
生成灯光的 GLSL 代码 - 添加到着色器包含文件:将生成的代码添加到
Effect.IncludesShadersStore['FixedLightInfo']
中
这个函数的核心作用是将灯光的参数(位置、方向、强度等)在编译时直接嵌入到着色器代码中,而不是在运行时通过 uniform 传递。
3.2 getFixedLightInfo 函数
这个函数根据灯光类型选择正确的灯光信息生成函数:
1 | export function getFixedLightInfo(light: Light, no: number) { |
它支持两种常见的灯光类型:点光源(PointLight)和聚光灯(SpotLight)。
3.3 灯光信息生成函数
针对不同类型的灯光,系统实现了专门的信息生成函数:
点光源处理 - getFixedPointLightInfo:
1 | export function getFixedPointLightInfo(light: PointLight, no: number) { |
聚光灯处理 - getFixedSpotLightInfo:
1 | export function getFixedSpotLightInfo(light: SpotLight, no: number) { |
这两个函数分别处理点光源和聚光灯的特定参数,它们都依赖于 getLightCommonInfo
提取通用的灯光信息,并最终调用 getFixedLightSourceCode
生成 GLSL 代码。
3.4 通用灯光信息提取
getLightCommonInfo
函数提取了所有灯光类型共有的参数:
1 | function getLightCommonInfo(light: Light) { |
这个函数处理灯光的基本属性,并执行必要的缩放和范围限制。
3.5 GLSL 代码生成
整个系统的核心是 getFixedLightSourceCode
函数,它实际生成了要嵌入到着色器中的 GLSL 代码:
1 | function getFixedLightSourceCode(variables: Record<string, number[]>, no: number) { |
这个函数完成了几个关键任务:
- 创建结构体定义:根据传入的变量创建 GLSL 结构体定义
- 创建结构体实例:用计算好的灯光参数创建结构体实例
- 添加预处理条件:用
#ifdef
条件包裹代码,确保仅在需要时编译 - 格式化浮点数:将所有数值格式化为固定精度的浮点数字符串
3.6 与着色器系统的集成
生成的固定灯光代码通过 Babylon.js 的着色器包含系统集成到渲染管线中:
-
代码被添加到
Effect.IncludesShadersStore['FixedLightInfo']
中 -
在自定义片段着色器中,通过
#include<FixedLightInfo>
指令引入这些代码:1
2
3
4// light declaration
// light declaration end -
着色器编译时,预处理器会根据定义的
LIGHT${no}
和LIGHT_FIXED${no}
条件决定是否包含特定灯光的代码
4. 基于 Babylon.js 内部机制的深入分析
CustomPBRMaterial 充分利用了 Babylon.js 的多个核心内部机制。以下是对这些机制的详细解析:
4.1 着色器定义系统(MaterialDefines)
Babylon.js 的着色器定义系统是通过 MaterialDefines 类实现的,它是着色器条件编译的核心机制:
1 | // Babylon.js中MaterialDefines的简化结构 |
工作原理:
- MaterialDefines 是一个动态对象,可以添加任意定义作为属性
- 每个定义可以是布尔值(表示开启/关闭)或数字值(如 LIGHT 数量)
- 当定义发生变化时,
isDirty
标志将设为 true,触发着色器重新编译 toString
方法将所有开启的定义转换为 GLSL 预处理指令(如#define SPOTLIGHT0
)
CustomPBRMaterial 的利用:
在 customShaderNameResolve
函数中,CustomPBRMaterial 根据 validLights
集合选择性地关闭某些灯光定义:
1 | if (defines instanceof MaterialDefines) { |
这种修改直接影响生成的着色器代码,使得 GLSL 条件编译跳过不需要的灯光计算部分,从而避免超出 UBO 限制。
4.2 着色器自定义钩子(customShaderNameResolve)
Babylon.js 允许材质定义 customShaderNameResolve
函数,这是一个强大的钩子,允许在着色器编译管线中进行干预:
1 | // Material类中的接口定义(简化) |
工作原理:
- 当材质需要编译着色器时,引擎会检查是否存在
customShaderNameResolve
- 如果存在,引擎会调用该函数,传入着色器编译所需的关键信息
- 该函数可以修改这些参数(如 uniforms、defines 等)
- 该函数返回的字符串将被用作着色器名称,告诉引擎使用哪个着色器
引擎处理流程:
1 | // Babylon.js内部处理着色器名称的简化逻辑 |
CustomPBRMaterial 的利用:
CustomPBRMaterial 利用此钩子实现了两个核心功能:
- 灯光筛选:修改 defines,禁用不需要的灯光
- 注册自定义着色器:将自定义着色器代码添加到 ShadersStore 并返回自定义着色器名称
这使得引擎在编译材质时使用开发者提供的着色器代码,而不是 Babylon.js 默认生成的 PBR 着色器。
4.3 Effect.ShadersStore 系统
Babylon.js 使用 Effect.ShadersStore
作为着色器代码的全局存储库,它是一个静态字典:
1 | // Effect类中的定义(简化) |
工作原理:
ShadersStore
是一个键值对集合,键是着色器名称,值是着色器代码- 创建 Effect 时,引擎会查找对应名称的着色器代码
- 开发者可以在运行时向这个存储库添加或修改着色器
- 每个着色器以“xxxShader”命名,如“customPBRVertexShader”
着色器编译流程:
- 当引擎需要编译着色器时,它首先查找 ShadersStore
- 获取着色器代码后,处理所有#include 指令
- 应用所有 define 预处理指令
- 编译处理后的 GLSL 代码
CustomPBRMaterial 的利用:
CustomPBRMaterial 直接向 ShadersStore 注册自定义着色器:
1 | Effect.ShadersStore['customPBRVertexShader'] = customePBRVertexShader |
这确保了引擎使用自定义着色器,而不是动态生成的着色器。值得注意的是,自定义着色器仍然使用 Babylon.js 的包含系统(#include<...>
指令),因此可以复用大部分标准 PBR 着色器功能。
4.4 材质继承系统
Babylon.js 的材质系统采用了面向对象的继承架构,允许开发者扩展现有材质:
1 | // 材质继承链(简化) |
工作原理:
- 每个材质类继承基类的功能,同时添加特定特性
- 材质派生类可以覆盖父类方法以自定义行为
- 基类提供核心接口和通用功能,如绑定、更新等
关键方法覆盖:
材质类中有几个关键方法可以被覆盖以影响渲染行为:
isReadyForSubMesh
:检查材质是否准备好被特定子网格渲染bindForSubMesh
:绑定材质参数到着色器getEffect
:获取或创建材质的 Effect 实例prepareDefines
:准备着色器定义
CustomPBRMaterial 的利用:
CustomPBRMaterial 充分利用了继承系统:
- 继承 PBRMaterial 保留所有 PBR 功能
- 添加自定义的灯光管理功能
- 覆盖
getClassName
方法以保持调试兼容性 - 添加
customShaderNameResolve
实现自定义着色器逻辑
这种继承方式使得 CustomPBRMaterial 能够在保留原有 PBR 渲染能力的同时,添加灯光筛选功能,是一种巧妙的扩展方式。
与 Babylon.js PBR 渲染管线的关系
相较于标准 PBR 渲染管线,CustomPBRMaterial 做了以下关键调整:
- 选择性灯光处理:标准管线处理所有影响网格的灯光,而 CustomPBRMaterial 只处理 validLights 集合中的灯光
- 自定义着色器:使用完全自定义的着色器代码,而非动态生成
- 保留材质接口:通过 getClassName 返回“PBRMaterial”保持与标准材质相同的界面
- 灯光硬编码:将灯光信息直接编译到着色器中,而非通过 UBO 传输
固定灯光着色器整合
固定灯光硬编码方案是 CustomPBRMaterial 性能优化的核心。通过分析代码,我们可以详细了解该方案的工作原理:
着色器包含系统利用
系统巧妙地利用了 Babylon.js 的着色器包含系统(Shader Include System)来实现灯光硬编码:
-
动态创建包含文件:
1
Effect.IncludesShadersStore['FixedLightInfo'] += `\n${fixedLightInfo}`
系统动态向
Effect.IncludesShadersStore
添加名为 ‘FixedLightInfo’ 的包含文件内容,这个文件包含所有固定灯光的 GLSL 代码。 -
在自定义着色器中引用:
1
2
3
4// light declaration
// light declaration end自定义片段着色器通过
#include<FixedLightInfo>
指令引入这些代码,确保所有固定灯光的定义和实例在着色器编译时可用。
预处理条件控制
系统使用 GLSL 预处理指令来控制哪些灯光代码需要包含在编译后的着色器中:
1 |
|
这种双重条件检查确保:
- 只有当材质需要处理该灯光时(
LIGHT${no}
被定义),才会包含该灯光的代码 - 只有当该灯光被标记为固定灯光时(
LIGHT_FIXED${no}
被定义),才会使用硬编码方式
UBO 与硬编码的无缝切换
系统实现了 UBO 传输和硬编码方式的无缝切换机制:
- 对于未标记为固定灯光的灯光,系统仍然使用标准的 UBO 传输机制
- 对于标记为固定灯光的灯光,系统使用硬编码的结构体实例
- 两种方式产生的变量名和数据结构保持一致,确保着色器代码的兼容性
灯光数据格式化
getFixedLightSourceCode
函数负责将 JavaScript 中的灯光数据格式化为 GLSL 代码:
1 | Object.entries(variables).map(([key, value]) => ` vec${value.length}(${value.map(val => val.toFixed(8)).join(', ')})`).join(',\n') |
这段代码实现了几个关键功能:
- 根据数组长度决定 GLSL 向量类型(vec2、vec3、vec4)
- 格式化浮点数精度(使用 toFixed(8) 确保一致性)
- 生成符合 GLSL 语法的向量构造函数调用
性能优化对比
固定灯光硬编码机制相对于标准 UBO 方式有显著的性能优势:
UBO 数量限制问题
WebGL 和 WebGPU 对 Uniform Buffer Objects (UBO) 的数量和大小有严格限制:
- WebGL 1.0:没有原生 UBO 支持,通过单独的 uniform 变量模拟,数量严格受限
- WebGL 2.0:支持 UBO,但数量通常限制在 8-32 个左右(取决于硬件)
- WebGPU:虽然限制更宽松,但仍有约束
在大量灯光场景下(特别是展览类应用),很容易超出这些限制,导致渲染错误或性能下降。
两种方案的性能对比
性能指标 | 标准 UBO 方式 | 固定灯光硬编码 |
---|---|---|
GPU 内存使用 | 每帧传输全部灯光数据 | 编译时确定,运行时不占用 UBO 空间 |
CPU→GPU 传输 | 每帧需要更新 UBO | 无需每帧更新固定灯光数据 |
着色器复杂度 | 动态生成,包含所有可能的灯光代码 | 编译时优化,只包含必要的灯光代码 |
灯光数量上限 | 受 UBO 数量限制,典型值为 8-32 个 | 理论上无限制,实际取决于着色器长度限制 |
灯光变换支持 | 完全支持动态变换 | 仅支持编译时确定的固定灯光属性 |
适用场景
固定灯光硬编码机制特别适合:
- 展览类应用:大量固定位置的聚光灯
- 建筑可视化:固定的室内外照明
- 大型静态场景:如虚拟博物馆、艺术展
而标准 UBO 方式更适合:
- 动态场景:灯光频繁移动、添加或删除
- 游戏应用:需要实时响应的灯光效果
- 小型场景:灯光数量不会超出 UBO 限制
实际性能提升
在实际应用中,固定灯光硬编码方案可以带来显著的性能提升:
- 渲染时间:减少 30%-50% 的 GPU 渲染时间(在灯光密集场景)
- 内存占用:减少 UBO 相关的 GPU 内存使用
- 编译优化:着色器编译器可以针对硬编码常量进行额外优化
结论
CustomPBRMaterial 是一个针对特定渲染需求(大量灯光场景)的优化解决方案,它通过灯光筛选、自定义着色器和材质复用等机制,解决了 Babylon.js 在 UBO 数量限制下的渲染问题。特别是通过将固定灯光信息硬编码到着色器中,巧妙地绕过了 WebGL/WebGPU 的 UBO 数量限制,使得在大量灯光的场景下仍能保持良好的性能。
这个实现展示了如何扩展 Babylon.js 的材质系统,在保持核心 PBR 渲染能力的同时,针对特定项目需求进行优化。其设计思路和实现方法对于其他面临类似 UBO 限制问题的 3D 应用具有很好的参考价值。
该方案的问题所在
这里真的要感谢 Cursor 这种利器,这篇文章其实早就打算写了,但是由于涉及到的流程和细节实在是太多了,以至于我每次不知道从何下手很头疼,不然连我自己都不知道原来之前做了那么多的细节工作🙈;当然,上面 Cursor 总结的文档里提到的优化指标数字图一乐就行,因为我压根没测过😂。
虽然这种魔改方案虽然实现了我的需求,但是也存在一定的问题:
- 对 Babylonjs 内部机制依赖过深:因为改动的着色器源码都是基于 Babylonjs 内的 PBR 材质已有的源码进行修改的,且依赖了内部的一些自定义 API,所以一旦 Babylonjs 内部实现有一些变动就可能导致现有的代码失效(实际上因为中途升级过几次 Babylonjs 相关包的版本,已经碰到这个问题),因此目前只能把 Babylonjs 的版本固定,一旦涉及到版本升级就要做好大改的准备😅
- 材质替换成本:由于展厅模型都是基于 GLTF 文件进行导入的,因此默认在加载资源时创建的网格材质都是 PBR 材质,尽管可以在加载完成后马上对这些网格材质进行替换,但是会造成一定的性能损耗(需要对旧材质进行注销和新材质的预编译)
https://www.khronos.org/opengl/wiki/Uniform_Buffer_Object ↩︎
https://github.com/xxf1996/babylonjs-research/blob/main/docs/大量聚光灯场景下UBO数量限制突破方案.md ↩︎
Babylon.js — Very, very much want to add deferred rendering, global illumination - Feature requests - Babylon.js ↩︎
https://github.com/xxf1996/babylonjs-research/blob/main/docs/PBR材质着色器编译流程.md#5-自定义包含指令语法 ↩︎