You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

406 lines
15 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 40| 实战如何实现3D地球可视化
你好,我是月影。
前几节课我们一起进行了简单图表和二维地图的实战这节课我们来实现更炫酷的3D地球可视化。
3D地球可视化主要是以3D的方式呈现整个地球的模型视觉上看起来更炫酷。它是可视化应用里常见的一种形式通常用来实现全球地理信息相关的可视化应用例如全球黑客攻防示意图、全球航班信息示意图以及全球贸易活动示意图等等。
因为内容比较多所以我会用两节课来讲解3D地球的实现效果。而且由于我们的关注点在效果因此为了简化实现过程和把重点聚焦在效果上我就不刻意准备数据了我们用一些随机数据来实现。不过即使我们要实现的是包含真实数据的3D可视化应用项目前面学过的数据处理方法仍然是适用的。这里我就不多说了。
在学习之前你可以先看一下我们最终要实现的3D地球可视化效果先有一个直观的印象。
![](https://static001.geekbang.org/resource/image/2d/e5/2d3a38yy1b1a0974a830a277150a54e5.gif)
如上面动画图像所示我们要做的3D可视化效果是一个悬浮于宇宙空间中的地球它的背后是一些星空背景和浅色的光晕并且地球在不停旋转的同时会有一些不同的地点出现飞线动画。
接下来,我们就来一步步实现这样的效果。
## 如何实现一个3D地球
第一步我们自然是要实现一个旋转的地球。通过前面课程的学习我们知道直接用SpriteJS的3D扩展就可以方便地绘制3D图形。这里我们再系统地说一下实现的方法。
### 1\. 绘制一个3D球体
首先我们加载SpriteJS和3D扩展最简单的方式还是直接使用CDN上打包好的文件代码如下
```
<script src="http://unpkg.com/spritejs/dist/spritejs.js"></script>
<script src="http://unpkg.com/sprite-extend-3d/dist/sprite-extend-3d.js"></script>
```
加载完成之后我们创建场景对象添加Layer代码如下
```
const {Scene} = spritejs;
const container = document.getElementById('container');
const scene = new Scene({
container,
});
const layer = scene.layer3d('fglayer', {
alpha: false,
camera: {
fov: 35,
pos: [0, 0, 5],
},
});
```
与2D的Layer不同SpriteJS的3D扩展创建的Layer需要设置相机。这里我们设置了一个透视相机视角为35度位置为 0, 0, 5
接着是创建WebGL的Program我们通过Layer对象的createProgram来创建代码如下
```
const {Sphere, shaders} = spritejs.ext3d;
const program = layer.createProgram({
...shaders.GEOMETRY,
cullFace: null,
});
```
SpriteJS的3D扩展内置了一些常用的Shader比如shaders.GEOMETRY 就是一个符合Phong反射模型的几何体Shader所以这次我们直接使用它。
接着我们创建一个球体它在SpriteJS的3D扩展中对应Sphere对象。
```
const globe = new Sphere(program, {
colors: '#333',
widthSegments: 64,
heightSegments: 32,
radius: 1,
});
layer.append(globe);
```
我们给球体设置颜色、宽度、高度和半径这些默认的属性然后将它添加到layer上这样我们就能在画布上将这个球体显示出来了效果如下所示。
![](https://static001.geekbang.org/resource/image/11/21/11f33d265ded0f54973860671f265d21.jpeg)
现在,我们只在画布上显示了一个灰色的球体,它和我们要实现的地球还相差甚远。别着急,我们一步一步来。
### 2\. 绘制地图
上节课,我们已经讲了绘制平面地图的方法,就是把表示地图的 JSON 数据利用墨卡托投影到平面上。接下来我们也要先绘制一张平面地图然后把它以纹理的方式添加到我们创建的3D球体上。
不过,与平面地图采用墨卡托投影不同,作为纹理的球面地图需要采用**等角方位投影**(Equirectangular Projection)。d3-geo模块中同样支持这种投影方式我们可以直接加载d3-geo模块然后使用对应的代码来创建投影。
从CDN加载d3-geo模块需要加载以下两个JS文件
```
<script src="https://d3js.org/d3-array.v2.min.js"></script>
<script src="https://d3js.org/d3-geo.v2.min.js"></script>
```
然后,我们创建对应的投影:
```
const mapWidth = 960;
const mapHeight = 480;
const mapScale = 4;
const projection = d3.geoEquirectangular();
projection.scale(projection.scale() * mapScale).translate([mapWidth * mapScale * 0.5, (mapHeight + 2) * mapScale * 0.5]);
```
这里,我们首先通过 d3.geoEquirectangular 方法来创建等角方位投影再将它进行缩放。d3的地图投影默认宽高为960 \* 480我们将投影缩放为4倍也就是将地图绘制为 3480 \* 1920大小。这样一来它就能在大屏上显示得更清晰。
然后我们通过tanslate将中心点调整到画布中心因为JSON的地图数据的0,0点在画布正中心。仔细看我上面的代码你会注意到我们在Y方向上多调整一个像素这是因为原始数据坐标有一点偏差。
通过我刚才说的这些步骤我们就创建好了投影接下来就可以开始绘制地图了。我们从topoJSON数据加载地图。
```
async function loadMap(src = topojsonData, {strokeColor, fillColor} = {}) {
const data = await (await fetch(src)).json();
const countries = topojson.feature(data, data.objects.countries);
const canvas = new OffscreenCanvas(mapScale * mapWidth, mapScale * mapHeight);
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
return drawMap({context, countries, strokeColor, fillColor});
}
```
这里我们创建一个离屏Canvas用加载的数据来绘制地图到离屏Canvas上对应的绘制地图的逻辑如下
```
function drawMap({
context,
countries,
strokeColor = '#666',
fillColor = '#000',
strokeWidth = 1.5,
} = {}) {
const path = d3.geoPath(projection).context(context);
context.save();
context.strokeStyle = strokeColor;
context.lineWidth = strokeWidth;
context.fillStyle = fillColor;
context.beginPath();
path(countries);
context.fill();
context.stroke();
context.restore();
return context.canvas;
```
这样我们就完成了地图加载和绘制的逻辑。当然我们现在还看不到地图因为我们只是将它绘制到了一个离屏的Canvas对象上并没有将这个对象显示出来。
### 3\. 将地图作为纹理
要显示地图为3D地球我们需要将刚刚绘制的地图作为纹理添加到之前绘制的球体上。之前我们绘制球体时使用的是SpriteJS中默认的shader它是符合Phong光照模型的几何材质的。因为考虑到地球有特殊光照我们现在自己实现一组自定义的shader。
```
const vertex = `
precision highp float;
precision highp int;
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
attribute vec2 uv;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
varying vec3 vNormal;
varying vec2 vUv;
varying vec4 vColor;
uniform vec3 pointLightPosition; //点光源位置
void main() {
vNormal = normalize(normalMatrix * normal);
vUv = uv;
vColor = color;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragment = `
precision highp float;
precision highp int;
varying vec3 vNormal;
varying vec4 vColor;
uniform sampler2D tMap;
varying vec2 vUv;
uniform vec2 uResolution;
void main() {
vec4 color = vColor;
vec4 texColor = texture2D(tMap, vUv);
vec2 st = gl_FragCoord.xy / uResolution;
float alpha = texColor.a;
color.rgb = mix(color.rgb, texColor.rgb, alpha);
color.rgb = mix(texColor.rgb, color.rgb, clamp(color.a / max(0.0001, texColor.a), 0.0, 1.0));
color.a = texColor.a + (1.0 - texColor.a) * color.a;
float d = distance(st, vec2(0.5));
gl_FragColor.rgb = color.rgb + 0.3 * pow((1.0 - d), 3.0);
gl_FragColor.a = color.a;
}
`;
```
我们用上面的Shader来创建Program。这组Shader并不复杂原理我们在视觉篇都已经解释过了。如果你觉得理解起来依然有困难可以复习一下视觉篇的内容。接着我们创建一个Texture对象将它赋给Program对象代码如下。
```
const texture = layer.createTexture({});
const program = layer.createProgram({
vertex,
fragment,
texture,
cullFace: null,
});
```
现在,画布上就显示出了一个中心有些亮光的球体。
![](https://static001.geekbang.org/resource/image/ae/0d/aeb4a6736810d9d5674b48b1e683800d.jpeg)
从中我们还是看不出地球的样子。这是因为我们给的texture对象是一个空的纹理对象。接下来我们只要执行loadMap方法将地图加载出来再添加给这个空的纹理对象然后刷新画布就可以了。对应代码如下
```
loadMap().then((map) => {
texture.image = map;
texture.needsUpdate = true;
layer.forceUpdate();
});
```
最终,我们就显示出了地球的样子。
![](https://static001.geekbang.org/resource/image/b6/a3/b637c0ae60390d16ed72a17749a8d9a3.jpeg)
我们还可以给地球添加轨迹球控制并让它自动旋转。在SpriteJS中非常简单只需要一行代码即可完成。
```
layer.setOrbit({autoRotate: true}); // 开启旋转控制
```
![](https://static001.geekbang.org/resource/image/06/47/063675af883cf21766ba63af91f74347.gif)
这样我们就得到一个自动旋转的地球效果了。
## 如何实现星空背景
不过,这个孤零零的地球悬浮在黑色背景的空间里,看起来不是很吸引人,所以我们可以给地球添加一些背景,比如星空,让它真正悬浮在群星闪耀的太空中。
要实现星空的效果第一步是要创建一个天空包围盒。天空包围盒也是一个球体Sphere对象只不过它要比地球大很多以此让摄像机处于整个球体内部。为了显示群星天空包围盒有自己特殊的Shader。我们来看一下
```
const skyVertex = `
precision highp float;
precision highp int;
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
uniform mat3 normalMatrix;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const skyFragment = `
precision highp float;
precision highp int;
varying vec2 vUv;
highp float random(vec2 co)
{
highp float a = 12.9898;
highp float b = 78.233;
highp float c = 43758.5453;
highp float dt= dot(co.xy ,vec2(a,b));
highp float sn= mod(dt,3.14);
return fract(sin(sn) * c);
}
// Value Noise by Inigo Quilez - iq/2013
// https://www.shadertoy.com/view/lsf3WH
highp float noise(vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix( mix( random( i + vec2(0.0,0.0) ),
random( i + vec2(1.0,0.0) ), u.x),
mix( random( i + vec2(0.0,1.0) ),
random( i + vec2(1.0,1.0) ), u.x), u.y);
}
void main() {
gl_FragColor.rgb = vec3(1.0);
gl_FragColor.a = step(0.93, noise(vUv * 6000.0));
```
上面的代码是天空包围盒的Shader实际上它是我们使用二维噪声的技巧来实现的。在第16节课中也有过类似的做法当时我们是用它来模拟水滴滚过的效果。
![](https://static001.geekbang.org/resource/image/72/91/72fbc484227f00ec4dcf7f2729c8f391.jpeg)
但在这里我们通过step函数和vUv的缩放将它缩小之后最终呈现出来星空效果。
![](https://static001.geekbang.org/resource/image/e2/c5/e2dfac7b34718ecc347969f807a6f9c5.jpeg)
对应的创建天空盒子的JavaScript代码如下
```
function createSky(layer, skyProgram) {
skyProgram = skyProgram || layer.createProgram({
vertex: skyVertex,
fragment: skyFragment,
transparent: true,
cullFace: null,
});
const skyBox = new Sphere(skyProgram);
skyBox.attributes.scale = 100;
layer.append(skyBox);
return skyBox;
}
createSky(layer);
```
不过,光看这些代码,你可能还不能完全明白,为什么二维噪声技巧就能实现星空效果。那也不要紧,完整的示例代码在[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)中最好的理解方式还是你自己试着手动修改一下skyFragment中的绘制参数看看实现出来效果你就能明白了。
## 要点总结
这节课我们讲了实现3D地球可视化效果的方法以及给3D地球添加天空背景的方法。
要实现3D地球效果我们可以使用SpriteJS和它的3D扩展库。首先我们绘制一个3D球体。然后我们用topoJSON数据绘制地图注意地图的投影方式必须选择等角方位投影。最后我们把地图作为纹理添加到3D球体上这样就绘制出了3D地球。
而要实现星空背景我们需要创建一个天空盒子它可以看成是一个放大很多倍的球体包裹在地球的外面。具体的思路就是我们创建一组特殊的Shader通过二维噪声来实现星空的效果。
说的这里你可能会有一些疑问我们为什么要用topoJSON数据来绘制地图而不采用现成的等角方位投影的平面地图图片直接用它来作为纹理那样不是能够更快绘制出3D地球吗的确这样确实也能够更简单地绘制出3D地球但这么做也有代价就是我们没有地图数据就不能进一步实现交互效果了比如说点击某个地理区域实现当前国家地区的高亮效果了。
那在下节课我们就会进一步讲解怎么在3D地球上添加交互效果以及根据地理位置来放置各种记号。你的疑问也都会一一解开。
## 小试牛刀
我们说如果不考虑交互可以直接使用更简单的等角方位投影地图作为纹理来直接绘制3D地球。你能试着在网上搜索类似的纹理图片来实现3D地球效果吗
另外你可以找类似的其他行星的图片比如火星、木星图片来实现3D火星、木星的效果吗
最后,你也可以想想,除了星空背景,如果我们还想在地球外部实现一层淡绿色的光晕,又该怎么做呢(提示:你可以使用距离场和颜色插值来实现)?
![](https://static001.geekbang.org/resource/image/bc/39/bcc477ea54486f586e2b90715c944439.gif)
今天的3D地球可视化实战就到这里了。欢迎把你实现的效果分享到留言区我们一起交流。也欢迎把这节课转发出去我们下节课见
* * *
## 源码
[课程完整示例代码详](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)