月度归档:2016年10月

那些中古游戏的技术(一)

概述

最近看到了一个非常有趣的东西,pico-8,这个小引擎模拟了70-80年代的机器性能,并提供了一个非常不错的编辑平台,包括了代码编辑器,地图编辑器,像素绘制器,甚至有音乐编辑器。开发者可以直接打开这个编辑器进行lua代码编写,因为引擎支持的技术比较久远,所以可以看到贡献者的游戏都是模拟中古时代的游戏,比如pacman,fc赛车等,另外由于卡带本身是文本格式,所以可以直接阅读贡献者代码,从中可以学习到不少东西。

扯回正题,正因为我看到了不少有趣的游戏,所以想整理下那些中古优秀游戏中,那些古老而精巧的技术。

光线投射(Raycasting)

介绍

由于早期的硬件性能限制,光线投是首个用来制作实时3d环境的技术手段,比如如下的 Wolfenstein 3D

image

需要注意的是光线投射不同于光线追踪(raytracing),光线追踪是用来生成真实的三维环境,有反射,阴影。而光线投射是一种廉价的实时半3d解决方案。

基本思路

游戏地图由2d的方格组成,每个方格可以是0(无障碍)或者是其他正值(代表不同的墙的材质)。

然后遍历屏幕的每个x坐标(即屏幕的每个垂直条纹),从相机的当前位置(玩家)发射一个射线,方向为玩家的视野角度和x坐标,然后计算射线击中墙壁的距离,通过这个距离可以计算墙壁渲染高度,这个距离越小,则渲染高度越高。

下图是一个顶视图,绿点代表玩家,红线代表两条射线,蓝色代表墙。

image

人类可以一眼就看出射线与墙的相交点,而计算机不太可能通过一个简单的公式就能得出射线与哪个方格相交了, 因为计算机只能检测有限数量的位置(实际上本文中的这句话不太特别准确),许多光线投射每个步数检测一定的常量距离,但是这有很大的概率错过某个墙,比如下图。

image

计算机错过了一个明显的蓝色墙。因为计算机只对红色点进行检测,就算你缩短检测间距,也不过让这个概率更低而已,我们需要更好的方法来解决这个问题。

image

这个更好的办法就是检查射线会遇到的每个墙的边。这样的话每个步进就不是一个常数,他取决于与下一个边的距离。

image

从上图可见,射线碰到了我们期望的墙,而不是传过去了。我们在这里指出的这个算法,其实是基于DDA(Digital Differential Analysis 数值微分法),DDA是一个快速的算法,一般是用来找出一条线经过了哪些方格,所以我们也可以用来找出射线击中了哪个方格,当一个墙的方格命中的时候,就停止该算法。

我们在提下相机的部分

image

由于这个游戏中的相机只能左右移动,所以可以将3d的相机行为直接做成一个简单的2d顶视图,绿色的点代表玩家当前位置,蓝色的线代表相机平面,平面的矢量朝右,所以右边蓝色的点为pos+dir+plane,黑色的点为pos+dir。

红色的线代表一些射线,这些射线矢量是很容易计算的,比如图上的第三条射线大约在右屏幕三分之一,那么这个矢量则为pos+dir+plane*1/3,得到这个矢量之后,通过他的xy分量就可以拿去DDA算法中使用了。

外部的两个边线为fov,即视角,这个很容易理解,我就不做翻译了。

对于这个游戏的相机来说,还有一个行为需要考虑,那就是旋转,旋转的话也比较容易,直接乘以一个旋转矩阵即可。

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

代码解析和详细介绍

未贴入纹理的光线投射器

下载源码

从最简单的平面着色开始,代码中还包含了fps计数和基本输入(转向和移动) 地图的组成是一个2d数组,每个值代表一个方格,如果值为0,代表这个方格什么都没有,可行走,如果大于0,则代表某一个类型的墙,目前地图的大小比较小,仅24×24大小的方格,但是作为样例来看已经足够了,在真实的商业游戏中,比如Wolfenstein 3D,远比这个大得多,一般也都是从外部文件中读取。 在本样例中,0代表空地, 1代表墙, 2代表一个小房间, 3代表一个柱子, 4代表走廊,具体代码如下。

#define mapWidth 24
#define mapHeight 24

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为66°,这个是一个常见的FPS游戏的FOV大小,键盘的左右可以用来旋转玩家朝向,但是相机平面的矢量必须保证同样垂直于朝向。

time还有oldTime用来存储当前帧以及上一帧的时间,主要用来计算FPS及用来计算每帧位移。

int main(int /*argc*/, char */*argv*/[])
{
  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 time = 0; //time of current frame
  double oldTime = 0; //time of previous frame

main函数剩余的部分是定义屏幕的分辨率,由于是遍历了每个竖向的屏幕,所以分辨率越大,则运行越慢。

screen(512, 384, 0, "Raycaster");

屏幕设置完毕后,游戏循环开始,在循环里面将会读取每帧输入及绘制每帧输出。

while(!done())
  {

接下来看是光线投射的部分,光线投射的循环是遍历所有的竖线,而不是屏幕的所有像素,所以遍历的压力会小得多,在处理光线投射逻辑前,我们需要定义一些变量。 rayPos代表射线起始位置,初始设置在玩家的位置

cameraX代表相机平面(即屏幕)的x坐标,屏幕最右边坐标值为1, 中间坐标值为0,左边坐标值为-1,按照上面的计算思路,射线的方向矢量则为玩家朝向矢量加上相机平面矢量的部分。

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 rayPosX = posX;
      double rayPosY = posY;
      double rayDirX = dirX + planeX * cameraX;
      double rayDirY = dirY + planeY * cameraX;

下一部分代码将会将之前说的DDA算法更加的深入编写。

mapX,mapY代表射线当前的方格位置,射线的位置是一个浮点型,而现在的mapX和mapY仅仅是作为一个方格坐标。

sideDistX和sideDistY初始化为射线起点到第一个x边和第一个y边的距离。

deltaDistX代表着第一个x边到下一个x边的距离,deltaDistY同理。

下图为这两个变量做了一个明确的图示。

image

perpWallDist待会会用来计算射线长度

DDA算法每次循环会跳过一个方格,不管方格是在x方向还是y方向,所以定义了两个变量stepX和stepY,这两个变量不是1就是-1。

最后hit变量用来标记循环结束后是否有命中。side变量用来标示命中的是x边还是y边,0代表x,1代表y

//which box of the map we're in
      int mapX = int(rayPosX);
      int mapY = int(rayPosY);

      //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
       // x/y = 1/y' => y' = y/x
       // so deltaDistX = sqrt(1^2 + y'^2)
      double deltaDistX = sqrt(1 + (rayDirY * rayDirY) / (rayDirX * rayDirX));
      double deltaDistY = sqrt(1 + (rayDirX * rayDirX) / (rayDirY * 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?

在DDA算法开始前,需要预先计算stepX,stepY,sideDistX,sideDistY

当射线方向为负的x分量时,stepX为-1, 反之为1,如果x分量为0时,则不使用stepX分量,y分量同理。

如果射线方向为负的x分量,则sideDistX为起始点到左边的第一个边,如果是正的x分量,则为右边的第一个边。

由于三角形的等比关系,则可算出sideDistX的值,sideDistY同理。

//calculate step and initial sideDist
      if (rayDirX < 0)
      {
        stepX = -1;
        sideDistX = (rayPosX - mapX) * deltaDistX;
      }
      else
      {
        stepX = 1;
        sideDistX = (mapX + 1.0 - rayPosX) * deltaDistX;
      }
      if (rayDirY < 0)
      {
        stepY = -1;
        sideDistY = (rayPosY - mapY) * deltaDistY;
      }
      else
      {
        stepY = 1;
        sideDistY = (mapY + 1.0 - rayPosY) * deltaDistY;
      }

接下来我们进入实际的DDA流程,循环中会每次循环进行一个方格的判断,我们通过sideDistX和sideDistY的大小判断下次应该判断的是x方向的下一个方格,还是y方向的下一个方格,并递增mapX和sideDistX,如果mapX,mapY对应的格子大于零,则判断击中,退出循环。

//perform DDA
      while (hit == 0)
      {
        //jump to next map square, OR 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;
      }

DDA完成后,我们需要计算射线与墙的距离,这样我们才能计算出需要绘制的墙的高度。需要注意的时,我们不使用斜边距离,而采用垂直相机平面的距离,否则会出现鱼眼效果。

这里是整个文章中最为困难的部分

通过代码分析应该得到的是如下图,其中如何得到除于rayDirX的,可参考下图,基本就是几何的三角形相似推出来的,实际上也可以通过斜边乘以这个角的余弦值得到,只不过计算量更多点。 另外(1 – stepX) / 2主要是为了兼容不同的step而已,不做解释。

image

//Calculate distance projected on camera direction (oblique distance will give fisheye effect!)
      if (side == 0) perpWallDist = (mapX - rayPosX + (1 - stepX) / 2) / rayDirX;
      else           perpWallDist = (mapY - rayPosY + (1 - stepY) / 2) / rayDirY;

计算完成距离之后,就可以计算画线的高度了,绘制高度与距离成反比,然后再乘以h,当然这里也可以乘以2h,这个取决于你要你的墙高点还是低点。

接下来我们需要计算绘制的起点及终点,在本例中,我们定义绘制的起点会导致墙体中心在屏幕中心,并做一些边界约束,代码如下

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

另外,我们通过不同的方块类型,定义不同的绘制颜色,另外当射线碰到y边界时,产生不同的亮度,这样效果会更好一些,至此整个循环结束。

//choose wall color
      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
      }

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

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

接下来的部分比较简单,本不想多做解释,既然都到这里了,还是继续吧。 后面主要是计算fps值和各种移动速度及转向速度。

//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();
    cls();

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

最后部分是读取输入,然后计算新的移动位置和朝向矢量

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

然后结果就是如此

image

下面是我用pico-8做的模拟。

贴入纹理的光线投射器

引入纹理的基本原理跟之前类似,主要的区别在于将创建屏幕缓冲,而非直接输出到屏幕中,然后在画线的部分做修改,不使用划线的api,而是绘制纹理的部分位置,所以主要的难点在于计算纹理uv坐标的位置。

因为难度不大,所以暂时跳过不做说明。

关于颜色预乘(premultiply)

引言

在许多纹理打包器中都有一个颜色预乘选项,但是我在第一次使用的时候,并没有特别理解这个句话的意思,理解上只是混合算法不同,并不理解引入这个的目的如何。

从理解上来说,将本来分离的RGBA四个因子重新预乘起来,是一个很奇怪的事情,特别是在早期,我一直觉得不预乘扩展性应该还好点,反正我可以在运行时再乘,也不是很在乎多一个乘法的效率问题。

为什么要引入预乘

这个就是我在本次阅读中学习到的点,我从这个文章中得到的启示,这个是spine论坛中的一篇文章,从目的到混合算法都说得很明确,我这里简单说明下。

传统的后乘alpha算法在shader或者混合配置中应该是这样的。

blend(source, dest) = (source.rgb * source.a) + (dest.rgb * (1 – source.a))

而预乘算法是这样的

blend(source, dest) = source.rgb + (dest.rgb * (1 – source.a))

从感受上来说,只是程序员为了效率引入的东西,而实际上他还解决了以下几个问题。 ·解决纹理比例缩放映射产生的颜色错误问题 ·可以和其他纹理一起正常混合而不打破批次渲染

本文最重要的目的是讲解第一点,为什么会出现颜色的错误问题。当一个图片素材被引擎放置在屏幕上时,往往不是1:1映射的,有可能因为在世界坐标的不同位置,映射到屏幕可能有不同的缩放比例。

比如有linear,nearest等算法可以将缺失的像素补全。这时候这些缺失像素的计算,会因为alpha因子的本身也会被平均而导致RGB因子被缩放两次,进而会产生一个黑色的边缘。

而实际上我们希望的结果应该是alpha因子RGB值只被平均一次,进而有需求就是将RGB值在素材中就被预乘。

关于瓷砖贴图的过渡(tile)

引言

瓷砖在游戏开发中的作用可以说非常巨大,哪怕在现在的2016年,依旧有非常多的游戏地表都是用有限的瓷砖拼起来的,除了id的mega texture以外,这里面的原因恐怕是不言自明的,因此我这段时间主要研究了一下这里面的一些技术问题,当然该问题跟美术相关性也比较大。

最早的瓷砖游戏

早期在红白机的几乎所有游戏都是瓷砖的,但是其中印象最深的或者最low的应该是dq1代,他的画面是这样的。

image

从这个游戏的画面可以明显的看到人为的瓷砖拼接痕迹,应该说瓷砖完全就是按类型划分的,中间完全没有过渡。 同样的位置,在sfc时间,已经变成了这样。

image

我们只看地形的话,特别是水与草地的连接区块,已经明显看到了有过渡,当然这种过渡本质上是美术做了第二个草地瓷砖导致的。 顺便说一句,dq1代是1986年出的,sfc版本在1993年同2代一起推出。

对于美国人来说,同样的图像进化印象最深的应该在Ultima IV到Ultima V的跨越,具体可见这个 地址

瓷砖存在的问题

使用瓷砖的首个问题恐怕跟现实中的一样,就是很明显的人造物体,而在游戏中,由于大量需要拟自然的草地,河流等,现在瓷砖首先需要处理的问题就是防止看起来像人造草地,人造河流。 其次是平滑的过渡,自然界的绝大部分地表与地表之间是有合理过渡的,比如草地与沙地,森林与草地等,而不会出现生硬的分割,比如dq1的fc版本。 最后一个问题是美术制作上的,如何生成一个可无限平铺的瓷砖,这个在技术上有一些手段,当然主要的处理还是在美术上。

瓷砖过渡

最简单的瓷砖过渡自然是人肉的方式,比如设计师在设计关卡的时候,根据周围的瓷砖选择应该选择的瓷砖,而美术在制作的时候就会制作一些过渡瓷砖,比如dq1 sfc版中主角的与水相邻的草地,就有明显的水花,这也与现实生活的观感一致,设计师在设计大地图的时候选中这块瓷砖即可。

但是关卡设计师如果每次想做个草地都要进行类似的判断,明显是一件愚蠢的行为,因为采用哪个瓷砖完全可以程序化,简单的说这个逻辑相当简单,完全可以用程序解决,而关卡设计师只要用填充工具填充一个草地即可,不需要关心用哪个过渡草地贴图。

如何处理瓷砖过渡

简单的计算下瓷砖过渡所需的块数

首先是最基本的一个类型一个瓷砖,那么块数显然是1

image

下图是我们预期达到的效果

image

看上去有点复杂,怎么计算需要多少块草瓷砖与水的过渡呢,以上图中三面环水的瓷砖举例,可以看如下的抽象图

1 0 0

0 # 0

1 1 1

#即那个瓷砖块,由这这个简易的抽象图可以明白,实际上采用那个瓷砖块由周围的8个格子决定,所以排列组合有2^8个,即256种组合,而这仅仅是草与水的组合,如果草与沙、泥土等那不得是天文数字。

所以下一步就是如何优化这个组合数量,可以看到这种组合排列方式有是以地形类型在图形中间为准的,即我们的思维方式是草,另外可以看到刚才抽象图中左上角的1对#号并没有相互影响的关系,也就是说,组合排列方式未必是相乘关系(当然想做的更好还是可以做不一样的,这两个草地相挨比较近,按道理中间应该有浅滩)。

现在来说一些详细的对策,对策一就是将4个边缘及4个对角的图片拆开处理,在程序中在动态合并起来,这样的话就只需要2^4 + 2^4 = 32个图片即可解决此问题,这种办法可以参见此文

对策二就是去除一些在游戏中不会出现或者不需要处理的过渡区块,比如刚才的草地对角(刚才也解释了,在这种情况下,实际上有会更好),对角外墙的衔接,这种处理往往在墙这种材质中比较常出现,适合泰格瑞亚这类游戏,具体可以参考此文中的Blob模式(仅需要47块),应该来说非常适合做此类游戏。

对策三是最常使用的招数,绝大部分游戏都使用此处理方式,那就是将瓷砖类型信息放在对角进行考虑,这样2^8 就直接变成了2^4即16个瓷砖组合。

image

当然这种方式也有几个问题,因为整体瓷砖有偏移,所以地表需要做偏移量,否则与人物等上层图层会有偏移。另外一块瓷砖就不仅仅是一个类型信息了,而是有四个类型信息,也就是占用内存会更多点。

在游戏应用上还有一种瓷砖过渡方式与此方式完全不同,过渡块实际上是一个单独的图片,程序会在游戏运行时动态用过渡块混合原始图片,这样美工的工作量很少很多,而且过渡块可以通用于很多资源,下图说明一切。

原理图

image

效果图 image

当然这种通用出来的过渡模板不可能通用一切类型,一般需要多个类型之间做几种,比如这个是我从帝国时代中截图得到的不同过渡块模板。

image

瓷砖类型间的过渡

刚才讨论的仅仅是两种瓷砖间的过渡,我们成功将256种可能缩减到了几十个,而如果有其他类型相互组合又如何处理呢,比如水与沙地,是否要重新制作几十个过渡块?

这个解决方法也有许多,比如在真实世界中,水一般不太可能跟砖块,铁等相互衔接,所以一般制作上不会按n*m的方式制作所有过渡块,只会在需要的时候制作即可,待会我们介绍Tiled编辑器的时候可以看到他是如何记录多个类型之间的过渡的。

另外一种处理方式比较讨巧,就是定好多个类型之间的层级关系,比如草地一定是在水上方,而岩石一定是在草地上方,而图片原型含有透明度,游戏运行是按层级做透明混合即可(war3即是这样的处理方式)。

2D游戏中的瓷砖过渡

下面我想举一些游戏中的例子,来说明下以上的做法。

编辑器Tiled

目前Tiled应该是2d瓷砖编辑器的王者,基本上包含了一个直接可用的瓷砖编辑器,另外他在0.9的版本新增了地形元素,包含了瓷砖过渡的功能。

image

可以看到沙地与鹅卵石、普通路面间均有过渡,另外操作上也很直观,采用的是方案三,即4斜角判断类型的方式,所以仅需16块瓷砖(实际上只需要15块,没有全是0的可能)即可自身瓷砖做任意平铺。不需要对角衔接的情况还可以去除两块。 具体的操作可以查看Tiled文档尝试一下。

泰格瑞亚

泰格瑞亚的采用比较极端,在Reddit上有泰格瑞亚美术的回答,他们使用的是256个过渡块,这个美术同学满是吐槽,我扒了下电脑上泰格瑞亚的游戏,还真是。

image

这个美术同学说,如果重新设计的话,他比较希望采用47块的那种方式,当然这种方式会导致对角块没有衔接,但是问题不大。

Starbound

Starbound是泰格瑞亚团队成员重新创立的一个科幻版的类泰格瑞亚,我没有仔细玩过,不过我特地研究过了一下他的tile拼接方式,发现他的格子是这样的。

image

瓷砖非常小。另外外部的衔接块只有4px,中间的块则是8px,我第一眼的直觉就是,这个应该还是类似方案三的过渡方式,上面的8×8的应该是对角衔接。于是我用ps重新做了下透明像素填充,为了能在Tiled中直接使用,于是这个泥土块变成了这样。

image

由此来看,泰格瑞亚的瓷砖过渡块是非常不人道的。顺便说下,starbound和饥荒的关卡编辑都是用Tiled制作的,虽然他们都包含了绝大部分程序化的世界生成,但是有非常多的套路关卡直接用Tiled设计了。

RPG Maker VX

这个是做RPG游戏非常有名的编辑器,出名的游戏我就不列举了,而这个编辑器给我最大的印象就是我在08年用js做一个rpg引擎时,一直苦恼于如何程序化衔接每个类型的瓷砖,而这个编辑器打开用,我很惊艳于他直接可用的过渡瓷砖,但是当时因为时间有限,并没有去深入研究他的实现,现在因为本篇文章的整理,基本上明白了他的实现原理就是我的类似方案三的实现,只不过他的地表应该是自带偏移了。他的砖块集如下,为了更明确的明白他的实现,我特地在一个可衔接砖块上标注了1到6。

image

不过我的标注应该是错误的,他实际上应该是13块的实现,即没有对角过渡。

3D游戏中的瓷砖过渡

3d游戏中普遍还是瓷砖地图,只不过绝大部分可能用了纹理混合达到细微调整。

魔兽争霸3

war3实际上在上面已经提到了,他使用15块过渡瓷砖做的地表,另外明确多个地表类型之间的层级关系,不过由于是3d游戏,有光照和阴影对场景的影响,所以导致看上去纹理并没有明显的重复。

下图是我用Tiled设定的war3草地图

image

与上面不同的是,war3有对角衔接图。

Dota2

Dota2的平面地表是可以做多材质,多纹理的混合的,而且混合效果非常好,衔接区块是不需要制作的,完全是用程序混合生成。 具体的可参考 这个 还有这个

Wang Tiles

除了边角处理以外,中间都相交的区块重复度还是很高,所以一般游戏基本上都会做多套随机使用,比如war3的图片,明显是有非常多的中间区块用来随机使用的,有没有更好的办法将这种重复感觉进一步弱化呢,答案是Wang Tiles。

image

本质上就是将随机选取中间块变成了根据更加多元化的边界及样式将人造感弱化。

文章已经有点长了,所以不太希望把这个话题再铺开,google下可以搜索得到很多相关文章,比如 这个, 这个

流亡黯道

流亡黯道的处理方式跟上文的瓷砖过渡没有太大关系,主要是使用了wang tiles的做法,做了一个比较不错的应用,我把他放在这里,官方这个教程很不错,主要是美术同学如何制作可以用来做wang tile的教程

其他方案

上文的瓷砖过渡方案主要都是美术制作的,而目前市面上3d游戏另外一部分制作是通过程序混合的,类似地形高度图的使用,应该是生成了专门的笔刷图或者顶点属性,在地图加载的时候动态混合出来使用的。 这样的话,美术可以直接在地图编辑器里作画,可以节省更多的美术资源,不过效果如何还是要看美术的功力。

比如在Dota2 Workshop中的这里文章中可以看到应该是在顶点中存了同个材质不同纹理在顶点的所占比重达到了混合效果,从dota2实际效果看,混合得挺不错的。

游戏样例分析

饥荒

image

饥荒的地表明显是Blob模式,即47块,每块为136像素 比较特别的是,饥荒的地表跟Blob模式是反向的,即实际操作中对应Blob模式的实物居然是空地,我在简单实现中发现,饥荒应该在设计阶段用的是1,0等简单的标示地表类型,而通过程序动态算出使用的区块的,因为如果是地表编辑的时候使用此方式,那么笔刷很难操作。

帝国时代

image

帝国时代的地表也是Blob模式,虽然有很多重复的上下左右贴图,不过那些应该在游戏中只是减少重复感使用的。 不过帝国时代需要衔接的地表类型比较多,所以通用了一个Blend纹理。