带你入门three.js——从0到1实现一个3d可视化地图

共 21311字,需浏览 43分钟

 ·

2021-07-16 18:22

关注并将「趣谈前端」设为星标

每日定时推送技术干货/优秀开源/技术思维

前言

7月份打算输出3篇万字长文带大家系统地学习可视化表达的3种方式,svg、canvas、webgl。所以这是第一篇文章3d的。读完本篇文章,你可以学到什么

  1. 对于three.js 这个框架有一个简单的理解,可以入门下。
  2. 学习three中的Raycaster,主要是用鼠标来判断当前选择的是哪一个物体。
  3. 我用一个简单的实例 带大家用three实现简单的可视化地球案例 。

3d框架的选择——three.js

1.为什么选择three.js

官网对 「Threejs」 的介绍非常简单:“Javascript 3D library”。「openGL」 是一个跨平台3D/2D的绘图标准,「WebGL」 则是「openGL」 在浏览器上的一个实现。web前端开发人员可以直接用「WebGL」 接口进行编程,但 「WebGL」 只是非常基础的绘图API,需要编程人员有很多的数学知识、绘图知识才能完成3D编程任务,而且代码量巨大。「Threejs」「WebGL」 进行了封装,让前端开发人员在不需要掌握很多数学知识和绘图知识的情况下,也能够轻松进行web 3D开发,降低了门槛,同时大大提升了效率。总结来一句话:就是你不懂计算机图形学,只要理解了three.js的一些基本概念你可以。

Threejs 的基本要素——场景

定义如下:

「场景」:是一个三维空间,所有物品的容器,可以把场景想象成一个空房间,接下来我们会往房间里放要呈现的物体、相机、光源等。

用代码表示就是如下:

const scene = new THREE.Scene();

你就把他想象成一个房间,然后你可以往里面去添加一些物体,加一个正方体哈,加矩形,什么都可以。其实three.js 整个之间的关系是一个 「树形结构」

Threejs 的基本要素——相机📷

「相机」:Threejs必须要往场景中添加一个相机,相机用来确定位置、方向、角度,相机看到的内容就是我们在屏幕上看到的内容。在程序运行过程中,可以调整相机的位置、方向和角度。

three.js 中的相机分为两种一种是正交相机📷 和透视相机📷,接下来我给大家一一介绍,但是理解照相机的情况下,你要先理解一个概念——视椎体

透视相机

视锥体是摄像机可见的空间,看上去像截掉顶部的金字塔。视锥体由6个裁剪面围成,构成视锥体的4个侧面称为上左下右面,分别对应屏幕的四个边界。为了防止物体离摄像机过近,设置近切面,同时为了防止物体离摄像机太远而不可见,设置远切面。

oc 就是照相机的位置, 近平面、和远平面图中已经标注。从图中可以看出,棱台组成的6个面之内的东西,是可以被看到的。影响透视照相机的大小因素:

  1. 摄像机视锥体垂直视野角度 也就是图中的「a」
  2. 摄像机视锥体近端面  也就是图中的 「near plane」
  3. 摄像机视锥体远端面  也就是图中的「far plane」
  4. 摄像机视锥体长宽比   「表示输出图像的宽和高之比」

对应的three 中的照相机:

const camera = new THREE.PerspectiveCamera( 45, width / height, 11000 );

透视相机最大的特点:就是符合我们人眼观察事物的特点, 近大远小。

近大远小的背后的实现原理就是相机会有一个投影矩阵:  投影矩阵的做的事情很简单,就是把视锥体转换成一个正方体。所以远截面的点就要缩小, 近距离的反而放大。

投影矩阵.png

正交相机

正交相机的特点就是视锥体的是一个立方体

在这种投影模式下,无论物体距离相机距离远或者近,在最终渲染的图片中物体的大小都保持不变。

这对于渲染2D场景或者UI元素是非常有用的。如图:

正交相机.png

three中代码如下:

const camera = new THREE.OrthographicCamera( width / - 2, width / 2, height / 2, height / - 211000 );

说完相机就要介绍下图形的组成形式了。

Threejs 的基本要素——网格

在计算机的世界里,一条弧线是由有限个点构成的有限条线段连接得到的。当线段数量越多,长度就越短,当达到你无法察觉这是线段时,一条平滑的弧线就出现了。计算机的三维模型也是类似的。只不过线段变成了平面,普遍用三角形组成的网格来描述。我们把这种模型称之为 Mesh 模型。

一条弧线由多条线段得到,线段的数量越多,越接近弧线。不懂的小伙伴,可以看下我的这篇文章:面试官问我会canvas? 我可以绘制一个烟花🎇动画里面「贝塞尔曲线可以是用一段段小线段去拟合起来的」

three.js 背后所有的图形在进行渲染之前, 都会进行三角化, 然后交给webgl 去渲染。

Threejs提供了一些常见的几何形状,有三维的也有二维的,三维的比如长方体、球体等,二维的比如长方形圆形等,如果默认提供的形状不能满足需求,也可以自定义通过定义顶点和顶点之间的连线绘制自定义几何形状,更复杂的模型还可以用建模软件建模后导入。

2d

image-20210703111412606.png

3d

image-20210703111444001.png

有了形状,可能渲染出来的图形没有美丽的样子,这时候材质就出来了。组成的mesh其实是有两个部分:

材质(Material)+几何体(Geometry)就是一个 mesh,Threejs提供了集中比较有代表性的材质,常用的用漫反射、镜面反射两种材质,还可以引入外部图片,贴到物体表面,成为纹理贴图。大家有兴趣可以自己去试一下。如图:

image-20210703111750461.png

Threejs 的基本要素——灯光

假如没有光,摄像机看不到任何东西,因此需要往场景添加光源,为了跟真实世界更加接近,Threejs支持模拟不同光源,展现不同光照效果,有点光源、平行光、聚光灯、环境光等。

AmbientLight(环境光)

环境光会均匀的照亮场景中的所有物体,环境光不能用来投射阴影,因为它没有方向。

const light = new THREE.AmbientLight( 0x404040 ); // soft white light

平行光(DirectionalLight)

平行光是沿着特定方向发射的光。这种光的表现像是无限远,从它发出的光线都是平行的。常常用平行光来模拟太阳光 的效果; 太阳足够远,因此我们可以认为太阳的位置是无限远,所以我们认为从太阳发出的光线也都是平行的。

const directionalLight = new THREE.DirectionalLight( 0xffffff0.5 );

点光源(PointLight)

从一个点向各个方向发射的光源。一个常见的例子是模拟一个灯泡发出的光。

const light = new THREE.PointLight( 0xff00001100 );

聚光灯(SpotLight)

光线从一个点沿一个方向射出,随着光线照射的变远,光线圆锥体的尺寸也逐渐增大。

const spotLight = new THREE.SpotLight( 0xffffff );

还有一些其他的灯光,感兴趣的小伙伴可以自行去three.js 官网查看。

Threejs 的基本要素——渲染器

渲染器就是去渲染你场景中灯光、相机、网格哇。

let renderer = new THREE.WebGLRenderer({
    antialiastrue// true/false表示是否开启反锯齿
    alphatrue// true/false 表示是否可以设置背景色透明
    precision'highp'// highp/mediump/lowp 表示着色精度选择
    premultipliedAlphafalse// true/false 表示是否可以设置像素深度(用来度量图像的分率)
    preserveDrawingBuffertrue// true/false 表示是否保存绘图缓冲
    maxLights3// 最大灯光数
    stencilfalse // false/true 表示是否使用模板字体或图案

three.js大体的一些要素我都介绍过了,接下面就进入在正题了,three.js 如何实现一个可视化地图呢?

可视化地图——three.js实现

场景的搭建

我先不管地图不地图的,地图的这些形状肯定是放置到场景中的。跟着我的脚步一步一步去搭建一个场景。场景的搭建就照相机,渲染器。我用一个map类来表示代码如下:

class chinaMap {
    constructor() {
      this.init()
    }

    init() {
      // 第一步新建一个场景
      this.scene = new THREE.Scene()
      this.setCamera()
      this.setRenderer()
    }

    // 新建透视相机
    setCamera() {
      // 第二参数就是 长度和宽度比 默认采用浏览器  返回以像素为单位的窗口的内部宽度和高度
      this.camera = new THREE.PerspectiveCamera(
        75,
        window.innerWidth / window.innerHeight,
        0.1,
        1000
      )
    }

    // 设置渲染器
    setRenderer() {
      this.renderer = new THREE.WebGLRenderer()
      // 设置画布的大小
      this.renderer.setSize(window.innerWidth, window.innerHeight)
      //这里 其实就是canvas 画布  renderer.domElement
      document.body.appendChild(this.renderer.domElement)
    }
    
    // 设置环境光
    setLight() {
      this.ambientLight = new THREE.AmbientLight(0xffffff// 环境光
      this.scene.add(ambientLight)
    }
  }

上面我做了一一的解释,现在场景有了,灯光也有了, 我们看下样子。

image-20210703140701037.png

对场景黑乎乎的什么都没有, 接下来我们我随便随便加一个长方体并且调用renderer的render方法。代码如下:

init() {
  //第一步新建一个场景
  this.scene = new THREE.Scene()
  this.setCamera()
  this.setRenderer()
  const geometry = new THREE.BoxGeometry()
  const material = new THREE.MeshBasicMaterial({ color0x00ff00 })
  const cube = new THREE.Mesh(geometry, material)
  this.scene.add(cube)
  this.render()
}

//render 方法 
render() {
  this.renderer.render(this.scene, this.camera)
}

按照上面👆去做你会页面还是明明都已经加了,为什么呢?

「默认情况下,当我们调用scene.add()的时候,物体将会被添加到(0,0,0)坐标。但将使得摄像机和立方体彼此在一起。为了防止这种情况的发生,我们只需要将摄像机稍微向外移动一些即可」

所以只要将照相机的位置z轴属性调整一下就可以到图片了

  // 新建透视相机
  setCamera() {
    // 第二参数就是 长度和宽度比 默认采用浏览器  返回以像素为单位的窗口的内部宽度和高度
    this.camera = new THREE.PerspectiveCamera(
      75,
      window.innerWidth / window.innerHeight,
      0.1,
      1000
    )
    this.camera.position.z = 5
  }

图片如下:

image-20210703142305435.png

这时候有同学就会问,嗯搞半天不和canvas 2d 一样嘛,有什么区别?看不出立体的感觉?OK 接下来我就让这个立方体动起来。其实就是不停的去调用 我们render 函数。我们用reqestanimationframe。尽量还是不要用setInterval,有一个很简单的优化。

「requestAnimationFrame」有很多的优点。最重要的一点或许就是当用户切换到其它的标签页时,它会暂停,因此不会浪费用户宝贵的处理器资源,也不会损耗电池的使用寿命。

我这里做的让立方体的x,y 不断的+0.1。先看下代码:

render() {
  this.renderer.render(this.scene, this.camera)
}

animate() {
  requestAnimationFrame(this.animate.bind(this))
  this.cube.rotation.x += 0.01
  this.cube.rotation.y += 0.01
  this.render()
}

效果图如下:

立方体的旋转.gif

是不是有那个那个感觉了, 我是以最简单的立方体的旋转,带大家从头入门下three.js。如果看到这里觉得这里,觉得对你有帮助的话,希望你能给我点个赞👍哦,感谢各位老铁了!下面正式地图需求分析。

地图数据的获得

其实最重要的是获取地图数据, 大家可以了解下openStreetMap

这个是一个可供自由编辑的世界地图。OpenStreetMap允许你查看,编辑或者使用世界各地的地理数据来帮助你。

这里我自己把中国地图的数据json拷贝下来了,代码如下:

// 加载地图数据
loadMapData() {
  const loader = new THREE.FileLoader()
  loader.load('../json/china.json', (data) => {
    const jsondata = JSON.parse(JSON.stringify(data))
  })
}

我给大家先看下json 数据的格式

![image-20210703154646470](/Users/wangzhengfei/Library/Application Support/typora-user-images/image-20210703154646470.png)

其实主要的是下面有个经纬度坐标, 其实这个才是我关心的,有了点才能生成线,最后才能生成平面。这里涉及到一个知识点, 「墨卡托投影转换」。 墨卡托投影转换可以把我们经纬度坐标转换成我们对应平面的2d坐标。大家对这个推导过程的感性的可以看下这篇文章:传送门

这里我直接用可视化框架——「d3」 它里面有自带的墨卡托投影转换。

// 墨卡托投影转换
  const projection = d3
    .geoMercator()
    .center([104.037.5])
    .scale(80)
    .translate([00])

由于中国有很多省,每个省都对应一个Object3d。

Object3d是three.js 所有的基类, 提供了一系列的属性和方法来对三维空间中的物体进行操纵。可以通过.add( object )方法来将对象进行组合,该方法将对象添加为子对象

我这里的整个中国是一个大的Object3d,每一个省是一个Object3d,省是挂在中国下的。然后中国这个Map挂在scene这个Object3d下。很明显,在three.js 是一个很典型的树形数据结构,我画了张图给大家看下。

image-20210704115145494.png

Scence场景下挂了很多东西, 其中有一个就是Map, 整个地图, 然后每个省份, 每个省份又是由Mesh和lLine 组成的。

我们看下代码:

     generateGeometry(jsondata) {
          // 初始化一个地图对象
          this.map = new THREE.Object3D()
          // 墨卡托投影转换
          const projection = d3
            .geoMercator()
            .center([104.037.5])
            .scale(80)
            .translate([00])

          jsondata.features.forEach((elem) => {
            // 定一个省份3D对象
            const province = new THREE.Object3D()
            this.map.add(province)
          })
          this.scene.add(this.map)
        }

看到这里我想你可能没有什么问题,我们整体框架定下来了,接下来我们进入核心环节

生成地图几何体

这里用到了 Three.shape() 和  THREE.ExtrudeGeometry()  为什么会用到这个呢? 我给大家解释下, 「首先每一个省份轮廓组成的下标是一个 2d坐标,但是我们要生成立方体,shape()  可以定义一个二维形状平面。它可以和ExtrudeGeometry一起使用,获取点,或者获取三角面。」

代码如下:

    // 每个的 坐标 数组
    const coordinates = elem.geometry.coordinates
    // 循环坐标数组
    coordinates.forEach((multiPolygon) => {
      multiPolygon.forEach((polygon) => {
        const shape = new THREE.Shape()
        const lineMaterial = new THREE.LineBasicMaterial({
          color'white',
        })
        const lineGeometry = new THREE.Geometry()

        for (let i = 0; i < polygon.length; i++) {
          const [x, y] = projection(polygon[i])
          if (i === 0) {
            shape.moveTo(x, -y)
          }
          shape.lineTo(x, -y)
          lineGeometry.vertices.push(new THREE.Vector3(x, -y, 4.01))
        }

        const extrudeSettings = {
          depth10,
          bevelEnabledfalse,
        }

        const geometry = new THREE.ExtrudeGeometry(
          shape,
          extrudeSettings
        )
        const material = new THREE.MeshBasicMaterial({
          color'#2defff',
          transparenttrue,
          opacity0.6,
        })
        const material1 = new THREE.MeshBasicMaterial({
          color'#3480C4',
          transparenttrue,
          opacity0.5,
        })

        const mesh = new THREE.Mesh(geometry, [material, material1])
        const line = new THREE.Line(lineGeometry, lineMaterial)
        province.add(mesh)
        province.add(line)
      })
    })

遍历第一个点的的和canvas2d画图其实是一模一样的, 移动起点, 然后后面在划线, 画出轮廓。然后我们在这里可以设置拉伸的深度, 然后接下来就是设置材质了。lineGeometry 其实 对应的是轮廓的边线。我们看下图片吧:

image-20210704142519856.png

相机辅助视图

为了方便调相机位置, 我增加了辅助视图, cameraHelper。 然后你回看下屏幕会出现一个十字架,然后我们就可以不断地调整相机的位置,让我们地地图处于画面的中央:

addHelper() {
  const helper = new THREE.CameraHelper(this.camera)
  this.scene.add(helper)
}

经过辅助的视图地不断调整:

哈哈哈哈,是不是有那个味道了。到这里我们的中国地图已经在画布的中央了就已经实现了。

增加交互控制器

现在地图是已经生成了,但是用户交互感比较差,这里我们引入three的OrbitControls  可以用鼠标在画面随意转动,就可以看到立方体的每一个部分了。但是这个方法不在three 的包里面, 得单独引入一个文件代码如下:

setController() {
  this.controller = new THREE.OrbitControls(
    this.camera,
    document.getElementById('canvas')
  )
}

我们看下效果:

轨道控制器.gif

射线追踪

但是对于我自己而言还是不满意, 我怎么知道的我点击的是哪一个省份呢,OK这时候就要引入我们three中非常重要的一个类了,Raycaster 。

这个类用于进行raycasting(光线投射)。光线投射用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)。

我们可以对canvas监听的onmouseMove 事件,然后 我们就可以知道当前移动的鼠标是选择的哪一个mesh。但是在这之前,我们先对每一个province这个对象上增加一个属性来表示他是哪一个省份的。

// 将省份的属性 加进来
province.properties = elem.properties

Ok, 我们可以引入射线追踪了带入如下:

setRaycaster() {
  this.raycaster = new THREE.Raycaster()
  this.mouse = new THREE.Vector2()
  const onMouseMove = (event) => {
    // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
    this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
    this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
  }
  window.addEventListener('mousemove', onMouseMove, false)
}

animate() {
  requestAnimationFrame(this.animate.bind(this))
  // 通过摄像机和鼠标位置更新射线
  this.raycaster.setFromCamera(this.mouse, this.camera)
  this.render()
}

由于我们不停地在在画布移动, 所以需要不停的的射线位置。现在有了射线, 那我们需要场景的所有东西去比较了,rayCaster 也提供了方法代码如下:

const intersects = this.raycaster.intersectObjects(
  this.scene.children, // 场景的
  true  // 若为true,则同时也会检测所有物体的后代。否则将只会检测对象本身的相交部分
)

这个intersects得到的交叉很多,但是呢我们只选择其中一个,那就是物体材质个数有两个的, 因为我们上面就是用对mesh用两个材质

 const mesh = new THREE.Mesh(geometry, [material, material1])

所以过滤代码如下

animate() {
  requestAnimationFrame(this.animate.bind(this))
  // 通过摄像机和鼠标位置更新射线
  this.raycaster.setFromCamera(this.mouse, this.camera)
  // 算出射线 与当场景相交的对象有那些
  const intersects = this.raycaster.intersectObjects(
    this.scene.children,
    true
  )
  const find = intersects.find(
    (item) => item.object.material && item.object.material.length === 2
  )

  this.render()
}

我怎么知道我到底找到没,我们对找到的mesh将它的表面变成灰色,但是这样会导致一个问题,我们鼠标再一次移动的时候要把上一次的材质给他恢复过来。

代码如下:

 animate() {
    requestAnimationFrame(this.animate.bind(this))
    // 通过摄像机和鼠标位置更新射线
    this.raycaster.setFromCamera(this.mouse, this.camera)
    // 算出射线 与当场景相交的对象有那些
    const intersects = this.raycaster.intersectObjects(
      this.scene.children,
      true
    )
    // 恢复上一次清空的
    if (this.lastPick) {
      this.lastPick.object.material[0].color.set('#2defff')
      this.lastPick.object.material[1].color.set('#3480C4')
    }
    this.lastPick = null
    this.lastPick = intersects.find(
      (item) => item.object.material && item.object.material.length === 2
    )
    if (this.lastPick) {
      this.lastPick.object.material[0].color.set(0xff0000)
      this.lastPick.object.material[1].color.set(0xff0000)
    }

    this.render()
  }

看下效果图:

鼠标pick.gif

增加tooltip

为了让交互更加完美,找到了同时在鼠标右下方显示个tooltip,那这个肯定是一个div默认是影藏的,然后根据鼠标的移动移动相应的位置。

第一步新建div

<div id="tooltip"></div>

第二步设置样式 默认是影藏的

#tooltip {
  position: absolute;
  z-index2;
  background: white;
  padding10px;
  border-radius2px;
  visibility: hidden;
}

第三步更改div的位置:

  setRaycaster() {
    this.raycaster = new THREE.Raycaster()
    this.mouse = new THREE.Vector2()
    this.tooltip = document.getElementById('tooltip')
    const onMouseMove = (event) => {
      this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1
      this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
      // 更改div位置
      this.tooltip.style.left = event.clientX + 2 + 'px'
      this.tooltip.style.top = event.clientY + 2 + 'px'
    }

    window.addEventListener('mousemove', onMouseMove, false)
  }

最后一步设置tooltip的名字:

showTip() {
    // 显示省份的信息
    if (this.lastPick) {
      const properties = this.lastPick.object.parent.properties

      this.tooltip.textContent = properties.name

      this.tooltip.style.visibility = 'visible'
    } else {
      this.tooltip.style.visibility = 'hidden'
    }
  }

到这里,整个3d可视化地球项目已经完成了, 我们一起看下效果吧。

最终效果.gif

看个视频, 涨点知识?


❤️ 看完三件事

如果你觉得这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  • 点个【在看】,或者分享转发,让更多的人也能看到这篇内容
  • 关注公众号【趣谈前端】,定期分享 工程化 可视化 / 低代码 / 优秀开源


从零设计可视化大屏搭建引擎

Dooring可视化搭建平台数据源设计剖析

可视化搭建的一些思考和实践

基于Koa + React + TS从零开发全栈文档编辑器(进阶实战)


点个在看你最好看

浏览 93
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报