资讯动态

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

发布时间:2026/10/31 5:05:36 来源:尧图企业网站定制
图形绘制这块内容我在带新人的时候反复讲讲到最后发现真正卡住人的从来不是某个接口怎么调而是坐标系怎么对、边界怎么判、性能热区在哪。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_castsize_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_castsize_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_castsize_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::vectoruint32_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)到(x1, y1)的单位方块像素中心在(x0.5, y0.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_castuint32_t(a) 24) | (static_castuint32_t(r) 16) | (static_castuint32_t(g) 8) | static_castuint32_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 - x0dy 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)下一个候选点只可能是(x1, y)或者(x1, y-1)。判断依据是中点(x1, y-0.5)在圆内还是圆外如果在圆内说明(x1, y)更接近圆弧如果在圆外则(x1, y-1)更接近。把中点代入圆方程定义一个判别式d (x1)² (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; // 选了 (x1, y) } else { d 2 * (x - y) 5; // 选了 (x1, 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::vectorstd::pairint,int pts, uint32_t color) { int n static_castint(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::vectordouble 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_castint(std::ceil(xs[i] - 0.5))); int xEnd std::min(fb.width() - 1, static_castint(std::floor(xs[i1] - 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, x1)所以左边界要用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_castuint8_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 就知道点到了谁。这个方法实现成本极低而且在图元形状复杂时比几何求交可靠得多很多成熟的编辑器都在用。我个人在实际操作中的体会是这类从零手写的练习项目最大的收获往往不在代码本身而在于你建立起来的那套判断标准。以后再面对任何一个图形库你会本能地去问它的坐标系怎么定义的它的像素中心在哪它的填充边界是开区间还是闭区间它的呈现路径有没有多余拷贝这些问题问得出来说明你已经从会用跨到懂行了而这一步跨越靠的正是把这类看似简单的小实例老老实实写完一遍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑