前言

和之前的 Mapbox 的文章一样,这篇文章的由来也是在我接手开发 Crypto Meetup 的时候遇到的一个实现问题。这个问题的来源是因为我本身对 Three.js 的不熟悉导致的,但是大家都知道,Three.js 是 Node.js 中十分著名而且简单的 WebGL 的实现库,你可以绘制动态的 2D 或者 3D 的物件或者是场景,甚至是写就一个游戏也是没问题的。
Crypto Meetup 是我在入职仙女座星系之后接手的第一个 Dapp 项目,目的是让用户之间可以共享地理位置,可以在持有特定 Fan 票的情况下,解锁他人的地理位置分享。虽然说叫 Dapp,但其实最终产品在写就的时候就没有太多关于区块链和去中心化的技术,使用的依然是 Vue 前端 + Koa2 后端这样的一个模式来完成的开发,最终部署到你们看到的网站的时候,也是依赖于中心化服务器提供的数据,所以说不算是一个严谨的 Dapp 吧,但是作为 Matataki 的一个小应用,还是蛮不错的,我在其中也学到了 Vue 项目的基本结构和如何构建的知识。

是什么问题呢?

在 Crypto Meetup 中,我们有两个东西组成,一个是平面地图的展示,一个是 3D 地球的展示,我们在上一篇相关的文章里面写到了关于怎么实现 Mapbox 在地图上渲染用户头像,以及 Mapbox 实现鼠标划过变色的具体实现问题和解决方案。

现在我们要做的,就是把用户的头像放到地球上,然后让地球上进行 2D 到 3D 的坐标转换,也就是球面坐标转换,还有令一点就是,用户头像是扁平的图像,是圆形的一个 2D 物体,我们还要让这个物体永远对齐我们的摄像机对准的地方。

因为我一开始对于怎么实现这个地球是十分疑惑的,在之前的人写下的代码中,使用了渲染器的代码,看起来不免有些困难,但是这个不是重点。我去找了很多资料,真的是非常多非常多的资料。

寻找参考资料

我先浏览的是 Three.js 提供的官方案例:https://threejs.org/examples/
然后搜索了 geometry,发现没有什么比较类似的案例。于是我转向了搜索引擎,去寻找更多的案例。

webgl geometry convex by Three.js

首先是这个问题: How To Place Marker with Lat/Lon On 3D Globe, Three.js?

后来找到了一个网站:http://stemkoski.github.io/Three.js/index.html
在这个网站里我找到了一个类似我们实现的内容的演示案例:

Labeled Geometry by Lee Stemkoski

接下来我看了源代码:

var spriteMaterial = new THREE.SpriteMaterial( 
    { map: texture, useScreenCoordinates: false, alignment: spriteAlignment } );

什么 Geometry 类型才适合?

他使用的是 Sprite 这个 Object API 来完成的,但是 Sprite 在加载图像作为 SpirteMaterial 的时候会有渲染不到的困难。于是我开始搜寻怎么样转换 2D 到 3D 的方法可以让我转换图片为一个所谓的 Mesh 对象。
我们先回去看看 Mesh 是怎么介绍的:Mesh - Three.js Documentation,在文档中,Mesh 是一个网格,是每一个 Geometry 结构的基础,是绘制图案的基本来源,我们定义 Mesh 之后,为我们的对象添加 Material 才能被渲染出来。接下来问题就来了,我应该用什么 Geometry?
2D 转 3D?什么样的 Geometry 才适合我们?我们已经知道 Sprite 是行不通的,我们需要换一个方案,
我找到了这个问题 Converting a 3D-Scene to 2D-image using raytracing (webgl, three.js)
但是似乎不是我们想要的答案,这里只是描述了 scene 的区别,和转换的理论方法。
我再找到了这个 How do you build a custom plane geometry in three.js? - StackOverflow,在这个问题的回答下面有很多方案,但是我看了另一个问题之后明确了我们需要的是什么:
Creating a plane, adding a texture on both sides and rotating the object on its side - StackOverflow,在这个回答下面,以及提问的代码中我们可以看到,使用了大量的 PlaneGeometry 来作为我们的图片承载对象。
那也就是说,我们 2D 物体都是基于 PlaneGeometry 完成的,那么怎么加载外部图像文件呢?
我找到了这个问题:Three.js Using 2D texture\sprite for animation (planeGeometry) - StackOverflow
我们发现,THREE.ImageUtils.loadTexture() 这个函数为我们加载了图像资源,后来又在下面看到,这个方法在后来的 Three.js 版本中废弃了,不得不换成 THREE.TextureLoader().load() 才能使用

基本的代码

于是我开始写代码。

const image = new THREE.TextureLoader().load(process.env.VUE_APP_CMUAPI + '/user/avatar?id=1332');
const geometry = new THREE.PlaneGeometry(10, 10, 1, 1)
const material = new THREE.MeshBasicMaterial({ map: image, transparent: true, side: THREE.DoubleSide })
const result = new THREE.Mesh(geometry, material)

我们先把这个物品放到 0,0,0 这个坐标上,然后注释掉渲染地球和其他物品的代码,我们刷新页面发现我们的图片被正确的加载了。也能正常的旋转,那么我们接下来要做的就是怎么去进行球面坐标的转换:

直角坐标和球面坐标的转换

按照球坐标与直角坐标的转换 - 知乎球坐标系 - Wikipedia 的介绍,我们知道了这个公式可以给我们进行几何坐标的转换。

转换公式 by 球坐标系 - Wikipedia

我们把这个写到代码里,

const phi = (90 - lat) * Math.PI / 180 // lat 是 纬度
const theta = (180 - lon) * Math.PI / 180 // lon 是 经度

const x = (EARTH_RADIUS + 8) * Math.sin(phi) * Math.cos(theta)
const y = (EARTH_RADIUS + 8) * Math.cos(phi)
const z = (EARTH_RADIUS + 8) * Math.sin(phi) * Math.sin(theta)

于是这样,我们就得到了我们的参数,x,y 和 z。
我们把之前渲染好的图片位置传入进去:

result.position.x = x
result.position.y = y
result.position.z = z
this.scene.add(result)

摄影机的视角跟踪怎么做?

好了,现在我们的头像可以紧紧的贴在我们的地球上,并且永远朝向一个方向,那么问题来了,我们怎么样才能使头像永远对齐我们的摄像机呢(也就是我们的视角)?
我查阅了 PlaneGeometry 的确有一个 lookAt(camera.position) 这样的使用方法,但是当我用到我们实际的代码的时候却没有什么反应。
我们在这个问题下找到了一些答案,TextGeometry to always face user? - StackOverflow

mesh.quaternion.copy(camera.quaternion)

这个方法相当于给 mesh 复制了一份动态的 摄像机 位置,但是这个方法不是动态的。
但是这样是不够的,我在另一个帖子里找到了最终解决这个问题的方法,首先我们在 Class 内定义一个 AvatarResult 数组来存储这些渲染好的图像,

this.AvatarResult.push(result)

我们在 this.scene.add(result) 之后添加到这个数组里,之后我们到 animate(也就是你的 this.render 所在的地方) 这个全局函数的地方,使用 forEach 方法,为每一个 Avatar 对象添加这个动态变化的参数

@autobind
animate() {
    if (!this.running) {
        return;
    }
    this.AvatarResult.forEach(e => e.rotation.setFromRotationMatrix(this.camera.matrix))
    requestAnimationFrame(this.animate);
    this.render();
}

最后的结果

最终我们获得了这样的结果:

最终的代码

最后,我们全部的代码只有很少的几行:

addNewPoint(lon, lat, magnitude, uid) {
    const phi = (90 - lat) * Math.PI / 180;
    const theta = (180 - lon) * Math.PI / 180;

    const x = (EARTH_RADIUS + 8) * Math.sin(phi) * Math.cos(theta);
    const y = (EARTH_RADIUS + 8) * Math.cos(phi);
    const z = (EARTH_RADIUS + 8) * Math.sin(phi) * Math.sin(theta);

    const image = new THREE.TextureLoader().load(process.env.VUE_APP_CMUAPI + '/user/avatar?id=' + uid);
    const geometry = new THREE.PlaneGeometry(10, 10, 1, 1)
    const material = new THREE.MeshBasicMaterial({ map: image, transparent: true, side: THREE.DoubleSide })
    const result = new THREE.Mesh(geometry, material)
    result.position.x = x
    result.position.y = y
    result.position.z = z
    result.quaternion.copy(this.camera.quaternion)
    this.scene.add(result)
    this.AvatarResult.push(result)
}

@autobind
animate() {
    if (!this.running) {
        return;
    }
    this.AvatarResult.forEach(e => e.rotation.setFromRotationMatrix(this.camera.matrix))
    requestAnimationFrame(this.animate);
    this.render();
}

这样我们就完成了我们要实现的内容了。

最后

谢谢你阅读到这里,感谢你对猫猫的支持,如果文中有什么地方写的不对和不够也希望大家能够指正。
因为国内 Three.js 的案例和解释还有文档实在不多,而且 Three.js 的官方文档查阅起来和搜索起来并不是很全面,在这里踩了很多很多的坑。
如果你也有类似的需求和需要,也感谢你阅读这些内容,也请放心,请自由的使用我在这篇文章中使用的任何代码

链接和参考

Mesh - Three.js Documentation: https://threejs.org/docs/#api/zh/objects/Mesh

webgl geometry convex by Three.js
Labeled Geometry by Lee Stemkoski
How do you build a custom plane geometry in three.js? - StackOverflow
Creating a plane, adding a texture on both sides and rotating the object on its side - StackOverflow
Three.js Using 2D texture\sprite for animation (planeGeometry) - StackOverflow
球坐标与直角坐标的转换 - 知乎
球坐标系 - Wikipedia
TextGeometry to always face user? - StackOverflow

感谢以上所有提供资源的作者和提问者,如果没有他们,也就不会有我的结果和最终的产品。