简介:这份资源是山东大学计算机图形学实验二的完整实现,面向正在学习OpenGL与三维图形编程的高校学生及自学者,解决从零搭建可交互3D迷宫漫游场景的实践难题。压缩包共246个文件,约25.92MB,包含cpp与h源码、md2与obj替身模型、bmp与tga纹理贴图、exe可执行程序以及sln、vcxproj等工程配置,另附pdb、idb调试符号与tlog编译日志,便于直接运行、调试与二次开发。实验要求全部落地:迷宫规模不少于10×10个Cell,读取替身模型并加载进场景,方向键控制转向与漫游,具备碰撞检测避免穿墙,支持第一人称与第三人称视角切换,墙面与地面均贴有纹理。已有832人学习下载,适合作为图形学课程实验的参考方案,帮助理解模型加载、纹理映射、相机控制与碰撞检测等核心环节,也可在此基础上扩展迷宫生成算法与交互逻辑。
1. 3D迷宫漫游到底在考什么:从一次“跑不起来”的实验说起
计算机图形学实验里,3D迷宫漫游是那种看起来简单、上手就翻车的题目。很多同学第一次打开实验要求,脑子里想的是“画个迷宫、让相机走进去”,结果真动手才发现:迷宫墙体怎么生成、相机怎么控制、碰撞怎么判断、光照怎么打,每一个都是独立的坑。这个实验真正考的不是你会不会调glBegin,而是你能不能把场景建模、相机系统、碰撞检测、光照渲染这四件事串成一条完整的渲染管线。
它适合两类人:一类是正在上图形学课、需要交实验的学生,另一类是工作里突然要写个第一人称漫游 Demo 的开发者。核心诉求很明确——用 OpenGL(或同类图形 API)搭一个能走、能看、不穿墙的 3D 迷宫。下面我按自己踩过的路,把从零到能跑的全过程拆开讲,参数、代码、排错都给你落到能抄的程度。
2. 迷宫场景怎么搭:网格地图、墙体生成与纹理坐标
2.1 为什么用二维数组存迷宫,而不是手写顶点
新手最容易犯的错,是直接在代码里一个面一个面地写墙体顶点。迷宫稍微大一点,顶点数就爆炸,改一个墙的位置要动几十行。常见做法是用一个二维整型数组当逻辑地图,1表示墙、0表示通路,渲染时遍历数组,遇到1就在对应格子生成一个立方体。这样地图和渲染解耦,改迷宫只改数组。
// 20x20 迷宫逻辑地图,1=墙,0=通路 const int MAP_W = 20, MAP_H = 20; int maze[MAP_H][MAP_W] = { {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1}, {1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1}, {1,0,1,0,1,0,1,1,1,0,1,0,1,1,1,1,1,1,0,1}, // ... 其余行省略,实际实验里补全 {1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1} }; const float CELL = 2.0f; // 每个格子在世界坐标里的边长逻辑说明:maze[y][x]的y对应世界坐标的 Z 轴、x对应 X 轴,这样遍历时x * CELL和y * CELL直接就是墙体中心的世界坐标。CELL取 2.0 是个经验值——太小相机贴墙会穿模,太大迷宫显得空旷。参数上,地图尺寸建议先做 20×20,跑通再加大,超过 50×50 就要考虑视锥剔除,否则帧率会掉。
2.2 单个墙体的立方体怎么画才不重复劳动
每个墙格子都是一个立方体,如果每个面都手写 6 个四边形,代码会非常长。我一般写一个drawCube(float cx, float cz)函数,内部用GL_QUADS画六个面,每个面给四个顶点和对应的纹理坐标。调用时只传格子中心坐标,墙体尺寸固定为CELL。
void drawCube(float cx, float cz) { float h = CELL; // 墙高 float s = CELL / 2.0f; // 半边长 glBegin(GL_QUADS); // 前面(+Z 面) glNormal3f(0, 0, 1); glTexCoord2f(0, 0); glVertex3f(cx - s, 0, cz + s); glTexCoord2f(1, 0); glVertex3f(cx + s, 0, cz + s); glTexCoord2f(1, 1); glVertex3f(cx + s, h, cz + s); glTexCoord2f(0, 1); glVertex3f(cx - s, h, cz + s); // 其余五个面同理,注意法线方向朝外 glEnd(); }逻辑说明:glNormal3f是给光照用的,法线方向必须朝外,否则光照会算反,墙面出现黑块。纹理坐标(0,0)到(1,1)对应整张纹理,如果想让砖块重复铺,可以把坐标乘上重复次数,比如glTexCoord2f(0, 0)改成glTexCoord2f(0, 0)配合glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)。参数上,墙高h建议等于CELL,这样迷宫看起来是正方形通道,视觉上最舒服。
2.3 纹理加载与重复铺贴的两个关键设置
纹理不设置重复模式,墙面就是一整张图拉伸,砖块会被拉得变形。加载纹理后必须设GL_TEXTURE_WRAP_S和GL_TEXTURE_WRAP_T为GL_REPEAT,过滤方式用GL_LINEAR避免马赛克。
glGenTextures(1, &texWall); glBindTexture(GL_TEXTURE_2D, texWall); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); // 之后用 glTexImage2D 上传像素数据逻辑说明:GL_REPEAT让纹理坐标超过 1 时自动循环,配合glTexCoord2f里乘系数就能控制砖块密度。GL_LINEAR在放大时做线性插值,比GL_NEAREST平滑。注意纹理尺寸最好是 2 的幂(如 256×256),否则老版本 OpenGL 可能不显示,这是血泪经验。
3. 第一人称相机:视角矩阵、移动向量与鼠标控制
3.1 相机系统的本质是一个位置加两个角度
第一人称相机说白了就是三个量:相机位置(px, py, pz)、偏航角yaw(左右转头)、俯仰角pitch(上下抬头)。视角矩阵用glRotatef和glTranslatef组合出来,顺序不能错——先旋转再平移,否则相机会绕世界原点转而不是原地转头。
glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glRotatef(pitch, 1, 0, 0); // 先俯仰 glRotatef(yaw, 0, 1, 0); // 再偏航 glTranslatef(-px, -py, -pz); // 最后平移到相机位置逻辑说明:OpenGL 的变换是右乘,代码里最后写的glTranslatef实际最先作用在顶点上,所以顺序是“先平移世界、再旋转”。pitch要限制在-89到89度之间,否则抬头到 90 度会出现万向锁,画面翻转。yaw可以任意累加,超过 360 度也没关系。
3.2 前后左右移动向量怎么算才不飘
很多人直接改px、pz来移动,结果转头之后按前进还是往原来的方向走。正确做法是用yaw算出前向量和右向量,再乘速度。
float rad = yaw * 3.1415926f / 180.0f; float fx = -sinf(rad), fz = -cosf(rad); // 前向量 float rx = cosf(rad), rz = -sinf(rad); // 右向量 float speed = 0.1f; if (keyW) { px += fx * speed; pz += fz * speed; } if (keyS) { px -= fx * speed; pz -= fz * speed; } if (keyA) { px -= rx * speed; pz -= rz * speed; } if (keyD) { px += rx * speed; pz += rz * speed; }逻辑说明:yaw为 0 时前向量是(0, -1),即朝 Z 轴负方向看,这是 OpenGL 默认视线方向。speed取 0.1 配合 60 帧渲染,每秒移动约 6 个单位,在CELL=2的迷宫里手感合适。如果觉得太快,降到 0.05;太慢就加到 0.15,但别超过CELL/2,否则一帧跨过墙体,碰撞检测会漏。
3.3 鼠标控制视角的灵敏度与边界处理
鼠标控制视角靠glutPassiveMotionFunc回调,拿到鼠标坐标后算和屏幕中心的差值,差值乘灵敏度加到yaw和pitch上。
void onMouseMove(int mx, int my) { int cx = WINDOW_W / 2, cy = WINDOW_H / 2; yaw += (mx - cx) * 0.1f; pitch += (cy - my) * 0.1f; if (pitch > 89) pitch = 89; if (pitch < -89) pitch = -89; glutWarpPointer(cx, cy); // 把鼠标拉回中心 }逻辑说明:0.1f是灵敏度,数值越大转头越快,一般 0.05 到 0.2 之间。glutWarpPointer把鼠标强制拉回窗口中心,否则鼠标移到边缘就转不动了。注意pitch的钳制必须在累加之后立刻做,不然快速甩鼠标会瞬间超过 90 度导致画面翻转。
4. 碰撞检测与穿墙排查:AABB 判定和三个必调参数
4.1 用格子索引做碰撞,比几何求交简单十倍
迷宫是格子结构,碰撞检测不需要算射线和三角形求交。把相机位置换算成格子索引,检查那个格子是不是墙就行。但相机有体积,只查中心点会贴墙穿模,所以要查相机周围四个角。
bool isWall(float wx, float wz) { int gx = (int)(wx / CELL); int gz = (int)(wz / CELL); if (gx < 0 || gx >= MAP_W || gz < 0 || gz >= MAP_H) return true; return maze[gz][gx] == 1; } bool canMove(float nx, float nz) { float r = 0.3f; // 相机碰撞半径 return !isWall(nx - r, nz - r) && !isWall(nx + r, nz - r) && !isWall(nx - r, nz + r) && !isWall(nx + r, nz + r); }逻辑说明:r是相机碰撞半径,取 0.3 意味着相机离墙 0.3 个单位就停。四个角都查,避免相机斜着贴墙时穿进去。isWall里越界返回true,防止相机走出地图边缘。移动时先算新位置,canMove通过才更新px、pz,不通过就保持原位。
4.2 三个必调参数:碰撞半径、移动步长、格子尺寸
这三个参数互相牵制,调不好就穿墙或者卡顿。下面这张表是我反复试出来的经验值。
| 参数 | 建议值 | 作用 | 调大后果 | 调小后果 |
|---|---|---|---|---|
| 碰撞半径 r | 0.3 | 相机离墙距离 | 通道变窄,容易卡住 | 贴墙穿模 |
| 移动步长 speed | 0.1 | 每帧移动距离 | 一帧跨墙,碰撞漏检 | 移动太慢,手感拖沓 |
| 格子尺寸 CELL | 2.0 | 迷宫通道宽度 | 迷宫空旷,走很久 | 通道拥挤,转不开身 |
逻辑说明:speed必须小于CELL - 2r,否则一帧的位移能跨过整面墙,碰撞检测直接失效。按CELL=2、r=0.3算,speed上限是 1.4,取 0.1 留了足够余量。如果迷宫格子改小,这三个参数要同步缩。
4.3 穿墙问题的排查顺序
穿墙是实验里最高频的翻车点,排查按这个顺序走:先看speed是不是太大,再看canMove是不是只查了中心点,最后看isWall的索引换算有没有取整错误。索引换算用(int)(wx / CELL),如果wx是负数,C 的取整是向零取整,会算错格子,所以地图坐标最好从 0 开始,别用负坐标。
5. 光照与渲染顺序:为什么你的迷宫一片死黑
5.1 光照三要素:法线、光源位置、材质
迷宫一片黑,九成是法线没设或者光源没开。OpenGL 固定管线光照需要GL_LIGHTING、GL_LIGHT0、法线和材质四样齐全。
glEnable(GL_LIGHTING); glEnable(GL_LIGHT0); GLfloat lightPos[] = { 10.0f, 20.0f, 10.0f, 1.0f }; // 点光源 GLfloat lightAmb[] = { 0.3f, 0.3f, 0.3f, 1.0f }; // 环境光 GLfloat lightDif[] = { 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, lightDif); glEnable(GL_COLOR_MATERIAL); // 让 glColor 影响材质逻辑说明:lightPos第四个分量是 1 表示点光源,0 表示方向光。环境光给 0.3 保证背光面不全黑,漫反射给 0.8 让正面有层次。GL_COLOR_MATERIAL让glColor3f直接控制材质颜色,省去单独设材质。注意光源位置要在设置视角矩阵之前调用glLightfv,否则光源会跟着相机跑。
5.2 渲染顺序:先画不透明墙体,再画其他
渲染顺序错了会出现墙体互相遮挡、透明物体盖住墙的问题。正确顺序是:清屏 → 设视角 → 设光源 → 画所有不透明墙体 → 画其他物体。墙体之间不需要排序,因为深度测试会处理。
glEnable(GL_DEPTH_TEST); glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 设视角、设光源 for (int y = 0; y < MAP_H; y++) for (int x = 0; x < MAP_W; x++) if (maze[y][x] == 1) drawCube(x * CELL + CELL/2, y * CELL + CELL/2); glutSwapBuffers();逻辑说明:GL_DEPTH_TEST必须开,否则后画的墙会盖住前面的墙。glClear要同时清颜色和深度缓冲,只清颜色会导致上一帧的深度残留,出现闪烁。drawCube传的是格子中心坐标,所以加CELL/2。
5.3 帧率与双缓冲:画面撕裂和卡顿的处理
不开双缓冲,画面会撕裂;不开垂直同步,帧率飙高但没意义。glutInitDisplayMode里加GLUT_DOUBLE,渲染完调glutSwapBuffers。
glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGB | GLUT_DEPTH);逻辑说明:GLUT_DOUBLE启用双缓冲,GLUT_DEPTH申请深度缓冲。如果帧率还是低,检查是不是每帧都在重新生成墙体顶点——墙体顶点应该只算一次,或者用显示列表缓存。20×20 的迷宫有约 200 面墙,每面 6 个四边形,固定管线完全扛得住,帧率低通常是光照计算或纹理切换太频繁。
6. 避坑与常见问题:五条踩坑记录
6.1 画面全黑,但代码没报错
现象:窗口打开是黑的,控制台无错误。原因:最常见是没开GL_LIGHTING却设了材质,或者相机位置在墙里面。解决:先临时关掉光照用glColor3f画纯色,确认几何能显示;再把相机位置打印出来,看是不是落在maze为 1 的格子里。
6.2 转头之后移动方向不对
现象:按 W 前进,但转头 90 度后按 W 往侧面走。原因:移动向量用了固定的(0, -1),没跟yaw联动。解决:按 3.2 的公式用sinf、cosf算前向量和右向量,每次移动前重新算。
6.3 贴墙走会穿进墙里
现象:斜着往墙上走,相机慢慢陷进墙体。原因:碰撞检测只查了相机中心点,没查四个角。解决:用 4.1 的canMove查四个角,碰撞半径r别小于 0.2。
6.4 纹理不显示,墙面是纯色
现象:设了纹理但墙面没图案。原因:纹理尺寸不是 2 的幂,或者没开GL_TEXTURE_2D。解决:把纹理改成 256×256,渲染前glEnable(GL_TEXTURE_2D),并确认glTexImage2D的格式参数和图片通道数匹配。
6.5 鼠标转视角到边缘就卡住
现象:鼠标移到窗口边缘后视角不再转。原因:没把鼠标拉回中心,坐标不再变化。解决:在onMouseMove末尾调glutWarpPointer(cx, cy),把鼠标强制归位。
7. 进阶技巧:用显示列表把帧率翻倍
跑通基础版之后,如果迷宫加大到 50×50,帧率会明显下降。瓶颈不在光照,而在每帧重复提交几百个glBegin/glEnd。固定管线里最直接的优化是显示列表:把整张迷宫的墙体编译成一个列表,每帧只调一次glCallList。
GLuint mazeList = glGenLists(1); glNewList(mazeList, GL_COMPILE); for (int y = 0; y < MAP_H; y++) for (int x = 0; x < MAP_W; x++) if (maze[y][x] == 1) drawCube(x * CELL + CELL/2, y * CELL + CELL/2); glEndList(); // 渲染循环里 glCallList(mazeList);逻辑说明:GL_COMPILE表示只编译不执行,墙体顶点被缓存到显存,后续每帧直接复用。注意显示列表里的坐标是编译时固定的,如果迷宫会动态变化就不能用,得改用 VBO。对于静态迷宫,显示列表能把帧率提升 2 到 3 倍,这是我在 50×50 地图上实测的结果。
再进一步,如果实验允许用现代 OpenGL,把墙体顶点打包成 VBO、用着色器渲染,性能还能再上一个台阶,但代码量会翻倍。我的习惯是:实验要求固定管线就用显示列表,要求可编程管线就上 VBO,别为了炫技把简单问题做复杂。
最后说个验证方法:跑通之后,把相机放在迷宫入口,按 W 一路走到出口,中途故意往墙上撞,看会不会穿;再快速甩鼠标转一圈,看画面会不会翻转。这两步过了,实验基本就没问题。希望帮到你。
本文还有配套的精品资源,点击获取