133 行代码实现质感地形

<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
    <title>Terrain Demo - PlayfulJS</title>
  </head>
  <body style='background: #000'>
    <canvas id='display' width='1' height='1' />

    <script>

      function Terrain(detail) {
        this.size = Math.pow(2, detail) + 1;
        this.max = this.size - 1;
        this.map = new Float32Array(this.size * this.size);
      }

      Terrain.prototype.get = function(x, y) {
        if (x < 0 || x > this.max || y < 0 || y > this.max) return -1;
        return this.map[x + this.size * y];
      };

      Terrain.prototype.set = function(x, y, val) {
        this.map[x + this.size * y] = val;
      };

      Terrain.prototype.generate = function(roughness) {
        var self = this;

        this.set(0, 0, self.max);
        this.set(this.max, 0, self.max / 2);
        this.set(this.max, this.max, 0);
        this.set(0, this.max, self.max / 2);

        divide(this.max);

        function divide(size) {
          var x, y, half = size / 2;
          var scale = roughness * size;
          if (half < 1) return;

          for (y = half; y < self.max; y += size) {
            for (x = half; x < self.max; x += size) {
              square(x, y, half, Math.random() * scale * 2 - scale);
            }
          }
          for (y = 0; y <= self.max; y += half) {
            for (x = (y + half) % size; x <= self.max; x += size) {
              diamond(x, y, half, Math.random() * scale * 2 - scale);
            }
          }
          divide(size / 2);
        }

        function average(values) {
          var valid = values.filter(function(val) { return val !== -1; });
          var total = valid.reduce(function(sum, val) { return sum + val; }, 0);
          return total / valid.length;
        }

        function square(x, y, size, offset) {
          var ave = average([
            self.get(x - size, y - size),   // upper left
            self.get(x + size, y - size),   // upper right
            self.get(x + size, y + size),   // lower right
            self.get(x - size, y + size)    // lower left
          ]);
          self.set(x, y, ave + offset);
        }

        function diamond(x, y, size, offset) {
          var ave = average([
            self.get(x, y - size),      // top
            self.get(x + size, y),      // right
            self.get(x, y + size),      // bottom
            self.get(x - size, y)       // left
          ]);
          self.set(x, y, ave + offset);
        }
      };

      Terrain.prototype.draw = function(ctx, width, height) {
        var self = this;
        var waterVal = this.size * 0.3;

        for (var y = 0; y < this.size; y++) {
          for (var x = 0; x < this.size; x++) {
            var val = this.get(x, y);
            var top = project(x, y, val);
            var bottom = project(x + 1, y, 0);
            var water = project(x, y, waterVal);
            var style = brightness(x, y, this.get(x + 1, y) - val);

            rect(top, bottom, style);
            rect(water, bottom, 'rgba(50, 150, 200, 0.15)');
          }
        }

        function rect(a, b, style) {
          if (b.y < a.y) return;
          ctx.fillStyle = style;
          ctx.fillRect(a.x, a.y, b.x - a.x, b.y - a.y);
        }

        function brightness(x, y, slope) {
          if (y === self.max || x === self.max) return '#000';
          var b = ~~(slope * 50) + 128;
          return ['rgba(', b, ',', b, ',', b, ',1)'].join('');
        }

        function iso(x, y) {
          return {
            x: 0.5 * (self.size + x - y),
            y: 0.5 * (x + y)
          };
        }

        function project(flatX, flatY, flatZ) {
          var point = iso(flatX, flatY);
          var x0 = width * 0.5;
          var y0 = height * 0.2;
          var z = self.size * 0.5 - flatZ + point.y * 0.75;
          var x = (point.x - self.size * 0.5) * 6;
          var y = (self.size - point.y) * 0.005 + 1;

          return {
            x: x0 + x / y,
            y: y0 + z / y
          };
        }
      };

      var display = document.getElementById('display');
      var ctx = display.getContext('2d');
      var width = display.width = window.innerWidth;
      var height = display.height = window.innerHeight;

      var terrain = new Terrain(9);
      terrain.generate(0.7);
      terrain.draw(ctx, width, height);

    </script>
    <script>
      (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
      (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
      m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
      })(window,document,'script','//www.google-analytics.com/analytics.js','ga');

      ga('create', 'UA-50885475-1', 'playfuljs.com');
      ga('send', 'pageview');
    </script>
  </body>
</html>

程序员们都喜欢创造一些东西,但是,还有什么会比创建一个世界更让人感到惊喜?想想Minecraft, Terragen, Skyrim,以及以前的每一个都会使用一些生成分形地形的飞行模拟器。今天,我们要来探索如何使用漂亮而又简单的QPSO算法 (diamond-square algorithm),到时,你也可以扮演上帝![ Demo] [ Source]

程序员往往是懒惰的(从经验来说的话),而懒惰的一个很好地“副作用”就是这真的是可以避免一些(重复)工作的很不错的方式。既然这样, 与其花上乏味的几个小时来创建可能是很蹩脚的岩石表面,不如我们在思想上教会电脑岩石到底是什么。为了达到我们的目的,我们会生成分形或者形状,而这些形状会以越来越小的变化不断重复。

我并不能以某种方式来证明地形确实是分形的,但是这种方法看起来真的很不错,因此你可以信任这种方法。

133 行代码实现质感地形_第1张图片

立体地图

我们会将我们的地形存储为一个简单地立体地图:一个由地形在任意给定的x,y坐标上的高度值所组成的二维数组。这是一个比较简单的数据结构,用我们喜欢的canvas,webgl,interpretive dance等技术都可以来渲染这些高度值。最大的限制是我们不能在地形中表示有垂直的洞的形状,比如洞穴,隧道或者桥梁。

1
2
3
4
5
function Terrain(detail) { 
   this .size = Math.pow(2, detail) + 1; 
   this .max = this .size - 1; 
   this .map = new Float32Array( this .size * this .size); 
}

对任何尺寸的网格你都可以应用上面的算法来生成地图,但是对于一个由2的整数幂加1的网格组成方形来说它是最简单的。我们将使用x、y和z轴相同大小的值,在一个多维数据集中实现我们的地形。我们把相关的细节(detail)(即网格的数量)转化成了2的整数幂加1,因此更多的网格数量需要有更大的数据集。

 

对应的算法

想法是这样的:取一个平面的方形。把它分成4个子方形,然后把这4个子方形的中心向上或向下随机的偏移一定量。把这些子方形再分成更多的子方形并且重复上面的步骤,每一次都将偏移的量减少,这样第一次的偏移会有最大的效果而后面的偏移都会提供更小的细节(起伏程度)。

这就是中点置换算法(Midpoint displacement algorithm)。我们的菱形算法基于类似的原则,但是生成了看起来会更自然的结果。与其只是把方格分成更多的子方格,不如在分成子方格与分成子的菱形方格之间做个替换。

133 行代码实现质感地形_第2张图片

 

1.设置各个角的坐标

首先,我们要设置各个角的坐标值来作为“种子”值,它会影响后面的呈现。我们会将所有的角落从数据集的一半的位置开始:

1
2
3
4
this .set(0, 0, self.max / 2); 
this .set( this .max, 0, self.max / 2); 
this .set( this .max, this .max, self.max / 2); 
this .set(0, this .max, self.max / 2);

 

2.将地图分块

现在,我们将会递归的来看立体地图的越来越小的分块。在每一个分块的过程中,我们会把地图分成方块,并在方形阶段更新它们的中心点。然后,我们会把地图分成菱形,并在菱形阶段再次更新它们的中心点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
divide( this .max); 
 
function divide(size) { 
   var x, y, half = size / 2; 
   var scale = roughness * size; 
   if (half < 1) return
 
   for (y = half; y < self.max; y += size) { 
     for (x = half; x < self.max; x += size) { 
       square(x, y, half, Math.random() * scale * 2 - scale); 
    
  
   for (y = 0; y <= self.max; y += half) { 
     for (x = (y + half) % size; x <= self.max; x += size) { 
       diamond(x, y, half, Math.random() * scale * 2 - scale); 
    
  
   divide(size / 2); 
}

saele变量保证了随着我们分块的次数的增多,偏移量是不断减小的。对于每一次分块,我们将当前的size变量与roughness相乘,roughness决定了我们的地形是平滑的(该变量值趋近于0时)还是起伏的(该变量值趋近于1时)。

 

3.形状

两种形状(方形和菱形)的工作机制是类似的,但是要从不同的点来绘制数据。在方形阶段,要在应用随机偏移之前获取四个角的坐标的平均值,在菱形阶段要在执行随机偏移之前获取四个边缘点的坐标的平均值。

1
2
3
4
5
6
7
8
9
function diamond(x, y, size, offset) { 
   var ave = average([ 
     self.get(x, y - size),      // top 
     self.get(x + size, y),      // right 
     self.get(x, y + size),      // bottom 
     self.get(x - size, y)       // left 
   ]); 
   self.set(x, y, ave + offset); 
}

 

渲染

算法只是给了我们数据,我们可以用很多种方式来渲染数据。我们将整合一连串的渲染技巧来渲染一个位于canvas元素上的栅格化的,等距的,3d形式的地形图上。

133 行代码实现质感地形_第3张图片

 

从back到front

首先,我们将创建嵌套的循环从我们地图的“back”(y = 0) 到“front”(y=this.size)来绘制矩形。如果你要渲染一个简单地,平的,自顶向下的方形,那么要执行的循环是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
for ( var y = 0; y < this .size; y++) { 
   for ( var x = 0; x < this .size; x++) { 
     var val = this .get(x, y); 
     var top = project(x, y, val); 
     var bottom = project(x + 1, y, 0); 
     var water = project(x, y, waterVal); 
     var style = brightness(x, y, this .get(x + 1, y) - val); 
 
     rect(top, bottom, style); 
     rect(water, bottom, 'rgba(50, 150, 200, 0.15)' ); 
  
}

 

光亮和阴影

我们对于集合映射的原始方法提供了一个很好的视觉文理。通过比较当前的高度值和下一个点的高度值,我们会找到一个坡度。坡度高的一侧我们用较亮的矩形来填充,另一侧则用较暗的矩形来填充。

1
2
var b = ~~(slope * 50) + 128; 
return [ 'rgba(' , b, ',' , b, ',' , b, ',1)' ].join( '' );

 

等轴投影

我们可以从正面来绘制每一样东西,但是,在将方块转为3d之前,先将它转为菱形看起来会更有趣。等轴投影将左上角和右下角在视图的中间对齐。

1
2
3
4
5
6
function iso(x, y) { 
   return
     x: 0.5 * (self.size + x - y), 
     y: 0.5 * (x + y) 
   }; 
}

 

透视投影

我们将使用一个同样简单的3d投影转换我们的x,y,z值为在二维视角屏幕上的平面图像。

所有的透视投影的基本想法都是用水平和垂直的位置除以深度,那样的话更高的深度的渲染就会更接近于原点(例如,越远的物体就会看起来越小)。

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function project(flatX, flatY, flatZ) { 
     var point = iso(flatX, flatY); 
     var x0 = width * 0.5; 
     var y0 = height * 0.2; 
     var z = self.size * 0.5 - flatZ + point.y * 0.75; 
     var x = (point.x - self.size * 0.5) * 6; 
     var y = (self.size - point.y) * 0.005 + 1; 
 
     return
       x: x0 + x / y, 
       y: y0 + z / y 
     }; 
  
};

 

把所有的内容整合起来

首先,我们用我们所期望的细小平面创建了一个地形实例。然后我们生成了它的立体地图,提供了一个位于0和1之间的roughness值。最后,我们把地形绘制到了canvas上。

 

1
2
3
var terrain = new Terrain(9); 
terrain.generate(0.7); 
terrain.draw(canvasContext, width, height);

 

试试看

请点击下面链接查看最终效果:来自另一个世界的地形(otherworldly terrain)

接下来

如果你跟我一样,这个简单的算法会让你渴望去创造一个在线的自制梦幻风景,一个基于飞行器的第一人称射击游戏,模拟钓鱼或者一个大型多人在线角色扮演游戏等等。这个单一的立方体式的,基于canvas的demo非常需要扩展。

下面的几项我希望你能去尝试:

1.用WebGl渲染;

2.跟随高度的变化,高度越小的地形越平滑(像沙子),高度越大的越崎岖;

3.投射暗影,而不是简单地基于斜坡来产生阴影;

4.添加一个功能,生成洞穴和隧道。

按照惯例,你还可以在这里看到这个想法。

 

相关的工作

现在有很多人在研究这个算法,并且他们创建了很多很多很酷的东西。而且,黑客新闻讨论也展示了很多相关的真的非常不可思议的例子。这里有几个比较突出的:

  • WebGL rendering implementation by callum
  • Objective C implementation by Chris Cieslak
  • Processing implementation by Jerome Herr
  • Heightmap-based raycaster by namuol
  • Procedural demo entry explanation by Inigo Quilez
  • Fractional Brownian Motion by rbaravaelle
  • Polygonal game map generation by Red Blob Games

 

讨论

可以在 Hacker News 参与讨论。

原文链接: Hunter Lofti   翻译: 伯乐在线 abell123
译文链接: http://blog.jobbole.com/68668/

完全源代码:

你可能感兴趣的:(133 行代码实现质感地形)