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

48次阅读

共计 10958 个字符,预计需要花费 28 分钟才能阅读完成。

前言

终于到周末了,前几篇的文章始终给大家介绍 2d,canvas 和 svg 的一些货色。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 编程工作,而且代码量微小。ThreejsWebGL 进行了封装,让前端开发人员在不须要把握很多数学知识和绘图常识的状况下,也可能轻松进行 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, 1, 1000);

​ 透视相机最大的特点:就是合乎咱们人眼察看事物的特点,近大远小。

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

正交相机

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

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

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

three 中代码如下:

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

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

Threejs 的基本要素——网格

在计算机的世界里,一条弧线是由无限个点形成的无限条线段连贯失去的。当线段数量越多,长度就越短,当达到你无奈觉察这是线段时,一条平滑的弧线就呈现了。计算机的三维模型也是相似的。只不过线段变成了立体,广泛用三角形组成的网格来形容。咱们把这种模型称之为 Mesh 模型。

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

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

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

2d

3d

有了形态,可能渲染进去的图形没有漂亮的样子,这时候材质就进去了。组成的 mesh 其实是有两个局部:

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

Threejs 的基本要素——灯光

如果没有光,摄像机看不到任何货色,因而须要往场景增加光源,为了跟真实世界更加靠近,Threejs 反对模仿不同光源,展示不同光照成果,有点光源、平行光、聚光灯、环境光等。

AmbientLight(环境光)

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

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

平行光(DirectionalLight)

平行光是沿着特定方向发射的光。这种光的体现像是有限远, 从它收回的光线都是平行的。经常用平行光来模仿太阳光 的成果; 太阳足够远,因而咱们能够认为太阳的地位是有限远,所以咱们认为从太阳收回的光线也都是平行的。

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);

点光源(PointLight)

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

const light = new THREE.PointLight(0xff0000, 1, 100);

聚光灯(SpotLight)

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

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

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

Threejs 的基本要素——渲染器

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

let renderer = new THREE.WebGLRenderer({
    antialias: true, // true/false 示意是否开启反锯齿
    alpha: true, // true/false 示意是否能够设置背景色通明
    precision: 'highp', // highp/mediump/lowp 示意着色精度抉择
    premultipliedAlpha: false, // true/false 示意是否能够设置像素深度(用来度量图像的分率)preserveDrawingBuffer: true, // true/false 示意是否保留绘图缓冲
    maxLights: 3, // 最大灯光数
    stencil: false // 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)
    }
  }

下面我做了一一的解释,当初场景有了,灯光也有了,咱们看下样子。

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

init() {
  // 第一步新建一个场景
  this.scene = new THREE.Scene()
  this.setCamera()
  this.setRenderer()
  const geometry = new THREE.BoxGeometry()
  const material = new THREE.MeshBasicMaterial({color: 0x00ff00})
  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
  }

图片如下:

这时候有同学就会问,嗯搞半天不和 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()}

效果图如下:

是不是有那个那个感觉了,我是以最简略的立方体的旋转,带大家从头入门下 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 数据的格局

其实次要的是上面有个经纬度坐标,其实这个才是我关怀的,有了点能力生成线,最初能力生成立体。这里波及到一个知识点,墨卡托投影转换。墨卡托投影转换能够把咱们经纬度坐标转换成咱们对应立体的 2d 坐标。大家对这个推导过程的理性的能够看下这篇文章:传送门

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

// 墨卡托投影转换
  const projection = d3
    .geoMercator()
    .center([104.0, 37.5])
    .scale(80)
    .translate([0, 0])

因为中国有很多省, 每个省都对应一个 Object3d。

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

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

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

咱们看下代码:

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

          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 = {
          depth: 10,
          bevelEnabled: false,
        }

        const geometry = new THREE.ExtrudeGeometry(
          shape,
          extrudeSettings
        )
        const material = new THREE.MeshBasicMaterial({
          color: '#2defff',
          transparent: true,
          opacity: 0.6,
        })
        const material1 = new THREE.MeshBasicMaterial({
          color: '#3480C4',
          transparent: true,
          opacity: 0.5,
        })

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

遍历第一个点的的和 canvas2d 画图其实是截然不同的,挪动终点,而后前面在划线,画出轮廓。而后咱们在这里能够设置拉伸的深度,而后接下来就是设置材质了。lineGeometry 其实 对应的是轮廓的边线。咱们看下图片吧:

相机辅助视图

为了不便调相机地位,我减少了辅助视图,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')
  )
}

咱们看下成果:

射线追踪

然而对于我本人而言还是不称心,我怎么晓得的我点击的是哪一个省份呢,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()}

看下效果图:

减少 tooltip

为了让交互更加完满,找到了同时在鼠标右下方显示个 tooltip,那这个必定是一个 div 默认是影藏的,而后依据鼠标的挪动挪动相应的地位。

第一步新建 div

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

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

#tooltip {
  position: absolute;
  z-index: 2;
  background: white;
  padding: 10px;
  border-radius: 2px;
  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 可视化地球我的项目曾经实现了,咱们一起看下成果吧。

总结

各位读者,如果感觉看完对你有帮忙的话,心愿你不要悭吝你手中的👍,点个👍和关注是对我最大的反对,常识输入不容易,而我勿忘初心,继续分享可视化的好文章,如果你对可视化感兴趣,你能够关注上面我的可视化专栏,或者能够关注我的公众号:前端图形,继续分享计算机图形学常识。本篇文章的所有代码都在我的 github 上 欢送 star,最初文章有哪里写的不对的中央,欢送斧正交换。

正文完
 0