ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

OpenGL实战:用VC++从零构建3D俄罗斯方块

OpenGL实战:用VC++从零构建3D俄罗斯方块 简介基于OpenGL和C实现的3D俄罗斯方块游戏源码工程面向学习计算机图形学与游戏编程的学生、开发者可帮助理解3D物体渲染、视角变换、碰撞检测与消行算法等核心问题。资源包为rar压缩格式总计62个文件其中包含35个头文件、4个cpp源文件以及png与bmp图像资源同时附带dsw、dsp工程文件整体仅90KB代码精炼、目录完整。已有281人学习/浏览对于入门级OpenGL项目具有一定参考热度。从main.cpp到Tetris.cpp源码分层清晰便于定位渲染与游戏逻辑同时包含界面资源可直接运行体验。通过这份源码可梳理OpenGL基础绘制流程、GDI辅助封装、三维方块建模与旋转消行等实现头文件覆盖GDI与OpenGL常用接口适合作为课程设计、毕业设计或3D游戏编程的起步模板。1. 为什么3D俄罗斯方块值得用 OpenGL 重写一遍很多写过2D俄罗斯方块的人会觉得所谓3D版无非是把一个二维网格套上三维外壳每个方块画成贴图立方体而已。真动手才知道事情全坏在“维度”两个字上立方体需要投影矩阵才能出现在屏幕上旋转不再是数组下标交换而是矩阵乘法消行判断的对象从一行布尔值变成了一组Y坐标切片。更麻烦的是Windows 上写 OpenGL 程序要先面对设备描述表(HDC)、渲染描述表(HGLRC)、像素格式三重初始化这一套流程在 VC 里配置错了往往屏幕上只有黑窗口连错误日志都不给。这篇文章要解决的就是这条路用 C/C 在 VC 环境里搭出一个能跑的 OpenGL 窗口把 7 种方块、旋转、碰撞、消行这套经典逻辑完整实现再通过键盘和鼠标把视角转起来。读者最好已经写过 C 类、懂一点矩阵概念如果完全没碰过 OpenGL也不妨碍我会把每个矩阵管线和参数都解释清楚。2. 用 OpenGL 在 VC 里搭出旋转立方体的最小框架2.1 先解决环境VC 工程要链接哪几个库在 VC 里做 OpenGL 开发最常见的方式是创建一个 Win32 App 或 Win32 Project然后手动把 OpenGL 的导入库添加到工程依赖项。老手会告诉你VC 6.0 到 Visual Studio 系列其实都自带 opengl32.lib 和 glu32.lib不需要额外下载任何 SDK你只需要告诉链接器去哪里找它们。通常的做法是在菜单 Project - Settings - Link - Object/library modules 里填入 opengl32.lib glu32.lib或者用 #pragma comment 指令直接在代码里声明依赖。// 在源文件顶部加入这一行相当于告诉链接器去链接 opengl32.lib #pragma comment(lib, opengl32.lib) #pragma comment(lib, glu32.lib) #include windows.h #include GL/gl.h #include GL/glu.h这段代码的作用是让 Visual C 工具链在链接阶段自动引入 OpenGL 的导入库。如果你用的是命令行编译也可以把这两个 .lib 作为参数传给 link.exe。参数说明opengl32.lib 是 OpenGL 核心函数glBegin、glVertex3f、glMatrixMode 等glu32.lib 是 OpenGL Utility Library 的函数gluPerspective、gluLookAt。有些教程会让你加 glaux.lib但那属于 Windows 早期的辅助库现在已经淘汰不建议在项目里引入否则会带来不必要的兼容问题。2.2 窗口和像素格式双缓冲与深度缓冲必须同时开OpenGL 渲染需要一个窗口来承载绘制内容但 Windows 的窗口要先通过 ChoosePixelFormat 和 SetPixelFormat 设定像素格式才能与 OpenGL 上下文绑定。很多初学者只配了颜色缓冲没有开启深度缓冲结果看到的是方块互相遮挡混乱还以为是矩阵问题。这里有一个固定的模板基本可以直接复用到任何 Win32 窗口里。// 在 WM_CREATE 或窗口显示之前调用 HDC hdc GetDC(hwnd); static PIXELFORMATDESCRIPTOR pfd { sizeof(PIXELFORMATDESCRIPTOR), 1, PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER, PFD_TYPE_RGBA, 32, // 颜色位深 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 24, // 深度缓冲位深这里是24位 8, // 模板缓冲 0, PFD_MAIN_PLANE, 0, 0, 0, 0 }; int pf ChoosePixelFormat(hdc, pfd); SetPixelFormat(hdc, pf, pfd); HGLRC hrc wglCreateContext(hdc); wglMakeCurrent(hdc, hrc);代码逻辑说明PIXELFORMATDESCRIPTOR 描述了我们想从显卡获得什么样的绘图表面。第 13 行指定 PFD_DOUBLEBUFFER这是为了让画面不闪烁第 18 行指定 24 位深度缓冲用于遮挡计算。如果没有 PFD_DOUBLEBUFFERSwapBuffers 函数会失去意义如果没有深度位glEnable(GL_DEPTH_TEST) 也只是空转。选择像素格式时ChoosePixelFormat 会返回一个最接近我们要求的格式编号如果函数返回 0说明当前显卡或虚拟环境不支持该配置常见原因是远程桌面或虚拟机里的显卡驱动没有启用 OpenGL 硬件加速。2.3 投影矩阵与视图矩阵gluPerspective 的参数到底怎么调一旦 OpenGL 上下文创建成功下一步就是设置 3D 空间到屏幕的映射。我一般习惯在窗口大小变化时重新设置视口和投影矩阵这样窗口拉大缩小后图形不会变形。// 在 WM_SIZE 消息里调用 GLfloat aspect (GLfloat)width / (GLfloat)height; glViewport(0, 0, width, height); glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(45.0f, aspect, 0.1f, 100.0f); glMatrixMode(GL_MODELVIEW); glLoadIdentity();参数说明gluPerspective 的第一个参数是垂直视角单位是度45 度接近人眼的自然视野第二个参数 aspect 是画面宽高比如果窗口宽度 800、高度 600aspect 就是 1.333第三个和第四个参数是近裁剪面和远裁剪面表示只渲染距离摄像机 0.1 到 100 个单位之间的物体。近裁剪面太小会导致深度精度下降太大则可能裁掉近处的方块。注意 glMatrixMode(GL_PROJECTION) 之后的 glLoadIdentity 绝不能省否则投影矩阵会不断累乘。2.4 画一个立方体从顶点坐标到光照法线俄罗斯方块的基本单元是立方体所以先把一个立方体画明白比什么都重要。在固定管线里最直接的方式是用 glBegin / glEnd 逐面绘制每个面由四个顶点构成并且要给出该面的法线向量。void DrawCube(float x, float y, float z, float r, float g, float b) { glPushMatrix(); glTranslatef(x, y, z); // 设置材质颜色并开启深度测试 glColor3f(r, g, b); glEnable(GL_DEPTH_TEST); glBegin(GL_QUADS); // 前面 glNormal3f(0.0f, 0.0f, 1.0f); glVertex3f(-0.5f, -0.5f, 0.5f); glVertex3f( 0.5f, -0.5f, 0.5f); glVertex3f( 0.5f, 0.5f, 0.5f); glVertex3f(-0.5f, 0.5f, 0.5f); // 后面 glNormal3f(0.0f, 0.0f, -1.0f); glVertex3f(-0.5f, -0.5f, -0.5f); glVertex3f(-0.5f, 0.5f, -0.5f); glVertex3f( 0.5f, 0.5f, -0.5f); glVertex3f( 0.5f, -0.5f, -0.5f); // 上面、下面、左面、右面同理省略 glEnd(); glPopMatrix(); }逻辑说明这里使用的立方体中心位于 (x, y, z) 处边长是 1所以顶点坐标在 -0.5 到 0.5 之间。glPushMatrix 和 glPopMatrix 保证每一次平移不会污染其他方块的坐标。每个面的 glNormal3f 是给面指定法线方向光照计算会用到它虽然你暂时还没开光源但后面加 glEnable(GL_LIGHTING) 时这些法线就必须正确否则立方体看起来会像被涂了一层金属漆。要验证这段代码是否工作需要在绘制完成之后调用 SwapBuffers(hdc)这样后台缓冲的内容才会显示到屏幕上。3. 俄罗斯方块核心算法形状、旋转、碰撞与消行3.1 形状存储为什么我用 4x4 矩阵而不是坐标数组网络上的许多教程喜欢用 int shape[4][4] 来表示一种方块这种表示方式在旋转时可以简单交换下标非常直观。但对于3D俄罗斯方块面数增加了矩阵维度会变成多层嵌套反而难以维护。所以我习惯用坐标数组来存放每个方块单元在局部空间中的位置旋转时对每个单元做矩阵变换。下面用二维版本来示意这个结构每种方块有四个小单元每个单元用 (x, y, z) 表示这样 3D 版本只是增加一个维度。以下是 7 种经典方块在某个朝向下的坐标定义。struct Block { int cells[4][3]; // 4个单元每个单元有(x,y,z) int colorIndex; }; Block blocks[7] { { {{1,0,0}, {2,0,0}, {1,1,0}, {2,1,0}}, 0 }, // O 方块 { {{0,0,0}, {1,0,0}, {2,0,0}, {3,0,0}}, 1 }, // I 方块 { {{0,0,0}, {1,0,0}, {1,1,0}, {2,1,0}}, 2 }, // S 方块 { {{1,0,0}, {2,0,0}, {0,1,0}, {1,1,0}}, 3 }, // Z 方块 { {{0,0,0}, {1,0,0}, {2,0,0}, {0,1,0}}, 4 }, // L 方块 { {{0,0,0}, {1,0,0}, {2,0,0}, {2,1,0}}, 5 }, // J 方块 { {{0,0,0}, {1,0,0}, {2,0,0}, {1,1,0}}, 6 }, // T 方块 };参数说明坐标数组里每个三元组表示该单元相对方块中心或者方块的锚点的偏移。这里为了直观把 O 方块的两个单元放在 x1 和 x2y0 和 y1相当于一个 2x2 的正方形。旋转时不能直接对数组元素做等值交换而是需要用旋转矩阵去乘每个 unit 的坐标。colorIndex 用来索引颜色表每个形状在渲染时对应不同颜色方便玩家区分。3.2 旋转矩阵绕 Y 轴旋转的公式与坐标取整3D俄罗斯方块的玩法通常是在一个立体的井字形空间内落下方块方块可以绕 Y 轴旋转也可以绕 X 轴或 Z 轴旋转。最常见的旋转操作是绕 Y 轴顺时针旋转 90 度其变换公式是// 绕Y轴顺时针旋转90度 // (x, y, z) - (z, y, -x) void Rotate90Y(Block b) { for (int i 0; i 4; i) { int ox b.cells[i][0]; int oz b.cells[i][2]; b.cells[i][0] oz; b.cells[i][2] -ox; } }代码说明这里直接使用旋转矩阵代入 theta90 度后的结果等于 cos900、sin901。旋转前先保存原来的 x 和 z 值否则先修改的坐标会被后一步使用导致旋转错乱。这个公式只适用于 90 度整数倍旋转因为俄罗斯方块的形状是离散网格非整数倍旋转会产生无法对齐格子的坐标。每次旋转之后通常还要对坐标做一次四舍五入或 clamp防止出现 -0 或浮点误差。我在实际项目中会直接使用整数坐标这样碰撞检测和渲染时的世界坐标换算都会非常稳定。3.3 碰撞检测把网格看成三维 bool 数组碰撞检测的核心是建立一个三维的占用表。假设游戏空间是 10 宽、20 高、10 深那就定义一个 bool cube[10][20][10]。每个方块落下后把对应坐标标记成 true。新的方块移动或旋转后先试探性地更新坐标再检查所有单元是否超出边界、是否与已有的 true 冲突。bool board[10][20][10] {false}; bool CanPlace(const Block b, int offsetX, int offsetY, int offsetZ) { for (int i 0; i 4; i) { int x b.cells[i][0] offsetX; int y b.cells[i][1] offsetY; int z b.cells[i][2] offsetZ; if (x 0 || x 10) return false; if (z 0 || z 10) return false; if (y 0 || y 20) return false; if (board[x][y][z]) return false; } return true; }逻辑说明offsetX、offsetY、offsetZ 是方块在游戏空间中的基准位置通常方块内部的坐标都是 0 到 3 的小整数实际棋盘坐标等于单元坐标加上偏移量。注意这里的高度 y 如果小于 0说明方块已经落到棋盘底面以下直接返回 false。棋盘边界范围如果改大比如想做一个 10x20x10 的游戏需要同步修改数组维度和这里的常量。这种检测方法的时间复杂度是 O(4)因为每个方块固定只有 4 个单元无论棋盘多大检测开销都极小。3.4 消行判定从最低一行往上扫3D 版本的消行比 2D 复杂一点因为棋盘是三维的行有两种理解可以是沿 Y 轴的整层即一个 y 值对应一个 10x10 平面也可以是沿 X 轴或 Z 轴的列。经典玩法通常消的是 Y 层即某一层所有坐标都被填满则这一层消失上方的所有层整体下移一层。实现时要注意从上往下遍历否则一次性消除多层时会漏判。int ClearLines() { int cleared 0; for (int y 0; y 20; y) { bool full true; for (int x 0; x 10; x) for (int z 0; z 10; z) if (!board[x][y][z]) { full false; break; } if (full) { cleared; // 把此层以上的所有层往下移一层 for (int yy y; yy 19; yy) for (int x 0; x 10; x) for (int z 0; z 10; z) board[x][yy][z] board[x][yy1][z]; // 最顶层置空 for (int x 0; x 10; x) for (int z 0; z 10; z) board[x][19][z] false; y--; // 因为移动了一层重新检查当前层 } } return cleared; }代码说明自底向上遍历所有 y 层。当发现某层 full 为 true就执行层下移操作把 y1 层的内容复制到 y 层。此时当前层 y 已经在循环中被处理但下移之后可能有新的方块从上面掉下来所以要将 y 自减强制重新检查同一个 y 值。这里有个经典陷阱如果一次性有两层同时满你不自减的话第二次循环会跳过被下移过的层导致只消了一层。cleared 返回值可以用于计分我一般让它返回消除层数然后乘以一个权重换算成游戏分数。4. 把键盘输入和渲染循环接起来控制、重力与视角4.1 Win32 消息循环里的方向键处理OpenGL 程序的窗口消息由 Win32 的 WndProc 函数处理。方向键属于系统按键消息它们不是字符消息所以要用 WM_KEYDOWN 来捕获而不是 WM_CHAR。下面这段是典型的按键处理分支放在 WndProc 的 switch 中。case WM_KEYDOWN: switch (wParam) { case VK_LEFT: MoveBlock(-1, 0, 0); break; case VK_RIGHT: MoveBlock( 1, 0, 0); break; case VK_UP: MoveBlock( 0, 0, -1); break; case VK_DOWN: MoveBlock( 0, 0, 1); break; case VK_SPACE: DropBlock(); break; case VK_ESCAPE: PostQuitMessage(0); break; } break;参数说明VK_LEFT 和 VK_RIGHT 让方块沿 X 轴左右移动VK_UP 和 VK_DOWN 让方块沿 Z 轴前后移动。MoveBlock 内部会调用前面写的 CanPlace 函数如果新位置合法就更新当前方块的 offsetX/offsetZ否则不更新。注意这里没有使用 VK_RETURN 和 VK_SHIFT 作为旋转键因为它们在游戏中容易误触我通常把旋转键放在键盘上的 R 键和 E 键分别绕 Y 轴逆时针和顺时针旋转。实际项目中还要考虑按键重复问题如果你按住左箭头不放WM_KEYDOWN 会因为系统自动重复而快速触发这不是什么大问题但如果你想要更精确的控制可以用 GetAsyncKeyState 在渲染循环里查询这样和帧率相关。4.2 用 GetTickCount 计算重力下落而不是 SetTimer很多初学者会用 SetTimer 在 WM_TIMER 里处理下落但这有个缺点如果窗口被拖动或菜单弹出WM_TIMER 消息会被阻塞掉落的频率会突然变慢。我一般会在主动渲染循环里用系统时间戳来控制重力速度。下面的代码展示了如何在消息循环里实现一个独立于帧率的下落逻辑。DWORD lastTick GetTickCount(); int gravityInterval 500; // 毫秒 while (running) { // 处理所有窗口消息 while (PeekMessage(msg, NULL, 0, 0, PM_REMOVE)) { if (msg.message WM_QUIT) running false; TranslateMessage(msg); DispatchMessage(msg); } // 重力计时 DWORD now GetTickCount(); if (now - lastTick (DWORD)gravityInterval) { if (!MoveBlock(0, -1, 0)) // 尝试下移一格 { LockBlockToBoard(); // 锁住方块生成新方块 } lastTick now; } // 渲染 RenderScene(); SwapBuffers(hdc); }逻辑说明PeekMessage 会让消息循环非阻塞地运行没有消息时继续执行渲染。GetTickCount 返回的是系统启动到现在的毫秒数用它除以间隔来判断是否到了下落时间。gravityInterval 就是每落一格的毫秒数数值越小下落越快游戏难度越高。当 MoveBlock(0, -1, 0) 返回 false 时说明方块无法继续下移此时执行 LockBlockToBoard把当前方块的四个单元写入 board 数组然后生成下一个随机方块。这里需要注意数据类型GetTickCount 返回的是 DWORD差值比较时两边都转成 DWORD避免无符号数减法溢出产生负面结果。4.3 摄像机视角旋转用 gluLookAt 实现观察者绕场3D 俄罗斯方块的看点在于视角能旋转让玩家看到不同面的堆积状态。我习惯把摄像机放在方块堆的斜上方像观察一个透明盒子。鼠标拖动可以改变两个角度值然后在每一帧里用 gluLookAt 重新计算观察矩阵。float yaw 45.0f; float pitch 30.0f; float distance 30.0f; void UpdateCamera() { glMatrixMode(GL_MODELVIEW); glLoadIdentity(); float radYaw yaw * 3.14159f / 180.0f; float radPitch pitch * 3.14159f / 180.0f; float cx distance * cosf(radPitch) * sinf(radYaw); float cy distance * sinf(radPitch); float cz distance * cosf(radPitch) * cosf(radYaw); gluLookAt(cx, cy, cz, // 摄像机位置 0.0f, 8.0f, 0.0f, // 观察点偏向上方的堆 0.0f, 1.0f, 0.0f); // 头部朝上 }参数说明yaw 控制摄像机绕 Y 轴旋转pitch 控制抬头或俯视角度distance 控制摄像机与场景中心的距离。计算出来的 cx、cy、cz 是将球坐标转换为直角坐标。观察点固定在 (0, 8, 0)即场景中心偏上一点的位置这样玩家主要看到方块下落的上半部分。aglorth不gluLookAt 会生成一个视图矩阵将它放在 GL_MODELVIEW 栈中后续所有方块都绘制在这个坐标空间里。鼠标消息中我通常把 WM_MOUSEMOVE 的 dx/dy 累加到 yaw 和 pitch但要限制 pitch 在 10 到 89 度之间避免摄像机翻转到底部导致画面颠倒。case WM_MOUSEMOVE: if (wParam MK_LBUTTON) { int dx LOWORD(lParam) - lastMouseX; int dy HIWORD(lParam) - lastMouseY; yaw dx * 0.5f; pitch - dy * 0.5f; if (pitch 10.0f) pitch 10.0f; if (pitch 89.0f) pitch 89.0f; lastMouseX LOWORD(lParam); lastMouseY HIWORD(lParam); } break;5. 进阶用 VBO 批量渲染方块让消行更快5.1 把 glBegin/glEnd 换成顶点缓冲区固定管线的 glBegin/glEnd 在现代显卡上效率并不高每一帧都要 CPU 重新提交顶点数据。当棋盘堆了 100 个方块一个有 6 个面、24 个顶点的立方体就会产生 2400 个顶点每帧重复调用 glVertex3f 的开销不小。更合理的做法是用一个顶点缓冲对象(VBO)保存所有板块的顶点每一帧只需更新那些变化了的方块坐标。下面是用单个 VBO 维护的棋盘绘制函数核心流程GLuint vbo; std::vectorfloat vertices; std::vectorfloat normals; void RebuildBoardGeometry() { vertices.clear(); normals.clear(); for (int x 0; x 10; x) for (int y 0; y 20; y) for (int z 0; z 10; z) if (board[x][y][z]) { // 为每个立方体生成24个顶点6个面*4个顶点 float cx x - 5.0f; float cy y; float cz z - 5.0f; // 此处省略向vertices和normals中填充顶点坐标和法线的代码 } glBindBuffer(GL_ARRAY_BUFFER, vbo); glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(float), vertices[0], GL_DYNAMIC_DRAW); glBindBuffer(GL_ARRAY_BUFFER, 0); } void DrawBoard() { glBindBuffer(GL_ARRAY_BUFFER, vbo); glEnableClientState(GL_VERTEX_ARRAY); glEnableClientState(GL_NORMAL_ARRAY); glVertexPointer(3, GL_FLOAT, 0, 0); glNormalPointer(GL_FLOAT, 0, (void*)(normals.size()*sizeof(float))); glDrawArrays(GL_QUADS, 0, vertices.size()/3); glDisableClientState(GL_VERTEX_ARRAY); glDisableClientState(GL_NORMAL_ARRAY); glBindBuffer(GL_ARRAY_BUFFER, 0); }代码说明RebuildBoardGeometry 在每次方块落定后调用扫描整个 board 数组把每个占据的立方体展开成顶点和法线。glBufferData 的最后一个参数使用 GL_DYNAMIC_DRAW含义是开发者会会频繁更新缓冲数据显卡会将这块内存放在更适合频繁写入的位置。| glDrawArrays | 按 GL_QUADS 绘制一次调用就能画完整个棋盘。注意顶点指针和法线指针需要指向同一个 VBO 里的不同偏移这里省略的填充代码需要你在实际项目中按顺序交替写入顶点坐标和法线坐标。5.2 这个技巧对调试也有帮助VBO 不只是为了性能它还让调试变得更简单因为你可以用 glGetBufferSubData 把显卡里的顶点数据读回 CPU检查它是否与理论值一致。当屏幕上出现残影或乱点时我会先输出 buffer 的长度再检查 vertices 数组是否有 NaN 或超出边界的坐标。相比之下glBegin/glEnd 的数据只存在于 CPU 端看不出问题的位置。5.3 验证一局游戏的三个指标写完整个程序后我建议从三个维度验证你的实现是否正确。第一个是方块是否能原地旋转而不穿模在场地任意位置按旋转键旋转后的新坐标必须经过 CanPlace 校验失败时保持原坐标不能出现方块旋转一半卡进已有方块的情况。第二个是消行是否只在整层填满时发生用记分板或者日志文件记录每次消行的行号手动堆出缺一格的层确认它不会触发消行。第三个是视角旋转时深度矛盾是否正确把摄像机绕到方块堆的背面透过缝隙仍能看到内部这说明深度测试和绘制顺序都没有问题。如果出现透明或者错层优先排查像素格式里的深度缓冲位数是否真的设置成了 24 位以及 glEnable(GL_DEPTH_TEST) 是否在渲染初始化后就被调用而不是在绘制循环内部反复开关。以上这些逻辑实际写入你的代码后一个由 OpenGL 驱动的 3D 俄罗斯方块就从一个概念变成了可玩的程序接下来要做的就是不断调整方块颜色与摄像机距离让玩家在旋转视角时不会产生眩晕感。本文还有配套的精品资源点击获取
返回列表