从一个点云 Mapper 学会 VAO、VBO、TBO 与 SSBO:重点拆解“申请、上传、绑定、解释”

从一个点云 Mapper 学会 VAO、VBO、TBO 与 SSBO:重点拆解“申请、上传、绑定、解释”

本文来自一次真实的点云渲染代码复盘。为了便于公开发布,类名、字段名、固定绑定点、业务名称和数据规模均做了泛化;OpenGL 对象关系和调用顺序保持不变。

mapper-opengl-architecture

1. 先建立一个不会忘的心智模型

我以前容易把 VBO、TBO、SSBO 当成三种完全不同的“显存”。看完这个 Mapper 后,更准确的理解是:

Buffer Object 提供一段 GPU 字节存储;VBO、TBO、SSBO 描述的是这段存储在某次使用中如何被绑定、如何被 Shader 解释。VAO 则不是数据仓库,而是一份顶点读取说明书。

四者可以先记成下面四句话:

名称 它保存/描述什么 Shader 如何读 本例用途
VBO 连续顶点字节 in 顶点属性 位置、颜色、法线、标量、空间编码
VAO VBO 与各属性的格式、偏移和步长 不直接读取;绘制时提供顶点输入状态 解释一个交错排列的点结构体
TBO Buffer 的一维纹理视图 samplerBuffer / texelFetch 按索引读取稀疏编辑记录
SSBO 绑定到索引点的结构化 Buffer buffer block 中的数组 遍历可见 LOD 树

VAO 会引用 VBO,但不会复制 VBO 内容。这与 Khronos 对 Vertex Specification 的说明一致:顶点格式和数据源关联保存在 VAO 中,后续修改被引用 Buffer 的内容,VAO 仍能看到新数据。

本文统一用五个动作分析每一种资源:

  1. 申请:创建对象,并为 Buffer 分配容量;
  2. 上传:把 CPU 字节写入 GPU Buffer;
  3. 绑定:让一次 draw / 一个 Shader 接口找到该对象;
  4. 解释:约定 GPU 端如何把字节还原为标量、向量或结构体;
  5. 释放:在 OpenGL 上下文有效时销毁资源。

最容易混淆的是第 3 步和第 4 步:绑定解决“去哪里找”,格式解决“找到后怎么看”。

2. Mapper 在架构上做了什么

这个 Mapper 不会在每帧把所有可见节点重新拼成一个大数组。它维护若干个持久化 VBO Page,每个节点占 Page 中一段连续区间:

Page 0: [node A][node C][free........][node F]
Page 1: [node B][free......................]

节点加入或更新时,CPU 线程只完成以下工作:

  • 保存节点的点数据;
  • 根据可见性、优先级和序号放入待上传队列;
  • 标记 VBO 数据、绘制命令或 LOD 表为 dirty。

真正需要 OpenGL 上下文的操作在 Render() 中完成。每帧流程可以概括为:

flowchart TD A[Render 进入,锁定 Mapper 状态] --> B[按帧预算处理待上传节点] B --> C[回收待释放的空 Page] C --> D{有可见点吗?} D -- 否 --> Z[本帧成功结束] D -- 是 --> E[确保 TBO 已上传并建立纹理视图] E --> F[若支持 OpenGL 4.3,确保 SSBO 已上传] F --> G[编译或取得 Shader Program] G --> H[上传 uniform,绑定 TBO 与 SSBO] H --> I[逐 Page 配置或绑定 VAO] I --> J[重建 dirty 的 MultiDraw 区间] J --> K[按 LOD 分组 glMultiDrawArrays] K --> L[解绑 TBO / SSBO]

这里有三个值得学习的架构决策:

  • 分页持久 VBO:节点显隐只改 draw ranges,不重建整份点云;
  • 上传预算:每帧限制节点数、字节数和耗时,避免 GUI 某一帧集中申请多页显存;
  • dirty flag:数据没变就不重复上传,Shader 没变就不重复配置 VAO。

3. VAO 与 VBO:点数据主通路

3.1 CPU 数据布局

点结构体采用交错布局。脱敏后的等价形式如下:

struct PointVertex
{
    float position[3];       // 12 B
    std::uint8_t color[4];   //  4 B
    float normal[3];         // 12 B
    float scalar;            //  4 B
    std::uint16_t code[4];   //  8 B
};                           // 共 40 B

static_assert(sizeof(PointVertex) == 40);

对应的偏移是 0、12、16、28、32,所有属性的 stride 都是 40 B。static_assert 很重要:一旦 C++ 侧布局因为字段或对齐发生变化,编译阶段就会提醒我们同步 VAO。

3.2 申请 VBO Page

概念上相当于:

glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER,
             pageCapacity * sizeof(PointVertex),
             nullptr,
             GL_DYNAMIC_DRAW);

项目中由 VTK 的 vtkOpenGLBufferObject::Allocate(..., ArrayBuffer, DynamicDraw) 封装。这里一次申请整页容量,而不是每个节点创建一个 VBO。

3.3 上传节点的一段数据

节点先从 Page 的空闲区间中取得 offset + count,随后只更新它自己的范围:

const auto byteOffset = nodeOffset * sizeof(PointVertex);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferSubData(GL_ARRAY_BUFFER,
                byteOffset,
                points.size() * sizeof(PointVertex),
                points.data());

项目中的对应动作是 UploadRange。这正是“持久 Page + 局部覆盖”的关键。

3.4 VAO 解释 VBO

VAO 记录的不是顶点本身,而是:

  • 哪个 VBO;
  • Shader 中哪个输入属性;
  • 从结构体的哪个字节开始;
  • 每个元素包含几个分量;
  • 分量的底层类型;
  • 是否归一化;
  • 相邻顶点间隔多少字节。

本例中的对应关系如下:

Shader 输入 C++ 字段 类型与分量 offset stride normalized
positionMC position float × 3 0 40 false
packedColor color ubyte × 4 12 40 true
normalMC normal float × 3 16 40 false
scalarValue scalar float × 1 28 40 false
spatialCodeParts code ushort × 4 32 40 false

其中颜色使用归一化转换,所以 0~255 会在 Shader 输入侧变成 0.0~1.0。空间编码拆为 4 个 16 位整数,是为了经过当前 VTK 属性转换路径后仍能无损重建 64 位编码。

可以这样记:

VBO 是一本按字节印刷的书,VAO 是目录和阅读规则,Vertex Shader 的 in 是要读取的栏目。

4. TBO:Buffer 加一层“纹理格式视图”

TBO 全称 Texture Buffer Object,更严格地说,它是 Buffer Object + Buffer Texture 的组合。它适合一维、按整数索引随机读取、格式统一的数据。官方接口说明见 glTexBuffer

本例用 TBO 保存按空间编码排序的稀疏编辑项,Shader 对它做二分查找。

4.1 CPU 与 GPU 的数据约定

struct EditRecord
{
    std::uint32_t codeLow;
    std::uint32_t codeHigh;
    std::uint32_t value;
    std::uint32_t reserved;
};
static_assert(sizeof(EditRecord) == 16);

纹理内部格式选择 GL_RGBA32UI

一个 texel = R32UI + G32UI + B32UI + A32UI
           = 4 × uint32
           = 16 字节
           = 一条 EditRecord

这就是 TBO 最核心的“解释”步骤。GL_RGBA32UI 同时决定:

  • 每个 texel 有 4 个无符号整数分量;
  • 每个分量 32 位;
  • Shader 必须用无符号整数采样器 usamplerBuffer

4.2 TBO 的完整生命周期

第一步:创建并上传 Buffer。

glGenBuffers(1, &editBuffer);
glBindBuffer(GL_TEXTURE_BUFFER, editBuffer);
glBufferData(GL_TEXTURE_BUFFER,
             records.size() * sizeof(EditRecord),
             records.data(),
             GL_DYNAMIC_DRAW);

项目的 VTK 封装使用 TextureBuffer 作为上传 target。若数组为空,仍上传一条零值占位记录,保证底层 Buffer 有有效存储;真正有效条数由单独的 uniform 控制,因此 Shader 不会把占位项当成业务数据。

第二步:创建 Texture Object,并让它查看该 Buffer。

glGenTextures(1, &editTexture);
glBindTexture(GL_TEXTURE_BUFFER, editTexture);
glTexBuffer(GL_TEXTURE_BUFFER, GL_RGBA32UI, editBuffer);

注意 glTexBuffer 一般不复制数据。它建立的是:

Texture Object --以 GL_RGBA32UI 解释--> Buffer Storage

所以这里同时存在两个句柄:Buffer handle 与 Texture handle。只创建 Buffer 还不是完整 TBO;必须建立纹理视图,Shader 的 sampler 才能访问它。

第三步:绘制前绑定纹理单元,并设置 sampler uniform。

glActiveTexture(GL_TEXTURE0 + kEditTexUnit);
glBindTexture(GL_TEXTURE_BUFFER, editTexture);
program.setUniform("editTable", kEditTexUnit);

纹理单元是 CPU 与 sampler 之间的“插座编号”。不要把 GL_TEXTURE_BUFFER、Texture handle 和 texture unit 混为一谈:

  • GL_TEXTURE_BUFFER:纹理目标/种类;
  • editTexture:某个纹理对象句柄;
  • kEditTexUnit:该次 draw 使用的纹理单元索引;
  • editTable:Shader 的 sampler uniform。

第四步:Shader 解释并读取。

uniform usamplerBuffer editTable;

uvec4 readEdit(int index)
{
    return texelFetch(editTable, index);
}

texelFetch 使用整数下标,不需要归一化纹理坐标,也没有过滤和 mipmap。得到的 uvec4 与 CPU 的四个 uint32_t 一一对应。

第五步:解绑与释放。

绘制后可以在对应纹理单元绑定 0,避免状态泄漏;上下文释放时分别删除 Texture Object 和 Buffer Object。

4.3 一句话记住 TBO

TBO = Buffer 存字节,Texture Object 用固定 texel 格式包装它,texture unit 把它接给 sampler,Shader 用 texelFetch 按整数索引读取。

5. SSBO:Buffer 加一个“结构体数组接口”

SSBO 全称 Shader Storage Buffer Object,自 OpenGL 4.3 起进入核心功能,可让多个 Shader 阶段读写较大的结构化数据;本例只读。Khronos 对 4.3 的概述见 OpenGL 4.3 发布说明

本例用 SSBO 保存压紧后的可见 LOD 树,Vertex Shader 根据每个点的空间编码向下遍历,找到最深可见层级的 spacing,再计算自适应点大小。

5.1 std430 下的数据对齐

CPU:

struct VisibleNode
{
    std::uint32_t childMask;
    std::uint32_t firstChild;
    std::uint32_t visible;
    float spacing;
};
static_assert(sizeof(VisibleNode) == 16);

GLSL:

struct VisibleNode
{
    uint childMask;
    uint firstChild;
    uint visible;
    float spacing;
};

layout(std430) readonly buffer VisibleNodeBuffer
{
    VisibleNode visibleNodes[];
};

四个 4 字节标量连续排列,单条正好 16 字节,C++ 与 GLSL 一致。真实项目里不要仅凭感觉判断对齐,至少应配合:

  • static_assert(sizeof(...))
  • 必要时检查 offsetof
  • vec3、矩阵、嵌套结构体和数组格外谨慎。

5.2 SSBO 的完整生命周期

第一步:重建 CPU 侧扁平数组。

Mapper 先从可见且已上传的节点构建逻辑八叉树,再压成连续数组:父节点保存 childMaskfirstChild,子节点按八分区顺序连续放置。Shader 可以用 bitCount 计算某个孩子在连续子数组中的偏移。

第二步:创建并上传 Buffer。

glGenBuffers(1, &lodBuffer);
glBindBuffer(GL_SHADER_STORAGE_BUFFER, lodBuffer);
glBufferData(GL_SHADER_STORAGE_BUFFER,
             nodes.size() * sizeof(VisibleNode),
             nodes.data(),
             GL_DYNAMIC_DRAW);

项目封装上传时传入了 ArrayBuffer target,随后却把同一个 handle 当 SSBO 绑定。这是合法的:Buffer Object 的存储本身没有永久“类型”,target 主要决定某次 API 操作通过哪个绑定点访问它。 “VBO/SSBO”更多是使用角色,不是创建时写死的对象种类。

为了可读性,若直接写原生 OpenGL,我仍会在 SSBO 相关代码中统一使用 GL_SHADER_STORAGE_BUFFER

第三步:把 Shader block 与 binding point 对上。

当前 Shader 没有直接写 layout(binding = N),因此 CPU 侧先查询 block,再指定绑定点:

GLuint block = glGetProgramResourceIndex(
    program,
    GL_SHADER_STORAGE_BLOCK,
    "VisibleNodeBuffer");

glShaderStorageBlockBinding(program, block, kVisibleBinding);

这一步建立:

Shader 中名为 VisibleNodeBuffer 的 block → binding point N

第四步:把具体 Buffer handle 接到同一个 binding point。

glBindBufferBase(GL_SHADER_STORAGE_BUFFER,
                 kVisibleBinding,
                 lodBuffer);

这一步建立:

binding point N → 本帧要读的 lodBuffer

两步合起来,Shader 才能找到实际数据。也可以在 GLSL 4.30 中直接写:

layout(std430, binding = 4) readonly buffer VisibleNodeBuffer { ... };

这样可省去 glShaderStorageBlockBinding,但 binding 值进入 Shader 源码;大型项目通常要统一规划,避免不同模块冲突。

第五步:Shader 按结构体数组读取。

VisibleNode entry = visibleNodes[nodeIndex];

与 TBO 不同,这里不需要 sampler 和 texelFetch;GLSL block 已经描述了结构体布局。

第六步:同步、解绑与释放。

本例由 CPU 上传、同一渲染流程随后只读,不存在前一个 GPU pass 写 SSBO 的情况。如果将来由 Compute Shader 写、Vertex Shader 再读,就必须根据生产者和消费者插入合适的 glMemoryBarrier。绘制后可将 binding point 绑定到 0;上下文释放时销毁 Buffer。

5.3 一句话记住 SSBO

SSBO = Buffer 存字节,binding point 负责接线,std430 buffer block 负责解释,Shader 像访问结构体数组一样访问它。

6. TBO 与 SSBO 到底怎么选

维度 TBO SSBO
Shader 接口 samplerBuffer buffer block
绑定方式 texture unit + sampler uniform indexed binding point
数据解释 glTexBuffer 的单一内部格式 GLSL 结构体 + std430 等布局
读取方式 texelFetch(sampler, index) array[index].field
写入 sampler 路径只读 可读写,可加 readonly/writeonly
版本印象 OpenGL 3.1 核心已有 Buffer Texture OpenGL 4.3 核心
更适合 同质 texel、查表、较简单记录 复杂结构体、大数组、GPU 读写与原子操作

本 Mapper 的选择很合理:

  • 编辑表每条固定为四个无符号整数,而且只需二分查找与读取,因此 TBO 的 RGBA32UI + uvec4 很直接;
  • LOD 节点有明确字段语义,并希望按结构体数组遍历,因此 SSBO 更自然。

不要简单记成“TBO 小、SSBO 大”。容量确实是考量,但更值得记的是 访问模型和数据表达方式

7. 绘制阶段:为什么使用 glMultiDrawArrays

每个 Page 中,一个可见节点对应一段 first + count。Mapper 按 LOD level 整理为多个 draw group:

glMultiDrawArrays(GL_POINTS,
                  group.firsts.data(),
                  group.counts.data(),
                  static_cast<GLsizei>(group.firsts.size()));

这样一个 Page 内的多个不连续节点区间可以合并为一次调用。切换 LOD group 时只更新 drawNodeLevel uniform,用于点大小计算,而不需要重传顶点数据。

这条主线可以概括为:

节点数据 → Page 区间 → VBO 局部上传 → VAO 解释
        → 可见区间数组 → glMultiDrawArrays(GL_POINTS)

TBO 与 SSBO 不负责“发出顶点”,而是在 Vertex Shader 处理 VBO 顶点时提供辅助查询数据。

8. 容易踩坑的地方

8.1 把 VAO 当成顶点数据

VAO 只保存顶点输入状态和对 Buffer 的引用,不保存点数组副本。释放或替换 VBO 后,相关 VAO 状态也必须重新检查。

8.2 TBO 只建 Buffer,不建 Texture View

只有 Buffer handle 时,samplerBuffer 还不能读。需要 Texture Object,并用 glTexBuffer 指定内部格式和关联的 Buffer。

8.3 SSBO 只绑定 Buffer,却没连接 Shader block

若 Shader 未写显式 binding,就必须用 glShaderStorageBlockBinding 把 block index 映射到 binding point,再用 glBindBufferBase 把具体 Buffer 接到相同 binding point。

8.4 C++ 与 GLSL 布局不一致

TBO 检查“一条 C++ 记录是否正好等于一个 texel”;SSBO 检查 C++ 对齐是否满足 GLSL 布局规则。sizeof 相等是底线,但复杂结构还要检查成员 offset。

8.5 释放 OpenGL 资源时没有当前上下文

Mapper 把空 Page 标记为 releasePending,等到 Render() 中上下文有效时再真正释放。这比在任意业务线程直接删除 GPU 资源安全。

8.6 忘记处理上下文重建

窗口或上下文释放后,GPU 对象失效。该 Mapper 会释放 VAO/VBO/TBO/SSBO,清空相关句柄,把仍存在的节点重新加入上传队列,并把 dirty flag 复位。

8.7 误以为只读 SSBO 永远不需要 barrier

readonly 只限制当前 Shader 的写操作,不替你处理跨 pass 可见性。是否需要 barrier 取决于数据之前是否由 GPU 写入,以及之后通过哪类接口读取。

9. 调试时按这张清单排查

遇到黑屏、随机值或驱动报错时,我会按下面顺序检查:

  1. 当前 OpenGL 上下文是否有效,版本是否满足 SSBO 要求;
  2. Buffer handle 是否非 0,实际分配字节数是否足够;
  3. 上传的 offset、count 和总容量是否越界;
  4. VAO 的字段 offset、stride、类型、normalized 是否与 C++ 结构一致;
  5. TBO 的内部格式是否与 sampler 类型、C++ 单条记录大小一致;
  6. sampler uniform 是否写入了正确的 texture unit,而不是 Texture handle;
  7. SSBO 的 block 是否映射到预期 binding point;
  8. glBindBufferBase 使用的 binding point 与上一步是否相同;
  9. std430 下的成员偏移是否一致;
  10. 跨 GPU pass 时是否缺少 memory barrier;
  11. draw 的 first/count 是否只覆盖已上传且可见的区间;
  12. 释放或 Shader 重编译后,VAO 与绑定缓存是否失效并重建。

开发阶段还可以开启 KHR_debug,在关键阶段插入 debug group,并用 RenderDoc 检查 Buffer 内容、VAO 属性和绑定点状态。

10. 最后再背一次

VBO:申请字节 → 上传顶点 → VAO 绑定格式 → Shader in 读取
TBO:申请字节 → 上传记录 → glTexBuffer 包装 → 纹理单元绑定 → texelFetch
SSBO:申请字节 → 上传结构 → block/binding point 接线 → std430 数组读取
VAO:不保存顶点;它保存“哪个 VBO 的哪些字节,应当怎样成为 Shader 输入”

如果只能记住一句:

Buffer 解决“数据放哪儿”,绑定解决“Shader 去哪儿找”,布局与格式解决“这些字节是什么意思”。

参考资料

posted @ 2026-08-12 16:13  Ytytyty  阅读(11)  评论(0)    收藏  举报