记录一次 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

所以,我会按这个顺序继续优化:

  1. 先减少绘制调用,把同类几何体合并提交。
  2. 再缓存不常变化的顶点,避免每帧重新生成。
  3. 若测量仍显示顶点传输成本较高,再考虑 VertexBuffer。

采用顶点缓冲时,还需要处理支持情况、创建和更新是否成功,以及场景加载、修改、卸载时的同步。它适合有明确收益的场景,不必为了几百个顶点一开始就增加这些维护工作。

7. 这次实践留下的提醒

这次优化让我意识到,看起来简单的画面,也可能因为提交方式产生明显开销。以后遇到大量重复图元的绘制问题,我会先统计每帧的调用次数,再检查哪些对象能在保留显示顺序的前提下合并。

后续如果补充性能记录,我会在相同场景、相同窗口与相机配置下,比较优化前后的帧时间和绘制耗时,并注明垂直同步、帧率限制等设置。调用次数解释了改动的原理,耗时数据才能说明它对实际运行的帮助。

对我来说,这次最值得记住的是:面对大量简单图元,除了看画了多少,还要看分了多少次来画。