ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

OpenGL 3D俄罗斯方块实战:VC++环境搭建与三维逻辑改造

OpenGL 3D俄罗斯方块实战:VC++环境搭建与三维逻辑改造 简介这是一份面向C与图形编程学习者的OpenGL实战项目源码以经典俄罗斯方块玩法为基础借助OpenGL渲染管线实现三维立体方块效果适合已掌握C基础语法、希望进阶图形渲染与游戏逻辑的开发者练手。压缩包共62个文件约90KB其中35个h头文件承担类声明与接口定义4个cpp源文件负责主逻辑、方块控制与渲染实现另有11个png与10个bmp图片资源用于纹理与界面素材并附带dsw、dsp工程文件可直接用VC打开编译。项目将游戏循环、方块旋转碰撞、三维坐标变换与OpenGL绘制整合在同一工程中目录结构清晰便于读者对照源码理解3D游戏从数据组织到画面输出的完整链路。目前已有283人学习适合作为课程设计、图形学实验或自学OpenGL的参考案例。1. 从一堆方块说起OpenGL 做 3D 俄罗斯方块到底难在哪很多人第一次听到「用 OpenGL 写 3D 俄罗斯方块」脑子里浮现的是把经典 2D 方块转个角度、加个透视就完事。真动手才发现难点根本不在渲染一个立方体而在于把「俄罗斯方块」这套离散的、基于网格的规则映射到一套连续的、基于矩阵的 3D 坐标系里还要让旋转、消层、下落这些逻辑在三维空间里自洽。标题里的 OpenGL、VC、C/C 三个词其实对应三件不同的事OpenGL 负责把方块画出来并做相机变换VC 是 Windows 上最省事的编译调试环境C/C 则是承载游戏状态机和碰撞检测的语言底座。这篇文章面向的是会一点 C、想拿一个完整 3D 小项目练手 OpenGL 管线的人也适合那些做过 2D 俄罗斯方块、想看看三维化之后哪些逻辑会翻车的开发者。我会把从环境搭建、坐标映射、方块旋转到消层判定的完整路径讲清楚参数怎么设、坑在哪都落到能直接抄的程度。2. 环境与窗口在 Visual Studio 里把 OpenGL 跑起来2.1 为什么选 VC 而不是别的编译器在 Windows 上做 OpenGL 开发VC 几乎是默认答案原因很实际OpenGL 本身只是个头文件加一个动态库Windows SDK 里自带opengl32.lib和GL/gl.h不需要额外装 SDK 就能编译出窗口程序。用 MinGW 或 VS Code 配 C/C 环境当然也能跑但链接 Windows 窗口 APIwglCreateContext、ChoosePixelFormat这些时MSVC 的兼容性最省心。热词里常出现的「vscode 配置 c/c 环境」「c/c 构建」在纯控制台程序里没问题一旦涉及 OpenGL 上下文创建新手很容易卡在链接错误上。我的建议是主力用 Visual Studio2019 或 2022 都行把 VS Code 留给写脚本和看代码。需要明确一点Windows 自带的gl.h版本很老只到 OpenGL 1.1。如果你打算用现代 OpenGL3.3 的着色器管线得自己加载扩展函数常见做法是用 GLAD 或 GLEW。但对于 3D 俄罗斯方块这种规模固定管线glBegin/glEnd、glMatrixMode完全够用而且代码量少、调试直观。下面这套方案走固定管线适合快速出效果。2.2 创建窗口和 OpenGL 上下文的最小代码先建一个空的 C 控制台项目把下面的代码贴进主文件。这段代码做了三件事注册窗口类、创建窗口、绑定 OpenGL 上下文。#include windows.h #include GL/gl.h #include GL/glu.h #pragma comment(lib, opengl32.lib) #pragma comment(lib, glu32.lib) HWND hWnd; HDC hDC; HGLRC hRC; LRESULT CALLBACK WndProc(HWND h, UINT msg, WPARAM w, LPARAM l) { switch (msg) { case WM_CLOSE: PostQuitMessage(0); return 0; case WM_KEYDOWN: // 键盘输入在这里处理后续章节会用到 if (w VK_ESCAPE) PostQuitMessage(0); return 0; } return DefWindowProc(h, msg, w, l); } bool InitGL() { hDC GetDC(hWnd); PIXELFORMATDESCRIPTOR pfd { sizeof(pfd), 1 }; pfd.dwFlags PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; pfd.iPixelType PFD_TYPE_RGBA; pfd.cColorBits 32; pfd.cDepthBits 24; // 深度缓冲3D 必须 int pf ChoosePixelFormat(hDC, pfd); if (!pf) return false; SetPixelFormat(hDC, pf, pfd); hRC wglCreateContext(hDC); wglMakeCurrent(hDC, hRC); return true; }pfd.cDepthBits 24这一行是 3D 和 2D 的分水岭。没有深度缓冲方块的前后关系会乱成一团后面的面盖住前面的面。PFD_DOUBLEBUFFER开双缓冲避免画面撕裂。窗口创建部分用标准CreateWindowEx即可注册类名随便取风格用WS_OVERLAPPEDWINDOW。2.3 投影矩阵和相机把 3D 场景塞进窗口窗口有了接下来要告诉 OpenGL 怎么把三维坐标投影到二维屏幕上。固定管线里用gluPerspective设透视投影用gluLookAt摆相机。void SetupProjection(int w, int h) { glViewport(0, 0, w, h); glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(45.0, (double)w / h, 0.1, 100.0); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); gluLookAt(8, 12, 8, // 相机位置 0, 0, 0, // 看向原点 0, 1, 0); // 上方向 }gluPerspective的四个参数分别是视场角、宽高比、近裁剪面、远裁剪面。视场角 45 度比较自然近裁剪面 0.1 防止相机贴太近时穿模远裁剪面 100 足够覆盖整个棋盘。gluLookAt把相机放在 (8,12,8)从斜上方俯视原点这样能同时看到棋盘的顶面和侧面3D 感最强。如果相机放在正上方看起来又变回 2D 了失去意义。提示gluPerspective和gluLookAt来自glu32.lib别忘了在项目里链接。如果报「无法解析的外部符号」九成是漏了#pragma comment(lib, glu32.lib)。3. 把俄罗斯方块搬进三维空间坐标映射与方块建模3.1 棋盘网格怎么对应到世界坐标2D 俄罗斯方块的棋盘是 10 列 20 行每个格子是一个单位。搬到 3D 之后我一般把棋盘放在 XZ 平面上Y 轴朝上表示高度。具体映射是列号对应 X 轴行号对应 Z 轴方块的下落方向是 Y 轴负方向。这样棋盘就是一个 10×20 的平面网格方块从上方Y 正方向往下掉。每个格子的世界坐标可以这样算// col: 0~9, row: 0~19 float cellSize 1.0f; float worldX (col - 4.5f) * cellSize; // 居中列范围 -4.5 ~ 4.5 float worldZ (row - 9.5f) * cellSize; // 居中行范围 -9.5 ~ 9.5 float worldY 0.0f; // 棋盘平面在 Y0减 4.5 和 9.5 是为了让棋盘中心落在原点相机看向原点时构图才对称。cellSize取 1.0 是习惯方便心算实际项目里可以调成 0.8 让方块之间留缝视觉上更清晰。3.2 用 glBegin/glEnd 画一个带明暗的立方体固定管线画立方体最直接的方式是六个面各画一个四边形。关键是要给每个面设不同的法线这样光照才能算出明暗差异方块才有立体感。void DrawCube(float x, float y, float z, float size, float r, float g, float b) { float h size / 2.0f; glPushMatrix(); glTranslatef(x, y, z); glColor3f(r, g, b); glBegin(GL_QUADS); // 前面 glNormal3f(0, 0, 1); glVertex3f(-h, -h, h); glVertex3f( h, -h, h); glVertex3f( h, h, h); glVertex3f(-h, h, h); // 后面 glNormal3f(0, 0, -1); glVertex3f(-h, -h, -h); glVertex3f(-h, h, -h); glVertex3f( h, h, -h); glVertex3f( h, -h, -h); // 左面 glNormal3f(-1, 0, 0); glVertex3f(-h, -h, -h); glVertex3f(-h, -h, h); glVertex3f(-h, h, h); glVertex3f(-h, h, -h); // 右面 glNormal3f(1, 0, 0); glVertex3f( h, -h, -h); glVertex3f( h, h, -h); glVertex3f( h, h, h); glVertex3f( h, -h, h); // 顶面 glNormal3f(0, 1, 0); glVertex3f(-h, h, -h); glVertex3f(-h, h, h); glVertex3f( h, h, h); glVertex3f( h, h, -h); // 底面 glNormal3f(0, -1, 0); glVertex3f(-h, -h, -h); glVertex3f( h, -h, -h); glVertex3f( h, -h, h); glVertex3f(-h, -h, h); glEnd(); glPopMatrix(); }每个面的顶点顺序必须一致这里统一用逆时针否则开启背面剔除后会有面消失。glNormal3f设的是面法线配合光照使用。如果不开光照glColor3f直接给颜色也能看但所有面颜色一样方块会糊成一坨分不清棱角。3.3 光照和材质让方块看起来是立体的固定管线的光照配置不复杂但参数设错就会全黑或者过曝。void SetupLighting() { glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); glEnable(GL_COLOR_MATERIAL); glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE); GLfloat lightPos[] { 10.0f, 20.0f, 10.0f, 1.0f }; GLfloat lightAmb[] { 0.3f, 0.3f, 0.3f, 1.0f }; GLfloat lightDiff[] { 0.8f, 0.8f, 0.8f, 1.0f }; glLightfv(GL_LIGHT0, GL_POSITION, lightPos); glLightfv(GL_LIGHT0, GL_AMBIENT, lightAmb); glLightfv(GL_LIGHT0, GL_DIFFUSE, lightDiff); glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LEQUAL); }GL_COLOR_MATERIAL让glColor3f直接控制材质颜色省去单独设材质的麻烦。环境光给 0.3漫反射给 0.8加起来不会过曝。glEnable(GL_DEPTH_TEST)必须开否则后画的方块会盖住先画的旋转视角时穿帮。光源位置用齐次坐标第四个分量 1.0 表示点光源如果用 0.0 就是方向光效果差别很大点光源更自然。4. 游戏逻辑旋转、下落与消层的三维化改造4.1 方块的数据结构怎么设计俄罗斯方块的核心是七种形状I、O、T、S、Z、J、L每种形状有四个旋转态。2D 里常用 4×4 矩阵存每个旋转态3D 里我建议沿用这个思路但把矩阵当成 XZ 平面上的布局Y 轴只负责下落高度。struct Tetromino { int shape[4][4]; // 当前旋转态的 4x4 布局1 表示有方块 int rotation; // 0~3 int type; // 0~6 对应七种形状 float posX, posZ; // 棋盘上的位置列、行 float posY; // 下落高度 float color[3]; };用 4×4 而不是紧凑数组是因为旋转时直接对矩阵做转置加翻转就行不用处理边界偏移。posX和posZ用浮点是为了做平滑下落动画逻辑判定时再取整。4.2 旋转的矩阵操作与碰撞检测2D 里旋转就是矩阵转置3D 里因为棋盘还是平面的旋转逻辑不变但碰撞检测要考虑 Y 轴高度。void RotateMatrix(int mat[4][4]) { int tmp[4][4]; for (int i 0; i 4; i) for (int j 0; j 4; j) tmp[i][j] mat[3 - j][i]; // 顺时针旋转 90 度 memcpy(mat, tmp, sizeof(tmp)); } bool CheckCollision(int board[10][20], Tetromino t, int newX, int newZ) { for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (!t.shape[i][j]) continue; int bx newX j; int bz newZ i; if (bx 0 || bx 10 || bz 20) return true; if (bz 0 board[bx][bz]) return true; } } return false; }旋转公式tmp[i][j] mat[3-j][i]是顺时针 90 度逆时针就把 i 和 j 对调。碰撞检测里bz 20表示触底board[bx][bz]表示撞到已固定的方块。注意bz 0的情况要放过因为方块刚生成时可能有一部分在棋盘上方。4.3 消层判定与下落节奏控制消层在 3D 里和 2D 一样检查每一行Z 方向是否填满。填满就把上面的行整体下移。int ClearLines(int board[10][20]) { int cleared 0; for (int z 19; z 0; z--) { bool full true; for (int x 0; x 10; x) { if (!board[x][z]) { full false; break; } } if (full) { cleared; for (int zz z; zz 0; zz--) for (int x 0; x 10; x) board[x][zz] board[x][zz - 1]; for (int x 0; x 10; x) board[x][0] 0; z; // 重新检查当前行 } } return cleared; }下落节奏用计时器控制不要用Sleep否则整个程序卡住。常见做法是记录上次下落时间在主循环里判断是否超过间隔。DWORD lastDrop GetTickCount(); float dropInterval 500.0f; // 毫秒随难度递减 // 主循环里 DWORD now GetTickCount(); if (now - lastDrop dropInterval) { if (!CheckCollision(board, current, current.posX, current.posZ 1)) { current.posZ 1; } else { LockPiece(board, current); // 固定到棋盘 SpawnNewPiece(); } lastDrop now; }dropInterval初始 500 毫秒每消一层减 20 毫秒最低不低于 100 毫秒。用GetTickCount而不是time因为精度到毫秒够用。5. 避坑与排查3D 俄罗斯方块最容易翻车的五个地方5.1 画面全黑或只有背景色现象窗口出来了但里面什么都没有只有glClearColor设的背景色。原因最常见的是没开深度测试或者相机位置和方块位置重合导致近裁剪面裁掉了所有东西。也有可能是gluLookAt的参数写反了相机看向的方向不对。解决先确认glEnable(GL_DEPTH_TEST)在渲染循环之前调用。然后检查gluLookAt的 eye 和 center 参数eye 不能等于 center。如果还不行临时把gluPerspective的远裁剪面调到 1000排除裁剪问题。5.2 方块旋转后位置偏移现象按旋转键方块不是绕自己中心转而是跳到了别的位置。原因旋转矩阵操作的是 4×4 布局但方块的posX/posZ是布局左上角在棋盘上的位置。旋转后布局的有效区域变了左上角对应的实际方块位置就偏了。解决旋转后做一次「踢墙」修正检查旋转后的布局是否越界或碰撞如果碰撞就尝试左右平移一格再检测。这是俄罗斯方块的经典做法2D 里叫 wall kick3D 里逻辑一样。5.3 消层后上面的方块不下落现象满行消失了但上面的方块悬在空中。原因消层循环里z之后没有正确处理连续满行的情况或者下移时把数据覆盖错了。解决用倒序循环从底部往上检查消一行就把上面所有行下移一行然后z重新检查同一位置。注意下移时要从上往下复制避免覆盖未处理的数据。5.4 键盘输入延迟或连发现象按一下方向键方块移动了好几格或者按住不放没反应。原因WM_KEYDOWN会重复触发Windows 的键盘重复延迟设置会影响手感。直接用WM_KEYDOWN做移动会导致连发过快。解决用一个bool keys[256]数组记录按键状态在WM_KEYDOWN里置 trueWM_KEYUP里置 false主循环里根据状态和计时器控制移动频率。这样既能连发又能控制速度。5.5 高 DPI 屏幕上画面模糊或错位现象在 4K 屏或缩放 125% 的屏幕上窗口大小和渲染区域对不上画面被拉伸。原因Windows 的高 DPI 缩放会让GetClientRect返回的逻辑坐标和实际像素不一致。解决在项目属性里把 DPI 感知设为「Per Monitor High DPI Aware」或者在程序启动时调用SetProcessDPIAware()。然后glViewport用实际客户区像素尺寸不要用逻辑尺寸。6. 进阶技巧用显示列表和轨迹线提升渲染效率与观感6.1 显示列表缓存方块几何每帧画几十个立方体每个立方体 6 个面 24 个顶点固定管线下一帧几百次glVertex3f调用在低端机上会掉帧。显示列表display list可以把一个立方体的绘制命令编译成 GPU 端缓存后续调用只发一个 ID。GLuint cubeList; void BuildCubeList() { cubeList glGenLists(1); glNewList(cubeList, GL_COMPILE); // 这里放 DrawCube 里 glBegin 到 glEnd 之间的内容 // 注意颜色和位移不要放进去否则所有方块一个颜色 glEndList(); } // 渲染时 glPushMatrix(); glTranslatef(x, y, z); glColor3f(r, g, b); glCallList(cubeList); glPopMatrix();关键点是显示列表里只放几何颜色和变换放在外面。这样不同颜色的方块共用同一份几何缓存既省显存又省调用。实测在 200 个方块规模下帧率能从 40 提升到 60 以上。6.2 下落轨迹线让玩家预判落点3D 视角下玩家很难判断方块会落在哪一列尤其是相机有俯角的时候。加一条从方块当前位置垂直向下的轨迹线能大幅提升可玩性。热词里「opengl 物体移动轨迹线」说的就是这个需求。void DrawTrajectory(int board[10][20], Tetromino t) { int dropZ t.posZ; while (!CheckCollision(board, t, t.posX, dropZ 1)) dropZ; glDisable(GL_LIGHTING); glColor4f(1.0f, 1.0f, 1.0f, 0.3f); glEnable(GL_BLEND); glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); glBegin(GL_LINES); for (int i 0; i 4; i) { for (int j 0; j 4; j) { if (!t.shape[i][j]) continue; float wx (t.posX j - 4.5f); float wz (dropZ i - 9.5f); glVertex3f(wx, 0.5f, wz); glVertex3f(wx, 0.0f, wz); } } glEnd(); glDisable(GL_BLEND); glEnable(GL_LIGHTING); }先算出方块能落到的最深位置dropZ然后在每个方块格子的正下方画一条短竖线。开混合让线半透明不会挡住方块本身。glDisable(GL_LIGHTING)是必须的否则线的颜色会被光照影响变得不可预测。6.3 用帧率无关的计时做下落前面用GetTickCount做下落计时在 144Hz 屏幕上会感觉下落偏快因为每帧间隔短了但计时器精度没变。更稳的做法是用QueryPerformanceCounter算高精度时间差把下落速度定义成「每秒下落多少格」。LARGE_INTEGER freq, last, now; QueryPerformanceFrequency(freq); QueryPerformanceCounter(last); float dropSpeed 2.0f; // 每秒下落 2 格 float dropAccum 0.0f; // 每帧 QueryPerformanceCounter(now); float dt (float)(now.QuadPart - last.QuadPart) / freq.QuadPart; last now; dropAccum dt * dropSpeed; if (dropAccum 1.0f) { dropAccum - 1.0f; // 执行一次下落 }这样无论帧率多少下落速度都是恒定的。dropSpeed随等级提升从 2.0 加到 10.0 左右就很有挑战性了。我自己的习惯是每做完一个 3D 小项目都会把相机参数和光照参数单独抽成一个配置文件方便反复调。3D 俄罗斯方块看着简单但相机角度差 10 度、光源位置偏一点观感就完全不一样这些参数值得花时间磨。希望帮到你。本文还有配套的精品资源点击获取
返回列表