一.前言

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-ternaryconst 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));});}

如何根据如上地图把点描在地图上,当然不是用贴图的方法,因为我们需要每个点的信息。根据逐个映射的方法可以来填充地图,图中黑色部分就是地域,透明部分就是海洋,那我们只用把黑色部分映射出来即可。这里面有几个点需要处理:
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 axisconst 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;      //一行的点数=圆周长x2for (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 numbersconst 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 orbitlet scalar;if (dist > radius * 1.85) {                                             //距离和radius乘以一个系数比较,获取scalescalar = 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 flickeringconst landingPos = polarToCartesian(geo_user_merged.lat, geo_user_merged.lon, radius + i/10000);  //转笛卡尔坐标,i参与计算防止z-fightingconst 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);             //曲线meshconst 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是根据两个经纬度坐标求中点坐标 ,这里重点讲一下:
直接进行经纬度求平均值是肯定不可取的,自己画个示意图就能知道。
正确的做法是求两个点在三个轴分量的平均值,然后在反向合成 即可求出中点。
先看下图:

图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 hoveredif (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); //射线穿过的物体会被拾取到arrayTargetreturn 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.intersectsif (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 indexif (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跳转,这里也不展开讲了。

四.结语

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

完整代码

解析GitHub首页3D动画相关推荐

  1. android开发骰子动画,GitHub - jieyou/dice: 一个css3 3d动画效果的色子(或称骰子?)...

    dice -- 3d色子(或称骰子?) 一个css3 3d动画效果的色子 完全效果(完全流畅的3d动画.阴影.圆角):Chrome\Firefox\Safari\iOS Safari 6.0+\And ...

  2. UC浏览器首页滑动动画实现

    UC浏览器首页滑动动画实现 我们先来看下UC浏览器首页的滑动动画和我最终实现的动画效果 使用方式 <cn.ittiger.ucpage.view.UCIndexViewxmlns:android ...

  3. Android Animation学习(五) ApiDemos解析:容器布局动画 LayoutTransition

    Android Animation学习(五) ApiDemos解析:容器布局动画 LayoutTransition Property animation系统还提供了对ViewGroup中的View改变 ...

  4. 只要做出角色3D模型,AI就能让它动起来!再也不怕3D动画拖更了丨SIGGRAPH 2020

    点击上方"AI遇见机器学习",选择"星标"公众号 重磅干货,第一时间送达 郭一璞 发自 云凹非寺 量子位 报道 | 公众号 QbitAI 一只3D的狗头卡通角色 ...

  5. Android Animation学习(四) ApiDemos解析:多属性动画

    Android Animation学习(四) ApiDemos解析:多属性动画 如果想同时改变多个属性,根据前面所学的,比较显而易见的一种思路是构造多个对象Animator , ( Animator可 ...

  6. 立体图形3D动画和绘制

    做了一个关于立体图形3D动画和绘制图形的例子,效果如下: 这个是参照苹果官方文档和例子来写的,其中茶壶是根据点.颜色渲染.网格结构和灯光效果来绘制出来的. 再说实现步骤前我们需要了解一下概念: GLK ...

  7. 纯CSS实现beautiful的3D动画

    大家好,我是"前端点线面",一位新生代农民工,欢迎关注我获取最新前端知识和大量思维导图("百题斩"获取<前端百题斩>pdf版:分别回复"g ...

  8. 系统学习iOS动画之六:3D动画

    本文是我学习<iOS Animations by Tutorials> 笔记中的一篇. 文中详细代码都放在我的Github上 andyRon/LearniOSAnimations. 到目前 ...

  9. 高仿支付宝首页头部动画

    高仿支付宝首页头部动画(使用design实现效果,CoordinatorLayout+AppBarLayout+CollapsingToolbarLayout+Toolbar) 效果图(效果图渐变不明 ...

  10. CSS3 3D动画(一)

    CSS3 3D动画知识点详解 这篇文章的主要内容是关于css3关于新增3d部分的一个解析,那么下一篇内容我们将会进行一个3D相册的一个练习. 由于CSS3的新增还有很多浏览器的不支持,所以我们简单说一 ...

最新文章

  1. java培训教程分享:Java中用户如何自定义异常?
  2. linux内核多种进程间通信机制
  3. Mysql-高可用集群[MyCat中间件使用](三)
  4. Windows下重叠I/O模型
  5. 获取网关_阿里二面问了这道题:如何设计一个微服务网关系统
  6. 32位的PLSQL登录64位的ORA11g有关问题
  7. docker限制容器日志大小
  8. java技术 ppt_Java技术简介与基本宣告ppt课件.ppt
  9. EDA发展历史及现状
  10. 关于sg90舵机的一点小想法
  11. 程序设计框架图和框架加载流程
  12. 51单片机——串口通信
  13. Windows10系统添加打印机步骤
  14. 蠕虫病毒 incaseformat 在国内肆虐,可导致用户数据丢失
  15. ad电阻原理图_arduino传感器专辑之光敏电阻模块
  16. 莎士比亚数据集_如何使用深度学习写莎士比亚
  17. java服务器常见状态码
  18. 一起来了解木马的七种分类
  19. zepto - 实现滑动翻页
  20. javaweb项目实战--学生管理系统

热门文章

  1. Python(三)微信公众号开发
  2. Access入门之索引查询
  3. 01-【Cron定时表达式】在线Cron表达式生成器+Cron表达式详解
  4. 南航计算机专业哪个校区,今天被南航拟录取了,写点干货留给后来人(学校选择+初试+复试)...
  5. Android、IOS JavascriptBridge 兼容方案
  6. android TextView属性汇总
  7. Kafka面试题总结
  8. 将GeoIP的region_code列表也复制过来一份
  9. C语言根号下的书写方法
  10. SPSS基础教程—怎样对数据进行综合评价排名