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.

603 lines
22 KiB
Markdown

2 years ago
# 41 | 实战如何实现3D地球可视化
你好,我是月影。
上节课我们实现了一个有着星空背景的3D地球效果。但这个效果还比较简单在某些可视化大屏项目中我们不仅要呈现视觉效果还要允许用户与可视化大屏中呈现的内容进行交互。所以这节课我们会先给这个3D地球添加各种交互细节比如让地球上的国家随着我们鼠标的移动而高亮接着我们再在地球上放置各种记号比如光柱、地标等等。
## 如何选中地球上的地理位置?
我们先来解决上节课留下的一个问题为什么我们在绘制3D地球的时候要大费周章地使用topoJSON数据而不是直接用一个现成的等角方位投影的世界地图图片作为球体的纹理。这是因为我们想让地球能够和我们的鼠标进行交互比如当点击到地图上的中国区域的时候我们想让中国显示高亮这是纹理图片无法实现的。接下来我们就来看看怎么实现这样交互的效果。
### 实现坐标转换
实现交互效果的难点在于坐标转换。因为鼠标指向地球上的某个区域的时候我们通过SpriteJS拿到的是当前鼠标在点击的地球区域的一个三维坐标而这个坐标是不能直接判断点中的区域属于哪个国家的我们需要将它转换成二维的地图经纬度坐标才能通过地图数据来获取到当前经纬度下的国家或地区信息。
那如何实现这个坐标转换呢首先我们的鼠标在地球上移动的时候通过SpriteJS我们拿到三维的球面坐标代码如下
```
layer.setRaycast();
globe.addEventListener('mousemove', (e) => {
console.log(e.hit.localPoint);
});
...
skyBox.attributes.raycast = 'none';
```
上面的代码中有一个小细节我们将天空包围盒的raycast设置成了none。为什么要这么做呢因为地球包围在天空盒子内这样设置之后鼠标就能穿透天空包围盒到达地球如果不这么做天空盒子就会遮挡住鼠标事件地球也就捕获不到事件了。这样一来当鼠标移动到地球上时我们就可以得到相应的三维坐标信息了。
接下来,我们要将三维坐标信息转换为经纬度信息,那第一步就是将三维坐标转换为二维平面坐标。
```
/**
* 将球面坐标转换为平面地图坐标
* @param {*} x
* @param {*} y
* @param {*} z
* @param {*} radius
*/
function unproject(x, y, z, radius = 1) {
const pLength = Math.PI * 2;
const tLength = Math.PI;
const v = Math.acos(y / radius) / tLength; // const y = radius * Math.cos(v * tLength);
let u = Math.atan2(-z, x) + Math.PI; // z / x = -1 * Math.tan(u * pLength);
u /= pLength;
return [u * mapScale * mapWidth, v * mapScale * mapHeight];
}
```
这个球面和平面坐标转换实际上就是将空间坐标系从球坐标系转换为平面直接坐标系。具体的转换方法是我们先将球坐标系转为圆柱坐标系再将圆柱坐标系转为平面直角坐标系。具体的公式推导过程比较复杂我们没必要深入理解你只要会用我给出的unproject函数就可以了。如果你对推导原理有兴趣可以回顾[第15课](https://time.geekbang.org/column/article/266346),自己来推导一下,或者阅读[这篇文章](https://zhuanlan.zhihu.com/p/34485962)。
拿到了二维平面直角坐标之后,我们可以直接用等角方位投影函数的反函数将这个平面直角坐标转换为经纬度,代码如下:
```
function positionToLatlng(x, y, z, radius = 1) {
const [u, v] = unproject(x, y, z, radius);
return projection.invert([u, v]);
}
```
接着我们实现一个通过经纬度拿到国家信息的函数。这里我们直接通过d3.geoContains方法从countries数据中拿到对应的国家信息。
```
function getCountryInfo(latitude, longitude, countries) {
if(!countries) return {index: -1};
let idx = -1;
countries.features.some((d, i) => {
const ret = d3.geoContains(d, [longitude, latitude]);
if(ret) idx = i;
return ret;
});
const info = idx >= 0 ? {...countries.features[idx]} : {};
info.index = idx;
return info;
}
```
这样一来我们只要修改mousemove方法就可以知道我们的鼠标移动在哪个国家之上了。
```
globe.addEventListener('mousemove', (e) => {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, countries);
if(country.properties) {
console.log(country.properties.name);
}
});
```
### 高亮国家地区的方法
下一步我们就可以实现一个方法来高亮鼠标移动到的国家或地区了。要高亮对应的国家或地区其实处理起来并不复杂。我们先把原始的非高亮的图片另存一份然后根据选中国家的index信息从contries原始数据中取出对应的那个国家用不同的填充色fillStyle再绘制一次最后更新texture和layer就可以将高亮区域绘制出来了。代码如下
```
function highlightMap(texture, info, countries) {
if(texture.index === info.index) return;
const canvas = texture.image;
if(!canvas) return;
const idx = info.index;
const highlightMapContxt = canvas.getContext('2d');
if(!imgCache) {
imgCache = new OffscreenCanvas(canvas.width, canvas.height);
imgCache.getContext('2d').drawImage(canvas, 0, 0);
}
highlightMapContxt.clearRect(0, 0, mapScale * mapWidth, mapScale * mapHeight);
highlightMapContxt.drawImage(imgCache, 0, 0);
if(idx > 0) {
const path = d3.geoPath(projection).context(highlightMapContxt);
highlightMapContxt.save();
highlightMapContxt.fillStyle = '#fff';
highlightMapContxt.beginPath();
path({type: 'FeatureCollection', features: countries.features.slice(idx, idx + 1)});
highlightMapContxt.fill();
highlightMapContxt.restore();
}
texture.index = idx;
texture.needsUpdate = true;
layer.forceUpdate();
```
仔细看上面的代码你会发现这里我们实际上做了两点优化一是我们在texture对象上记录了上一次选中区域的index。如果移动鼠标时index没发生变化说明鼠标仍然在当前高亮的国家内没有必要重绘。二是我们保存了原始非高亮图片。之所以这样做是因为我们只要将保存的非高亮图片通过drawImage一次绘制然后再绘制高亮区域就可以完成地图高亮效果而不需要每次都重新用Path来绘制整个地图了因而大大减少了Canvas2D绘图指令的数量显著提升了性能。
实现了这个函数之后我们改写mousemove事件处理函数就能将这个交互效果完整地显示出来了。具体的代码和效果图如下
```
globe.addEventListener('mousemove', (e) => {
const [lng, lat] = positionToLatlng(...e.hit.localPoint);
const country = getCountryInfo(lat, lng, countries);
highlightMap(texture, country, countries);
});
```
![](https://static001.geekbang.org/resource/image/14/e8/14ea287ce617789ce37cd2e3d40ee4e8.gif)
## 如何在地球上放置标记?
通过选中对应的国家,我们可以实现鼠标的移动的高亮交互效果。接下来,我们来实现另一个交互效果,在地球的指定经纬度处放置一些标记。
### 如何计算几何体摆放位置?
既然要把物体放置在地球指定的经纬坐标处那我们接下来的操作依然离不开坐标转换。首先我们知道几何体通常默认是以中心点为0,0但我们放置的时候却需要将物体的底部放置在球面上所以我们需要对球面坐标位置进行一个坐标变换。
![](https://static001.geekbang.org/resource/image/82/06/82406267ec504e29b8d975789a4f8a06.jpg)
因此,我们在实现放置函数的时候,会通过 latlngToPosition 先将经纬度转成球面坐标pos再延展到物体高度的一半因为球心的坐标是0,0所以pos位置就是对应的三维向量我们使用scale就可以直接将它移动到我们要的高度了。
```
function setGlobeTarget(globe, target, {latitude, longitude, transpose = false, ...attrs}) {
const radius = globe.attributes.radius;
if(transpose) target.transpose();
if(latitude != null && longitude != null) {
const scale = target.attributes.scaleY * (attrs.scale || 1.0);
const height = target.attributes.height;
const pos = latlngToPosition(latitude, longitude, radius);
// 要将底部放置在地面上
pos.scale(height * 0.5 * scale / radius + 1);
attrs.pos = pos;
}
target.attr(attrs);
const sp = new Vec3().copy(attrs.pos).scale(2);
target.lookAt(sp);
globe.append(target);
}
```
这里的 latlngToPosition 是前面 positionToLatlng 的反向操作,也就是先用 projection 函数将经纬度映射为地图上的直角坐标,然后用直角坐标转球面坐标的公式,将它转为球面坐标。具体的实现代码如下:
```
/**
* 将经纬度转换为球面坐标
* @param {*} latitude
* @param {*} longitude
* @param {*} radius
*/
function latlngToPosition(latitude, longitude, radius = 1) {
const [u, v] = projection([longitude, latitude]);
return project(u, v, radius);
}
/**
* 将平面地图坐标转换为球面坐标
* @param {*} u
* @param {*} v
* @param {*} radius
*/
function project(u, v, radius = 1) {
u /= mapScale * mapWidth;
v /= mapScale * mapHeight;
const pLength = Math.PI * 2;
const tLength = Math.PI;
const x = -radius * Math.cos(u * pLength) * Math.sin(v * tLength);
const y = radius * Math.cos(v * tLength);
const z = radius * Math.sin(u * pLength) * Math.sin(v * tLength);
return new Vec3(x, y, z);
}
```
有了这个位置之后我们将物体放上去并且让物体朝向球面的法线方向。这一步我们可以用lookAt函数来实现。不过lookAt函数是让物体的z轴朝向向量方向而我们绘制的一些几何体比如圆柱体其实是要让y轴朝向向量方向所以这种情况下我们需要对几何体的顶点做一个转置操作也就是将它的顶点向量的x、y、z的值轮换一下让x = y、y = z、 z = x。这么做之后我们就可以在地球表面摆放几何体了。
### 摆放光柱
我们先在地球的指定位置上放置一些光柱,光柱通常可以用来标记当前位置是一个重要的地点。光柱效果如下图所示:
![](https://static001.geekbang.org/resource/image/2d/67/2d02f5fd187b659b47a299baa4fe9e67.gif)
这个效果怎么实现呢因为光柱本身是圆柱体所以我们可以用Cylindar对象来绘制。而光柱的光线还会随着高度衰减对应的Shader代码如下
```
const beamVertx = `
precision highp float;
precision highp int;
attribute vec3 position;
attribute vec3 normal;
attribute vec4 color;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
varying vec3 vNormal;
varying vec4 vColor;
uniform vec4 ambientColor; // 环境光
uniform float uHeight;
void main() {
vNormal = normalize(normalMatrix * normal);
vec3 ambient = ambientColor.rgb * color.rgb;// 计算环境光反射颜色
float height = 0.5 - position.z / uHeight;
vColor = vec4(ambient + 0.3 * sin(height), color.a * height);
vec3 P = position;
P.xy *= 2.0 - pow(height, 3.0);
gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
}
`;
const beamFrag = `
precision highp float;
precision highp int;
varying vec3 vNormal;
varying vec4 vColor;
void main() {
gl_FragColor = vColor;
```
实现Shader的代码并不复杂在顶点着色器里我们可以根据高度减少颜色的不透明度。另外我们还可以根据高度对xy也就是圆柱的截面做一个扩展 P.xy \*= 2.0 - pow(height, 3.0),这样就能产生一种光线发散(顶部比底部略大)的效果了。
于是对应的addBeam函数实现如下所示。它就是根据参数创建对应的圆柱体对象并把它们添加到地球对应的经纬度位置上。
```
function addBeam(globe, {
latitude,
longitude,
width = 1.0,
height = 25.0,
color = 'rgba(245,250,113, 0.5)',
raycast = 'none',
segments = 60} = {}) {
const layer = globe.layer;
const radius = globe.attributes.radius;
if(layer) {
const r = width / 2;
const scale = radius * 0.015;
const program = layer.createProgram({
transparent: true,
vertex: beamVertx,
fragment: beamFrag,
uniforms: {
uHeight: {value: height},
},
});
const beam = new Cylinder(program, {
radiusTop: r,
radiusBottom: r,
radialSegments: segments,
height,
colors: color,
});
setGlobeTarget(globe, beam, {transpose: true, latitude, longitude, scale, raycast});
return beam;
}
}
```
### 摆放地标
除了摆放光柱我们还可以摆放地标。地标通常表示当前位置产生了一个重大事件。地标实现起来会比光柱更复杂一些它由一个定位点Spot和一个动态的标记Marker共同组成。摆放了地标的地图效果如下图所示
![](https://static001.geekbang.org/resource/image/bc/4f/bc82d420e44aed51be20a16f63c9434f.gif)
想要实现它第一步我们还是要实现对应的Shader。不过我们这次需要实现两组Shader。首先是spot的顶点着色器和片元着色器实现起来也非常简单。在顶点着色器中我们根据uWidth扩展x、y坐标根据顶点绘制出一个特定大小的平面图形。在片元着色器中我们让图形的中心稍亮一些让边缘亮度随着距离衰减这么做是为了增强视觉效果。不过由于分辨率的原因具体的效果在截图中可能体现不出来你可以运行示例代码把地球局部放大来实际观察和体会一下。
```
const spotVertex = `
precision highp float;
precision highp int;
attribute vec4 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
uniform float uWidth;
uniform float uSpeed;
uniform float uHeight;
varying vec2 st;
void main() {
float s = 0.0 + (0.2 * uWidth * position.w);
vec3 P = vec3(s * position.xy, 0.0);
st = P.xy;
gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
}
`;
const spotFragment = `
precision highp float;
precision highp int;
uniform vec2 uResolution;
uniform vec3 uColor;
uniform float uWidth;
varying vec2 st;
void main() {
float d = distance(st, vec2(0));
gl_FragColor.rgb = uColor + 1.5 * (0.2 * uWidth - 2.0 * d);
gl_FragColor.a = 1.
```
接着我们实现marker的顶点着色器和片元着色器它们稍微复杂一些。
```
const markerVertex = `
precision highp float;
precision highp int;
attribute vec4 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
uniform float uTime;
uniform float uWidth;
uniform float uSpeed;
uniform float uHeight;
varying float time;
void main() {
time = mod(uTime, 1.5 / uSpeed) * uSpeed + position.z - 1.0;
float d = clamp(0.0, uWidth * mix(1.0, 0.5, min(1.0, uHeight)), time);
float s = d + (0.1 * position.w);
vec3 P = vec3(s * position.xy, uHeight * time);
gl_Position = projectionMatrix * modelViewMatrix * vec4(P, 1.0);
}
`;
const markerFragment = `
precision highp float;
precision highp int;
uniform vec2 uResolution;
uniform vec3 uColor;
varying float time;
void main() {
float t = clamp(0.0, 1.0, time);
gl_FragColor.rgb = uColor;
gl_FragColor.a = 1.0 - t
```
在顶点着色器里我们根据时间参数uTime来调整物体定点的高度。这样当我们设置uHeight参数时marker就能呈现出立体的效果。
有了这两组着色器之后我们再实现两个函数用来分别生成spot和marker的顶点函数代码如下
```
function makeSpotVerts(radis = 1.0, n_segments) {
const vertex = [];
for(let i = 0; i <= n_segments; i++) {
const theta = Math.PI * 2 * i / n_segments;
const x = radis * Math.cos(theta);
const y = radis * Math.sin(theta);
vertex.push(x, y, 1, 0, x, y, 1, 1.0);
}
return {
position: {data: vertex, size: 4},
};
}
function makeMarkerVerts(radis = 1.0, n_segments) {
const vertex = [];
for(let i = 0; i <= n_segments; i++) {
const theta = Math.PI * 2 * i / n_segments;
const x = radis * Math.cos(theta);
const y = radis * Math.sin(theta);
vertex.push(x, y, 1, 0, x, y, 1, 1.0);
}
const copied = [...vertex];
vertex.push(...copied.map((v, i) => {
return i % 4 === 2 ? 0.33 : v;
}));
vertex.push(...copied.map((v, i) => {
return i % 4 === 2 ? 0.67 : v;
}));
return {
position: {data: vertex, size: 4},
};
}
```
这两个函数都是用生成正多边形顶点的算法来生成对应的顶点它们的区别是spot只生成一组顶点因为是平面图形所以z坐标为0而marker则生成三组不同高度的顶点组成立体的形状。
接着我们再实现一个初始化函数用来生成spot和marker对应的WebGLProgram函数代码如下
```
function initMarker(layer, globe, {width, height, speed, color, segments}) {
const markerProgram = layer.createProgram({
transparent: true,
vertex: markerVertex,
fragment: markerFragment,
uniforms: {
uTime: {value: 0},
uColor: {value: new Color(color).slice(0, 3)},
uWidth: {value: width},
uSpeed: {value: speed},
uHeight: {value: height},
},
});
const markerGeometry = new Geometry(layer.gl, makeMarkerVerts(globe.attributes.radius, segments));
const spotProgram = layer.createProgram({
transparent: true,
vertex: spotVertex,
fragment: spotFragment,
uniforms: {
uTime: {value: 0},
uColor: {value: new Color(color).slice(0, 3)},
uWidth: {value: width},
uSpeed: {value: speed},
uHeight: {value: height},
},
});
const spotGeometry = new Geometry(layer.gl, makeSpotVerts(globe.attributes.radius, segments));
return {
program: markerProgram,
geometry: markerGeometry,
spotGeometry,
spotProgram,
mode: 'TRIANGLE_STRIP',
}
```
最后我们实现addMarker方法将地标添加到地球上。这样我们就实现了绘制地标的功能。
```
function addMarker(globe, {
latitude,
longitude,
width = 1.0,
height = 0.0,
speed = 1.0,
color = 'rgb(245,250,113)',
segments = 60,
lifeTime = Infinity} = {}) {
const layer = globe.layer;
const radius = globe.attributes.radius;
if(layer) {
let mode = 'TRIANGLES';
const ret = initMarker(layer, globe, {width, height, speed, color, segments});
const markerProgram = ret.program;
const markerGeometry = ret.geometry;
const spotProgram = ret.spotProgram;
const spotGeometry = ret.spotGeometry;
mode = ret.mode;
if(markerProgram) {
const pos = latlngToPosition(latitude, longitude, radius);
const marker = new Mesh3d(markerProgram, {model: markerGeometry, mode});
const spot = new Mesh3d(spotProgram, {model: spotGeometry, mode});
setGlobeTarget(globe, marker, {pos, scale: 0.05, raycast: 'none'});
setGlobeTarget(globe, spot, {pos, scale: 0.05, raycast: 'none'});
layer.bindTime(marker.program);
if(Number.isFinite(lifeTime)) {
setTimeout(() => {
layer.unbindTime(marker.program);
marker.dispose();
spot.dispose();
marker.program.remove();
spot.program.remove();
}, lifeTime);
}
return {marker, spot};
}
}
```
## 要点总结
今天,我们在上节课的基础上学习了与地球交互,以及在地球上放置标记的方法。
与地球交互的实现过程我们可以总结为四步首先我们通过将三维球面坐标转换为经纬度坐标再通过topoJSON的API获取当前选中的国家或地区信息然后在离屏Canvas上将当前选中的国家或地区高亮显示最后更新纹理和重绘layer。这样高亮显示的国家或地区就出现在3D地球上了。
而要在地球上放置标记我们先要计算几何体摆放位置然后实现标记对应的shader创建WebGLProgram最后将标记添加到地球表面对应经纬度换算的球面坐标位置处用lookAt让它朝向法线方向就可以了。
到这里地球3D可视化的核心功能我们就全部实现了。实际上除了这些功能以外我们还可以添加一些更加复杂的标记比如两个点之间连线以及动态的飞线。这些功能实现的基本原理其实和放置标记是一样的所以只要你掌握了我们今天讲的思路就能比较轻松地解决这些需求了。
## 小试牛刀
实际上地球上不仅可以放置普通的单点标记还可以用曲线将两个地理点连接起来。具体的方法是在两个点之间计算弧线或贝塞尔曲线然后将这些连线生成并绘制出来。在SpriteJS的3D扩展中有一个Path3d对象它可以绘制空间中的三维曲线。你能试着实现两点之间的连线吗如果很轻松就能实现你还可以试着添加动画实现两点之间的飞线动画效果。提示你可以通过官方文档来学习Path3d的用法实现两点之间的连线。在实现飞线动画效果的时候你可以参照GitHub仓库里的代码来进行学习来理解我是怎么做的
欢迎把你实现的效果分享到留言区,我们一起交流。也欢迎你把这节课转发出去,我们下节课见!
* * *
## 源码
示例代码详见[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/vis-geo-earth)
## 推荐阅读
[球坐标与直角坐标的转换](https://zhuanlan.zhihu.com/p/34485962)