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

资讯详情

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

DEV C++图形学实践:从Bresenham算法到三维动画的期末作业指南

DEV C++图形学实践:从Bresenham算法到三维动画的期末作业指南 1. 项目概述从零到一用DEV C搞定图形学期末作业又到了期末季对于很多计算机相关专业的同学来说计算机图形学这门课的大作业绝对算得上是一个“甜蜜的负担”。说它甜蜜是因为亲手敲出代码看着屏幕上从无到有地生成线条、图形甚至三维模型那种成就感无与伦比说它是负担是因为图形学涉及数学、算法和底层API门槛不低尤其是当学校要求使用相对“复古”的DEV C作为开发环境时很多同学更是无从下手。我当年也是这么过来的从对着黑框框发呆到能画出会动的图形踩了不少坑也积累了不少经验。今天我就以DEV C为平台结合计算机图形学的核心知识点为你拆解一套从简单到进阶的作业示例涵盖1-6个典型任务目标就是让你能看懂、能修改、能交出一份让老师眼前一亮的作业。为什么是DEV C虽然现在Visual Studio、Code::Blocks等IDE功能更强大但很多高校的机房环境、课程实验仍沿用DEV C因为它轻量、免配置专注于C/C教学。图形学作业的核心在于理解算法原理并用代码实现DEV C配合经典的graphics.h库或第三方如EasyX完全足够。本系列示例将围绕直线、圆、多边形填充、二维变换、三维初步和简单动画这六个核心模块展开每个模块我都会给出可直接运行的代码并重点讲解背后的为什么——为什么用这个算法参数怎么调常见的坑在哪里我会假设你有一些C语言基础但对图形学是新手所以会尽量用“说人话”的方式把矩阵乘法、齐次坐标这些概念讲明白。2. 环境搭建与第一个图形画一条直线万事开头难在图形学里开头就是画一条直线。别小看这条线它背后是计算机图形学的基石算法之一。2.1 开发环境配置与第一个窗口首先确保你的DEV C安装了图形库。DEV C默认不带图形库我们需要手动配置。最常见的是使用graphics.h这个头文件及其对应的库文件。一个简单的方法是安装EasyX图形库它针对DEV C和Visual C做了适配安装简单。下载与安装访问EasyX官网下载对应DEV C的安装包。安装过程就是一直“下一步”它会自动检测你的DEV C路径并进行配置。验证安装打开DEV C新建一个“Console Application”项目。在代码开头尝试包含#include graphics.h。如果编译不报错说明环境配置成功。现在让我们创建第一个图形窗口并画一条线。这里的关键是理解图形模式的初始化。#include graphics.h // 包含图形库头文件 #include conio.h // 用于_getch()函数等待按键 int main() { // 初始化图形窗口宽度640像素高度480像素 initgraph(640, 480); // 设置当前绘图颜色为白色。RGB(255,255,255)代表白色 setcolor(WHITE); // 在坐标(100, 100)到(500, 300)之间画一条直线 line(100, 100, 500, 300); // 等待用户按任意键否则窗口会一闪而过 _getch(); // 关闭图形窗口 closegraph(); return 0; }注意initgraph函数的后两个参数通常用于设置窗口标题等这里省略了。_getch()是必要的否则程序会立即执行closegraph()关闭窗口你将什么也看不到。这是新手最容易忽略的一点。编译并运行这段代码你应该能看到一个窗口中间有一条白色的斜线。恭喜你已经成功迈出了第一步这个line函数是库函数帮我们封装好的。但图形学的作业老师往往要求你自己实现画线算法这就引出了著名的DDA算法和Bresenham算法。2.2 手动实现Bresenham画线算法为什么是Bresenham因为它效率高只使用整数加减法避免了浮点数运算和取整操作是计算机图形学中最经典的直线光栅化算法。理解它对后续理解其他扫描转换算法大有裨益。假设我们要从点(x0, y0)画到点(x1, y1)且斜率在0到1之间即较为平缓的直线。算法的核心思想是每次在x方向上前进一个像素步长为1然后决定y方向是前进1个像素还是保持不动。这个决策靠一个误差项p来判断。下面是针对斜率0k1情况的Bresenham算法实现void BresenhamLine(int x0, int y0, int x1, int y1, int color) { int dx x1 - x0; int dy y1 - y0; int p 2 * dy - dx; // 决策参数的初始值 int twoDy 2 * dy; int twoDyMinusDx 2 * (dy - dx); int x x0, y y0; putpixel(x, y, color); // 画起点 while (x x1) { x; if (p 0) { p twoDy; // 误差项更新y不变 } else { y; p twoDyMinusDx; // 误差项更新y增加 } putpixel(x, y, color); // 画下一个点 } }实操心得理解p的意义p本质上衡量的是理想直线与当前候选像素中心在垂直方向上的距离差。p0意味着下一个像素点选(x1, y)更接近直线p0则选(x1, y1)更接近。斜率处理上述代码只适用于斜率在0到1之间。实际作业中你需要处理所有八分圆的情况斜率绝对值大于1、负斜率等。通用的处理方法是根据dx和dy的绝对值大小交换x和y的角色并考虑步进方向。这是作业中的一个常见扩展点。效率你可以看到循环体内只有整数加减法和比较没有乘除法这就是它快的原因。在main函数中调用BresenhamLine(100, 100, 500, 300, WHITE)效果应该和直接用line函数一样。3. 基础图元绘制圆与多边形填充画完直线我们升级到圆和多边形。圆是第二个基础图元而多边形填充则是向区域绘制迈进的关键一步。3.1 中点圆算法绘制完美圆形和直线一样我们也不满足于直接用circle函数而是要探究其原理——中点圆算法。它利用了圆的八对称性只需计算八分之一圆弧的点就能通过对称得到整个圆效率极高。算法针对圆心在原点(0,0)半径为R的圆。我们从(0, R)点开始在斜率绝对值小于等于1的区域即从90度到45度这部分圆弧进行决策。决策参数p基于圆方程x^2 y^2 - R^2 0在中点处的值。void MidpointCircle(int xc, int yc, int r, int color) { int x 0; int y r; int p 1 - r; // 决策参数初始值 // 利用圆的八对称性画点 circlePoints(xc, yc, x, y, color); while (x y) { // 只计算从(0,r)到(r/sqrt(2), r/sqrt(2))这部分 x; if (p 0) { p 2 * x 1; } else { y--; p 2 * (x - y) 1; } circlePoints(xc, yc, x, y, color); } } // 利用对称性画出八个点 void circlePoints(int xc, int yc, int x, int y, int color) { putpixel(xc x, yc y, color); putpixel(xc - x, yc y, color); putpixel(xc x, yc - y, color); putpixel(xc - x, yc - y, color); putpixel(xc y, yc x, color); putpixel(xc - y, yc x, color); putpixel(xc y, yc - x, color); putpixel(xc - y, yc - x, color); }提示circlePoints函数是算法的精髓。它根据计算出的一个点(x,y)通过对称变换得到圆上其他七个点从而极大地减少了计算量。在作业中完整实现并注释这个对称过程能很好地体现你对算法的理解。3.2 扫描线填充算法给多边形上色画出一个多边形的轮廓后下一步就是填充它。扫描线填充算法是其中最经典的一种。它的思路非常直观想象一条水平线从上到下扫描屏幕找出这条线与多边形每条边的交点然后在这些交点对之间的像素点进行填充。实现步骤拆解边表ET构建遍历多边形的所有边线段忽略水平边。对每一条边记录其y_max最高点的y坐标、x_min当前最低点的x坐标即交点初始值、1/kx的增量即边斜率的倒数。活性边表AET维护初始化一个空的AET。从屏幕最顶部的扫描线开始向下逐行处理。更新将ET中y_min等于当前扫描线y值的边加入AET。排序对AET中的所有边按其x_min当前交点x值从小到大排序。填充遍历排序后的AET两两配对第12第34...对配对之间的x坐标区间内的所有像素点进行着色。淘汰与更新将AET中y_max等于当前y的边移除。对AET中剩余的每一条边更新其x_min x_min 1/k即准备下一扫描线的交点x值。重复y值加1处理下一条扫描线直到AET为空。// 边表节点结构简化版 struct Edge { int y_max; float x_min; float slope_inv; // 1/k struct Edge* next; }; void scanlineFill(int poly[][2], int n, int color) { // 1. 创建并初始化边表ET // 2. 初始化活性边表AET为空 // 3. 获取多边形y坐标范围 [y_min, y_max] // 4. for (int y y_min; y y_max; y) { // a. 将ET中y_min y的边插入AET // b. 对AET按x_min排序 // c. 遍历AET两两配对填充像素 // d. 从AET中删除y_max y的边 // e. 更新AET中剩余边的x_min: x_min slope_inv // } }常见问题与排查交点舍入问题交点的x坐标通常是浮点数填充时需要取整。一般采用“左闭右开”或约定俗成的规则避免填充重复或遗漏。一个稳妥的做法是填充区间取ceil(x_left)到floor(x_right)-1。顶点处理当扫描线恰好经过多边形顶点时该顶点会是两条边的端点可能被计算两次。标准的处理方法是检查顶点相邻的两条边如果它们在顶点的上下两侧即一个的y_max是顶点另一个的y_min是顶点则该顶点只算一个交点如果两条边都在顶点上方或下方则算两个交点。可以通过比较顶点前后两条边的y值来判断。性能算法涉及大量的链表操作和排序。对于作业级别的多边形顶点数不多性能足够。在实现时确保你的链表插入、删除操作正确这是调试的难点。4. 二维图形变换平移、旋转与缩放让图形动起来是图形学作业的亮点。二维变换是基础核心在于矩阵运算。我们将一个点(x, y)表示为齐次坐标[x, y, 1]这样平移、旋转、缩放都可以用3x3矩阵乘法来表示。4.1 变换矩阵的原理与实现平移将点(x, y)移动到(xtx, yty)。|1 0 tx| |x| |xtx| |0 1 ty| * |y| |yty| |0 0 1 | |1| | 1 |缩放以原点为中心将点(x, y)缩放为(x*sx, y*sy)。|sx 0 0| |x| |x*sx| |0 sy 0| * |y| |y*sy| |0 0 1| |1| | 1 |旋转绕原点逆时针旋转θ角度。|cosθ -sinθ 0| |x| |x*cosθ - y*sinθ| |sinθ cosθ 0| * |y| |x*sinθ y*cosθ| | 0 0 1| |1| | 1 |在代码中我们需要定义一个结构来表示点和变换矩阵并实现矩阵乘法。typedef struct { float x, y; } Point2D; typedef struct { float m[3][3]; } Matrix3x3; // 初始化单位矩阵 void initIdentityMatrix(Matrix3x3* mat) { for(int i0; i3; i) for(int j0; j3; j) mat-m[i][j] (ij) ? 1.0f : 0.0f; } // 创建平移矩阵 Matrix3x3 createTranslationMatrix(float tx, float ty) { Matrix3x3 mat; initIdentityMatrix(mat); mat.m[0][2] tx; mat.m[1][2] ty; return mat; } // 创建旋转矩阵角度制 Matrix3x3 createRotationMatrix(float angle) { float rad angle * 3.14159265f / 180.0f; float cosA cos(rad); float sinA sin(rad); Matrix3x3 mat; initIdentityMatrix(mat); mat.m[0][0] cosA; mat.m[0][1] -sinA; mat.m[1][0] sinA; mat.m[1][1] cosA; return mat; } // 点与矩阵相乘 Point2D transformPoint(Point2D p, Matrix3x3 mat) { Point2D result; // 使用齐次坐标 [x, y, 1] result.x p.x * mat.m[0][0] p.y * mat.m[0][1] 1 * mat.m[0][2]; result.y p.x * mat.m[1][0] p.y * mat.m[1][1] 1 * mat.m[1][2]; // 通常w1这里省略了除以w的步骤因为我们的变换矩阵第三行是[0,0,1] return result; }4.2 复合变换与绕任意点旋转实际应用中我们经常需要组合多种变换。例如绕一个非原点的点(cx, cy)旋转。这可以通过“平移-旋转-平移回去”的复合变换实现T(cx,cy) * R(θ) * T(-cx,-cy)。矩阵乘法的顺序很重要因为矩阵乘法不满足交换律。在代码中变换顺序是从右向左应用的。// 绕任意点(cx,cy)旋转angle角度 Matrix3x3 createRotationAboutPointMatrix(float cx, float cy, float angle) { Matrix3x3 T1 createTranslationMatrix(-cx, -cy); // 移到原点 Matrix3x3 R createRotationMatrix(angle); // 旋转 Matrix3x3 T2 createTranslationMatrix(cx, cy); // 移回 // 复合矩阵: T2 * R * T1 (注意乘法顺序) Matrix3x3 result; // 这里需要实现矩阵乘法函数 matrixMultiply(A, B) Matrix3x3 temp matrixMultiply(R, T1); result matrixMultiply(T2, temp); return result; } // 矩阵乘法函数实现 Matrix3x3 matrixMultiply(Matrix3x3 A, Matrix3x3 B) { Matrix3x3 C; for(int i0; i3; i) { for(int j0; j3; j) { C.m[i][j] 0; for(int k0; k3; k) { C.m[i][j] A.m[i][k] * B.m[k][j]; } } } return C; }实操心得矩阵乘法的顺序这是最容易出错的地方。记住变换是从右向左依次施加到点上的。M_final M3 * M2 * M1那么对点p的变换是p M_final * p M3 * (M2 * (M1 * p))。在实现绕任意点旋转时一定要先平移T(-cx,-cy)再旋转R最后平移回去T(cx,cy)。浮点数精度三角函数cos和sin会产生浮点数误差经过多次变换后误差可能累积。对于显示来说通常可以接受但如果要进行严格的几何判断需要注意。性能优化对于需要变换大量点的情况如一个多边形所有顶点应预先计算好复合变换矩阵然后对每个顶点应用一次矩阵乘法而不是对每个顶点依次进行多个变换。5. 三维图形初探与简单动画将二维扩展到三维并让图形动起来你的作业档次会立刻提升。这里我们实现一个最简单的三维立方体的线框模型旋转动画。5.1 三维坐标与正交投影三维点我们用(x, y, z)表示。为了在二维屏幕上显示需要进行投影。我们先使用最简单的正交投影直接忽略z坐标将(x, y, z)投影为(x, y)。这相当于从z轴正方向看向原点。首先定义三维立方体的8个顶点假设中心在原点边长为2typedef struct { float x, y, z; } Point3D; Point3D cube[8] { {-1, -1, -1}, {1, -1, -1}, {1, 1, -1}, {-1, 1, -1}, // 后面四个点 {-1, -1, 1}, {1, -1, 1}, {1, 1, 1}, {-1, 1, 1} // 前面四个点 };定义连接这些顶点的12条边用顶点索引对表示int edges[12][2] { {0,1}, {1,2}, {2,3}, {3,0}, // 后面的面 {4,5}, {5,6}, {6,7}, {7,4}, // 前面的面 {0,4}, {1,5}, {2,6}, {3,7} // 连接前后的棱 };绘制函数就是遍历每条边将两个端点的三维坐标投影到二维然后画线。void drawCube(Point3D* cube, int edges[12][2]) { for(int i0; i12; i) { int idx1 edges[i][0]; int idx2 edges[i][1]; // 正交投影直接取x,y。为了在屏幕中央显示可以加上偏移量。 int x1 (int)(cube[idx1].x * 50 320); // 放大50倍平移到屏幕中心(320,240) int y1 (int)(cube[idx1].y * 50 240); int x2 (int)(cube[idx2].x * 50 320); int y2 (int)(cube[idx2].y * 50 240); line(x1, y1, x2, y2); } }5.2 三维旋转变换与动画循环让立方体动起来就是让它绕某个轴旋转。三维旋转矩阵比二维复杂一些。绕x轴、y轴、z轴旋转的矩阵如下绕Z轴旋转与二维旋转类似| cosθ -sinθ 0 | | sinθ cosθ 0 | | 0 0 1 |绕X轴旋转| 1 0 0 | | 0 cosθ -sinθ | | 0 sinθ cosθ |绕Y轴旋转| cosθ 0 sinθ | | 0 1 0 | |-sinθ 0 cosθ |我们实现一个绕Y轴旋转的函数并创建一个动画循环。// 绕Y轴旋转一个三维点 Point3D rotateY(Point3D p, float angle) { float rad angle * 3.14159265f / 180.0f; float cosA cos(rad); float sinA sin(rad); Point3D result; result.x p.x * cosA p.z * sinA; result.y p.y; result.z -p.x * sinA p.z * cosA; return result; } int main() { initgraph(640, 480); setcolor(WHITE); float angle 0; Point3D rotatedCube[8]; while(!kbhit()) { // 当没有按键时循环 cleardevice(); // 清空屏幕 // 1. 根据当前角度计算旋转后的顶点 for(int i0; i8; i) { rotatedCube[i] rotateY(cube[i], angle); } // 2. 绘制旋转后的立方体 drawCube(rotatedCube, edges); // 3. 更新角度实现连续旋转 angle 1.0f; // 每帧旋转1度 if(angle 360) angle - 360; // 4. 加入微小延迟控制旋转速度 Sleep(50); // 延迟50毫秒 } closegraph(); return 0; }常见问题与排查技巧实录问题1立方体旋转时形状扭曲或不像立方体。排查最可能的原因是旋转矩阵公式写错了特别是正负号。仔细核对绕Y轴旋转矩阵的sinθ项。记住右手坐标系下绕Y轴旋转公式是x x*cosθ z*sinθ,z -x*sinθ z*cosθ。可以尝试先绕Z轴旋转公式简单不易错。技巧在初始位置angle0打印出8个顶点的坐标确认是否正确。然后旋转90度再打印坐标看是否符合预期例如绕Y轴转90度原来的(1,0,0)点应该变成(0,0,-1)。问题2动画闪烁严重。排查这是因为我们在while循环中先cleardevice()清屏再画图。清屏和绘制之间屏幕是空的造成了闪烁。解决使用双缓冲技术。原理是在内存中创建一个和屏幕画布一样大小的“缓冲区图像”所有的绘图操作都先在这个缓冲区上进行等一整帧画面全部画好后再一次性将这个缓冲区的内容拷贝到屏幕上显示。EasyX库提供了BeginBatchDraw()、FlushBatchDraw()和EndBatchDraw()函数来实现简单的双缓冲。BeginBatchDraw(); // 开始批量绘图 while(!kbhit()) { cleardevice(); // ... 你的绘制代码 ... FlushBatchDraw(); // 批量绘制到屏幕 Sleep(50); } EndBatchDraw(); // 结束批量绘图使用双缓冲后闪烁会大大减轻。问题3立方体深度感不强看不出前后。排查我们用的是正交投影所有z值被忽略所以前面的边和后面的边在屏幕上重叠了。解决实现背面剔除或深度排序画家算法。对于简单的立方体线框一个取巧的方法是不画那些被遮挡的边。在正交投影下可以判断一条边的两个端点是否都在某个坐标轴的正方向或负方向例如当立方体绕Y轴旋转时可以粗略地认为z值较大的点在前方。更简单的方法是改用透视投影让远处的线看起来短一些。透视投影公式为x_screen (x / z) * d centerX,y_screen (y / z) * d centerY其中d是观察距离。注意z值太接近0会导致除零错误需要加一个偏移。6. 作业整合与进阶优化建议将以上六个部分整合起来你已经可以完成一个非常丰富的图形学期末作业了。一个完整的作业报告或程序可以这样组织第一部分基础图元绘制。展示你实现的Bresenham直线和中点圆算法并与库函数结果对比。第二部分多边形填充。用扫描线算法填充一个自己定义的复杂多边形如五角星并处理顶点特殊情况。第三部分二维变换。绘制一个图形如房子、三角形实现通过键盘如方向键平移、A/D键旋转、W/S键缩放交互控制其变换并展示复合变换如绕某个点旋转。第四部分三维与动画。展示旋转的立方体线框模型并尝试实现透视投影让立方体更有立体感。可以增加交互例如按X、Y、Z键切换旋转轴。进阶优化与扩展方向反走样你画的直线或圆在放大时会有明显的“锯齿”。可以调研并实现简单的反走样算法如Wu反走样线算法这会是作业的加分项。颜色与渐变在填充多边形时不使用单一颜色而是实现线性渐变填充。这需要在扫描线填充时根据当前像素的x或y坐标插值计算颜色。简单光照对于三维立方体可以尝试不画线框而是填充它的面。并给每个面一个固定的颜色通过计算面法向量与“光线”方向的点积来模拟漫反射光照使立方体看起来更真实。模型加载从简单的.obj文件格式中读取三维模型仅顶点和面信息并显示这能极大提升作业的复杂度和技术含量。我个人在实际操作中的体会是图形学作业最难的不是写代码而是调试。一个符号的错误可能导致整个图形扭曲。我的建议是分模块测试大量使用printf。每实现一个函数比如画线就立刻写个小程序测试它把计算出的像素坐标打印出来和手工计算的结果对比。对于矩阵变换先测试单位矩阵再测试单一的平移、旋转最后测试复合变换。动画部分先把旋转角度固定为0度、90度、180度看看图形是否在预期位置。耐心地、一步一步地验证远比写完后面对一团乱麻的图形抓狂要高效得多。最后享受从无到有创造出图形的过程这份成就感是驱动你克服所有困难的最大动力。
返回列表