UBO
撰写时间:2026-08-01
修订时间:2026-08-26
概述
上一章中,每一光源均需填充下面的结构体:
故而在客户端中,initLightings函数充斥着大量调用gl.uniformXXX方法的代码:
如若所需光源数量较多,例如20盏光源以上,上面太多的调用次数将立即导致应用程序的效能大幅下降。而若使用UBO(Uniform Buffer Object),我们只需在客户端准备好一整块连续排列的二进制数据,然后调用一次gl.bufferData方法,所有光源数据就一下子全飞进显存了。其本质,就是在保持片断着色器结构代码不变的情况下,将所有相关联的uniform数据先在客户端准备好,然后再一次性向服务器传送,从而大幅提升应用效能。
Uniform Block std140 布局
在片断着色器中声明内存布局
使用UBO的第一步,是将片断着色器中的uLights的声明移至一个使用特定内存布局的声明中:
std140布局是开发人员与GPU所达成的一种约定,它通过牺牲一点内存空间,强制规范了各个变量在内存中的对齐方式,从而确保不同平台和程序间的内存布局完全一致。开发人员遵循约定后,GPU无需进行在线布局查询,仅根据对齐规则,就能快速地定位到各个元素的起始偏移值位置并正确地取出相应的数据。这是一种以空间换取效率的经典应用。
官方描述
OpenGL 4.5 规范(第7.6.2.2节Standard Uniform Block Layout
)详细给出了std140布局的10条规则。其总体要求如下:
结构体成员将依据它们在结构体内的声明顺序依序存储。每个成员都有一个base offset及一个base alignment。aligned offset的计算方法:将base offset向上取整至base alignment的倍数。结构体内第一个成员的base offset取自该结构体的aligned offset。结构体内的其它成员的base offset,取上一成员所占用的最后一个基本机器单位(last basic machine unit)的偏移值,再加上一个所得出。所有结构体成员均被存储在内存中的aligned offset的位置。
官方描述不易理解,下面以具体实例逐步详细解析。在此之前,需了解,对于每种元素,它们的base alignment及字长是固定的,具体见下表(均以字节为单位):
| 变量类型 | base alignment | 字长 |
|---|---|---|
| int | 4 | 4 |
| float | 4 | 4 |
| vec3 | 16 | 12 |
方向光实例详析
我们结合LightProps结构体进行详细说明。先看第一个元素。
| 序号 | 变量类型 | 变量名称 | base offset | base alignment | aligned offset | 字长 | 所占用的字节偏移值 | 下一元素的 base offset |
|---|---|---|---|---|---|---|---|---|
| 1 | int | type | 0 | 4 | 0 | 4 | [0, 3] | 4 |
作为结构体的第一个元素,type的base offset值为0字节处。类型为int,其base alignment值为4,意味着其应对齐存储至4的倍数的位置,也即(0, 4, 8, 12, 16)等的位置。此时,其base offset值为0,符合base alignment的要求,因此aligned offset直接取自base offset的值0。由此,type在第0个字节处进行存储。
type的类型为int,共占用字长为4字节的空间,则所占用的字节偏移值为(0, 1, 2, 3),则下一元素的base offset应取值为第4个字节处。
来到第二个元素。
| 序号 | 变量类型 | 变量名称 | base offset | base alignment | aligned offset | 字长 | 所占用的字节偏移值 | 下一元素的 base offset |
|---|---|---|---|---|---|---|---|---|
| 2 | vec3 | color | 4 | 16 | 16 | 12 | [16, 27] | 28 |
第二个元素为color,base offset取自上表最后一列下一元素的 base offset
的值4,其base alignment值为16,要求须对齐排在16的倍数的位置,即(0, 16, 32)等位置。由于base offset不满足此要求,则aligned offset的值取16。由此,color在第16个字节处进行存储。第4到第15个字节处(共计12个字节)将自动填充0值。
由此可看出这三者的关系:base offset是初始偏移值,base alignment是必须对齐存储的偏移值,而aligned offset是根据base offset是否满足base alignment的要求而算出的实际存储偏移值。
上面aligned offset的值可由下面公式自动求出:
即,如果baseOffset正好为baseAlignment的倍数,则排在baseOffset的位置;如果baseOffset不为baseAlignment的倍数,将有小数点,则将整数的商加1后再与baseAlignment相乘,这样就能满足自动向右按base alignment对齐排列的要求。
color的类型为vec3,共占用字长为12字节的空间,所占用的字节偏移值为[16 — 27],则下一元素的base offset应取值为第28个字节处。
来到第三个元素。
| 序号 | 变量类型 | 变量名称 | base offset | base alignment | aligned offset | 字长 | 所占用的字节偏移值 | 下一元素的 base offset |
|---|---|---|---|---|---|---|---|---|
| 3 | float | intensity | 28 | 4 | 28 | 4 | [28, 31] | 32 |
第3个元素intensity的base offset正好为其base alignment的倍数,则aligned offset的值取自base offset的值。占用字长为4字节的空间,下一元素应初步排在第32字节的位置。
来到第四个元素。
| 序号 | 变量类型 | 变量名称 | base offset | base alignment | aligned offset | 字长 | 所占用的字节偏移值 | 下一元素的 base offset |
|---|---|---|---|---|---|---|---|---|
| 4 | vec3 | normedDirection | 32 | 16 | 32 | 12 | [32, 43] | 44 |
第4个元素normedDirection的base offset正好为base alignment的倍数,则aligned offset的值取自base offset的值。占用字长为12字节的空间,下一元素应初步排在第44字节的位置。
上面的步骤对齐排完了一个方向光所需的4种属性值。
LightProps 所有属性编排
下表列出了LightProps所有属性的对齐编排。
| 序号 | 变量类型 | 变量名称 | base offset | base alignment | aligned offset | 字长 | 所占用的字节偏移值 | 下一元素的 base offset |
|---|---|---|---|---|---|---|---|---|
| 1 | int | type | ||||||
| 2 | vec3 | color | ||||||
| 3 | float | intensity | ||||||
| 4 | vec3 | normedDirection | ||||||
| 5 | vec3 | position | ||||||
| 6 | vec3 | attenuation | ||||||
| 7 | vec3 | normedConeDirection | ||||||
| 8 | float | innerCutoff | ||||||
| 9 | float | outerCutoff | ||||||
| 10 | Next LightProps |
回看std140布局的声明:
UBOuLightBlock所包裹的uLights是结构体LightProps的数组。因此下一个排列对象为LightProps结构体。
与之前一样,下一个LightProps结构体的base offset取自上表中第9行最后一列的数据100,但其base alignment是LightProps结构体所有成员的base alignment的最大值,也即16,故其aligned offset应排在第112个字节处的位置。
因为第一个LightProps结构体的第一个元素type的aligned offset值为0,故第一个LightProps结构体占用了从0到111共计112个字节的空间,因此,对齐后,每个LightProps结构体的字长为112个字节。
如果代表数组元素数量MAX_LIGHTS的值为10,则10个LightProps结构体共需1120个字节。
自动计算 UBO 对齐数据
上面的计算过程较为麻烦且易出错,我们将其逻辑包装进一个ubo-layout.js文件中。
下面调用并打印状态:
在calcPackInfo函数返回的对象中,alignedOffsets封装了各个元素的aligned offset数据,structSize存储了整个结构体的字长值。
有了这些基本数据,下面我们就可以很轻松地据此构建出一个二进制数据块。
构建二进制数据块
ubo-layout.js文件中的packLights函数负责将各个光源的数据,按std140内存布局格式,打包为一个二进制数据块。
并非每个光源的所有属性值都可以打包进中二进制数据块中,我们仅严格依照常量LIGHT_PROPS_LAYOUT中的属性名值构建二进制数据块。这样做的好处是正如下面所见,在客户端,我们可为光源设置其他的属性名值以满足应用的其他需求,但又不影响std140内存布局的正常设置。
初始化 UBO 缓冲区
ubo-layout.js文件中的initUBO函数可用以初始化一个UBO。
根据片断着色器中的代码:
以字符串uLightBlock
作为参数调用initUBO函数即可获取一个经初始化后的UBO缓冲区。
向 UBO 缓冲区填充数据
上面的各个步骤均为内部流程,实际上,客户端仅需与ubo-layout.js文件中的uploadToUBO函数打交道。
该函数将上面的流程串成一个生产线,先初始化一个UBO缓冲区,接着计算UBO对齐数据,构建一个二进制数据块并填充至UBO缓冲区中。
通过这种方式,我们将使用UBO的所有繁杂的流程都封装为一个函数。
客户端代码
将各种光源封装为类
在上一章中,在客户端加入聚光的代码如下:
尽管gl.uniformXXX方法已因使用UBO而不再需要,但剩余代码仍丑陋不堪。因此有必要将各种光源封装为类。
在为各种光源设计类时,需时刻遵循ubo-layout.js文件中LIGHT_PROPS_LAYOUT的定义:
因此,在Light.js文件中,为各种光源所设计的类的代码如下:
在客户端进行实例化的代码:
在各种光源类的构造方法及其类属性中,名称中带有下划线(_
)的参数或类属性,均不是LIGHT_PROPS_LAYOUT所定义的必要部分。但它们又均不可或缺。
例如,_position及_target虽非必要属性,但它们既可用以方便渲染出其光照朝向,同时又是据以自动计算出方向光DirectionalLight类的必要属性normedDirection、点光PointLight类及聚光SpotLight类的必要属性attenuation的直接依据。而_extendLen属性可参与点光与聚光的必要属性attenuation的自动计算,用以提供更直观的UI效果。
对这些属性,构造函数中均提供了默认值,且置于所有参数的最后。UBO的packLights函数不会处理它们。
实际上,各种光源的所有参数都有默认值,因此,要图方便,可使用以下代码:
默认光源的颜色均为白色,且都位于坐标值为(0, 0, 0)的正上方,垂直照射下来(点光四向照射)。然后,根据需要再单独修正各个参数。
此外,我们将一些在客户端常用的计算光照的辅助函数封装进lights-utils.js模块中。
整体运行的代码
经过上面的步骤,客户端代码可以非常整洁了。
上面涉及到UBO的代码仅为以下两行:
运行应用。
上面各种光源的使用对象的构造方法看似较臃肿,但如果换成如下的构造方法:
我们将立即崩溃。根源在于,对于每种光源,我们需要精细控制较多的光照系数。
自动生成着色器代码
上一节中有一个较大的问题,就是客户端所指定的最大光源数,无法与着色器的最大光源数自动同步。原因在于之前的着色器代码全部都是固定硬编码,从而造成需要分别维护两处的代码,很容易造成不一致。
并且,ubo-layout.js虽已声明了LIGHT_PROPS_LAYOUT的格式:
但硬编码的着色器代码却不一定与此保持一致。以后这里的代码若因升级而修改,硬编码的着色器代码将无法再正常工作。解决的方法是我们需要一套着色器代码的生成机制。
从大的方面来看,着色器代码生成器需分别生成顶点着色器代码及片断着色器代码两套代码(两个产品)。并且,使用光照与不使用光照的着色器代码有很大不同,且顶点着色器代码及片断着色器代码之间须自动保持一致,这意味着我们需要自动生成两套产品家族。
至于纹理,两套产品家族都有可能使用纹理,或者不使用纹理,因此是否使用纹理是两套产品家族的选项。
以上特点,决定了这里应用抽象工厂模式是最佳选择。
而在生成着色器代码时,我们需要逐一生成着色器的各个部分,且可从客户端接收各种参数以实现灵活的配置。因此,在工厂内部,可以采用建造者模式以实现颗粒级精细控制。
下面是自动生成的不使用纹理、不使用光照的着色器代码:
这是最简单的着色器代码。
ShaderSrcFactory的createVShaderSrc及createFShaderSrc均返回Promise实例,以允许在构建着色器代码过程中灵活加载各类外部资源。
下面是自动生成的同时使用纹理及光照的着色器代码:
这是目前为止最复杂的着色器代码。
ubo-2.html使用了类似的代码来生成着色器代码,并用于实例化WebGLApp:
运行应用。
具体代码详见下节。
着色器代码生成器代码
ShaderSrcFactory.js代码:
ShaderSrcBuilder.js代码:
可以直接加载外部.js文件,可以根据参数决定是否使用纹理并设置最大光源数,且根据ubo-layout.js中的LIGHT_PROPS_LAYOUT来生成UBO内存布局代码,从而保证了单一的代码来源。
纹理光照
对于光照应用,法线数据是不可或缺的。
SolidMesh的构造方法加入了normals形参,而在initSolidVAO方法中自动补全法线数据,且在创建实例属性solidVAO时,须同时加入法线数据。
而较容易疏忽的是TextureMesh的texVAO也须加入法线数据,否则,在渲染纹理时,将出现两个问题:一是纹理网格物体颜色较暗,二是纹理网格物体打不上光照效果。
FaceTextureMesh亦是需要注意此问题。
运行应用。
参考资源
Specifications
GLSL std140 Layout
- Standard Uniform Block Layout
- GLSL std140布局规则
- GLSL标准Uniform Block(std140)布局规则
- I need explanation about std140 uniform blocks offsets
- OpenGL Programming Guide, 9th Edition, P724, The std140 Layout Rules
