三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

C++与EasyX实现3D游戏:光线投射算法入门与实践

C++与EasyX实现3D游戏:光线投射算法入门与实践

1. 项目概述:为什么选择C++和EasyX来入门3D游戏?

很多刚学完C++基础语法的朋友,都会面临一个灵魂拷问:我能用C++做什么?课本上的控制台程序,输入输出都是黑底白字的命令行,成就感实在有限。想做个带界面的东西,MFC、Qt这些框架又显得过于庞大和复杂,光是配置环境就能劝退一大半人。而“做游戏”这个念头,往往是最能点燃编程热情的。它综合了逻辑、数学、图形和交互,是检验编程能力的绝佳试金石。

那么,对于一个C++新手,如何能绕过复杂的图形API(如OpenGL、DirectX),快速搭建一个看得见、摸得着的3D游戏原型呢?答案就是EasyX。EasyX是一个为Visual C++量身定制的免费图形库,它的设计初衷就是为了降低图形编程的门槛。你不需要理解窗口消息循环、设备上下文这些Win32 API的概念,安装后,直接包含一个头文件,调用几个像circleline这样直观的函数,就能在屏幕上画出图形。这就像给你一套积木,让你能专注于搭建城堡的创意和结构,而不是先去学习如何制造每一块积木。

我们这个项目的目标,就是用C++和EasyX,从零开始实现一个第一人称视角的3D小游戏。听起来很酷,对吧?你可能会想,EasyX不是个2D绘图库吗?没错,它本身只提供2D像素的绘制能力。但3D图形的本质,就是将三维空间中的物体,通过一系列数学计算(投影、变换),最终映射到二维的屏幕上。我们将要做的,就是亲手实现这套数学计算流程,用纯粹的代码“计算”出3D感。这不仅能让你做出一个可以行走、环顾四周的简易3D游戏,更能让你深刻理解3D图形学最核心的原理——从世界坐标到屏幕坐标的整个变换管线。这比直接使用现成的3D引擎(如Unity、Unreal)学到的底层知识要扎实得多。

整个项目会涵盖几个核心环节:首先,我们需要一个虚拟的3D世界,比如由墙面构成的简单迷宫;其次,我们需要一个虚拟的“眼睛”(即摄像机)来观察这个世界,并实现通过键盘控制其移动和旋转(第一人称视角);最后,也是最关键的一步,我们需要一套算法,将摄像机“看到”的3D墙面,正确地绘制到2D的EasyX窗口上。这个过程,我们称之为“光线投射”或“2.5D渲染”,它是一种在早期3D游戏(如《德军总部3D》)中广泛使用的、高效且易于理解的渲染技术。通过这个项目,你收获的将不仅仅是一个可以运行的游戏.exe文件,更是一整套从数学原理到代码实现的、关于3D图形渲染的底层认知。

2. 核心原理:2.5D光线投射算法深度解析

在真正动手写代码之前,我们必须把核心的渲染原理吃透。我们采用的是一种名为光线投射的技术。为什么不用更通用的三角形光栅化呢?因为对于我们的目标——一个由垂直墙面构成的简单网格迷宫——来说,光线投射算法更加直观、高效,且完全可以用2D绘图API实现。

你可以把我们的游戏世界想象成一个俯视图下的网格地图,就像一张棋盘。每个格子要么是空的(0),要么是一面墙(用不同的数字代表不同的墙类型或颜色)。玩家(摄像机)在这个网格地图上用一个点(x, y)来表示位置,并用一个角度a来表示面朝的方向。

那么,如何把这样一个2D的俯视地图,变成第一人称视角的3D画面呢?核心思路是模拟人眼或摄像机的成像过程。在我们的屏幕上,从左到右有很多列像素。对于屏幕上的每一列像素,我们从摄像机位置(x, y)出发,沿着当前摄像机方向a稍微偏左或偏右的角度,发射一条虚拟的“光线”到2D地图中。

这条光线会一步一步地在地图网格中前进(这就是“数字微分分析器DDA”算法的用武之地),直到它撞到一堵墙。此时,我们记录下光线走过的距离dist。这个距离非常关键!根据透视投影的原理,物体离观察者越远,它在屏幕上看起来就越小。因此,我们可以用这个距离dist来计算这堵墙在屏幕上应该被绘制的高度:墙高 = 常数 / dist。这里的“常数”是一个调节因子,可以控制整体的视野缩放比例。

距离dist是斜边长度,但我们通常需要的是垂直于摄像机视线方向的垂直距离,以避免“鱼眼效应”。因此,更准确的公式是:垂直距离 = dist * cos(光线与摄像机中心方向的夹角)。计算出墙高后,我们就在屏幕的当前列上,从中心位置向上向下各绘制墙高一半的像素,这样就形成了一堵竖条状的墙。对屏幕的每一列都重复这个过程,所有竖条拼合起来,就形成了一幅完整的、具有深度感的3D场景视图。

这个过程有几个精妙之处:第一,它完美地利用了“距离决定高度”的透视关系。第二,通过用不同的颜色或纹理绘制不同ID的墙,可以轻松区分不同的墙体。第三,光线撞击墙面的位置(是撞在墙的X面还是Y面)可以用来模拟简单的光照或阴影,让墙面看起来更有立体感。例如,让撞在X面的墙(南北向的墙)比撞在Y面的墙(东西向的墙)暗一些,就能产生一种方向光的效果。

注意:这里描述的是最基础的“一维高度场”光线投射。它假设所有墙都是垂直的、无限高的,因此我们只需要计算每一列墙的绘制高度。这大大简化了问题,使得用2D绘图库实现3D视角成为可能,也是本项目选择此算法的根本原因。

2.1 数学基础:向量、角度与距离计算

要实现上述算法,我们需要和几个基本的数学概念打交道。不用担心,我们只用到最基础的部分。

首先,是玩家的位置和方向。位置用(playerX, playerY)两个浮点数表示。方向用一个角度playerA表示(单位通常是弧度)。在C++的数学库<cmath>中,三角函数sin,cos,tan等默认使用弧度制。弧度制更符合计算机的计算特性。记住转换关系:360度 = 2 * PI 弧度

当我们按下“左转”或“右转”键时,就是给playerA增加或减少一个小的角度值(例如0.05弧度)。当我们按下“前进”键时,新的位置计算为:newX = playerX + cos(playerA) * moveSpeednewY = playerY + sin(playerA) * moveSpeed这里,cos(playerA)sin(playerA)分别代表了面朝方向在X轴和Y轴上的单位分量。乘以移动速度moveSpeed,就得到了该帧在X和Y方向上的位移。

其次,是光线步进(DDA)算法。这是光线投射的核心。我们不是真的让光线连续前进,而是在网格地图上“跳格子”。算法需要计算光线与下一个X方向网格线和下一个Y方向网格线的距离,然后选择更近的那条线跨过去,检查落点格子是否为墙。这个过程效率极高,因为它避免了浮点数循环和开方运算,直到找到墙为止。

最后,是距离校正与投影。通过DDA算法得到的是从摄像机到撞击点的直线距离(欧几里得距离)。但正如前面提到的,直接使用这个距离会导致画面中心的墙看起来正确,而两侧的墙被拉伸(鱼眼效应)。这是因为屏幕每一列对应的光线,与摄像机中心方向线之间存在一个夹角deltaA。正确的垂直距离应该是:perpDist = dist * cos(deltaA)。然后用这个perpDist去计算墙高:wallHeight = (screenHeight / perpDist) * scaleFactorscaleFactor是一个可调参数,用于控制视野的宽窄。

2.2 世界表示:网格地图与玩家状态

我们需要一种简洁的方式来定义游戏世界。一个二维的std::vector<std::vector<int>>或者一个普通的二维数组int map[MAP_WIDTH][MAP_HEIGHT]就足够了。每个int值代表一个地图格子的类型:0代表空地,可通行;1、2、3...等代表不同类型的墙。

玩家的状态需要用一个结构体来维护:

struct Player { float x, y; // 世界坐标中的位置 float angle; // 面朝方向(弧度) float fov; // 视野范围(弧度),通常设为 PI/3 (60度) float moveSpeed; // 移动速度(每帧) float rotateSpeed; // 旋转速度(弧度/每帧) };

fov(Field of View)视野非常重要。它决定了玩家能看到多宽的范围。在光线投射中,屏幕最左侧的列对应角度playerA - fov/2,最右侧的列对应playerA + fov/2。屏幕中间的列自然就对应playerA本身。通过遍历从-fov/2+fov/2的一系列角度,我们就能生成整个屏幕的3D视图。

3. 环境搭建与EasyX基础

工欲善其事,必先利其器。我们选择Visual Studio作为开发环境,因为它与EasyX的集成最为简单无缝。建议使用VS2019或VS2022社区版,它们是免费且功能强大的。

3.1 安装与配置EasyX

  1. 下载:访问EasyX官网(easyx.cn),下载对应你Visual Studio版本的安装包。
  2. 安装:运行安装程序,它会自动检测你系统上已安装的VS版本,并为你勾选。一路点击“下一步”即可完成安装。这个过程真的不超过一分钟。
  3. 验证:打开Visual Studio,创建一个新的“空项目”或“控制台应用”项目。在源代码文件中,尝试包含EasyX头文件并写一个最简单的程序:
    #include <graphics.h> // EasyX图形库头文件 #include <conio.h> int main() { initgraph(640, 480); // 初始化一个640x480的图形窗口 circle(320, 240, 100); // 在窗口中心画一个半径为100的圆 getch(); // 按任意键继续 closegraph(); // 关闭图形窗口 return 0; }
    如果编译运行后能弹出一个画着圆圈的窗口,说明EasyX配置成功。

实操心得:在创建项目时,如果你选择的是“控制台应用”,运行时会同时出现黑色的控制台窗口和图形窗口。如果你觉得控制台窗口碍事,可以将项目属性中的“子系统”从“控制台 (/SUBSYSTEM:CONSOLE)”改为“Windows (/SUBSYSTEM:WINDOWS)”。这样编译后,就只会弹出图形窗口。在调试时,保留控制台窗口可以方便地用printf输出日志,各有优劣。

3.2 EasyX核心绘图函数与双缓冲

对于我们的3D游戏,我们不会用到EasyX里那些画圆、画矩形的函数。我们最核心的操作是直接操作像素。putpixel(x, y, color)函数用于在指定坐标(x, y)画一个颜色为color的点。但更高效的做法是使用GetImageBuffer函数。

双缓冲技术是游戏和动画编程中防止画面闪烁的必备技术。原理是:我们不在屏幕上直接绘图,而是先在一块“后台”的内存画布上绘制完整的一帧图像,绘制完成后,一次性将这块内存复制到前台屏幕。EasyX提供了简便的机制:

#include <graphics.h> #include <conio.h> int main() { initgraph(640, 480); BeginBatchDraw(); // 开始批量绘图,开启双缓冲模式 // 游戏主循环 while (true) { cleardevice(); // 清空后台缓冲区 // ... 在这里进行所有的绘图操作,例如调用你的3D渲染函数 ... FlushBatchDraw(); // 将后台缓冲区的内容一次性刷新到屏幕 Sleep(10); // 控制帧率,例如每秒100帧 } EndBatchDraw(); // 结束批量绘图 closegraph(); return 0; }

使用BeginBatchDraw()EndBatchDraw()包裹你的主循环,在循环内用cleardevice()清屏,绘制完一帧后调用FlushBatchDraw()刷新,这是EasyX下游戏编程的标准模式。

另一个重要的函数是GetAsyncKeyState。它用于检测按键状态,非常适合实时控制的游戏。例如:

if (GetAsyncKeyState('W') & 0x8000) { // 玩家按下了W键,处理前进逻辑 } if (GetAsyncKeyState(VK_LEFT) & 0x8000) { // 玩家按下了左箭头键,处理左转逻辑 }

它比_getch()这种等待输入的函数更适合游戏循环。

4. 分步实现:从2D地图到3D视图

现在,让我们把理论付诸实践,一步步构建我们的游戏。我会将代码模块化,方便你理解和调试。

4.1 步骤一:定义游戏世界与玩家

首先,我们定义一些常量和全局变量。

#include <graphics.h> #include <conio.h> #include <cmath> #include <vector> #include <cstdio> const int SCREEN_WIDTH = 1024; const int SCREEN_HEIGHT = 768; const int MAP_SIZE = 10; // 10x10的地图 const float PI = 3.1415926535f; const float FOV = PI / 3.0f; // 60度视野 const float MAX_DEPTH = 16.0f; // 光线最远能看到的距离 // 简单的地图,1表示墙,0表示空地 int gameMap[MAP_SIZE][MAP_SIZE] = { {1,1,1,1,1,1,1,1,1,1}, {1,0,0,0,0,0,0,0,0,1}, {1,0,1,1,0,1,0,1,0,1}, {1,0,1,0,0,0,0,1,0,1}, {1,0,0,0,1,1,0,0,0,1}, {1,0,1,0,0,0,0,1,0,1}, {1,0,1,1,0,1,0,1,0,1}, {1,0,0,0,0,0,0,0,0,1}, {1,0,1,0,1,0,1,0,0,1}, {1,1,1,1,1,1,1,1,1,1} }; struct Player { float x = 1.5f, y = 1.5f; // 初始位置在地图(1.5, 1.5)处 float angle = 0.0f; // 初始面朝东(0弧度) float moveSpeed = 0.05f; float rotateSpeed = 0.05f; } player;

这里我们定义了一个10x10的迷宫地图,玩家被放置在(1.5, 1.5)的位置。使用.5是为了让玩家一开始不紧贴着墙,有转身的空间。

4.2 步骤二:实现光线投射核心函数

这是整个引擎的心脏。我们将为屏幕的每一列计算一堵墙。

void castRaysAndDrawWalls() { // 遍历屏幕的每一列 for (int x = 0; x < SCREEN_WIDTH; x++) { // 计算当前列对应的光线角度:从 player.angle - FOV/2 到 player.angle + FOV/2 float rayAngle = player.angle - FOV / 2.0f + (float)x / SCREEN_WIDTH * FOV; // 确保角度在0到2PI之间 rayAngle = fmod(rayAngle, 2 * PI); if (rayAngle < 0) rayAngle += 2 * PI; // 光线的单位方向向量 float rayDirX = cos(rayAngle); float rayDirY = sin(rayAngle); // 玩家在地图网格中的坐标 int mapX = (int)player.x; int mapY = (int)player.y; // 从玩家到下一个X边和Y边的距离(初始值) float sideDistX, sideDistY; // 光线在X和Y方向上每走一个网格单元的距离(delta距离) float deltaDistX = (rayDirX == 0) ? 1e30 : fabs(1.0f / rayDirX); float deltaDistY = (rayDirY == 0) ? 1e30 : fabs(1.0f / rayDirY); // 步进方向(+1或-1)以及初始的sideDist计算 int stepX, stepY; if (rayDirX < 0) { stepX = -1; sideDistX = (player.x - mapX) * deltaDistX; } else { stepX = 1; sideDistX = (mapX + 1.0f - player.x) * deltaDistX; } if (rayDirY < 0) { stepY = -1; sideDistY = (player.y - mapY) * deltaDistY; } else { stepY = 1; sideDistY = (mapY + 1.0f - player.y) * deltaDistY; } // DDA算法循环 bool hitWall = false; int side = 0; // 记录最后击中的是X面还是Y面,用于后续着色 float perpWallDist = 0.0f; while (!hitWall) { // 跳向下一个最近的网格边界 if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; // 击中的是X面(南北墙) } else { sideDistY += deltaDistY; mapY += stepY; side = 1; // 击中的是Y面(东西墙) } // 检查是否击中墙 if (mapX >= 0 && mapX < MAP_SIZE && mapY >= 0 && mapY < MAP_SIZE) { if (gameMap[mapY][mapX] > 0) { // 注意:地图是[行][列],即[y][x] hitWall = true; } } else { // 光线跑出了地图边界,也视为击中(防止越界) hitWall = true; } } // 计算垂直距离(用于校正鱼眼效应) if (side == 0) { perpWallDist = (sideDistX - deltaDistX); } else { perpWallDist = (sideDistY - deltaDistY); } // 根据距离计算墙在屏幕上的高度 int lineHeight = (int)(SCREEN_HEIGHT / perpWallDist); // 计算墙条在屏幕上绘制的起始和结束Y坐标 int drawStart = -lineHeight / 2 + SCREEN_HEIGHT / 2; if (drawStart < 0) drawStart = 0; int drawEnd = lineHeight / 2 + SCREEN_HEIGHT / 2; if (drawEnd >= SCREEN_HEIGHT) drawEnd = SCREEN_HEIGHT - 1; // 选择墙的颜色(这里根据地图值和击中面来简单区分) COLORREF wallColor; int mapValue = gameMap[mapY][mapX]; if (mapValue == 1) { wallColor = (side == 0) ? RGB(200, 100, 100) : RGB(150, 50, 50); // 红墙,X面亮,Y面暗 } else { wallColor = (side == 0) ? RGB(100, 100, 200) : RGB(50, 50, 150); // 蓝墙 } // 使用EasyX画一条垂直线来代表这堵墙 setlinecolor(wallColor); line(x, drawStart, x, drawEnd); } }

这段代码是光线投射的核心。它遍历屏幕的每一列x,计算对应的光线角度,然后使用DDA算法在网格地图中步进,直到找到一堵墙。接着计算正确的垂直距离perpWallDist,并由此算出墙的绘制高度lineHeight。最后,根据墙的类型和击中的面(side)选择颜色,并在屏幕的x列上,从drawStartdrawEnd画一条垂直线。

4.3 步骤三:集成输入处理与游戏主循环

现在我们需要把渲染、输入和玩家状态更新整合到一个流畅的游戏循环中。

void handleInput() { float oldX = player.x, oldY = player.y; float oldAngle = player.angle; // 旋转 if (GetAsyncKeyState('A') & 0x8000) player.angle -= player.rotateSpeed; if (GetAsyncKeyState('D') & 0x8000) player.angle += player.rotateSpeed; // 确保角度在合理范围内 player.angle = fmod(player.angle, 2 * PI); if (player.angle < 0) player.angle += 2 * PI; // 移动(基于新的角度) float moveX = 0, moveY = 0; if (GetAsyncKeyState('W') & 0x8000) { moveX += cos(player.angle) * player.moveSpeed; moveY += sin(player.angle) * player.moveSpeed; } if (GetAsyncKeyState('S') & 0x8000) { moveX -= cos(player.angle) * player.moveSpeed; moveY -= sin(player.angle) * player.moveSpeed; } // 简单的碰撞检测:只检测目标点所在的格子是否为墙 int newMapX = (int)(player.x + moveX); int newMapY = (int)(player.y + moveY); if (gameMap[newMapY][(int)player.x] == 0) { // 允许X方向移动 player.x += moveX; } if (gameMap[(int)player.y][newMapX] == 0) { // 允许Y方向移动 player.y += moveY; } } int main() { initgraph(SCREEN_WIDTH, SCREEN_HEIGHT); BeginBatchDraw(); // 设置字体,用于显示调试信息 LOGFONT f; gettextstyle(&f); _tcscpy_s(f.lfFaceName, _T("Consolas")); f.lfHeight = 20; settextstyle(&f); settextcolor(RGB(255, 255, 255)); while (true) { // 处理输入 if (GetAsyncKeyState(VK_ESCAPE) & 0x8000) break; // 按ESC退出 handleInput(); // 渲染 cleardevice(); // 1. 绘制3D视图(墙面) castRaysAndDrawWalls(); // 2. (可选)在屏幕上方绘制2D迷你地图,用于调试 int miniMapScale = 10; for (int y = 0; y < MAP_SIZE; y++) { for (int x = 0; x < MAP_SIZE; x++) { if (gameMap[y][x] == 1) { setfillcolor(RGB(100, 100, 100)); } else { setfillcolor(RGB(50, 50, 50)); } solidrectangle(x * miniMapScale, y * miniMapScale, (x + 1) * miniMapScale, (y + 1) * miniMapScale); } } // 绘制玩家在迷你地图上的位置和方向 setfillcolor(RGB(255, 0, 0)); solidcircle((int)(player.x * miniMapScale), (int)(player.y * miniMapScale), 3); setlinecolor(RGB(0, 255, 0)); line((int)(player.x * miniMapScale), (int)(player.y * miniMapScale), (int)((player.x + cos(player.angle) * 2) * miniMapScale), (int)((player.y + sin(player.angle) * 2) * miniMapScale)); // 显示调试信息 TCHAR info[256]; _stprintf_s(info, _T("Pos: (%.2f, %.2f) Angle: %.2f"), player.x, player.y, player.angle); outtextxy(10, SCREEN_HEIGHT - 30, info); FlushBatchDraw(); Sleep(16); // 约60帧/秒 } EndBatchDraw(); closegraph(); return 0; }

主循环while (true)是游戏的发动机。每一帧,我们依次处理输入、更新玩家状态、清屏、渲染3D视图、渲染2D迷你地图(用于直观理解玩家位置和方向)、显示调试信息,最后刷新屏幕。Sleep(16)让循环每秒运行大约60次,这是一个比较流畅的帧率。

4.4 步骤四:优化与增强视觉效果

基础的渲染已经完成,但画面看起来可能比较单调。我们可以通过一些简单的技巧来大幅提升视觉效果。

1. 地面和天空的绘制:在绘制墙线之前或之后,我们可以用渐变色填充屏幕的上下部分来模拟天空和地面。

// 在castRaysAndDrawWalls函数开头或主循环清屏后绘制天空和地面 for (int y = 0; y < SCREEN_HEIGHT / 2; y++) { // 天空渐变(从上到下变亮) int brightness = 100 + (y * 55) / (SCREEN_HEIGHT / 2); setlinecolor(RGB(135, 206, 235)); // 天蓝色 // 更高效的做法是使用渐变填充函数,这里为清晰起见用画线 line(0, y, SCREEN_WIDTH, y); } for (int y = SCREEN_HEIGHT / 2; y < SCREEN_HEIGHT; y++) { // 地面渐变(从上到下变暗) int brightness = 155 - ((y - SCREEN_HEIGHT / 2) * 55) / (SCREEN_HEIGHT / 2); setlinecolor(RGB(100, brightness, 50)); // 草地绿 line(0, y, SCREEN_WIDTH, y); } // 注意:这种逐行画线的方法效率较低。更高效的做法是使用EasyX的渐变填充函数或直接操作图像缓冲区。

2. 纹理映射(贴图):让墙面显示图片纹理而不是纯色,沉浸感会强很多。这需要引入位图资源。

  • 准备一张纹理图片(例如128x128像素),保存为.bmp格式。
  • 在EasyX中,使用loadimage函数加载它。
  • 在光线投射计算中,当击中墙时,我们需要计算击中点在墙上的精确位置(一个0到1之间的小数),这个值称为wallX
  • 然后,根据wallX和绘制墙的y坐标,从纹理图片中取出对应的颜色,画到屏幕上。
IMAGE wallTexture; loadimage(&wallTexture, _T("wall.bmp")); // 加载纹理 // 在castRaysAndDrawWalls函数内部,计算wallX float wallX; // 击中点在墙上的位置 if (side == 0) { // 如果击中的是X面 wallX = player.y + perpWallDist * rayDirY; } else { // 如果击中的是Y面 wallX = player.x + perpWallDist * rayDirX; } wallX -= floor(wallX); // 取小数部分,得到0-1之间的值 // 根据wallX计算纹理的X坐标 int texX = (int)(wallX * TEXTURE_WIDTH); if ((side == 0 && rayDirX > 0) || (side == 1 && rayDirY < 0)) { texX = TEXTURE_WIDTH - texX - 1; // 反转纹理,使其看起来正确 } // 在绘制墙的竖条时,对每个y像素计算纹理Y坐标并取色 for (int y = drawStart; y < drawEnd; y++) { int d = y * 256 - SCREEN_HEIGHT * 128 + lineHeight * 128; // 魔法数字,用于计算透视正确的纹理Y int texY = ((d * TEXTURE_HEIGHT) / lineHeight) / 256; COLORREF color = getpixel(texX, texY, &wallTexture); // 从纹理获取颜色(此函数为示例,EasyX中为GetImagePixel) putpixel(x, y, color); }

纹理映射是光线投射引擎从“玩具”升级到“像样游戏”的关键一步,虽然增加了计算量,但视觉效果提升是质的飞跃。

3. 帧率控制与平滑移动:目前的移动速度moveSpeed是固定的,在高性能电脑上可能太快。一个更好的方法是根据每帧耗时deltaTime来调整移动距离。

// 在主循环中 static DWORD lastTime = GetTickCount(); DWORD currentTime = GetTickCount(); float deltaTime = (currentTime - lastTime) / 1000.0f; // 转换为秒 lastTime = currentTime; // 在handleInput中,移动距离基于deltaTime float actualMoveSpeed = player.moveSpeed * deltaTime * 60.0f; // 假设以60帧为基准

这样就能保证在不同性能的电脑上,移动速度是一致的。

5. 常见问题、调试技巧与性能优化

在实现过程中,你几乎一定会遇到各种奇怪的问题。这里我总结了一些常见坑点和解决思路。

5.1 视觉异常与调试

  1. 鱼眼效应(Fisheye Effect)

    • 现象:画面中心的墙看起来正常,但两侧的墙向外弯曲,像通过鱼眼镜头看东西。
    • 原因:直接使用了从摄像机到撞击点的直线距离dist,而没有使用垂直于摄像机平面的垂直距离perpDist
    • 解决:确保在计算墙高时,使用的是perpDist = dist * cos(deltaAngle),其中deltaAngle是当前光线角度与摄像机中心角度player.angle的差值。
  2. 墙面撕裂或闪烁

    • 现象:墙的边缘有锯齿,或者画面在移动时闪烁。
    • 原因:没有使用双缓冲,或者清屏cleardevice()和绘制FlushBatchDraw()的顺序不对。
    • 解决:确保主循环结构是BeginBatchDraw()->while(1){ cleardevice(); ...绘制...; FlushBatchDraw(); }->EndBatchDraw()。同时,检查你的绘制代码是否每一帧都覆盖了整个屏幕区域。
  3. 撞墙穿模

    • 现象:玩家可以穿过薄墙,或者卡在墙角。
    • 原因:碰撞检测太简单。我们只检测了目标点(newX, newY)所在的格子,如果移动速度过快,可能会“跳过”墙所在的格子。
    • 解决:更稳健的碰撞检测是分别检测X方向和Y方向的移动。或者,可以检测从(oldX, oldY)(newX, newY)的线段是否与墙格子的边界相交。对于初学者,一个简单有效的办法是降低moveSpeed,并分别处理X和Y方向的碰撞,就像我们在handleInput函数中做的那样。

5.2 性能优化技巧

当把屏幕分辨率调高,或者地图变大后,你可能会发现帧率下降。光线投射是一个O(n)的算法(n是屏幕宽度),每一帧都要计算几百上千条光线。优化是必要的。

  1. 限制光线最大步数:在DDA算法的while循环中,增加一个步数计数器,如果超过MAX_STEPS(比如MAP_SIZE*2)就强制跳出循环,并视为没有击中墙(绘制天空/地面)。这可以防止在开阔地带无限循环。

  2. 降低分辨率渲染:这是一个“作弊”但极其有效的方法。我们不在SCREEN_WIDTH(如1024)的每一列都计算光线,而是每隔2列或4列计算一次。计算出的墙高和颜色用于这2列或4列。这相当于将水平分辨率降低了一半或四分之一,渲染负担立刻减少50%或75%。在putpixelline时,画一个更宽的竖条即可。对于小游戏,视觉损失在可接受范围内。

  3. 使用查找表:三角函数sincos的计算开销较大。我们可以预先计算好一个角度查表。例如,创建一个大小为360720的浮点数数组sinTablecosTable,在程序初始化时用sincos函数填充。在游戏运行时,需要某个角度的正弦值时,先将弧度转换为度数索引,然后直接从表中读取。这能显著提升速度。

  4. 优化绘图调用line函数对于画单列墙是高效的。但如果使用纹理映射,对每个像素调用putpixel可能会慢。可以考虑使用GetImageBuffer获取指向绘图缓冲区内存的指针,直接操作内存来设置像素颜色,这是最快的绘图方式。

5.3 功能扩展思路

当你的基础引擎运行稳定后,可以尝试添加更多游戏元素:

  • 多种墙类型与门:在地图数组中用不同的数字代表不同的墙或门。在渲染时根据数字选择不同的颜色或纹理。门可以设计成可以开关的,这需要额外的状态管理。
  • 精灵(Sprite)渲染:渲染一些2D的物体,比如敌人、道具、装饰物。这需要额外的步骤:计算物体相对于摄像机的角度和距离,然后像画墙一样,根据距离计算它在屏幕上的大小和水平位置,最后将一张2D图片缩放并绘制到正确的位置。难点在于处理物体与墙的前后遮挡关系(深度缓冲)。
  • 简单的敌人AI:给敌人一个位置和状态(巡逻、追击、攻击)。在2D迷你地图的逻辑层更新敌人的位置,然后像渲染精灵一样把它们画到3D视图里。
  • 声音与交互:使用PlaySound函数播放.wav文件,为脚步声、开门声、撞击声添加音效。

从零开始用C++和EasyX实现一个第一人称3D游戏,是一个极具挑战也极具成就感的项目。它强迫你去理解从数学、算法到系统编程、实时渲染的完整链条。当你最终按下WASD键,在自己编码的世界里自由行走时,那种感觉是无与伦比的。这个项目就像一个微型的“游戏引擎”实验,它所蕴含的光线投射、DDA算法、透视投影、双缓冲等概念,是通往更复杂的图形编程和游戏开发领域的坚实基石。我建议你在实现基础版本后,不要停下,选择上面提到的一两个扩展功能去实现它,你会对3D图形有更深的理解。

← 返回列表