解析GitHub首页3D动画

一.前言

GitHub是世界上最大的代码托管平台,超5千万开发者正在使用。GitHub中文社区,是国内领先的开源社区,是一个帮您发现GitHub上优质开源项目的地方。
它的首页动画很有意思,如下:

动画主要展示了世界各地都在用github,闪光点和发出的射线表达了各地的pull,merge,push等操作,比较生动并且科技感十足。
这边我们将代码扒了下来,并去掉了多余不关心的部分,方便我们进行解析。
本文主要对如下三点进行解析:
1.地球的制作,世界地图的描点
2.射线和冒尖闪光点的制作
3.鼠标hover的交互原理,点击跳转等

二.动画解析

1.制作地球仪

地球严谨上来讲是个椭圆球体,但是为了方便计算这里当作标准球体。

球体创建

    const geometry = new SphereBufferGeometry(radius, detail, detail);      //构建几何球体,radius为半径,detail为段数

    const materialFill = new MeshStandardMaterial({         //使用PBR物理材质
      color: waterColor,                                    //材质颜色使用深蓝色
      metalness: 0,                                         //金属度
      roughness: 0.9,                                       //粗糙度  
    });

    this.uniforms = [];
    /*
    省略材质预编译代码
    */

    this.mesh = new Group();
    const meshFill = new Mesh(geometry, materialFill);
    meshFill.renderOrder = 1;
    this.mesh.add(meshFill);
    this.meshFill = meshFill;
    this.materials = [materialFill];

更详细的代码在Globe类中,其中还包含了一些光线和阴影的处理,这里先忽略不做分析。

加载地图数据

    loadAssets() {
        let basePath = 'webgl-globe/';
        let imagePath = 'images/';
        const dataPath = `${basePath}data/`;
  
        // eslint-disable-next-line no-nested-ternary
        const manifest = [
          { url: `${basePath}${imagePath}map.png`, id: 'worldMap' }
        ];
  
        const loader = new Loader();
  
        return new Promise((resolve, reject) => {
          loader
            .load(manifest)
            .then(({ assets }) => {
              resolve(assets);
              loader.dispose();
            })
            .catch((error) => reject(error));
        });
      }

解析GitHub首页3D动画_第1张图片

如何根据如上地图把点描在地图上,当然不是用贴图的方法,因为我们需要每个点的信息。根据逐个映射的方法可以来填充地图,图中黑色部分就是地域,透明部分就是海洋,那我们只用把黑色部分映射出来即可。这里面有几个点需要处理:
1.如何获取纹理数据
2.已知经纬度,如何判断有效点 (图中黑色的像素点)
3.如何映射

获取纹理数据

getImageData(image) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext('2d');
    ctx.canvas.width = image.width;
    ctx.canvas.height = image.height;
    ctx.drawImage(image, 0, 0, image.width, image.height);
    return ctx.getImageData(0, 0, image.width, image.height);
}

用上面拿到的imageData来创建上下文,然后用getImageData就可以获取纹理数据

根据经纬度判断有效点

visibilityForCoordinate(long, lat, imageData) {
    const dataSlots = 4;                              //R、G、B、A 每个像素用4个1bytes值
    const dataRowCount = imageData.width * dataSlots; //行数据个数
    const x = parseInt((long + 180)/360 * imageData.width + 0.5);   //根据经度计算横坐标  (-180,180) => (0,360)
    const y = imageData.height - parseInt((lat + 90)/180 * imageData.height - 0.5); //纬度范围 (-90,90) => (0,180) 上面是0 所以用imageData.height来减
    const alphaDataSlot = parseInt(dataRowCount * (y - 1)  + x * dataSlots) + (dataSlots - 1);  

    return imageData.data[alphaDataSlot] > MAP_ALPHA_THRESHOLD;     //该点在图片上的透明度大于阈值
}

根据经纬度来确定横纵,找到该像素在imageData数组中的点,(dataSlots - 1)就是rgba中的a即透明值

如何映射

function polarToCartesian(lat, lon, radius, out) {     //根据球的参数方程来转化
  out = out || new Vector3();
  const phi = (90 - lat) * DEG2RAD;
  const theta = (lon + 180) * DEG2RAD;
  out.set(-(radius * Math.sin(phi) * Math.cos(theta)), radius * Math.cos(phi), radius * Math.sin(phi) * Math.sin(theta));
  return out;
}

回忆一下球的参数方程:x=a+Rsinu,y=b+Rsinucosv,z=c+Rsinusinv(u,v为参数)
这里几何的意义是将极坐标转化为笛卡尔坐标

填充地图
处理了上述这些问题,我们就可以开始填充地图了

buildWorldGeometry() {
      const { assets: { textures: { worldMap }, }, } = AppProps;
      const dummyDot = new Object3D();
      const imageData = this.getImageData(worldMap.image);
      const dotData = [];
      const dotResolutionX = 2; // how many dots per world unit along the X axis
      const rows = this.worldDotRows;
      for (let lat = -90; lat <= 90; lat += 180/rows) {         //纬度(-90,90)
        const segmentRadius = Math.cos(Math.abs(lat) * DEG2RAD) * GLOBE_RADIUS;   //半径
        const circumference = segmentRadius * Math.PI * 2;      //圆周长
        const dotsforRow = circumference * dotResolutionX;      //一行的点数=圆周长x2
        for (let x = 0; x < dotsforRow; x++) {          
          const long = -180 + x*360/dotsforRow;                 //经度
          if (!this.visibilityForCoordinate(long, lat, imageData)) continue;  //检测该经纬度是否可见  
          const pos = polarToCartesian(lat, long, this.radius);  //极坐标转笛卡3D尔坐标
          dummyDot.position.set(pos.x, pos.y, pos.z);
          const lookAt = polarToCartesian(lat, long, this.radius + 5);
          dummyDot.lookAt(lookAt.x, lookAt.y, lookAt.z);
          dummyDot.updateMatrix();
          dotData.push(dummyDot.matrix.clone());  //得到三维点的矩阵
        }
      }
      const geometry = new CircleBufferGeometry(this.worldDotSize, 5);    //圆点
      const dotMaterial = new MeshStandardMaterial({ color: COLORS.LAND, metalness: 0, roughness: 0.9, transparent: true, alphaTest: 0.02 }); //物理材质
      dotMaterial.onBeforeCompile = function (shader) {
        const fragmentShaderBefore = 'gl_FragColor = vec4( outgoingLight, diffuseColor.a );'
        const fragmentShaderAfter = `
          gl_FragColor = vec4( outgoingLight, diffuseColor.a );
          if (gl_FragCoord.z > 0.51) {
            gl_FragColor.a = 1.0 + ( 0.51 - gl_FragCoord.z ) * 17.0;
          }
        `
        shader.fragmentShader = shader.fragmentShader.replace(fragmentShaderBefore, fragmentShaderAfter); //替换成成自定义的材质
      };
      const dotMesh = new InstancedMesh(geometry, dotMaterial, dotData.length);  //多实例渲染,提升性能
      for (let i = 0; i < dotData.length; i++) dotMesh.setMatrixAt(i, dotData[i]);
      dotMesh.renderOrder = 3;
      this.worldMesh = dotMesh;
      this.container.add(dotMesh);    //添加所有的有效区域点
    }

上述代码的逻辑是:
1.将球以经纬度分成若干份,即球面由若干个3D点组成
2.然后根据经纬度拿到该点对应地图上的像素alpha值,筛选掉无效区域(即透明区域)
3.将筛选后的点 根据经纬度转化成笛卡尔3D坐标,即球面上的有效点
4.根据3D有效点,创建圆形的小亮点,添加在container上 即下图中的一个个小白点

图2.2 球体上添加有效区域点后,简单地球仪的样子

2.射线和尖峰点的制作

射线的制作

    {
        "uml": "California City",
        "gm": {
            "lat": 35.1258,
            "lon": -117.9859
        },
        "uol": "California City",
        "gop": {                                            //经纬度
            "lat": 35.1258,
            "lon": -117.9859
        },
        "l": "Jupyter Notebook",
        "nwo": "executablebooks/sphinx-book-theme",         //name with owner
        "pr": 506,
        "ma": "2022-02-24T22:44:55Z",
        "oa": "2022-02-24T00:27:54Z"
    },

上面是单个数据,data.json数据中包含了所有的git分支open,merge信息,包括地点时间,作者等。

制作射线的思路是根据json中的数据取出open和merge两个经纬度的坐标,如果这个坐标满足条件(距离大于一定长度,例如上面距离为0就忽略),就根据两个点 以及两者之间得到贝塞尔曲线的两个控制点。得到曲线后,根据我们前面文章有提到的TubeBufferGeometry建立管道,就可以得到弧线(射线其实可以看做是超级瘦的管道)。
来结合代码看一下创建射线的过程:

for (let i = 0; i < maxAmount; i++) {
        const { gop, gm } = data[i];
        // Casting longitude and latitude into numbers
        const geo_user_opened = { lat: +gop.lat, lon: +gop.lon };         //取open点
        const geo_user_merged = { lat: +gm.lat, lon: +gm.lon };           //取merge点
        if (!hasValidCoordinates(geo_user_opened) || !hasValidCoordinates(geo_user_merged)) {
          continue;
        }
        const vec1 = polarToCartesian(geo_user_opened.lat, geo_user_opened.lon, radius);
        const vec2 = polarToCartesian(geo_user_merged.lat, geo_user_merged.lon, radius);
        const dist = vec1.distanceTo(vec2);
        if (dist > 1.5) {                                                         //距离大于1.5才继续
          // arcs in outer orbit
          let scalar;
          if (dist > radius * 1.85) {                                             //距离和radius乘以一个系数比较,获取scale
            scalar = map(dist, 0, radius * 2, 1, 3.25);
          } else if (dist > radius * 1.4) {
            scalar = map(dist, 0, radius * 2, 1, 2.3);
          } else {
            scalar = map(dist, 0, radius * 2, 1, 1.5);
          }
          const midPoint = latLonMidPoint(geo_user_opened.lat, geo_user_opened.lon, geo_user_merged.lat, geo_user_merged.lon);  //获取中点
          const vecMid = polarToCartesian(midPoint[0], midPoint[1], radius * scalar);
          ctrl1.copy(vecMid);
          ctrl2.copy(vecMid);
          const t1 = map(dist, 10, 30, 0.2, 0.15);    //[10,30] => [0.2, 0.15]
          const t2 = map(dist, 10, 30, 0.8, 0.85);    //[10,30] => [0.8, 0.85]
          scalar = map(dist, 0, radius * 2, 1, 1.7);
          const tempCurve = new CubicBezierCurve3(vec1, ctrl1, ctrl2, vec2);       //建立临时三维贝塞尔曲线
          tempCurve.getPoint(t1, ctrl1);        //根据t1设置ctrl1点
          tempCurve.getPoint(t2, ctrl2);        //根据t2设置ctrl2点
          ctrl1.multiplyScalar(scalar);         //根据scale放大
          ctrl2.multiplyScalar(scalar);
          const curve = new CubicBezierCurve3(vec1, ctrl1, ctrl2, vec2);           //建立三维贝塞尔曲线
          // i is used to offset z to make sure that there's no z-fighting (objects
          // being rendered on the  same z-coordinate), which would cause flickering
          const landingPos = polarToCartesian(geo_user_merged.lat, geo_user_merged.lon, radius + i/10000);  //转笛卡尔坐标,i参与计算防止z-fighting
          const lookAt = polarToCartesian(geo_user_merged.lat, geo_user_merged.lon, radius+5);
          this.landings.push({pos: landingPos, lookAt: lookAt });
          const curveSegments = MIN_LINE_DETAIL + parseInt(curve.getLength());
          const geometry = new TubeBufferGeometry(curve, curveSegments, TUBE_RADIUS, this.TUBE_RADIUS_SEGMENTS, false);
          const hitGeometry = new TubeBufferGeometry(curve, parseInt(curveSegments/this.HIT_DETAIL_FRACTION), TUBE_HIT_RADIUS, this.TUBE_RADIUS_SEGMENTS, false);
          geometry.setDrawRange(0, 0);      
          hitGeometry.setDrawRange(0, 0);
          const lineMesh = new Mesh(geometry, this.tubeMaterial);             //曲线mesh
          const lineHitMesh = new Mesh(hitGeometry, this.hiddenMaterial);     //选中态的mesh 默认隐藏
          lineHitMesh.name = 'lineMesh';
          lineMesh.userData = { dataIndex: i };
          lineHitMesh.userData = { dataIndex: i, lineMeshIndex: this.lineMeshes.length };
          this.lineMeshes.push(lineMesh);
          this.lineHitMeshes.push(lineHitMesh);
        }
      }
      const { width, height } = parentNode.getBoundingClientRect();
    }

这其中latLonMidPoint是根据两个经纬度坐标求中点坐标 ,这里重点讲一下:
直接进行经纬度求平均值是肯定不可取的,自己画个示意图就能知道。
正确的做法是求两个点在三个轴分量的平均值,然后在反向合成 即可求出中点。
先看下图:

解析GitHub首页3D动画_第2张图片

图3.1是P1点在球体的空间示意图,图3.2是P1点的投影图,3.2中列出了求分量的公式。上代码:

function latLonMidPointMul(latlonArr){
  let x = 0,y = 0, z = 0;
  let lon,lat;
  for(var i = 0; i < latlonArr.length; i++){
    let latlon = latlonArr[i];
    lon = degreesToRadians(latlon.lon);
    lat = degreesToRadians(latlon.lat);

    x += Math.cos(lat) * Math.sin(lon);
    y += Math.cos(lat) * Math.cos(lon);
    z += Math.sin(lat);
  }

  x /= latlonArr.length;
  y /= latlonArr.length;
  z /= latlonArr.length;

  lon = radiansToDegrees(Math.atan2(x,y));
  lat = radiansToDegrees(Math.atan2(z,Math.sqrt(x*x + y*y)));
  return [lon, lat];
}

这里代码扩展了一下可以求多个点的中心点,degreesToRadians和radiansToDegrees是弧度和角度的转换,先转为弧度是为了方便三角函数的运算,后转成角度输出得到中心点的经纬度。

产生射线的动画
根据上面的原理和算法我们得出了想要的射线,动画只需要根据geometry的内置函数setDrawRange来实现即可,先来看一下函数定义:

	setDrawRange( start, count ) {
		this.drawRange.start = start;
		this.drawRange.count = count;
	}

顾明思议,设置起点和终点即可。起点和终点的坐标我们是已知的,那么只需要在update中给一定的速度让他增长即可,具体实现可以看一下代码:

update(delta = 0.01, visibleIndex) {
    let newVisibleIndex = parseInt(this.visibleIndex + delta * this.DATA_INCREMENT_SPEED);
    if (newVisibleIndex >= this.lineMeshes.length) {
    newVisibleIndex = 0;
    this.visibleIndex = 0;
    }
    if (newVisibleIndex > this.visibleIndex) this.isAnimating.push(this.animatedObjectForIndex(newVisibleIndex));     //新加入一条线
    let continueAnimating = [];
    let continueAnimatingLandingOut = [];
    for (const animated of this.isAnimating) {      //遍历animating数组(场景中存在一个或多个线段在做动画)
    const max = animated.line.geometry.index.count;
    const count = animated.line.geometry.drawRange.count + delta * this.lineAnimationSpeed;   //曲线根据速度向前移动一段距离
    let start = animated.line.geometry.drawRange.start + delta * this.lineAnimationSpeed;

    if (count >= max && start < max) this.animateLandingIn(animated);

    if (count >= max * this.PAUSE_LENGTH_FACTOR + this.MIN_PAUSE && start < max) {            //反向走
        // Pause animation of this line if it's being hovered
        if (animated.line == this.highlightedMesh) {        //鼠标hover的话 暂停
        continueAnimating.push(animated);
        continue;
        }
        start = this.TUBE_RADIUS_SEGMENTS * Math.ceil(start/this.TUBE_RADIUS_SEGMENTS);
        const startHit = this.TUBE_RADIUS_SEGMENTS * Math.ceil(start/this.HIT_DETAIL_FRACTION/this.TUBE_RADIUS_SEGMENTS);
        animated.line.geometry.setDrawRange(start, count);                                      //设置进度  
        animated.lineHit.geometry.setDrawRange(startHit, count/this.HIT_DETAIL_FRACTION);
        continueAnimating.push(animated);
    } else if (start < max) {                                                                 //正向走
        animated.line.geometry.setDrawRange(0, count);
        animated.lineHit.geometry.setDrawRange(0, count/this.HIT_DETAIL_FRACTION);
        continueAnimating.push(animated);
    } else {
        this.endAnimation(animated);                                                             //走完了 
    }
    }
    for (let i = 0; i < this.animatingLandingsOut.length; i++) {
    if (this.animateLandingOut(this.animatingLandingsOut[i])) {                                //应该结束就返回false,返回为true下次继续走相当于循环
        continueAnimatingLandingOut.push(this.animatingLandingsOut[i]);                          //不该结束放入continue数组
    }
    }
    this.isAnimating = continueAnimating;
    this.animatingLandingsOut = continueAnimatingLandingOut;
    this.visibleIndex = this.visibleIndex + delta * this.DATA_INCREMENT_SPEED;
}

尖峰点的制作
这里尖峰点指地球动画上像触须一样的点,头上还有一个亮点。
触须使用CylinderBufferGeometry来实现,两点是point粒子,动画的实现方法参考射线,这里就不再赘述,有兴趣的可以看一下源码 我在里面附加了注释

三.交互原理

1.自转和拖拽旋转

自动和拖拽旋转就是根据鼠标操作控制rotaion,threejs也有自己的工具类支持,这里编写了control类来实现,方便添加一些自定义的处理。

2.hover亮起和跳转

3D里的鼠标交互基本都是用射线检测来实现的,那本例中如何根据射线检测来实现呢,这里结合代码来讲:

function getMouseIntersection(mouse, camera, objects, raycaster, arrayTarget, recursive = false) {
  raycaster = raycaster || new Raycaster();       //new一条射线
  raycaster.setFromCamera(mouse, camera);         //射线定义为从相机鼠标定义一条线
  const intersections = raycaster.intersectObjects(objects, recursive, arrayTarget); //射线穿过的物体会被拾取到arrayTarget
  return intersections.length > 0 ? intersections[0] : null;
}

根据该函数定义,我们只用把需要检测的物体放入objects中即可。

const { raycaster, camera, mouseScreenPos } = this;
const frameValid = this.raycastIndex % this.raycastTrigger === 0;     //10帧检测一次
let found = false;
let dataItem;

if (frameValid) {
this.testForDataIntersection();       //检测数据交互 结果存放于this.intersects

if (this.intersects.length) {         //length>1 则鼠标与点或线相交
    const globeDistance = this.radius * this.containerScale;

    for (let i = 0; i < this.intersects.length && !found; i++) {
    const { instanceId, object } = this.intersects[i]; // vertex index

    if (object.name === 'lineMesh') {                         //弧线
        dataItem = this.setMergedPrEntityDataItem(object);
        found = true;
        break;
    } else if (object === this.openPrEntity.spikeIntersects && this.shouldShowOpenPrEntity(instanceId)) {   //尖峰点
        dataItem = this.setOpenPrEntityDataItem(instanceId);
        found = true;
        break;
    } else if (object.name === 'arcticCodeVault') {       //旗帜
        dataItem = {
        header: 'Arctic Code Vault',
        body: 'Svalbard • Cold storage of the work of 3,466,573 open source developers. For safe keeping.\nLearn more →',
        type: POPUP_TYPES.CUSTOM,
        url: 'https://archiveprogram.github.com'
        }
        this.highlightArcticCodeVault();
        found = true;
        break;
    }
    }
}

if (found && dataItem) {
    this.setDataInfo(dataItem);
    this.dataInfo.show();
} else {
    this.dataInfo.hide();
    this.openPrEntity.setHighlightIndex(-9999);
    this.mergedPrEntity.resetHighlight();
    this.resetArcticCodeVaultHighlight();
    this.dataItem = null;
    if (AppProps.isMobile) this.mouse = { x: -9999, y: -9999 } // Don't let taps persist on the canvas
}
}

上面代码的核心逻辑是:
1.拿到碰撞的物体(可能是地球,射线,尖峰,旗帜)
2.碰撞对应物体后设定特定的状态,并显示dataItem信息
3.根据dataItem信息来设置跳转的url路径
4.鼠标点击后即可跳转,松开后隐藏dataItem面板

其中设定特定状态这里,针对射线和尖峰,代码里有设置高亮的方法

//设置曲线高亮就是替换成高亮材质即可
setHighlightObject(object) {
    const index = parseInt(object.userData.lineMeshIndex);
    const lineMesh = this.lineMeshes[index];
    if (lineMesh == this.highlightedMesh) return;
    lineMesh.material = this.highlightMaterial;
    this.resetHighlight();
    this.highlightedMesh = lineMesh;
}

实质是提前创建了高亮的材质,然后替换材质即可,材质的属性可以随意配置。
跳转就是herf跳转,这里也不展开讲了。

四.结语

此动画初步看时比较复杂难于下手,当逐个解析时还是可以很好的去理解的,难点在于一些立体空间的计算。这方面时间久了不用就非常生疏,好在通过一些投影图的辅助还是可以算出来的。另外,这其中的科技感大多是一些光效的处理,本文并没有做过多的解析,后面有机会会做详细的解析。

完整代码

你可能感兴趣的:(three.js,WebGL)