首先看下最终呈现效果
看下 gif 效果 (可以用OBS Studio 录屏和嗨格式转换器转成 gif)
主要功能点包括
- 自动旋转
- 扫光动画
- 卫星环绕
- 地球大气层云朵飘飘特效
- 涟漪波纹动画
- 飞线标注动画
- 文本标注
- 点击交互
技术选型考量:
首先,echart-gl 其实也是可以实现 3D 地球的,应该说大部分功能,echarts 都能实现,但是 echarts 偏向于实用,很多动画特效、很多炫酷的效果,用 echarts 来实现比较吃力,总体上,视觉效果远不如 three.js 。但是,用 three.js 来做 3d 地图,思路是很好,但这里面可能涉及到很多 webGL 方面的知识点、空间几何数学知识点、动画分析和图片分解的能力,总体上难度会更大一点,也会更有挑战性,效果也会更惊艳。
一、创建基本场景
用 three.js 来做世界地图,需要初始化 3D 场景,并将需要的物体加入到这个场景当中,基本元素包括:
- Scene —— 即3d场景,后续所有内容都会加入到场景当中,可以理解为一个大容器;
- Camera 相机 —— 可以理解为人的眼睛,可以设置相机的位置,模拟人眼在某个角度去看待 3D 世界的东西;
- Renderer 渲染器 —— 及时捕捉页面的元素的动态变化,并渲染到 web 页面上;
- OrbitControls 轨道控制器 —— 主要功能就是支持用户在页面上拖动元素、缩放元素,并进行更多细节的调整。
二、球体的基本实现
首先地球是个球体模型,three.js 本身就已经支持,所以不需要去加载外部的模型。地球的样式,使用贴图来实现就可以,但是贴图需要按照一定的比例,长宽基本上是 2:1 , 而且地理边界线要比较准确,下面是我在网上找到的地球贴图。
贴图找到之后,就是要按照 three.js 的要求来创建物体。
第1步,建立一个球模型
const earth_geometry = new SphereBufferGeometry(earth.radius, 50, 50 );
第2步,创建球的材质
this.uniforms.map.value = this.options.textures.earth;
const earth_material = new ShaderMaterial({
uniforms: this.uniforms,
vertexShader: earthVertex,
fragmentShader: earthFragment,
});
earth_material.needsUpdate = false;
注:以上代码涉及到顶点着色器、片元着色器,这些逻辑比较复杂,后面有时间,可以继续研究,暂时就先理解为创建材质就可以了。
第3步:创建地球,并加入场景中
this.earth = new Mesh(earth_geometry, earth_material);
this.earth.name = "earth";
this.earthGroup.add(this.earth);
呈现的效果如下:
当然也可以用其他贴图,效果如下:
用上面那张很鲜艳的图,效果如下:
球体做完了,怎么做自动旋转呢?首先我们需要了解 three.js 的坐标系, 我们在空间,加上辅助线,代码如下:
//注解:加上辅助线,试一下(红色X轴,绿色Y轴,蓝色Z轴)
const axesHelper = new AxesHelper(200);
this.scene.add(axesHelper);
这时候,在3D空间,生成了坐标系,其中红色线表示X轴, 绿色线表示Y轴, 蓝色线表示Z轴,效果如下:
很明显,这跟我们高中数学课上,见到的坐标系是一致的,three.js 的坐标系是这样,但是其他 3D 的坐标系可能不是这样的,所以,选定什么样的技术框架,就要适应它的坐标系。
这时候,我们如果想要地球自西往东(高中地理常识)旋转,这时候,只需要控制球体,绕Y轴旋转就可以了,具体怎么旋转,参考右手法则,大拇指指向Y轴正方向,其它四指弯曲的方向,就是旋转方向;如果是负数,则大拇指指向Y轴的反方向,其它四指弯曲的方向,就是旋转方向。
自西往东旋转,代码应该是:
this.earthGroup.rotation.y += 0.01;
三、大气层云朵飘飘效果
方案一:可以在地球的外面再套一个透明的球,这个球贴上云层图片,然后旋转速度跟地球的旋转速度不一样,形成相对运动,这样就会有云朵飘飘的效果。
可以在网上找一张透明的云层贴图:
加上云层后,效果如下:
由于地球的贴图本身又云朵纹理,外面又加上一层流动的云朵,整体看起来,云朵有点多,可以让设计师去掉地球贴图的云朵,这样视觉效果更好。
方案二:上面的实现方式有点单调,原因是外层的球,设置了自西往东,跟地球转动方向一致,虽然跟地球的转动速度不一样,形成了相对运动,但是云朵也只能局限于左右运动,不能向其他方向运动,这个效果比较死板。如果想要往其他方向运动,可以创建着色器材质,靠顶点着色器、片元着色器来辅助。
着色器相关知识点简介如下:
1、绘制简单的几何体,加载贴图
例如创建一个球体,加载云层纹理贴图
const fgeometry = new THREE.SphereGeometry(this.earth.radius * 1.002, 60, 60);
const cloudTexture = new THREE.TextureLoader().load('/static/images/cloud.png');
2、使用uniform变量
这里除了将纹理贴图传到着色器中,还传递了一个时间,这个时间来让纹理动起来。云朵的纹理的 wrapS 和 wrapT 设置成 THREE.RepeatWrapping,这是让纹理简单地重复到无穷大,而不至于 0,0 到 1,1 的范围。
uniforms = {
cloudTexture: {
value: cloudTexture
},
time: {
value: 0.0
},
}
3、顶点着色器
顶点着色器确定每个顶点的位置,并通过 varying 向片元着色器传递相关变量。
//顶点着色器
const VSHADER_SOURCE =
`varying vec2 v_Uv;
void main () {
//顶点纹理坐标
v_Uv = uv;
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
`;
4、片元着色器
片元着色器获取该点的位置后,去纹理图片找对应位置的色块,并通过时间参数 time 改变纹理坐标的偏移量,将色块赋值。
//片元着色器
const FSHADER_SOURCE =
`
//时间变量
uniform float time;
//大气纹理图像
uniform sampler2D cloudTexture;
//片元纹理坐标
varying vec2 v_Uv;
void main () {
//向量加法,根据时间变量计算新的纹理坐标
vec2 new_Uv= v_Uv + vec2(0.01, 0.02) * time;
//提取大气纹理图像的颜色值(纹素)
vec4 colors = texture2D(cloudTexture, new_Uv);
gl_FragColor = vec4(colors.rgb, colors.a * 0.6);
}
`
5、整体代码
createEarthAperture() {
//顶点着色器
const VSHADER_SOURCE =
`
varying vec2 v_Uv;
void main () {
//顶点纹理坐标
v_Uv = uv;
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}
`;
//片元着色器
const FSHADER_SOURCE =
`
uniform float time; //时间变量
uniform sampler2D cloudTexture; //大气纹理图像
varying vec2 v_Uv; //片元纹理坐标
void main () {
//向量加法,根据时间变量计算新的纹理坐标
vec2 new_Uv= v_Uv + vec2(0.01, 0.02) * time;
//利用噪声随机使纹理坐标随机化
//vec4 noise_Color = texture2D( cloudTexture, new_Uv );
//new_Uv.x += noise_Color.r * 0.2;
//new_Uv.y += noise_Color.g * 0.2;
//提取大气纹理图像的颜色值(纹素)
vec4 colors = texture2D(cloudTexture, new_Uv);
gl_FragColor = vec4(colors.rgb, colors.a * 0.6);
}
`
//着色器材质
const flowMaterial = new THREE.ShaderMaterial({
uniforms: this.cloud_uniforms,
//顶点着色器
vertexShader: VSHADER_SOURCE,
//片元着色器
fragmentShader: FSHADER_SOURCE,
transparent: true
})
//创建比基础球体略大的球状几何体
const fgeometry = new THREE.SphereGeometry(this.options.earth.radius * 1.002, 60, 60);
//创建大气球体
const fsphere = new THREE.Mesh(fgeometry, flowMaterial);
this.group.add(fsphere);
}
这样,云朵飘飘的效果就已经形成。
四、地球辉光
如果想要地球边缘发光的效果,那么可以借助贴图,通过精灵来实现。
精灵
这里说的精灵是 three.js 自定义的一种特质物品,就是无论空间怎么翻转、移动,该物体总是对着照相机,也就是说,物体总是面对着用户,创建精灵物体需要精灵材质,典型的代码如下:
createEarthGlow() {
//注解:地球半径
const R = this.options.earth.radius;
//注解:TextureLoader创建一个纹理加载器对象,可以加载图片作为纹理贴图
const texture = this.options.textures.glow;
/*
注解:创建精灵材质对象SpriteMaterial,为什么是精灵材质,因为这个背景图始终要面向用户,
所以要创建精灵,精灵就要选用精灵材质
*/
const spriteMaterial = new SpriteMaterial({
//注解:设置精灵纹理贴图
map: texture,
color: 0x4390d1,
//注解:开启透明,如果是false,将是一个正方形,遮挡后面景观
transparent: true,
//注解:可以通过透明度整体调节光圈,就是可以控制发光亮度
opacity: 0.7,
//禁止写入深度缓冲区数据
depthWrite: false,
});
//注解:创建表示地球光圈的精灵模型
const sprite = new Sprite(spriteMaterial);
sprite.name = 'glow';
//注解:适当缩放精灵
sprite.scale.set(R * 3.0, R * 3.0, 1);
//注解:将精灵加到 earthGroup
this.earthGroup.add(sprite);
}
看下精灵贴图:
五、图片标注
地球上面已经创建好了,现在如果想要在地球表面进行一些标注,该怎么实现呢?首先这里涉及到坐标的变换。我们拿到的数据可能是经纬度的数据,那这些数据怎么转化为三维空间的坐标呢?
这里需要空间几何的一些计算方法,见下图:
经纬度坐标,转球面坐标,代码如下:
//注解:{地球半径} R {经度(角度值)} longitude {维度(角度值)} latitude
export const lon2xyz = (R:number, longitude:number, latitude:number): Vector3 => {
//转弧度值
let lon = longitude * Math.PI / 180;
//转弧度值
const lat = latitude * Math.PI / 180;
//js坐标系z坐标轴对应经度-90度,而不是90度
lon = -lon;
//经纬度坐标转球面坐标计算公式
const x = R * Math.cos(lat) * Math.cos(lon);
const y = R * Math.sin(lat);
const z = R * Math.cos(lat) * Math.sin(lon);
//返回球面坐标
return new Vector3(x, y, z);
}
有了这个坐标转化,下面的工作就容易开展下去了。
场景:我们要在地球仪某个地点上标注一个平面,这时,不得不说 three.js 一个很重要的几何体 PlaneBufferGeometry, PlaneBufferGeometry 是平面的意思,创建这个平面之后,该平面默认是紧贴着 XOY 平面的,而且跟 Z 轴 垂直,很好理解。
但是平面既然默认是贴在 XOY 平面上,那怎么旋转、移动到球面上呢?
第一步:将平面位置移动到球面某个点上
mesh.position.set(coord.x, coord.y, coord.z);
第二步:旋转平面,使之贴近地球表面
原来平面的单位法向量(与这个平面垂直的向量称为法向量)是 (0,0,1), 而地球某个点 (x,y,z) 的单位法向量是:
new Vector3(coord.x, coord.y, coord.z).normalize()
这样通过旋转法向量,就可以将平面贴在地球上
完整代码如下
//注解:光柱底座矩形平面
export const createPointMesh = (options: {
radius: number,
lon: number,
lat: number,
material: MeshBasicMaterial
}) => {
//注解:创建一个平面几何体,这个几何体长和宽都是1,大小先不纠结,因为后面通过 scale 可以缩放,注意这个平面默认在三维空间的 XOY 平面上
const geometry = new PlaneBufferGeometry(1, 1);
//注解:通过传过来的平面材质,生成物体 mesh ,并且通过 scale 缩放这个平面的大小
const mesh = new Mesh(geometry, options.material);
const size = options.radius * 0.05;
mesh.scale.set(size, size, size);
//注解:经纬度转球面坐标,并设置这个平面的位置(注意这个1.0015,主要还是比地球稍微高出一点)
const coord = lon2xyz(options.radius * 1.0021, options.lon, options.lat);
mesh.position.set(coord.x, coord.y, coord.z);
//注解:将这个向量,转化为长度为1,方向不变的变量(这个其实就是这个平面的单位法向量)
const coordVec3 = new Vector3(coord.x, coord.y, coord.z).normalize();
//注解:这个是z轴正方向,长度为1的向量
const meshNormal = new Vector3(0, 0, 1);
//注解:将这个平面进行旋转,使之贴近球面,怎么旋转? 首先我们创建的平面,默认是垂直于z轴的,所以需要从(0,0,1)旋转到平面位置对应的法向量,也就是 coordVec3 代表的向量
mesh.quaternion.setFromUnitVectors(meshNormal, coordVec3);
return mesh;
}
我们稍微将这个平面放大一点,用上蓝色的波纹贴图,效果如下:
现在这2个平面就是紧贴地球了。
六、涟漪波纹动画
上面已经做好了平面,现在可以让这个波纹动起来,原理就是不断地放大缩小这个平面,从而形成波纹效果。 在 requestanimationframe 里面,改变缩放大小、透明度,从而形成动画,代码如下:
//注解:通过不断改变这个缩放,让波纹动起来 mesh.scale.set 、 mesh.material.opacity 来改变大小和材质
if (this.waveMeshArr.length) {
this.waveMeshArr.forEach((mesh: Mesh) => {
mesh.userData['scale'] += 0.007;
mesh.scale.set(
mesh.userData['size'] * mesh.userData['scale'],
mesh.userData['size'] * mesh.userData['scale'],
mesh.userData['size'] * mesh.userData['scale']
);
if (mesh.userData['scale'] <= 1.5) {
(mesh.material as Material).opacity = (mesh.userData['scale'] - 1) * 2;
//保证透明度在0\~1之间变化
} else if (mesh.userData['scale'] > 1.5 && mesh.userData['scale'] <= 2) {
(mesh.material as Material).opacity = 1 - (mesh.userData['scale'] - 1.5) * 2;
//2等于1/(2.0-1.5) mesh缩放2倍对应0 缩放1.5被对应1
} else {
mesh.userData['scale'] = 1;
}
});
}
效果如下
七、文本标注
上面通过创建平面 PlaneBufferGeometry 并将贴图放在这个平面上,通过位置移动、旋转来在球面上进行标注,但是,如果我们想要标注文本,文本内容是动态的,那该怎么办呢?
答案依旧是使用贴图,不过这张贴图不是 png 的固定图片,而是在页面上创建一个 div, 并将 div 转化为 canvas, 然后用 canvas 作为贴图,这样就可以实现文本标注。
需要注意的是:这个文本是要给用户看的,所以无论地球怎么旋转,这个文本都要面朝用户,这就是典型的精灵的特性,所以我们用精灵来实现。
代码如下:
const p = lon2xyz(this.options.earth.radius * 1.001, e.E, e.N);
//注解:根据城市名称,生成html
const div = `<div class="fire-div">${e.name}</div>`;
const shareContent = document.getElementById("html2canvas");
shareContent.innerHTML = div;
//注解:将以上的 html 转化为 canvas,再将 canvas 转化为贴图
const opts = {
//注解:这样表示背景透明
backgroundColor: null,
scale: 6,
dpi: window.devicePixelRatio,
};
const canvas = await html2canvas(document.getElementById("html2canvas"), opts);
const dataURL = canvas.toDataURL("image/png");
const map = new TextureLoader().load(dataURL);
//注解:根据精灵材质,生成精灵,为什么选用精灵?因为精灵的特点就是,始终面向用户
const material = new SpriteMaterial({
map: map,
transparent: true,
});
const sprite = new Sprite(material);
//注解:这里的缩放,是根据一个单位来计算的,精灵是二维的,所以第三个无论怎么设置,都是不会变动的, 所以干脆设置为1
const len = 5 + (e.name.length - 2) * 2;
sprite.scale.set(len, 3, 1);
//注解:精灵图片悬空,很好理解,将精灵加入到 eath 这个物体上,而没有加入 eathgroup 中, 其实好像都是一样的
sprite.position.set(p.x * 1.1, p.y * 1.1, p.z * 1.1);
最后呈现的效果:
八、光柱标注
首先看下光柱的贴图
原理:在地球上标记光柱,其实也是通过创建2个平面来实现,每个平面的贴图如上,这两个平面十字相交(让它看起来更加立体,所以十字相交),最后垂直于地球平面即可。
十字相交的形态,可以看下图:
首先创建一个平面 PlaneBufferGeometry, 然后设置贴图为光柱图片,再克隆一个一模一样的平面,将这两个平面通过旋转,形成十字相交状态。 再创建一个组合,将这两个平面都加入到组合当中,设置整体组合的位置,再类似旋转法向量一样,将平面组合垂直于地球表面,整体代码如下:
//注解:创建柱状,需要注意的是,translate 之后如果还设置组合的位置,那这个组合的中心点为 translate 之前的那个中心点
export const createLightPillar = (options: { radius: number, lon: number, lat: number, index: number, textures: Record\<string, Texture>, punctuation: punctuation }) => {
//注解:这里是设置光柱的高度
const height = options.radius * 0.3;
//注解:这里是创建一个平面,默认是垂直于z轴的,经过旋转
const geometry = new PlaneBufferGeometry(options.radius * 0.1, height);
//注解:经过旋转,这个平面与 ZOX 平面平行
geometry.rotateX(Math.PI / 2);
//注解:经过转移Z轴,使得这个平面全部都在Z轴的正方向
geometry.translate(0, 0, height/2);
//注解:将这个平面和材质生成一个物体
const material = new MeshBasicMaterial({
map: options.textures.light_column,
color: options.punctuation.lightColumn.endColor,
transparent: true,
side: DoubleSide,
//是否对深度缓冲区有任何的影响
depthWrite: false,
});
const mesh = new Mesh(geometry, material);
//注解:生成一个物体组合
const group = new Group();
//注解:这里是两个光柱十字交叉,也就是两个平面互相垂直,互相在中心点交叉
group.add(mesh, mesh.clone().rotateZ(Math.PI / 2));
//注解:将经纬度坐标转化为球面坐标,然后将这个组合设置在这个坐标上面(这时候还没完,因为这个组合没有垂直于球体)
const SphereCoord = lon2xyz(options.radius * 1.0023, options.lon, options.lat);
group.position.set(SphereCoord.x, SphereCoord.y, SphereCoord.z);
//注解:将位置向量变成程度为1的同方向的向量
const coordVec3 = new Vector3(
SphereCoord.x,
SphereCoord.y,
SphereCoord.z
).normalize();
const meshNormal = new Vector3(0, 0, 1);
//按这个向量的位置旋转,使得上面的组合体能都垂直于地球表面
group.quaternion.setFromUnitVectors(meshNormal, coordVec3);
return group;
}
九、流光特效与卫星环绕
使用 three.js 来实现 3D 流光动画,一种比较简单的处理方式,就是使用贴图,效果如下:
使用的贴图如下:
1、创建顶点数组
//注解:创建圆环点组合(这个这些点,都是在 ZOX 平面上)
const circlePoints = getCirclePoints({
//注解:圆的半径
radius: this.options.earth.radius * 1.8,
//注解:圆的切割数量
number: 100,
//注解:表示闭合
closed: true,
});
//注解:这里是将ZOX平面上以原点为圆心,半径为R的圆切割成 N 个点
export const getCirclePoints = (option) => {
const list = [];
for (
let j = 0;
j < 2 * Math.PI - 0.1;
j += (2 * Math.PI) / (option.number || 100)
) {
list.push([
parseFloat((Math.cos(j) * (option.radius || 10)).toFixed(2)),
0,
parseFloat((Math.sin(j) * (option.radius || 10)).toFixed(2)),
]);
}
if (option.closed) list.push(list[0]);
return list;
}
2、设置曲线的材质,并设置贴图
this.options.textures.flyline.wrapT = THREE.RepeatWrapping;
this.options.textures.flyline.wrapS = THREE.RepeatWrapping;
this.options.textures.flyline.repeat.set(1, 2);
//注解:圆环材质
const circleMaterial = new MeshBasicMaterial({
//color: new Color("#0cd1eb"),
map: this.options.textures.flyline,
side: DoubleSide,
transparent: true,
depthWrite: false,
opacity: 1,
});
circleMaterial.needsUpdate = true;
3、利用顶点数组、材质创建一条管道
//注解:创建圆环轨道(已掌握)
export const createAnimateLine = (option) => {
//注解:由多个点数组构成的曲线,通常用于道路
const linePoint = option.pointList.map((item: any) => new Vector3(item[0], item[1], item[2]));
//注解:曲线路径
const curve = new CatmullRomCurve3(linePoint);
//注解:根据曲线路径,生成管道体
const tubeGeometry = new TubeGeometry(
curve,
option.number || 50,
option.radius || 1,
option.radialSegments
);
return new Mesh(tubeGeometry, option.material);
}
4、不断推进贴图的 offset,使之看起来像流动的光线
this.options.textures.flyline.offset.x = this.options.textures.flyline.offset.x + 0.01
最终呈现效果
但是这个红色的光圈好像不太好看,后面我把它改成淡蓝色的了。
十、点击交互
three.js 这个框架中,实现点击事件,只能通过射线(Ray)与射线拾取(Raycaster)来实现。
1、创建射线
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
2、监听页面的点击事件
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e=>{
//获取鼠标点击的位置
let x = e.clientX;
let y = e.clientY;
//我们最终点击的位置,要用映射的方式传给射线,射线根据计算的比例,计算出实际发射射线的方向
mouse.x = ( e.clientX / window.innerWidth ) * 2 - 1;
mouse.y = - ( e.clientY / window.innerHeight ) * 2 + 1;
})
我们点击的位置,一般不是三维空间中的实际位置,所以需要将点击位置转化为 -1 至 1 的坐标中,再通过照相机往这个方向发射一条射线,看击中那些物体,再响应相关的事件。
3、创建射线,并拾取物体
//创建射线对象
let raycaster = new THREE.Raycaster();
//创建映射用,用于保存映射结果的顶点
let mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click',e=>{
//获取鼠标点击的位置
let x = e.clientX;
let y = e.clientY;
mouse.x = (x / window\.innerWidth ) * 2 - 1;
mouse.y = - ( y / window\.innerHeight ) * 2 + 1;
//使用当前相机和映射点修改当前射线属性
raycaster.setFromCamera(mouse,camera);
// 计算物体和射线的交点
let intersects = raycaster.intersectObjects( scene.children );
console.log(intersects);
})
以上代码中,射线拾取到的物体会放在集合 intersects 中,实际上射线击中物体后,可能还是再击中吉他物体,如果想要设置射线集中某些物体,减少遍历的个数,可以在 intersectObjects 这个方法中,传递待需要响应点击事件的物体。
//创建一个待点击的物品集合
this.earth.clickMesh = [];
//将需要响应点击事件的物体,加入以上集合中
this.earth.clickMesh.push(mesh1);
//遍历拾取的物体,做响应
const intersects = this.raycaster.intersectObjects( this.earth.clickMesh );
if(intersects && intersects.length > 0){
const firstObj = intersects[0];
const message = firstObj.object.userData;
this.option.callback(message);
}
如果物体很多,不好区分,那么可以在物体中添加一些自定义的属性,如 id、name 等,如下:
//将这个精灵存放起来
sprite.userData['event_type'] = 'sprite';
sprite.userData['event_name'] = e.name;
this.clickMesh.push(sprite);
这样点击后,alert 出来,效果如下
十一、飞线动画
应用场景:例如在地球仪上,画一条从上海到纽约的飞线。
这里会涉及到比较多的数学知识,几何知识,空间矩阵变换等等。
飞线轨道的画法,比较复杂,首先需要进行2次坐标的旋转。例如下图,假设 A为上海, B为纽约,首先 A 和 B 都是在地球表面上, O为地球的圆心, 这时候,需要将 ABO 这个平面旋转到 XOY 平面上,具体怎么旋转?正如上文所说的,用法向量来辅助旋转。
ABO 这个平面的单位法向量计算方式如下:
//注解:飞线起点与球心构成方向向量,方向是球心指向开始地点
const startDir = startSphere.clone().sub(origin);
//注解:飞线结束点与球心构成方向向量。方向是球心指向结束地点
const endDir = endSphere.clone().sub(origin);
//注解:cross 将会生成一个法向量,垂直于上面2个向量,并且指向遵守右手法则,这里通过 normalize 将这个法向量长度变成1
const normal = startDir.clone().cross(endDir).normalize();
旋转到XOY平面上,对应的法向量为 (0,0,1)也就是 Z 轴,这很好理解吧?!
好,通过上面的旋转,我们就可以将 ABO 这个平面贴到 XOY 这个平面上,但是,ATB 这条圆弧,还不是关于Y轴对称的,所以还需要再旋转一次。
也就是说,目前 OG 还没有和 Y轴重叠,那么怎么将 OG 和 Y轴 重叠呢?这时,再借助向量的旋转来实现: 可以计算出 AB 这条弦(直线)的中点,这个中点和圆心O的连线称为一个向量,Y轴的单位向量是 (0,1,0),这时候再旋转一次:
/*计算第二次旋转的四元数*/
//注解:获取这两个点的中点,那么圆心和这个中点的连线必定垂直于开始点和结束点的连线
const middleV3 = startSphereXOY.clone().add(endSphereXOY).multiplyScalar(0.5);
//注解:圆心与这个重点的连线,必定垂直于开始点和结束点的连线,这里取法向量
const midDir = middleV3.clone().sub(origin).normalize();
//注解:这里表示将旋转到Y轴的正方向,注意这里取单位向量
const yDir = new Vector3(0, 1, 0);
//注解:这里旋转为以Y轴为对称的坐标,需要生成第二个四元数
const quaternionXOY_Y = new Quaternion().setFromUnitVectors(midDir, yDir);
//第二次旋转:使旋转到XOY平面的点再次旋转,实现关于Y轴对称
const startSpherXOY_Y = startSphereXOY.clone().applyQuaternion(quaternionXOY_Y);
const endSphereXOY_Y = endSphereXOY.clone().applyQuaternion(quaternionXOY_Y);
这时候,就旋转出上图的结果, AB两点关于Y轴对称,这时候,来画出飞线轨道,也就是圆弧 ATB 就比较容易了,首先定一下 T点,设置为地球半径的某个倍数(大于1,当然也不要太大,否则飞线太高),这样,ATB三点已知,就可以计算出飞线轨道的圆心,也就是 G 的坐标。同时可以计算出 BGA 的角度是多少,飞线开始角度为 NGA, 结束角度为 BGN, 那么取这个范围,就可以画出轨道上较为高亮的线。
综上所述,我们旋转了两次,最后画出了飞线轨道,那怎么还原回来呢?这里涉及到四元数,也就是每旋转一次,都产生一个四元数矩阵,将矩阵反推会去,就可以旋转回来。大致原理是这样,有兴趣的话,直接研究源码吧。
十二、昼夜交换特效
这个特效,原理是:做一张白天的地球贴图,再做一张夜晚的地球贴图,通过获取白天贴图某个色块的值,再获取夜晚这个位置的色块的值,两个值分别乘以一个百分数(两个百分数加起来等于 100%)来达到昼夜交换的效果。
怎么取这两个贴图的色块呢?那就需要 Shader 编程了,类似上面做白云飘飘的效果那样,比较复杂,暂时没有实现,后续会跟进这个特效。
彩蛋
3D版中国地图已经在预研中,后续有时间会更新下博客。
看下动态效果
加上旋转、跳动的动画
由于时间忙,暂时停更博客,3d 版地图,看这一篇