基于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来做了,但是,了解一下如何从头开始写一个数据大屏还是挺有好玩的. ------------- 为什么要做数据大屏? 现如今的大数据逐渐发挥出了它的力量,并无形的改变着我们的 ...
随机推荐
- C# 中的数组使用
· // 数组 /// 数组是一组相同类型的数据(ps:js中的数组可以不同类型) 访问通过索引访问数组元素 /// 数组的声明 要使用 new 使用 {} 来初始化数组元素 还需要指定数组的大小 / ...
- JDBC 和 Mybatis
使用JDBC连接操作数据库 Mybatis是JDBC的二次封装 使用更加简单了
- 什么是 Ajax,Ajax 的原理,Ajax 都有哪些优点和缺点
ajax是异步的js和xml,是一种创建交互式网页的开发技术,是和服务器进行异步通讯的技术 : 核心就是使用XMLHttpRequest向服务器发送请求获取数据 : 优点: 页面不需要刷新,用户体验良 ...
- 你为什么不应该过度关注go语言的逃逸分析
逃逸分析算是go语言的特色之一,编译器自动分析变量/内存应该分配在栈上还是堆上,程序员不需要主动关心这些事情,保证了内存安全的同时也减轻了程序员的负担. 然而这个"减轻负担"的特性 ...
- 自建家庭 KTV,在家想嗨就嗨
现在用户最多.曲库最多的 K 歌软件是全民K歌,基本上想唱的歌都有,而且基本上每首歌都有 MV 或视频,使用体验也还不错,但是收费太贵了,对于一个月唱不了几次的打工人来说,唱一首歌就是"天价 ...
- keycloak~token配置相关说明
会话有效期 在 Keycloak 中,"SSO Session Idle" 和 "SSO Session Max" 是用于配置单点登录(SSO)会话的两个参数. ...
- 初识GO语言--基础命令
- 一文彻底搞定Spring Security 认证,实现登陆登出功能
Spring Security 是一个强大且灵活的安全框架,提供了身份验证(认证)和授权(授权)功能.下面我们将详细介绍 Spring Security 的认证功能流程,并提供自定义实现登录接口的示例 ...
- 使用sklearn中的Adaboost分类器来实现ORL人脸分类
使用sklearn中的Adaboost分类器来实现ORL人脸分类 前言:博主上网浏览使用Adaboost实现人脸分类时,发现并没有分类,大部分全都是关于人脸识别检测的,并没有实现对某个人的精准分类(例 ...
- windows当中C++版本的Opencv安装(动态库+静态库)
主要参考2篇博客,其实就是dll文件和lib文件的使用方法而已.链接如下: 1.静态库opencv配置 2.动态库opencv安装