【threejs】从零到一的web3d时代

ALLENJIAO 2020-06-13

    “未来早已到来,只是尚未流行。”——K.K

    最近,由于业务的需求,笔者的团队终于迈进了3d时代。

    其实,早在2017年,笔者便开始尝试前端的3d探索,因为当时主要的业务场景是运营活动,求新、创新便是活动的特性。不过,当时由于种种原因,最后未能落地,但未曾想到,会在3年后有了落地的时刻:

【threejs】从零到一的web3d时代

动态效果请查看这个demo: gis3dmodel

    这也是笔者第一次在正式场景中做这种尝试,而且由于时效性特别强,中间过程伴随着各种各样的问题,尽管最终效果也没有尽善尽美,但也算是里程碑式的第一步,心想着记录下这次探索的积累,于是才有了此文。

    其实,与当时相比,现在的业内的web3d环境已经有了很大的改观:前有U3D大量的商业案例成熟的开发模式,后有微软为babylon.js站台力挺,远不像当年只有3d领域three.js一家独奏的时代,不过比较下来,由于three.js海量的资料与文献,最终成为了笔者选择它作为团队撬开3d时代大门的钥匙(笔者这里直接略过了webgl,而直接选择了封装好一定功能上层库):

【threejs】从零到一的web3d时代

    相比2d时代,除了原本的舞台(scene)、渲染器(renderer)之外,新加入了光源(light)、摄像头(camera),以用来在绘图区域,描述一个虚拟的3d场景。而原本在场景中的元素,也变得复杂了起来:有geometry来描述它们的几何形状,有material来描述他们的材质,然后共同作用与object3d及其子类上,最终成为虚拟3d空间中的2d或3d角色。

    不过,要真正把three.js介绍完按笔者目前的熟悉程度,还是远远不够的,所以就以上做一个简单的介绍吧,然后回到笔者的实践中来。

    在笔者的业务场景中,主要述求是需要在一个3d场景中基于GIS信息绘制2d地图对象,然后给它添加一些光柱、辉光等效果存在着一个和上图类似的基于GIS信息的2d地图对象,需要解决经纬度虚拟三维空间坐标转换的问题,还有诸如渐变线条镜头转动效果辉光效果等等或模型上或交互上诸多问题的确认和解决,但整体来讲,其中最具有挑战性的却是一种新的工作流的建立:需要统一PM、UE/I、FE的想法,因为设计师提供的是一张静态的设计稿,而FE需要实现的则是包含比2d交互更多的3d交互效果(还有说镜头效果等)。

   不过工作流的问题比较虚,还是简单整理点实际坑:

   1.经纬度转换

   其实拿到经纬度的时候,聪明的你一定会先有一个疑问,经纬度类似于球面坐标,你要在平面绘制,难道不需要先做墨卡托投影吗?笔者刚开始也有这个疑问,不过经过对数据源的了解,其实我们拿到的gis数据就是已经做过墨卡托投影的了,所以就不必画蛇添足呢。

   另外,笔者需要将一连串经纬度数据,绘制到虚拟三维空间中去,而这些经纬度信息,由于本身的精度问题,直接绘制会导致整个地图在视觉上特别的小,那么如何解决呢?当然是做缩放咯。另外,笔者原以为需要对经纬度做球面坐标到平面坐标的转变,但是查阅资料后发现,并没有这个必要,于是就只需要进行缩放了,而缩放的逻辑也比较简单:

// 经纬度的最小值和最大值需在外层完成取值

function zoomXY(coords) {
  let per = (maxX - minX) / (maxY - minY);
  return coords.map(({ x, y }) => ({
    x: (x - minX) / (maxX - minX) * 100,
    y: (y - minY) / (maxY - minY) * 100 * per
  }))
}

    笔者先找到这些经纬度的最大值与最小值,在把他们按照原本的xy比例映射到x总长为100的区间上去,对原本都在31.XXX的维度进行放大,让他们能够比较清晰的呈现在画布上。

    2.渐变线绘制

    这个就更简单了,因为笔者使用了业界一个泛用性比较多的库——threejs,在它的官方demo中便提供了一种线性材质LineMaterial,更够让使用者自己定义Line的颜色:

for (let i = 0, len = coords.length; i < len; i++) {
    const geometry = new LineGeometry();
    const color = new THREE.Color();
    const positions = [];
    const colors = [];

    for (let j = 0, _len = coords[i].length; j < _len; j++) {
      if (j < _len / 2) {
        color.setHSL(.56 + .05 * j / (coords[i].length / 2), 1, .49 + .01 * j / (coords[i].length / 2));
      } else {
        color.setHSL(.61 - .05 * j / coords[i].length, 1, .5 - .01 * j / coords[i].length);
      }
      colors.push(color.r, color.g, color.b);
      positions.push(coords[i][j].x, coords[i][j].y, 0);
    }
    geometry.setPositions(positions);
    geometry.setColors(colors);

    const matLine = new LineMaterial({
      color: 0xffffff,
      linewidth: LINEWIDTH,
      vertexColors: true,
      dashed: false,
    });
    const line = new Line2(geometry, matLine);

    值得注意的是,因为笔者的场景需要保证颜色的平滑过渡,所以笔者在前半段进行了颜色HSL值的递增,后半段进行递减,最终实现首尾闭合。同时,这个材质还有个特殊的地方是需要在RaF中进行逐帧更新:

matLine.resolution.set(this._dom.clientWidth, this._dom.clientHeight)  

    3.光柱光晕辉光等效果

    这个就是比较基础的贴材质的功夫了,边缘的辉光笔者使用了Tween函数和scale去动态改变sprite的大小,做到让两个辉光能够跟着不规则路径进行“匀速运动”。另一方面,对于光晕和光柱则更简单了:

function getShiningCylinder(imgReousrce) {
  const texture = new THREE.TextureLoader().load(imgReousrce);
  texture.wrapS = THREE.RepeatWrapping;
  texture.repeat.set(10, 1);
  const geometry = new THREE.CylinderGeometry(radius, radius, height, 32, 1, true);
  const material = new THREE.MeshBasicMaterial({
    color: 0xffff00,
    map: texture,
    blending: THREE.AdditiveBlending,
    side: THREE.DoubleSide,
    transparent: true,
    opacity: 1,
    depthWrite: false,
  })
  // 因为时间仓猝,并未进行类的抽象,而是直接以闭包实现的丑陋代码还请见谅
  const cylinder = new THREE.Mesh(geometry, material);
  cylinder.rotation.x = Math.PI / 2;
  cylinder.position.set(0, 0, height / 2);
  cylinder.update = () => {
    cylinder._counter += .5;
    let angle = cylinder._counter * Math.PI / 180;
    cylinder._texture.offset.x = angle;
    if (cylinder.scale.y > 0) {
      cylinder.scale.x += 0.06;
      cylinder.scale.z += 0.06;
      cylinder.scale.y -= 0.005;
      cylinder.position.set(cylinder.position.x, cylinder.position.y, height / 2  * cylinder.scale.y);
    } else {
      cylinder.scale.set(1, 1, 1);
      cylinder.position.set(cylinder.position.x, cylinder.position.y, cylinder._orginZ);
    }
  }
  return cylinder;
}

    只要给一个开口的圆柱体贴上材质,然后逐帧改变它的位置信息,就能够实现。 

    不过,在整个过程中,笔者也发现threejs的文档体系整体并不是很完善,对于很多属性和方法的描述也不是很清楚,都需要开发者基于个人经验去做出决策,算是个不大不小的缺点。同时,由于threejs api的过于原子性特点,也让笔者产生了希望基于threejs打造一个小而美的3d引擎的想法。

    与此同时,笔者在完成了团队3d可视化能力从0到1的突破后,也需要将这些经验的方法输送给团队的其他同学,而想到此处,笔者在激动之余,更深深的觉得:

    “未来早已到来,只是尚未流行。

     写在最后

     其实,本次的gis模型只是小试牛刀而已,紧接着就要去呈现数字城市了,其中笔者感觉又将扔掉多年的C捡了起来,开始拾掇GLSL,简单的来说,three.js提供的普通材质还不足以覆盖笔者面临的业务场景

【threejs】从零到一的web3d时代

图片来源于网络

    更细致的纹理效果,很难依靠操作材质来实施,所以需要对纹理进行“定制”,而在web3d时代,webgl提供的定制材质的方式之一便是GLSL(OpenGL Shading Language),简单来说主要是使用着色器(shader),下面则是openGL中的两种着色器的工作流,主要就是从顶点着色器进行图元装配(primitive assembly),然后对它进行光栅化(Rasterization),之后再藉由片元着色器进行计算、混合、防抖,并进行一些必要裁剪(主要是超出显示区域的部分)。

【threejs】从零到一的web3d时代

    最后,将数据推入帧缓冲区(frame buffer),再将它最终绘制到屏幕上。而GLSL正是能够通过C语言操作去顶点着色器和片元着色器,影响最终输出结果的语言,有了它才能够做出更精致的效果。

    再写就扯远了,笔者团队对于web3d时代的探索才刚刚起步,希望有朝一日,也能够孵化出比肩业内优秀方案的web3d解决方案。

相关推荐