尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC图形学算法源码实战:从DDA到Phong光照的C++实现

MFC图形学算法源码实战:从DDA到Phong光照的C++实现 简介这套计算机图形学源码基于C与MFC框架实现由孔令德编写面向高校学生及需要Windows图形编程范例的开发者适合入门到中级阶段对照学习。压缩包共1522个文件约140.24MB其中以h头文件、cpp源文件为主配合大量ico图标、bmp位图以及43组MFC工程文件dsp、dsw、rc等涵盖多个独立示例项目便于逐个对照学习。内容覆盖二维与三维坐标变换、Bresenham与DDA线段绘制、扫描线多边形填充、正斜投影与透视投影、Phong光照模型与颜色混合、贝塞尔曲线与NURBS曲面以及MFC按钮、滑块、对话框等交互界面搭建知识点覆盖较完整。已有2583人学习浏览内含多个可运行的MFC示例工程适合入门者对照源码理解原理也适合中级开发者参考MFC图形应用的工程组织方式。1. 把图形学算法直接画在窗口上这套MFC源码为什么值得跑一遍做计算机图形学实验最痛苦的其实不是数学推导而是你算完了半边天屏幕上什么都没有。老孔孔令德这套MFC源码解决的就是这个问题DDA、Bresenham、扫描线填充、透视投影、Phong光照这些经典算法全部以C代码直接画在Windows窗口客户区里按F5就能看到像素级输出。适合三类人图形学课程作业要交MFC实验报告的学生、想在OpenGL封装之外看清算法内部机制的开发者、以及需要一套可改参数快速出图的C图形学工具底子的从业者。这套代码的价值不在「写得多么优雅」而在「改一行就能看到变化」真正入手以后投影矩阵、扫描线配对、光照系数这些概念全是看得见摸得着的。2. 解压到工程目录看懂这套MFC图形学源码的组织方式与加载方法压缩包里出现频率最高的文件名是Test.aps说明老孔建工程时用的默认工程名就叫Test是典型的MFC AppWizard画图程序骨架。这一章先把工程里每个文件干什么讲清楚再给一套从VC6一路迁到VS2008练出来的加载步骤最后把最关键的OnDraw绘制调用链拆开看。2.1 老孔这套源码的文件结构与职责划分典型的老VC6 MFC工程长这样解压后对照着找文件就行Test\ │ Test.dsw # VC6 工作空间描述文件旧版双击直接打开 │ Test.dsp # 工程文件保存编译选项与文件列表 │ Test.aps # AppStudio 资源缓存删掉后编译时会自动重建 │ Test.rc # 菜单、工具栏、对话框等资源脚本 │ TestView.cpp # CTestView 视图类绘制算法的落点 │ TestDoc.cpp # CTestDoc 文档类负责维护绘图数据 │ StdAfx.h # 预编译头汇总 MFC 标准头文件 │ StdAfx.cpp # 预编译头的实现 └─ res\ # 图标、位图等二进制资源这里的核心逻辑是MFC的文档/视图架构TestDoc保存点、线、多边形这些数据TestView负责把数据画到窗口两者通过框架自动关联。老孔的实验代码大部分集中在TestView.cpp里所以你想改算法基本只要动这一个文件。Test.aps属于编译中间产物它和rc文件同时存在时才反映当前资源状态初学时如果不小心删了它工程会自己重建不用担心丢东西。dsw/dsp是VC6时代格式VS2008以上打开时会提示转换如果只想看代码不想动工程结构选「No」也能浏览源文件。2.2 从VC6到新版本加载这套MFC工程的三个配置步骤这么多年跑老工程的经验是别用VS2019、VS2022硬扛VC6代码库的链接方式和Unicode默认值变化太大折腾半天不如用VS2008或者VC6自带的编译器。如果你手头只有新版本VS也不是完全不行按下面这套顺序改解压到纯英文路径不要放在带中文或空格的目录里否则rc资源加载阶段容易出怪问题。用VS打开.dsw文件提示是否转换格式时选择转换生成新的.sln和.vcxproj。打开「项目 → 属性」按这三处配置检查一遍常规的字符集改成「使用多字节字符集」C/C的预编译头改成「使用/Yu StdAfx.h」链接器的子系统保持「Windows」。按F7编译如果报错集中在stdafx.h和CString相关先回去查字符集多字节/Unicode不一致是这类源码最常见的报错源。CtrlF5运行能弹出窗口并画出图形就算加载成功。提示VS2010以上版本默认创建工程时用的是Unicode字符集而老孔的VC6实验代码是按多字节写的两者混在一起会出现大量C2664编译错误改字符集是第一优先级的操作。老工程升级失败不是代码有问题而是编译环境和工程配置不匹配。我一般会先看stdafx.h里有没有#define _AFXDLL再看字符集设置解决掉这两个90%的MFC老代码都能在新环境跑起来。2.3 从OnDraw到屏幕像素MFC绘图调用链MFC里所有静态图形的最后汇合点全是视图类的OnDraw函数结构大致是这样void CTestView::OnDraw(CDC* pDC) { // 1. 选一把白色画刷把窗口客户区擦干净 CBrush bg(RGB(255, 255, 255)); CBrush* pOldBrush pDC-SelectObject(bg); CRect rc; GetClientRect(rc); pDC-Rectangle(rc); pDC-SelectObject(pOldBrush); // 2. 在擦净的客户区里调用绘图函数 DrawDDALine(pDC, CP2(50, 50), CP2(300, 200), RGB(0, 0, 255)); DrawBresenhamLine(pDC, 50, 100, 300, 260, RGB(255, 0, 0)); }pDC是框架传进来的设备上下文画完不用手动ReleaseDC框架会处理先清背景再画图形是纪律性习惯否则窗口拖动后旧图形残影和前景图形叠在一起。GetClientRect得到的是客户区矩形Rectangle真正起作用的是pDC上的白色画刷不是背景橡皮擦。CP2是工程里定义的点结构体成员为x和y。需要注意OnDraw只在两种时刻被调用窗口第一次显示以及有人调用Invalidate()或InvalidateRect()触发重绘。鼠标交互那里如果画了东西必须同步把数据写进Doc再Invalidate一下否则窗口一被遮挡再恢复内容就丢了。3. 画线与填充算法DDA、Bresenham和多边形扫描线填充的MFC落地这一组代码是整套源码里最值得动手改参数的三个算法DDA和Bresenham解决「屏幕上点怎么连成线」扫描线解决「多边形怎么涂满颜色」。屏幕像素是整型网格直线经过的像素坐标往往不是整数所以这三个算法的本质都是同一件事怎么把连续几何离散成像素同时尽量不出现断点和多余的点。3.1 DDA与Bresenham代码对照浮点递推还是整数误差DDA算法思路最直白沿着直线方向一步步走每一步都计算当前坐标四舍五入后画点。void DrawDDALine(CDC* pDC, CP2 p0, CP2 p1, COLORREF clr) { double dx p1.x - p0.x; double dy p1.y - p0.y; // 步数取两个方向差值的绝对值较大者保证每步最多跨一个像素 int steps (abs(dx) abs(dy)) ? abs(dx) : abs(dy); double xInc dx / steps; double yInc dy / steps; double x p0.x, y p0.y; for (int i 0; i steps; i) { // (0.5) 实现四舍五入否则像素点会整体向左上偏移 pDC-SetPixel((int)(x 0.5), (int)(y 0.5), clr); x xInc; y yInc; } }步数steps等于dx、dy中绝对值大的那个这样xInc或yInc中有一个恒为1另一个小于等于1画出来的点列在视觉上连续。代码里最容易出错的是取整方向C的(int)强转是截断不是四舍五入负坐标时甚至会向零点方向偏一个像素所以0.5这个修正必须保留。DDA的代价是每一步都用浮点加法和强转循环量大了之后效率一般这是它后来被Bresenham替代的根本原因。Bresenham算法完全去掉浮点用一个整数误差变量记录「理想直线和当前像素的纵向偏差」根据误差符号决定下一步走x还是走yvoid DrawBresenhamLine(CDC* pDC, int x0, int y0, int x1, int y1, COLORREF clr) { int dx abs(x1 - x0); int dy abs(y1 - y0); // sx、sy 记录线段在x、y方向上的行进方向-1 或 1 int sx (x0 x1) ? 1 : -1; int sy (y0 y1) ? 1 : -1; int err dx - dy; // 初始偏差 两方向距离差 while (true) { pDC-SetPixel(x0, y0, clr); if (x0 x1 y0 y1) break; int e2 2 * err; // 用 e2 判断更偏向 x 方向还是 y 方向 if (e2 -dy) { err - dy; x0 sx; } if (e2 dx) { err dx; y0 sy; } } }err变量维护的是当前像素与理想直线的带符号距离e22*err是为了同时和-dy、dx比较这样只做整数乘法就能判断下一步往哪走。两个if允许x和y同时走一步对应斜率正好是1或-1的对角线情况。Bresenham在MFC里最省心的是没有任何浮点运算放到OnDraw大循环里画几百条线性能都是稳的。把err声明在循环外也是经验避免每条线重新分配栈空间。这两个算法在工程里可以直接二选一肉眼几乎看不出差别Bresenham胜在纯整数运算适合嵌入式等无浮点环境。3.2 多边形扫描线填充边表和活性边表怎么配合填充是画线的高阶版本多边形边界已知要判断哪些像素在内部。这套MFC源码里的经典做法是用扫描线算法从多边形最低点逐行向上每条扫描线与多边形边界求交点把左右端点配对后对整行填色用两个数据结构来组织// 边表结点记录一条边与当前扫描线的交点状态 struct Edge { double x; // 当前扫描线与边的交点的x坐标 double dx; // 扫描线上移一行时x的增量 int yMax; // 该边的最高扫描线编号超过就移除 }; // 建ET按边的最低y值分桶插入到对应扫描线的链表中 void BuildEdgeTable(CP2 poly[], int n, vectorlistEdge et) { for (int i 0; i n; i) { CP2 p0 poly[i]; CP2 p1 poly[(i 1) % n]; // 最后一条边回到第一个顶点 if (p0.y p1.y) swap(p0, p1); // 保证 p0 是下端点 Edge e; e.yMax (int)p1.y; e.x p0.x; e.dx (p1.x - p0.x) / (p1.y - p0.y); et[(int)p0.y].push_back(e); // 按下端点y分桶 } }BuildEdgeTable这一步对应「从多边形建立边表ET」每条边按下端点y值插入对应桶。填充主体则是另一层循环从最低扫描线往上每次把新边加入活性边表AET按x排序后两两配对填色行结束后删除yMax小于当前行的边。最常翻车的是顶点配对扫描线正好通过多边形顶点时如果这个顶点是同侧两条边共用的最高点或最低点直接按两条边各算一个交点会导致配对错乱。解法是约定「下端点参与计算上端点不参与」也就是边只在到达最低点时插入到达最高点时移除这一条必须写进代码的条件判断里。如果只想快速验证扫描线填得对不对可以用MFC自带的系统API做个对照CRgn rgn; rgn.CreatePolygonRgn(pts, n, ALTERNATE); pDC-FillRgn(rgn, CBrush(RGB(255, 0, 0)));CreatePolygonRgn的ALTERNATE模式走的是奇偶规则和自己写的扫描线配对结果相同。把两个结果画在一起对比如果颜色区域一致说明AET配对逻辑没写错不一致就把FillRgn那行注释掉回去断点看活性边表的x顺序。这个对照手法比反复看顶点坐标图高效得多。3.3 几何变换的坐标陷阱屏幕坐标系y轴向下平移、旋转、缩放这三类变换的实验代码核心是一个齐次坐标矩阵乘法void TransformPoint(CP2 p, double tx, double ty, double angle, double sx, double sy) { double x p.x, y p.y; // 旋转和缩放中心是原点想绕任意点转先平移到该点完事再平移回去 double c cos(angle), s sin(angle); p.x (x * c - y * s) * sx tx; p.y (x * s y * c) * sy ty; }tx、ty是平移距离sx、sy是缩放倍数angle是弧度制旋转角。这套MFC源码里最容易让人困惑的是旋转方向数学课的右手坐标系y轴向上逆时针为正而Windows窗口坐标y轴向下同一个正角度在屏幕上看起来是顺时针转。解决方法很简单旋转角取负值angle -angle或者把代码里的sin项正负对调两种改法效果一样。缩放如果x、y系数不同就是非等比缩放圆会被压成椭圆图形学实验里经常拿这个现象来验证矩阵是否真的生效。我自己的验证习惯是画一个矩形先平移300像素再旋转30度然后缩放0.5倍连续变换后把结果和手动计算的坐标值对比打印一旦对不上优先怀疑角度单位和坐标系方向其次是变换顺序——先缩放后旋转和先旋转后缩放结果完全不同别指望调换矩阵乘法次序会自动等价。4. 把三维画到二维屏幕投影矩阵、深度缓存和Phong光照的实现MFC本身不做三维渲染这套源码里的三维效果是靠纯数学硬算出来的先把三维顶点投影到屏幕坐标再用MoveTo/LineTo画线框逐像素SetPixel画实体表面。这一章拆开三个部分投影公式怎么写、深度缓冲怎么存、光照颜色怎么算。4.1 正交投影与透视投影的矩阵落地正交投影很简单直接丢掉z值CP3 OrthographicProject(CP3 p) { // 只保留x、yz用于后面的深度判断 return CP3(p.x, p.y, p.z); }透视投影才是关键它模拟的是人眼的近大远小。这里的CP3是工程里定义的三维点结构体成员为x、y、zCP3 PerspectiveProject(CP3 p, double d) { // d 是视点与观察平面之间的距离工程里通常取500~1000 CP3 r; r.x d * p.x / (d p.z); r.y d * p.y / (d p.z); r.z p.z; return r; }坐标约定是z轴指向屏幕内p.z越大离观察者越远。分母dp.z的意思是离得越远的点p.z越大收缩得越厉害d越小透视变形越夸张d太小时图像会严重桶形畸变d取500到1000之间视觉上最接近真实镜头。如果想投影后物体变大直接缩小d或放大顶点坐标不要在公式里额外加缩放系数。正交投影适合工程制图和游戏里的俯视角透视投影适合看立体感强的场景。代码里画立方体线框时通常两个投影都留一套函数用一个变量切换方便直接对比视觉效果。4.2 Z-Buffer深度缓存隐藏面消除的手写实现画多个三维物体时后面的物体不能盖住前面的。最简单的画家算法先画远的再画近的但物体互相穿插时会画错MFC工程里常见的深度方案是z-bufferdouble* zBuffer new double[W * H]; for (int i 0; i W * H; i) zBuffer[i] 1e10; // 初始化成极大值表示该像素还没有有效深度 // 假设此时要对像素(x, y)着色深度为z int idx y * W x; if (z zBuffer[idx]) { zBuffer[idx] z; // 更近的点覆盖之前的点 pDC-SetPixel(x, y, color); // 屏幕上的颜色也换成新的 }判断条件z小于缓存值意味着z越小越近。这套MFC源码里如果用了统一的投影函数z就是投影前保留的三维z不会随投影被挤扁。需要注意的坑是深度缓存数组要一次分配成连续内存用二维数组double zBuf[800][600]在栈上声明会直接把栈爆掉而且逐像素判断如果放在OnDraw里窗口每次重绘整个函数会重新算一遍只适合小分辨率的演示场景。把分辨率从800x600降到320x240性能提升肉眼可见渲染循环里第一件该做的事就是设定合理的缓冲区尺寸。4.3 Phong光照的简化版环境光和漫反射完整Phong模型包含环境光、漫反射、镜面反射三项MFC逐像素画图代价高示例源码一般只取前两项COLORREF PhongShade(CP3 normal, CP3 lightDir, COLORREF baseColor) { // 两个向量需先归一化否则cos值不对颜色整体偏暗或偏亮 double lambert normal.x * lightDir.x normal.y * lightDir.y normal.z * lightDir.z; if (lambert 0) lambert 0; // 背光面直接取0 int r (int)(GetRValue(baseColor) * (0.2 0.8 * lambert)); int g (int)(GetGValue(baseColor) * (0.2 0.8 * lambert)); int b (int)(GetBValue(baseColor) * (0.2 0.8 * lambert)); return RGB(min(255, r), min(255, g), min(255, b)); }0.2是环境光系数保证背光面不是纯黑0.8是漫反射权重lambert是平面法向量和光线方向的点积等于两向量夹角的余弦值。点积为1代表正对光源最亮点积为0代表光线擦着表面掠过完全是暗面。这里的坑有两个一是法向量必须归一化直接拿长度不为1的向量算点积颜色整体偏移这是做光照时最典型的玄学问题二是颜色通道乘完系数要记得用min(255, ...)夹住否则RGB值溢出画面会突然变成一片白。工程里想加镜面高光就在括号里再补一项pow(max(0, R·V), n)n是反光度但注意pow运算放到逐像素循环里会非常拖速度只适合画小球这种单物体演示。5. 避坑指南这套MFC源码编译运行阶段的高频报错与排查MFC老工程能编译通过不等于能稳定运行下面几条是我在迁移这套图形学源码过程中实际踩过的坑每条都按现象、原因、解决给出可以直接对照排查。5.1 编译报C1010或C2664先查预编译头和字符集现象按F7编译报fatal error C1010: unexpected end of file while looking for precompiled header或者大量error C2664提示CString无法转成const char*。原因前者是文件没有包含StdAfx.h老孔这批代码每个cpp顶部都有#include stdafx.h迁移到新工程后预编译头选项被改了后者是工程当前运行在Unicode字符集而代码里的字符串常量还是窄字符。解决项目属性里把预编译头设为「使用/Yu StdAfx.h」字符集设为「使用多字节字符集」两条都是下拉框操作改完重新生成解决方案即可。如果还有零星报错看一下是不是有个别cpp把include写在了其他头文件后面MFC要求StdAfx.h必须是第一个include。5.2 退出时dumpcont.cpp(23)断言失败全局析构里别碰TRACE现象窗口正常关闭但调试器弹出断言失败框输出窗口显示dumpcont.cpp(23) : atltracegeneral。原因MFC在程序退出阶段会先销毁内部追踪对象CTrace之后如果某些全局对象或静态对象的析构函数里还调用了TRACE、AfxTrace宏就会踩到已经失效的对象触发无意义的断言。解决把全局对象析构里的TRACE删掉改用OutputDebugString输出调试信息或者把退出时的日志写到文件里。判断是不是这个原因直接看输出窗口里断言出现的位置是不是dumpcont.cpp以及是否只在程序退出时出现这两条对上就可以锁定。5.3 .aps资源缓存与rc不同步资源视图错乱现象改了菜单或对话框后资源视图里显示的还是旧样子有时删掉Test.aps再编译菜单彻底错乱ID对不上。原因.aps文件是AppStudio的缓存正常流程下它由rc文件编译产生但如果手工删除它IDE会用当前内存里的资源模型重新生成这时候如果rc文件本身已经被改过或者多个编辑器同时打开两个来源的数据就会打架。解决别手动删.aps清理工程时只清理Debug、Release目录资源改乱后最可靠的办法是打开Test.rc和Resource.h核对资源ID宏是否一致不一致的按Resource.h里的宏定义改回。记住一件事.aps不是源文件它永远是rc的衍生品。5.4 图形一闪而过或拖动窗口后内容丢失统一走OnDraw现象鼠标在某处一移动画好的图形立刻消失或者窗口最小化再恢复只剩一片白。原因绘制逻辑写在鼠标消息里用CClientDC临时取的DC画完就没了窗口重绘走OnDrawOnDraw里没有同样的绘制内容刷新时自然画不出来。解决把所有静态图形数据存到文档类成员里鼠标消息里只更新数据并调用Invalidate()真正画图的代码全部集中在OnDraw里。这样窗口恢复、遮挡解除、缩放拖动后框架会主动触发OnDraw把内容重画。如果追求实时交互跟手可以再做双缓冲在内存DC上先画完再用BitBlt整块贴到窗口能压住闪烁但核心还是「重绘逻辑唯一」。5.5 BMP位图显示不出来LoadBitmap后还要检查DC状态现象用LoadBitmap加载资源后调用DrawBitmap窗口上不显示或显示成黑块。原因可能是rc资源文件里根本没有对应的BITMAP资源ID填写只是引用了头文件宏也可能是CBitmap还没选入DC就开始画还有一种常见原因位图加载成功但DC的选区、窗口坐标系不一致。解决先用CImage img; img.Load(_T(test.bmp)); img.Draw(pDC, 0, 0);从文件加载验证一遍CImage可以直接读外部bmp文件改路径即可不用折腾rc资源确认能显示后再改回CBitmap方案。MFC里显示位图的正确姿势是LoadBitmap → 创建兼容DC → SelectObject选入位图 → BitBlt贴图少了SelectObject那步画面永远是空的。6. 进阶验证用贝塞尔曲线把整套源码连起来跑一遍前面几章讲的是单个算法的落地最后一招是把它们串成一个综合Demo用鼠标点四个控制点生成一条三次贝塞尔曲线再把曲线上的点当作运动小球轨迹同时用第4章的光照函数给小球上色、用包围盒做碰撞检测这一套做完前面所有知识点就都活了。6.1 de Casteljau递推画三次贝塞尔曲线贝塞尔曲线的核心是de Casteljau递推给定n1个控制点每次在相邻两点连线上按比例t取点得到n个点再对这n个点重复同样操作直到剩下一个点那个点就是曲线上t处的坐标CP2 DeCasteljau(const vectorCP2 pts, double t) { vectorCP2 tmp(pts); for (int level 1; level (int)pts.size(); level) { for (int i 0; i (int)pts.size() - level; i) { tmp[i].x (1 - t) * tmp[i].x t * tmp[i 1].x; tmp[i].y (1 - t) * tmp[i].y t * tmp[i 1].y; } } return tmp[0]; }调用时把t从0到1按步长循环每一步得到一个点把点连起来就是整条曲线。步长参数影响平滑度0.01每个像素几乎都有点落位显示细腻但循环100次0.1只要10个点曲线段与段之间会看到折线感。工程里做交互预览用0.05做最终输出用0.01。6.2 曲线轨迹、光照和碰撞的联动验证把曲线上每个采样点当作圆心画一个小球球颜色用第4章的PhongShade方法基于法线方向算就能看到小球从第一个控制点运动到最后一个控制点。碰撞检测先用最简单的一层把障碍物当成轴对齐矩形判断圆心到矩形四边的最近距离是否小于半径小于则标记为碰撞状态把球换成红色。这个Demo把第2章的OnDraw入口、第3章的变换参数、第4章的光照和深度习惯、以及本节的曲线递推全部串在一条主线上每个环节都能独立改参数看效果。从那以后我每次拿到一套图形学实验源码都强制自己跑完「先复现一条最简曲线 → 改控制点看曲线变化 → 换光照系数看颜色偏移」这三步再去做别的。这套MFC源码也一样别一上来就把十几个算法全读一遍先用贝塞尔曲线把框架摸透再回头读DDA和扫描线进度反而快。希望帮到你。本文还有配套的精品资源点击获取
返回列表