记录一次
GridColliderEditor的绘制优化:理解 draw call 的开销,用sf::VertexArray合并网格线和碰撞格子,再梳理顶点缓存与sf::VertexBuffer的区别。本文基于 SFML 3,源码分析以 3.0.0 为例。
1. 几百条网格线,为什么也会卡?
在开发网格碰撞编辑器 GridColliderEditor 时,我遇到了绘制网格线导致程序卡顿的问题。
网格本身很简单:一些横线、竖线,再加上表示碰撞区域的格子。每条线只有两个顶点,直觉上不应该有太大的绘制负担。
但检查代码后,我发现原来的实现是在循环里逐条调用 draw()。画一条线就提交一次,画下一条线再提交一次。网格一大,每帧就会出现数百次绘制调用;如果碰撞格子也逐个绘制,调用次数还会继续增加。
这次排查让我开始关注一个此前容易忽略的指标:一帧里到底调用了多少次 draw()?
2. SFML 会自动把多次 draw 合并吗?
从 SFML 3.0.0 的实现来看,顶点绘制接口不会把相邻的多次调用自动收集成一个批次。
沿着顶点绘制路径,可以看到这样的调用关系:
target.draw(vertices, vertexCount, primitiveType)
↓
RenderTarget::drawPrimitives(...)
↓
glDrawArrays(...)
在输入有效、渲染目标激活成功的常规路径下,这个顶点接口每调用一次,就会执行一次底层绘制命令。因此,循环里提交 302 条线,仍然是 302 次底层绘制,而不会因为它们颜色相同就自动变成一次。源码:RenderTarget.cpp
这里的结论针对顶点绘制接口。若调用的是 draw(drawable),实际提交几次还取决于该对象内部的绘制实现,不能把所有 draw() 重载都简单理解为“一次底层绘制”。
vertexCache 也不是批处理缓存
源码里有一个容易让人误解的名字:vertexCache。
它用于少量顶点的 CPU 预变换,使用的是 states.transform,也就是对象变换。相机对应的 view 在另一处处理。这个缓存并不会保留前一次调用的顶点,再与下一次调用拼在一起。源码:顶点预变换与绘制状态设置
理解这一点后,我把“缓存”拆成了三个不同的问题:是否重复计算顶点、是否重复传输数据、是否重复提交绘制。它们需要分别考虑。
3. 小图元很多,提交开销也会累积
一次绘制除了处理顶点和像素,还涉及函数调用、绘制状态检查,以及驱动处理绘制命令的开销。SFML 的官方顶点数组教程也明确指出,绘制调用次数会显著影响性能,并建议把大量图元组织成较大的批次。官方教程:顶点数组
对于网格线这种简单图元,每次只提交两个顶点,单次调用完成的工作很少,但调用本身的开销仍然存在。几百次、几千次累积起来,就可能成为瓶颈。
不过,仅凭调用次数多,还不能断言 GPU 大部分时间都在处理命令。实际瓶颈也可能在 CPU、驱动、顶点数据构建或其他环节,需要结合耗时测量判断。
对这次网格绘制来说,最直接的改法是:先把所有线的顶点放在一起,再统一绘制。
4. 用 VertexArray,把网格线合成一次提交
sf::VertexArray 可以保存多个图元的顶点。使用 sf::PrimitiveType::Lines 时,每两个顶点组成一条独立的线,因此很适合批量绘制网格。官方教程:顶点数组与图元类型
原来的写法大致如下,仅展示竖线部分:
for (int x = 0; x <= columns; ++x)
{
const float px = origin.x + x * cellSize.x;
const sf::Vertex line[] = {
{{px, origin.y}, gridColor},
{{px, origin.y + height}, gridColor}
};
target.draw(line, 2, sf::PrimitiveType::Lines);
}
循环执行多少次,就提交多少次。横线如果同样处理,也会继续增加调用次数。
整理后的思路是把“构建顶点”和“绘制顶点”分开。下面的函数展示了网格顶点的构建方式,格子数量和尺寸由调用方提供:
#include <SFML/Graphics.hpp>
#include <cstddef>
sf::VertexArray buildGridLines(
int columns,
int rows,
sf::Vector2f origin,
sf::Vector2f cellSize,
sf::Color gridColor)
{
sf::VertexArray lines(sf::PrimitiveType::Lines);
if (columns <= 0 || rows <= 0)
return lines;
const auto lineCount =
(static_cast<std::size_t>(columns) + 1) +
(static_cast<std::size_t>(rows) + 1);
lines.resize(lineCount * 2);
const float width = columns * cellSize.x;
const float height = rows * cellSize.y;
std::size_t index = 0;
for (int x = 0; x <= columns; ++x)
{
const float px = origin.x + x * cellSize.x;
lines[index++] = sf::Vertex{{px, origin.y}, gridColor};
lines[index++] = sf::Vertex{{px, origin.y + height}, gridColor};
}
for (int y = 0; y <= rows; ++y)
{
const float py = origin.y + y * cellSize.y;
lines[index++] = sf::Vertex{{origin.x, py}, gridColor};
lines[index++] = sf::Vertex{{origin.x + width, py}, gridColor};
}
return lines;
}
构建后,绘制阶段只需要:
target.draw(gridLines);
这里要注意两点:columns、rows 表示格子的数量,所以两个方向分别需要 columns + 1、rows + 1 条线;横线和竖线彼此独立,应使用 Lines,否则 LineStrip 会把相邻顶点连续连接起来。
302 次调用,如何变成 1 次?
以 302 条网格线为例,顶点数量是 302 × 2 = 604。逐条绘制与合并绘制的区别如下:
| 对比项 | 逐条绘制 | 合并绘制 |
|---|---|---|
| 网格线数量 | 302 条 | 302 条 |
| 顶点总数 | 604 个 | 604 个 |
| 网格线 draw call | 302 次 | 1 次 |
| 每次提交的顶点数 | 2 个 | 604 个 |
线的数量和顶点总数都没变,改变的是提交方式。
这张表是根据代码结构得到的调用次数对比。它不能直接换算为“帧率提升 302 倍”:减少的是这部分的提交开销,整帧仍然包含其他工作。原始记录没有优化前后的帧时间数据,因此这里不额外给出性能倍数。
5. 碰撞格子也能合批,但 SFML 3 没有 Quads
除了网格线,碰撞格子的填充区域也可以合并绘制。
这里有一个版本差异:SFML 3 移除了 Quads。官方迁移指南给出的一种替代方案,是用两个相邻三角形组成原来的四边形。官方迁移指南:移除 Quads
用独立的 Triangles 表示矩形时,每个矩形需要六个顶点:
void appendCell(
sf::VertexArray& cells,
sf::Vector2f topLeft,
sf::Vector2f size,
sf::Color color)
{
const float x0 = topLeft.x;
const float y0 = topLeft.y;
const float x1 = x0 + size.x;
const float y1 = y0 + size.y;
// 第一个三角形:左上、右上、右下
cells.append(sf::Vertex{{x0, y0}, color});
cells.append(sf::Vertex{{x1, y0}, color});
cells.append(sf::Vertex{{x1, y1}, color});
// 第二个三角形:左上、右下、左下
cells.append(sf::Vertex{{x0, y0}, color});
cells.append(sf::Vertex{{x1, y1}, color});
cells.append(sf::Vertex{{x0, y1}, color});
}
创建数组时指定 sf::PrimitiveType::Triangles,遍历碰撞格子并追加顶点,最后统一调用 target.draw(collisionCells)。
如果有 K 个碰撞格子,且这些格子的绘制状态兼容,那么网格线与格子填充两部分就可以从原来的 302 + K 次提交,变成两个非空批次、共 2 次提交。场景边界线和其他界面元素仍需另行计算。
这里也不能把“顶点数量不变”推广到所有改动:网格线合批确实保持 604 个顶点;如果把原来的四顶点 Quads 改为独立三角形,每个矩形的顶点数会从 4 个变成 6 个。
合并时,要保留原来的显示关系
批量绘制仍然需要考虑纹理、着色器、混合模式、变换和绘制顺序。不同颜色可以放在各自顶点里,但使用不同绘制状态的对象,通常需要拆成不同批次。官方教程:顶点颜色、纹理与绘制状态
对编辑器而言,还要保留网格线与格子填充的层次关系。如果原来是先画填充、后画网格线,合批后也应保留这个顺序。带透明度或互相遮挡的对象,尤其需要检查最终画面。
6. 下一步:缓存顶点,再考虑 VertexBuffer
合并提交之后,我还需要区分两种进一步优化。
VertexArray 可以构建一次、重复使用
VertexArray 本身不会要求每帧重建。是否重建,取决于代码把构建逻辑放在哪里。
如果每帧都在 draw() 里创建数组、遍历网格,虽然 draw call 已经减少,但顶点生成和内存分配的开销还在。对于很少变化的网格,可以把数组保存为成员,只在格子数量、尺寸、原点或颜色变化时更新,普通帧直接绘制已有数组。
如果只是通过 view 移动或缩放相机,世界坐标下的网格几何通常不需要重新生成;若网格始终跟随屏幕或只生成可见区域,则要根据具体实现更新。
VertexBuffer 减少的是重复传输
sf::VertexBuffer 则把顶点数据保存在图形内存中。对于帧间没有变化的数据,它可以避免像 CPU 端顶点数组那样重复传输顶点;如果数据传输成为瓶颈,这才有机会带来进一步收益。官方文档:VertexBuffer
所以,我会按这个顺序继续优化:
- 先减少绘制调用,把同类几何体合并提交。
- 再缓存不常变化的顶点,避免每帧重新生成。
- 若测量仍显示顶点传输成本较高,再考虑
VertexBuffer。
采用顶点缓冲时,还需要处理支持情况、创建和更新是否成功,以及场景加载、修改、卸载时的同步。它适合有明确收益的场景,不必为了几百个顶点一开始就增加这些维护工作。
7. 这次实践留下的提醒
这次优化让我意识到,看起来简单的画面,也可能因为提交方式产生明显开销。以后遇到大量重复图元的绘制问题,我会先统计每帧的调用次数,再检查哪些对象能在保留显示顺序的前提下合并。
后续如果补充性能记录,我会在相同场景、相同窗口与相机配置下,比较优化前后的帧时间和绘制耗时,并注明垂直同步、帧率限制等设置。调用次数解释了改动的原理,耗时数据才能说明它对实际运行的帮助。
对我来说,这次最值得记住的是:面对大量简单图元,除了看画了多少,还要看分了多少次来画。