基于Three.js的大屏3D地图(一)
依赖安装
yarn add three
yarn add @types/three
yarn add d3-geo
three库安装后在node_modules下其还包含核心three/src和插件three/example/jsm的源码,在开发调试时可以直接查阅。使用Three.js过程中会涉及到许多的类、方法及参数配置,所以建议安装@types/three库;不仅能提供类型提示,还有助于加快理解Three.js中的众多概念及关联关系。
d3-geo是d3库中独立出来专门用于处理地理数据可视化的模块。我们需要使用d3-geo中的部分方法来对原始的经纬度数据做墨卡托投影以在二维平面上正确定位。
数据处理
GeoJSON数据
我们是通过GeoJSON数据格式来绘制地图的。在开发测试阶段可以直接从阿里云的DataV地理工具中在线获取地图数据。
获取到的GeoJSON格式框架如下:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"adcode": 110000,
"name": "北京市",
"center": [116.405285, 39.904989],
"centroid": [116.41995, 40.18994]
},
"geometry": {
"type": "MultiPolygon",
"coordinates": [[[[]]]]
}
}
]
}


我们处理地图数据需要考虑的是MultiPolygon和Polygon类型。
墨卡托投影
经纬度坐标是记录某点在地球表面这一“曲面”结构上的确切位置,如果我们直接使用这些点坐标在二维平面上绘制是会产生形变的,因而需要先对所有的坐标做一次墨卡托投影转换以使它们能够在同一平面上展示。
在渲染地图前还要确保地图位于场景的中心,因此需要先计算出当前地图数据的中心点,将该中心点作为投影中心:
/**
* 计算边界和中心位置
*/
calcSide(geojson: any) {
const mapSideInfo = this.mapSideInfo = { minLon: Infinity, maxLon: -Infinity, minLat: Infinity, maxLat: -Infinity }
const { features } = geojson
features.forEach(feature => {
const { coordinates, type } = feature.geometry
coordinates.forEach(coordinate => {
if(type === "MultiPolygon") coordinate.forEach(item => dealWithCoord(item))
if(type === "Polygon") dealWithCoord(coordinate)
})
})
this.centerPos = {
x: (mapSideInfo.maxLon + mapSideInfo.minLon) / 2,
y: (mapSideInfo.maxLat + mapSideInfo.minLat) / 2
}
function dealWithCoord(lonlatArr) {
lonlatArr.forEach(([lon, lat]) => {
if(lon > mapSideInfo.maxLon) mapSideInfo.maxLon = lon
if(lon < mapSideInfo.minLon) mapSideInfo.minLon = lon
if(lat > mapSideInfo.maxLat) mapSideInfo.maxLat = lat
if(lat < mapSideInfo.minLat) mapSideInfo.minLat = lat
})
}
}
得出中心位置后,调用d3-geo的geoMercator生成转换方法:
this.coordTrans = geoMercator().center([this.centerPos.x, this.centerPos.y]).translate([0, 0])
将中心点坐标作为参数传入center()后返回一个变更了投影中心的新方法。接着我们还需要调用translate来修改默认的偏移量(见文档:https://d3js.org/d3-geo/projection#projection_translate)。
绘制地图
基础场景搭建
init(initData: confData) {
const { width, height, container } = initData
this.cfg = initData
// 创建场景与透视相机
const scene = new THREE.Scene()
this.scene = scene
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 1000)
camera.position.set(0, 0, 200)
this.camera = camera
// Webgl渲染器
const renderer = new THREE.WebGLRenderer()
renderer.setSize(width, height)
this.renderer = renderer
// 轨道控制器
new OrbitControls(camera, renderer.domElement)
container.appendChild(renderer.domElement)
// 2D渲染器
const labelRenderer = new CSS2DRenderer()
labelRenderer.domElement.style.position = "absolute"
labelRenderer.domElement.style.top = "0px"
labelRenderer.domElement.style.pointerEvents = "none"
labelRenderer.setSize(width, height)
container.appendChild(labelRenderer.domElement)
this.css2DRenderer = labelRenderer
// 开启循环渲染帧
const animate = () => {
renderer.render(scene, camera)
labelRenderer.render(scene, camera)
this.requestID = requestAnimationFrame(animate)
}
animate()
}
在开发阶段还可以引入坐标轴和性能检测面板来辅助开发:
// 坐标轴参考
this.axesHelper = new THREE.AxesHelper(150)
this.scene.add(this.axesHelper)
// 性能监测
this.stats = new Stats()
this.cfg.container.appendChild(this.stats.dom)
const animate = () => {
// ...
this.stats?.update()
}
绘制平面地图
首先利用THREE.Shape对象根据GeoJSON中的所有点连接成线,构造出地图在平面的轮廓:
createMapModel(geojson) {
features.forEach(feature => {
const { coordinates, type } = feature.geometry
coordinates.forEach(coordinate => {
if(type === "MultiPolygon") coordinate.forEach(item => dealWithCoord(item))
if(type === "Polygon") dealWithCoord(coordinate)
})
})
function dealWithCoord(lonlatArr) {
const pieceMesh = _this.createPieceMesh(lonlatArr)
_this.scene.add(pieceMesh)
}
}
createPieceMesh(lonlatArr) {
// 绘制区块形状
const shape = new THREE.Shape()
lonlatArr.forEach((lonlat, index) => {
let [x, y] = this.coordTrans(lonlat)
y = -y
if(!index) shape.moveTo(x, y)
else shape.lineTo(x, y)
})
// todo
}
THREE.ExtrudeGeometry挤出三维效果
有用过Blender或3DMax之类三维设计软件的同学应该对Extrude挤出操作不陌生,该操作就是将模型上的某一个平面沿着其法线方向拉伸出来。ThreeJS中有一个ExtrudeGeometry方法可以达到同样的目的。我们直接用下面的动图来生动展示下是如何从二维平面上挤出3D地图的:
createPieceMesh(lonlatArr: number[][]): THREE.Mesh {
// 绘制区块形状
// ...
// 构造几何体
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: this.cfg.depth,
bevelEnabled: false
})
const material = new THREE.MeshBasicMaterial({ color: 0xffffff })
const mesh = new THREE.Mesh(geometry, material)
return mesh
}

描边
上一步渲染的模型是纯白色材质的,为了方面观察还加上了黑色描边,下面补上代码:
createLine(lonlatArr: number[][]) {
const points: number[] = []
lonlatArr.forEach(lonlat => {
let [x, y] = this.coordTrans(lonlat)
y = -y
points.push(x, y, 0)
})
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points)
const meterial = new THREE.LineBasicMaterial({ color: 0x000000, linewidth: 2 })
const line = new THREE.Line(lineGeometry, meterial)
return line
}
线宽问题
线条材质的参数中有一个linewidth,可以供我们配置线条的宽度。但在实际使用中发现线宽只能固定为1不变,官方文档中给出了如下解释:

同时也给出了解决方案,可以使用拓展包中的Line2来渲染不同宽度的线条:
import { Line2 } from 'three/examples/jsm/lines/Line2'
import { LineGeometry } from 'three/examples/jsm/lines/LineGeometry'
import { LineMaterial } from 'three/examples/jsm/lines/LineMaterial'
createLine(lonlatArr: number[][]) {
const points: number[] = []
lonlatArr.forEach(lonlat => {
let [x, y] = this.coordTrans(lonlat)
y = -y
points.push(x, y, 0)
})
const lineGeometry = new LineGeometry()
lineGeometry.setPositions(points)
const lineMaterial = new LineMaterial({ color: 0x000000, linewidth: 2 })
const line = new Line2(lineGeometry, lineMaterial)
line.position.z = this.cfg.depth + 0.01
return line
}
LineGeometry缺陷
在构造LineGeometry时需要注意使用的是setPositions方法而不是setFromPoints。在three.js的171版本之前是不能使用setFromPoints方法来构造geometry的。
const points: THREE.Vector3[] = []
lonlatArr.forEach(lonlat => {
let [x, y] = this.coordTrans(lonlat)
y = -y
points.push(new THREE.Vector3(x, y, 0))
})
// Error:
const lineGeometry = new LineGeometry()
lineGeometry.setFromPoints(points)

因为LineGeometry是继承自LineSegmentsGeometry,但该类在实例化中会有预设的position属性,从而导致执行setFromPoints时发生数组下标越界的问题:https://github.com/mrdoob/three.js/commit/add7f9ba79a7f23732cf6e9e25ebcd4987550d45。
为地图正面和侧面应用不同的样式
目前为止我们的地图有一种样式,整个模型表面都是白色的。
const material = new THREE.MeshBasicMaterial({ color: 0xffffff }) // 纯白色材质
const mesh = new THREE.Mesh(geometry, material)
我们的大屏3D地图需要更为多样的表现,能对模型正面侧面应用上不同的样式。官方文档在ExtrudeGeometry构造函数的下面有这么一段说明:

在构造Mesh对象的第二个参数中传入材质数组的话,则可以将不同材质分别应用到模型的正面和侧面。我们用一个泥红色的半透明材质用作正面材质,草绿色材质用于侧面,渲染出一副水彩风格的地图:
const material = new THREE.MeshBasicMaterial({
color: 0xdd8787,
transparent: true, // 开启透明度
opacity: 0.7
})
const materialSide = new THREE.MeshBasicMaterial({
color: 0x9bda8c
})
// ...
const mesh = new THREE.Mesh(geometry, [material, materialSide])

纹理贴图
创建材质Material的时候除了可以通过color字段配置颜色,还可以通过map字段传入Texture对象来为模型贴上贴图。
const material = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('./top_image.jpg')
})
const materialSide = new THREE.MeshBasicMaterial({
map: new THREE.TextureLoader().load('./side_image.jpg')
})
// ...
const mesh = new THREE.Mesh(geometry, [material, materialSide])
读者可以用任意图片用作贴图看下渲染的效果,会发现贴图以一种非常奇怪的方式拉伸。这是因为我们没有定义好模型的UV映射坐标,即geometry.attributes.uv;该属性定义了应该如何将纹理贴图上的像素应用在我们的模型表面。
为了方便讲解,笔者这里不使用地图数据构造的geometry,而是一个相对更加简单的几何体:
const shape = new THREE.Shape()
shape.moveTo(-4, 4)
shape.lineTo(-4, -4)
shape.lineTo(4, -4)
shape.lineTo(4, 1)
shape.lineTo(1, 1)
shape.lineTo(1, 4)
const shape2 = new THREE.Shape()
shape2.moveTo(3, 4)
shape2.lineTo(4, 4)
shape2.lineTo(4, 3)
shape2.lineTo(3, 3)
const m1 = new THREE.MeshLambertMaterial({ color: 0xF0B5B5 }), m2 = new THREE.MeshLambertMaterial({ color: 0xffffff })
const geometry = new THREE.ExtrudeGeometry(shape, { depth: this.cfg.depth, bevelEnabled: false })
const geometry2 = new THREE.ExtrudeGeometry(shape2, { depth: this.cfg.depth, bevelEnabled: false })
const mesh = new THREE.Mesh(geometry, [m1, m2])
const mesh2 = new THREE.Mesh(geometry2, [m1, m2])
this.scene.add(mesh, mesh2)

接着为这两个几何体应用下面的UV测试图片作为纹理贴图:


可以看到我们的UV测试图只以1X1单位大小显示在模型表面上的一小部分地方,其他部分则由图片的四边拉伸填充至整个表面。而另外还有某些面连贴图都无法完整显示。
将几何体的position及uv属性打印出来:
console.log(mesh.geometry.getAttribute('position'))
console.log(mesh.geometry.getAttribute('uv'))


可以看到uv值并不都在[0-1]的区间内。对于uv值小于0的区域,会直接从贴图u/v坐标=0处采样像素点填充;同理,大于1的区域则是从u/v坐标=1处采样。这也就是上一步中贴图被异常拉伸的原因。
那么,打印的这些uv属性是如何得来的呢?我们看回文档ExtrudeGeometry的构造函数中有一个UVGenerator选项:

通过ExtrudeGeometry对象生成几何体时可以传入UVGenerator函数来决定几何体的uv应该如何计算。但文档中并没有进一步介绍该函数如何使用,需要直接看源码才能知道细节。打开ExtrudeGeometry的源码处,在构造函数中有这么一行对uv生成函数的处理逻辑[constructor -> addShape]:

在外部没有传入UVGenerator的情况下则会使用内置的WorldUVGenerator 。在WorldUVGenerator中有generateTopUV和generateSideWallUV两个函数分别用于定义顶面和侧面的uv生成逻辑:

结合命名和代码大致逻辑很容易看出来,默认的生成规则其实就是根据世界坐标的x/y/z值来作为uv值。顶面的生成规则很简单,直接使用顶点的xy坐标值用作uv值。侧面的生成规则相对复杂些,需要考虑前两个顶点的x/y值的差异量来判断是x·z平面来用作贴图还是y·z平面。
侧面纹理
既然可以自定义UV生成规则,就好解决了。我们先从generateSideWallUV开始。对于ExtrudeGeometry中的每一个侧面矩形,都会调用一次generateSideWallUV,传入的四个顶点下标index顺序是固定的:从该侧边平面的法线方向观察(即我们Extrude出来的几何体面向摄像机的一面,另一面默认是不可见的),垂直于shape平面的边作为底边来看的话,读取顺序是从左下角逆时针开始。
明白了上述原理后,结合笔者的需求:对于上传的侧面贴图,应用到每一个侧面并将其撑满。修改后的generateSideWallUV代码就很简单了:
generateSideWallUV: function(geometry, vertices, indexA, indexB, indexC, indexD) {
return [
new Vector2(0, 0),
new Vector2(1, 0),
new Vector2(1, 1),
new Vector2(0, 1)
]
}

顶面纹理
顶面的贴图需求和侧面类似,也是期望贴图能够撑满该面。有所不同的是顶面不是像侧面那样的矩形,而是一个不规则形状。需要知道顶面的“包围矩形”,然后让贴图撑满该矩形,就能达到我们的目的。
在ThreeJS中有一个Box3类可以帮助我们计算场景中物体的包围盒:
const box = new THREE.Box3()
box.setFromObject(this.scene)
const size = new THREE.Vector3()
box.getSize(size)
console.log('box: ', box)
console.log('size: ', size)

有了包围盒信息后就可以计算顶面中每个顶点所对应的UV值了。但是笔者这里不打算调整默认的generateTopUV;相较于在每次调用的generateTopUV中做计算,我们可以在创建纹理的时候就配置好它的缩放及偏移量:
const texture = new THREE.TextureLoader().load('./uv_test.jpg')
texture.colorSpace = THREE.SRGBColorSpace
const box = new THREE.Box3()
box.setFromObject(this.mapPieceGroup)
const size = new THREE.Vector3()
box.getSize(size)
texture.repeat.set(1 / size.x, 1 / size.y)
texture.offset.set(Math.abs(box.min.x / size.x), Math.abs(box.min.y / size.y))
texture.repeat的传参可以是小于1的值,相当于将贴图放大了。传入1 / size.x, 1 / size.y使得贴图的宽高同顶面的包围矩形一样。
接着设置纹理偏移texture.offset,使得缩放后的贴图和包围矩形对齐。

至此,纹理贴图也大功告成。让我们回到3D地图配置,整合本文的所有代码,根据设计图和相应的素材,检验下我们的demo成果:


基于Three.js的大屏3D地图(一)的更多相关文章
- Visual-platform,基于Vue的可视化大屏开发GUI框架
visual-platform 基于Vue的可视化大屏开发GUI框架 ------ CreatedBy 漆黑小T 构建用于开发可视化大屏项目的自适应布局的GUI框架. github仓库: https: ...
- vue+echarts可视化大屏,全国地图下钻,页面自适应
之前写过一篇关于数据大屏及地图下钻的文章 https://www.cnblogs.com/weijiutao/p/13977011.html ,但是存在诸多问题,如地图边界线及行政区划老旧,无法自适应 ...
- 使用Three.js实现炫酷的赛博朋克风格3D数字地球大屏 🌐
声明:本文涉及图文和模型素材仅用于个人学习.研究和欣赏,请勿二次修改.非法传播.转载.出版.商用.及进行其他获利行为. 背景 近期工作有涉及到数字大屏的需求,于是利用业余时间,结合 Three.js ...
- 使用webgl(three.js)搭建3D智慧园区、3D大屏,3D楼宇,智慧灯杆三维展示,3D灯杆,web版3D,bim管理系统——第六课
前言: 今年是建国70周年,爱国热情异常的高涨,为自己身在如此安全.蓬勃发展的国家深感自豪. 我们公司楼下为庆祝国庆,拉了这样的标语,每个人做好一件事,就组成了我们强大的祖国. 看到这句话,深有感触, ...
- 基于 HTML5 的工业组态高炉炼铁 3D 大屏可视化
前言 在大数据盛行的现在,大屏数据可视化也已经成为了一个热门的话题.大屏可视化可以运用在众多领域中,比如工业互联网.医疗.交通.工业控制等等.将各项重要指标数据以图表.各种图形等形式表现在一个页面上, ...
- 基于 HTML + WebGL 结合 23D 的疫情地图实时大屏 PC 版
前言 2019年12月以来,湖北省武汉市陆续发现了多例肺炎病例,现已证实为一种新型冠状病毒感染引起的急性呼吸道传染病并蔓延全国,肺炎疫情牵动人心,人们每天起来第一件事变成了关注疫情进展,期望这场天灾早 ...
- 基于 HTML + WebGL 结合 23D 的疫情地图实时大屏 PC 版【转载】
前言 2019年12月以来,湖北省武汉市陆续发现了多例肺炎病例,现已证实为一种新型冠状病毒感染引起的急性呼吸道传染病并蔓延全国,肺炎疫情牵动人心,人们每天起来第一件事变成了关注疫情进展,期望这场天灾早 ...
- Three.js实现3D地图实例分享
本文主要给大家介绍了关于利用Three.js开发实现3D地图的实践过程,文中通过示例代码介绍的非常详细,对大家学习或者使用three.js具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习 ...
- threejs三维地图大屏项目分享
这是最近公司的一个项目.客户的需求是基于总公司和子公司的数据,开发一个数据展示大屏. 大屏两边都是一些图表展示数据,中间部分是一个三维中国地图,点击中国地图的某个省份,可以下钻到省份地图的展示. 地图 ...
- 从零开始设计数据大屏—基于Vue ZT
虽然已经决定这个项目用Wyn来做了,但是,了解一下如何从头开始写一个数据大屏还是挺有好玩的. ------------- 为什么要做数据大屏? 现如今的大数据逐渐发挥出了它的力量,并无形的改变着我们的 ...
随机推荐
- .NET周刊【10月第1期 2024-10-06】
国内文章 基于DPAPI+RDP技术实现本地打开远程程序,并映射到本地机器桌面上 https://www.cnblogs.com/weskynet/p/18445584 该教程讲述如何使用Remote ...
- 高性能 Nginx HTTPS 调优 - 如何为 HTTPS 提速 30%
为什么要优化 Ngin HTTPS 延迟 Nginx 常作为最常见的服务器,常被用作负载均衡 (Load Balancer).反向代理 (Reverse Proxy),以及网关 (Gateway) 等 ...
- VuePress安装
linux 下预构建二进制文件安装 Nodejs Nodejs预购建二级制文件下载地址 安装 Nodejs 和 npm # 解压 tar xvf node-v20.15.1-linux-x64.tar ...
- Nuxt.js 应用中的 imports:sources 事件钩子详解
title: Nuxt.js 应用中的 imports:sources 事件钩子详解 date: 2024/10/27 updated: 2024/10/27 author: cmdragon exc ...
- 高效链接,极致体验:新时代的NFC智能互动系统
在科技不断发展的今天,数字化互动方式的创新已经成为提升用户体验和企业品牌价值的重要环节."碰一碰"系统应运而生,为新时代的社交传播.品牌推广和智能互动提供了一个前所未有的平台. 一 ...
- Redis示例配置文件
# 注意单位问题:当需要设置内存大小的时候,可以使用类似1k.5GB.4M这样的常见格式: # # 1k => 1000 bytes # 1m => # 1kb => 1024 by ...
- 21.Kubernetes配置默认存储类
Kubernetes配置默认存储类 前言 今天在配置Kubesphere的时候,出现了下面的错误 经过排查,发现是这个原因 我通过下面命令,查看Kubernetes集群中的默认存储类 kubectl ...
- 7.Kubernetes集群YAML文件详解
Kubernetes集群YAML文件详解 概述 k8s 集群中对资源管理和资源对象编排部署都可以通过声明样式(YAML)文件来解决,也就是可以把需要对资源对象操作编辑到YAML 格式文件中,我们把这种 ...
- Air201资产定位模组LuatOS:录音&播放录音功能的操作方法
一直有小伙伴们问,迷你小巧的合宙Air201虽然有很多优点,超低功耗.精准定位,那么它是否支持录音.播放录音功能? 那必须能!高集成化设计的Air201自带了ES8311音频解码芯片(Audio ...
- 超级干货!Air780E的串口通信分享
猛然发现,Air780E的串口通信还没分享,难怪已经有小伙伴提出了要求! 那我们来讲解低功耗4G模组Air780E的串口通信的基本用法,小伙伴们,学起来吧! 一.硬件准备 780E开发板一套 ...