前言

我是一个电子游戏爱好者,对市面上各种类型的电子游戏都稍有涉猎,其中最喜欢的应该就是射击游戏

凭借着对游戏的喜爱,我曾经追溯过游戏发展的历史,尤其是我最感兴趣的射击游戏:按照时间年表从现在到过去,《Splatoon》将游戏机制与手柄操作完美结合,定义了新的手柄射击方式;《Counter-Strike》这部里程碑式的作品将多人对抗竞技发扬光大;《Half-Life》让游戏不再局限于射击体验,而在流程中更加着重于剧情与解谜成分;《GoldenEye 007》和《Halo》把射击游戏搬上了主机平台,优化了手柄的操作;《Quake》,世界上第一款实时3D渲染的射击游戏,第一款真正意义上的3D射击游戏;《Doom》和《Wolfenstein 3D》,利用伪3D的技术,超越了世代技术力的限制,让第一人称射击游戏提前出现了许多年

我其实在查资料的时候就很疑惑,毕竟我没有亲自经历过电子游戏发展早期的那个时代,既然机能不足,不能够支撑3D,那这个所谓的伪3D是个什么东西?

带着这个问题,我就进一步寻找答案,最终找到了叫做Raycasting的技术,这正是当年约翰卡马克在《德军总部3D》和《DOOM》中使用的算法

这是一篇Raycasting教程的博客,质量很高:Raycasting (lodev.org)

更令我惊喜的是,这个算法并没有想象中那样复杂,在复现完以后,我能明确的说:只要有初中几何的知识,以及掌握最简单的二维矩阵乘法知识,就可以复现出这个算法


正好,在计算机图形学的实验课上有足够的时间学习和复现这个算法,毕竟“实验课想做啥就做啥”

于是,带着对射击游戏祖师爷《德军总部3D》的致敬和好奇,我复现了这个经典游戏的基本框架


此篇博客后续内容:Raycasting学习记录_进阶与扩展,增加地板天花板纹理、鼠标控制视角、decoration的Sprite绘制、《白色相簿2》立绘Sprite的绘制,都是在此博客的基础上添加的新内容

源代码和可执行程序已经部署到github上:Raycasting

Raycasting

简介

Raycasting is a rendering technique to create a 3D perspective in a 2D map. Back when computers were slower it wasn't possible to run real 3D engines in realtime, and raycasting was the first solution. Raycasting can go very fast, because only a calculation has to be done for every vertical line of the screen. The most well known game that used this technique, is of course Wolfenstein 3D.

利用Raycasting算法做的游戏是很有时代特色的,比如这样的:

一切可交互的物体,如敌人和物品,都用Sprite贴图来显示,交互逻辑也是很简单的“射爆”

可惜这次复现并没有涉及到Sprite,不过今后肯定会补上的


基本原理,一张动态图就能很好地解释:

用一个二维数组存储地图,0代表可以行走的地面,正数值可以代表不同的墙体,并且可以区分对应不同的纹理

从人物视角发出屏幕宽度像素个数的光线(比如大小是1440*900,就发射出1440道光线),每到光线发射出去后会检测墙面碰撞,如果碰到了墙面,就会记住对应的距离,然后根据近大远小的原理,在屏幕上画出对应高度的线,这个高度就代表墙的高度

比如一道光线碰到了墙,光线长度是5,我们就可以在屏幕对应位置画出\(\dfrac{屏幕高度}{5}\)这么长的线,就代表墙高\(\dfrac{屏幕高度}{5}\);另一道光线碰到了墙,光线长度是10,我们就可以在屏幕对应位置画出\(\dfrac{屏幕高度}{10}\)这么长的线,就代表墙高\(\dfrac{屏幕高度}{10}\)。因此,在屏幕上显示的墙就很符合近大远小的特点:距离近的墙显示就大,距离远的墙显示就小

至于如何计算光线碰到墙的距离,就可以利用DDA算法,具体会在后面的“复现”解释

最基本的复现

本次复现使用SFML多媒体库,原因是我之前用过,比较熟悉,还很简单

最基本的复现,指基础的Raycasting,不涉及纹理和小地图等

可在这里查看源代码

下面是每个部分具体实现的讲解

必要参数声明

此处定义屏幕大小,地图大小,地图

还有玩家位置,视野,面朝方向这些参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#define screenWidth 1440
#define screenHeight 900
#define mapWidth 24
#define mapHeight 24

int worldMap[mapWidth][mapWidth] =
{
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,2,2,2,2,0,0,0,0,3,0,3,0,3,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,3,0,0,0,3,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,2,0,2,2,0,0,0,0,3,0,3,0,3,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,0,0,0,5,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1}
};

double posX = 22, posY = 12; //x and y start position
double dirX = -1, dirY = 0; //initial direction vector
double planeX = 0, planeY = 0.66; //the 2d raycaster version of camera plane

double Mytime = 0; //time of current frame
double oldTime = 0; //time of previous frame

Texture texture[8]; //纹理数组
Clock Myclock; //用于计时

参考原教程,人物的视野范围可以用\(\overrightarrow{pos}\)\(\overrightarrow{dir}\)\(\overrightarrow{plane}\)组合求得,如图所示,上面代码的对应6个变量就是设置这三个向量用的

关于计时:变量 timeoldTime 用于存储当前帧和前一帧的时间,这两者之间的时间差可以用来确定当按下某个键时应该移动多少(无论帧的计算需要多长时间,都能保持恒定的速度),即用于求移动速度和旋转速度

main函数的结构

根据SFML库里介绍的,SFML最基础的main程序结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int main()
{
//定义窗口大小
sf::RenderWindow window(sf::VideoMode(200, 200), "SFML works!");

//while死循环,以帧为单位
while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close(); //用于关闭窗口
}

window.clear(); //清空屏幕上所有图形
//*****************************************************
// 此处往屏幕上画图形,每一帧都会被清空重画
//*****************************************************
window.display(); //显示在屏幕上画的图形
}

return 0;
}

SFML每次都会重新画一帧,因此需要每一帧调用window.clear();函数

我们要做的,就是每一帧都在屏幕对应位置上画出对应的线

Raycasting的main函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main()
{
RenderWindow window(sf::VideoMode(screenWidth, screenHeight), "Raycasting");

while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();
}

window.clear();

DDA_algorithm(window);
Move(window);

window.display();
}

return 0;
}

在创建窗口后,就是经典的main函数死循环,每一帧判断是否关闭,然后清除所有图形,重新画新的,再显示到屏幕上

用于画图形的是DDA_algorithm(window)

Move(window)函数是控制角色移动的,会改变\(\overrightarrow{pos}\)\(\overrightarrow{dir}\)\(\overrightarrow{plane}\)三个向量


紧接着介绍对应的两个函数

DDA_algorithm函数

函数的定义如下

1
void DDA_algorithm(RenderWindow& window)

需要传递进main里面声明的window窗口

再看看原理图:

由于每一帧都需要从人物视角发出屏幕宽度像素个数的光线,每到光线发射出去后会检测墙面碰撞,再把对应的长度的墙画在屏幕上

所以这个函数就是一个x0遍历到screenWidth的循环,每一个x都对应着一个屏幕像素的横坐标,这样我们就有了给竖直线定位用的横坐标,所以这个程序的大致结构如下:

1
2
3
4
5
6
7
8
9
10
11
void DDA_algorithm(RenderWindow& window)
{
for (int x = 0; x < screenWidth; x++)
{
//角色视角发出对应x的一根线,确定这根线的方向,起点就在角色位置
//执行DDA算法,用于判断线是否碰到了墙壁,并记录光线的长度
//利用光线的长度计算墙的高度
//根据地图,不同的墙选择不同的颜色
//在屏幕对应位置画对应颜色的线
}
}

比如,窗口大小设置为1440*900,对应就是1440根光线执行注释里对应的5个操作,然后对应位置画竖直的线,就能达到伪3D的效果


下面将对应注释中的5个操作,介绍具体过程

角色发出光线

需要定义光线的方向,过程如下图

定义光线向量为\(\overrightarrow{rayDir}\),这个向量就可以通过\(\overrightarrow{dir}\)\(\overrightarrow{plane}\)两个向量求得 \[ \overrightarrow{rayDir}=\overrightarrow{dir}+对应倍数\cdot\overrightarrow{plane} \]

这个倍数的范围应该是在\([-1,1]\)上,这样就能全面覆盖所有视野范围

至于倍数的确定,就需要用到x了:把x这个变量映射到\([-1,1]\)上,对应图上的图,映射得到的倍数命名为cameraX \[ cameraX=\dfrac{2}{screenWidth}\cdot x-1 \] 所以对应的\(\overrightarrow{rayDir}\)就能求出来,代码对应如下:

1
2
3
4
//calculate ray position and direction
double cameraX = 2 * x / (double)screenWidth - 1; //x-coordinate in camera space
double rayDirX = dirX + planeX * cameraX;
double rayDirY = dirY + planeY * cameraX;

DDA算法

必要的变量定义与计算

需要定义几个变量用于计算发出光线碰到墙后的长度

1
2
3
4
5
6
7
8
9
10
11
12
//which box of the map we're in
int mapX = int(posX);
int mapY = int(posY);

//length of ray from current position to next x or y-side
double sideDistX;
double sideDistY;

double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(1 / rayDirX);
double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(1 / rayDirY);

double perpWallDist; //光线长度

mapXmapY是现在角色所在地图的位置

sideDistXsideDistY是光线从起始位置到第一个X边和第一个Y边需要行进的距离,可能文字无法精确描述,可以看看图里的定义

deltaDistX是光线从一个X边到另一个X边需要行进的距离

deltaDistY是光线从一个Y边到另一个Y边需要行进的距离

看图就会更容易理解:

对于一个给定的方向\(\overrightarrow{rayDir}\),对应的deltaDistXdeltaDistY是固定的

设图上的角度为\(\theta\)

推导过程如下: \[ \begin{align*} \tan{\theta}=\frac{rayDirX}{rayDirY}&=\frac{1}{\sqrt{deltaDistX^2-1}}\\\\ deltaDistX&=\sqrt{\frac{rayDirX^2+rayDirY^2}{rayDirX^2}}\\\\ &=\left|\frac{|rayDir|}{rayDirX}\right|\\\\\\ 同理,deltaDistY&=\left|\frac{|rayDir|}{rayDirY}\right| \end{align*} \] 分子的\(|rayDir|\)是模长的意思,而整个式子外面的符号是绝对值的意思

但因为DDA算法只与deltaDistXdeltaDistY比例有关,所以可以把两个deltaDist化简为: \[ \begin{align*} deltaDistX&=\left|\frac{1}{rayDirX}\right|\\\\ deltaDistY&=\left|\frac{1}{rayDirY}\right| \end{align*} \]

  1. 如果不进行化简,就会导致deltaDist考虑到光线的实际长度,而deltaDist这个变量会用于sideDist的计算,进而用于求出画线长度变量perpWallDist的计算;说人话就是使用公式\(deltaDistX=\left|\dfrac{|rayDir|}{rayDirX}\right|\)\(deltaDistY=\left|\dfrac{|rayDir|}{rayDirY}\right|\)计算,就会导致墙的高度考虑了光线起点与终点的欧氏距离,会产生“鱼眼效应”,详情见这里
  2. 化简之后,从计算方法上可以避免产生“鱼眼效应”,因为把deltaDist缩放了|rayDir|,在计算过程中可以使perpWallDist的值刚好等于sideDistX - deltaDistX或者sideDistY - deltaDistY,很神奇吧
  3. 还得注意,这种计算方法只是“避免”产生鱼眼效应,而不是“修正”鱼眼效应,这两个是完全不同的概念
  4. 具体证明“为什么能避免鱼眼效应”见这里
  5. 如果想看产生“鱼眼效应”的效果,见这里只要不嫌晕


但是需要注意,如果方向垂直于坐标轴,比如:

此时的rayDirX = 0,所以给deltaDistX赋值为无穷,比如1e30,Y方向同理

所以就有了对应的代码:

1
2
double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(1 / rayDirX);
double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(1 / rayDirY);


再定义一系列新变量:

1
2
3
4
5
6
//what direction to step in x or y-direction (either +1 or -1)
int stepX;
int stepY;

int hit = 0; //was there a wall hit?
int side; //was a NS or a EW wall hit?

stepX,光线方向在X的正负方向,+1代表正方向,-1代表负方向(规定向右为正)

stepY,光线方向在Y的正负方向,+1代表正方向,-1代表负方向(规定向上为正)

hit,检测光线是否撞墙,用于结束循环

side,当光线撞到X方向的墙时,赋值0;当光线撞到Y方向的墙时,赋值1


计算sideDist,这个需要自己动手在纸上画一画,就是初中的相似三角形知识

对应的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 //calculate step and initial sideDist
if (rayDirX < 0)
{
stepX = -1;
sideDistX = (posX - mapX) * deltaDistX;
}
else
{
stepX = 1;
sideDistX = (mapX + 1.0 - posX) * deltaDistX;
}
if (rayDirY < 0)
{
stepY = -1;
sideDistY = (posY - mapY) * deltaDistY;
}
else
{
stepY = 1;
sideDistY = (mapY + 1.0 - posY) * deltaDistY;
}


执行DDA算法

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//perform DDA
while (hit == 0)
{
//jump to next map square, either in x-direction, or in y-direction
if (sideDistX < sideDistY)
{
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
}
else
{
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
//Check if ray has hit a wall
if (worldMap[mapX][mapY] > 0) hit = 1;
}

这段代码还是挺简单的,可以在纸上画一画,就能理解过程了

可能等我有空了会做一个manim动画演示?应该会做吧

只需要注意,判断撞墙以后对应的长度会多加一个,于是需要减掉

计算对应墙的高度

当DDA结束以后,我们要计算光线的长度,这样才能求出对应墙的高度

注意:我们不用角色到墙壁实际的欧氏距离(即碰到墙后光线的长度),而是用相机平面到墙壁的距离来计算长度

如果用实际欧氏距离,就会出现“鱼眼效应”,如图所示:

在玩家角色P的左边,显示了一些从墙的击中点到玩家的红色光线,代表欧几里得距离

在玩家的右边,显示了一些从墙的击中点直接到相机平面的绿色光线,而不是到玩家,这些绿线的长度是我们将使用的垂直距离的例子,而不是直接的欧几里得距离

在示例图中,玩家正在直接看着墙,而在这种情况下,我们期望墙的底部和顶部在屏幕上形成一条完美的水平线,即面朝着墙,希望墙高度相等,如图所示:

然而,红色的光线都有不同的长度,所以会为不同的垂直条纹计算出不同的墙高,从而产生圆润的效果

右边的绿色光线都有相同的长度,所以会给出正确的结果,当玩家旋转时仍然适用

用一个比较极端的例子来描述“用相机平面的距离代替欧拉距离”:

这样就可以很好地理解了,只不过这个例子画的太极端了,完全没有考虑视野范围的情况

正常来说是应该计算出FOV里对应所有光线的终点,再拿相机平面到这些点的距离算出墙的高度


参考原教程,里面给出了证明过程,证明了这样的计算就能避免鱼眼效应

1
2
3
4
5
6
7
8
//教程里这部分解释的比较清楚了
//也可以看我在博客里的证明过程,写的更清楚
if (side == 0) perpWallDist = (sideDistX - deltaDistX);
else perpWallDist = (sideDistY - deltaDistY);

//Calculate height of line to draw on screen
//可任意修改墙的高度
int lineHeight = (int)(screenHeight / perpWallDist);


详细的证明过程整理如下,以这个图为例:


如果上文计算deltaDistXdeltaDistY的代码如下:

1
2
3
4
// 鱼眼效应
double rayDir_len = sqrt(rayDirX * rayDirX + rayDirY * rayDirY);
double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(rayDir_len / rayDirX);
double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(rayDir_len / rayDirY);

就会产生“鱼眼效应”,如下图所示,如果不嫌晕的话,其实挺有意思的

不同墙选不同颜色

我们可以规定,如果光线撞到Y墙,就让颜色的RGB减半,视觉效果上就会更暗,这样会有更好的显示效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//choose wall color
Color color;
switch (worldMap[mapX][mapY])
{
case 1: color = Color::Red; break; //red
case 2: color = Color::Green; break; //green
case 3: color = Color::Blue; break; //blue
case 4: color = Color::White; break; //white
default: color = Color::Yellow; break; //yellow
}

//give x and y sides different brightness
if (side == 1)
{
color.r /= 2;
color.g /= 2;
color.b /= 2;
}

屏幕对应位置画线

先计算线对应的起点drawStart和终点drawEnd,再带入画线函数

计算过程如图:

画线函数指定了屏幕对应的x坐标,线的起点和终点,还有对应的颜色

画线我单独写了一个函数:verLine,在DDA函数的最后直接调用就行

代码如下:

1
2
3
4
5
6
7
8
//calculate lowest and highest pixel to fill in current stripe
int drawStart = -lineHeight / 2 + screenHeight / 2;
if (drawStart < 0) drawStart = 0;
int drawEnd = lineHeight / 2 + screenHeight / 2;
if (drawEnd >= screenHeight) drawEnd = screenHeight - 1;

//draw the pixels of the stripe as a vertical line
verLine(window, x, drawStart, drawEnd, color);


画线函数定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//画线
void verLine(sf::RenderWindow& window, int x, int y1, int y2, const sf::Color& color)
{
// Ensure y1 is less than y2
if (y2 < y1)
{
std::swap(y1, y2);
}

// Create a sf::VertexArray for the line
sf::VertexArray line(sf::Lines, 2);

// Set the position and color of the line vertices
line[0].position = sf::Vector2f(x, y1);
line[0].color = color;
line[1].position = sf::Vector2f(x, y2);
line[1].color = color;

// Draw the line
window.draw(line);
}

至此,DDA函数结束

人物移动逻辑

对应了main函数中的Move函数

具体实现涉及到方向矩阵的乘法,说白了就是二维矩阵乘法


先计算帧之间的时间,用于计算速度

1
2
3
4
5
6
7
8
9
//timing for input and FPS counter
oldTime = Mytime;
Mytime = getTicks();
double frameTime = (Mytime - oldTime) / 1000.0; //frameTime is the time this frame has taken, in seconds


//speed modifiers
double moveSpeed = frameTime * 5.0; //the constant value is in squares/second
double rotSpeed = frameTime * 3.0; //the constant value is in radians/second

其中getTicks函数定义如下:

1
2
3
4
5
unsigned long getTicks()
{
sf::Time elapsed = Myclock.getElapsedTime();
return elapsed.asMilliseconds();
}


前后走

很好理解,对应坐标加上速度乘方向

对应代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//move forward if no wall in front of you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Up) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::W))
{
if (worldMap[int(posX + dirX * moveSpeed)][int(posY)] == false) posX += dirX * moveSpeed;
if (worldMap[int(posX)][int(posY + dirY * moveSpeed)] == false) posY += dirY * moveSpeed;
}
//move backwards if no wall behind you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Down) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::S))
{
if (worldMap[int(posX - dirX * moveSpeed)][int(posY)] == false) posX -= dirX * moveSpeed;
if (worldMap[int(posX)][int(posY - dirY * moveSpeed)] == false) posY -= dirY * moveSpeed;
}

左右走

左右走也类似于前后走,只不过对应方向改变了

对应代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//向右走,原理还是跟前后走一样,只是方向用矩阵相乘重新算了
//向右走就是在(dirY, -dirX)这个方向走
if (sf::Keyboard::isKeyPressed(sf::Keyboard::D))
{
if (worldMap[int(posX + dirY * moveSpeed * 0.75)][int(posY)] == false) posX += dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY - dirX * moveSpeed * 0.75)] == false) posY -= dirX * moveSpeed * 0.75;
}
//向左走同理
//在(-dirY, dirX)方向
if (sf::Keyboard::isKeyPressed(sf::Keyboard::A))
{
if (worldMap[int(posX - dirY * moveSpeed * 0.75)][int(posY)] == false) posX -= dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY + dirX * moveSpeed * 0.75)] == false) posY += dirX * moveSpeed * 0.75;
}

视角左右转

注意:\(\overrightarrow{dir}\)\(\overrightarrow{plane}\)两个向量同时都要旋转

对应代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//rotate to the right
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Right))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(-rotSpeed) - dirY * sin(-rotSpeed);
dirY = oldDirX * sin(-rotSpeed) + dirY * cos(-rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(-rotSpeed) - planeY * sin(-rotSpeed);
planeY = oldPlaneX * sin(-rotSpeed) + planeY * cos(-rotSpeed);
}
//rotate to the left
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Left))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(rotSpeed) - dirY * sin(rotSpeed);
dirY = oldDirX * sin(rotSpeed) + dirY * cos(rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(rotSpeed) - planeY * sin(rotSpeed);
planeY = oldPlaneX * sin(rotSpeed) + planeY * cos(rotSpeed);
}

至此,Move函数结束

最后的效果

源代码

无纹理,最基本Raycasting的实现,源代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
#include <SFML/Graphics.hpp>
#include <iostream>

using namespace std;
using namespace sf;

#define screenWidth 1440
#define screenHeight 900
#define mapWidth 24
#define mapHeight 24

int worldMap[mapWidth][mapWidth] =
{
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,2,2,2,2,0,0,0,0,3,0,3,0,3,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,3,0,0,0,3,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,2,0,2,2,0,0,0,0,3,0,3,0,3,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,0,0,0,5,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1}
};

double posX = 22, posY = 12; //x and y start position
double dirX = -1, dirY = 0; //initial direction vector
double planeX = 0, planeY = 0.66; //the 2d raycaster version of camera plane

double Mytime = 0; //time of current frame
double oldTime = 0; //time of previous frame

sf::Clock Myclock; //用于计时
unsigned long getTicks()
{
sf::Time elapsed = Myclock.getElapsedTime();
return elapsed.asMilliseconds();
}

//画线
void verLine(sf::RenderWindow& window, int x, int y1, int y2, const sf::Color& color)
{
// Ensure y1 is less than y2
if (y2 < y1)
{
std::swap(y1, y2);
}

// Create a sf::VertexArray for the line
sf::VertexArray line(sf::Lines, 2);

// Set the position and color of the line vertices
line[0].position = sf::Vector2f(x, y1);
line[0].color = color;
line[1].position = sf::Vector2f(x, y2);
line[1].color = color;

// Draw the line
window.draw(line);
}

void DDA_algorithm(RenderWindow& window)
{
for (int x = 0; x < screenWidth; x++)
{
//calculate ray position and direction
double cameraX = 2 * x / (double)screenWidth - 1; //x-coordinate in camera space
double rayDirX = dirX + planeX * cameraX;
double rayDirY = dirY + planeY * cameraX;
//which box of the map we're in
int mapX = int(posX);
int mapY = int(posY);

//length of ray from current position to next x or y-side
double sideDistX;
double sideDistY;

double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(1 / rayDirX);
double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(1 / rayDirY);

double perpWallDist;

//what direction to step in x or y-direction (either +1 or -1)
int stepX;
int stepY;

int hit = 0; //was there a wall hit?
int side; //was a NS or a EW wall hit?
//calculate step and initial sideDist
if (rayDirX < 0)
{
stepX = -1;
sideDistX = (posX - mapX) * deltaDistX;
}
else
{
stepX = 1;
sideDistX = (mapX + 1.0 - posX) * deltaDistX;
}
if (rayDirY < 0)
{
stepY = -1;
sideDistY = (posY - mapY) * deltaDistY;
}
else
{
stepY = 1;
sideDistY = (mapY + 1.0 - posY) * deltaDistY;
}
//perform DDA
while (hit == 0)
{
//jump to next map square, either in x-direction, or in y-direction
if (sideDistX < sideDistY)
{
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
}
else
{
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
//Check if ray has hit a wall
if (worldMap[mapX][mapY] > 0) hit = 1;
}
//教程里这部分写的比较清楚,但可以看我博客的内容,更清楚
if (side == 0) perpWallDist = (sideDistX - deltaDistX);
else perpWallDist = (sideDistY - deltaDistY);

//Calculate height of line to draw on screen
//修改墙的高度
int lineHeight = (int)(screenHeight / perpWallDist) * 3;

//calculate lowest and highest pixel to fill in current stripe
int drawStart = -lineHeight / 2 + screenHeight / 2;
if (drawStart < 0) drawStart = 0;
int drawEnd = lineHeight / 2 + screenHeight / 2;
if (drawEnd >= screenHeight) drawEnd = screenHeight - 1;

//choose wall color
Color color;
switch (worldMap[mapX][mapY])
{
case 1: color = Color::Red; break; //red
case 2: color = Color::Green; break; //green
case 3: color = Color::Blue; break; //blue
case 4: color = Color::White; break; //white
default: color = Color::Yellow; break; //yellow
}

//give x and y sides different brightness
if (side == 1)
{
color.r /= 2;
color.g /= 2;
color.b /= 2;
}

//draw the pixels of the stripe as a vertical line
verLine(window, x, drawStart, drawEnd, color);
}
}

void Move(RenderWindow& window)
{
//timing for input and FPS counter
oldTime = Mytime;
Mytime = getTicks();
double frameTime = (Mytime - oldTime) / 1000.0; //frameTime is the time this frame has taken, in seconds


//speed modifiers
double moveSpeed = frameTime * 5.0; //the constant value is in squares/second
double rotSpeed = frameTime * 3.0; //the constant value is in radians/second
//move forward if no wall in front of you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Up) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::W))
{
if (worldMap[int(posX + dirX * moveSpeed)][int(posY)] == false) posX += dirX * moveSpeed;
if (worldMap[int(posX)][int(posY + dirY * moveSpeed)] == false) posY += dirY * moveSpeed;
}
//move backwards if no wall behind you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Down) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::S))
{
if (worldMap[int(posX - dirX * moveSpeed)][int(posY)] == false) posX -= dirX * moveSpeed;
if (worldMap[int(posX)][int(posY - dirY * moveSpeed)] == false) posY -= dirY * moveSpeed;
}
//向右走,原理还是跟前后走一样,只是方向用矩阵相乘重新算了
//向右走就是在(dirY, -dirX)这个方向走
if (sf::Keyboard::isKeyPressed(sf::Keyboard::D))
{
if (worldMap[int(posX + dirY * moveSpeed * 0.75)][int(posY)] == false) posX += dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY - dirX * moveSpeed * 0.75)] == false) posY -= dirX * moveSpeed * 0.75;
}
//向左走同理
//在(-dirY, dirX)方向
if (sf::Keyboard::isKeyPressed(sf::Keyboard::A))
{
if (worldMap[int(posX - dirY * moveSpeed * 0.75)][int(posY)] == false) posX -= dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY + dirX * moveSpeed * 0.75)] == false) posY += dirX * moveSpeed * 0.75;
}
//rotate to the right
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Right))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(-rotSpeed) - dirY * sin(-rotSpeed);
dirY = oldDirX * sin(-rotSpeed) + dirY * cos(-rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(-rotSpeed) - planeY * sin(-rotSpeed);
planeY = oldPlaneX * sin(-rotSpeed) + planeY * cos(-rotSpeed);
}
//rotate to the left
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Left))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(rotSpeed) - dirY * sin(rotSpeed);
dirY = oldDirX * sin(rotSpeed) + dirY * cos(rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(rotSpeed) - planeY * sin(rotSpeed);
planeY = oldPlaneX * sin(rotSpeed) + planeY * cos(rotSpeed);
}

}

int main()
{
sf::RenderWindow window(sf::VideoMode(screenWidth, screenHeight), "Raycasting");

while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();
}

window.clear();
DDA_algorithm(window);
Move(window);
window.display();

}

return 0;
}

加纹理版

具体实现思路和最基础版本完全一样,只是略有不同

纹理部分会代替最基本实现中“颜色选择”部分

可在这里查看源代码


变量声明处多了与纹理相关的定义,顺便换一张地图,重新定义角色起始位置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#define texWidth 64
#define texHeight 64

Texture texture[8];


//修改新地图,修改角色位置
int worldMap[mapWidth][mapHeight] =
{
{4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,7,7,7,7,7,7,7},
{4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,0,0,0,0,0,0,7},
{4,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7},
{4,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7},
{4,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,7,0,0,0,0,0,0,7},
{4,0,4,0,0,0,0,5,5,5,5,5,5,5,5,5,7,7,0,7,7,7,7,7},
{4,0,5,0,0,0,0,5,0,5,0,5,0,5,0,5,7,0,0,0,7,7,7,1},
{4,0,6,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,0,0,0,8},
{4,0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,7,7,1},
{4,0,8,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,0,0,0,8},
{4,0,0,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,7,7,7,1},
{4,0,0,0,0,0,0,5,5,5,5,0,5,5,5,5,7,7,7,7,7,7,7,1},
{6,6,6,6,6,6,6,6,6,6,6,0,6,6,6,6,6,6,6,6,6,6,6,6},
{8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4},
{6,6,6,6,6,6,0,6,6,6,6,0,6,6,6,6,6,6,6,6,6,6,6,6},
{4,4,4,4,4,4,0,4,4,4,6,0,6,2,2,2,2,2,2,2,3,3,3,3},
{4,0,0,0,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,0,0,0,6,2,0,0,5,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,2,0,2,2},
{4,0,6,0,6,0,0,0,0,4,6,0,0,0,0,0,5,0,0,0,0,0,0,2},
{4,0,0,5,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,2,0,2,2},
{4,0,6,0,6,0,0,0,0,4,6,0,6,2,0,0,5,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,0,0,0,2},
{4,4,4,4,4,4,4,4,4,4,1,1,1,2,2,2,2,2,2,3,3,3,3,3}
};

double posX = 22, posY = 11.5; //x and y start position

main函数多了读取纹理的操作,把对应的纹理读取到Texture texture[8]这个纹理数组中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int main()
{
RenderWindow window(sf::VideoMode(screenWidth, screenHeight), "Raycasting-texture");

LoadTexture();

while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();
}

window.clear();
DDA_algorithm(window);
Move(window);
window.display();
}

return 0;
}

LoadTexture定义如下:

1
2
3
4
5
6
7
8
9
10
11
void LoadTexture()
{
texture[0].loadFromFile("pics/eagle.png");
texture[1].loadFromFile("pics/redbrick.png");
texture[2].loadFromFile("pics/purplestone.png");
texture[3].loadFromFile("pics/greystone.png");
texture[4].loadFromFile("pics/bluestone.png");
texture[5].loadFromFile("pics/mossy.png");
texture[6].loadFromFile("pics/wood.png");
texture[7].loadFromFile("pics/colorstone.png");
}


原教程的纹理添加过程我也试过,但是非常卡,并且图像显示还有很大问题

于是这里只记录用SFML实现纹理的方法,具体参考为YouTube视频以及对应的代码

原作者英文口语还是很奇怪的,代码也是研究了很久才看明白,不过最后全变成我的东西了


纹理添加的代码具体更新内容,还是在DDA函数里修改:

1
2
3
4
5
6
7
8
9
10
void DDA_algorithm(RenderWindow& window)
{
for (int x = 0; x < screenWidth; x++)
{
...
//此处结束DDA算法,准备纹理相关变量的声明
//设置纹理
//画出纹理
}
}

计算纹理对应坐标texX

此处紧接着DDA算法结束

1
2
//texturing calculations
int texNum = worldMap[mapX][mapY] - 1; //1 subtracted from it so that texture 0 can be used!

变量texNum是当前地图方块的值减1,原因是存在纹理0,但地图砖块0没有纹理,因为它代表一个空的空间

1
2
3
4
5
//calculate value of wallX
double wallX; //where exactly the wall was hit
if (side == 0) wallX = posY + perpWallDist * rayDirY;
else wallX = posX + perpWallDist * rayDirX;
wallX -= floor((wallX)); //减去向下取整,即只保留小数部分

wallX表示墙被击中的确切位置,而不仅仅是墙的整数坐标

具体的计算方法,用到了相似三角形的内容,需要参考“避免鱼眼效应证明”那一块的内容

具体来说,计算墙被光线击中具体位置与玩家坐标之间差值Dist的公式如下: \[ \begin{align*} \frac{|\overrightarrow{\text{dir}}|=1}{\text{perpWallDist}}&=\frac{\text{rayDirY}}{\text{yDist}}=\frac{\text{rayDirX}}{\text{xDist}}\\\\ \text{即:}\text{yDist}&=\text{perpWallDist}\cdot \text{rayDirY}\\ \text{xDist}&=\text{perpWallDist}\cdot \text{rayDirX}\\ \end{align*} \] 具体的差值xDistyDist算出来以后,要根据方向选择是在玩家坐标的基础上加还是减,但是因为rayDirXrayDirY本来就有正负号,因此刚好直接用,体现在代码里就是这段代码:

1
2
3
4
double wallX; //where exactly the wall was hit
if (side == 0) wallX = posY + perpWallDist * rayDirY;
else wallX = posX + perpWallDist * rayDirX;
wallX -= floor(wallX); //减去向下取整,即只保留小数部分

这里用的floor函数,用于将一个数向下取整,即floor()函数会返回不大于输入参数的最大整数

在C++中,将一个浮点数转换为整数(即(int)wallX)时,会直接舍去小数部分。例如,(int)3.14的结果是3(int)-3.14的结果是-3

floor()函数则会返回不大于输入参数的最大整数。例如,floor(3.14)的结果是3floor(-3.14)的结果是-4

所以,如果wallX是正数,那么wallX -= floor(wallX);wallX -= (int)wallX;的结果是一样的。但是,如果wallX是负数,那么这两种操作的结果就会不同。

wallX -= floor((wallX))会只保留小数部分,取值范围就在\([0,1)\)

1
2
//x coordinate on the texture
int texX = int(wallX * double(texWidth));

texX是纹理的x坐标,根据wallX计算出来的,是光线撞到墙,墙的位置对应到纹理上的x坐标,即用wallX算出来的小数乘以texWidth,可以得到纹理上确切的x坐标

镜像处理

默认texX是纹理图像从左向右x坐标

但是有两种情况计算出来的结果是从右向左的,会导致镜像翻转

如果不进行镜像处理,就会出现这样的情况:

这两个面看过去,鹰的头向左偏

从这两个面看过去,鹰的头向右偏

而原图的鹰头就是偏向右的

因此需要进行镜像处理

1
2
3
// 防止镜像
if (side == 0 && rayDirX > 0) texX = texWidth - texX - 1;
if (side == 1 && rayDirY < 0) texX = texWidth - texX - 1;

纹理的Sprite

这部分的具体思路就与原教程不一样了

我的思路还是与画线一样,即画出纹理对应的一条线,宽为1像素,长为对应线的长度,只是颜色是直接拿纹理的颜色

具体实现,就是创建长为texHeight,宽为1像素的Sprite,对其设置对应纹理

1
2
3
4
5
6
7
8
9
10
//为了获取纹理具体某一个坐标的像素颜色
//创建一个新的sprite对象
sf::Sprite wall_sprite;

//设置sprite的纹理
wall_sprite.setTexture(texture[texNum]);

//设置sprite的纹理矩形,定义了在纹理中的哪个部分应用到wall_sprite上
//宽度为1个像素,相当于原来的画线
wall_sprite.setTextureRect(sf::IntRect(texX, 0, 1, texHeight));


再指定左上角位置为(x, round(0.5f * screenHeight - 0.5f * lineHeight)

至于Y起点指定的位置,为什么不是刚刚画线时的drawStart,我在后面会详细解释这个问题,还是挺有意思的一个小bug

1
2
//设置sprite的位置,坐标指定了左上角的位置
wall_sprite.setPosition(x, round(0.5f * screenHeight - 0.5f * lineHeight));


长度值是texHeight,而我们应该画线的长度是lineHeight,所以对这个Sprite进行缩放

1
2
//设置sprite的缩放因子
wall_sprite.setScale(1, lineHeight / (float)texHeight);


最后还是和之前一样的思路,Y墙上的颜色要变暗,就进行处理

SFML纹理颜色的处理需要用“调制混合”的概念

举个例子:原来的R通道为200,与128混合为:200 * 128 / 255 = 100,实现了颜色除以2的效果

1
2
3
4
5
6
7
//利用SFML中sprite的setColor功能,使y墙变暗
//这个函数实现的是“调制混合”
//举个例子:原来的R通道为200,与128混合为:200 * 128 / 255 = 100,实现了颜色除以2的效果
if (side == 1)
{
wall_sprite.setColor(Color(128, 128, 128));
}

绘制纹理Sprite

这个就简单了,因为纹理Sprite我们已经设置位置和大小,直接画出来就行了

1
2
// 绘制sprite
window.draw(wall_sprite);

最后的效果

源代码

带纹理Raycasting的实现,源代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
#include <SFML/Graphics.hpp>
#include <iostream>
#include <string>
#include <vector>
#include <cmath>

using namespace std;
using namespace sf;

#define screenWidth 1440
#define screenHeight 900
#define mapWidth 24
#define mapHeight 24
#define texWidth 64
#define texHeight 64

int worldMap[mapWidth][mapHeight] =
{
{4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,7,7,7,7,7,7,7},
{4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,0,0,0,0,0,0,7},
{4,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7},
{4,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7},
{4,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,7,0,0,0,0,0,0,7},
{4,0,4,0,0,0,0,5,5,5,5,5,5,5,5,5,7,7,0,7,7,7,7,7},
{4,0,5,0,0,0,0,5,0,5,0,5,0,5,0,5,7,0,0,0,7,7,7,1},
{4,0,6,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,0,0,0,8},
{4,0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,7,7,1},
{4,0,8,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,0,0,0,8},
{4,0,0,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,7,7,7,1},
{4,0,0,0,0,0,0,5,5,5,5,0,5,5,5,5,7,7,7,7,7,7,7,1},
{6,6,6,6,6,6,6,6,6,6,6,0,6,6,6,6,6,6,6,6,6,6,6,6},
{8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4},
{6,6,6,6,6,6,0,6,6,6,6,0,6,6,6,6,6,6,6,6,6,6,6,6},
{4,4,4,4,4,4,0,4,4,4,6,0,6,2,2,2,2,2,2,2,3,3,3,3},
{4,0,0,0,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,0,0,0,6,2,0,0,5,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,2,0,2,2},
{4,0,6,0,6,0,0,0,0,4,6,0,0,0,0,0,5,0,0,0,0,0,0,2},
{4,0,0,5,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,2,0,2,2},
{4,0,6,0,6,0,0,0,0,4,6,0,6,2,0,0,5,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,0,0,0,2},
{4,4,4,4,4,4,4,4,4,4,1,1,1,2,2,2,2,2,2,3,3,3,3,3}
};

double posX = 22, posY = 11.5; //x and y start position
double dirX = -1, dirY = 0; //initial direction vector
double planeX = 0, planeY = 0.66; //the 2d raycaster version of camera plane

double Mytime = 0; //time of current frame
double oldTime = 0; //time of previous frame

Texture texture[8];
Clock Myclock; //用于计时

//获取时间
unsigned long getTicks();
//读取纹理
void LoadTexture();
//DDA算法,用于计算墙的高度和画线
void DDA_algorithm(RenderWindow& window);
//移动函数,用于改变位置和朝向
void Move(RenderWindow& window);

int main()
{
RenderWindow window(sf::VideoMode(screenWidth, screenHeight), "Raycasting-texture");

LoadTexture();

while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();
}

window.clear();
DDA_algorithm(window);
Move(window);
window.display();
}

return 0;
}

unsigned long getTicks()
{
sf::Time elapsed = Myclock.getElapsedTime();
return elapsed.asMilliseconds();
}

void LoadTexture()
{
texture[0].loadFromFile("pics/eagle.png");
texture[1].loadFromFile("pics/redbrick.png");
texture[2].loadFromFile("pics/purplestone.png");
texture[3].loadFromFile("pics/greystone.png");
texture[4].loadFromFile("pics/bluestone.png");
texture[5].loadFromFile("pics/mossy.png");
texture[6].loadFromFile("pics/wood.png");
texture[7].loadFromFile("pics/colorstone.png");
}

void DDA_algorithm(RenderWindow& window)
{
for (int x = 0; x < screenWidth; x++)
{
//calculate ray position and direction
double cameraX = 2 * x / (double)screenWidth - 1; //x-coordinate in camera space
double rayDirX = dirX + planeX * cameraX;
double rayDirY = dirY + planeY * cameraX;
//which box of the map we're in
int mapX = int(posX);
int mapY = int(posY);

//length of ray from current position to next x or y-side
double sideDistX;
double sideDistY;

double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(1 / rayDirX);
double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(1 / rayDirY);

double perpWallDist;

//what direction to step in x or y-direction (either +1 or -1)
int stepX;
int stepY;

int hit = 0; //was there a wall hit?
int side; //was a NS or a EW wall hit?
//calculate step and initial sideDist
if (rayDirX < 0)
{
stepX = -1;
sideDistX = (posX - mapX) * deltaDistX;
}
else
{
stepX = 1;
sideDistX = (mapX + 1.0 - posX) * deltaDistX;
}
if (rayDirY < 0)
{
stepY = -1;
sideDistY = (posY - mapY) * deltaDistY;
}
else
{
stepY = 1;
sideDistY = (mapY + 1.0 - posY) * deltaDistY;
}
//perform DDA
while (hit == 0)
{
//jump to next map square, either in x-direction, or in y-direction
if (sideDistX < sideDistY)
{
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
}
else
{
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
//Check if ray has hit a wall
if (worldMap[mapX][mapY] > 0) hit = 1;
}
//教程里这部分解释的比较清楚了
//也可以看我在博客里的证明过程,写的更清楚
if (side == 0) perpWallDist = (sideDistX - deltaDistX);
else perpWallDist = (sideDistY - deltaDistY);

//Calculate height of line to draw on screen
//可以任意修改墙的高度
int lineHeight = (int)(screenHeight / perpWallDist);

//calculate lowest and highest pixel to fill in current stripe
int drawStart = -lineHeight / 2 + screenHeight / 2;
if (drawStart < 0) drawStart = 0;
int drawEnd = lineHeight / 2 + screenHeight / 2;
if (drawEnd >= screenHeight) drawEnd = screenHeight - 1;


/*********************************************************************************/
/* 此部分替换了原raycasting画线部分
/* 以下为纹理相关计算,是新内容
/* 具体和原来的画线函数差不多,都是在具体位置上画出一条线
/* 只不过这里画的是具体纹理上的一条线,利用了SFML中的sprite
/*********************************************************************************/


//texturing calculations
int texNum = worldMap[mapX][mapY] - 1; //1 subtracted from it so that texture 0 can be used!

//calculate value of wallX
double wallX; //where exactly the wall was hit
if (side == 0) wallX = posY + perpWallDist * rayDirY;
else wallX = posX + perpWallDist * rayDirX;
wallX -= floor((wallX));

//x coordinate on the texture
int texX = int(wallX * double(texWidth));
if (side == 0 && rayDirX > 0) texX = texWidth - texX - 1;
if (side == 1 && rayDirY < 0) texX = texWidth - texX - 1;

// How much to increase the texture coordinate per screen pixel
double step = 1.0 * texHeight / lineHeight;
// Starting texture coordinate
double texPos = (drawStart - screenHeight / 2 + lineHeight / 2) * step;

//为了获取纹理具体某一个坐标的像素颜色
//创建一个新的sprite对象
sf::Sprite wall_sprite;

//设置sprite的纹理
wall_sprite.setTexture(texture[texNum]);

//设置sprite的纹理矩形,定义了在纹理中的哪个部分应用到wall_sprite上
//宽度为1个像素,相当于原来的画线
wall_sprite.setTextureRect(sf::IntRect(texX, 0, 1, texHeight));

//设置sprite的位置,坐标指定了左上角的位置
//wall_sprite.setPosition(x, drawStart); //太几把恶心了,破案了:指定左上角的位置为drawStart
// //drawStart在特殊情况下会被赋值为0,即if (drawStart < 0) drawStart = 0;
// //在此情况下绘制的起点就会改变为0,令人恶心
wall_sprite.setPosition(x, round(0.5f * screenHeight - 0.5f * lineHeight));

//设置sprite的缩放因子
wall_sprite.setScale(1, lineHeight / (float)texHeight);


//利用SFML中sprite的setColor功能,使y墙变暗
//这个函数实现的是“调制混合”
//举个例子:原来的R通道为200,与128混合为:200 * 128 / 255 = 100,实现了颜色除以2的效果
if (side == 1)
{
wall_sprite.setColor(Color(128, 128, 128));
}
// 绘制sprite
window.draw(wall_sprite);

}
}

void Move(RenderWindow& window)
{
//timing for input and FPS counter
oldTime = Mytime;
Mytime = getTicks();
double frameTime = (Mytime - oldTime) / 1000.0; //frameTime is the time this frame has taken, in seconds


//speed modifiers
double moveSpeed = frameTime * 3.0; //the constant value is in squares/second
double rotSpeed = frameTime * 2.0; //the constant value is in radians/second
//move forward if no wall in front of you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Up) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::W))
{
if (worldMap[int(posX + dirX * moveSpeed)][int(posY)] == false) posX += dirX * moveSpeed;
if (worldMap[int(posX)][int(posY + dirY * moveSpeed)] == false) posY += dirY * moveSpeed;
}
//move backwards if no wall behind you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Down) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::S))
{
if (worldMap[int(posX - dirX * moveSpeed)][int(posY)] == false) posX -= dirX * moveSpeed;
if (worldMap[int(posX)][int(posY - dirY * moveSpeed)] == false) posY -= dirY * moveSpeed;
}
//向右走,原理还是跟前后走一样,只是方向用矩阵相乘重新算了
//向右走就是在(dirY, -dirX)这个方向走
if (sf::Keyboard::isKeyPressed(sf::Keyboard::D))
{
if (worldMap[int(posX + dirY * moveSpeed * 0.75)][int(posY)] == false) posX += dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY - dirX * moveSpeed * 0.75)] == false) posY -= dirX * moveSpeed * 0.75;
}
//向左走同理
//在(-dirY, dirX)方向
if (sf::Keyboard::isKeyPressed(sf::Keyboard::A))
{
if (worldMap[int(posX - dirY * moveSpeed * 0.75)][int(posY)] == false) posX -= dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY + dirX * moveSpeed * 0.75)] == false) posY += dirX * moveSpeed * 0.75;
}
//rotate to the right
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Right))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(-rotSpeed) - dirY * sin(-rotSpeed);
dirY = oldDirX * sin(-rotSpeed) + dirY * cos(-rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(-rotSpeed) - planeY * sin(-rotSpeed);
planeY = oldPlaneX * sin(-rotSpeed) + planeY * cos(-rotSpeed);
}
//rotate to the left
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Left))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(rotSpeed) - dirY * sin(rotSpeed);
dirY = oldDirX * sin(rotSpeed) + dirY * cos(rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(rotSpeed) - planeY * sin(rotSpeed);
planeY = oldPlaneX * sin(rotSpeed) + planeY * cos(rotSpeed);
}
}

加点东西

本来是准备结束的,但是在实际演示的前一天,在我给同学展示的时候,有个同学就提出来了:这样看不出地图会迷路,是不是加个小地图会更好?

我一想,太对了,当天直接完成了小地图的部分

我再一想,既然小地图都做了,为什么不把角色视野范围做出了?

太对了,当天直接熬夜完成了视野范围的部分

本来想给地板和天花板都上个纹理的,但是因为实际做了之后帧数特别低,甚至无法跑到60帧,就搁置了,以后有时间再进行优化,最后就是只给地板和天花板弄了个纯色

新的main函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
int main()
{
RenderWindow window(sf::VideoMode(screenWidth, screenHeight), "Raycasting-texture");

LoadTexture();

while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();
//检测Tab键或~键是否被按下
//sf::Keyboard::isKeyPressed函数会在每一帧都检测键盘的状态
//sf::Event::KeyPressed事件只会在按键被按下的那一帧触发一次,即使你按住按键不放,它也不会在下一帧再次触发
//因此,使用sf::Event::KeyPressed事件可以确保DrawminimapFlag的值只会被反转一次
if (event.type == sf::Event::KeyPressed)
{
if (event.key.code == sf::Keyboard::Tab || event.key.code == sf::Keyboard::Tilde)
{
DrawminimapFlag = !DrawminimapFlag;
}
}
if (event.type == sf::Event::KeyPressed)
{
//按加号键增大小地图
if (event.key.code == sf::Keyboard::Add)
{
miniMapScale++;
}
//按减号键缩小小地图
if (event.key.code == sf::Keyboard::Subtract)
{
miniMapScale--;
}
}
}

window.clear();

DrawFloorCeiling(window);
DDA_algorithm(window);
Move(window);
if (DrawminimapFlag)
{
drawMiniMap(window);
}

window.display();
}
return 0;
}

加了对应画天花板地板的函数DrawFloorCeiling和画小地图的函数drawMiniMap

其中,按Tab键可以开关小地图,实际上加一个标志位,每次按键检测设置flag即可,很容易

加地板天花板

这个还是很容易的,只需要显示两个有色矩形,一个为天花板,一个为地板

在DDA画线之前画上天花板和地板就行了

1
2
3
4
5
6
7
8
9
10
11
12
void DrawFloorCeiling(RenderWindow& window)
{
RectangleShape CeilingRect(Vector2f(screenWidth, screenHeight / 2));
CeilingRect.setFillColor(Color(156,220,235));

RectangleShape FloorRect(Vector2f(screenWidth, screenHeight / 2));
FloorRect.setPosition(0, screenHeight / 2);
FloorRect.setFillColor(Color(68,68,68));

window.draw(CeilingRect);
window.draw(FloorRect);
}

加小地图和视野范围

这个还是挺有趣的

先在全局变量添加小地图参数和FOV视野变量

1
2
3
4
5
6
7
8
9
10
11
//创建一个表示视野范围的三角扇形
//还是程序设计的结构问题,应该给玩家安排个类,这个当做类的成员的
//而且中间参数还在DDA算法中计算,实际上不应该这样的
VertexArray fov(sf::TriangleFan, screenWidth + 1);

//小地图的宽度和高度
const int minimapWidth = 24;
const int minimapHeight = 24;
//小地图的大小(单位:像素)
int miniMapScale = 20;
bool DrawminimapFlag = 0;

如果把角色相关的东西都定义在一个类里,修改就不会很头疼了

但是因为这些代码是在原来基础上添加的,就不想重新构建了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
//要注意:SFML中的y轴是向下的,而用二维数组计算时y轴是向上的
//需将游戏中的坐标系转换为小地图的坐标系,具体来说,就是将y坐标进行翻转
void drawMiniMap(sf::RenderWindow& window)
{
// 遍历地图的每一个格子
for (int x = 0; x < minimapWidth; ++x)
{
for (int y = 0; y < minimapHeight; ++y)
{
// 创建一个表示地图格子的矩形
sf::RectangleShape rect(sf::Vector2f(miniMapScale, miniMapScale));

//设置位置,注意翻转
rect.setPosition(x * miniMapScale, (mapHeight - y - 1) * miniMapScale);

// 根据地图数据设置矩形的颜色
if (worldMap[x][y] > 0) {
rect.setFillColor(sf::Color::White);
}
else {
rect.setFillColor(sf::Color::Black);
}

// 在窗口上绘制矩形
window.draw(rect);
}
}

// 创建一个表示玩家位置的圆形
int radius = 4;
sf::CircleShape player(radius);
player.setFillColor(sf::Color::Red);
player.setPosition(posX * miniMapScale - player.getRadius(), (mapHeight - posY) * miniMapScale - player.getRadius());

// 在窗口上绘制圆形
window.draw(player);

// 设置三角扇形的原点为玩家的位置
//FOV具体计算的参数在DDA算法里,我觉得这个结构很垃圾,应该在全局变量加个数组,在DDA算法中访问并修改这个参数
//但是好像没啥时间给我改代码了,能跑就是胜利
fov[0].position = sf::Vector2f(posX * miniMapScale, (mapHeight - posY) * miniMapScale);
fov[0].color = sf::Color::Transparent;

// 在窗口上绘制三角扇形
window.draw(fov);
}

对应扇形参数的设置我添加到DDA算法中了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void DDA_algorithm(RenderWindow& window)
{
for (int x = 0; x < screenWidth; x++)
{
...
//此处结束了DDA算法
/*********************************************************************************/
/* 此部分为添加FOV用
/* 计算射线的终点
/*********************************************************************************/
double rayEndX = posX + perpWallDist * rayDirX;
double rayEndY = posY + perpWallDist * rayDirY;

// 设置三角扇形的顶点
fov[x + 1].position = sf::Vector2f(rayEndX * miniMapScale, (mapHeight - rayEndY) * miniMapScale);
fov[x + 1].color = sf::Color(255, 255, 255, 150);
...
}
}


其他内容都没修改

最后的效果

源代码

带纹理,画天花板地板,画小地图FOV视野Raycasting的实现,源代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431

#include <SFML/Graphics.hpp>
#include <iostream>
#include <string>
#include <vector>
#include <cmath>

using namespace std;
using namespace sf;

#define screenWidth 1440
#define screenHeight 900
#define mapWidth 24
#define mapHeight 24
#define texWidth 64
#define texHeight 64

int worldMap[mapWidth][mapHeight] =
{
{4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,7,7,7,7,7,7,7,7},
{4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,0,0,0,0,0,0,7},
{4,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7},
{4,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7},
{4,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,7,0,0,0,0,0,0,7},
{4,0,4,0,0,0,0,5,5,5,5,5,5,5,5,5,7,7,0,7,7,7,7,7},
{4,0,5,0,0,0,0,5,0,5,0,5,0,5,0,5,7,0,0,0,7,7,7,1},
{4,0,6,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,0,0,0,8},
{4,0,7,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,7,7,1},
{4,0,8,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,0,0,0,8},
{4,0,0,0,0,0,0,5,0,0,0,0,0,0,0,5,7,0,0,0,7,7,7,1},
{4,0,0,0,0,0,0,5,5,5,5,0,5,5,5,5,7,7,7,7,7,7,7,1},
{6,6,6,6,6,6,6,6,6,6,6,0,6,6,6,6,6,6,6,6,6,6,6,6},
{8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4},
{6,6,6,6,6,6,0,6,6,6,6,0,6,6,6,6,6,6,6,6,6,6,6,6},
{4,4,4,4,4,4,0,4,4,4,6,0,6,2,2,2,2,2,2,2,3,3,3,3},
{4,0,0,0,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,0,0,0,6,2,0,0,5,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,4,6,0,6,2,0,0,0,0,0,2,2,0,2,2},
{4,0,6,0,6,0,0,0,0,4,6,0,0,0,0,0,5,0,0,0,0,0,0,2},
{4,0,0,5,0,0,0,0,0,4,4,0,3,2,0,0,0,0,0,2,2,0,2,2},
{4,0,6,0,6,0,0,0,0,4,2,0,7,2,0,0,5,0,0,2,0,0,0,2},
{4,0,0,0,0,0,0,0,0,4,5,0,6,2,0,0,0,0,0,2,0,0,0,2},
{4,4,4,4,4,4,4,4,4,4,1,1,1,2,2,2,2,2,2,3,3,3,3,3}
};

//程序必要的参数
double posX = 22, posY = 11.5; //x and y start position
double dirX = -1, dirY = 0; //initial direction vector
double planeX = 0, planeY = 0.66; //the 2d raycaster version of camera plane

double Mytime = 0; //time of current frame
double oldTime = 0; //time of previous frame

//创建一个表示视野范围的三角扇形
//还是程序设计的结构问题,应该给玩家安排个类,这个当做类的成员的
//而且中间参数还在DDA算法中计算,实际上不应该这样的
VertexArray fov(sf::TriangleFan, screenWidth + 1);
//墙面纹理数组
Texture texture[8];
//用于计时
Clock Myclock;

//小地图的宽度和高度
const int minimapWidth = 24;
const int minimapHeight = 24;
//小地图的大小(单位:像素)
int miniMapScale = 20;
bool DrawminimapFlag = 0;

//获取时间
unsigned long getTicks();
//读取纹理
void LoadTexture();
//画天花板和地板
void DrawFloorCeiling(RenderWindow& window)
{
RectangleShape CeilingRect(Vector2f(screenWidth, screenHeight / 2));
CeilingRect.setFillColor(Color(156,220,235));

RectangleShape FloorRect(Vector2f(screenWidth, screenHeight / 2));
FloorRect.setPosition(0, screenHeight / 2);
FloorRect.setFillColor(Color(68,68,68));

window.draw(CeilingRect);
window.draw(FloorRect);
}
//整的新活:用SFML的VertexArray来可视化FOV
void drawMiniMap(sf::RenderWindow& window);
//DDA算法,用于计算墙的高度和画线
void DDA_algorithm(RenderWindow& window);
//移动函数,用于改变位置和朝向
void Move(RenderWindow& window);


int main()
{
RenderWindow window(sf::VideoMode(screenWidth, screenHeight), "Raycasting-texture");

LoadTexture();

while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();
//检测Tab键或~键是否被按下
//sf::Keyboard::isKeyPressed函数会在每一帧都检测键盘的状态
//sf::Event::KeyPressed事件只会在按键被按下的那一帧触发一次,即使你按住按键不放,它也不会在下一帧再次触发
//因此,使用sf::Event::KeyPressed事件可以确保DrawminimapFlag的值只会被反转一次
if (event.type == sf::Event::KeyPressed)
{
if (event.key.code == sf::Keyboard::Tab || event.key.code == sf::Keyboard::Tilde)
{
DrawminimapFlag = !DrawminimapFlag;
}
}
if (event.type == sf::Event::KeyPressed)
{
//按加号键增大小地图
if (event.key.code == sf::Keyboard::Add)
{
miniMapScale++;
}
//按减号键缩小小地图
if (event.key.code == sf::Keyboard::Subtract)
{
miniMapScale--;
}
}
}

window.clear();

DrawFloorCeiling(window);
DDA_algorithm(window);
Move(window);
if (DrawminimapFlag)
{
drawMiniMap(window);
}

window.display();
}

return 0;
}

unsigned long getTicks()
{
sf::Time elapsed = Myclock.getElapsedTime();
return elapsed.asMilliseconds();
}

void LoadTexture()
{
texture[0].loadFromFile("pics/eagle.png");
texture[1].loadFromFile("pics/redbrick.png");
texture[2].loadFromFile("pics/purplestone.png");
texture[3].loadFromFile("pics/greystone.png");
texture[4].loadFromFile("pics/bluestone.png");
texture[5].loadFromFile("pics/mossy.png");
texture[6].loadFromFile("pics/wood.png");
texture[7].loadFromFile("pics/colorstone.png");
}

//要注意:SFML中的y轴是向下的,而用二维数组计算时y轴是向上的
//需将游戏中的坐标系转换为小地图的坐标系,具体来说,就是将y坐标进行翻转
void drawMiniMap(sf::RenderWindow& window)
{
// 遍历地图的每一个格子
for (int x = 0; x < minimapWidth; ++x)
{
for (int y = 0; y < minimapHeight; ++y)
{
// 创建一个表示地图格子的矩形
sf::RectangleShape rect(sf::Vector2f(miniMapScale, miniMapScale));
//rect.setPosition(x * miniMapScale, y * miniMapScale);
rect.setPosition(x * miniMapScale, (mapHeight - y - 1) * miniMapScale);

// 根据地图数据设置矩形的颜色
if (worldMap[x][y] > 0) {
rect.setFillColor(sf::Color::White);
}
else {
rect.setFillColor(sf::Color::Black);
}

// 在窗口上绘制矩形
window.draw(rect);
}
}

// 创建一个表示玩家位置的圆形
int radius = 4;
sf::CircleShape player(radius);
player.setFillColor(sf::Color::Red);
//player.setPosition(posX * miniMapScale - player.getRadius(), posY * miniMapScale - player.getRadius());
player.setPosition(posX * miniMapScale - player.getRadius(), (mapHeight - posY) * miniMapScale - player.getRadius());

// 在窗口上绘制圆形
window.draw(player);

// 设置三角扇形的原点为玩家的位置
//FOV具体计算的参数在DDA算法里,我觉得这个结构很垃圾,应该在全局变量加个数组,在DDA算法中访问并修改这个参数
//但是好像没啥时间给我改代码了,能跑就是胜利
fov[0].position = sf::Vector2f(posX * miniMapScale, (mapHeight - posY) * miniMapScale);
fov[0].color = sf::Color::Transparent;

// 在窗口上绘制三角扇形
window.draw(fov);
}

void DDA_algorithm(RenderWindow& window)
{
for (int x = 0; x < screenWidth; x++)
{
//calculate ray position and direction
double cameraX = 2 * x / (double)screenWidth - 1; //x-coordinate in camera space
double rayDirX = dirX + planeX * cameraX;
double rayDirY = dirY + planeY * cameraX;
//which box of the map we're in
int mapX = int(posX);
int mapY = int(posY);

//length of ray from current position to next x or y-side
double sideDistX;
double sideDistY;

double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(1 / rayDirX);
double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(1 / rayDirY);

double perpWallDist;

//what direction to step in x or y-direction (either +1 or -1)
int stepX;
int stepY;

int hit = 0; //was there a wall hit?
int side; //was a NS or a EW wall hit?
//calculate step and initial sideDist
if (rayDirX < 0)
{
stepX = -1;
sideDistX = (posX - mapX) * deltaDistX;
}
else
{
stepX = 1;
sideDistX = (mapX + 1.0 - posX) * deltaDistX;
}
if (rayDirY < 0)
{
stepY = -1;
sideDistY = (posY - mapY) * deltaDistY;
}
else
{
stepY = 1;
sideDistY = (mapY + 1.0 - posY) * deltaDistY;
}
//perform DDA
while (hit == 0)
{
//jump to next map square, either in x-direction, or in y-direction
if (sideDistX < sideDistY)
{
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
}
else
{
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
//Check if ray has hit a wall
if (worldMap[mapX][mapY] > 0) hit = 1;
}
//教程里这部分解释的比较清楚了
//也可以看我在博客里的证明过程,写的更清楚
if (side == 0) perpWallDist = (sideDistX - deltaDistX);
else perpWallDist = (sideDistY - deltaDistY);

/*********************************************************************************/
/* 此部分为添加FOV用
/* 计算射线的终点
/*********************************************************************************/
double rayEndX = posX + perpWallDist * rayDirX;
double rayEndY = posY + perpWallDist * rayDirY;

// 设置三角扇形的顶点
fov[x + 1].position = sf::Vector2f(rayEndX * miniMapScale, (mapHeight - rayEndY) * miniMapScale);
fov[x + 1].color = sf::Color(255, 255, 255, 150);

//Calculate height of line to draw on screen
//可以任意修改墙的高度
int lineHeight = (int)(screenHeight / perpWallDist);

//calculate lowest and highest pixel to fill in current stripe
int drawStart = -lineHeight / 2 + screenHeight / 2;
if (drawStart < 0) drawStart = 0;
int drawEnd = lineHeight / 2 + screenHeight / 2;
if (drawEnd >= screenHeight) drawEnd = screenHeight - 1;


/*********************************************************************************/
/* 此部分替换了原raycasting画线部分
/* 以下为纹理相关计算,是新内容
/* 具体和原来的画线函数差不多,都是在具体位置上画出一条线
/* 只不过这里画的是具体纹理上的一条线,利用了SFML中的sprite
/*********************************************************************************/


//texturing calculations
int texNum = worldMap[mapX][mapY] - 1; //1 subtracted from it so that texture 0 can be used!

//calculate value of wallX
double wallX; //where exactly the wall was hit
if (side == 0) wallX = posY + perpWallDist * rayDirY;
else wallX = posX + perpWallDist * rayDirX;
wallX -= floor((wallX));

//x coordinate on the texture
int texX = int(wallX * double(texWidth));
if (side == 0 && rayDirX > 0) texX = texWidth - texX - 1;
if (side == 1 && rayDirY < 0) texX = texWidth - texX - 1;

// How much to increase the texture coordinate per screen pixel
double step = 1.0 * texHeight / lineHeight;
// Starting texture coordinate
double texPos = (drawStart - screenHeight / 2 + lineHeight / 2) * step;

//为了获取纹理具体某一个坐标的像素颜色
//创建一个新的sprite对象
sf::Sprite wall_sprite;

//设置sprite的纹理
wall_sprite.setTexture(texture[texNum]);

//设置sprite的纹理矩形,定义了在纹理中的哪个部分应用到wall_sprite上
//宽度为1个像素,相当于原来的画线
wall_sprite.setTextureRect(sf::IntRect(texX, 0, 1, texHeight));

//设置sprite的位置,坐标指定了左上角的位置
//wall_sprite.setPosition(x, drawStart); //太几把恶心了,破案了:指定左上角的位置为drawStart
// //drawStart在特殊情况下会被赋值为0,即if (drawStart < 0) drawStart = 0;
// //在此情况下绘制的起点就会改变为0,令人恶心
wall_sprite.setPosition(x, round(0.5f * screenHeight - 0.5f * lineHeight));

//设置sprite的缩放因子
wall_sprite.setScale(1, lineHeight / (float)texHeight);


//利用SFML中sprite的setColor功能,使y墙变暗
//这个函数实现的是“调制混合”
//举个例子:原来的R通道为200,与128混合为:200 * 128 / 255 = 100,实现了颜色除以2的效果
if (side == 1)
{
wall_sprite.setColor(Color(128, 128, 128));
}
// 绘制sprite
window.draw(wall_sprite);

}
}

void Move(RenderWindow& window)
{
//timing for input and FPS counter
oldTime = Mytime;
Mytime = getTicks();
double frameTime = (Mytime - oldTime) / 1000.0; //frameTime is the time this frame has taken, in seconds


//speed modifiers
double moveSpeed = frameTime * 3.0; //the constant value is in squares/second
double rotSpeed = frameTime * 2.0; //the constant value is in radians/second
//move forward if no wall in front of you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Up) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::W))
{
if (worldMap[int(posX + dirX * moveSpeed)][int(posY)] == false) posX += dirX * moveSpeed;
if (worldMap[int(posX)][int(posY + dirY * moveSpeed)] == false) posY += dirY * moveSpeed;
}
//move backwards if no wall behind you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Down) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::S))
{
if (worldMap[int(posX - dirX * moveSpeed)][int(posY)] == false) posX -= dirX * moveSpeed;
if (worldMap[int(posX)][int(posY - dirY * moveSpeed)] == false) posY -= dirY * moveSpeed;
}
//向右走,原理还是跟前后走一样,只是方向用矩阵相乘重新算了
//向右走就是在(dirY, -dirX)这个方向走
if (sf::Keyboard::isKeyPressed(sf::Keyboard::D))
{
if (worldMap[int(posX + dirY * moveSpeed * 0.75)][int(posY)] == false) posX += dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY - dirX * moveSpeed * 0.75)] == false) posY -= dirX * moveSpeed * 0.75;
}
//向左走同理
//在(-dirY, dirX)方向
if (sf::Keyboard::isKeyPressed(sf::Keyboard::A))
{
if (worldMap[int(posX - dirY * moveSpeed * 0.75)][int(posY)] == false) posX -= dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY + dirX * moveSpeed * 0.75)] == false) posY += dirX * moveSpeed * 0.75;
}
//rotate to the right
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Right))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(-rotSpeed) - dirY * sin(-rotSpeed);
dirY = oldDirX * sin(-rotSpeed) + dirY * cos(-rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(-rotSpeed) - planeY * sin(-rotSpeed);
planeY = oldPlaneX * sin(-rotSpeed) + planeY * cos(-rotSpeed);
}
//rotate to the left
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Left))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(rotSpeed) - dirY * sin(rotSpeed);
dirY = oldDirX * sin(rotSpeed) + dirY * cos(rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(rotSpeed) - planeY * sin(rotSpeed);
planeY = oldPlaneX * sin(rotSpeed) + planeY * cos(rotSpeed);
}
}

展示的Demonstration

展示的逻辑

就是用按键控制一系列显示,我感觉当时展示还挺成功的

唯一的遗憾是没有实时演示这个程序,而是录的视频,因为教室的电脑没装VisualStudio的运行环境

具体演示过程如下:

先只显示小地图,角色可以在小地图上自由行走,为了突出展示的主题“给二维数组一点小小的伪3D震撼”

再显示人物视野范围,按Shift开启

再缩小小地图,按<键或减号键缩小

再显示伪3D效果,按回车开启

再显示地板天花板,按B开启

再显示纹理,按T开启


按照这个顺序演示,确实感觉太牛逼了,层层递进,一波一波的震撼,尤其是逐渐缩小小地图,按回车无缝衔接,显示Raycasting的结果,真正做到了给二维数组一点小小的伪3D震撼


对应修改

添加对应的Flag标志:

1
2
3
4
5
6
7
8
9
10
//是否画伪3D效果                        回车键
bool DrawFlag = 0;
//是否画小地图,默认画而且很大 Tab键或~键
bool DrawminimapFlag = 1;
//是否画小地图上的FOV shift
bool DrawFOV = 0;
//是否加纹理 T
bool TextureFlag = 0;
//是否画天花板地板 B
bool DrawFloorCeilingFlag = 0;

main函数修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
int main()
{
RenderWindow window(sf::VideoMode(screenWidth, screenHeight), "Raycasting-Demonstration");


LoadTexture();

while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();

//sf::Keyboard::isKeyPressed函数会在每一帧都检测键盘的状态
//sf::Event::KeyPressed事件只会在按键被按下的那一帧触发一次,即使你按住按键不放,它也不会在下一帧再次触发
//因此,使用sf::Event::KeyPressed事件可以确保DrawminimapFlag的值只会被反转一次
if (event.type == sf::Event::KeyPressed)
{
//按Tab键或~键开关小地图
if (event.key.code == sf::Keyboard::Tab || event.key.code == sf::Keyboard::Tilde)
{
DrawminimapFlag = !DrawminimapFlag;
}
//按加号键或>键增大小地图
if (event.key.code == sf::Keyboard::Add || event.key.code == sf::Keyboard::Period)
{
miniMapScale++;
}
//按减号键或<键缩小小地图
if (event.key.code == sf::Keyboard::Subtract || event.key.code == sf::Keyboard::Comma)
{
miniMapScale--;
}
//按shift键画FOV
if (event.key.code == sf::Keyboard::LShift)
{
DrawFOV = !DrawFOV;
}
//按T是否纹理
if (event.key.code == sf::Keyboard::T)
{
TextureFlag = !TextureFlag;
}
//按B是否画天花板
if (event.key.code == sf::Keyboard::B)
{
DrawFloorCeilingFlag = !DrawFloorCeilingFlag;
}
//按回车是否伪3D效果
if (event.key.code == sf::Keyboard::Enter)
{
DrawFlag = 1;
}
}

}

window.clear();
if (DrawFloorCeilingFlag)
{
DrawFloorCeiling(window);
}

DDA_algorithm(window);
Move(window);

if (DrawminimapFlag)
{
drawMiniMap(window);
}

window.display();
}
return 0;
}

这里实际上还涉及到SFML的一个小问题:按键检测

在移动逻辑中,我们用的逻辑是sf::Keyboard::isKeyPressed(sf::Keyboard::W)

而在改变flag时,我们用的逻辑是event事件的sf::Event::KeyPressed

具体为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
//sf::Keyboard::isKeyPressed函数会在每一帧都检测键盘的状态
//sf::Event::KeyPressed事件只会在按键被按下的那一帧触发一次,即使你按住按键不放,它也不会在下一帧再次触发
//因此,使用sf::Event::KeyPressed事件可以确保DrawminimapFlag的值只会被反转一次
if (event.type == sf::Event::KeyPressed)
{
//按Tab键或~键开关小地图
if (event.key.code == sf::Keyboard::Tab || event.key.code == sf::Keyboard::Tilde)
{
DrawminimapFlag = !DrawminimapFlag;
}
//按加号键或>键增大小地图
if (event.key.code == sf::Keyboard::Add || event.key.code == sf::Keyboard::Period)
{
miniMapScale++;
}
//按减号键或<键缩小小地图
if (event.key.code == sf::Keyboard::Subtract || event.key.code == sf::Keyboard::Comma)
{
miniMapScale--;
}
//按shift键画FOV
if (event.key.code == sf::Keyboard::LShift)
{
DrawFOV = !DrawFOV;
}
//按T是否纹理
if (event.key.code == sf::Keyboard::T)
{
TextureFlag = !TextureFlag;
}
//按B是否画天花板
if (event.key.code == sf::Keyboard::B)
{
DrawFloorCeilingFlag = !DrawFloorCeilingFlag;
}
//按回车是否伪3D效果
if (event.key.code == sf::Keyboard::Enter)
{
DrawFlag = 1;
}
}

原因如下:

sf::Keyboard::isKeyPressed函数会在每一帧都检测键盘的状态

sf::Event::KeyPressed事件只会在按键被按下的那一帧触发一次,即使你按住按键不放,它也不会在下一帧再次触发

因此,使用sf::Event::KeyPressed事件可以确保Flag的值只会被反转一次

源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529

#include <SFML/Graphics.hpp>
#include <iostream>
#include <string>
#include <vector>
#include <cmath>

using namespace std;
using namespace sf;

#define screenWidth 1440
#define screenHeight 900
#define mapWidth 24
#define mapHeight 24
#define texWidth 64
#define texHeight 64

int worldMap[mapWidth][mapWidth] =
{
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,2,2,2,2,0,0,0,0,3,0,3,0,3,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,3,0,0,0,3,0,0,0,1},
{1,0,0,0,0,0,2,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,2,2,0,2,2,0,0,0,0,3,0,3,0,3,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,0,0,0,5,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1},
{1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1}
};

//程序必要的参数
double posX = 22, posY = 12; //x and y start position
double dirX = -1, dirY = 0; //initial direction vector
double planeX = 0, planeY = 0.66; //the 2d raycaster version of camera plane

double Mytime = 0; //time of current frame
double oldTime = 0; //time of previous frame

//创建一个表示视野范围的三角扇形
//还是程序设计的结构问题,应该给玩家安排个类,这个当做类的成员的
//而且中间参数还在DDA算法中计算,实际上不应该这样的
VertexArray fov(sf::TriangleFan, screenWidth + 1);
//墙面纹理数组
Texture texture[8];
//用于计时
Clock Myclock;

//小地图的宽度和高度
const int minimapWidth = 24;
const int minimapHeight = 24;
//小地图的大小(单位:像素) 加减号或者<>键(,和.)操作放大缩小
int miniMapScale = 36;

//是否画伪3D效果 回车键
bool DrawFlag = 0;
//是否画小地图,默认画而且很大 Tab键或~键
bool DrawminimapFlag = 1;
//是否画小地图上的FOV shift
bool DrawFOV = 0;
//是否加纹理 T
bool TextureFlag = 0;
//是否画天花板地板 B
bool DrawFloorCeilingFlag = 0;

//获取时间
unsigned long getTicks();
//读取纹理
void LoadTexture();
//画天花板和地板
void DrawFloorCeiling(RenderWindow& window)
{
RectangleShape CeilingRect(Vector2f(screenWidth, screenHeight / 2));
CeilingRect.setFillColor(Color(156, 220, 235));

RectangleShape FloorRect(Vector2f(screenWidth, screenHeight / 2));
FloorRect.setPosition(0, screenHeight / 2);
FloorRect.setFillColor(Color(68, 68, 68));

window.draw(CeilingRect);
window.draw(FloorRect);
}
//整的新活:用SFML的VertexArray来可视化FOV
void drawMiniMap(sf::RenderWindow& window);
//DDA算法,用于计算墙的高度和画线
void DDA_algorithm(RenderWindow& window);
//移动函数,用于改变位置和朝向
void Move(RenderWindow& window);
//画线
void verLine(sf::RenderWindow& window, int x, int y1, int y2, const sf::Color& color)
{
// Ensure y1 is less than y2
if (y2 < y1)
{
std::swap(y1, y2);
}

// Create a sf::VertexArray for the line
sf::VertexArray line(sf::Lines, 2);

// Set the position and color of the line vertices
line[0].position = sf::Vector2f(x, y1);
line[0].color = color;
line[1].position = sf::Vector2f(x, y2);
line[1].color = color;

// Draw the line
window.draw(line);
}

int main()
{
RenderWindow window(sf::VideoMode(screenWidth, screenHeight), "Raycasting-Demonstration");


LoadTexture();

while (window.isOpen())
{
sf::Event event;
while (window.pollEvent(event))
{
if (event.type == sf::Event::Closed)
window.close();

//sf::Keyboard::isKeyPressed函数会在每一帧都检测键盘的状态
//sf::Event::KeyPressed事件只会在按键被按下的那一帧触发一次,即使你按住按键不放,它也不会在下一帧再次触发
//因此,使用sf::Event::KeyPressed事件可以确保DrawminimapFlag的值只会被反转一次
if (event.type == sf::Event::KeyPressed)
{
//按Tab键或~键开关小地图
if (event.key.code == sf::Keyboard::Tab || event.key.code == sf::Keyboard::Tilde)
{
DrawminimapFlag = !DrawminimapFlag;
}
//按加号键或>键增大小地图
if (event.key.code == sf::Keyboard::Add || event.key.code == sf::Keyboard::Period)
{
miniMapScale++;
}
//按减号键或<键缩小小地图
if (event.key.code == sf::Keyboard::Subtract || event.key.code == sf::Keyboard::Comma)
{
miniMapScale--;
}
//按shift键画FOV
if (event.key.code == sf::Keyboard::LShift)
{
DrawFOV = !DrawFOV;
}
//按T是否纹理
if (event.key.code == sf::Keyboard::T)
{
TextureFlag = !TextureFlag;
}
//按B是否画天花板
if (event.key.code == sf::Keyboard::B)
{
DrawFloorCeilingFlag = !DrawFloorCeilingFlag;
}
//按回车是否伪3D效果
if (event.key.code == sf::Keyboard::Enter)
{
DrawFlag = 1;
}
}

}

window.clear();
if (DrawFloorCeilingFlag)
{
DrawFloorCeiling(window);
}

DDA_algorithm(window);
Move(window);

if (DrawminimapFlag)
{
drawMiniMap(window);
}

window.display();
}

return 0;
}

unsigned long getTicks()
{
sf::Time elapsed = Myclock.getElapsedTime();
return elapsed.asMilliseconds();
}

void LoadTexture()
{
//texture[0].loadFromFile("pics/eagle.png");
//texture[1].loadFromFile("pics/redbrick.png");
//texture[2].loadFromFile("pics/purplestone.png");
//texture[3].loadFromFile("pics/greystone.png");
//texture[4].loadFromFile("pics/bluestone.png");
//texture[5].loadFromFile("pics/mossy.png");
//texture[6].loadFromFile("pics/wood.png");
//texture[7].loadFromFile("pics/colorstone.png");
texture[0].loadFromFile("pics/redbrick.png");
texture[1].loadFromFile("pics/greystone.png");
texture[2].loadFromFile("pics/bluestone.png");
texture[3].loadFromFile("pics/colorstone.png");
texture[4].loadFromFile("pics/purplestone.png");
}

//要注意:SFML中的y轴是向下的,而用二维数组计算时y轴是向上的
//需将游戏中的坐标系转换为小地图的坐标系,具体来说,就是将y坐标进行翻转
void drawMiniMap(sf::RenderWindow& window)
{
// 遍历地图的每一个格子
for (int x = 0; x < minimapWidth; ++x)
{
for (int y = 0; y < minimapHeight; ++y)
{
// 创建一个表示地图格子的矩形
sf::RectangleShape rect(sf::Vector2f(miniMapScale, miniMapScale));
//rect.setPosition(x * miniMapScale, y * miniMapScale);
rect.setPosition(x * miniMapScale, (mapHeight - y - 1) * miniMapScale);

// 根据地图数据设置矩形的颜色
if (worldMap[x][y] > 0) {
rect.setFillColor(sf::Color::White);
}
else {
rect.setFillColor(sf::Color::Black);
}

// 在窗口上绘制矩形
window.draw(rect);
}
}

// 创建一个表示玩家位置的圆形
int radius = 4;
sf::CircleShape player(radius);
player.setFillColor(sf::Color::Red);
//player.setPosition(posX * miniMapScale - player.getRadius(), posY * miniMapScale - player.getRadius());
player.setPosition(posX * miniMapScale - player.getRadius(), (mapHeight - posY) * miniMapScale - player.getRadius());

// 在窗口上绘制圆形
window.draw(player);

// 设置三角扇形的原点为玩家的位置
//FOV具体计算的参数在DDA算法里,我觉得这个结构很垃圾,应该在全局变量加个数组,在DDA算法中访问并修改这个参数
//但是好像没啥时间给我改代码了,能跑就是胜利
fov[0].position = sf::Vector2f(posX * miniMapScale, (mapHeight - posY) * miniMapScale);
fov[0].color = sf::Color::Transparent;

// 在窗口上绘制三角扇形
if (DrawFOV == 1)
{
window.draw(fov);
}

}

void DDA_algorithm(RenderWindow& window)
{
for (int x = 0; x < screenWidth; x++)
{
//calculate ray position and direction
double cameraX = 2 * x / (double)screenWidth - 1; //x-coordinate in camera space
double rayDirX = dirX + planeX * cameraX;
double rayDirY = dirY + planeY * cameraX;
//which box of the map we're in
int mapX = int(posX);
int mapY = int(posY);

//length of ray from current position to next x or y-side
double sideDistX;
double sideDistY;

double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(1 / rayDirX);
double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(1 / rayDirY);

double perpWallDist;

//what direction to step in x or y-direction (either +1 or -1)
int stepX;
int stepY;

int hit = 0; //was there a wall hit?
int side; //was a NS or a EW wall hit?
//calculate step and initial sideDist
if (rayDirX < 0)
{
stepX = -1;
sideDistX = (posX - mapX) * deltaDistX;
}
else
{
stepX = 1;
sideDistX = (mapX + 1.0 - posX) * deltaDistX;
}
if (rayDirY < 0)
{
stepY = -1;
sideDistY = (posY - mapY) * deltaDistY;
}
else
{
stepY = 1;
sideDistY = (mapY + 1.0 - posY) * deltaDistY;
}
//perform DDA
while (hit == 0)
{
//jump to next map square, either in x-direction, or in y-direction
if (sideDistX < sideDistY)
{
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
}
else
{
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
//Check if ray has hit a wall
if (worldMap[mapX][mapY] > 0) hit = 1;
}
//教程里这部分解释的比较清楚了
//也可以看我在博客里的证明过程,写的更清楚
if (side == 0) perpWallDist = (sideDistX - deltaDistX);
else perpWallDist = (sideDistY - deltaDistY);

/*********************************************************************************/
/* 此部分为添加FOV用
/* 计算射线的终点
/*********************************************************************************/
double rayEndX = posX + perpWallDist * rayDirX;
double rayEndY = posY + perpWallDist * rayDirY;

// 设置三角扇形的顶点
fov[x + 1].position = sf::Vector2f(rayEndX * miniMapScale, (mapHeight - rayEndY) * miniMapScale);
fov[x + 1].color = sf::Color(255, 255, 255, 150);

//Calculate height of line to draw on screen
//可以任意修改墙的高度
int lineHeight = (int)(screenHeight / perpWallDist) * 3;

//calculate lowest and highest pixel to fill in current stripe
int drawStart = -lineHeight / 2 + screenHeight / 2;
if (drawStart < 0) drawStart = 0;
int drawEnd = lineHeight / 2 + screenHeight / 2;
if (drawEnd >= screenHeight) drawEnd = screenHeight - 1;

/*无纹理时*/
if (TextureFlag == 0)
{
//choose wall color
Color color;
switch (worldMap[mapX][mapY])
{
case 1: color = Color::Red; break; //red
case 2: color = Color::Green; break; //green
case 3: color = Color::Blue; break; //blue
case 4: color = Color::White; break; //white
default: color = Color::Yellow; break; //yellow
}

//give x and y sides different brightness
if (side == 1)
{
color.r /= 2;
color.g /= 2;
color.b /= 2;
}

//draw the pixels of the stripe as a vertical line
if (DrawFlag == 1)
{
verLine(window, x, drawStart, drawEnd, color);
}

}

/*********************************************************************************/
/* 此部分替换了原raycasting画线部分
/* 以下为纹理相关计算,是新内容
/* 具体和原来的画线函数差不多,都是在具体位置上画出一条线
/* 只不过这里画的是具体纹理上的一条线,利用了SFML中的sprite
/*********************************************************************************/

if (TextureFlag == 1)
{
//texturing calculations
int texNum = worldMap[mapX][mapY] - 1; //1 subtracted from it so that texture 0 can be used!

//calculate value of wallX
double wallX; //where exactly the wall was hit
if (side == 0) wallX = posY + perpWallDist * rayDirY;
else wallX = posX + perpWallDist * rayDirX;
wallX -= floor((wallX));

//x coordinate on the texture
int texX = int(wallX * double(texWidth));
if (side == 0 && rayDirX > 0) texX = texWidth - texX - 1;
if (side == 1 && rayDirY < 0) texX = texWidth - texX - 1;

// How much to increase the texture coordinate per screen pixel
double step = 1.0 * texHeight / lineHeight;
// Starting texture coordinate
double texPos = (drawStart - screenHeight / 2 + lineHeight / 2) * step;

//为了获取纹理具体某一个坐标的像素颜色
//创建一个新的sprite对象
sf::Sprite wall_sprite;

//设置sprite的纹理
wall_sprite.setTexture(texture[texNum]);

//设置sprite的纹理矩形,定义了在纹理中的哪个部分应用到wall_sprite上
//宽度为1个像素,相当于原来的画线
wall_sprite.setTextureRect(sf::IntRect(texX, 0, 1, texHeight));
//wall_sprite.setTextureRect(sf::IntRect(texX, 0, 1, lineHeight));
//wall_sprite.setTextureRect(sf::IntRect(static_cast<unsigned short>(round(texX)), 0, 1, lineHeight));

//设置sprite的位置,坐标指定了左上角的位置
//wall_sprite.setPosition(x, drawStart); //太几把恶心了,破案了:指定左上角的位置为drawStart
// //drawStart在特殊情况下会被赋值为0,即if (drawStart < 0) drawStart = 0;
// //在此情况下绘制的起点就会改变为0,令人恶心
wall_sprite.setPosition(x, round(0.5f * screenHeight - 0.5f * lineHeight));

//设置sprite的缩放因子
wall_sprite.setScale(1, lineHeight / (float)texHeight);


//利用SFML中sprite的setColor功能,使y墙变暗
//这个函数实现的是“调制混合”
//举个例子:原来的R通道为200,与128混合为:200 * 128 / 255 = 100,实现了颜色除以2的效果
if (side == 1)
{
wall_sprite.setColor(Color(128, 128, 128));
}
// 绘制sprite
if (DrawFlag == 1)
{
window.draw(wall_sprite);
}
}
}
}

void Move(RenderWindow& window)
{
//timing for input and FPS counter
oldTime = Mytime;
Mytime = getTicks();
double frameTime = (Mytime - oldTime) / 1000.0; //frameTime is the time this frame has taken, in seconds


//speed modifiers
double moveSpeed = frameTime * 3.0; //the constant value is in squares/second
double rotSpeed = frameTime * 2.0; //the constant value is in radians/second
//move forward if no wall in front of you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Up) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::W))
{
if (worldMap[int(posX + dirX * moveSpeed)][int(posY)] == false) posX += dirX * moveSpeed;
if (worldMap[int(posX)][int(posY + dirY * moveSpeed)] == false) posY += dirY * moveSpeed;
}
//move backwards if no wall behind you
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Down) ||
sf::Keyboard::isKeyPressed(sf::Keyboard::S))
{
if (worldMap[int(posX - dirX * moveSpeed)][int(posY)] == false) posX -= dirX * moveSpeed;
if (worldMap[int(posX)][int(posY - dirY * moveSpeed)] == false) posY -= dirY * moveSpeed;
}
//向右走,原理还是跟前后走一样,只是方向用矩阵相乘重新算了
//向右走就是在(dirY, -dirX)这个方向走
if (sf::Keyboard::isKeyPressed(sf::Keyboard::D))
{
if (worldMap[int(posX + dirY * moveSpeed * 0.75)][int(posY)] == false) posX += dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY - dirX * moveSpeed * 0.75)] == false) posY -= dirX * moveSpeed * 0.75;
}
//向左走同理
//在(-dirY, dirX)方向
if (sf::Keyboard::isKeyPressed(sf::Keyboard::A))
{
if (worldMap[int(posX - dirY * moveSpeed * 0.75)][int(posY)] == false) posX -= dirY * moveSpeed * 0.75;
if (worldMap[int(posX)][int(posY + dirX * moveSpeed * 0.75)] == false) posY += dirX * moveSpeed * 0.75;
}
//rotate to the right
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Right))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(-rotSpeed) - dirY * sin(-rotSpeed);
dirY = oldDirX * sin(-rotSpeed) + dirY * cos(-rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(-rotSpeed) - planeY * sin(-rotSpeed);
planeY = oldPlaneX * sin(-rotSpeed) + planeY * cos(-rotSpeed);
}
//rotate to the left
if (sf::Keyboard::isKeyPressed(sf::Keyboard::Left))
{
//both camera direction and camera plane must be rotated
double oldDirX = dirX;
dirX = dirX * cos(rotSpeed) - dirY * sin(rotSpeed);
dirY = oldDirX * sin(rotSpeed) + dirY * cos(rotSpeed);
double oldPlaneX = planeX;
planeX = planeX * cos(rotSpeed) - planeY * sin(rotSpeed);
planeY = oldPlaneX * sin(rotSpeed) + planeY * cos(rotSpeed);
}
}

遇到的问题

纹理问题

我在编码过程中碰到了如图所示的问题

开什么玩笑?要吐了!

那么这是怎么会是呢?

为什么会出现“墙往两侧倒”的这种情况?

为什么刚刚纯色版本没出现这个问题?

其实很简单,因为纯色版本也有这样的问题,只是纯色没有纹理,看不出来这样的效果

最主要的问题就出在“设置线的起点”这条语句上

先看看纯色的这里是怎么做的:

1
2
3
4
5
int drawStart = -lineHeight / 2 + screenHeight / 2;
if (drawStart < 0) drawStart = 0;
...
//draw the pixels of the stripe as a vertical line
verLine(window, x, drawStart, drawEnd, color);

我们在设置起点的时候,明确指定了:如果起点小于0,就把drawStart强行赋值为0

再来看看纹理设置位置:

1
2
//设置sprite的位置,坐标指定了左上角的位置
wall_sprite.setPosition(x, drawStart);

这就导致了问题:当这个墙超过视野范围,计算的起点必须是负数,但是强行赋值为0,就会导致纹理的起点变成0


原教程中,把起点强行设置为0,可能是因为原教程里用的图形库不支持负数坐标,并且显示逻辑也跟我在SFML中用的不一样

但是在SFML中是支持坐标超出屏幕范围的,也就是支持坐标指定为负数,那就不用担心了

我们就把设置位置这条的Y坐标起始位置修改为drawStart的计算过程:

1
2
//设置sprite的位置,坐标指定了左上角的位置
wall_sprite.setPosition(x, round(0.5f * screenHeight - 0.5f * lineHeight));

这样就解决问题了

经过这个bug,深刻认识到参考只能是参考,和实际一定会有差别,代码不能直接复制粘贴啊,笑拉了





参考资料

First-person shooter - Wikipedia

Raycasting (lodev.org)

德军总部3D - 维基百科

Ray casting - Wikipedia

Making my First RAYCASTING Game in C++

Kofybrek/Raycasting: Tried to write a ray casting game with no experience

I Used RAYCASTING to Make a HORROR Game in C++