Lode 的计算机图形学教程

光线投射 II:地板与天花板

目录

返回目录

简介

在前一篇光线投射文章中,介绍了如何渲染无纹理的平面墙壁,以及如何渲染带纹理的墙壁。然而,地板和天花板一直保持平面且无纹理的状态。如果你希望地板和天花板保持无纹理,则无需额外代码,但若要给它们添加纹理,则需要更多的计算。
《Wolfenstein 3D》没有地板或天花板纹理,但在 Wolf3D 之后不久出现的一些其他光线投射游戏拥有这些纹理,例如《Blake Stone 3D》:



你可以在这里下载本教程的完整源代码。

工作原理

与墙壁纹理不同,地板和天花板纹理是水平的,因此不能像墙壁那样用垂直条纹的方式绘制。它们改用水平扫描线来绘制。 透视效果与墙壁类似,但旋转了 90 度。与墙壁每条垂直条纹恰好使用 1 个纹理不同,多个地板纹理(或同一纹理的重复)可能会跨越我们的水平线。

绘制天花板的方式与绘制地板相同,因此这里只解释地板的绘制。

地板投射在墙壁之前完成,因此我们先绘制整个地板(和天花板),然后在下一步中像之前一样用墙壁覆盖部分像素。
简而言之,地板投射的工作方式如下:逐扫描线处理。对于当前扫描线,计算与扫描线左侧像素对应的地板位置,以及与右侧像素对应的位置。这可以通过计算从摄像机出发、穿过摄像机平面上该像素的光线击中地板的位置来得出。相关公式和说明在下面的地板投射代码中。

然后,我们可以在最左点和最右点之间进行线性插值,以获得与该扫描线其他像素对应的地板坐标。这之所以可行,是因为地板纹理是完全水平的。如果地板是倾斜的,我们则需要使用代价更高的透视校正纹理映射。

注意:Ádám Tóth 于 2019 年贡献了水平扫描线技术的思路和演示代码。在此之前,本教程描述的是基于垂直条纹的技术,但水平技术更快,也更符合光线投射游戏的实际工作方式。垂直技术已移至末尾的单独章节。

代码

代码会尝试加载前一篇光线投射教程中的 wolfenstein 纹理,你可以在这里(版权归 id Software 所有)下载它们。 如果你不想加载纹理,可以改用前一篇光线投射教程中生成纹理的那部分代码,但效果会稍差一些。



代码的第一部分与前一篇光线投射教程完全相同,在此列出是为了说明新代码将插入的位置。这里还有一张新地图。这段代码声明了所有需要的变量、加载纹理,并绘制带纹理的垂直墙壁条纹。关于纹理的加载,请参阅前一篇光线投射教程中关于获取图像或使用其他方式生成纹理的说明。

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

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

Uint32 buffer[screenHeight][screenWidth]; // y-coordinate first because it works per scanline

int main(int /*argc*/, char */*argv*/[])
{
  double posX = 22.0, posY = 11.5;  //x and y start position
  double dirX = -1.0, dirY = 0.0; //initial direction vector
  double planeX = 0.0, planeY = 0.66; //the 2d raycaster version of camera plane

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

  std::vector<Uint32> texture[8];
  for(int i = 0; i l-< 8; i++) texture[i].resize(texWidth * texHeight);

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

  //load some textures
  unsigned long tw, th, error = 0;
  error |= loadImage(texture[0], tw, th, "pics/eagle.png");
  error |= loadImage(texture[1], tw, th, "pics/redbrick.png");
  error |= loadImage(texture[2], tw, th, "pics/purplestone.png");
  error |= loadImage(texture[3], tw, th, "pics/greystone.png");
  error |= loadImage(texture[4], tw, th, "pics/bluestone.png");
  error |= loadImage(texture[5], tw, th, "pics/mossy.png");
  error |= loadImage(texture[6], tw, th, "pics/wood.png");
  error |= loadImage(texture[7], tw, th, "pics/colorstone.png");
  if(error) { std::cout << "error loading images" << std::endl; return 1; }

  //start the main loop
  while(!done())
  {
接下来是新的地板投射代码,它逐行处理,而不是逐垂直条纹处理。

rowDistance 的公式表示从摄像机到当前行地板的水平距离,即 posZ / p,其中 p 是当前像素到屏幕中心的距离,可以解释如下:

摄像机光线经过以下两点:摄像机本身(位于某个高度 posZ),以及摄像机前方的一个点(穿过一个包含屏幕像素的假想垂直平面),该点与摄像机的水平距离为 1,垂直位置比 posZ 低 p(即 posZ - p)。当光线经过该点时,它在垂直方向移动了 p 个单位,在水平方向移动了 1 个单位。要击中地板,光线需要在垂直方向移动 posZ 个单位,水平方向的移动比例相同。穿过摄像机平面时的比例是 1 / p,因此要向前移动 posZ 倍才能到达地板,总水平距离为 posZ / p。

注意:这里所做的步进是仿射纹理映射,这意味着我们可以在两点之间进行线性插值,而不必为每个像素计算一次除法。这在一般情况下并不是透视正确的,但对于完全水平的地板/天花板(以及完全垂直的墙壁)来说是正确的,因此我们可以在光线投射中使用它。

    //FLOOR CASTING
    for(int y = 0; y < h; y++)
    {
      // rayDir for leftmost ray (x = 0) and rightmost ray (x = w)
      float rayDirX0 = dirX - planeX;
      float rayDirY0 = dirY - planeY;
      float rayDirX1 = dirX + planeX;
      float rayDirY1 = dirY + planeY;

      // Current y position compared to the center of the screen (the horizon)
      int p = y - screenHeight / 2;

      // Vertical position of the camera.
      float posZ = 0.5 * screenHeight;

      // Horizontal distance from the camera to the floor for the current row.
      // 0.5 is the z position exactly in the middle between floor and ceiling.
      float rowDistance = posZ / p;

      // calculate the real world step vector we have to add for each x (parallel to camera plane)
      // adding step by step avoids multiplications with a weight in the inner loop
      float floorStepX = rowDistance * (rayDirX1 - rayDirX0) / screenWidth;
      float floorStepY = rowDistance * (rayDirY1 - rayDirY0) / screenWidth;

      // real world coordinates of the leftmost column. This will be updated as we step to the right.
      float floorX = posX + rowDistance * rayDirX0;
      float floorY = posY + rowDistance * rayDirY0;

      for(int x = 0; x < screenWidth; ++x)
      {
        // the cell coord is simply got from the integer parts of floorX and floorY
        int cellX = (int)(floorX);
        int cellY = (int)(floorY);

        // get the texture coordinate from the fractional part
        int tx = (int)(texWidth * (floorX - cellX)) & (texWidth - 1);
        int ty = (int)(texHeight * (floorY - cellY)) & (texHeight - 1);

        floorX += floorStepX;
        floorY += floorStepY;

        // choose texture and draw the pixel
        int floorTexture = 3;
        int ceilingTexture = 6;
        Uint32 color;

        // floor
        color = texture[floorTexture][texWidth * ty + tx];
        color = (color >> 1) & 8355711; // make a bit darker
        buffer[y][x] = color;

        //ceiling (symmetrical, at screenHeight - y - 1 instead of y)
        color = texture[ceilingTexture][texWidth * ty + tx];
        color = (color >> 1) & 8355711; // make a bit darker
        buffer[screenHeight - y - 1][x] = color;
      }
    }

接下来是墙壁投射代码,与前一篇教程完全相同,这里没有任何新内容,仅为完整展示全部代码而列出。它紧接在地板投射之后执行。这部分是逐垂直条纹处理,而不像上面的地板投射代码那样逐行处理。

    //WALL CASTING
    for(int x = 0; x < w; x++)
    {
      //calculate ray position and direction
      double cameraX = 2 * x / double(w) - 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;

      //length of ray from one x or y-side to next x or y-side
      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;
      }

      //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;
      //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 - h / 2 + lineHeight / 2) * step;
      for(int y = drawStart; y<drawEnd; y++)
      {
        // Cast the texture coordinate to integer, and mask with (texHeight - 1) in case of overflow
        int texY = (int)texPos & (texHeight - 1);
        texPos += step;
        Uint32 color = texture[texNum][texWidth * texY + texX];
        //make color darker for y-sides: R, G and B byte each divided through two with a "shift" and an "and"
        if(side == 1) color = (color >> 1) & 8355711;
        buffer[y][x] = color;
      }

最后,屏幕被绘制并再次清空,同时处理输入。这段代码与之前相同。

    drawBuffer(buffer[0]);
    for(int y = 0; y < h; y++) for(int x = 0; x < w; x++) buffer[y][x] = 0; //clear the buffer instead of cls()

    //timing for input and FPS counter
    oldTime = time;
    time = getTicks();
    double frameTime = (time - oldTime) / 1000.0; //frametime is the time this frame has taken, in seconds
    print(1.0 / frameTime); //FPS counter
    redraw();

    //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
    readKeys();
    //move forward if no wall in front of you
    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;
    }
    //move backwards if no wall behind you
    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;
    }
    //rotate to the right
    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);
    }
    //rotate to the left
    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);
    }
  }
}

以下是低分辨率下的效果:



这个光线投射器在高分辨率下非常慢,肯定还有优化空间。

特殊技巧

这些技巧其实并不那么特别,只是一些可以修改以获得不同效果的方法。

要调整地板和天花板纹理的大小,例如将其放大 4 倍,可以修改代码的这一部分:

        int floorTexX, floorTexY;
        floorTexX = int(currentFloorX * texWidth) % texWidth;
        floorTexY = int(currentFloorY * texHeight) % texHeight;

改为:

        int floorTexX, floorTexY;
        floorTexX = int(currentFloorX * texWidth / 4) % texWidth;
        floorTexY = int(currentFloorY * texHeight / 4) % texHeight;



到目前为止,整个关卡各处的地板纹理都相同。由于在关卡的描述方式中,所有非墙壁的格子代码均为 0,因此无法用这个值为每个格子指定独立的地板纹理。你可以将非墙壁格子的值改为 0 或负数,这样在光线投射时负数表示无墙壁,该值可用于指定该处应使用哪个地板纹理。如果你想对天花板做同样的处理,还需要另一个值来表示天花板纹理,因此也可以考虑为墙壁、地板和天花板分别使用独立的地图。这里不采用那种方式,而是演示如何根据格子的坐标为每个格子指定独立纹理:如果其在地图上的 x 和 y 坐标之和为偶数,则使用纹理 3;若为奇数,则使用纹理 4,这将产生棋盘格图案。

要获取地图中当前格子的 x 和 y 坐标,取 currentFloorX 和 currentFloorY 的整数部分。为此,将地板投射部分的 for 循环修改如下(粗体部分为新增或修改的内容):

      //draw the floor from drawEnd to the bottom of the screen
      for(int y = drawEnd + 1; y < h; y++)
      {
        currentDist = h / (2.0 * y - h); //you could make a small lookup table for this instead

        double weight = (currentDist - distPlayer) / (distWall - distPlayer);

        double currentFloorX = weight * floorXWall + (1.0 - weight) * posX;
        double currentFloorY = weight * floorYWall + (1.0 - weight) * posY;

        int floorTexX, floorTexY;
        floorTexX = int(currentFloorX * texWidth) % texWidth;
        floorTexY = int(currentFloorY * texHeight) % texHeight;

        int checkerBoardPattern = (int(currentFloorX) + int(currentFloorY))) % 2;
        int floorTexture;
        if(checkerBoardPattern == 0) floorTexture = 3;
        else floorTexture = 4;

        //floor
        buffer[y][x] = (texture[floorTexture][texWidth * floorTexY + floorTexX] >> 1) & 8355711;
        //ceiling (symmetrical!)
        buffer[h - y][x] = texture[6][texWidth * floorTexY + floorTexX];
      }
    }



类似地,也可以根据地图来为每个格子选择地板纹理。currentFloorX 的整数部分给出当前地板格子在地图中的坐标,而小数部分则给出纹理上纹素的坐标。

如果将棋盘格代码从 "(int(currentFloorX) + int(currentFloorY)) % 2" 改为 "(int(currentFloorX + currentFloorY)) % 2", 得到的不是棋盘格图案,而是斜线条纹,因为此时小数部分也被加进去了。



垂直版本

作为上述水平扫描线地板投射技术的替代方案,也可以采用垂直方式处理。 这样可以在绘制当前墙壁条纹的同一垂直条纹中继续绘制地板。然而,这种技术更慢,因为它需要透视正确的纹理映射,对每个像素都要进行一次除法运算。此外,基于扫描线的技术也更快,因为扫描线顺序由于内存缓存的局部性而渲染更快。

结果看起来相同(地板仍然是水平的),只是以不同的方式渲染。

这种技术的工作方式如下:绘制完墙壁的垂直条纹后,对墙壁底部像素以下直到屏幕底部的每个像素进行地板投射。你需要知道当前条纹内地板上两个点的精确坐标,两个容易找到的点是:玩家的位置,以及墙壁正前方地板上的点。然后,对每个像素,计算其在地板上的投影到玩家的距离。有了这个距离,你可以通过在找到的两个点(墙壁处的点和你所在位置的点)之间进行线性插值,找到该像素所代表的地板的精确位置。

完成所有地板计算后,根据精确位置可以轻松找到纹理上纹素的坐标,从而获取需要绘制的像素颜色。由于地板和天花板是对称的,天花板纹理的纹素坐标相同,只需在屏幕上半部分对应的像素处绘制,并可以为天花板和地板使用不同的纹理。

当前像素的投影到地板的距离可按如下方式计算:

线性插值——根据当前距离和两个已知距离获取精确的地板位置——可以用一个权重因子来完成。该权重因子为 "weight = (currentDist - distPlayer) / (distWall - distPlayer)",由于当前像素始终位于墙壁和玩家位置之间,精确位置为:"currentFloorPos = weight * floorPosWall + (1.0 - weight) * playerPos"。注意 distPlayer 实际上为 0,因此权重实际上是 "currentDist / distWall"。


这次不给出完整代码,地板投射现在紧接在墙壁投射之后完成,在同一个 x 循环中。在添加此代码之前,不要忘记移除或禁用另一段地板投射代码。

墙壁绘制完毕后,地板投射即可开始。首先计算墙壁正前方地板的位置,根据光线击中的是墙壁的北、东、南还是西侧,共有 4 种不同情况。设置好该位置和距离后,y 方向的 for 循环从墙壁下方的像素开始直到屏幕底部,依次计算当前距离、由此得出权重、由此得出地板的精确位置,以及由此得出纹理上的纹素坐标。有了这些信息,就可以同时绘制地板和天花板像素。地板会被调暗处理。

    for(int x = 0; x < w; x++)
    {
      //WALL CASTING
      // [SNIP... the floor casting code goes in the same x-for-loop as the wall casting, wall casting code not duplicated here]

      //FLOOR CASTING (vertical version, directly after drawing the vertical wall stripe for the current x)
      double floorXWall, floorYWall; //x, y position of the floor texel at the bottom of the wall

      //4 different wall directions possible
      if(side == 0 && rayDirX > 0)
      {
        floorXWall = mapX;
        floorYWall = mapY + wallX;
      }
      else if(side == 0 && rayDirX < 0)
      {
        floorXWall = mapX + 1.0;
        floorYWall = mapY + wallX;
      }
      else if(side == 1 && rayDirY > 0)
      {
        floorXWall = mapX + wallX;
        floorYWall = mapY;
      }
      else
      {
        floorXWall = mapX + wallX;
        floorYWall = mapY + 1.0;
      }

      double distWall, distPlayer, currentDist;

      distWall = perpWallDist;
      distPlayer = 0.0;

      if (drawEnd < 0) drawEnd = h; //becomes < 0 when the integer overflows

      //draw the floor from drawEnd to the bottom of the screen
      for(int y = drawEnd + 1; y < h; y++)
      {
        currentDist = h / (2.0 * y - h); //you could make a small lookup table for this instead

        double weight = (currentDist - distPlayer) / (distWall - distPlayer);

        double currentFloorX = weight * floorXWall + (1.0 - weight) * posX;
        double currentFloorY = weight * floorYWall + (1.0 - weight) * posY;

        int floorTexX, floorTexY;
        floorTexX = int(currentFloorX * texWidth) % texWidth;
        floorTexY = int(currentFloorY * texHeight) % texHeight;

        //floor
        buffer[y][x] = (texture[3][texWidth * floorTexY + floorTexX] >> 1) & 8355711;
        //ceiling (symmetrical!)
        buffer[h - y][x] = texture[6][texWidth * floorTexY + floorTexX];
      }
    }

下一部分

直接跳转至第 III 部分
最后编辑于:2019 年

版权所有 © 2004-2020 Lode Vandevenne。保留所有权利。