Chapter 84: Buffers and Textures
OpenGL 中的 buffer 和 texture 是 draw call 最常接触的两类 GPU 资源。读完本章后,读者应能追踪一帧中顶点、索引、uniform、像素数据从 CPU 侧内存进入 OpenGL 对象,再被 shader 和固定功能采样逻辑读取的路径,并能根据更新频率、绑定目标、映射方式和格式选择判断一个资源路径是否会制造带宽浪费或同步等待。
本章使用一个贯穿场景:一个编辑器视口每帧绘制 5000 个带贴图的调试 quad。CPU 每帧生成 quad 顶点和索引,更新一次相机矩阵和若干材质参数,fragment shader 从一张图集纹理采样。这个场景小到能完整追踪,资源类型又覆盖 VBO、IBO、UBO、texture、sampler、mapping、fence 和 layout。任何 OpenGL buffer 或 texture 设计,最终都要落到这条路径:数据如何存放、如何更新、何时绑定、哪个管线阶段读取、读取时是否连续、CPU 是否等待 GPU。
OpenGL 的资源接口带有强状态机特征。buffer object 本身只是一段由 OpenGL 管理的线性存储;它被绑定到 GL_ARRAY_BUFFER、GL_ELEMENT_ARRAY_BUFFER、GL_UNIFORM_BUFFER 等 target 后,才进入具体用途。texture object 持有图像存储和部分采样参数;sampler object 可以把采样状态从纹理图像中分离出来。Khronos 的 Buffer Object、Buffer Object Streaming、Texture 和 Sampler Object 页面给出了对象语义,本章把这些语义整理成工程判断顺序。
贯穿场景中的资源路径可以先压缩成一张图。图中的重点是所有资源都在 draw 之前变成“GPU 可读状态”,draw call 本身只消费已经绑定好的对象和状态。
这条路径会贯穿本章后续小节。VBO 和 IBO 决定顶点输入带宽,UBO 决定小块常量数据如何批量绑定,texture 与 sampler 决定 fragment shader 的采样成本,mapping 与 fence 决定 CPU 更新资源时是否与上一帧 GPU 读取同一段内存发生冲突。
84.1 OpenGL Buffer Objects and Binding Targets
OpenGL buffer object 是一段由 OpenGL 上下文管理的线性字节存储。它本身没有“顶点缓冲”“索引缓冲”“uniform 缓冲”的固定身份;身份来自绑定 target、绑定时机和后续命令如何读取它。在贯穿场景中,同一类底层对象可以分别承载 quad 顶点、quad 索引和相机参数,但每条路径的绑定点和生命周期差异很大。
Buffer 的第一层判断是 target。GL_ARRAY_BUFFER 通常承载顶点属性数据,GL_ELEMENT_ARRAY_BUFFER 承载索引,GL_UNIFORM_BUFFER 承载 uniform block。还有 GL_PIXEL_UNPACK_BUFFER、GL_PIXEL_PACK_BUFFER、GL_SHADER_STORAGE_BUFFER 等 target,但本章聚焦 draw 输入和常量数据。target 影响 OpenGL 命令解释当前绑定对象的方式,也影响状态被记录到哪里。
VBO 的关键边界是“属性指针捕获”。在传统绑定式接口中,调用 glVertexAttribPointer 时,OpenGL 会把当前 GL_ARRAY_BUFFER 绑定的 buffer 记录到当前 VAO 的属性状态里。后续重新绑定 GL_ARRAY_BUFFER 并不会自动改变已经记录好的属性来源。贯穿场景中的 quad 顶点 ring buffer 每帧换 offset 时,代码要么重新设置 attribute pointer,要么使用支持 offset 的绑定模型把 buffer binding 和 attribute format 分离。
IBO 的关键边界是“索引绑定属于 VAO 状态”。GL_ELEMENT_ARRAY_BUFFER 的绑定会被当前 VAO 记录。调试 quad 使用同一套索引布局时,VAO 可以长期持有 index buffer;如果每帧从 ring buffer 分配新的索引段,draw call 的 index pointer 参数和 base offset 必须与本帧写入区间对应。索引错位常表现为三角形随机飞出、纹理坐标错接或 draw 数量正确但画面结构破碎。
UBO 的关键边界是“target 绑定和 indexed binding”。更新 UBO 数据时可以把对象绑定到 GL_UNIFORM_BUFFER;真正让 shader 的 uniform block 读取某个 buffer 区间时,需要通过 glBindBufferBase 或 glBindBufferRange 绑定到 indexed binding point。Program 中的 uniform block index 还要映射到同一个 binding point。贯穿场景中的相机矩阵适合放在每帧更新一次的 UBO 区间,而材质数组、光源列表或对象实例参数会根据大小和访问模式转向 UBO、SSBO 或 texture buffer。
一个最小 draw 输入可以写成下面这样。代码展示的是状态捕获关系,省略了 shader 编译和错误检查。
GLuint vao = 0;
GLuint vertex_buffer = 0;
GLuint index_buffer = 0;
struct Vertex {
float px, py;
float u, v;
};
glCreateVertexArrays(1, &vao);
glCreateBuffers(1, &vertex_buffer);
glCreateBuffers(1, &index_buffer);
glNamedBufferData(vertex_buffer, vertex_bytes, vertex_data, GL_STREAM_DRAW);
glNamedBufferData(index_buffer, index_bytes, index_data, GL_STREAM_DRAW);
glVertexArrayVertexBuffer(vao, 0, vertex_buffer, 0, sizeof(Vertex));
glEnableVertexArrayAttrib(vao, 0);
glVertexArrayAttribFormat(vao, 0, 2, GL_FLOAT, GL_FALSE, offsetof(Vertex, px));
glVertexArrayAttribBinding(vao, 0, 0);
glEnableVertexArrayAttrib(vao, 1);
glVertexArrayAttribFormat(vao, 1, 2, GL_FLOAT, GL_FALSE, offsetof(Vertex, u));
glVertexArrayAttribBinding(vao, 1, 0);
glVertexArrayElementBuffer(vao, index_buffer);
这段代码使用 Direct State Access(DSA)风格,把“对象是谁”和“当前 context 绑定了什么”分开。它的工程收益是降低状态污染概率:VAO 明确记录 vertex buffer、attribute format 和 element buffer。绑定式接口也能完成同样工作,但需要在调用 glVertexAttribPointer 时精确控制当前 GL_ARRAY_BUFFER。
Buffer 的第二层判断是数据生命周期。静态网格顶点通常一次上传、多帧读取,可以使用长期存储并少量绑定;调试 quad 顶点每帧重写,属于 stream data;相机 UBO 每帧更新一次,数据小但 draw 共享范围广。GL_STATIC_DRAW、GL_DYNAMIC_DRAW、GL_STREAM_DRAW 是 usage hint,它们表达访问意图,实际内存放置和性能结果仍由驱动、硬件和访问模式共同决定。工程上应把它们当作声明输入,再用帧捕获和耗时证据验证。
Buffer 的第三层判断是“谁读写”。CPU 直接上传、GPU draw 读取,是最常见的 VBO/IBO 路径;GPU 写入 transform feedback 或 compute 输出,再由 draw 读取,是 server-side 路径;CPU 读回结果会引入更强同步约束。贯穿场景只需要 CPU 写入、GPU 读取,因此优化目标是让 CPU 每帧写入新的内存区间,让 GPU 顺序读取已经完成的区间。
84.2 纹理对象与采样状态
Texture object 持有图像存储、mip 层级、internal format、部分参数和 swizzle 等状态。Sampler object 持有 wrap、filter、LOD clamp、compare mode 等采样状态。贯穿场景的图集纹理可以长期驻留在 GPU 侧,fragment shader 每个像素用 UV 采样;采样结果是否正确,取决于 texture storage、texture unit、sampler state、shader uniform 和 UV 插值共同组成的路径。
纹理的第一层判断是 format。internalformat 描述 GPU 侧如何存储 texel,例如 GL_RGBA8、GL_SRGB8_ALPHA8、GL_RGBA16F。format 和 type 描述 CPU 上传数据的布局,例如 GL_RGBA 搭配 GL_UNSIGNED_BYTE。如果 internal format 与 shader 期望的颜色空间、精度和通道数不匹配,画面会出现偏色、透明错误、HDR 截断或 bandwidth 过高。UI 图集通常使用 GL_RGBA8 或 sRGB 格式;法线贴图会使用线性格式;HDR 中间纹理会使用 floating-point 格式。
纹理的第二层判断是 mip。Mip level 是同一纹理在不同分辨率下的预过滤版本。远处或缩小时,fragment shader 对纹理的采样 footprint 会覆盖多个 texel;mipmap 让硬件从合适层级读取,降低 aliasing 和缓存压力。调试 quad 如果始终是屏幕空间 UI,可以关闭 mip 或使用单层 mip;场景中的材质图集随距离缩小时,应生成完整 mip 链,并处理图集边缘 padding。
纹理的第三层判断是 wrap/filter。Wrap 决定 UV 超出 [0, 1] 范围时如何取样,filter 决定放大和缩小时如何在 texel 与 mip 之间插值。图集场景常用 GL_CLAMP_TO_EDGE,因为重复采样会把相邻子图带入边缘;普通 tiled material 常用 repeat。Filter 的选择直接改变视觉和采样成本:nearest 适合像素风格,linear 适合连续图像,trilinear 读取两个 mip 层,anisotropic filtering 会增加倾斜视角下的采样量。
Sampler object 的工程价值是把“图像内容”和“采样方式”拆开。同一张图集可以用 nearest sampler 绘制像素 UI,也可以用 linear sampler 绘制缩放后的预览;同一 sampler 可以绑定给多张纹理,减少重复状态设置。没有 sampler object 时,采样参数保存在 texture object 上,状态组合更容易散落在资源加载和渲染代码中。
一个清晰的纹理创建路径应显式写出 storage、upload 和 sampler。下面代码使用 immutable texture storage,适合运行时尺寸和格式固定的图集。
GLuint atlas = 0;
GLuint atlas_sampler = 0;
glCreateTextures(GL_TEXTURE_2D, 1, &atlas);
glTextureStorage2D(atlas, mip_count, GL_SRGB8_ALPHA8, width, height);
glTextureSubImage2D(atlas, 0, 0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
glGenerateTextureMipmap(atlas);
glCreateSamplers(1, &atlas_sampler);
glSamplerParameteri(atlas_sampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glSamplerParameteri(atlas_sampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glSamplerParameteri(atlas_sampler, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR);
glSamplerParameteri(atlas_sampler, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTextureUnit(0, atlas);
glBindSampler(0, atlas_sampler);
glProgramUniform1i(program, atlas_uniform_location, 0);
这段代码把 texture 绑定到 texture unit 0,把 sampler 也绑定到同一个 unit,再把 shader 的 sampler uniform 设为 0。排查错误纹理时,检查顺序应按这个路径推进:texture object 是否有完整 storage,上传格式是否匹配,mip 是否完整,sampler 是否绑定到同一 unit,program uniform 是否指向该 unit,fragment shader 的 UV 是否落在预期子区域。
Swizzle 适合处理通道重排。单通道字体图可以使用 GL_R8 存储,再通过 swizzle 把 red 通道扩展到 alpha 或 rgb 路径;这样可以减少纹理带宽和内存占用。Swizzle 属于 texture state,sampler object 管采样方式,两者需要分清。把通道映射写清楚,可以让 shader 保持稳定,也能让资源导入阶段统一处理灰度图、遮罩图和打包材质图。
84.3 高效缓冲更新与数据组织策略
动态 buffer 更新的核心问题是“CPU 写入的区间是否仍被 GPU 使用”。贯穿场景每帧生成调试 quad 顶点和索引,如果 CPU 反复覆盖同一段 VBO,而上一帧 draw 还在 GPU 队列中读取这段数据,驱动需要插入等待、复制或重新分配。高效更新策略都围绕一个目标展开:让 CPU 写入新区域,让 GPU 读取旧区域,两者通过明确的区间和同步关系解耦。
最基础的更新方式是 glBufferSubData。它适合小块数据或低频更新,例如每帧几十字节的相机参数。它的优点是代码清晰,驱动负责从 CPU 内存复制到 buffer storage;风险是更新区间如果正被 GPU 读取,驱动可能等待或把数据复制到新的内部存储。对于小 UBO,glBufferSubData 常能满足需求;对于每帧大量顶点,持续用它覆盖同一区间会把性能风险交给驱动。
Orphaning 是常见 stream 策略。调用 glBufferData(target, size, nullptr, usage) 或等价重分配路径时,应用告诉驱动当前旧存储可以留给 GPU 继续读取,新写入使用一块新的存储。随后用 glBufferSubData 或 map 写入新内容。它适合整块 buffer 每帧更新的情况,代码简单,依赖驱动管理后台存储池。贯穿场景中,如果每帧 quad 数量大致稳定,orphaning 可以把“覆盖旧数据”的冲突改成“分配新存储后写入”。
Persistent mapping 是更显式的 stream 策略。使用 glBufferStorage 创建 immutable storage,并带上 GL_MAP_WRITE_BIT、GL_MAP_PERSISTENT_BIT 等标志后,CPU 可以长期持有映射指针。应用把大 buffer 划成 ring,逐帧写入不同区间,并用 fence 判断旧区间何时可重新使用。这个方案减少每帧 map/unmap 调用和中间复制,但要求应用自己管理对齐、flush、wrap-around 和 fence。
Ring buffer 把时间维度编码进内存布局。贯穿场景可以为顶点和索引各分配一个几 MB 的 persistent mapped buffer。每帧从 write head 分配一段连续区间,写入本帧 quad 数据,draw call 使用对应 byte offset;帧结束时在该区间后放置 fence。后续帧如果 write head 即将覆盖某个仍在飞行的区间,CPU 先查询 fence 状态,确认 GPU 已经读完再复用该区间。
下面的伪代码展示 ring 分配逻辑。它强调区间生命周期,省略了完整错误处理和平台封装。
struct UploadSlice {
GLsizeiptr offset;
GLsizeiptr size;
};
UploadSlice allocate_slice(RingBuffer& ring, GLsizeiptr bytes, GLsizeiptr alignment) {
GLsizeiptr aligned_head = align_up(ring.head, alignment);
if (aligned_head + bytes > ring.capacity) {
aligned_head = 0;
}
wait_until_region_is_free(ring, aligned_head, bytes);
UploadSlice slice { aligned_head, bytes };
ring.head = aligned_head + bytes;
return slice;
}
这个伪代码的判断点是 wait_until_region_is_free。高效实现通常先轮询 fence,发现区间仍被使用时选择下一段空闲空间;只有整个 ring 都耗尽时才阻塞。Ring 太小会频繁等待,ring 过大会提高内存占用。工程上可用“最大连续几帧的动态顶点字节数”给初始容量,再用工具观察 CPU 是否在同步点等待。
数据组织策略决定 GPU 读取是否连续。Interleaved layout 把同一顶点的 position、uv、color 等属性放在一个结构里,适合每个 draw 都读取大部分属性的情况。Planar layout 把 position、normal、uv 分成不同 buffer 或不同段,适合某些 pass 只读取少量属性的情况,例如 depth pre-pass 只需要 position。贯穿场景的 quad vertex 只包含 position、uv、color,interleaved layout 能让 vertex fetch 连续读取同一顶点所需数据。
一个合理的 quad vertex layout 应把 stride、alignment 和实际 shader 输入对应起来。下面的结构适合 UI 或调试 overlay,颜色使用 32-bit 打包以降低带宽。
struct DebugQuadVertex {
float position_x;
float position_y;
float texcoord_x;
float texcoord_y;
std::uint32_t color_rgba8;
};
这个 layout 的每个顶点为 20 字节。若平台或驱动对 vertex fetch 对齐敏感,可以把结构 padding 到 24 或 32 字节后再验证实际收益。这里的判断依据是 capture 中的 vertex input 配置、GPU 耗时和带宽相关计数;单靠结构大小无法推出最终性能。若 fragment shader 成本远高于 vertex fetch,压缩 vertex layout 的收益会被像素采样和 blend 开销覆盖。
84.4 同步与映射机制
Mapping 把 buffer storage 的某个区间暴露成 CPU 指针。glMapBuffer 映射整个 buffer,glMapBufferRange 能指定 offset、size 和访问标志。映射本身只是访问入口;真正的性能边界来自同一区间是否正被 GPU 使用、CPU 写入何时对 GPU 可见、GPU 读取何时完成,以及应用是否把这些状态显式化。
传统 map/unmap 的生命周期较短。应用 map 一段 buffer,写入数据,unmap 后提交 draw。这个路径清晰,但每帧调用次数多时会增加驱动开销。若没有 invalidate 标志,map 可能为了保持旧数据而等待 GPU 或复制旧内容。使用 GL_MAP_INVALIDATE_BUFFER_BIT 或 GL_MAP_INVALIDATE_RANGE_BIT 可以声明旧内容不再需要,从而给驱动更自由的存储选择。
Persistent mapping 把指针生命周期拉长。创建时使用 glBufferStorage,映射时使用 GL_MAP_PERSISTENT_BIT。若还使用 GL_MAP_COHERENT_BIT,CPU 写入与 OpenGL 访问的可见性模型更直接,但不同平台仍要遵守同步和缓存刷新约定。若使用非 coherent persistent mapping,应用需要在提交 draw 前对写入区间调用 glFlushMappedBufferRange 或使用相应 barrier,让 GPU 能读取最新数据。
Fence sync 是 ring buffer 的区间凭证。glFenceSync 放入 GL 命令流后,表示它之前的命令执行到该点时 fence 会变为 signaled。贯穿场景每帧写入一个顶点区间并提交 draw 后,可以把 fence 与该区间绑定。后续准备复用这个区间时,用 glClientWaitSync 或 glGetSynciv 查询 fence,确认 GPU 已完成读取。这个判断比“等待两帧”更可观察,也能适应不同 GPU 队列深度。
同步路径可以按区间写清楚。
// CPU 写入本帧动态顶点区间。
std::memcpy(static_cast<std::uint8_t*>(mapped_ptr) + slice.offset, vertices, slice.size);
// 非 coherent mapping 需要刷新写入区间。
glFlushMappedNamedBufferRange(vertex_buffer, slice.offset, slice.size);
// draw 使用同一段 buffer offset。
glBindVertexArray(vao);
glDrawElementsBaseVertex(GL_TRIANGLES, index_count, GL_UNSIGNED_SHORT, index_offset_ptr, base_vertex);
// 在 draw 之后记录 fence,后续复用 slice 前检查它。
GLsync fence = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
track_region(slice.offset, slice.size, fence);
这段代码的关键点是 fence 放在 draw 之后。Fence 保护的是本帧提交的 GPU 读取,而非 CPU 已经完成的写入。应用复用区间前等待 fence,含义是 GPU 已经越过使用该区间的命令。把 fence 放在上传之后、draw 之前,会得到错误的保护边界:CPU 可能很快看到 fence 完成,但 GPU 后续 draw 仍会读取该区间。
Driver stall 常由隐式同步触发。典型信号包括某次 map 调用耗时异常、glBufferSubData 偶发阻塞、draw 数量没变但 CPU frame time 抖动、profile 中出现 waiting for GPU 或 buffer update 相关等待。排查顺序应先定位阻塞 API,再看它访问的 buffer 区间是否与在飞行 draw 重叠,随后检查是否使用 orphaning、invalidate、ring buffer 或 fence 把区间拆开。
Flush 和 invalidate 解决的问题不同。Flush 让 CPU 写入的 mapped range 对 GPU 可见;invalidate 声明旧内容失效,帮助驱动丢弃或换用存储。二者都属于“告诉驱动访问意图”的机制。贯穿场景中,持续写入新 ring 区间时,flush 关注新数据可见性;准备整块重写时,invalidate 关注旧数据保留成本。
84.5 性能与内存布局优化策略
Buffer 与 texture 优化的共同目标是控制带宽、缓存局部性和同步等待。贯穿场景看起来只是绘制调试 quad,但性能问题可能来自三个位置:CPU 上传动态顶点时等待旧区间,vertex shader 读取过宽的顶点格式,fragment shader 对图集纹理产生低局部性采样。优化顺序应先判断瓶颈类别,再调整资源布局。
顶点带宽的第一步是减少无效属性。屏幕空间 quad 不需要 normal、tangent、joint、weight,也不需要 32-bit float color。把 color 压成 UNSIGNED_BYTE normalized,position 根据坐标范围选择 16-bit 或 32-bit,UV 根据图集精度选择 half 或 normalized fixed-point,都能降低 vertex fetch 字节数。压缩属性会增加解码或精度边界,因此要观察画面边缘、文字清晰度和 shader 指令成本。
Interleaved 与 planar 的选择应由 pass 读取集合决定。Forward 或 UI pass 一次读取所有属性,interleaved 通常更直接;depth-only pass 只读取 position,planar 或单独 position buffer 可以减少无效读取;GPU skinning 可能需要权重、joint、position、normal 等多组数据,布局要同时考虑 cache line、attribute fetch 和更新频率。把静态属性与动态属性拆开也常见:静态 mesh position/uv 常驻,动态 instance transform 或颜色单独 stream。
Texture format 的第一步是用与用途匹配的 internal format。颜色图在参与显示输出时要区分 sRGB 与线性空间;法线、roughness、metallic、mask 这类数据贴图通常使用线性格式;HDR buffer 使用 16-bit float 或更高精度;单通道遮罩使用 GL_R8 或对应压缩格式。格式过宽会提高内存和带宽,格式过窄会产生 banding、法线台阶或 alpha 边缘问题。
Mip 与 cache behavior 要一起判断。缺少 mip 的远处纹理会产生闪烁,也会让 fragment shader 在一个像素 footprint 中访问跨度很大的 texel 区域,降低缓存局部性。图集生成 mip 时需要 padding 或预扩边,因为 mip 过滤会混入相邻子图颜色。贯穿场景中的 debug atlas 如果包含文字和图标,生成 mip 后要检查小尺寸下是否糊边、串色或 alpha 泄漏。
Texture binding 的优化依赖批次组织。OpenGL 传统 texture unit 数量有限,频繁切换 texture 会增加状态设置和驱动验证成本。图集把多张小图合并成一张 texture,能减少 bind 次数,但会带来 UV 打包、padding、mip bleeding 和最大尺寸限制。若材质数量更大,可以考虑 array texture、bindless texture 扩展或分批排序;这些方案需要结合目标 OpenGL 版本和扩展支持判断。
Buffer update 策略可以按数据量分层。小于几个 KB 的每帧常量数据先用 UBO ring 或 glBufferSubData 验证;几十 KB 到几 MB 的动态顶点使用 orphaning 或 persistent mapped ring;GPU 生成再 GPU 消费的数据留在 server-side buffer,减少 CPU 读回;需要 CPU 读回的统计或截图走 PBO 和延迟读取。这个分层的依据是数据量、更新频率、读取阶段和同步方向。
一个可复用的优化检查顺序如下。先看 CPU 时间:如果 buffer update 或 map 调用阻塞,优先处理区间复用和 fence。再看 GPU 时间:如果 vertex input 或 memory bandwidth 占比高,检查 vertex stride、属性读取集合和 interleaved/planar 布局。随后看 fragment 阶段:如果 texture sampling 或 color output 成本高,检查 format、mip、filter、图集 padding、overdraw 和 blend。最后看状态提交:如果 draw call 多且 texture/buffer 切换频繁,按 program、texture、sampler、UBO 和 blend/depth state 组织批次。
贯穿场景的合理落地方案可以归纳为:静态图集使用 immutable texture storage 和独立 sampler;动态 quad 顶点和索引使用 persistent mapped ring 或 orphaning;相机矩阵使用 UBO ring;VAO 固定 attribute format;draw call 按 texture atlas 和 shader 排序;每个 ring 区间用 fence 管理复用。这个方案把视觉结果、资源生命周期、管线读取和同步证据连接起来,能解释大部分 OpenGL buffer/texture 性能现象。
最小自检任务
给定一个 OpenGL 编辑器视口:每帧绘制 8000 个带图标的 2D quad。当前实现每帧调用 glBufferSubData 覆盖同一个 VBO 和 IBO,所有图标来自一张 GL_RGBA8 图集,采样参数保存在 texture object 上,远处缩小时图标边缘出现串色,CPU frame time 偶发从 4ms 跳到 18ms。请给出排查和修改顺序,要求说明 buffer、texture、sampler、同步和布局各自回答的问题。
答案要点
先定位 CPU 抖动来自哪个 API。若 glBufferSubData 或 map 调用耗时异常,说明 CPU 更新区间可能与 GPU 仍在读取的区间重叠。修改顺序是把动态 VBO/IBO 改成 orphaning 或 persistent mapped ring,每帧写入新 slice,draw 之后为 slice 记录 fence,复用 slice 前查询 fence。这样把“覆盖同一存储”改成“按时间划分区间”。
再检查 vertex layout。2D quad 需要 position、UV、颜色和可选图标索引,normal、tangent、骨骼权重等属性应从这个 pass 的 vertex format 中移除。颜色可用 packed RGBA8,position 与 UV 根据精度需求选择 16-bit 或 32-bit。判断依据是 vertex stride、draw 数量、GPU vertex input 耗时和画面精度。
随后检查纹理路径。GL_RGBA8 图集适合普通 UI 颜色图;若图标参与 sRGB 输出,应确认 internal format 和采样后的颜色空间一致。远处串色通常与 mip 过滤和图集 padding 有关,应为每个子图扩边并重新生成 mip,或对始终屏幕空间显示的 UI 图标关闭 mip。Wrap 应使用 GL_CLAMP_TO_EDGE,filter 根据缩放需求选择 linear、nearest 或 mipmap filter。
然后把采样状态从 texture object 中抽出到 sampler object。这样同一张图集可以配 nearest 或 linear sampler,也能把 wrap/filter/LOD clamp 集中管理。排查时按 texture storage、mip 完整性、sampler unit、shader sampler uniform、UV 子区域顺序检查。
最后验证批次和状态提交。按 shader、texture atlas、sampler、UBO 和 blend state 排序 quad,减少重复绑定和 driver validation。若优化后 CPU 抖动消失但 GPU 时间仍高,应继续检查 overdraw、blend、texture filter 和 framebuffer 带宽。
本章知识点总结
- Buffer 身份:OpenGL buffer object 是线性存储,VBO、IBO、UBO 的工程身份来自绑定 target、绑定时机和管线读取方式。
- VAO 捕获:顶点属性来源在设置 attribute pointer 或 DSA vertex binding 时进入 VAO 状态,后续普通绑定不会自动改变已捕获来源。
- 索引边界:
GL_ELEMENT_ARRAY_BUFFER绑定属于 VAO 状态,索引 offset 与本帧写入区间必须对应。 - UBO 绑定:UBO 更新 target 和 shader 读取的 indexed binding point 是两层关系,uniform block 需要映射到正确 binding point。
- Texture 存储:Texture object 持有图像存储、format、mip、swizzle 等状态,format 选择直接影响颜色正确性、精度和带宽。
- Sampler 状态:Sampler object 把 wrap、filter、LOD 等采样状态从图像内容中分离,使同一纹理能服务不同采样策略。
- 动态更新:高效 stream buffer 更新要让 CPU 写入新内存区间,让 GPU 继续读取旧区间,并用 orphaning 或 ring buffer 降低隐式同步概率。
- Persistent 映射:Persistent mapped buffer 用长期映射指针减少每帧 map/unmap 开销,同时要求应用显式管理区间、flush 和 fence。
- Fence 语义:Fence 放在 draw 之后才能表示该 draw 之前的 GPU 读取完成,区间复用前应检查对应 fence。
- Flush 边界:Flush 负责让 mapped range 的 CPU 写入对 GPU 可见,invalidate 负责声明旧内容失效并降低保留旧数据的成本。
- 布局选择:Interleaved layout 适合一次读取大部分属性的 pass,planar layout 适合只读取少量属性或更新频率不同的 pass。
- Mip 影响:Mip 同时影响远距离纹理稳定性和缓存局部性,图集使用 mip 时需要 padding 或扩边处理。
- 优化顺序:先定位 CPU 同步等待,再检查 vertex stride 与 buffer 更新,随后分析 texture format、mip/filter、overdraw 和状态切换。