光线投射(raycasting)的技术魅力

目录

介绍

​编辑

基本理念

未经授权的Raycaster

纹理Raycaster

Wolfenstein 3D纹理

性能注意事项

END


介绍

 光线投射是一种在二维地图中创建三维透视图的渲染技术。当计算机速度较慢时,不可能实时运行真正的3D引擎,光线投射是第一个解决方案。光线投射可以进行得非常快,因为只需要对屏幕的每一条垂直线进行计算。使用这种技术的最著名的游戏当然是《德军总部3D》。

48a9128543d44fcba7b00fc3b5cea112.png

德军总部3D的光线投射引擎非常有限,甚至可以在286台计算机上运行:所有的墙都有相同的高度,并且是2D网格上的正交正方形,如德军总部3D地图编辑器的截图所示:

6ca1bc66585744349421f347a960d27b.png

像楼梯、跳跃或高度差异这样的事情用这个引擎是不可能实现的。后来的游戏,如Doom和Duke Nukem 3D也使用了光线投射,但更先进的引擎允许倾斜的墙壁、不同的高度、有纹理的地板和天花板、透明的墙壁等。精灵图(敌人、物体和糖果)是2D图像,但本教程暂时不讨论精灵图。

光线投射与光线追踪不同!光线投射是一种快速的半3D技术,即使在4MHz的图形计算器上也能实时工作,而光线追踪是一种逼真的渲染技术,支持真实3D场景中的反射和阴影,直到最近,计算机才变得足够快,可以在相当高的分辨率和复杂的场景中实时执行。

本文档中完整地给出了无纹理和有纹理的光线投射器的代码,但它很长,您也可以下载代码:

raycaster_flat.cpp

raycaster_textured.cpp

基本理念

光线投射的基本思想如下:贴图是一个二维正方形网格,每个正方形可以是0(=没有墙),也可以是正值(=具有特定颜色或纹理的墙)。

对于屏幕的每个x(即屏幕的每个垂直条纹),发出一条从玩家位置开始的光线,其方向取决于玩家的观看方向和屏幕的x坐标。然后,让这条光线在二维贴图上向前移动,直到它碰到一个作为墙的贴图正方形。如果它撞到了墙上,计算这个击中点到玩家的距离,并用这个距离计算这堵墙在屏幕上的高度:墙越远,它在屏幕上就越小,越近,它看起来就越高。这些都是2D计算。此图显示了从玩家(绿点)开始并击中蓝墙的两条光线(红色)的自上而下的概览:

7212e62239f23bb29f400a2e31a148e6.gif

要找到光线在行进中遇到的第一堵墙,必须让它从玩家的位置开始,然后一直检查光线是否在墙内。如果它在墙内(击中),则循环可以停止,计算距离,并绘制具有正确高度的墙。如果光线位置不在墙中,则必须进一步跟踪它:在该光线的方向上,将某个值添加到它的位置,对于这个新位置,再次检查它是否在墙内。继续这样做,直到最后撞墙。

人类可以立即看到光线照射到墙上的位置,但用一个公式无法找到光线直接照射到哪个正方形,因为计算机只能检查光线上的有限数量的位置。许多光线投射器每走一步都会为光线添加一个常数值,但它可能会错过一堵墙!例如,使用此红色光线,在每个红点检查其位置:

9ed088bbadee7b13d975eea7c3f8afd4.gif

正如你所看到的,光线直接穿过蓝墙,但计算机没有检测到这一点,因为它只检查了有红点的位置。你检查的位置越多,计算机检测不到墙壁的可能性就越小,但需要的计算就越多。在这里,步长减半,所以现在它检测到光线穿过了墙壁,尽管位置并不完全正确:

46b8e57f9e97f385dcc72001342b98f3.gif

对于这种方法的无限精度,需要无限小的步长,因此需要无限数量的计算!这很糟糕,但幸运的是,有一种更好的方法,只需要很少的计算,但可以检测到每一面墙:这个想法是在光线将遇到的墙的每一侧进行检查。我们将每个正方形的宽度设置成1,所以墙的每一边都是一个整数值,中间的地方在点之后有一个值。现在步长不是恒定的,它取决于到下一边的距离:

964b7c54b9a072b84d732ec5a3886ec4.gif

正如你在上面的图像中看到的,光线正好击中我们想要的墙壁。按照本教程中介绍的方式,使用了一种基于DDA或“数字差分分析”的算法。DDA是一种快速算法,通常用于正方形网格,以查找直线击中的正方形(例如,在屏幕上画一条线。屏幕是一个正方形像素网格)。因此,我们也可以使用它来找到我们的光线击中地图的哪些正方形,并在击中墙壁的正方形后停止算法。
一些光线跟踪器使用欧几里得角来表示玩家和光线的方向,并使用另一个角度来确定视野。然而,我发现使用矢量和相机更容易:玩家的位置总是一个矢量(x和y坐标),但现在,我们也将方向设为矢量:因此,方向现在由两个值决定:x和y座标。方向向量可以如下所示:如果你沿着玩家看的方向画一条线,穿过玩家的位置,那么这条线的每一点都是玩家的位置和方向向量的倍数之和。方向向量的长度其实并不重要,重要的是它的方向。将x和y乘以相同的值会改变长度,但保持相同的方向。
这种使用矢量的方法还需要一个额外的矢量,即相机平面矢量。在一个真正的3D引擎中,还有一个相机平面,这个平面实际上是一个3D平面,所以需要两个向量(u和v)来表示它。然而,光线投射发生在2D地图中,所以这里的相机平面实际上不是一个平面,而是一条线,并且用一个向量来表示。摄影机平面应始终垂直于方向向量。相机平面表示计算机屏幕的表面,而方向向量垂直于它并指向屏幕内部。玩家的位置是一个点,是相机平面前面的一个点。屏幕的某个x坐标的某条射线,就是从这个玩家位置开始,穿过屏幕上的那个位置或相机平面的射线。

13ab4d1c973ce80b3977ef4f33460cb5.gif

上面的图像代表这样一个2D相机。绿点是位置(矢量“pos”)。以黑点结束的黑线表示方向矢量(矢量“dir”),因此黑点的位置为pos+dir。蓝线表示整个相机平面,从黑点到右蓝点的矢量表示矢量“平面”,因此右蓝点位置为pos/dir+plane,左蓝点位置为pos+dir-plane(这些都是矢量相加)。
图像中的红线是几条光线。这些光线的方向很容易在相机外计算:它是骆驼的方向矢量和相机平面矢量的一部分的总和:例如,图像上的第三条红色光线在其长度的1/3处穿过相机平面的右侧。所以这条射线的方向是dir+平面*1/3。该光线方向是矢量rayDir,然后DDA算法使用该矢量的X和Y分量。
两条外侧线是屏幕的左右边界,这两条线之间的角度称为视野。FOV由方向矢量的长度与平面的长度之比决定。以下是不同FOV的几个例子:
如果方向矢量和摄影机平面矢量具有相同的长度,则FOV将为90°:

de1ee1b6b55c6c645642e220e04cdea9.gif

如果方向矢量比相机平面长得多,则FOV将远小于90°,并且您的视野将非常狭窄。不过,你会看到更详细的内容,深度也会更小,所以这与放大效果相同: 

b9d19930f6827e3b119a6510e71adfc9.gif

如果方向矢量短于相机平面,则FOV将大于90°(如果方向矢量接近0,则最大为180°),并且您将拥有更宽的视野,如缩小效果

1843fc4ad93646575b8040043a921591.gif: 

当玩家旋转时,相机必须旋转,因此方向向量和平面向量都必须旋转。然后,光线也会自动旋转。

335ac91a301474d3f541af324293ae6d.gif

若要旋转矢量,请将其与旋转矩阵相乘

[ cos(a) -sin(a) ]
[ sin(a)  cos(a) ]

如果你不知道向量和矩阵,试着在谷歌上找到一个教程,稍后会为本教程提供一个关于这些的附录。
没有什么可以阻止你使用不垂直于方向的相机平面,但结果会看起来像一个“扭曲”的世界。

未经授权的Raycaster


从基础知识开始,我们将从一个无纹理的光线投射器开始。此示例还包括一个fps计数器(每秒帧数),以及用于移动和旋转的带有碰撞检测的输入关键帧。
世界地图是一个二维数组,其中每个值表示一个正方形。如果值为0,则该正方形表示一个空的、可穿过的正方形;如果值大于0,则表示具有特定颜色或纹理的墙。这里声明的地图非常小,只有24乘24的正方形,并且直接在代码中定义。对于真正的游戏,比如《德军总部3D》,你可以使用更大的地图,然后从文件中加载。网格中的所有零都是空的,所以基本上你可以看到一个很大的房间,周围有一堵墙(值1),里面有一个小房间(值2),几个壁柱(值3),还有一条带房间的走廊(值4)。请注意,这段代码还没有包含在任何函数中,请将其放在主函数启动之前。


#define mapWidth 24
#define mapHeight 24
#define screenWidth 640
#define screenHeight 480

int worldMap[mapWidth][mapHeight]=
{
  {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}
};

声明了前几个变量:posX和posY表示玩家的位置矢量,dirX和dirY表示玩家方向,planeX和planeY表示玩家相机平面。确保相机平面垂直于方向,但你可以改变它的长度。方向的长度和相机平面之间的比率决定了FOV,这里的方向向量比相机平面长一点,所以FOV将小于90°(更准确地说,FOV是2*atan(0.66/1.0)=66°,这非常适合第一人称射击游戏)。稍后使用输入键旋转时,dir和plane的值将发生更改,但它们始终保持垂直并保持相同的长度。
变量time和oldTime将用于存储当前帧和前一帧的时间,这两者之间的时间差可用于确定按下某个键时应移动的量(无论帧的计算需要多长时间都以恒定速度移动),以及FPS计数器。

int main(int /*argc*/, char */*argv*/[])
{
  double posX = 22, posY = 12;  //x和y起始位置
  double dirX = -1, dirY = 0; //初始方向矢量
  double planeX = 0, planeY = 0.66; //相机平面的2d raycaster视野

  double time = 0; //当前帧的时间
  double oldTime = 0; //上一帧的时间

主要功能的其余部分现在开始。首先,创建具有所选分辨率的屏幕。如果你选择一个大分辨率,比如1280*1024,效果会很慢,不是因为光线处理算法很慢,而是因为从CPU上传整个屏幕到视频卡的速度太慢了。

screen(screenWidth, screenHeight, 0, "Raycaster");

设置好屏幕后,游戏循环开始,这是一个绘制整个帧并每次读取输入的循环。

 while(!done())
  {

这里开始实际的光线投射。光线投射循环是一个遍历每个x的for循环,因此不需要对屏幕的每个像素进行计算,只需要对每个垂直条纹进行计算,这根本不算多!要开始光线投射循环,需要对一些变量进行delcare和计算:
射线从玩家的位置开始(posX,posY)。
cameraX是屏幕当前x坐标所代表的相机平面上的x坐标,这样做可以使屏幕右侧获得坐标1,屏幕中心获得坐标0,屏幕左侧获得坐标-1。由此,光线的方向可以像前面解释的那样计算:作为方向向量和平面向量的一部分的总和。这必须对向量的x和y坐标都进行(因为添加两个向量就是添加它们的x坐标和y坐标)。

 for(int x = 0; x < w; x++)
    {
      //计算射线位置和方向
      double cameraX = 2 * x / double(w) - 1; //摄影机空间中的x坐标
      double rayDirX = dirX + planeX * cameraX;
      double rayDirY = dirY + planeY * cameraX;

在下一个代码块中,将声明和计算更多变量,这些变量与DDA算法相关:
mapX和mapY表示射线所在地图的当前正方形。射线位置本身是一个浮点数字,包含关于我们在地图的哪个正方形以及我们在该正方形中的位置的信息,但mapX和mapY只是该正方形的坐标。
sideDistX和sideDistY最初是光线从其起始位置到第一个x侧和第一个y侧必须行进的距离。在代码的后面,它们将在执行步骤时递增。
deltaDistX和deltaDistY是光线从1个x侧到下一个x侧,或从1个y侧到下个y侧所必须行进的距离。下图显示了初始sideDistX、sideDistY以及deltaDistX和deltaDistY:

9cb331312f1517c746918a937751aa71.gif

当你用毕达哥拉斯几何推导deltaDistX时,你会得到下面的公式。对于蓝色三角形(deltaDistX),一边的长度为1(因为它恰好是一个单元),另一边的长度是raydirY/raydirX,因为它是在X方向上走1步时光线在y方向上行进的单位数量的整数。对于绿色三角形(deltaDistY),公式类似。
deltaDistX=sqrt(1+(rayDirY*rayDirY)/(rayDirX*rayDirX))
deltaDistY=sqrt(1+(rayDirX*rayDirX)/(rayDirY*rayDirY))
但这可以简化为:
deltaDistX=abs(|rayDir|/rayDirX)
deltaDistY=abs(|rayDir|/rayDirY)
其中,|rayDir|是向量rayDirX的长度,rayDirY(即sqrt(rayDirX*rayDirX+rayDirY*rayDirY)):您确实可以验证例如sqrt(1+(rayDirY*rayDirY)/(rayDirX*rayDirX))等于abs(sqrt(rayDirX*rayDirX+rayDirY*rayDirX)/rayDirX)。但是,我们可以使用1而不是|rayDir|,因为只有deltaDistX和deltaDistY之间的*比率*对下面的DDA代码很重要,所以我们得到:
deltaDistX=abs(1/rayDirX)
deltaDistY=abs(1/rayDirY)
因此,代码中使用的deltaDist和sideDist值与上图所示的长度不匹配,但它们的相对大小仍然匹配。
[感谢Artem发现了这种简化]
稍后将使用变量perpWallDist来计算光线的长度。
DDA算法总是在每个循环中正好跳一个正方形,要么是x方向的正方形,要么就是y方向的正方形。如果它必须在负或正的x方向上进行,而负或正y方向将取决于射线的方向,并且这一事实将存储在步骤x和步骤y中。这些变量总是-1或+1。
最后,hit用于确定即将到来的循环是否可以结束,并且side将包含墙的x侧或y侧是否被击中。如果x边被击中,则边被设置为0,如果y边被击中则边将为1。我所说的x边和y边,是指网格中两个正方形之间的边界线。

      //我们在地图的哪个区块里
      int mapX = int(posX);
      int mapY = int(posY);

      //从当前位置到下一个x或y侧的光线长度
      double sideDistX;
      double sideDistY;

       //从一个x或y侧到下一个x侧或y侧的射线长度
      double deltaDistX = (rayDirX == 0) ? 1e30 : std::abs(1 / rayDirX);
      double deltaDistY = (rayDirY == 0) ? 1e30 : std::abs(1 / rayDirY);
      double perpWallDist;

      //在x或y方向上的步进方向(+1或-1)
      int stepX;
      int stepY;

      int hit = 0; //有墙被撞了吗?
      int side; //是南北走向的墙还是东西走向的墙被击中了?

 注意:如果rayDirX或rayDirY为0,则可以通过将其设置为非常高的值1e30来避免通过零进行除法运算。如果您使用的是C++、Java或JS等语言,实际上并不需要这样做,因为它支持IEEE 754浮点标准,该标准给出了Infinity的结果,在下面的代码中可以正确工作。然而,其他一些语言,如Python,不允许通过零进行除法,因此上面给出了适用于所有地方的更通用的代码。1e30是一个任意选择的足够高的数字,如果您的编程语言支持指定该值,则可以设置为无穷大。
现在,在实际DDA开始之前,仍然需要计算第一个步骤X、步骤Y以及初始的sideDistX和sideDistY。
如果射线方向具有负的x分量,则stepX为-1,如果射线方向有正的x分量则为+1。如果x分量为0,那么stepX的值是多少并不重要,因为它将不被使用。
y分量也是如此。
如果射线方向具有负x分量,sideDistX是从射线起始位置到左侧第一边的距离,如果射线方向有正x分量,则使用右侧第一边。
y分量也是如此,但现在第一条边在位置上方或下方。
对于这些值,使用整数值mapX并从中减去实际位置,在某些情况下添加1.0,具体取决于使用的是顶部或底部的左侧还是右侧。然后你得到到这一边的垂直距离,把它乘以deltaDistX或deltaDistY,得到真正的欧几里得距离。

     //计算步长和初始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平方,直到墙被击中。每次,无论是在x方向上跳一个正方形(使用步长x),还是在y方向上跳正方形(使用步骤y),它总是一次跳1个正方形。如果光线的方向是x方向,则循环每次只需在x方向上跳一个正方形,因为光线永远不会改变其y方向。如果光线向y方向倾斜一点,那么在x方向上每跳那么多次,光线就必须在y方向上跳一个正方形。如果光线正好在y方向,它就不必在x方向上跳跃,等等。。。
sideDistX和sideDistY在其方向上的每次跳跃都会随着deltaDistX而递增,mapX和mapY分别随着stepX和stepY而递增。
当光线击中墙时,循环结束,然后我们将知道墙的x侧还是y侧在变量“侧”中被击中,以及mapX和mapY击中了什么墙。然而,我们不知道墙被击中的确切位置,但在这种情况下不需要这样做,因为我们现在不会使用纹理墙。

      //执行DDA
      while (hit == 0)
      {
        //在x方向或y方向上跳到下一个贴图正方形
        if (sideDistX < sideDistY)
        {
          sideDistX += deltaDistX;
          mapX += stepX;
          side = 0;
        }
        else
        {
          sideDistY += deltaDistY;
          mapY += stepY;
          side = 1;
        }
        //检查光线是否碰到墙壁
        if (worldMap[mapX][mapY] > 0) hit = 1;
      } 

DDA完成后,我们必须计算射线到墙的距离,这样我们就可以计算出在此之后必须绘制的墙的高度。
我们不使用到代表玩家的点的欧几里得距离,而是使用到相机平面的距离(或者,投影在相机方向上的点到玩家的距离),以避免鱼眼效应。鱼眼效应是如果你使用真实距离,你会看到的效果,在真实距离中,所有的墙都变成圆形,如果你旋转,会让你恶心。
下图显示了为什么我们使用相机平面的距离而不是玩家。P是玩家,黑线是相机平面:在玩家的左边,显示了从墙上的命中点到玩家的几条红色光线,代表欧几里得距离。在玩家的右侧,显示了一些绿色光线从墙上的命中点直接射向相机平面,而不是射向玩家。所以这些绿线的长度是垂直距离的例子,我们将使用它来代替直接欧几里得距离。
在图像中,玩家正直视墙壁,在这种情况下,你会期望墙壁的底部和顶部在屏幕上形成一条完美的水平线。然而,红色光线都有不同的长度,因此会为不同的垂直条纹计算不同的墙高度,从而产生圆形效果。右边的绿色光线都有相同的长度,所以会给出正确的结果。同样的情况仍然适用于玩家旋转时(然后相机平面不再是水平的,绿线将有不同的长度,但每条之间仍有不断的变化),墙壁在屏幕上变成对角线但却是直线。这个解释有点草率,但给出了主意。

a9358441d394ed6e41eeec9b8bba2e2d.png

请注意,这部分代码不是“鱼眼校正”,这里使用的光线投射方式不需要这样的校正,这里计算距离的方式可以简单地避免鱼眼效应。计算这个垂直距离比实际距离更容易,我们甚至不需要知道墙被击中的确切位置。
这个垂直距离在代码中被称为“perpWallDist”。计算它的一种方法是使用从一个点到一条线的最短距离公式,其中该点是墙被击中的地方,而该线是相机平面:
 

30c51b9d6d04e100f569a68801576275.png

然而,它的计算可以比这更简单:由于deltaDist和sideDist是如何按上述系数|rayDir|缩放的,sideDist的长度已经几乎等于perpWallDist。我们只需要从中减去deltaDist一次,后退一步,因为在上面的DDA步骤中,我们又前进了一步,最终进入墙内。
根据光线是击中X侧还是Y侧,使用sideDistX或sideDistY计算公式。

      //计算投影在相机方向上的距离(欧几里得距离会产生鱼眼效应!)
      if(side == 0) perpWallDist = (sideDistX - deltaDistX);
      else          perpWallDist = (sideDistY - deltaDistY);

 对于边==1的情况,perpWallDist公式的更详细推导如下图所示。
要点的含义:

  • P: 玩家在代码中的位置(posX,posY)
  • H: 射线在墙上的命中点。其y位置已知为mapY+(1-步长y)/2
  • yDist匹配“(mapY+(1-stepY)/2-posY)”,这是欧几里得距离向量的y坐标,在世界坐标中。这里,(1-stepY)/2)是基于正或负y方向的0或1的校正项,其也用于sideDistY的初始化。
  • dir:主要玩家看方向,由代码中的dirX、dirY给出。这个向量的长度总是恰好为1。这与屏幕中心的观看方向相匹配,而不是当前光线的方向。它垂直于摄影机平面,perpWallDist与此平行。
  • 橙色虚线(可能很难看到,请使用CTRL+滚动轮或CTRL+plus在桌面浏览器中放大以更好地查看):添加到dir以获取rayDir的值。重要的是,它平行于摄影机平面,垂直于dir。
  • A: 摄影机平面中最接近H的点,即perpWallDist与摄影机平面相交的点
  • B: 玩家X轴最接近H的点,yDist穿过玩家X轴的点
  • C: 玩家位置的点+rayDirX
  • D: 指向玩家位置+光线方向。
  • E: 这是点D减去了dir矢量,换句话说,E+dir=D。
  • 在下面的解释中使用了点A、B、C、D、E、H和P:它们形成三角形,被认为是BHP、CDP、AHP和DEP。

 实际推导:

1:三角形PBH和PCD具有相同的形状但不同的尺寸,因此边缘的比例相同
2:给定步骤1,三角形显示比率yDist/rayDirY等于比率欧几里得/|rayDir|,所以现在我们可以推导perpWallDist=欧几里得/| rayDir|。
3:三角形AHP和EDP具有相同的形状但不同的尺寸,因此具有相同的边比率。边ED的长度,即|ED|,等于dir的长度,|dir|,即1。类似地,|DP|等于|rayDir|。
4:给定步骤3,三角形显示比率欧几里得/|rayDir|=perpWallDist/|dir|=perp WallDist/1。
5:将步骤4和2组合显示perpWallDist=yDist/rayDirY,其中yDist是mapY+(1-步骤Y)/2)-posY
6:在代码中,sideDistY-deltaDistY,在DDA步骤之后,等于(posY+(1-stepY)/2-mapY)*deltaDistY(假定sideDistY是根据posY和mapY计算的),因此yDist=(sideDistY-deltaDistY)/detaDistY
7:给定deltaDistY=1/|rayDirY|,步骤6给出yDist=(sideDistY-deltaDistY)*|rayDirY|
8:结合步骤5和7,得到perpWallDist=yDist/rayDirY=(sideDistY-deltaDistY)/|rayDirY|/rayDirY。
9:给定代码中sideDistY和deltaDistY符号的情况如何处理,绝对值无关紧要,等于(sideDistY-deltaDistY),这是使用的公式

683440f54d1487f5416bd40c0b97876d.png

[感谢Thomas van der Berg在2016年指出了代码的简化(perpWallDist可以简化,并将其值重新用于wallX)。
[感谢Roux Morgan在2020年帮助澄清了perpWallDist的解释,在此之前,教程缺乏一些信息]
[感谢Noah Wagner和Elias为perpWallDist找到了进一步的简化]
现在我们有了计算的距离(perpWallDist),我们可以计算必须在屏幕上绘制的线的高度:这是perpWallDist的倒数,然后乘以h,屏幕的像素高度,使其达到像素坐标。当然,如果希望墙更高或更低,也可以将其乘以另一个值,例如2*h。h的值将使墙看起来像高度、宽度和深度相等的立方体,而大的值将创建更高的长方体(取决于您的显示器)。
然后在这个lineHeight(因此是应该绘制的垂直线的高度)之外,计算出我们真正应该绘制的位置的开始和结束位置。墙的中心应该在屏幕的中心,如果这些点位于屏幕之外,则它们的上限为0或h-1。 

      //计算要在屏幕上绘制的线的高度
      int lineHeight = (int)(h / perpWallDist);

      //计算填充当前条带的最低和最高像素
      int drawStart = -lineHeight / 2 + h / 2;
      if(drawStart < 0)drawStart = 0;
      int drawEnd = lineHeight / 2 + h / 2;
      if(drawEnd >= h)drawEnd = h - 1;

最后,根据被击中的墙的编号,选择一种颜色。如果y面被击中,颜色会变深,这样会产生更好的效果。然后使用verLine命令绘制垂直线。这将结束光线投射循环,在至少对每个x执行此操作之后。
 

     //选择墙的颜色
      ColorRGB color;
      switch(worldMap[mapX][mapY])
      {
        case 1:  color = RGB_Red;  break; //red
        case 2:  color = RGB_Green;  break; //green
        case 3:  color = RGB_Blue;   break; //blue
        case 4:  color = RGB_White;  break; //white
        default: color = RGB_Yellow; break; //yellow
      }

      //给x和y侧不同的亮度
      if (side == 1) {color = color / 2;}

      //将条纹的像素绘制为垂直线
      verLine(x, drawStart, drawEnd, color);
    }

光线投射循环完成后,将计算当前帧和上一帧的时间,计算并打印FPS(每秒帧数),并重新绘制屏幕,以便所有内容(所有墙和FPS计数器的值)都可见。之后,使用cls()清除后台缓冲区,这样,当我们在下一帧中再次绘制墙时,地板和天花板将再次变为黑色,而不是仍然包含上一帧中的像素。
速度修改器使用frameTime和恒定值来确定输入关键帧的移动和旋转速度。由于使用了frameTime,我们可以确保移动和旋转速度与处理器速度无关。

   //输入和FPS计数器的定时
    oldTime = time;
    time = getTicks();
    double frameTime = (time - oldTime) / 1000.0; //frameTime是此帧所用的时间,以秒为单位
    print(1.0 / frameTime); //FPS计数器
    redraw();
    cls();

    //速度修改器
    double moveSpeed = frameTime * 5.0; //常数值以平方/秒为单位
    double rotSpeed = frameTime * 3.0; //常数值以弧度/秒为单位

最后一部分是输入部分,读取按键。
如果按下向上箭头,玩家将向前移动:将dirX添加到posX,将dirY添加到posY。这假设dirX和dirY是归一化向量(它们的长度为1),但它们最初是这样设置的,所以没关系。还内置了一个简单的碰撞检测,即如果新位置在墙内,你就不会移动。然而,这种碰撞检测可以改进,例如,通过检查玩家周围的一个圆圈是否不会进入墙内,而只是一个点。
如果按下向下箭头,也会执行同样的操作,但随后会减去方向。
若要旋转,如果按下左箭头或右箭头,方向向量和平面向量都将通过使用与旋转矩阵相乘的公式进行旋转(并在角度rotSpeed上)。

  readKeys();
    //如果你面前没有墙,就向前走
    if (keyDown(SDLK_UP))
    {
      if(worldMap[int(posX + dirX * moveSpeed)][int(posY)] == false) posX += dirX * moveSpeed;
      if(worldMap[int(posX)][int(posY + dirY * moveSpeed)] == false) posY += dirY * moveSpeed;
    }
    //如果身后没有墙,就向后移动
    if (keyDown(SDLK_DOWN))
    {
      if(worldMap[int(posX - dirX * moveSpeed)][int(posY)] == false) posX -= dirX * moveSpeed;
      if(worldMap[int(posX)][int(posY - dirY * moveSpeed)] == false) posY -= dirY * moveSpeed;
    }
    //向右旋转
    if (keyDown(SDLK_RIGHT))
    {
      //摄影机方向和摄影机平面都必须旋转
      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);
    }
    //向左旋转
    if (keyDown(SDLK_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);
    }
  }
}

这就结束了无纹理光线投射器的代码,结果如下所示,您可以在地图中四处走动:

03e31b3e45897f460a8f7d9835c6de59.gif

以下是一个示例,说明如果摄影机平面不垂直于方向向量,则世界会出现倾斜: 

eae78b5543a1f54c33b3b616cf4de464.gif

纹理Raycaster


光线投射器的纹理版本的核心几乎是一样的,只是在最后需要对纹理进行一些额外的计算,并且需要在y方向上循环遍历每个像素,以确定纹理的哪个texel(纹理像素)应该用于它。
不能再使用垂直线命令绘制垂直条纹,而是必须单独绘制每个像素。这次最好的方法是使用2D阵列作为屏幕缓冲区,并立即将其复制到屏幕上,这比使用pset快得多。
当然,我们现在还需要一个额外的纹理数组,由于“drawbuffer”函数适用于颜色的单个整数值(而不是R、G和B的3个独立字节),因此纹理也以这种格式存储。通常,您会从纹理文件加载纹理,但对于这个简单的示例,会生成一些愚蠢的纹理。
代码与上一个示例基本相同,粗体部分是新的。仅对新零件进行说明。
screenWidth和screenHeight现在是在一开始定义的,因为我们需要相同的值来创建屏幕缓冲区。这里定义的纹理宽度和高度也是新的。这些显然是纹理的纹理像素的宽度和高度。
世界地图也发生了变化,这是一个更复杂的地图,有走廊和房间来显示不同的纹理。同样,0是空的可遍历空间,每个正数对应不同的纹理。 

#define screenWidth 640
#define screenHeight 480
#define texWidth 64
#define texHeight 64
#define mapWidth 24
#define mapHeight 24

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}
};

屏幕缓冲区和纹理数组在这里声明。纹理数组是std::向量的数组,每个向量都有一定的宽度*高度像素。

int main(int /*argc*/, char */*argv*/[])
{
  double posX = 22.0, posY = 11.5;  //x和y起始位置
  double dirX = -1.0, dirY = 0.0; //初始方向矢量
  double planeX = 0.0, planeY = 0.66; //相机平面的2d raycaster版本

  double time = 0; //当前帧的时间
  double oldTime = 0; //上一帧的时间

  Uint32 buffer[screenHeight][screenWidth]; //y坐标优先,因为它按扫描线工作
  std::vector texture[8];
  for(int i = 0; i < 8; i++) texture[i].resize(texWidth * texHeight);

现在,主要功能从生成纹理开始。我们有一个双循环,它遍历纹理的每个像素,然后每个纹理的相应像素得到一个由x和y计算的特定值。一些纹理得到XOR模式,一些是简单的梯度,另一些是砖模式,基本上都是非常简单的模式,看起来不会那么漂亮,更好的纹理请参阅下一章。

 screen(screenWidth,screenHeight, 0, "Raycaster");

  //生成一些纹理
  for(int x = 0; x < texWidth; x++)
  for(int y = 0; y < texHeight; y++)
  {
    int xorcolor = (x * 256 / texWidth) ^ (y * 256 / texHeight);
    //int xcolor = x * 256 / texWidth;
    int ycolor = y * 256 / texHeight;
    int xycolor = y * 128 / texHeight + x * 128 / texWidth;
    texture[0][texWidth * y + x] = 65536 * 254 * (x != y && x != texWidth - y); //带有黑色十字架的平坦红色纹理
    texture[1][texWidth * y + x] = xycolor + 256 * xycolor + 65536 * xycolor; //倾斜灰度
    texture[2][texWidth * y + x] = 256 * xycolor + 65536 * xycolor; //倾斜的黄色渐变
    texture[3][texWidth * y + x] = xorcolor + 256 * xorcolor + 65536 * xorcolor; //xor灰度
    texture[4][texWidth * y + x] = 256 * xorcolor; //xor绿色
    texture[5][texWidth * y + x] = 65536 * 192 * (x % 16 && y % 16); //红色方块
    texture[6][texWidth * y + x] = 65536 * ycolor; //红色梯度
    texture[7][texWidth * y + x] = 128 + 256 * 128 + 65536 * 128; //平坦的灰色纹理
  }

这也是游戏循环和DDA算法之前的初始声明和计算的开始。这里什么都没有改变。

  //开始主循环
  while(!done())
  {
    for(int x = 0; x < w; x++)
    {
      //计算射线位置和方向
      double cameraX = 2*x/double(w)-1; //摄影机空间中的x坐标
      double rayDirX = dirX + planeX*cameraX;
      double rayDirY = dirY + planeY*cameraX;

      //我们在地图的哪个区块里
      int mapX = int(posX);
      int mapY = int(posY);

      //从当前位置到下一个x或y侧的光线长度
      double sideDistX;
      double sideDistY;

      //从一个x或y侧到下一个x侧或y侧的射线长度
      double deltaDistX = sqrt(1 + (rayDirY * rayDirY) / (rayDirX * rayDirX));
      double deltaDistY = sqrt(1 + (rayDirX * rayDirX) / (rayDirY * rayDirY));
      double perpWallDist;

      //在x或y方向上的步进方向(+1或-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;
      }

This is again the DDA loop, and the calculations of the distance and height, nothing has changed here either.

      //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;
      }

      //Calculate distance of perpendicular ray (Euclidean distance would give fisheye effect!)
      if(side == 0) perpWallDist = (sideDistX - deltaDistX);
      else          perpWallDist = (sideDistY - deltaDistY);

      //Calculate height of line to draw on screen
      int lineHeight = (int)(h / perpWallDist);

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

但是,以下计算是新的,并替换了无纹理光线投射器的颜色选择器。
变量texNum是当前贴图正方形的值减去1,原因是存在纹理0,但贴图平铺0没有纹理,因为它表示一个空白空间。无论如何,为了能够使用纹理0,请减去1,以便值为1的贴图平铺将给出纹理0,等等。。。
值wallX表示墙被击中的确切值,而不仅仅是墙的整数坐标。这是知道我们必须使用纹理的哪个x坐标所必需的。这是通过首先计算世界上精确的x或y坐标,然后减去墙的整数值来计算的。请注意,即使它被称为wallX,如果side==1,它实际上是墙的y坐标,但它始终是纹理的x坐标。
最后,texX是纹理的x坐标,这是根据wallX计算的。

      //纹理计算
      int texNum = worldMap[mapX][mapY] - 1; //1 subtracted from it so that texture 0 can be used!

      //计算wallX的值
      double wallX; //墙被击中的确切位置
      if (side == 0) wallX = posY + perpWallDist * rayDirY;
      else           wallX = posX + perpWallDist * rayDirX;
      wallX -= floor((wallX));

      //纹理上的x坐标
      int texX = int(wallX * double(texWidth));
      if(side == 0 && rayDirX > 0) texX = texWidth - texX - 1;
      if(side == 1 && rayDirY < 0) texX = texWidth - texX - 1;

 现在我们知道了纹理的x坐标,我们知道这个坐标将保持不变,因为我们保持在屏幕的同一垂直条纹中。现在,我们需要在y方向上进行循环,为垂直条纹的每个像素提供纹理的正确y坐标,称为texY。
texY的值是通过为每个像素增加预先计算的步长(这是可能的,因为这在垂直条纹中是恒定的)来计算的。步长告诉垂直屏幕坐标中每个像素的纹理坐标(以浮点表示)增加多少。然后,它需要将浮点值强制转换为整数,以选择实际的纹理像素。
注:可以使用更快的纯整数bresenham或DDA算法。

注意:这里所做的步骤是仿射纹理映射,这意味着我们可以在两点之间进行线性插值,而不必为每个像素计算不同的划分。一般来说,这是不正确的透视图,但对于完全垂直的墙壁(以及完全水平的地板/天花板),这是正确的,所以我们可以将其用于光线投射。
然后,简单地从纹理[texNum][texX][texY]中获得要绘制的像素的颜色,这是正确纹理的正确texel。
就像无纹理的光线投射器一样,如果墙的y侧被击中,我们也会使颜色值变暗,因为这看起来会更好一点(就像有一种照明)。然而,由于颜色值不存在于单独的R、G和B值之外,而是这3个字节粘在一个整数中,因此使用了不那么直观的计算。

通过将R、G和B除以2,颜色会变暗。将十进制数除以10,可以通过删除最后一位来完成(例如300/10是30:删除最后一个零)。类似地,将二进制数除以2,也就是这里所做的,与移除最后一位相同。这可以通过用>>1将其向右移位来完成。但是,这里我们对一个24位整数进行位移位(实际上是32位,但没有使用前8位)。正因为如此,一个字节的最后一位将成为下一字节的第一位,这会破坏颜色值!因此,在比特移位之后,每个字节的第一个比特都必须设置为零,这可以通过将值与二进制值011111110111111101111111进行二进制“与”来实现,二进制值0111111011111111111是十进制的8355711。所以这样做的结果确实是颜色变深了。
最后,将当前缓冲区像素设置为该颜色,然后继续下一个y。

      //每个屏幕像素的纹理坐标增加多少
      double step = 1.0 * texHeight / lineHeight;
      //起始纹理坐标
      double texPos = (drawStart - h / 2 + lineHeight / 2) * step;
      for(int y = drawStart; y<drawEnd; y++)
      {
        //将纹理坐标强制转换为整数,并在溢出时使用(texHeight-1)进行掩码
        int texY = (int)texPos & (texHeight - 1);
        texPos += step;
        Uint32 color = texture[texNum][texHeight * texY + texX];
        //使y边的颜色变暗:R、G和B字节各除以二,并带有“shift”和“and”
        if(side == 1) color = (color >> 1) & 8355711;
        buffer[y][x] = color;
      }
    }

现在,缓冲区仍然需要绘制,之后必须清除(在无纹理版本中,我们只需使用“cls”。由于缓存的内存位置,为了速度,请确保按扫描线顺序进行)。此代码的其余部分也是相同的。 


    drawBuffer(buffer[0]);
    for(int y = 0; y < h; y++) for(int x = 0; x < w; x++) buffer[y][x] = 0; //清除缓冲区而不是cls()
    //输入和FPS计数器的定时
    oldTime = time;
    time = getTicks();
    double frameTime = (time - oldTime) / 1000.0; //frametime是此帧所用的时间,以秒为单位
    print(1.0 / frameTime); //FPS计数器
    redraw();

    //速度修改器
    double moveSpeed = frameTime * 5.0; //常数值以平方/秒为单位
    double rotSpeed = frameTime * 3.0; //常数值以弧度/秒为单位

钥匙又来了,这里也没有什么变化。如果你喜欢,可以尝试添加扫射键(向左和向右扫射)。这些键的制作方式必须与上下键相同,但使用planeX和planeY而不是dirX和dirY。

    readKeys();
    //如果你面前没有墙,就向前走
    if (keyDown(SDLK_UP))
    {
      if(worldMap[int(posX + dirX * moveSpeed)][int(posY)] == false) posX += dirX * moveSpeed;
      if(worldMap[int(posX)][int(posY + dirY * moveSpeed)] == false) posY += dirY * moveSpeed;
    }
    //如果身后没有墙,就向后移动
    if (keyDown(SDLK_DOWN))
    {
      if(worldMap[int(posX - dirX * moveSpeed)][int(posY)] == false) posX -= dirX * moveSpeed;
      if(worldMap[int(posX)][int(posY - dirY * moveSpeed)] == false) posY -= dirY * moveSpeed;
    }
    //向右旋转
    if (keyDown(SDLK_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);
    }
    //向左旋转
    if (keyDown(SDLK_LEFT))
    {
      //摄影机方向和摄影机平面都必须旋转
      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);
    }
  }
}

以下是一些效果的屏幕截图:

 

 

 注意:通常图像是通过水平扫描线存储的,但对于光线投射器,纹理是以垂直条纹绘制的。因此,为了最佳地使用CPU的缓存并避免页面丢失,将纹理逐个垂直条纹而不是每条水平扫描线存储在内存中可能更有效。为此,在生成纹理后,将其X和Y交换为(仅当texWidth和texHeight相同时,此代码才有效 ):

  //交换纹理X/Y,因为它们将用作垂直条纹
  for(size_t i = 0; i < 8; i++)
  for(size_t x = 0; x < texSize; x++)
  for(size_t y = 0; y < x; y++)
  std::swap(texture[i][texSize * y + x], texture[i][texSize * x + y]);

或者只需在生成纹理的位置交换X和Y,但在许多情况下,加载图像或从其他格式获取纹理后,无论如何,您都会在扫描线中看到它,并且必须以这种方式进行交换。
当从纹理中获取像素时,请使用以下代码:

Uint32 color = texture[texNum][texSize * texX + texY];

Wolfenstein 3D纹理

与其只生成一些纹理,不如从图像中加载一些纹理!例如,以下8种纹理来自Wolfenstein 3D,版权归ID Software所有。

只需将生成纹理图案的代码部分替换为以下内容(并确保这些纹理位于正确的路径中)。你可以在这里下载纹理。 

  //生成一些纹理
  unsigned long tw, th;
  loadImage(texture[0], tw, th, "pics/eagle.png");
  loadImage(texture[1], tw, th, "pics/redbrick.png");
  loadImage(texture[2], tw, th, "pics/purplestone.png");
  loadImage(texture[3], tw, th, "pics/greystone.png");
  loadImage(texture[4], tw, th, "pics/bluestone.png");
  loadImage(texture[5], tw, th, "pics/mossy.png");
  loadImage(texture[6], tw, th, "pics/wood.png");
  loadImage(texture[7], tw, th, "pics/colorstone.png");

 

 在最初的《德军总部3D》中,墙一侧的颜色也比另一侧的颜色暗,以产生阴影效果,但它们每次都使用不同的纹理,深色和浅色。然而,在这里,每面墙只使用一个纹理,将R、G和B除以2的代码行使y面更暗。

性能注意事项

在现代计算机上,当使用高分辨率(截至2019年为4K)时,该软件raycaster将比一些更复杂的3D图形在GPU上使用3D显卡渲染的速度慢。
在本教程中,至少有两个问题阻碍了光线投射器代码的速度,如果你想制作一个超快速的光线投射器以获得非常高的分辨率,你可以考虑这些问题:

  • 光线投射使用垂直条纹,但内存中的屏幕缓冲区是用水平扫描线布置的。因此,绘制垂直条纹对缓存的内存局部性不利(事实上,这是最坏的情况),并且失去良好的缓存可能比现代机器上的一些3D计算更能影响速度。可以用更好的缓存行为对此进行编程(例如,一次处理多个条纹,使用缓存遗忘转置算法,或使用90度旋转的光线投射器),但为了简单起见,本教程的其余部分忽略了此缓存问题。
  • 这是在SDL中使用QuickCG(在QuickCG中,在redraw()中),与硬件渲染相比,这对于大分辨率来说是缓慢的。QuickCG对SDL本身的使用可能不是最佳的,例如使用OpenGL(甚至用于软件渲染)可能会更快,因此这可能在幕后解决。由于这个CG教程是关于软件渲染的,所以这里也忽略了这个问题。

END

万字长文,翻译不易,感兴趣的话点一个赞或收藏哦

下一篇敬请期待 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值