Blog of CodeJELLOW’S ENGINEERING NOTES
← 返回文章

OpenLayers Workshop(四)WebGL Meteor Shower

OpenLayers Workshop(四)WebGL Meteor Shower Rendering meteorites with Canvas 2D 用Canvas 2D渲染陨石 Rendering points with WebGL 用WebGL渲染点…

在这个模块中,我们将使用WebGL来对CSV文件的陨石撞击数据进行自定义渲染。

这个模块使用Canvas 2D的上下文来渲染带有法向量图层的点,然后我们使用新的点渲染工具去使用WebGL来渲染同样的数据。最后我们将开发一个自定义的片段着色器来展示如何动态设置数据样式。

Rendering meteorites with Canvas 2D 用Canvas 2D渲染陨石

在OpenLayers 6中,每一个地图中的图层都有一个独立的渲染器。先前,所有的图层渲染器都被一个独立的地图渲染器管理,并依赖一个单独的渲染策略。所以在OpenLayers 6之前,,你需要去在Canvas 2D和WebGL中选择选择一个去渲染所有的图层。在OpenLayers 6中,你的地图的图层可以拥有不同的渲染策略。例如,你可以用Canvas 2D去渲染某些图层,再用WebGL去渲染其它的。

ol/layer/Vector这个类使用Canvas 2D来渲染点线面。这个图层有一个全功能的、特征样式灵活的渲染器。对于数量极多的特征来说,WebGL是一个更加合适的技术。在这个模块中,我们首先使用Canvas 2D去渲染45,000个陨石的位置,然后再将这个样例迁移的WebGL中。

首先修改index.html文件,我们将要渲染一个全屏的页面:

TEXT
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>OpenLayers</title>
  <style>
    html,
    body,
    #map-container {
      margin: 0;
      height: 100%;
      width: 100%;
      font-family: sans-serif;
    }
  </style>
</head>

<body>
  <div id="map-container"></div>  
</body>

</html>

然后我们从一个本地的CSV文件中获取并解析数据,将特征结果加入到vector source中,并把这些数据用vector layer渲染到地图上。那个meteorites.csv文件长这样:

TEXT
name,mass,year,reclat,reclong
Aachen,21,1880,50.775000,6.083330
...

文件的第一行是一个标题行,在我们解析时会跳过。后续的行中包含位置的名称、陨石的质量、撞击的年份、用逗号分割的经纬度。我们使用一个XMLHttpRequest的客户端来获取数据,并写一个简短的函数去解析文件中的每一行,并返回具有集合点的特征。

更新mian.js文件,并加载、渲染本地的CSV中的陨石撞击数据:

TEXT
import 'ol/ol.css';
import {fromLonLat} from 'ol/proj';
import {Map, View} from 'ol';
import {Vector as VectorLayer, Tile as TileLayer} from 'ol/layer';
import {Vector as VectorSource, Stamen} from 'ol/source';
import Feature from 'ol/Feature';
import Point from 'ol/geom/Point';

const source = new VectorSource();

const client = new XMLHttpRequest();
client.open('GET', 'data/meteorites.csv');
client.onload = function() {
  const csv = client.responseText;
  const features = [];

  let prevIndex = csv.indexOf('\n') + 1; // scan past the header line

  let curIndex;
  while ((curIndex = csv.indexOf('\n', prevIndex)) != -1) {
    const line = csv.substr(prevIndex, curIndex - prevIndex).split(',');
    prevIndex = curIndex + 1;

    const coords = fromLonLat([parseFloat(line[4]), parseFloat(line[3])]);
    if (isNaN(coords[0]) || isNaN(coords[1])) {
      // guard against bad data
      continue;
    }

    features.push(new Feature({
      mass: parseFloat(line[1]) || 0,
      year: parseInt(line[2]) || 0,
      geometry: new Point(coords)
    }));
  }
  source.addFeatures(features);
};
client.send();


new Map({
  target: 'map-container',
  layers: [
    new TileLayer({
      source: new Stamen({
        layer: 'toner'
      })
    }),
    new VectorLayer({
      source: source
    })
  ],
  view: new View({
    center: [0, 0],
    zoom: 2
  })
});

在获取并解析了这些数据后,特征将会被加入到vector source中。这个数据源将在一个tile layer上的vector layer渲染。我们没有在这里对特征进行任何自定义样式设置,重点只是想看看使用带有 45,000 个用 Canvas 2D 渲染的特征的地图的感觉。

Rendering points with WebGL 用WebGL渲染点

在之前的步骤中,我们使用一个普通的vector layer渲染点特征.这个图层使用的是2D的画布上下文来渲染的.用这个图层,你可以渲染数以万计的点,只不过你要注意编写高效的样式代码.为了渲染一个很大数量的点,或者为了更加高效地动态的定义样式,WebGL是一个较好的解决方案.OpenLayers已经具备了一套用WebGL渲染的工具.用WebGL目前局限于渲染点,这也是我们将要在这个练习中做的.

首先,我们引入启用WebGL的点图层的构造器.这个图层是一个利用WebGl的技术优势的易用的入口点.

官方文档中提示这样使用:

TEXT
import {WebGLPoints as WebGLPointsLayer} from 'ol/layer';

但是会报错:

TEXT
...Uncaught TypeError: ol_layer__WEBPACK_IMPORTED_MODULE_4__.WebGLPoints is not a constructor

GitHub中给出了一个解决方案:

TEXT
import WebGLPointsLayer from 'ol/layer/WebGLPoints';

原因是原来的写法是从一个模块中为一个构造方法定义了一个别名,但是这个构造方法已经不存在了.

在地图的layers数组中,用WebGLPointsLayer的一个实例替换VectorLayer,并且使用之前的vector source.

TEXT
layers: [
  new TileLayer({
    source: new Stamen({
      layer: 'toner'
    })
  }),
  new WebGLPointsLayer({
    source: source,
    style: {
      symbol: {
        symbolType: 'square',
        size: 10,
        color: 'rgba(255,0,0,0.5)'
      }
    }
  })
],

这么一折腾之后,陨石撞击的位置就被WebGL渲染出来了:

图片暂不可用。

正如你所见的,我们在创建图层是指定了style参数,这个参数允许我们指定了点的外观(即红色正方形).

改变一个WebGL的图层的样式与库的其它部分完全不同.与哪些使用Fill,Stroke和Image类的其它vector图层不同,我们只需要提供一个含有样式参数的对象.这个对象支持的属性十分简单:opacity透明度, color颜色, size大小, offset偏移量, src (for images)图片源文件 和 symbolType (which can be circle, square, triangle or image)符号类型.

TEXT
WebGL layers use a completely different rendering system, and the style object is actually transformed dynamically into fragment and vertex shaders.
WebGL的图层使用了一个完全不同的渲染系统,并且实际上样式对象将被动态的转换成片段和顶点的着色器.

在移动地图的过程中你可以发现使用WebGL的显示效果要比之前用Canvas 2D的好得多.

现在,我认为我们都会觉得这个地图不是很好看:这可能是因为每一个点都是相同的样式.

让我们使用圆形来替换掉之前的正方形:

TEXT
new WebGLPointsLayer({
  source: source,
  style: {
    symbol: {
      symbolType: 'circle',
      // equivalent to: 18 * clamp('mass' / 200000, 0, 1) + 8
      size: ['+', ['*', ['clamp', ['*', ['get', 'mass'], 1/20000], 0, 1], 18], 8],
      color: 'rgba(255,0,0,0.5)'
    }
  }
})

这就是有意思的地方.我们不止指定了我们要将点渲染成圆形,我们还指定了越大的陨石它的圆也越大.

动态大小的表达式使用了一个OpenLayers的实验性的API,并且有可能以后会发生改变.尽管如此,我们还是要具体看看这个表达式.

TEXT
// equivalent to: 18 * clamp('mass' / 200000, 0, 1) + 8
size: ['+', ['*', ['clamp', ['*', ['get', 'mass'], 1/20000], 0, 1], 18], 8],

WebGLPointsLayer类在数值类型的属性中支持这种表达式,比如大小,透明度,颜色组件等等.

一个表达式由表现为数组的操作符组成.比如:

TEXT
['get', 'mass']
TEXT
['clamp', value, 0, 1]
TEXT
['*', value, 3]

第一个运算符get将根据后面的名称(mass)读取特征的属性.其它的属性比如clamp和*将允许对另一个操作符的结果进行运算.在之前的样例中我们使用这些运算符来利用陨石的质量并转换成最终要显示的大小值,范围从8到26.

TEXT
size: ['+', ['*', ['clamp', ['*', ['get', 'mass'], 1/20000], 0, 1], 18], 8]

那么这个表达式的值等价于:

TEXT
let rawMass = getMass();
let mass = rawMass * 1/20000;
let clampedMass = clamp(mass, 0, 1);
let sizeT = clampedMass * 18;
let size = sizeT + 8;

最终效果图:

图片暂不可用。

Animating meteorite impacts 为陨石撞击添加动画效果

目前为止我们已经成功地从CSV文件中获取数据并使用WebGL渲染之,但是这个地图实在是没有什么意思.我们使用了陨石的质量去决定地图上显示的圆圈的半径,但是我们还没能使用点特征中解析为year的属性的陨石撞击日期.

陨石特征的year属性的取址范围是1850到2015,我们将设置一个循环动画,使得年份循环递增,并渲染当年撞击的陨石,在年份增加的过程中逐步减小那一年的陨石大小.

第一步,我们将创建一个循环动画,并将当前年份渲染到地图顶部的一个<div>标签中.将下面的标签添加到index.html的map-container下面.

TEXT
<div id="year"></div>

编辑style块以包含以下规则

TEXT
#year {
  position: absolute;
  bottom: 1em;
  left: 1em;
  color: white;
  -webkit-text-stroke: 1px black;
  font-size: 2em;
  font-weight: bold;
}

现在我们定义一些代表年份范围和动画播放速度的变量,将下面这些代码加到main.js里面(放在自定义的图层上边)。

TEXT
const minYear = 1850;
const maxYear = 2015;
const span = maxYear - minYear;
const rate = 10; // years per second

const start = Date.now();
let currentYear = minYear;

接下来需要将地图实例分配给一个变量,随后我们会用到这个变量:

TEXT
const map = new Map({

在地图的配置底下,加入以下的render函数以让循环动画开始:

TEXT
const yearElement = document.getElementById('year');

function render() {
  const elapsed = rate * (Date.now() - start) / 1000;
  currentYear = minYear + (elapsed % span);
  yearElement.innerText = currentYear.toFixed(0);

  map.render();
  requestAnimationFrame(render);
}

render();

如果在此之前没有什么差错的话,现在已经可以从地图的左下角看到循环滚动的数字了。

图片暂不可用。

现在要处理棘手的环节了。先前我们使用WebGL的点图层去显示我们的数据,然后用一些看上去很聪明的表达式去给陨石点动态的样式。但是如果我们想要给这些点动画时应该怎么做呢?这就是WebGLPointsLayer这个类的局限了。

首先我们需要导入一个renderer类来替换WebGLPointsLayer:

TEXT
import Renderer from 'ol/renderer/webgl/PointsLayer';

然后使用这个renderer去创建一个自定义的图层类:

TEXT
class CustomLayer extends VectorLayer {
  createRenderer() {
    return new Renderer(this, {
      // options go here
    })
  }
};

接下来我们要为这个renderer提供一些选项。

Uniforms 是特征之间不会变化的值。他们有点像常量,尽管每一个帧他们的值都可以发生更改。这个选项将接收一个uniforms的对象,在这个uniforms对象中我们可以提供一个固定值或者一个函数来在运行时计算值。

将这个对象加入到渲染器的选项中:

TEXT
uniforms: {
  u_currentYear: function() {
    return currentYear;
  }
},

Attributes 是特征之间相互不同的值。他们同时被name和以当前的特征为参数的callback(回调)修饰。WebGL的渲染器接收一个attributes的数组。

既然我们想要将陨石撞击的mass和year属性同时考虑进去,让我们相应的定义两个属性:

TEXT
attributes: [{
  name: 'size',
  callback: function (feature) {
    return 32 * clamp(feature.get('mass') / 200000, 0, 1) + 16;
  }
},
{
  name: 'year',
  callback: function (feature) {
    return feature.get('year');
  },
}],

最后,我们将定义两个将要使用到上面的attribs和uniforms的shaders(片段和顶点)。Shaders是技术上的代码,但是必须作为字符串传递给GPU执行。使用fragmentShader和vertexShader属性将shaders提供给渲染器:

TEXT
vertexShader: `
  precision mediump float;

  uniform mat4 u_projectionMatrix;
  uniform mat4 u_offsetScaleMatrix;
  uniform mat4 u_offsetRotateMatrix;

  attribute vec2 a_position;
  attribute float a_index;
  attribute float a_size;
  attribute float a_year;

  varying vec2 v_texCoord;
  varying float v_year;

  void main(void) {
    mat4 offsetMatrix = u_offsetScaleMatrix;
    float offsetX = a_index == 0.0 || a_index == 3.0 ? -a_size / 2.0 : a_size / 2.0;
    float offsetY = a_index == 0.0 || a_index == 1.0 ? -a_size / 2.0 : a_size / 2.0;
    vec4 offsets = offsetMatrix * vec4(offsetX, offsetY, 0.0, 0.0);
    gl_Position = u_projectionMatrix * vec4(a_position, 0.0, 1.0) + offsets;
    float u = a_index == 0.0 || a_index == 3.0 ? 0.0 : 1.0;
    float v = a_index == 0.0 || a_index == 1.0 ? 0.0 : 1.0;
    v_texCoord = vec2(u, v);
    v_year = a_year;
  }`,
fragmentShader: `
  precision mediump float;

  uniform float u_currentYear;

  varying vec2 v_texCoord;
  varying float v_year;

  void main(void) {
    if (v_year > u_currentYear) {
      discard;
    }

    vec2 texCoord = v_texCoord * 2.0 - vec2(1.0, 1.0);
    float sqRadius = texCoord.x * texCoord.x + texCoord.y * texCoord.y;

    float factor = pow(1.1, u_currentYear - v_year);

    float value = 2.0 * (1.0 - sqRadius * factor);
    float alpha = smoothstep(0.0, 1.0, value);

    gl_FragColor = vec4(1.0, 0.0, 0.0, 0.5);
    gl_FragColor.a *= alpha;
    gl_FragColor.rgb *= gl_FragColor.a;
  }`

我们不会深究顶点的着色器。它的主要职责就是设置点的大小并将year属性提供给片段的着色器。

TEXT
For now, the renderer still requires you to give it a full working shader although most of it is reusable from other use cases. This might evolve with upcoming releases of OpenLayers.
目前,渲染器仍然需要您为它提供一个完整的工作着色器,尽管其中大部分都可以从其他用例中重用。 这可能会随着即将发布的 OpenLayers 版本而发展。

让我们仔细看看片段着色器:

TEXT
fragmentShader: `
  precision mediump float;

  uniform float u_currentYear;

  varying vec2 v_texCoord;
  varying float v_year;

  void main(void) {
    if (v_year > u_currentYear) {
      discard;
    }

    vec2 texCoord = v_texCoord * 2.0 - vec2(1.0, 1.0);
    float sqRadius = texCoord.x * texCoord.x + texCoord.y * texCoord.y;

    float factor = pow(1.1, u_currentYear - v_year);

    float value = 2.0 * (1.0 - sqRadius * factor);
    float alpha = smoothstep(0.0, 1.0, value);

    gl_FragColor = vec4(1.0, 0.0, 0.0, 0.5);
    gl_FragColor.a *= alpha;
    gl_FragColor.rgb *= gl_FragColor.a;
  }`

代码块main开始于一个条件判断:

TEXT
if (v_year > u_currentYear) {
  discard;
}

这意味着如果一个点的撞击年份比当前的年份高,我们就直接不渲染它。本质上我们是从GPU上过滤掉不需要渲染的点的。真有够简单的呢。

接下来的这一部分使用smoothstep function来把正方形编程圆形。调整传递给smoothstep的参数就可以搞清楚它的工作原理:

TEXT
vec2 texCoord = v_texCoord * 2.0 - vec2(1.0, 1.0);
float sqRadius = texCoord.x * texCoord.x + texCoord.y * texCoord.y;

float factor = pow(1.1, u_currentYear - v_year);

float value = 2.0 * (1.0 - sqRadius * factor);
float alpha = smoothstep(0.0, 1.0, value);

使用所有渲染器选项后,现在应该能够看到流星撞击地点的出现,然后随着时间的流逝而缩小。

图片暂不可用。

关于着色器的一些附加说明:

两个着色器在开始时都有定义; 你可能已经认识了我们之前指定的uniforms和attributes,但是着色器还包括渲染器默认提供的uniforms和attributes,例如矩阵uniforms 和保存点坐标的vec2 a_position 向量;

为了将数据从顶点着色器传递到片段着色器,我们使用了变量类型; 必须在两个着色器中声明一个变量,并且它在顶点着色器中分配的值可以在片段着色器中访问。

了解更多概念,可以去看这些 attribute, varying 和 uniform 。

通过 RSS 订阅更新 →