构建 ViziCities 的经验教训

就在两周多前,Peter SmartRobin Hawkes 发布了ViziCities 的第一个版本。该版本可供免费使用,并根据 MIT 许可证开源。

在这篇文章中,我将与您分享在 ViziCities 开发过程中所学到的经验教训。从应用程序架构到细致入微的 WebGL 渲染改进,我们在过去的一年里学到了很多,我们希望通过分享我们的经验,帮助其他人避免同样的错误。

什么是 ViziCities?

简单来说,ViziCities 是一款 WebGL 应用程序,它允许您以 3D 方式可视化世界上的任何地方。它的主要目的是观察城市区域,但如果某个区域有完善的 OpenStreetMap 覆盖范围,它也能在其他地方完美运行。

演示

解释 ViziCities 功能的最佳方式是 亲身体验。您需要一个 支持 WebGL 的浏览器,并意识到您正在使用的是 pre-alpha 版本的软件。单击并拖动鼠标在周围移动,使用鼠标滚轮放大,并通过单击鼠标滚轮或按住 Shift 键的同时单击并拖动来旋转相机。

如果您现在无法试用演示,也可以观看这段简短的视频

它的意义何在?

我们开始这个项目的原因有很多。其中一个原因是,对于我们来说,这是一个激动人心的技术和设计挑战——Peter 和我都热衷于通过探索未知领域来挑战自我。

另一个原因是我们受到了最新 SimCity 游戏的启发,以及他们如何可视化城市性能数据——事实上,SimCity 开发商 Maxis 联系了我们,告诉我们他们非常喜欢这个项目!

为现实世界城市而不是虚构城市创建一种可视化方法,这令人振奋。想象一下,如果您可以以 3D 方式查看城市,并叠加城市最新的数据,这将是一件多么有吸引力的事情。想象一下,如果您可以看到您所在地区的普查数据、教育数据、健康数据、犯罪数据、财产信息、实时交通(火车、公共汽车、交通等),您将能够更多地了解您居住的地方。

这仅仅是开始——可能性是无限的。

为什么要使用 3D?

我们经常被问到“为什么要用 3D?”——除了“因为它是一种视觉上令人感兴趣的城市查看方式”之外,简而言之,3D 允许您以二维地图无法实现的方式进行操作和分析数据。例如,通过使用 3D,您可以考虑高度和深度,因此您可以更好地可视化城市中您上下方大量的事物——例如管道和地下隧道,或桥梁、高架桥、高层建筑、天气和飞机!在二维地图上,查看所有这些东西会非常混乱,而在 3D 中,您可以像在现实世界中一样看到它——您可以轻松地查看城市中的物体是如何相互关联的。

核心技术

在最基本的层面上,ViziCities 使用 Three.js 构建,这是一个 WebGL 库,它抽象了浏览器中 3D 渲染的所有复杂性。我们还使用一系列其他技术,例如 Web Workers,它们各自服务于特定的目的或解决我们在开发过程中遇到的特定问题。

现在让我们看看其中的一些技术。

经验教训

在过去的一年里,我们从对 3D 渲染和地理数据可视化几乎一无所知,到至少对它们中的每一个都足够了解,以至于我们开始变得危险。在此过程中,我们遇到了很多障碍,不得不花大量时间找出问题所在,并想出解决方案来克服这些障碍。

解决问题的过程是我非常热衷的一种过程,但它并不适合所有人,我希望我将要分享的经验教训能够帮助您避免这些相同的问题,从而节省时间,并有更多的时间去做生活中更重要的事情。

这些经验教训没有特定的顺序。

使用模块化、解耦的应用程序架构从长远来看会得到回报

最初,我们使用依赖性很高的实验性原型,这些原型无法轻松拆分并在其他实验中使用。虽然这让我们了解了所有内容的工作原理,但它很混乱,而且在构建一个完整的应用程序时,它会造成很多麻烦。

最终,我们基于一种简单的使用 构造函数模式prototype 属性的方法重新编写了所有内容。使用这种方法,我们可以将逻辑分离到解耦的模块中,使所有内容更容易理解,同时还能让我们扩展和替换功能,而不会破坏其他任何东西(我们使用 Underscore _.extend 方法 来扩展对象)。

这里有一个例子,说明了我们如何使用构造函数模式。

为了在模块之间进行通信,我们使用 中介者模式。这使我们能够尽可能地保持解耦,因为我们可以发布事件,而不必知道谁订阅了这些事件。

这里有一个例子,说明了我们如何使用中介者模式

/* globals window, _, VIZI */
(function() {
  "use strict";

  // Apply to other objects using _.extend(newObj, VIZI.Mediator);
  VIZI.Mediator = (function() {
    // Storage for topics that can be broadcast or listened to
    var topics = {};

    // Subscribe to a topic, supply a callback to be executed
    // when that topic is broadcast to
    var subscribe = function( topic, fn ){
      if ( !topics[topic] ){
        topics[topic] = [];
      }

      topics[topic].push( { context: this, callback: fn } );

      return this;
    };

    // Publish/broadcast an event to the rest of the application
    var publish = function( topic ){
      var args;

      if ( !topics[topic] ){
        return false;
      }

      args = Array.prototype.slice.call( arguments, 1 );
      for ( var i = 0, l = topics[topic].length; i < l; i++ ) {

        var subscription = topics[topic][i];
        subscription.callback.apply( subscription.context, args );
      }
      return this;
    };

    return {
      publish: publish,
      subscribe: subscribe
    };
  }());
}());

我认为,这两种模式是新 ViziCities 应用程序架构中最有用的方面——它们使我们能够快速迭代,而不必担心破坏所有内容。

使用 promise 代替回调

在项目初期,我和我的朋友 Hannah Wolfe (Ghost 的 CTO) 谈论了回调有多么烦人,尤其是当您想按顺序加载大量内容时。Hannah 马上指出了我有多么愚蠢(感谢 Hannah),并说我应该使用 promise,而不是与回调作斗争。当时,我把它们当作另一种时髦的潮流,但最终她是正确的(一如既往),从那时起,我尽可能地使用 promise 来重新控制应用程序流程。

对于 ViziCities,我们最终使用了 Q 库,尽管有很多其他的选择(Hannah 在 Ghost 中使用 when.js)。

无论您选择哪个库,一般的用法都是一样的——您设置 promise,并在以后处理它们。但是,当您想将一堆任务排队,并按顺序处理它们,或者在所有任务完成后执行某些操作时,promise 的优势就体现出来了。我们在许多地方使用了它,最明显的是在 首次加载 ViziCities 时(这也让我们可以输出进度条)。

我不会说谎,promise 需要一段时间才能理解,但一旦你理解了,你就会永远无法回头。我保证(抱歉,忍不住)。

使用一致的构建流程和基本测试

我一直都不是一个太关心流程、代码质量、测试,甚至确保事情按正确的方式完成的人。我是一个爱折腾的人,我更喜欢学习和看到结果,而不是花时间在构建一个可靠的流程上,这让我感觉是在浪费时间。事实证明,我的这种爱折腾的方法并不适合大型 Web 应用程序,大型 Web 应用程序需要一致性和健壮性。谁会想到呢?

为了确保代码的一致性和质量,第一步是启用 严格模式代码风格检查。这意味着最明显的错误和不一致之处会在早期被发现。另外,由于我们使用了构造函数模式,我们 将每个模块包装在一个匿名函数中,以便我们可以按模块启用严格模式,而不必将其全局启用。

在这一点上,使用手动流程来创建新的构建(使用所有模块和外部依赖项生成单个 JavaScript 文件)和服务示例仍然很麻烦。突破性进展是采用了一个使用 Grunt 的完整构建系统,这主要归功于去年在一次活动中与 Jack Franklin 聊天时,他了解了我的困境(随后,他把感冒传给了我,我花了 8 周才好,但它值了)。

Grunt 使我们能够在终端中运行一个简单的命令来执行诸如自动测试、连接和压缩文件以供发布等操作。我们还使用它来服务本地构建,并在示例在浏览器中打开时自动刷新它们。您可以查看 我们的 Grunt 设置,以了解我们如何设置所有内容。

对于自动化测试,我们使用 MochaChaiSinon.jsSinon-ChaiPhantomJS。它们中的每一个在测试过程中都扮演着略有不同的角色

  • Mocha 用于整体测试框架
  • Chai 用作断言库,允许您编写可读的测试
  • Sinon.js 用于模拟应用程序逻辑,并通过测试过程跟踪行为
  • PhantomJS 用于在从终端启动的无头浏览器中运行客户端测试

我们已经创建了一些(不可否认地是基本的)测试,我们计划在发布 0.1.0 版本之前改进并增加测试覆盖率。

Travis CI 用于确保我们在将更改推送到 GitHub 时不会破坏任何东西。它会在推送更改时自动执行代码风格检查并通过 Grunt 运行我们的测试,包括来自其他贡献者的拉取请求(救命稻草)。此外,它还允许您在 GitHub 自述文件中放置一个很酷的徽章,向所有人显示当前版本是否正在成功构建。

这些解决方案共同使 ViziCities 比以往任何时候都更加可靠。它们还意味着我们可以通过自动构建快速移动,并且我们不必担心意外破坏任何东西。这种安心是无价的。

监控性能以衡量改进

可以使用 FPSMeter 监控每秒帧数的总体性能。它有助于调试导致浏览器锁定或阻止渲染循环以快速运行的应用程序部分。

您还可以使用 Three.js renderer.info 属性 监控正在渲染的内容以及它如何随时间推移而变化。

值得注意的是,要确保物体在移出当前视窗时不会被渲染。在 ViziCities 的早期,我们在这方面遇到了很多问题,唯一确保的方法是监控这些值。

使用 D3.js 将地理坐标转换为 2D 像素坐标

我们遇到的第一个问题之一是如何将地理坐标(纬度和经度)转换为基于像素的坐标。实现这一目标所涉及的数学并不简单,如果你想考虑不同的地理投影,它会变得更加复杂(相信我,它会很快变得令人困惑)。

幸运的是,D3.js 库 已经为你解决了这些问题,特别是在其 geo 模块 中。假设你已经包含了 D3.js,你可以像这样转换坐标

var geoCoords = [-0.01924, 51.50358]; // Central point as [lon, lat]
var tileSize = 256; // Pixel size of a single map tile
var zoom = 15; // Zoom level

var projection = d3.geo.mercator()
  .center(geoCoords) // Geographic coordinates of map centre
  .translate([0, 0]) // Pixel coordinates of .center()
  .scale(tileSize << zoom); // Scaling value

// Pixel location of Heathrow Airport to relation to central point (geoCoords)
var pixelValue = projection([-0.465567112, 51.4718071791]); // Returns [x, y]

scale() 值是理解该过程中最难的部分。它基本上会根据你想要缩放的程度(想象一下在 Google Maps 上放大)来改变返回的像素值。我花了很长时间才理解它,所以我在 ViziCities 源代码中 详细介绍了 scale 的工作原理,以便其他人学习(以及为了让我自己记住!)。一旦你掌握了缩放,你将完全控制地理到像素的转换过程。

将 2D 建筑轮廓实时挤出成 3D 物体

虽然 2D 建筑轮廓很容易找到,但将它们转换为 3D 物体结果并不像我们想象的那样容易。目前还没有包含 3D 建筑物的公共数据集,虽然这很可惜,但它让自行完成这件事变得更加有趣。

我们最终使用的是 THREE.ExtrudeGeometry 对象,将对表示 2D 建筑占地面积的像素点数组(作为 THREE.Shape 对象)的引用传递给它。

以下是将 2D 轮廓挤出成 3D 物体的基本示例

var shape = new THREE.Shape();
shape.moveTo(0, 0);
shape.lineTo(10, 0);
shape.lineTo(10, 10);
shape.lineTo(0, 10);
shape.lineTo(0, 0); // Remember to close the shape

var height = 10;
var extrudeSettings = { amount: height, bevelEnabled: false };

var geom = new THREE.ExtrudeGeometry( shape, extrudeSettings );
var mesh = new THREE.Mesh(geom);

有趣的是,事实证明,实时生成 3D 物体实际上比预先渲染它们并加载它们更快。这主要是因为下载预先渲染的 3D 物体比下载 2D 坐标字符串并将其在运行时生成要花费更长的时间。

使用 Web Workers 显著提高性能并防止浏览器锁定

我们注意到的一件事是,生成 3D 物体会导致浏览器锁定,特别是在同时处理大量形状(比如整个城市)时。为了解决这个问题,我们深入研究了 Web Workers 的神奇世界。

Web Workers 允许你在一个与浏览器渲染器完全独立的处理器线程中运行应用程序的一部分,这意味着在 Web Worker 线程中发生的任何事情都不会减慢浏览器渲染器(即不会锁定)。它非常强大,但要让它按你想要的方式工作也可能非常复杂。

我们最终使用了 Catiline.js Web Worker 库来抽象一些复杂性,并让我们能够专注于利用 Web Workers 的优势,而不是与它们作斗争。结果是 一个 Web Worker 处理脚本,它传递 2D 坐标数组并返回生成的 3D 物体。

在让它正常工作后,我们注意到,虽然大多数浏览器锁定都被消除了,但引入了两种新的锁定。具体来说,在将 2D 坐标传递给 Web Worker 脚本时出现了一种锁定,在将 3D 物体返回到主应用程序时出现了另一种锁定。

这个问题的解决方案来自 Vladimir AgafonkinLeafletJS 的著名人物)的灵感。他帮助我理解,为了避免后一种锁定(将 3D 物体传递回应用程序),我需要使用 可传输对象,即 ArrayBuffer 对象。这样做允许你有效地将 Web Worker 线程中创建的物体的所有权转移到主应用程序线程,而不是复制它们。我们 有效地实现了这一点,完全消除了第二种锁定。

为了消除第一种锁定(将 2D 坐标传递给 Web Worker),我们需要采取不同的方法。问题再次在于数据的复制,但在这种情况下,你无法使用可传输对象。解决方案在于使用 importScripts 方法 将数据加载到 Web Worker 脚本中。不幸的是,我还没有找到一种方法可以在使用 XHR 请求获取的动态数据时执行此操作。尽管如此,这绝对是一个可行的解决方案。

使用 simplify.js 在渲染之前降低 2D 形状的复杂度

我们早期发现的一件事是,复杂的 2D 形状在作为 3D 物体大量渲染时会造成很大的压力。为了解决这个问题,我们使用 Vladimir Agafonkin 的 simplify.js 库来降低 2D 形状的质量,然后再渲染。

这是一个很棒的小工具,它允许你在大幅减少所用点的数量的同时保留总体形状,从而降低其复杂度和渲染成本。通过使用这种方法,我们可以在几乎不改变物体外观的情况下渲染更多物体。

获取建筑物的准确高度非常困难

我们从未想到会遇到一个问题,那就是获取城市中建筑物的准确高度信息。虽然数据存在,但通常价格高得令人难以置信,或者要求你接受教育才能获得折扣。

我们采取的方法是使用 来自 OpenStreetMap 的准确高度数据(如果可用),回退到一个最佳猜测,该猜测使用 建筑类型 结合 2D 占地面积。在大多数情况下,这将比仅仅选择一个随机高度(我们最初是这样做的)对高度进行更准确的猜测。

限制相机移动以控制性能

ViziCities 的 最初的梦想 是在一个地方可视化整个城市,飞来飞去,从云层中俯瞰城市,就像某种神灵一样。我们很快发现,这需要付出代价,即性能代价和数据大小代价。我们负担不起这两者。

当我们意识到这不可能实现时,我们考虑从不同的角度来解决问题。如何在不渲染整个城市的情况下感觉自己正在观看整个城市?解决方案出乎意料地简单。

通过限制相机移动,使其一次只能查看一小块区域(限制缩放和旋转),你能够更好地控制一次可以有多少个物体出现在视野中。例如,如果你阻止用户倾斜相机以查看地平线,那么你就不需要渲染相机与场景边缘之间的每一个物体。

这种简单的方法意味着你可以在 ViziCities 中前往世界上任何地方,无论是繁华的大都市还是乡村隐居地,都不需要改变你处理性能的方式。每种情况都是可以预测的,因此可以优化。

基于瓦片的物体批处理以提高加载和渲染性能

我们采取的另一种提高性能的方法是将整个世界分成一个 网格系统,与 Google 和其他地图提供商的方式完全一样。这允许你以小块加载数据,这些数据最终会构建成完整的图像。

在 ViziCities 的情况下,我们使用瓦片只请求对你可见的地理区域的 JSON 数据。这意味着你可以随着每个瓦片加载开始输出 3D 物体,而不是等待所有内容加载完成。

这种方法的副产品是你可以从 视锥剔除 中受益,即不渲染不在你视野中的物体,从而显著提高性能。

缓存已加载数据以在查看同一位置时节省时间和资源

与基于瓦片的加载相结合的是一个 缓存系统,这意味着你不会两次请求相同的数据,而是从本地存储中提取数据。这节省了带宽,也节省了时间,因为下载每个 JSON 瓦片可能需要一段时间。

我们目前使用的是一种简单的本地方法,它在每次刷新时都会重置缓存,但我们计划实施类似 localForage 的功能,使缓存能够在浏览器会话之间持久保存。

使用 OpenStreetMap Overpass API 而不是滚动自己的 PostGIS 数据库

在 ViziCities 开发的后期,我们意识到继续使用自己的 PostGIS 数据库来存储和操作地理数据是不可行的。一方面,它需要一台巨大的服务器才能存储整个 OpenStreetMap 数据库,但实际上,设置和管理它非常麻烦,需要一种外部方法。

解决方案是 Overpass API,这是一个通向 OpenStreetMap 数据的外部 JSON 和 XML 端点。Overpass 允许你 发送请求 以获取边界框内(在本例中为地图瓦片)的特定 OpenStreetMap 标签。

http://overpass-api.de/api/interpreter?data=[out:json];((way(51.50874,-0.02197,51.51558,-0.01099)[%22building%22]);(._;node(w);););out;

并获得一个漂亮的 JSON 响应

{
  "version": 0.6,
  "generator": "Overpass API",
  "osm3s": {
    "timestamp_osm_base": "2014-03-02T22:08:02Z",
    "copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."
  },
  "elements": [

{
  "type": "node",
  "id": 262890340,
  "lat": 51.5118466,
  "lon": -0.0205134
},
{
  "type": "node",
  "id": 278157418,
  "lat": 51.5143963,
  "lon": -0.0144833
},
...
{
  "type": "way",
  "id": 50258319,
  "nodes": [
    638736123,
    638736125,
    638736127,
    638736129,
    638736123
  ],
  "tags": {
    "building": "yes",
    "leisure": "sports_centre",
    "name": "The Workhouse"
  }
},
{
  "type": "way",
  "id": 50258326,
  "nodes": [
    638736168,
    638736170,
    638736171,
    638736172,
    638736168
  ],
  "tags": {
    "building": "yes",
    "name": "Poplar Playcentre"
  }
},
...
  ]
}

这样带来的副产品是,您将获得开箱即用的全球支持,并从 OpenSteetMap 的分钟级更新中受益。说真的,如果您 编辑或添加了 OpenStreetMap 的内容(请务必这样做),它将在几分钟内显示在 ViziCities 中。

限制并发 XHR 请求数量

我们最近学到的一件事是,同时向 Overpass API 端点发送大量 XHR 请求对我们和 Overpass 来说都不是好事。它通常会导致延迟,因为 Overpass 会对我们进行限速,因此数据需要很长时间才能传回浏览器。好的一面是,我们已经 使用 promise 来管理 XHR 请求,所以我们已经准备好解决这个问题。

拼图的最后一块是使用 throat.js 来限制并发 XHR 请求的数量,这样我们就可以控制并加载资源,而不会滥用外部 API。它非常简单,而且 运行完美。再也没有加载延迟了!

在您自己的项目中使用 ViziCities

我希望这些经验教训和技巧在某种程度上有所帮助,也希望它能鼓励您自己尝试使用 ViziCities。设置起来很容易,而且文档齐全,只需前往 ViziCities GitHub 仓库,您就可以找到所需的一切。

为 ViziCities 做贡献

我们开源 ViziCities 的部分原因是鼓励其他人帮助构建它,并使其比我和 Peter 能够做到的还要出色。自发布以来,我们已经在 GitHub 上有超过 1,000 人收藏了该项目,并且有将近 100 个分支。更重要的是,我们收到了来自社区成员的 9 个 Pull Request,这些成员是我们之前不认识的,也没有要求他们帮忙。看到人们这样帮助我们真是太棒了。

如果要选出一个我们最喜欢的贡献,那就是添加了通过 在 URL 中添加坐标 来加载世界各地内容的能力。这是一个很酷的功能,它使该项目对每个人来说都更加易用。

我们希望有更多的人参与贡献,无论您是 处理问题 还是玩弄视觉样式。阅读有关 如何贡献 的更多信息,并试一试吧!

接下来是什么?

自我们发布该项目以来,这是一年来的疯狂,也是 两个星期的更疯狂。我们从未想过它会以这种方式激发人们的兴趣,它让我们大吃一惊。

接下来的步骤是逐步解决问题,并为 0.1.0 版本做好准备,该版本仍然是 alpha 版本,但将相对稳定。除此之外,我们将继续尝试令人兴奋的新技术,比如 Oculus Rift(是的,那就是我戴着它的人)…

以 3D 方式可视化实时空中交通…

等等。 敬请关注

关于 Robin Hawkes

Robin 热衷于通过代码解决问题。他是一位数字修补匠,Pusher 的开发者关系主管,Mozilla 之前的布道师,图书作者,也是一位英国人。

更多 Robin Hawkes 的文章…

关于 Robert Nyman [名誉编辑]

Mozilla Hacks 的技术布道师和编辑。关于 HTML5、JavaScript 和开放网络进行演讲和博客。Robert 是 HTML5 和开放网络的坚定支持者,自 1999 年以来一直在从事网络前端开发工作——在瑞典和纽约市。他经常在 http://robertnyman.com 上发博,喜欢旅行和结识新朋友。

更多 Robert Nyman [名誉编辑] 的文章…


2 条评论

  1. Hugo

    这真是一个很棒的项目,谢谢您的文章。

    2014 年 3 月 8 日 上午 7:39

    1. Robin Hawkes

      没问题,很高兴您喜欢它!

      2014 年 3 月 8 日 下午 5:13

本文的评论已关闭。