小白7码-学习3D建模,写出一个地图缝补怪

工作之余,花了很久学习使用three.js进行3D建模。对于零基础的人来说,真的想要入门的确挺难的。工作和兴趣才是最好的导师。公司业务未来有构建大屏驾驶舱的需求。也许每个公司都有未来构建大屏驾驶舱的原始冲动。正好,最近有业务要构建一个带地图的展示页面。

选择一个好的开始就成功了一半。发现边学边做对我来说才是最佳途径。前面花了不少时间看官方文档和B站上的一些教程。虽然弥补了一些基础,但是在真正实践的时候才发现有很多问题。比如如何调整整体展示的细节,或者如何去锯齿化等。最后参考了很多同类型的作品,慢慢地写出了一个缝补怪。我把它叫做v3map。可以根据geojson数据绘制一个3D地图,如下图所示。

图表 1 V3Map

所以接下来我们来好好聊聊怎么使用three.js生成一个地图模型。

1. 前期准备工作

在开始构建地图模型前,我们需要一些准备工作。比如构建项目,添加three.js等。

1.1. 构建项目

构建项目大家应该熟能生巧了。我在这里使用vscode通过npm构建了一个地图项目。项目通过webpack打包,所以需要引入webpack。同时最重要的是引入three.js。使用npm进行包管理不是这里的重点,不再信息展开。具体的可以查询官方文档。此外为了测试和事例,添加了vite。

1.2. 添加地理坐标数据

正所谓巧妇难为无米之炊。没有对应的地理坐标数据,就无法构建相应的模型。在这里不得不感谢一下alibaba大佬,确实留下一些好东西。地理数据可以在alibaba的数据可视化库中进行获取。此外后期地图建模还会使用一些字体文件。也可以使用阿里普惠体,都很好用。

2. 地图建模

很多和我一样的小白一定和我一样一开始就直接使用地理坐标数据就进行模型构建。这样做的结果就是在屏幕上你什么都看不到。为什么会这样呢,明明代码没有问题啊?问题在于地理坐标数据都是经纬度。这些数据对于默认展示都非常大。这样的数据会导致几个问题。第一个,模型会非常大,导致默认情况下你看不到全貌。另外一个,模型会偏移到视线观察范围外。而且就算你幸运地想到了上面的问题,你能看到地图模型,你也会得到一个不自然的地图模型,就好像它头重脚轻。为什么呢?

2.1. 墨卡托投影

上面的最后我们提到了使用地理数据建模会得到一个头重脚轻的模型。其实问题的症结在于我们都忽视了经纬度坐标数据虽然只有两个数据,但是它其实是一个三维坐标。因为地球是个球体。经纬度是球体上的坐标,所以隐藏了第三维的坐标值。如果需要将经纬度坐标绘制到平面几何体,我们需要使用墨卡托投影公式。网上有几个墨卡托投影公式。我实践后目前只找到一个公式是有效的,如下:

javascript 复制代码
/*** The mercator projection function implemation to convert the longitude and latitude to plain x and y position* @param {*} item* @returns*/const convertToXY = function (item) {const [longitude, latitude] = item;const x = longitude * Math.PI / 180 * radius;const d = latitude * Math.PI / 180;const y = (radius / 2) * Math.log((1.0 + Math.sin(d)) / (1.0 - Math.sin(d)));return [x, y];};

当然,很多第三方的工作包,例如d3,已经实现了墨卡托投影的转换公式。我为了尽量减小包的规模,所以考虑自己实现了。也是学习过程的乐趣。

2.2. 移位和缩放

墨卡托投影转化后的数据还不能直接使用。就像开始提到了,直接使用数据过大,会超出可观察的视线范围内。所以需要对数据进行矫正,进行平移和缩放。将模型的中心点平移到3D坐标世界的原点,然后根据特定的缩放比率进行缩小。这里的缩放比率通常是坐标值和屏幕物理尺寸的比值,和屏幕的物理像素比正相关。我在模型中通过配置的方式进行设置,方便适应不同的物理设备。

2.3. 地图主体建模

本质上来说地图是很多块表示行政单位区域的有厚度的非规则几何体,我称它为地图单位。正常地图单位包含了单位的主体几何体,表示边界的边框以及对应的文本标识等。

2.3.1. 主体几何体

主体的几何体我们需要用到three.js的ExtrudeGeometry构建。通常根据经纬度坐标转换的平面坐标单位数据借助Shape先构建一个平面几何。然后通过ExtrudeGeometry压铸生成有厚度的立体几何。ExtrudeGeometry可以设置多个material来配置上下平面和侧面的样式。

javascript 复制代码
_generateGeometry() {const { depth, bevelThickness } = this._options;const transformation = this._context.getTransformation();const shape = new Shape();this._polygon.forEach((item, index) => {const [longtitude, latitude] = item;const { x, y } = transformation.transform({x: longtitude,y: latitude});if (index === 0) {shape.moveTo(x, y);} else {shape.lineTo(x, y);}});return new ExtrudeGeometry(shape, {depth,bevelEnabled: true,bevelSegments: 1,bevelThickness: bevelThickness|| 0.001});}_generateTopMaterial() {const { surface } = this._options;const { color } = surface || {};return new MeshPhongMaterial({color: color && this._getColor(color) || 0xC3E7F8,combine: MultiplyOperation,transparent: true,opacity: 0.8,});}_generateSideMaterial() {const { side } = this._options;const { color } = side || {};return new MeshLambertMaterial({color: color && this._getColor(color) || 0x196486,transparent: true,opacity: 1,})}_generateMaterial() {return [this._generateTopMaterial(),this._generateSideMaterial()];}_generateMesh(geometry, material) {return new Mesh(geometry, material);}

如果没有颜色和边框区分,那么地图单位因为同一颜色会形成一片。

2.3.2. 边框

如果需要区分地图单元,比如我们想要知道中国每个省/直辖市等行政单位,可以选择添加边框或者使用不同颜色。这里我们使用Line/LineLoop进行构建。由于three.js的限制。Line

的宽度还不能自由设置。后续我发现可以使用TubeGeometry替代实现。

javascript 复制代码
_generateGeometry() {const { depth, depthDelta } = this._options;const transformation = this._context.getTransformation();const points = [];this._polygon.forEach((item) => {const [longtitude, latitude] = item;const { x, y } = transformation.transform({x: longtitude,y: latitude});points.push(new Vector3(x, y, depth + depthDelta + 0.001));});return new BufferGeometry().setFromPoints(points);}_generateMaterial() {const { color, weight } = this._options;return new LineBasicMaterial({color: this._getColor(color),linewidth: weight});}_generateLine(geometry, material) {return new LineLoop(geometry, material);}

2.3.3. 文本标签

文本标签在three.js中有两种实现方式:一种使用TextGeometry扩展模型。另一种使用基于html的CSS2DObject扩展模型。TextGeometry需要使用字体库同时展示的时候会受视线角度的影响。这里采用的CSS2DObject方式实现。

kotlin 复制代码
_generateDocElement() {const { className } = this._options;const docElement = document.createElement('div');docElement.className = className;docElement.textContent = this._text;this._applyStyle(docElement);docElement.style.position = "absolute";docElement.style.display = "block";docElement.style.textAlign = "center";return docElement;}_generateLabel(docElement) {const { depth } = this._options;const transformation = this._context.getTransformation();const { x, y } = transformation.transform(this._position);const label = new CSS2DObject(docElement);label.position.x = x;label.position.y = y;label.position.z = depth;return label;}

CSS2DObject需要包含一个HTML document element对象。我们的文本标签需要放在HTML document element对象中。同时CSS2DObject可以进行相应的位置调节。对于HTML document element对象的样式必须设置position,display。否则将会有显示问题。

在构建完成主体三要素之后,我们就可以得到一个基本的图形单元,如下图所示:

图表 2 基本图形

2.4. 装饰和标注

有了地图主体之后,我们可以进一步对地图进行装饰和标注。可以标注重点坐标,实现飞线联动等。

2.4.1. 标注

使用过百度地图的小伙伴们应该知道可以在地图上标注某个点时,地图上会出现一个红色的小图标。那么如何在3D地图上标注某个点呢。这里我们需要使用Sprite和SpriteMaterial。

首先我们需要准备好一个图标文件,如下所示:

图表 3 标注图标

然后我们需要使用TextureLoader预先加载图标资源。我在这里写了一个资源管理器用于管理图片资源,防止重复加载。

加载完图标资源后,我们就可以将设置到Sprite中。同时可以设置图标的坐标和大小尺寸的缩放。

kotlin 复制代码
_generateMaterial() {const { transparent } = this._options;return new SpriteMaterial({map: this.getTexture(),transparent});}_generateSprite(material) {const { depth, scale, depthDelta } = this._options;const transformation = this._context.getTransformation();const { x, y } = transformation.transform(this._position);const sprite = new Sprite(material);sprite.scale.set(scale, scale, scale);sprite.position.set(x, y, depth + depthDelta);sprite.renderOrder = 1;return sprite;}

2.4.2. 飞线

飞线是连接两个坐标之间的带有动画效果的线条。飞线本质上是一个TubeGeometry图形,借助CatmullRomCurve3描绘轨迹。只是我们给TubeGeometry图形赋予了特殊的纹理效果。

所以首先我们需要准备一张纯色线条里加载一小段异色的纹理图片,如下图所示。然后使用纹理的一个特性------改变偏移。这样我们就可以实现飞线。当然还有其他更好的方法。

scala 复制代码
/*** The flowing light animation;*/class TextureAnimation extends IAnimation {constructor(texture) {super();texture.wrapS = RepeatWrapping;texture.wrapT = RepeatWrapping;texture.repeat.set(1, 1);texture.needsUpdate = true;this._texture = texture;this._step = 0.01}isCompleted() {false;}animate() {this._texture.offset.x -= this._step;}}

图表 4 飞线纹理

javascript 复制代码
_generateGeometry() {const { depth, minDepthDelta, maxDepthDelta, weight } = this._options;const transformation = this._context.getTransformation();const startPoint = transformation.transform(this._startPoint);const endPoint = transformation.transform(this._endPoint);const deltaX = (endPoint.x - startPoint.x) / 3;const deltaY = (endPoint.y - startPoint.y) / 3;const points = [new Vector3(startPoint.x, startPoint.y, depth + minDepthDelta),new Vector3(startPoint.x + deltaX, startPoint.y + deltaY, depth + maxDepthDelta),new Vector3(startPoint.x + deltaX * 2, startPoint.y + deltaY * 2, depth + maxDepthDelta),new Vector3(endPoint.x, endPoint.y, depth + minDepthDelta)];const curve = new CatmullRomCurve3(points);const segment = Math.ceil(Math.abs((deltaX * 3) / 0.01));return new TubeGeometry(curve, segment, weight, segment * 5);}_generateMaterial() {return new MeshBasicMaterial({map: this.getTexture(),side: DoubleSide,transparent: true});}_generateMesh(geometry, material) {return new Mesh(geometry, material);}

2.4.3. 溜边

除了地图单元模块需要边框,也可以对整个地图增加溜边效果。溜边是看起来会动的边框,实现原理同上面的飞线。

typescript 复制代码
_generateGeometry() {const { depth, depthDelta, width } = this._options;const transformation = this._context.getTransformation();const feature = this._features[0];const { geometry } = feature;const { coordinates, type } = geometry;const points = [];const [ coordinate ] = coordinates;if (type === "MultiPolygon") {const [ polygon ] = coordinate;polygon.forEach((item) => {const [longtitude, latitude] = item;const { x, y } = transformation.transform({x: longtitude,y: latitude});points.push(new Vector3(x, y, depth + depthDelta + 0.01))});}const curve = new CatmullRomCurve3(points);return new TubeGeometry(curve, points.length, width/2, 8);}_generateMaterial(texture) {return new MeshBasicMaterial({map: texture,side: DoubleSide,transparent: true});}_generateMesh(geometry, material) {return new Mesh(geometry, material);}

2.5. 事件

除了视觉展示之外,交互也是很重要的一部分。Three.js的交互是由Raycaster来实现的。原理就像在屏幕上点击后仿佛产生了一条射线。Raycaster会返回射线路径上的目标对象。

所以在实现事件之前,我们最好在建模的时候养成比较好的习惯,给每个模型设置name属性,就像给它们赋予一个身份标识。这样我们就可以在事件中找到目标对象并区分使用。

然后我们需要给地图模型的容器document element注册相应的事件。通过该注册事件获取屏幕坐标。

然后使用通过Raycaster屏幕坐标获取目标对象进行处理。

kotlin 复制代码
import { EventEmitter } from "events";import { Raycaster } from "three";export default class EventManager extends EventEmitter {constructor(context, scene, camera, container) {super();this._context = context;this._scene = scene;this._camera = camera;this._container = container;this._raycaster = new Raycaster();this._raycaster.layers.set(1);this._onListen = this._onListen.bind(this);}onDispatch(callback) {this.addListener("dispatch", callback);}offDispatch(callback) {this.removeListener("dispatch", callback);}_getTargetName(intersect) {const { object } = intersect;let name = object.name;let parent = object.parent;while (!name && parent) {name = parent.name;parent = parent.parent;}return name;}_onListen(event) {if (!this._on) {this._on = true;const { clientX, clientY } = event;const { clientWidth, clientHeight, offsetWidth, offsetHeight } = this._container;const width = clientWidth || offsetWidth || window.innerWidth;const height = clientHeight || offsetHeight || window.innerHeight;const position = {x: (clientX / width) * 2 - 1,y: - (clientY / height) * 2 + 1};this._raycaster.setFromCamera(position, this._camera);const intersects = this._raycaster.intersectObjects(this._scene.children, true);let target = "";if (intersects && intersects.length) {const length = intersects.length;for (let i = 0; i < length; i++) {const intersect = intersects[i];target = this._getTargetName(intersect);if (target) {break;}}}if (target) {this.emit("dispatch", {type: "click",target,position: {x: clientX,y: clientY}});}this._on = false;}}listen() {this._container.addEventListener("pointerdown", this._onListen);}stop() {this._container.removeEventListener("pointerdown", this._onListen);}}

2.6. 结论

学习是一个痛并快乐着的过程。学习3D建模需要韧性。尤其是对新手小白也是一个煎熬的过程。但是相信大家最后都能有好的收获。最后附上源码给大家参考一下。

GitHub V3Map源码

相关推荐
m0_748247552 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255023 小时前
前端常用算法集合
前端·算法
真的很上进3 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203983 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
图表制作解说(目标1000个图表)3 小时前
ECharts散点图-气泡图,附视频讲解与代码下载
echarts·统计分析·数据可视化·散点图·大屏可视化
NiNg_1_2343 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1234 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~5 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语5 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport5 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap