在浏览网页时,笔者发现华为云一个有趣的效果,就是将地球上布局的城市标注出来,当城市出现在地球正面视线范围内时,就显示出来,而在靠近边缘时,就慢慢地隐藏直至消失不见;那么这种效果是如何实现的呢?里面又包含了哪些的逻辑呢?本文我们就来看下这个效果的实现过程。
我们先来看下效果示例:

❝由于gif文件太大了,这里就不截取动图效果了,感兴趣的小伙伴可以直接滑到文末查看实现的效果。
❞
环境准备
首先还是准备我们画布的基础环境,初始化场景、相机、渲染器、控制器四件套:
export defaultclass Index {
constructor() {
// 初始化场景
this.scene = initScene();
this.scene.background = new Color(0xf7f7f7);
// 初始化相机
this.camera = initCamera(new Vector3(10, 0, 0), 55, 0.001, 20000);
// 初始化渲染器
this.renderer = initRenderer();
// 初始化控制器哦
this.controls = initOrbitControls(this.camera, this.renderer);
// 禁止缩放
this.controls.enableZoom = false;
// 阻尼
this.controls.enableDamping = true;
// 自动旋转
this.controls.autoRotate = true;
}
}
我们再向场景中添加一个地球,这里我们直接用一个地球的纹理贴图即可:
// 球体的半径大小
const SPHERE_RADIUX = 3;
initMesh() {
this.loader = new TextureLoader();
const mat = new MeshBasicMaterial({
map: this.loader.load("ditu.jpg"),
});
const geo = new SphereGeometry(SPHERE_RADIUX);
const sphere = new Mesh(geo, mat);
sphere.position.set(0, 0, 0);
this.scene.add(sphere);
}
下面就需要在地球🌍上贴上一个个的城市定位了,这里用到一个新的渲染器:CSS2DRenderer
,这个渲染器用于将Html元素嵌入到3D场景中去,用于在场景中展示一些额外的信息;比如VR看房时候的标签,使用html标签有更好的操控,比如使用CSS还可以实现点击、悬浮、激活等等效果。
我们在初始化场景的时候添加一个CSS2DRenderer渲染器:
import {
CSS2DRenderer,
CSS2DObject,
} from"three/examples/jsm/renderers/CSS2DRenderer.js";
exportdefaultclass Index {
constructor() {
// 其他场景代码
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = "absolute"; // 设置渲染器样式
labelRenderer.domElement.style.top = "0";
labelRenderer.domElement.style.left = "0";
labelRenderer.domElement.style.zIndex = "1";
this.labelRenderer = labelRenderer;
document.body.appendChild(labelRenderer.domElement);
// 这里修改
this.controls = initOrbitControls(this.camera, this.labelRenderer, false);
}
}
❝这里要注意的是,我们在初始化controls控制器的时候,需要修改将CSS2DRenderer传入控制器的构造函数中,否则就会出现画布无法转动的情况。
❞
下面我们就需要将Html标签添加到CSS2DRenderer中去,
const list = [
{ id: 0, name: "北京", lng: 116.39, lat: 39.9 },
// 省略其他城市
];
const tagsList = [];
for (let i = 0; i < list.length; i++) {
const { name, lng, lat } = list[i];
const pos = this.latLongToVector3(lng, lat, SPHERE_RADIUX);
const box = document.createElement("div");
box.className = "global_position-box";
box.innerHtml = name;
const tag = new CSS2DObject(box);
tag.position.copy(pos);
this.scene.add(tag);
tagsList.push(tag);
}
this.tagsList = tagsList;
这里在循环列表的时候,需要将数据的经纬度坐标转换成在三维空间里的x、y、z坐标,我们用到一个latLongToVector3
函数进行转换处理,我们下面会介绍到这个函数。
通过CSS2DObject实例化标签后,设置标签的左边;但是将标签添加到页面上去后,我们会看到,如果是在我们视线背面的标签,同时也显现出来了。

这样的效果肯定不是我们想要的,因此,我们需要在每次render的时候,就需要不断的去控制每个html标签的透明度,当标签在我们视线后面的时候就设置为0,这才是我们想要的效果;那么标签的透明怎么来计算呢?我们先来学习三个工具函数的使用。
经纬度vs球坐标系
首先我们要学习的一个就是经纬度和三维球体坐标转换的一个关系函数,它也是Three.js中做三维地图经常会遇到的一个问题,下面我们就来看下它的原理和实现逻辑。
我们知道在三维坐标系中,我们用xyz能很快确定一个点在空间上的坐标;但是在球体坐标系中,我们需要另外三个参数来确定一个点的位置,我们看下数学中是如何来表示的:
-
径向距离:也就是我们常说的球体半径,是球面坐标点到球心的距离,用r表示。 -
极角:是z轴与r的交角,一般用 θ
表示。 -
方位角:是赤道面(由 x 轴与 y 轴确定的平面)上起始于 x 轴,沿逆时针方向量出的角度,通常用 φ
表示。
❝我们假设地球是一个完美的球体。
❞

❝需要注意的是,Three.js中,y轴和z轴与数学描述中的位置是相反的,即y轴是纵向的,z轴是从后往前延伸的;这也导致了下面代码中y和z的计算方式与公式的计算方式互换。
❞
如果用公式来表示,直角坐标和球坐标的对应关系如下:

公式有了,我们下面就要来看公式中的极角θ和方位角φ分别如何来得到;我们知道,纬度是点相对于赤道平面的角度,从-90°的南极到90°的北极,而极角是Three.js中的Y轴和点之间的夹角,因此北极是0,赤道是90,而南极是180;因此我们需要用90减去纬度,再通过度数和弧度的转换即可得到如下代码:
const phi = (90 - latitude) * (Math.PI / 180);
其次是方位角,由于是沿逆时针方向量出的角度,我们对其取反:
const theta = (longitude + 180) * (Math.PI / 180);
❝但是我们实际开发中拿到的地图不一定是标准的地图,需要对方位角进行处理,不一定是加180。
❞
极角和方位角得到了,我们通过公式得到一个标准的经纬度转换三维空间坐标的函数如下:
/**
* 将经纬度转换为三维空间坐标
* @param {number} longitude - 经度(-180到180)
* @param {number} latitude - 纬度(-90到90)
* @param {number} radius - 球体半径
* @returns {THREE.Vector3} 返回Three.js的三维向量坐标
*/
function latLongToVector3(longitude, latitude, radius): Vector3 {
// 极角(从北极开始)
const phi = (90 - latitude) * (Math.PI / 180);
// 方位角(从本初子午线开始)
const theta = (longitude + 180) * (Math.PI / 180);
// 计算球体上的点坐标(Y轴向上)
const x = -radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
returnnew THREE.Vector3(x, y, z);
}
角度计算函数
下面我们再来看一个数学问题:
❝在三维空间中,已知ABC三个点的坐标,求每个点的角度?
❞
经常学高数的朋友都知道,不要把它想象成三维,而是一个平面上的三角形;根据下面三角形的余弦定理:

我们看上面的公式,根据任意三条边的长度,我们都可以计算出角度的余弦值;因此我们需要一个函数来计算三维空间下两个点之间的距离:
// 计算空间上的两个点之间的距离
export function calc3DPointDist(x1, y1, z1, x2, y2, z2) {
const distX = x2 - x1;
const distY = y2 - y1;
const distZ = z2 - z1;
return Math.sqrt(distX * distX + distY * distY + distZ * distZ);
}
有了这个函数,我们就可以计算每个角对应边的长度了:
/**
* 已经ABC三维坐标,求各个点的角度
* @param {*} A
* @param {*} B
* @param {*} C
* @param {String} pos
* @returns
*/
export function calc3DAngle(A, B, C, pos = "A") {
// 三角形每条边的长度
const a = calc3DPointDist(B.x, B.y, B.z, C.x, C.y, C.z);
const b = calc3DPointDist(A.x, A.y, A.z, C.x, C.y, C.z);
const c = calc3DPointDist(A.x, A.y, A.z, B.x, B.y, B.z);
// ...
}
将上面的余弦定理继续推导一下,我们可以得到每个角度的cos计算公式:

再利用Math.acos
,我们就得到了ABC三个角的角度;再通过传入的参数pos,直接得到我们想要角的角度:
export function calc3DAngle(A, B, C, pos = "A") {
// 省略上面a、b、c的计算
let cosA = Math.acos((b * b + c * c - a * a) / (b * c * 2));
let cosB = Math.acos((a * a + c * c - b * b) / (a * c * 2));
let cosC = Math.acos((a * a + b * b - c * c) / (a * b * 2));
return {
A: (cosA * 180) / Math.PI,
B: (cosB * 180) / Math.PI,
C: (cosC * 180) / Math.PI,
}[pos];
}
clamp函数
clamp函数很多同学可能都没用过,一般在c++或者python中用的比较多,它的作用是;它需要传入三个值:
function clamp(num, min, max) {
// ...
}
三个参数分别代表如下含义:
-
num:需要判断的数值。 -
min:范围的最小值。 -
max:范围的最大值。
为了方便大家理解,我们还是举几个例子🌰来简单看下:
// 返回10,小于最小值,返回最小值
clamp(5, 10, 20)
// 返回16,在返回内,返回原值
clamp(16, 10, 20)
// 返回20,超出最大值,返回最大值
clamp(36, 10, 20)
通过三个demo,相信大家就能理解这个函数的作用了,函数的实现其实也非常简单:
function clamp(num, min, max) {
return Math.min(Math.max(num, min), max);
}
显示还是隐藏标签
对上面两个函数理解后,我们就可以回到地球上坐标的处理了;我们在threejs每次render的时候循环tagsList:
{
render() {
// 当前摄像头的位置
const cameraPos = this.camera.position;
if (this.tagsList && this.tagsList.length) {
this.tagsList.map((tag) => {
const { position, element } = tag;
});
}
}
}
首先我们将tag打印出来看下,里面有两个属性position和element,是我们所需要的;position属性是一个Vector3类型,表示tag当前的位置信息,element属性是一个dom节点,表示标签对应的dom元素。
我们想象一下,在三维空间中,我们的相机位置Camera一直在旋转的,因为设置了autoRotate自动旋转;而标签的位置position是固定的;因此这两个点和原点之间就形成了一个特殊的三角形:

临界情况就是以原点为顶点的角正好是90度,此时我们刚刚能看到标签;当相机位置不断旋转时,如果小于90度,我们还是可以看到标签的;但是如果大于90度,标签已经到了球体的后面了。
const originPoint = new Vector3(0, 0, 0)
this.tagsList.map((tag) => {
const { position } = tag;
// 以原点为顶点的角度
const ang = calc3DAngle(cameraPos, originPoint, position, "B");
// 省略其他
});
❝这里传入的顺序无所谓,我们只要计算以原点为顶点的角度即可。
❞
利用上面的calc3DAngle
函数,我们将三个位置传入,就可以很轻松的得到角度ang;有了这个角度,我们就可以计算标签的透明度了;我们上面提到了,透明度的临界值就是90度,但是实际上由于视角和球体的缘故,这个角度不是很准确,笔者测试之后大概是在85度左右。
同时,我们的标签也并不是一下子透明度就从1变到0的,我们需要给它一个缓冲范围,让它也缓缓,在这个范围内会进行变化;这个范围大致就是从80度到85度之间,透明度会从1到0逐渐的变化。

看到这样的映射关系图,相信大家已经猜到了,没错,这里就要用到我们上面介绍的clamp函数
了,我们将角度ang夹到80到85之间,然后使用scale函数
进行映射后就得到了我们的透明度opacity,给element元素的样式赋值即可:
/**
* 映射范围
* @param {Number} number 需要映射的数值
* @param {Number} inMin 映射入口的最小
* @param {Number} inMax 映射入口的最大
* @param {Number} outMin 映射出口的最小
* @param {Number} outMax 映射出口的最大
*/
exportfunction scale(number, inMin, inMax, outMin, outMax) {
return ((number - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}
const ANG1 = 80;
const ANG2 = 85;
this.tagsList.map((tag) => {
// 省略其他代码
const opacity = scale(clamp(ang, ANG1, ANG2), ANG1, ANG2, 1, 0);
tag.element.style.opacity = opacity;
});
这样我们的标签就实现了一个过渡变化;最后,在vue页面卸载之后,别忘记还需要将CSS2DRenderer渲染器的dom节点删除,否则会导致页面会有问题:
{
beforeDestroy() {
if (this.labelRenderer) {
document.body.removeChild(this.labelRenderer.domElement);
}
}
}
我们来看下实现的效果https://gallery.xieyufei.com/case/three/global,跟原页面的效果已经十分接近了。❞
总结
我们发现很多3D场景下的问题,其本质都是一个个数学问题;本文我们研究了球体和经纬度转换函数、三个点之间的角度计算函数,这两个问题无不考验着我们的数学推理能力;笔者甚至发现,在完成这个案例的过程中,学习的数学知识,甚至比写代码的时间更长、更费时间。
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Globe with Labeled Points</title>
<style>
body { margin: 0; overflow: hidden; }
canvas { display: block; }
.label-box {
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 10px;
border-radius: 5px;
font-family: Arial, sans-serif;
font-size: 12px;
text-align: center;
min-width: 100px;
}
.label-title { margin: 0; font-weight: bold; }
.label-desc { margin: 2px 0 0 0; font-size: 10px; }
.label-position {
width: 6px;
height: 6px;
background: red;
border-radius: 50%;
margin: 5px auto;
}
.global_position-box {
display: flex;
justify-content: center;
}
.cont { display: flex; flex-direction: column; align-items: center; }
</style>
</head>
<body>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.134.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.134.0/examples/js/renderers/CSS2DRenderer.js"></script>
<script>
const { CSS2DRenderer, CSS2DObject } = THREE;
class Globe {
constructor() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0xf7f7f7);
this.camera = new THREE.PerspectiveCamera(
55,
window.innerWidth / window.innerHeight,
0.001,
20000
);
this.camera.position.set(10, 0, 0);
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(window.devicePixelRatio);
this.renderer.setClearColor(0xf7f7f7);
this.renderer.autoClear = false;
document.body.appendChild(this.renderer.domElement);
this.labelRenderer = new CSS2DRenderer();
this.labelRenderer.setSize(window.innerWidth, window.innerHeight);
this.labelRenderer.domElement.style.position = "absolute";
this.labelRenderer.domElement.style.top = "0";
this.labelRenderer.domElement.style.left = "0";
this.labelRenderer.domElement.style.zIndex = "9";
document.body.appendChild(this.labelRenderer.domElement);
this.controls = new THREE.OrbitControls(this.camera, this.labelRenderer.domElement);
this.controls.enableZoom = false;
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this._resizeFn = this.resizeFn.bind(this);
window.addEventListener("resize", this._resizeFn);
this.loader = new THREE.TextureLoader();
this.loadAssets();
}
loadAssets() {
// 也可以下载作者的纹理
// https://gallery.xieyufei.com/images/global/ditu.jpg
this.loader.load(
"https://threejs.org/examples/textures/planets/earth_atmos_2048.jpg",
(texture) => {
console.log("纹理加载成功");
this.map = texture;
this.initMesh();
this.render();
},
(progress) => {
console.log(`纹理加载进度: ${(progress.loaded / progress.total * 100).toFixed(2)}%`);
},
(error) => {
console.error("纹理加载失败:", error.message || error);
this.map = null;
this.initMesh();
this.render();
}
);
}
initMesh() {
const SPHERE_RADIUS = 3;
const material = new THREE.MeshBasicMaterial({
map: this.map || null,
color: this.map ? 0xffffff : 0xaaaaaa,
});
const geometry = new THREE.SphereGeometry(SPHERE_RADIUS);
const sphere = new THREE.Mesh(geometry, material);
sphere.position.set(0, 0, 0);
this.scene.add(sphere);
const list = [
{ id: 0, name: "北京", lng: 116.39, lat: 39.9 },
{ id: 1, name: "上海", lng: 121.39, lat: 31.25 },
{ id: 2, name: "广东", lng: 113.25, lat: 23.13 },
{ id: 3, name: "新加坡", lng: 104.51, lat: 1.18 },
{ id: 4, name: "曼谷", lng: 100.49, lat: 13.75 },
{ id: 5, name: "伊斯坦布尔", lng: 28.58, lat: 41.02 },
{ id: 6, name: "哈萨克斯坦", lng: 68, lat: 48 },
{ id: 7, name: "瑞典", lng: 20, lat: 65 },
{ id: 8, name: "墨西哥", lng: -105, lat: 24 },
{ id: 9, name: "加拿大", lng: -105, lat: 64 },
{ id: 10, name: "巴西", lng: -56, lat: -18 },
{ id: 11, name: "俄罗斯", lng: 100, lat: 70 },
{ id: 12, name: "澳大利亚", lng: 133, lat: -25 },
{ id: 13, name: "印尼", lng: 122, lat: -2.5 },
{ id: 14, name: "南非", lng: 27, lat: -27 },
{ id: 15, name: "沙特阿拉伯", lng: 45, lat: 25 },
{ id: 16, name: "阿尔及利亚", lng: 6, lat: 25 },
];
this.tagsList = [];
// 标签样式可以根据 class 自定义
list.forEach(({ name, lng, lat }) => {
const p1 = document.createElement("p");
p1.className = "label-title";
p1.innerHTML = name;
const p2 = document.createElement("p");
p2.className = "label-desc";
p2.innerHTML = `业务数量:${Math.floor(Math.random() * 25) + 5}`;
const labelBox = document.createElement("div");
labelBox.className = "label-box";
labelBox.append(p1, p2);
const posTag = document.createElement("div");
posTag.className = "label-position";
const pos = this.latLongToVector3(lng, lat, SPHERE_RADIUS);
const box = document.createElement("div");
box.className = "global_position-box";
const cont = document.createElement("div");
cont.className = "cont";
cont.append(labelBox, posTag);
box.append(cont);
const tag = new CSS2DObject(box);
tag.position.copy(pos);
this.scene.add(tag);
this.tagsList.push(tag);
});
}
latLongToVector3(longitude, latitude, radius) {
const phi = (90 - latitude) * (Math.PI / 180);
const theta = (longitude + 30) * (Math.PI / 180);
const x = -radius * Math.sin(phi) * Math.cos(theta);
const y = radius * Math.cos(phi);
const z = radius * Math.sin(phi) * Math.sin(theta);
return new THREE.Vector3(x, y, z);
}
resizeFn() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.labelRenderer.setSize(window.innerWidth, window.innerHeight);
}
render() {
this.renderer.render(this.scene, this.camera);
this.labelRenderer.render(this.scene, this.camera);
this.controls.update();
const ANG1 = 80;
const ANG2 = 85;
const originPoint = new THREE.Vector3(0, 0, 0);
if (this.tagsList) {
this.tagsList.forEach((tag) => {
const angle = this.calc3DAngle(this.camera.position, originPoint, tag.position);
const opacity = this.scale(this.clamp(angle, ANG1, ANG2), ANG1, ANG2, 1, 0);
tag.element.style.opacity = opacity;
});
}
this.timer = requestAnimationFrame(this.render.bind(this));
}
calc3DAngle(A, B, C) {
const AB = A.clone().sub(B).normalize();
const BC = C.clone().sub(B).normalize();
const angle = Math.acos(AB.dot(BC)) * (180 / Math.PI);
return angle;
}
clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
scale(value, inMin, inMax, outMin, outMax) {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
}
beforeDestroy() {
if (this.timer) cancelAnimationFrame(this.timer);
window.removeEventListener("resize", this._resizeFn);
if (this.labelRenderer.domElement) {
document.body.removeChild(this.labelRenderer.domElement);
}
}
}
const globe = new Globe();
window.addEventListener("beforeunload", () => globe.beforeDestroy());
</script>
</body>
</html>