Three.js 3D地图拓展——省份标注与渐变
本文进一步探讨了Three.js中3d地图实现,以及主流实现的一些坑点与技巧。完整代码见 github.com/igdswzcd/th… .
数据获取与处理
可以从阿里云DataV datav.aliyun.com/portal/scho… 获取geoJson数据,但是,通常的教程代码,都会碰到渲染失败的问题,排查发现,除了内蒙古为Polygon,其余地区均为MultiPolygon,后者多了一层数组包装,因此需要对Polygon数据进行预处理。
features.forEach((elem, index) => {
const province = new THREE.Object3D();
let coordinates = elem.geometry.coordinates;
if (elem.geometry.type === "Polygon") {
coordinates = [coordinates];
}
//...
})
省份标注
省份标注一般通过HTML元素对齐到3D对象,需要以下实现:
- 绝对定位图层
- 文本坐标(世界坐标→窗口偏移量)
- 视图变更时更新坐标
此处只讨论2、3。
我们拥有的是省份边界点集的经纬度数据,在常规的平面地图实现中,一般使用墨卡托投影进行映射,指定经纬度中心,输入经纬度,得到Three.js场景中的世界坐标。
import * as d3 from 'd3';
const projection = d3.geoMercator().center([104.0, 36.0]).scale(80).translate([0, 0]);
那么,用什么作为省份标注的锚点呢?省份中心是一个比较好的选择。
检查数据可以发现,geoJson有时也会附赠区域的中心坐标,以阿里云DataV数据为例,存在center
和centroid
两个属性,前者代表省会坐标,后者代表几何中心。
{"type":"Feature","properties":{"adcode":110000,"name":"北京","center":[116.405285,39.904989],"centroid":[116.41995,40.18994],......
同样观察DataV数据,河北存在多组Polygon,不存在centroid
,因此仅对河北使用后者作为标注锚点,其余均使用前者。
将中心经纬度坐标投影,得到了场景世界坐标,还差一步什么呢?我们基于Camera进行观察,但省份标注是HTML元素,并不真的属于世界,因此,我们还需要手动计算世界坐标相对于Camera的位置,再将Camera空间[(-1, -1), (1, 1)]映射到Window空间[(0, innerHeight), (innerWidth, 0)]即可。
// 绘制每个省份时计算_centroid
province.properties = elem.properties;
if (elem.properties.centroid || elem.properties.center) {
const [x, y] = projection(elem.properties.centroid || elem.properties.center);
province.properties._centroid = [x, y];
}
mapObj.add(province);
// ...
// mapData在此前附着到场景中,并命名标记
const mapData = scene.children.find((item) => item.name === 'mapData');
// _centroid为预计算的墨卡托投射结果,世界坐标的不变性
const provinces = mapData.children;
if (Array.isArray(provinces)) {
provinces.forEach(({ properties }) => {
const { _centroid, name } = properties;
if (_centroid) {
const [worldX, worldY] = _centroid;
// 此处-y: 常见的绘制方式都使用了翻转的y,保持一致
const vector = new THREE.Vector3(worldX, -worldY, 4);
const { x: cameraX, y: cameraY } = vector.project(camera);
const windowX = ((cameraX + 1) / 2) * width;
const windowY = ((-cameraY + 1) / 2) * height;
// 添加标注到DOM
const labelElem = document.createElement('div');
labelElem.textContent = name;
labelElem.style.transform = `translate(-50%, -50%) translate(${windowX}px,${windowY}px)`;
labels.value.appendChild(labelElem);
}
});
}
至于更新时机,逐帧更新最简单流畅,但性能上存在一些隐患,也可以对OrbitControls实例监听change
事件,或监听dom上的mousedown
、mouseup
以设置鼠标动作状态进行拦截,不再赘述。
局部渐变
需求中,需要为每个省份单独设置中心渐变,但是,即使使用可自定义Shader的ShaderMaterial,着色公式中使用的uv变量,也仍然是世界坐标,而非局部坐标,因此,常规的渐变总是全局连续的。
但是,拥有完整的uv坐标,也意味着可以计算出其中点与极差,这样,在Shader中,我们就可以计算出局部距离,进而实现局部渐变了。
const pointsX = new Set();
const pointsY = new Set();
for (let i = 0; i < polygon.length; i++) {
const [x, y] = projection(polygon[i]);
//...
// 收集投射后的uv坐标集
pointsX.add(x);
pointsY.add(-y);
}
// 保存在class或uniforms中均可
// 计算中点与极差
function calcMidAndDist(someSet) {
const sortedArr = Array.from(someSet).sort((a, b) => a - b);
const max = sortedArr[sortedArr.length - 1];
const min = sortedArr[0];
const mid = (max + min) / 2;
const dist = max - min;
return [mid, dist];
}
有了局部坐标系,我们就可以在Fragment Shader中进行渐变渲染了,此处简单缩小了color1的比重,使中心颜色范围更小。也可以使用其他的混合函数绘制不同的渐变,好文参考blog.csdn.net/weixin_4302…
同时,由于使用了自定义的ShaderMaterial,并不能直接修改材质的颜色实现选中效果,因此将hover与否写入uniform,据此进行条件渲染。
const fragmentShader = (
color1: number[],
color2: number[],
hoverColor: number[],
{ midX, midY, disX, disY }: GradUVParams,
) => `
varying vec2 vUV;
uniform bool hover;
void main() {
if (hover) {
gl_FragColor = vec4(${hoverColor.join(', ')}, 1.);
return;
}
float t = vUV.y;
float kx = 1. - abs((vUV.x - ${midX}) * 1.5 / ${disX});
float ky = 1. - abs((vUV.y - ${midY}) * 1.5 / ${disY});
float k1 = (kx + ky) / 2.;
float k2 = 1. - k1;
vec3 color1 = vec3(${color1.join(', ')});
vec3 color2 = vec3(${color2.join(', ')});
vec3 finalColor = color1 * k1 + color2 * k2;
gl_FragColor = vec4(finalColor, 1.);
}
`;
线条与锯齿
在官方文档中也写道,由于底层的一些限制,绘制省份边界常见的解决方案,LineBasicMaterial的许多参数都是无效的,例如linewidth,因此,改用addons中的Line2体系,进一步定制线条。
大多数用法都与LineBasicMaterial相同,只有在写入顶点时,不能继承自BufferGeometry的setFromPoints(),而应该使用setPositions(),将原参数扁平化作为连续数组传入即可。
import {
Line2,
LineGeometry,
LineMaterial,
} from 'three/examples/jsm/Addons.js';
//...
lineGeometry.setPositions(points);
//...
const line = new Line2(lineGeometry, lineMaterial);
但是,线条转折较多,该如何让线条变得更平缓呢?可以采取同步分辨率与抗锯齿的策略。
// 开启自带的抗锯齿
renderer = new THREE.WebGLRenderer({ antialias: true });
// 渲染考虑放大倍数
renderer.setPixelRatio(devicePixelRatio);
// FXAA后处理
import {
RenderPass,
ShaderPass,
FXAAShader,
EffectComposer,
} from 'three/examples/jsm/Addons.js';
composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
const fxaaPass = new ShaderPass(FXAAShader);
// 超分辨率
fxaaPass.uniforms['resolution'].value.set(
0.5 / width / devicePixelRatio,
0.5 / height / devicePixelRatio,
);
composer.addPass(fxaaPass);
现在,线条看上去更柔缓了。