UniformBuffer
需要预先学习:
1. UniformBuffer
回顾一下常规的 uniform 设置方式:glUniform* 逐个把值传进当前着色器程序。在 Phong模型与Blinn-Phong模型 里已经能看到问题的一角——光颜色、光位置、视角位置……每个变量一次调用。当场景里的着色器程序越来越多,每个程序又都要一份相同的 view、projection 矩阵时,这种方式的 CPU 开销会变得难以忽视。
UniformBuffer(uniform buffer object,UBO,统一变量缓冲对象)把一批 uniform 打包进一个缓冲对象:一次上传整块数据,绑定到绑定点(binding point)之后,任意多个着色器程序都可以直接读取同一份数据。
这篇文章先走完 UBO 的完整使用流程,再分析 std140 布局规则,最后从底层回答一个问题:它到底比 glUniform* 快在哪里。
1.1 常规方式的问题
先看常规方式的做法与它的两个问题。
每帧渲染时,你需要先激活程序,再逐个设置 uniform:
glUseProgram(programA);
glUniformMatrix4fv(glGetUniformLocation(programA, "projection"), 1, GL_FALSE, &projection[0][0]);
glUniformMatrix4fv(glGetUniformLocation(programA, "view"), 1, GL_FALSE, &view[0][0]);
glUniform3fv(glGetUniformLocation(programA, "viewPos"), 1, &cameraPos[0]);
glUseProgram(programB);
// 换了一个程序,同样的 projection、view 要再设一遍
glUniformMatrix4fv(glGetUniformLocation(programB, "projection"), 1, GL_FALSE, &projection[0][0]);
glUniformMatrix4fv(glGetUniformLocation(programB, "view"), 1, GL_FALSE, &view[0][0]);
问题有两个:
- 每个程序都要重复设置:uniform 是着色器程序自己的状态,
projection和view对所有程序都是一样的,却要每个程序各调一遍。程序越多,重复越多。 - 每个变量一次调用:
glUniform*每次只传一个变量。一个程序带十几个 uniform,每帧就是十几次函数调用进入驱动。
UBO 解决的正是这两个问题:数据与程序解耦(一处上传,处处共享),整块数据一次传完。
1.2 UBO 的使用方法
GLSL 端:声明 uniform 块
在 GLSL 里,把要共享的一批 uniform 包进一个 uniform 块:
#version 330 core
layout (location = 0) in vec3 aPos;
layout (std140) uniform Matrices
{
mat4 projection;
mat4 view;
};
void main()
{
gl_Position = projection * view * vec4(aPos, 1.0);
}
块内的成员和普通 uniform 用法完全相同,直接按成员名访问。区别在于:这些数据来自一个缓冲对象,而不是 glUniform* 的逐个设置。
layout (std140) 指定块内成员的内存布局(成员在缓冲里的排列规则),后文详解。目前只需要知道:std140 是固定的标准布局,CPU 端可以照着规则准备出字节完全一致的数据。
CPU 端:创建与上传
缓冲对象的操作流程还是熟悉的三步:生成 → 绑定 → 填充数据(见 缓冲对象与顶点),只是 target 换成了 GL_UNIFORM_BUFFER:
GLuint uboMatrices;
glGenBuffers(1, &uboMatrices); // 生成
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices); // 绑定
glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), nullptr, GL_STATIC_DRAW); // 分配空间
之后每帧只需要更新变化的数据,用 glBufferSubData 写入块内的指定位置:
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), &projection[0][0]); // 写入 projection
glBufferSubData(GL_UNIFORM_BUFFER, sizeof(glm::mat4), sizeof(glm::mat4), &view[0][0]); // 写入 view
连接两者:绑定点
现在着色器里有一个 uniform 块,显存里有一块缓冲数据,还需要一条线把两者接起来,这条线就是绑定点(binding point)。
绑定点是 OpenGL 全局的"插槽"数组:一边把 UBO 插进某个插槽,一边让着色器里的块从同一个插槽取数据:
绑定点 0
[projection|view] <--------------> Matrices 块(着色器 A)
(UBO 缓冲) Matrices 块(着色器 B)
Matrices 块(着色器 C)
着色器侧的连接需要两步:
// 查询块在程序中的索引
GLuint blockIndex = glGetUniformBlockIndex(shaderProgram, "Matrices");
// 把块指到 0 号绑定点
glUniformBlockBinding(shaderProgram, blockIndex, 0);
注意这一步操作的是着色器程序,不需要 glUseProgram 激活它,只需在链接完成之后调用。对每个用到该块的程序都执行一次(每个程序只需在初始化时设置一次,之后不再变)。
缓冲侧的连接:
// 把 UBO 绑定到 0 号绑定点
glBindBufferBase(GL_UNIFORM_BUFFER, 0, uboMatrices);
glBindBufferBase 一次完成"绑定到 target + 绑定到绑定点"。两边都指向 0 号绑定点后,连接建立:所有程序的 Matrices 块都会从这块缓冲读取数据。
注:如果使用 GLSL 4.2 以上(
#version 420),GLSL 端可以直接写layout(std140, binding = 0),省去glGetUniformBlockIndex与glUniformBlockBinding两步。本文主示例使用 330 core,所以采用运行时指定的方式。
每帧的完整流程
初始化(一次性):
// 创建 UBO 并分配空间
GLuint uboMatrices;
glGenBuffers(1, &uboMatrices);
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), nullptr, GL_STATIC_DRAW);
// 连接到 0 号绑定点
glBindBufferBase(GL_UNIFORM_BUFFER, 0, uboMatrices);
GLuint blockIndex = glGetUniformBlockIndex(shaderProgram, "Matrices");
glUniformBlockBinding(shaderProgram, blockIndex, 0);
渲染循环(每帧):
// 更新数据,一次整块上传
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), &projection[0][0]);
glBufferSubData(GL_UNIFORM_BUFFER, sizeof(glm::mat4), sizeof(glm::mat4), &view[0][0]);
// 正常绘制,切换程序时不再需要重设 projection / view
glUseProgram(programA);
glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_INT, 0);
glUseProgram(programB); // 直接绘制,Matrices 自动生效
glDrawElements(GL_TRIANGLES, count, GL_UNSIGNED_INT, 0);
1.3 std140 布局规则
为什么需要标准布局
如果不写 layout,GLSL 默认使用 shared 布局:成员的排列由显卡驱动自行决定,CPU 端无法预知每个成员在缓冲中的偏移。packed 布局更激进,甚至会剔除被判定未使用的成员。
这两种布局性能上可能有优势(驱动可以针对硬件优化),但你的 C++ 结构体没法和它们对上——除非用 glGetActiveUniformsiv 逐个查询偏移再拼数据,非常繁琐。
std140 是一份固定的标准布局:成员偏移完全由规则推导,任何实现、任何硬件上结果都相同。CPU 端按规则写出对应的 C++ 结构体,两边内存字节一一对应,直接整块上传即可。代价是一些成员之间会有填充(padding)字节,浪费一点空间。
对齐规则
std140 的规则可以概括为一张表:
| GLSL 类型 | 对齐要求 | 占用大小 |
|---|---|---|
int / uint / float / bool |
4 | 4 |
vec2 |
8 | 8 |
vec3 |
16 | 12(下一个成员仍偏移 16) |
vec4 |
16 | 16 |
mat4 |
16 | 64(4 列,每列按 vec4) |
mat3 |
16 | 48(3 列,每列按 vec4) |
| 数组(任意元素类型) | 16(元素对齐取整) | 16 的倍数 × 元素个数 |
| 结构体 | 成员最大对齐取整到 16 | 对齐值的整数倍 |
提炼成几条规则:
- 标量按自身大小对齐
vec2按 8 对齐;vec3、vec4按 16 对齐- 矩阵按"列向量数组"处理,每一列都按
vec4对齐(mat4就是 4 个vec4,占 64) - 数组每个元素的大小向上取整到 16 的倍数(
float arr[3]占 48,不是 12) - 结构体的对齐值 = 成员最大对齐值向上取整到 16 的倍数,末尾补齐到对齐值的倍数
"按 N 对齐"的含义:成员的起始偏移必须是 N 的整数倍,不够就在前面填充空白字节。
偏移计算示例
用一个经典的"坑"做例子——两个 vec3:
layout (std140) uniform Light
{
vec3 position; // 偏移 0,占用 12
vec3 color; // 偏移 16,中间空了 4 个字节!
};
color 的对齐要求是 16,而 position 结束于 12,所以 color 要跳到 16 处开始:
数组也一样:
layout (std140) uniform Lights
{
vec3 lightPositions[4]; // 每个元素占 16(12 取整到 16),块大小 64,不是 48
};
完整的逐成员排布:
layout (std140) uniform Camera
{
vec3 cameraPos; // 偏移 0, 占用 12
float exposure; // 偏移 12,占用 4(对齐 4,正好填进空隙)
vec2 screenSize; // 偏移 16,占用 8(对齐 8,16 已满足)
mat4 view; // 偏移 24?不对——mat4 对齐 16,偏移 32,占用 64
};
C++ 端的对应结构体
CPU 端要写出一个与 std140 字节完全一致的结构体。C++ 编译器的默认对齐规则(glm::vec3 按 4 对齐)与 std140 不一致,所以不能直接照抄,需要手动填充:
struct LightCPU // 错误:color 的偏移是 12,与 GLSL 端的 16 对不上
{
glm::vec3 position;
glm::vec3 color;
};
struct LightStd140 // 正确:手动补齐到 std140 布局
{
glm::vec3 position; // 偏移 0
float pad0; // 偏移 12,填充字节,值任意
glm::vec3 color; // 偏移 16
float pad1; // 偏移 28,把总大小补齐到 32(16 的倍数)
};
glm::mat4 与 std140 的 mat4 内存布局天然一致(都是 4 列 × 4 个 float 连续排列的 64 字节,列主序),这就是前文主示例里 Matrices 块可以直接整块上传的原因。
注意:填充字节的值无所谓,但位置和数量必须与 std140 规则一致。可以用
static_assert(offsetof(LightStd140, color) == 16)在编译期验证你的结构体没有写错。
查询实际偏移
拿不准偏移时,可以让 OpenGL 直接告诉你。glGetUniformIndices 与 glGetActiveUniformsiv 能查询块内成员的真实偏移(注意块内成员要用 块名.成员名 的完整路径):
const GLchar* names[2] = { "Matrices.projection", "Matrices.view" };
GLuint indices[2];
glGetUniformIndices(shaderProgram, 2, names, indices);
GLint offsets[2];
glGetActiveUniformsiv(shaderProgram, 2, indices, GL_UNIFORM_OFFSET, offsets);
// offsets[0] == 0,offsets[1] == 64
类似地,GL_UNIFORM_BLOCK_DATA_SIZE 可以查询整个块的字节大小,用来给 glBufferData 分配空间:
GLuint blockIndex = glGetUniformBlockIndex(shaderProgram, "Matrices");
GLint blockSize;
glGetActiveUniformBlockiv(shaderProgram, blockIndex, GL_UNIFORM_BLOCK_DATA_SIZE, &blockSize);
注:std430 是 SSBO 专用的布局(见 ComputeShader),规则比 std140 宽松——数组元素不必取整到 16,
vec3 lightPositions[4]每个元素只占 12 字节。UBO 不支持 std430,只能用 std140 或更保守的布局。
1.4 为什么 UBO 更快
glUniform* 的开销在哪
glUniform* 看起来只是"给一个变量赋值",但它并不是简单地写一段显存。每次调用都要:
- 进入驱动:OpenGL 的每个调用都要经过驱动的入口(参数校验、上下文查找,甚至跨用户态与内核态),这是一笔与数据量无关的固定开销
- 查找与打包:驱动根据 location 找到 uniform,把值收集、打包进随绘制命令下发的数据里——GPU 执行 draw call 时,着色器要用的 uniform 必须已经在 GPU 可读的位置
也就是说:100 个 uniform 就是 100 次驱动调用、100 次小的数据收集。而多程序场景下,这些调用还要乘以程序数量——每个程序各存一份相同的 view、projection。
UBO 改变了什么
- 一次调用上传一批数据:
glBufferSubData一次调用搬运整块字节,驱动可以用一次 DMA(Direct Memory Access,直接内存访问)异步传输完成,替代 N 次glUniform*的逐个收集 - 驱动不用替你打包:UBO 的数据由你自己按 std140 布局摆好、整块放进显存,驱动只剩下一条"把绑定点 X 指向这块缓冲"的命令。
glUniform*场景下由驱动完成的打包工作被完全省去 - 多程序零成本共享:UBO 是全局资源,不属于任何程序。绑定到绑定点后,所有引用该绑定点块的程序直接读同一份显存数据——切换程序时一个字都不用重设
- 数据常驻显存:一帧内甚至多帧内不变的 uniform(如 projection),只需在变化时重新上传一次;
glUniform*则是每帧、每个程序都要重设
一笔账
回到开头的场景:每帧 3 个程序,每个程序 6 个 uniform:
| 方式 | 每帧驱动调用 |
|---|---|
glUniform* 逐个设置 |
3 × 6 = 18 次,外加 location 查询 |
| UBO | 1 次 glBufferSubData,程序切换时 0 次 |
程序与 uniform 越多(后处理、阴影 pass、前向渲染与延迟渲染 中的多 pass 渲染都是典型场景),差距越大。CPU 侧的这些调用最终会吃掉原本用于提交绘制命令的时间,让 CPU 先于 GPU 成为瓶颈。
注:UBO 的加速本质不是"传输变快了",而是把 N 次小的驱动调用合并成 1 次批量操作,并消除了多程序间的重复设置。需要搬运的字节数本身并没有变少。如果场景里只有一两个简单着色器,常规
glUniform*更直观省事,不必为了用而用。
1.5 与 SSBO 的区别
UBO 有一个"兄弟"——ComputeShader 里使用的 SSBO(shader storage buffer object),两者都是"显存里的数据块 + 绑定点"的模型,但定位不同:
| UniformBuffer | SSBO | |
|---|---|---|
| target | GL_UNIFORM_BUFFER |
GL_SHADER_STORAGE_BUFFER |
| 着色器访问 | 只读 | 可读可写 |
| 大小上限 | 较小(一般 64KB,可查询上限) | 大得多 |
| 可用布局 | std140 等 | std140 / std430 等 |
| 典型用途 | 矩阵、灯光参数等小批常量数据 | 粒子位置、后处理中间结果等大数据 |
经验法则:一批"对所有绘制都相同的只读常量"用 UBO;"着色器要读写的大块数据"用 SSBO。
1.6 C++ 封装
按照项目里的惯例封装一个 UniformBuffer 类:构造时分配空间,析构时释放,提供绑定点绑定与整块更新两个操作:
// UniformBuffer.hpp
#pragma once
#include <glad/glad.h>
namespace MY::OpenGL
{
class UniformBuffer
{
public:
UniformBuffer() = delete;
/*
* 构造时分配 size 字节的空间
* usage 与 glBufferData 的 usage 参数含义相同,如 GL_STATIC_DRAW、GL_DYNAMIC_DRAW
*/
UniformBuffer(GLsizeiptr size, GLenum usage = GL_STATIC_DRAW);
~UniformBuffer();
// 禁止拷贝
UniformBuffer(const UniformBuffer&) = delete;
UniformBuffer& operator=(const UniformBuffer&) = delete;
public:
GLuint get_buffer_id() const { return m_buffer_id; }
/*
* 绑定到指定的绑定点
*/
void bind_base(GLuint binding_point) const;
/*
* 更新块内数据,offset 为块内偏移(字节)
*/
void set_data(const void* data, GLsizeiptr size, GLintptr offset = 0) const;
/*
* 更新单个 POD 对象,要求 T 的内存布局与 GLSL 端 std140 布局一致
*/
template<typename T>
void set_data(const T& data, GLintptr offset = 0) const
{
set_data(&data, sizeof(T), offset);
}
private:
GLuint m_buffer_id = 0;
};
}
// UniformBuffer.cpp
#include "UniformBuffer.hpp"
namespace MY::OpenGL
{
UniformBuffer::UniformBuffer(GLsizeiptr size, GLenum usage)
{
glGenBuffers(1, &m_buffer_id);
glBindBuffer(GL_UNIFORM_BUFFER, m_buffer_id);
glBufferData(GL_UNIFORM_BUFFER, size, nullptr, usage);
}
UniformBuffer::~UniformBuffer()
{
glDeleteBuffers(1, &m_buffer_id);
}
void UniformBuffer::bind_base(GLuint binding_point) const
{
glBindBufferBase(GL_UNIFORM_BUFFER, binding_point, m_buffer_id);
}
void UniformBuffer::set_data(const void* data, GLsizeiptr size, GLintptr offset) const
{
glBindBuffer(GL_UNIFORM_BUFFER, m_buffer_id);
glBufferSubData(GL_UNIFORM_BUFFER, offset, size, data);
}
}
使用起来非常轻量:
// 初始化:创建并连接到 0 号绑定点
UniformBuffer matricesUBO(2 * sizeof(glm::mat4), GL_DYNAMIC_DRAW);
matricesUBO.bind_base(0);
GLuint blockIndex = glGetUniformBlockIndex(shaderProgram, "Matrices");
glUniformBlockBinding(shaderProgram, blockIndex, 0);
// 渲染循环:每帧整块更新
matricesUBO.set_data(projection, 0); // 偏移 0 处写入 projection
matricesUBO.set_data(view, sizeof(glm::mat4)); // 偏移 64 处写入 view



