ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++图形绘制实战:帧缓冲、光栅化与呈现优化

C++图形绘制实战:帧缓冲、光栅化与呈现优化

图形绘制这块内容我在带新人的时候反复讲,讲到最后发现真正卡住人的从来不是某个接口怎么调,而是坐标系怎么对、边界怎么判、性能热区在哪。C++ 实例(十二)图形绘制的题目看着像个交作业的练习题,但它背后其实压着一条相当完整的技术链路:像素怎么组织、图元怎么离散化、画布怎么从内存搬到屏幕、画质和帧率怎么平衡。这些东西你在任何一个成熟图形库里都能白嫖到,但只有自己拿 C++ 从头撸过一遍,才知道那些封装替你挡掉了多少脏活。这篇文章适合三类人:刚学完 C++ 语法想找个能跑起来的项目练手的新手、做过业务代码但没碰过图形底层的中级开发者、以及想给课程设计或小工具加个可视化模块的同学。整套代码我尽量不依赖任何重型框架,核心部分纯标准库就能编译运行,最后再讲怎么对接窗口和字符终端两种呈现方式。

1. 开工之前:C++ 图形绘制的三条路线怎么选

1.1 三条路线各自的适用边界

市面上能拿来用 C++ 画图的路子,粗分就三类,每一类的技术栈、调试体验和天花板差别很大,选错了后面返工成本极高。我把它们摊开列一张表,你对照自己的目标挑就行。

路线呈现载体上手成本可控性性能上限典型适用场景
字符画布控制台文本极低高低(万级字符/帧)教学演示、算法可视化、终端小工具
内存位图 + 软件光栅化自管理像素缓冲中等最高中(百万像素/帧,纯 CPU)图形算法练习、图像处理前置、渲染管线入门
调用系统或第三方图形接口窗口 / GPU中高中高桌面工具、交互式应用、实时动画

字符画布这条路的好处是零依赖、跨平台、贴到哪儿都能跑,缺点是分辨率天然被字符网格锁死,一个字符就是一大块像素,画个斜线出来全是锯齿。它最大的价值在于把"光栅化"这件事变得可视化——你能亲眼看到一条理想直线是怎么被拆成一个个离散格子的。

内存位图路线是我最推荐的练习路径。你自己开一块width * height * 4字节的缓冲区,往里面写颜色,写完再决定怎么显示出去。它的妙处在于"画"和"显示"彻底解耦:同一份绘制代码,输出到字符终端是字符画,输出到窗口就是正常图片,中间只换一个呈现层。这种分层设计在后面扩展功能时省下的力气,比一开始就图省事直接调接口要多得多。

调图形接口这条路,优点是直接拿到硬件加速、有现成的插值、有成熟的事件循环,缺点是你很容易写三年还在"调参数",对底下发生了什么一无所知。常见的情况是:有人能熟练地画矩形、贴纹理,但一遇到"三角形边缘闪烁""半透明叠加颜色不对"就完全懵,因为不知道采样和混合阶段到底做了什么。

1.2 我为什么坚持先手写一遍光栅化

有人会问,都有现成的库了,为什么还要手写 Bresenham、手写扫描线填充?这不是重复造轮子吗?我的看法是:造轮子的目的不是替代轮子,是让你在轮子坏掉的时候知道从哪拆。

具体来说,手写光栅化能让你获得三样东西。第一是对离散化的直觉——理想世界的直线是连续的,屏幕是离散的,这个转换过程中产生的误差、走样、断点,全部有数学原因,你写一遍就再也不会对着一条歪歪扭扭的线猜半天。第二是对坐标系和裁减的敏感度——图形程序里最容易出事的就是"越界"和"翻转",自己写帧缓冲就必须处理x >= width这类判断,写多了你会形成肌肉记忆。第三是对性能的体感——你能亲手测出"每帧两百万次像素写入"到底要几毫秒,这个数字感后面在做任何优化决策时都是锚点。

提示:练习阶段不要一上来就追求"完整渲染器"。我见过太多人从"我要写个软渲染器"开始,写了两周还在设计抽象层,最后一行像素都没画出来。正确姿势是先让一个红点在屏幕上亮起来,再让它连成线,再让线围成面。

还有一点值得说:这条路线写出来的代码,迁移成本极低。你今天输出到字符终端,明天想接到窗口上,只需要实现一个新的呈现函数,绘制逻辑一行不用改。反过来,如果你一开始就把绘制逻辑和某个具体接口绑死,将来想换个环境重写一遍几乎是必然的。

2. 画布抽象:把"绘制"和"呈现"彻底拆开

2.1 帧缓冲的最小可用设计

整个项目的地基是一个帧缓冲类。它的职责非常朴素:记住尺寸,持有一块像素数组,提供写单点的能力,其余什么都不管。我见过不少新手把"画线"也塞进帧缓冲里,这是典型的职责混淆——帧缓冲是像素容器,图元是画在容器上的操作,两者必须分开。

#include <cstdint> #include <vector> #include <algorithm> class FrameBuffer { public: FrameBuffer(int w, int h) : width_(w), height_(h), pixels_(static_cast<size_t>(w) * h, 0u) {} int width() const { return width_; } int height() const { return height_; } // 写单点:越界直接丢弃,不做断言 void setPixel(int x, int y, uint32_t color) { if (x < 0 || y < 0 || x >= width_ || y >= height_) return; pixels_[static_cast<size_t>(y) * width_ + x] = color; } uint32_t getPixel(int x, int y) const { if (x < 0 || y < 0 || x >= width_ || y >= height_) return 0u; return pixels_[static_cast<size_t>(y) * width_ + x]; } void clear(uint32_t color = 0u) { std::fill(pixels_.begin(), pixels_.end(), color); } const uint32_t* data() const { return pixels_.data(); } private: int width_; int height_; std::vector<uint32_t> pixels_; };

这段代码里有两个设计决定值得展开。第一个是像素用uint32_t而不是三个uint8_t。原因是四个字节对齐后,寻址计算简单、缓存友好,而且可以直接把 RGB 和 Alpha 打包进一个整数,做混合运算时不用来回拆包。如果为了"省内存"用三字节,你会发现在各种地方都要写* 3,还容易踩到结构体填充的坑,得不偿失。

第二个是setPixel里的越界处理选择了"静默丢弃"而不是assert。这个决定初期会让调试变难——明明画错了却没有任何提示。但换来的好处是:你后面写各种图元算法时不需要在每个循环里都重复判断边界,代码能干净一大截。我的做法是提供一个编译期开关,Debug 构建时打开越界计数,运行时统计一下有多少次被丢弃,超阈值就打印警告,既不打断执行又能暴露问题。

注意:这块缓冲区的大小要算清楚。1920×1080 的uint32_t数组是 1920×1080×4 ≈ 8.3 MB,拷贝一次就是 8 MB 的内存流量。如果你的呈现层每帧都做一次全量拷贝,帧率上限会被内存带宽死死压住,这个后面讲双缓冲的时候还会提到。

2.2 坐标与颜色约定,越早定死越好

图形代码里最容易产生"玄学 bug"的地方就是约定不统一。同一个项目里,A 函数认为原点在左上、y 轴向下,B 函数认为是数学坐标系、原点在左下、y 轴向上,两者一混,你画出来的东西就是上下颠倒的。所以开工第一件事,把约定写进注释、写进头文件,所有人遵守。

我采用的一套约定是这样的:原点在左上角,x 轴向右,y 轴向下,与绝大多数光栅设备的存储顺序一致;像素(x, y)覆盖的几何区域是从(x, y)到(x+1, y+1)的单位方块,像素中心在(x+0.5, y+0.5)。最后这条特别重要,它决定了后面做抗锯齿、做区域采样时该取哪个点。很多人画圆画出来总差半个像素,就是因为把"像素坐标"当成了"像素中心"。

颜色的打包格式我用0xAARRGGBB,高位是 Alpha,低位是蓝色,与常见的内存序一致。取分量的时候用位运算:

inline uint8_t rOf(uint32_t c) { return (c >> 16) & 0xFF; } inline uint8_t gOf(uint32_t c) { return (c >> 8) & 0xFF; } inline uint8_t bOf(uint32_t c) { return c & 0xFF; } inline uint8_t aOf(uint32_t c) { return (c >> 24) & 0xFF; } inline uint32_t makeColor(uint8_t r, uint8_t g, uint8_t b, uint8_t a = 255) { return (static_cast<uint32_t>(a) << 24) | (static_cast<uint32_t>(r) << 16) | (static_cast<uint32_t>(g) << 8) | static_cast<uint32_t>(b); }

为什么要提前把这个定死?因为一旦你写了五十个函数再回头统一,那基本等于重写。而且 Alpha 通道的存在会倒逼你在早期就决定"是否支持混合"——如果你的setPixel只做覆盖,那半透明色画上去就是不透明的;如果做混合,就要处理"目标像素本身的 Alpha 如何处理"这个经典问题。我在练习项目里的做法是:setPixel做覆盖,另提供一个blendPixel做混合,由调用方决定语义,避免一个函数承担两种模糊的职责。

3. 图元实现:从直线到填充的整数化推导

3.1 直线:Bresenham 的误差项到底在算什么

画直线是整个图形库最基础也最见功力的地方。最朴素的做法是遍历 x,用浮点算y = kx + b然后四舍五入,这在数学上没错,但有两个致命问题:一是浮点运算在早期硬件上慢,二是斜率很大时(接近竖直)会画出断续的点,因为 x 每次只走一格,y 却跳了很多格。

Bresenham 的思路是把浮点误差换成整数增量。核心思想是:每走一步 x,我们积累一个"误差",当误差超过半个像素的判定阈值时,y 也走一步,同时把误差减掉一个单位。整个过程只用整数加减和比较,没有除法没有浮点。

推导过程我简化说一遍。假设斜率0 <= k <= 1,从(x0, y0)画到(x1, y1)。令dx = x1 - x0,dy = y1 - y0(都为正)。真实的 y 增量是dy/dx,我们用一个累加器err表示"当前 y 相对理想位置的偏差,已经放大 dx 倍"。每次 x 前进一格,理想 y 应该增加dy,所以err += dy。当err超过dx/2(也就是半个像素对应的放大值)时,实际 y 向前跳一格,同时err -= dx。为了避免dx/2带来的除法,通常把整个公式再乘 2,变成err += 2*dy、判据err > dx、修正err -= 2*dx。

void drawLine(FrameBuffer& fb, int x0, int y0, int x1, int y1, uint32_t color) { int dx = std::abs(x1 - x0); int dy = -std::abs(y1 - y0); // 注意取负,统一符号 int sx = x0 < x1 ? 1 : -1; int sy = y0 < y1 ? 1 : -1; int err = dx + dy; // 等价于 2*dy - 2*dx 的简化形式 while (true) { fb.setPixel(x0, y0, color); if (x0 == x1 && y0 == y1) break; int e2 = err << 1; if (e2 >= dy) { err += dy; x0 += sx; } if (e2 <= dx) { err += dx; y0 += sy; } } }

这个版本的好处是它把八个方向的直线统一处理了,不用像教科书那样按斜率分四类写四份代码。dy取负是为了让"比较"的方向统一,e2的判断逻辑是:如果误差在水平方向的调整范围内就横向走,如果误差在垂直方向的调整范围内就纵向走,两个条件可能同时成立,那就走对角线,这正是 45 度线上的情形。

实测下来有几个点必须注意。第一,端点包含关系要写对,while(true)里先画再判断,保证起点和终点都被画上,很多断头线的 bug 就出在这里。第二,如果你的画布很小(比如 100×100 的字符画布),一条线画出来只有十几个点,视觉上会显得非常零散,这是正常的离散化结果,不是算法错了。第三,如果要做抗锯齿的线,就不能只写单点了,得按覆盖面积给相邻两行的像素分配权重,这时候误差项里的小数部分正好是现成的权重来源,这个技巧后面讲抗锯齿时再细说。

3.2 圆与椭圆:八分对称省下七倍计算量

画圆的经典做法是"中点圆算法",本质和 Bresenham 是同一套思想,只是把误差判定从直线换成了圆弧。圆的方程是x² + y² = r²,我们只算八分之一弧(从正上方到 45 度),其余七个部分靠对称复制。

推导的关键是那个"中点"是怎么来的。假设当前点已经确定在(x, y),下一个候选点只可能是(x+1, y)或者(x+1, y-1)。判断依据是中点(x+1, y-0.5)在圆内还是圆外:如果在圆内,说明(x+1, y)更接近圆弧;如果在圆外,则(x+1, y-1)更接近。把中点代入圆方程,定义一个判别式d = (x+1)² + (y-0.5)² - r²,然后展开成整数形式:

void drawCircle(FrameBuffer& fb, int cx, int cy, int r, uint32_t color) { if (r < 0) return; int x = 0, y = r; int d = 1 - r; // 初始判别式的整数形式 while (x <= y) { // 八分对称,一次写八个点 fb.setPixel(cx + x, cy + y, color); fb.setPixel(cx - x, cy + y, color); fb.setPixel(cx + x, cy - y, color); fb.setPixel(cx - x, cy - y, color); fb.setPixel(cx + y, cy + x, color); fb.setPixel(cx - y, cy + x, color); fb.setPixel(cx + y, cy - x, color); fb.setPixel(cx - y, cy - x, color); if (d < 0) { d += 2 * x + 3; // 选了 (x+1, y) } else { d += 2 * (x - y) + 5; // 选了 (x+1, y-1) --y; } ++x; } }

初始判别式1 - r这个值不是拍脑袋来的,它等于1.25 - r取整后的形式,因为整数运算中 0.25 的偏差在有限步内不会影响判断结果。如果你看到别人写d = 3 - 2*r,那是另一种等价的展开方式,两者都能跑,别混着用就行。

注意:八分对称在画布尺寸为偶数、圆心落在像素边界上时会出现重复绘制同一个点的情况(比如cx + x和cx - x相等),这时候重复调用setPixel是没有害处的,但如果你的setPixel以后改成了带混合的实现,重复绘制就会导致同一位置被叠加两次、颜色变深。我在改混合版本时就踩过这个坑,圆上某些点明显比别的地方亮一截,排查了半天才想起是这里。

椭圆可以在这套框架上推广,把x和y的增量分别乘以半轴长度的平方,判别式变成二维形式,分两段(水平切线和垂直切线之间)处理。思路一致,但边界判断要小心,特别是当长轴短轴接近时容易出现一段只有两三个点的情况。

3.3 多边形填充:扫描线算法里的边界规则

填充比画线容易出问题,因为涉及拓扑判断。最常见的实现是扫描线算法:对每一行扫描线,求出它与多边形各条边的交点 x 坐标,排序,然后两两配对填充区间。

这里面有几个必须遵守的规则,每一条我都在实际代码里踩过。第一条是水平边要跳过。水平边与扫描线平行,要么不产生交点要么产生无穷多交点,不跳过就会导致该行整条被填满或者直接除零。第二条是顶点规则:当扫描线正好穿过某个顶点时,两条相邻边会各贡献一个交点,按理说应该算一个(因为它不改变奇偶性)。但如果两条边在顶点的同一侧(形成尖角)或者不同侧(形成穿越),处理方式必须一致,否则会出现"漏填一个像素"或者"多填一截"。常用做法是把交点的 y 范围定义成半开区间[minY, maxY),这样每个顶点自然只被一条边算一次。

void fillPolygon(FrameBuffer& fb, const std::vector<std::pair<int,int>>& pts, uint32_t color) { int n = static_cast<int>(pts.size()); if (n < 3) return; int minY = pts[0].second, maxY = pts[0].second; for (auto& p : pts) { minY = std::min(minY, p.second); maxY = std::max(maxY, p.second); } minY = std::max(minY, 0); maxY = std::min(maxY, fb.height() - 1); std::vector<double> xs; for (int y = minY; y <= maxY; ++y) { xs.clear(); double sy = y + 0.5; // 取像素中心所在的扫描线 for (int i = 0; i < n; ++i) { const auto& a = pts[i]; const auto& b = pts[(i + 1) % n]; if (a.second == b.second) continue; // 水平边跳过 int yLo = std::min(a.second, b.second); int yHi = std::max(a.second, b.second); if (sy < yLo || sy >= yHi) continue; // 半开区间,顶点只算一次 double t = (sy - a.second) / double(b.second - a.second); xs.push_back(a.first + t * (b.first - a.first)); } std::sort(xs.begin(), xs.end()); for (size_t i = 0; i + 1 < xs.size(); i += 2) { int xStart = std::max(0, static_cast<int>(std::ceil(xs[i] - 0.5))); int xEnd = std::min(fb.width() - 1, static_cast<int>(std::floor(xs[i+1] - 0.5))); for (int x = xStart; x <= xEnd; ++x) fb.setPixel(x, y, color); } } }

代码里的sy = y + 0.5和ceil(xs[i] - 0.5)这两处是配套的。因为扫描线取的是像素中心的 y,交点算出来的 x 是几何上精确的位置,而像素x覆盖的是[x, x+1),所以左边界要用ceil(交点 - 0.5),右边界用floor(交点 - 0.5),这样能保证填充区域和几何区域严格对齐。如果你随便用round代替,相邻两个多边形共边时就会出现一条一像素宽的黑缝,或者反过来重叠一像素。这个细节在做地图、做图形拼贴的时候特别明显。

4. 把画面搬到屏幕上:终端与窗口两种落地

4.1 字符终端:降采样与灰度映射的取舍

帧缓冲画完之后,怎么显示出去是另一门学问。字符终端这条路,本质是把一个W×H的像素画布降采样到一个cols×rows的字符网格。这里有个容易被忽略的比例问题:终端字符的宽高比通常不是 1:1,一个字符的高度大概是宽度的两倍。如果你把像素画布按 1:1 直接映射到字符网格,画出来的圆会变成竖着的椭圆。

我的处理方式是让字符网格的采样区域在垂直方向覆盖两倍于水平方向的像素行数。举个例子,目标 80×40 的字符画布,对应的像素画布应该设成 80×80,采样时每 1×2 个像素对应一个字符。

// 字符密度表:从稀疏到稠密,按覆盖面积从少到多排列 static const char* kRamp = " .:-=+*#%@"; char pixelToChar(uint8_t luma, int range) { int idx = luma * (range - 1) / 255; return kRamp[idx]; } void presentToConsole(const FrameBuffer& fb, int cols, int rows) { const int rampLen = 10; for (int r = 0; r < rows; ++r) { std::string line; line.reserve(cols); for (int c = 0; c < cols; ++c) { int x0 = c * fb.width() / cols; int x1 = (c + 1) * fb.width() / cols; int y0 = r * fb.height() / rows; int y1 = (r + 1) * fb.height() / rows; long sum = 0, cnt = 0; for (int y = y0; y < y1; ++y) { for (int x = x0; x < x1; ++x) { uint32_t c0 = fb.getPixel(x, y); // 亮度加权:人眼对绿色最敏感 long l = (rOf(c0) * 299 + gOf(c0) * 587 + bOf(c0) * 114) / 1000; sum += l; ++cnt; } } uint8_t avg = cnt ? static_cast<uint8_t>(sum / cnt) : 0; line.push_back(pixelToChar(avg, rampLen)); } printf("%s\n", line.c_str()); } }

亮度加权那三个系数(299/587/114)不是随便写的,它来自人眼视锥细胞对不同波长的敏感度差异。如果你直接取 RGB 的平均值,红色和蓝色会被过度高估,画出来的灰度图和视觉感受对不上,尤其画彩色图形的时候,红色区域会显得比实际亮很多。

提示:整屏刷新字符终端会闪得很厉害。解决办法是用光标定位序列回到左上角重绘,而不是每帧清屏再打印。具体做法是先输出\033[H把光标移到左上,然后逐行覆盖。另外终端本身有输出缓冲,记得每帧结束fflush一次,不然你看到的是几帧拼在一起的残影。

4.2 窗口呈现:双缓冲与重绘节奏

如果要正经做交互式的图形,最后还是得接到窗口上。不管用哪个库,核心概念都一样:有一块"后台缓冲",绘制全部在后台进行,画完一次性提交给显示。为什么必须这样?因为如果在显示过程中一边画一边刷,用户会看到画面被"从上到下"逐步填充的过程,这就是经典的撕裂和闪烁。

我自己的做法是在窗口封装的呈现层里维护两份帧缓冲:一份是绘制用的工作缓冲,一份是提交用的显示缓冲。每帧的流程是:清空工作缓冲 → 画所有图元 → 把工作缓冲的像素一次性写成窗口的像素格式 → 交换。这里有个优化点值得说:如果你的库支持直接往窗口的后备缓冲里画,那就省掉一次全量拷贝。1080p 的帧缓冲拷贝一次就是 8 MB 的内存流量,在 60 帧下就是每秒近 500 MB,对集显和低端机器来说是实打实的开销。

帧率控制这块,新手最容易犯的错是"不限制帧率"。写个while(true)死循环疯狂重绘,CPU 直接吃满一个核,风扇狂转,笔记本半小时就没电。正确的做法是每帧结束时算一下这一帧花了多久,如果比目标帧间隔短,就主动睡一会儿。当然不是精确 sleep,睡过头会导致掉帧,通常做法是留 1 到 2 毫秒的余量,然后忙等补足最后那点时间。

另外一个很实用的技巧是脏矩形。静态画面不需要每帧全量重绘,只重绘发生变化的那块区域。比如你画的是个不断旋转的指针,那就只清理旧指针和新指针覆盖的两个矩形。这个优化在字符终端上效果尤其明显,因为字符的输出开销比内存写大得多。

5. 排查实录:图形绘制里最容易踩的坑

图形代码的 bug 有个共同特点:现象很直观,原因很隐蔽。我把这些年遇到的高频问题整理成一张速查表,遇到现象先对号入座,能省下不少时间。

现象可能原因排查与解决
直线出现断点、断成一截一截循环终止条件写成开区间,端点没画改成先画后判断,确保首尾点都写入
斜线有明显阶梯感且左右不对称误差项符号处理不一致,或用了浮点四舍五入统一用整数增量版本,检查dx/dy的符号
圆画出来是椭圆像素非正方形,或字符终端宽高比未补偿检查采样比例,终端需垂直方向 2:1 补偿
圆上某些点比别处亮八分对称在对称轴处重复写点,叠加了混合混合模式下需去重,或改用覆盖写
多边形内部有黑缝或漏填扫描线取整数 y 而非像素中心,边界取整方式不对称扫描线取y + 0.5,左右边界分别用ceil/floor
相邻多边形共边处有一条细线同上,或两个多边形分别独立填充没有共享边界规则统一采用半开区间规则,或先合并再填充
画面剧烈闪烁直接往显示缓冲画,没有双缓冲加后台缓冲,画完一次性提交
越界访问导致崩溃图元算法没有做裁剪,直接写数组帧缓冲的setPixel做静默丢弃 + Debug 计数
帧率越高颜色越淡每帧没有清屏,旧内容叠加每帧开头clear,或用脏矩形精确清理
程序跑一会儿越来越卡每帧都在push_back扩容,或频繁分配临时容器复用容器,把reserve放到循环外

表里最后一条值得单独展开说。我最早写扫描线填充时,把xs那个交点数组定义在循环内部,每一行都新建一个vector。画面上完全看不出问题,但一测性能就发现填充一个小多边形比画一整屏都慢。原因是每行都在做堆分配,几十万次malloc/free下来,光分配器就吃掉一半时间。把容器提到循环外、用clear复用之后,同样的代码快了将近一个数量级。这类问题在图形程序里特别常见,因为图形代码天然是深循环,任何在循环体内的分配都会被放大成千上万倍。

还有一类问题属于"约定不一致",比 bug 更难查。比如你在某个函数里把颜色定义成0xRRGGBBAA,另一个函数里以为是0xAARRGGBB,结果画出来的颜色整体偏移了,看起来像是"颜色不对"但怎么改都改不对。我的经验是:所有颜色相关的常量都用makeColor构造函数生成,代码里绝不出现裸的十六进制字面量。一旦养成这个习惯,这类 bug 就绝迹了。

注意:调试图形代码时,不要一上来就打印大量日志。图形 bug 的信息主要在"画面"里,不在"数值"里。我的常规做法是把可疑的那一步单独画到一个很小的画布上(比如 16×16),然后用字符终端打印出来看。小尺寸下每一个像素都清晰可见,问题往往一眼就能看出来,比在 800×600 的大图上找半天强得多。

6. 从能画到画得动:性能与画质的进一步优化

6.1 先把热区找出来,再谈优化

图形程序的性能优化有个铁律:不要凭感觉猜。你觉得慢的地方,测出来往往不是瓶颈;你完全没注意的一行代码,可能吃掉 60% 的时间。我习惯的第一个动作是分段计时,在绘制流程的几个关键节点打时间戳,跑一百帧取平均,先看清楚时间到底花在哪。

按经验,纯 CPU 的软件绘制流程里,时间分布大致是这样的:图元生成(算坐标、算交点)通常只占一到两成,像素写入占两到四成,而呈现层的格式转换和拷贝往往占掉剩下的四到六成。这个分布意味着一个反直觉的结论:优化你的画线算法收益很小,优化呈现路径收益很大。

具体能做的几件事。第一,把像素格式在绘制阶段就对齐到呈现层需要的格式,省掉转换循环。第二,能直接写显存就别写中间缓冲,哪怕代码难看一点。第三,对静态内容做缓存,比如背景图只画一次,之后每帧只贴变化的部分。第四,如果图元数量大,先做视口裁剪,把完全在屏幕外的图元直接丢掉,这个判断成本极低但收益可能非常高——一个放大到十倍的场景里,九成的图元都在屏幕外。

6.2 抗锯齿:超采样和覆盖率两种思路

抗锯齿是画质提升里性价比最高的一步。锯齿的本质是"一个像素要么全属于图形,要么完全不属于",而真实世界里的边界是渐变的。解决思路就一条:让边界像素的颜色按覆盖比例混合。

最直接的做法是超采样:把画布放大 N 倍渲染,再降采样回来。比如 4 倍超采样,就是先画 4W×4H,然后每 4×4 个像素取平均。这个方法实现极简单,代码改动量几乎为零,缺点是内存和计算量都涨 16 倍。对静态图片或者低帧率场景完全够用,对实时动画就不合适了。

更精细的做法是把覆盖率算出来。以直线为例,Bresenham 的误差项恰好反映了理想直线在像素内的偏移程度,把这个误差归一化之后就是上下两个像素的权重,一行代码就能得到接近完美的抗锯齿直线。这个方法开销几乎为零,但需要对每个图元单独推导,通用性差一些。

还有一个讨巧的中间方案:只在图元边缘做 2×2 超采样,内部区域仍然单次写入。实现上就是先判断这个像素是否完全在图形内部,是就直接写,否则才做细采样。对于大面积填充的图形,这个优化能把抗锯齿的开销压到很低。

6.3 这套代码还能往哪儿长

写到这一步,你手上已经有一个能画线、画圆、填充多边形、能输出到字符终端和窗口的小型绘图库了。但我更看重的是它作为跳板的价值,往下还能接不少东西。

往渲染管线方向走,下一步是把图元从"直接画到屏幕"改成"先变换再画"。加一个 3×3 的仿射变换矩阵,就能实现平移、旋转、缩放,而且变换只需要作用在顶点上,光栅化过程完全不用改。这是所有图形系统的标准分层,理解了这一层,后面看任何渲染框架的文档都不会迷路。

往图像处理方向走,帧缓冲本身就是一张位图,你已经有了读写像素的能力,那模糊、锐化、边缘检测这些卷积操作就是在这个数组上滑窗。我当初做完这个绘图库之后,顺手加了几个滤镜,发现两者代码复用率极高,因为底层的像素访问接口是同一套。

往交互方向走,需要补的是事件循环和命中检测。命中检测最简单的做法是"颜色缓冲法":给每个图元分配一个唯一的 ID 颜色,画到一张隐藏的 ID 缓冲里,鼠标点击时读那个位置的 ID 就知道点到了谁。这个方法实现成本极低,而且在图元形状复杂时比几何求交可靠得多,很多成熟的编辑器都在用。

我个人在实际操作中的体会是,这类"从零手写"的练习项目,最大的收获往往不在代码本身,而在于你建立起来的那套判断标准。以后再面对任何一个图形库,你会本能地去问:它的坐标系怎么定义的?它的像素中心在哪?它的填充边界是开区间还是闭区间?它的呈现路径有没有多余拷贝?这些问题问得出来,说明你已经从"会用"跨到"懂行"了,而这一步跨越,靠的正是把这类看似简单的小实例老老实实写完一遍。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进