☰
OpenGL 3D俄罗斯方块实战:三维数据结构与碰撞检测
2026/10/1 3:40:16 网站建设 项目流程

简介:这是一份面向C++与图形编程学习者的OpenGL实战项目源码,以经典俄罗斯方块为载体,演示如何在VC++环境下用OpenGL构建可交互的3D游戏。适合已掌握C++基础、希望入门三维渲染与游戏循环的开发者,用于理解顶点绘制、坐标变换、光照与键盘响应等核心环节。压缩包共62个文件,约90KB,包含4个cpp源文件与35个头文件,另有11张png、10张bmp贴图资源及dsw、dsp工程文件,源码与素材分层清晰,可直接用Visual Studio打开编译运行。项目将游戏逻辑与渲染模块拆分,Tetris、OpenGL、Info等文件各司其职,便于对照阅读与二次修改。目前已有283人学习下载,可作为课程设计、图形学实验或自学OpenGL的参考案例,帮助读者快速搭建3D游戏框架并理解渲染管线的基本组织方式。

1. 从二维到三维:OpenGL 实现 3D 俄罗斯方块的工程视角

很多人第一次听到「3D 俄罗斯方块」,脑子里浮现的是把二维方块贴个立体贴图,或者干脆用透视相机拍一个平面棋盘。这两种做法都算不上真正的 3D 俄罗斯方块。真正的 3D 版本,方块是在一个三维井道里下落,玩家需要同时考虑 X、Y、Z 三个轴向的堆叠关系,消层判定也从「一行填满」变成「一层填满」。这个变化带来的不只是视觉升级,而是整个数据结构、碰撞检测、旋转矩阵和渲染管线的重构。

用 OpenGL 配合 VC++ 来做这件事,是一个很典型的 C/C++ 图形学练手项目。它逼着你把线性代数、坐标变换、深度测试、光照模型这些平时停留在课本上的东西真正用起来。适合已经写过控制台程序、想跨进图形编程门槛的 C/C++ 开发者,也适合想用一个小项目把 OpenGL 管线跑通的学生和初级工程师。接下来我会按「先立住原理、再动手复现」的顺序,把这条路径拆开讲清楚。

2. 三维井道与方块的数据结构:先想清楚再写渲染

2.1 为什么不能用二维数组硬撑

二维俄罗斯方块用int board[20][10]就能描述整个游戏状态,每个格子只有「空」和「被占用」两种状态。到了 3D,井道变成一个长方体空间,常见做法是10 × 20 × 10的网格,也就是 2000 个单元格。如果继续用一维或二维数组去模拟,消层判定和碰撞检测会变得极其别扭。

我一般会用三维数组int board[10][20][10],第一维是 X(宽度),第二维是 Y(高度),第三维是 Z(深度)。每个元素存 0 表示空,存 1 到 7 表示不同颜色的方块类型。这样消层时只需要检查某一层 Y 上所有 X、Z 组合是否都非零。

// 3D 井道尺寸定义 const int BOARD_W = 10; // X 轴宽度 const int BOARD_H = 20; // Y 轴高度 const int BOARD_D = 10; // Z 轴深度 // 井道状态:0 为空,1-7 表示七种方块颜色索引 int board[BOARD_W][BOARD_H][BOARD_D]; // 初始化 void initBoard() { memset(board, 0, sizeof(board)); } // 检查某一层是否填满 bool isLayerFull(int y) { for (int x = 0; x < BOARD_W; x++) for (int z = 0; z < BOARD_D; z++) if (board[x][y][z] == 0) return false; return true; }

这段代码里isLayerFull是消层判定的核心。参数y是要检查的高度层,遍历该层所有 X、Z 组合,只要有一个空格就返回 false。注意memset对三维数组同样有效,因为内存是连续分配的。

2.2 方块用 4×4×4 包围盒还是坐标列表

二维俄罗斯方块通常用 4×4 矩阵表示七种形状的四种旋转态。到了三维,方块的基本单元变成小立方体,一个方块由 4 个小立方体组成。表示方式有两种:一种是 4×4×4 的包围盒矩阵,另一种是直接存 4 个相对坐标。

包围盒方式更直观,旋转时对整个 4×4×4 矩阵做变换,但浪费空间且旋转后需要重新归一化。坐标列表方式更紧凑,旋转时对每个坐标乘以旋转矩阵即可。

struct Block { int x, y, z; // 相对于方块原点的偏移 }; // 七种 3D 方块,每种由 4 个小立方体组成 // 这里以 I 型为例,沿 Y 轴延伸 Block shapes[7][4] = { // I 型:沿 Y 轴四个连续 {{0,0,0},{0,1,0},{0,2,0},{0,3,0}}, // O 型:2x2 平面 {{0,0,0},{1,0,0},{0,0,1},{1,0,1}}, // 其余五种略,实际项目中需补全 };

参数说明:shapes[7][4]中第一维是方块类型,第二维是四个小立方体。每个Block存的是相对偏移,实际渲染时加上方块当前的世界坐标。这种表示方式在旋转时只需要对四个坐标分别乘以旋转矩阵,计算量小且不容易出错。

2.3 旋转矩阵在三维里的正确用法

二维旋转只需要一个角度,三维旋转需要区分绕 X、Y、Z 三个轴。俄罗斯方块的旋转通常是绕 Y 轴(竖直轴)旋转 90 度,这样方块在水平面内转向。但 3D 版本里,玩家可能还需要绕 X 轴或 Z 轴翻转,这就涉及万向锁问题。

常见做法是用欧拉角分别处理三个轴的旋转,每次旋转 90 度,避免连续小角度累积误差。旋转矩阵用整数运算,因为 90 度的正弦余弦值只有 0、1、-1。

// 绕 Y 轴旋转 90 度(顺时针) void rotateY(Block& b) { int tmp = b.x; b.x = b.z; b.z = -tmp; } // 绕 X 轴旋转 90 度 void rotateX(Block& b) { int tmp = b.y; b.y = b.z; b.z = -tmp; } // 绕 Z 轴旋转 90 度 void rotateZ(Block& b) { int tmp = b.x; b.x = b.y; b.y = -tmp; }

这三个函数是三维旋转的基础。注意旋转后坐标可能出现负数,需要在碰撞检测时做边界处理。实际项目中,我会在旋转后先做一次「合法性检查」,如果旋转后方块超出井道或与已有方块重叠,就撤销这次旋转。这个逻辑和二维版本一致,只是检查范围从二维扩展到三维。

3. OpenGL 渲染管线:从顶点数据到三维井道可视化

3.1 VC++ 下 OpenGL 环境配置的常见做法

在 Windows 上用 VC++ 写 OpenGL,最省事的方案是用 Visual Studio 自带的 OpenGL 库加上 GLUT 或 FreeGLUT。VS2017、VS2019、VS2022 都支持,新建一个空项目,把opengl32.lib、glu32.lib、glut32.lib加到链接器输入里即可。

如果你用的是 VS2010 或更早版本,配置方式类似,但要注意平台工具集的选择。Win11 上跑 Visual Studio 也没问题,只是需要确认 Windows SDK 版本和 OpenGL 头文件路径。

# 在 VS 项目属性中配置 # 链接器 -> 输入 -> 附加依赖项,加入: opengl32.lib glu32.lib glut32.lib # 包含目录加入 GLUT 头文件所在路径 # 库目录加入 GLUT 库文件所在路径

这段配置不是代码,而是项目属性里的操作步骤。opengl32.lib和glu32.lib是 Windows 自带的,glut32.lib需要自己下载 FreeGLUT 或 GLUT 的 Windows 版本。注意 64 位和 32 位库不能混用,VS 项目里选的是 x64 就全部用 64 位库。

3.2 用顶点数组画一个立方体

3D 俄罗斯方块的基本渲染单元是小立方体。每个立方体有 8 个顶点、6 个面。用 OpenGL 的立即模式(glBegin/glEnd)画立方体最简单,虽然性能不是最优,但对于 2000 个单元格以内的场景完全够用。

void drawCube(float x, float y, float z, float size, float r, float g, float b) { glPushMatrix(); glTranslatef(x, y, z); // 平移到目标位置 glColor3f(r, g, b); // 设置颜色 glBegin(GL_QUADS); // 前面 glVertex3f(-size, -size, size); glVertex3f( size, -size, size); glVertex3f( size, size, size); glVertex3f(-size, size, size); // 后面 glVertex3f(-size, -size, -size); glVertex3f(-size, size, -size); glVertex3f( size, size, -size); glVertex3f( size, -size, -size); // 左面 glVertex3f(-size, -size, -size); glVertex3f(-size, -size, size); glVertex3f(-size, size, size); glVertex3f(-size, size, -size); // 右面 glVertex3f( size, -size, -size); glVertex3f( size, size, -size); glVertex3f( size, size, size); glVertex3f( size, -size, size); // 上面 glVertex3f(-size, size, -size); glVertex3f(-size, size, size); glVertex3f( size, size, size); glVertex3f( size, size, -size); // 下面 glVertex3f(-size, -size, -size); glVertex3f( size, -size, -size); glVertex3f( size, -size, size); glVertex3f(-size, -size, size); glEnd(); glPopMatrix(); }

参数说明:x, y, z是立方体中心的世界坐标,size是半边长,r, g, b是颜色分量。glPushMatrix和glPopMatrix保证变换不会影响后续绘制。六个面按逆时针顺序给出顶点,保证法线朝外,配合光照时不会出现黑面。

3.3 深度测试与透视投影:让 3D 看起来像 3D

没有深度测试的 3D 场景,后面的物体会盖住前面的,看起来像纸片。开启深度测试只需要两行代码,但效果立竿见影。

// 在初始化中开启深度测试 glEnable(GL_DEPTH_TEST); glDepthFunc(GL_LESS); // 设置透视投影 glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluPerspective(45.0, // 视野角度 (double)w / h, // 宽高比 0.1, // 近裁剪面 100.0 // 远裁剪面 ); glMatrixMode(GL_MODELVIEW);

gluPerspective的四个参数决定了相机的可视范围。视野角度 45 度比较自然,宽高比用窗口宽除以高,近裁剪面 0.1 和远裁剪面 100.0 根据场景尺度调整。如果井道尺寸是 10×20×10,相机放在 (15, 25, 15) 看向原点,这个投影参数就能把整个井道收进画面。

3.4 用显示列表优化大量立方体的绘制

2000 个单元格如果每个都调用drawCube,每帧要执行上万次glBegin/glEnd,帧率会掉得厉害。常见优化是用显示列表(display list)把立方体的绘制命令编译一次,之后每次调用只发一个命令。

GLuint cubeList; void createCubeList() { cubeList = glGenLists(1); glNewList(cubeList, GL_COMPILE); drawCube(0, 0, 0, 0.5, 1.0, 1.0, 1.0); // 在原点画一个单位立方体 glEndList(); } // 渲染时 void drawBlockAt(float x, float y, float z, float r, float g, float b) { glPushMatrix(); glTranslatef(x, y, z); glColor3f(r, g, b); glCallList(cubeList); glPopMatrix(); }

glGenLists(1)申请一个显示列表编号,glNewList和glEndList之间是编译的绘制命令。之后用glCallList调用,OpenGL 会直接执行编译好的命令,省去函数调用开销。注意显示列表里的颜色和变换会被外部状态覆盖,所以颜色和位置放在glCallList外面设置。

4. 碰撞检测与消层逻辑:3D 俄罗斯方块的核心玩法

4.1 下落碰撞:从二维到三维的扩展

二维俄罗斯方块的碰撞检测只检查左右边界和底部。三维版本需要检查六个方向:X 正负、Z 正负、Y 负方向,以及是否与已有方块重叠。

bool checkCollision(Block blocks[4], int baseX, int baseY, int baseZ) { for (int i = 0; i < 4; i++) { int x = baseX + blocks[i].x; int y = baseY + blocks[i].y; int z = baseZ + blocks[i].z; // 边界检查 if (x < 0 || x >= BOARD_W) return true; if (y < 0 || y >= BOARD_H) return true; if (z < 0 || z >= BOARD_D) return true; // 重叠检查 if (board[x][y][z] != 0) return true; } return false; }

参数说明:blocks是当前方块的四个小立方体相对坐标,baseX, baseY, baseZ是方块原点在世界坐标中的位置。函数返回 true 表示碰撞,false 表示可以移动。注意 Y 轴方向,如果y < 0说明方块已经落到底部以下,也应该判定为碰撞。

4.2 消层判定与层下落

三维消层比二维复杂的地方在于,消掉一层后,上面的层需要整体下落。如果一次消掉多层,下落逻辑要逐层处理。

int clearFullLayers() { int cleared = 0; for (int y = 0; y < BOARD_H; y++) { if (isLayerFull(y)) { // 将 y 以上的所有层下移一格 for (int yy = y; yy < BOARD_H - 1; yy++) { for (int x = 0; x < BOARD_W; x++) for (int z = 0; z < BOARD_D; z++) board[x][yy][z] = board[x][yy + 1][z]; } // 顶层清空 for (int x = 0; x < BOARD_W; x++) for (int z = 0; z < BOARD_D; z++) board[x][BOARD_H - 1][z] = 0; cleared++; y--; // 重新检查当前层,因为上面的层已经落下来了 } } return cleared; }

这段代码里y--是关键。消掉一层后,上面的层整体下移,原来 y+1 的层现在到了 y 位置,需要重新检查。如果不做y--,会漏掉连续满层的情况。返回值cleared可以用来计算得分,一次消多层应该有额外奖励。

4.3 旋转后的合法性检查

旋转是 3D 俄罗斯方块最容易出 bug 的地方。旋转后坐标可能超出边界,也可能和已有方块重叠。我的做法是:先备份当前方块坐标,执行旋转,然后调用checkCollision,如果碰撞就恢复备份。

bool tryRotate(Block blocks[4], int baseX, int baseY, int baseZ, int axis) { Block backup[4]; memcpy(backup, blocks, sizeof(Block) * 4); for (int i = 0; i < 4; i++) { if (axis == 0) rotateX(blocks[i]); else if (axis == 1) rotateY(blocks[i]); else rotateZ(blocks[i]); } if (checkCollision(blocks, baseX, baseY, baseZ)) { memcpy(blocks, backup, sizeof(Block) * 4); return false; } return true; }

参数axis决定绕哪个轴旋转,0 是 X 轴,1 是 Y 轴,2 是 Z 轴。备份和恢复用memcpy完成,注意sizeof(Block) * 4是四个方块的总字节数。这个逻辑在二维版本里也常见,只是三维多了两个旋转轴。

5. 避坑与排查:3D 俄罗斯方块开发中的血泪经验

5.1 深度测试开了但画面还是不对

现象:明明调用了glEnable(GL_DEPTH_TEST),但远处的方块还是盖住了近处的。

原因:深度缓冲区没有在创建窗口时申请。GLUT 的glutInitDisplayMode需要加上GLUT_DEPTH位。

解决:把初始化改成glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH),然后glEnable(GL_DEPTH_TEST)才会生效。另外每帧渲染前要glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT),把深度缓冲区也清掉。

5.2 方块旋转后位置偏移

现象:方块绕 Y 轴旋转后,整体位置往某个方向偏了。

原因:旋转是绕方块原点进行的,如果方块原点不在几何中心,旋转后就会偏移。比如 I 型方块沿 Y 轴延伸,原点在底部,绕 Y 轴旋转没问题,但绕 X 轴旋转就会甩出去。

解决:旋转前先把方块坐标平移到以几何中心为原点,旋转后再平移回去。或者在设计方块形状时,就让四个小立方体的坐标关于原点对称。

5.3 消层后上面的方块悬空

现象:消掉一层后,上面的方块没有落下来,浮在半空。

原因:消层逻辑只清空了满层,没有把上面的层下移。或者下移时循环方向写反了,从下往上复制导致数据被覆盖。

解决:下移时从下往上遍历,先把 y+1 层复制到 y 层,再处理 y+1 层。如果从上往下复制,y 层的数据会先被覆盖,y+1 层再复制时拿到的就是错误数据。

5.4 帧率低到个位数

现象:井道里方块多了以后,画面卡顿严重。

原因:每个立方体都用glBegin/glEnd立即模式绘制,2000 个单元格就是 12000 个面,每帧重复提交。

解决:用显示列表把单个立方体的绘制编译一次,渲染时只改变换和颜色。如果还卡,可以把整个井道中静态的方块合并成一个显示列表,只在方块变化时重新编译。更彻底的方案是用 VBO(顶点缓冲对象),但那是 OpenGL 3.0+ 的内容,新手项目用显示列表足够。

5.5 VC++ 链接时报找不到 glut32.lib

现象:编译通过,链接时报LNK1104: 无法打开文件“glut32.lib”。

原因:项目属性里加了glut32.lib到附加依赖项,但库文件所在目录没有加到库目录里,或者库文件是 32 位的而项目是 64 位的。

解决:确认 FreeGLUT 或 GLUT 的库文件路径,在项目属性的「库目录」里加上。然后检查项目平台是 Win32 还是 x64,下载对应版本的库。如果用的是 VS2017 以上版本,也可以直接用 NuGet 安装nupengl.core包,省去手动配置。

6. 进阶技巧:用光照和相机控制提升 3D 表现力

6.1 加一盏方向光让立方体有立体感

没有光照的 3D 场景,所有面颜色一样,看起来像平面。加一盏方向光,配合法线,立方体的不同面会有明暗变化,立体感立刻出来。

void setupLighting() { glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); glEnable(GL_COLOR_MATERIAL); glColorMaterial(GL_FRONT_AND_BACK, GL_AMBIENT_AND_DIFFUSE); GLfloat lightPos[] = {1.0f, 1.0f, 1.0f, 0.0f}; // 方向光 GLfloat lightAmbient[] = {0.3f, 0.3f, 0.3f, 1.0f}; GLfloat lightDiffuse[] = {0.8f, 0.8f, 0.8f, 1.0f}; glLightfv(GL_LIGHT0, GL_POSITION, lightPos); glLightfv(GL_LIGHT0, GL_AMBIENT, lightAmbient); glLightfv(GL_LIGHT0, GL_DIFFUSE, lightDiffuse); }

lightPos第四个分量为 0 表示方向光,为 1 表示点光源。方向光适合模拟太阳光,整个场景光照均匀。glColorMaterial让材质颜色跟随glColor设置,这样之前用glColor3f设置的颜色仍然有效,只是叠加了光照效果。

6.2 用键盘控制相机绕井道旋转

固定视角看久了会腻,让玩家能用键盘旋转相机,从不同角度观察井道,是 3D 版本相比 2D 的天然优势。

float cameraAngle = 45.0f; float cameraRadius = 30.0f; float cameraHeight = 25.0f; void updateCamera() { float rad = cameraAngle * 3.14159f / 180.0f; float eyeX = cameraRadius * cos(rad); float eyeZ = cameraRadius * sin(rad); float eyeY = cameraHeight; gluLookAt(eyeX, eyeY, eyeZ, // 相机位置 0, 10, 0, // 看向井道中心 0, 1, 0); // 上方向 } // 键盘回调 void keyboard(unsigned char key, int x, int y) { if (key == 'a') cameraAngle -= 5.0f; if (key == 'd') cameraAngle += 5.0f; if (key == 'w') cameraHeight += 1.0f; if (key == 's') cameraHeight -= 1.0f; glutPostRedisplay(); }

gluLookAt的九个参数分别是相机位置、观察目标、上方向。cameraAngle控制水平旋转,cameraHeight控制俯仰高度。每次按键后调用glutPostRedisplay触发重绘。注意相机高度不能低于井道底部,否则会穿模。

6.3 用计时器控制下落速度

俄罗斯方块的下落速度需要随时间加快,用glutTimerFunc比用Sleep更合适,因为不会阻塞渲染循环。

int fallInterval = 500; // 毫秒 void timerCallback(int value) { // 尝试下落一格 if (!checkCollision(currentBlocks, baseX, baseY - 1, baseZ)) { baseY--; } else { // 固定方块到井道 lockBlock(); clearFullLayers(); spawnNewBlock(); } glutPostRedisplay(); glutTimerFunc(fallInterval, timerCallback, 0); } // 在 main 中启动 glutTimerFunc(fallInterval, timerCallback, 0);

fallInterval初始 500 毫秒,每消一层减少 20 毫秒,最低不低于 100 毫秒。glutTimerFunc的回调里先处理下落逻辑,再重新注册定时器,形成循环。注意lockBlock要把当前方块的四个小立方体写入board数组,然后生成新方块。

6.4 验证 3D 逻辑是否正确的一个笨办法

3D 俄罗斯方块的 bug 往往藏在视觉死角里。我的习惯是加一个调试模式,按Tab键切换到俯视图,从正上方看整个井道的 XZ 平面。这样能快速发现方块是否重叠、消层是否彻底、边界检查是否有漏洞。

bool topView = false; void display() { glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); glLoadIdentity(); if (topView) { gluLookAt(0, 50, 0, // 正上方 0, 0, 0, 0, 0, -1); // 上方向指向 -Z } else { updateCamera(); } drawBoard(); drawCurrentBlock(); glutSwapBuffers(); }

俯视图下,Y 轴被压缩,XZ 平面的布局一目了然。这个技巧在调试消层逻辑时特别有用,因为消层是按 Y 层判定的,俯视图能直接看到哪一层还有空洞。等逻辑验证无误,再切回透视视角正常玩。

这套方案我从头写下来,最大的体会是:3D 俄罗斯方块的难点不在 OpenGL 渲染,而在三维数据结构和碰撞逻辑的严谨性。渲染出问题顶多是画面难看,逻辑出问题就是游戏没法玩。所以我的习惯是先把board数组和碰撞检测写对,用控制台输出验证消层和下落,确认无误后再接 OpenGL 渲染。这样调试成本最低,也最容易定位问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询