需要预先学习:


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]);

问题有两个:

  1. 每个程序都要重复设置:uniform 是着色器程序自己的状态,projection 和 view 对所有程序都是一样的,却要每个程序各调一遍。程序越多,重复越多。
  2. 每个变量一次调用: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 对齐值的整数倍

提炼成几条规则:

  1. 标量按自身大小对齐
  2. vec2 按 8 对齐;vec3、vec4 按 16 对齐
  3. 矩阵按"列向量数组"处理,每一列都按 vec4 对齐(mat4 就是 4 个 vec4,占 64)
  4. 数组每个元素的大小向上取整到 16 的倍数(float arr[3] 占 48,不是 12)
  5. 结构体的对齐值 = 成员最大对齐值向上取整到 16 的倍数,末尾补齐到对齐值的倍数

"按 N 对齐"的含义:成员的起始偏移必须是 N 的整数倍,不够就在前面填充空白字节。

偏移计算示例

用一个经典的"坑"做例子——两个 vec3:

layout (std140) uniform Light
{
    vec3 position;     // 偏移 0,占用 12
    vec3 color;        // 偏移 16,中间空了 4 个字节!
};

color 的对齐要求是 16,而 position 结束于 12,所以 color 要跳到 16 处开始:

offset(color)=⌈0+1216⌉×16=16\text{offset}(\text{color}) = \left\lceil \frac{0 + 12}{16} \right\rceil \times 16 = 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* 看起来只是"给一个变量赋值",但它并不是简单地写一段显存。每次调用都要:

  1. 进入驱动:OpenGL 的每个调用都要经过驱动的入口(参数校验、上下文查找,甚至跨用户态与内核态),这是一笔与数据量无关的固定开销
  2. 查找与打包:驱动根据 location 找到 uniform,把值收集、打包进随绘制命令下发的数据里——GPU 执行 draw call 时,着色器要用的 uniform 必须已经在 GPU 可读的位置

也就是说:100 个 uniform 就是 100 次驱动调用、100 次小的数据收集。而多程序场景下,这些调用还要乘以程序数量——每个程序各存一份相同的 view、projection。

UBO 改变了什么

  1. 一次调用上传一批数据:glBufferSubData 一次调用搬运整块字节,驱动可以用一次 DMA(Direct Memory Access,直接内存访问)异步传输完成,替代 N 次 glUniform* 的逐个收集
  2. 驱动不用替你打包:UBO 的数据由你自己按 std140 布局摆好、整块放进显存,驱动只剩下一条"把绑定点 X 指向这块缓冲"的命令。glUniform* 场景下由驱动完成的打包工作被完全省去
  3. 多程序零成本共享:UBO 是全局资源,不属于任何程序。绑定到绑定点后,所有引用该绑定点块的程序直接读同一份显存数据——切换程序时一个字都不用重设
  4. 数据常驻显存:一帧内甚至多帧内不变的 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