本文是THREE.js系列文章中的一部分。第一篇是 THREE.js 基础,如果你还没有读过或者你是THREE.js新手,你可能需要考虑从那开始。
有时你想在 3D 场景中显示一些文本,这有很多种选择,每一种都有各自的优缺点。
使用 3D 文本
如果你看过 图元章节 你就会看到 TextGeometry 可以
生成3D文本,这可能对飞行类的Logo很有效,但对统计、信息、标记类不是很合适。
使用带2D文本的纹理图
这篇文章 使用Canvas作为纹理 提到Canvas可以作为物体的纹理绘制。你可以向Canvas中绘制文字并且 以Billboard的方式展示它。这种方法的优点是文本已被集成到3D场景中,像3D场景中的计算机终端,这可能是比较完美的。
使用HTML元素并定位它们以匹配3D场景
这种方法的好处是您可以使用所有的HTML能力。你的HTML中可以有多个元素,可以通过CSS设置样式,它也可以被用户选中因为它就是实际的文本内容。
本文将介绍上述的最后一种方法。
让我们从简单的开始,我们将使用一些图元制作一个3D场景,然后为每个图元添加一个标签。我们会从这篇响应式开发中的一个例子开始。
我们会添加一个 OrbitControls 就像我们在 这篇光照的文章里做的一样。
import * as THREE from 'three';
+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
const controls = new OrbitControls(camera, canvas); controls.target.set(0, 0, 0); controls.update();
我们需要提供一个HTML元素来包含我们的标签元素。
<body> - <canvas id="c"></canvas> + <div id="container"> + <canvas id="c"></canvas> + <div id="labels"></div> + </div> </body>
通过将Canvas元素和 <div id="labels">
放在一个父元素里面,我们可以用这个CSS让它们重叠。
#c {
- width: 100%;
- height: 100%;
+ width: 100%; /* 让我们的容器决定尺寸 */
+ height: 100%;
display: block;
}
+#container {
+ position: relative; /* 作为子元素的相对定位元素 */
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+#labels {
+ position: absolute; /* 把Label定位在容器内 */
+ left: 0; /* 默认定位在左上角 */
+ top: 0;
+ color: white;
+}
让我们也为Label本身添加一些CSS。
#labels>div {
position: absolute; /* 让我们的容器决定尺寸 */
left: 0; /* 默认定位在左上角 */
top: 0;
cursor: pointer; /* 当悬浮时,变为一个小手 */
font-size: large;
user-select: none; /* 不允许文字被选中 */
text-shadow: /* 创造一个黑色阴影 */
-1px -1px 0 #000,
0 -1px 0 #000,
1px -1px 0 #000,
1px 0 0 #000,
1px 1px 0 #000,
0 1px 0 #000,
-1px 1px 0 #000,
-1px 0 0 #000;
}
#labels>div:hover {
color: red;
}
现在进入我们的代码,我们不必添加太多,我们有一个函数makeInstance,可以用来生成立方体。我们现在让它同时添加一个Label元素。
+const labelContainerElem = document.querySelector('#labels');
-function makeInstance(geometry, color, x) {
+function makeInstance(geometry, color, x, name) {
const material = new THREE.MeshPhongMaterial({color});
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
cube.position.x = x;
+ const elem = document.createElement('div');
+ elem.textContent = name;
+ labelContainerElem.appendChild(elem);
- return cube;
+ return {cube, elem};
}
你可以发现,我们正添加一个 <div> 到容器里, 每一个立方体各一个。 我们也返回一个对象,包含cube和Label元素elem。
为了调用它,我们需要为每一个立方体起个名字
const cubes = [ - makeInstance(geometry, 0x44aa88, 0), - makeInstance(geometry, 0x8844aa, -2), - makeInstance(geometry, 0xaa8844, 2), + makeInstance(geometry, 0x44aa88, 0, 'Aqua'), + makeInstance(geometry, 0x8844aa, -2, 'Purple'), + makeInstance(geometry, 0xaa8844, 2, 'Gold'), ];
剩下的就是在渲染时定位Label元素。
const tempV = new THREE.Vector3();
...
-cubes.forEach((cube, ndx) => {
+cubes.forEach((cubeInfo, ndx) => {
+ const {cube, elem} = cubeInfo;
const speed = 1 + ndx * .1;
const rot = time * speed;
cube.rotation.x = rot;
cube.rotation.y = rot;
+ // 获取立方体中心的位置
+ cube.updateWorldMatrix(true, false);
+ cube.getWorldPosition(tempV);
+
+ // 获取标准化屏幕坐标,x和y都会在-1和1区间
+ // x = -1 表示在最左侧
+ // y = -1 表示在最底部
+ tempV.project(camera);
+
+ // 将标准屏幕坐标转化为CSS坐标
+ const x = (tempV.x * .5 + .5) * canvas.clientWidth;
+ const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
+
+ // 将元素移动到此位置
+ elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
});
这样我们就有了与物体对齐的Label。
这里有一些问题我们需要处理。
一个是我们旋转对象,一旦它们重叠了,那么它们对应的Label可能也会重叠。

另外一个问题是,我们缩小了视野,物体移出了视锥体范围,Label还是在显示。
重叠对象的一种解决办法是 用这篇文章中的拾取方法,我们将传递对象在屏幕上的位置,然后调用RayCaster来告诉我们和哪些对象相交了。
如果我们的对象不是结果的第一个,说明我们并不在它最前面。
const tempV = new THREE.Vector3();
+const raycaster = new THREE.Raycaster();
...
cubes.forEach((cubeInfo, ndx) => {
const {cube, elem} = cubeInfo;
const speed = 1 + ndx * .1;
const rot = time * speed;
cube.rotation.x = rot;
cube.rotation.y = rot;
// 获取立方体中心的位置
cube.updateWorldMatrix(true, false);
cube.getWorldPosition(tempV);
// 获取标准化屏幕坐标,x和y都会在-1和1区间
// x = -1 表示在最左侧
// y = -1 表示在最底部
tempV.project(camera);
+ // 调用Raycast获取所有相交的物体
+ // 以相机为起点,物体为终点
+ raycaster.setFromCamera(tempV, camera);
+ const intersectedObjects = raycaster.intersectObjects(scene.children);
+ // 如果第一个相交的是此物体,那么就是可见的
+ const show = intersectedObjects.length && cube === intersectedObjects[0].object;
+
+ if (!show) {
+ // 隐藏Label
+ elem.style.display = 'none';
+ } else {
+ // 显示Label
+ elem.style.display = '';
// 将标准屏幕坐标转化为CSS坐标
const x = (tempV.x * .5 + .5) * canvas.clientWidth;
const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
// 将元素移动到此位置
elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
+ }
});
这解决了重叠问题。
为了处理超出视锥体不可见的问题,我们通过检查 tempV.z检查此对象的原点是否在截锥体之外。
- if (!show) {
+ if (!show || Math.abs(tempV.z) > 1) {
// 隐藏Label
elem.style.display = 'none';
这 部分工作 有效是因为我们计算的标准化坐标包含一个z
值,它从-1开始,也就是相机视锥体的 near 值,
+1结束,也就是相机视锥体的 far值。
对于视锥体检查,上面的解决方案失败了。因为我们只检查对象的原点,对于一个大对象,它的原点可能会超出视锥体,但是对象仍然有一部分处于可视范围内。
更正确的解决方案是检查对象本身是否在视锥体中。不幸的是,检查很慢。对于3个立方体来说,这不是问题。但是其他情况不一定。
// 初始化
const frustum = new THREE.Frustum();
const viewProjection = new THREE.Matrix4();
...
// 在检查前
camera.updateMatrix();
camera.updateMatrixWorld();
camera.matrixWorldInverse.copy(camera.matrixWorld).invert();
...
// 然后,对每一个Mesh
someMesh.updateMatrix();
someMesh.updateMatrixWorld();
viewProjection.multiplyMatrices(
camera.projectionMatrix, camera.matrixWorldInverse);
frustum.setFromProjectionMatrix(viewProjection);
const inFrustum = frustum.contains(someMesh));
我们当前的重叠解决方案有类似的问题,拾取很慢。我们可以使用基于GPU的拾取方案, 参考拾取章节,不过它也并非没有代价。使用哪个解决方案取决于你的需要。
另外一个问题是Label显示顺序,如果我们修改了代码以生成更长的Label
const cubes = [ - makeInstance(geometry, 0x44aa88, 0, 'Aqua'), - makeInstance(geometry, 0x8844aa, -2, 'Purple'), - makeInstance(geometry, 0xaa8844, 2, 'Gold'), + makeInstance(geometry, 0x44aa88, 0, 'Aqua Colored Box'), + makeInstance(geometry, 0x8844aa, -2, 'Purple Colored Box'), + makeInstance(geometry, 0xaa8844, 2, 'Gold Colored Box'), ];
然后设置CSS让它们不换行
#labels>div {
+ white-space: nowrap;
然后我们可能就会遇到这个问题
你可以看到紫色盒子在后面,但它的Label却在水蓝色盒子的前面。
我们可以修复这个问题,通过给每一个元素设置 zIndex。投影生成的位置有一个 z 值,
-1表示最前面,1表示最后面。 zIndex 却是一个整型,并且含义相反,
zIndex越大表示越靠前,所以下面的代码可能有用。
// 将标准屏幕坐标转化为CSS坐标
const x = (tempV.x * .5 + .5) * canvas.clientWidth;
const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
// 将元素移动到此位置
elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
+// 设置排序用的zIndex
+elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
由于投影 z 值的取值限制,我们需要选择一个大数来分散这些值,否则许多Label将具有相同的值。为了保证Label不和页面其他的部分重叠,通过设置 z-index 给Label的容器,我们可以让浏览器创建一个新的 层叠上下文
#labels {
position: absolute; /* 把自己定位在容器内 */
+ z-index: 0; /* 创建一个新的层叠上下文,这样子节点就不会和页面其他内容冲突 */
left: 0; /* 默认定位在左上角 */
top: 0;
color: white;
z-index: 0;
}
现在Label应该总是按正确的顺序排列。
我们在这里用一个例子说明更多的问题。让我们像谷歌地球一样画一个地球仪并标记国家。
我找到 这些数据, 包含了各个国家的边界信息,用的协议是 CC-BY-SA。
加载这份数据, 可以生成国家的轮廓,大部分都带有国家的名称和定位。
JSON数据是一个类似这样结构的数组
[
{
"name": "Algeria",
"min": [
-8.667223,
18.976387
],
"max": [
11.986475,
37.091385
],
"area": 238174,
"lat": 28.163,
"lon": 2.632,
"population": {
"2005": 32854159
}
},
...
其中min,max,lat,lon都是经纬度信息。
开始加载它,这份代码是基于这篇优化大量对象,尽管我们没有绘制大量对象,但我们将使用 相同的解决办法,和 按需渲染 方案一样。
第一件事是创建一个球体,并且使用轮廓纹理。
{
const loader = new THREE.TextureLoader();
const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
const geometry = new THREE.SphereGeometry(1, 64, 32);
const material = new THREE.MeshBasicMaterial({map: texture});
scene.add(new THREE.Mesh(geometry, material));
}
然后我们先创建一个loader,来加载JSON文件
async function loadJSON(url) {
const req = await fetch(url);
return req.json();
}
然后调用
let countryInfos;
async function loadCountryData() {
countryInfos = await loadJSON('resources/data/world/country-info.json');
...
}
requestRenderIfNotRequested();
}
loadCountryData();
现在让我们用这些数据来生成和放置Labels
在这一篇文章 优化大量对象, 我们已经创建了一个小辅助对象,以便于计算地球上的经纬度位置,具体可以看看这篇文章是如何解释它们怎么工作的。
const lonFudge = Math.PI * 1.5; const latFudge = Math.PI; // 这些小工具会使得盒模型定位非常容易 // 我们可以旋转lonHelper Y轴上的分量到经度上 const lonHelper = new THREE.Object3D(); // 我们可以旋转latHelper X轴上的分量到纬度上 const latHelper = new THREE.Object3D(); lonHelper.add(latHelper); // positionHelper将对象移动到球体的边缘 const positionHelper = new THREE.Object3D(); positionHelper.position.z = 1; latHelper.add(positionHelper);
我们将使用它去计算每一个Label的位置
const labelParentElem = document.querySelector('#labels');
for (const countryInfo of countryInfos) {
const {lat, lon, name} = countryInfo;
// 调整helper,旋转指向经纬度点的位置
lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
// 获取经纬度位置
positionHelper.updateWorldMatrix(true, false);
const position = new THREE.Vector3();
positionHelper.getWorldPosition(position);
countryInfo.position = position;
// 给每一个国家添加一个Label
const elem = document.createElement('div');
elem.textContent = name;
labelParentElem.appendChild(elem);
countryInfo.elem = elem;
上面的代码看起来非常类似于我们为制作立方体Label而编写的代码,每个Label对应一个元素,完成后我们有一个数组 countryInfos,
对于我们添加的每个国家/地区都有一个 elem
属性代表Label元素 和一个 position 代表它的位置。
就像我们对立方体所做的那样,我们需要在渲染的时候先更新Label。
const tempV = new THREE.Vector3();
function updateLabels() {
// 如果JSON文件还没加载进来,就退出
if (!countryInfos) {
return;
}
for (const countryInfo of countryInfos) {
const {position, elem} = countryInfo;
// 获取标准化屏幕坐标,x和y都会在-1和1区间
// x = -1 表示在最左侧
// y = -1 表示在最底部
tempV.copy(position);
tempV.project(camera);
// 将标准屏幕坐标转化为CSS坐标
const x = (tempV.x * .5 + .5) * canvas.clientWidth;
const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
// 将元素移动到此位置
elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
// 设置排序用的zIndex
elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
}
}
您可以看到上面的代码与之前的立方体示例基本类似,唯一的区别我们在初始化时预先计算了Label位置,我们可以这样做因为地球上的国家永远不会移动,只有我们的相机在移动。
然后我们需要在我们的渲染循环中调用 updateLabels
function render() {
renderRequested = false;
if (resizeRendererToDisplaySize(renderer)) {
const canvas = renderer.domElement;
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}
controls.update();
+ updateLabels();
renderer.render(scene, camera);
}
这就是我们得到的结果
整出了密集恐惧症!
现在有两个问题:
出现了背对我们的Label
Label真的太多了
对于 问题#1 我们不能像上面那种方式使用 RayCaster ,因为除了地球以外没有什么可相交的。相反,我们可以
检查特定的国家是否远离我们,这是可行的,因为Label的位置围绕的是一个球体。事实上,我们使用的是一个半径1.0的单位球体,这意味着这些位置已经是单位向量,数学计算上比较简单。
const tempV = new THREE.Vector3();
+const cameraToPoint = new THREE.Vector3();
+const cameraPosition = new THREE.Vector3();
+const normalMatrix = new THREE.Matrix3();
function updateLabels() {
// 如果JSON文件还没加载进来,就退出
if (!countryInfos) {
return;
}
+ const minVisibleDot = 0.2;
+ // 获取表示相机相对方向的变换矩阵
+ normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
+ // 获取相机的世界坐标
+ camera.getWorldPosition(cameraPosition);
for (const countryInfo of countryInfos) {
const {position, elem} = countryInfo;
+ // 根据相机的方向定位位置
+ // 由于球体在原点并且球体是半径为1.0的单位球体
+ // 这就能获取相对于相机的单位向量
+ tempV.copy(position);
+ tempV.applyMatrix3(normalMatrix);
+
+ // 计算从相机到这个位置的方向向量
+ cameraToPoint.copy(position);
+ cameraToPoint.applyMatrix4(camera.matrixWorldInverse).normalize();
+
+ // 求得相机方向 和相机连点方向 的点积.
+ // 1 = 正对相机
+ // 0 = 相对于相机而言,位于球体的边缘
+ // < 0 = 远离相机
+ const dot = tempV.dot(cameraToPoint);
+
+ // 如果方向不面向我们,隐藏它
+ if (dot < minVisibleDot) {
+ elem.style.display = 'none';
+ continue;
+ }
+
+ // 将元素恢复为其默认显示样式
+ elem.style.display = '';
// 获取标准化屏幕坐标,x和y都会在-1和1区间
// x = -1 表示在最左侧
// y = -1 表示在最底部
tempV.copy(position);
tempV.project(camera);
// 将标准屏幕坐标转化为CSS坐标
const x = (tempV.x * .5 + .5) * canvas.clientWidth;
const y = (tempV.y * -.5 + .5) * canvas.clientHeight;
// 将元素移动到此位置
countryInfo.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
// 设置排序用的zIndex
elem.style.zIndex = (-tempV.z * .5 + .5) * 100000 | 0;
}
}
上面我们使用位置作为方向向量并获得相对于相机的位置,点乘得到向量之间的余弦值,这给了我们一个-1到1之间的值,其中-1表示正对相机,0表示相对于相机球体的边缘上,大于0表示处在后方。然后我们使用该值来显示或隐藏元素。
在上图中,我们可以看到Label方向的点乘方向是从相机指向该位置的方向。如果你旋转角度,你会看到正对相机时点乘结果为-1.0,正好在球体相对相机的切线上时为0.0,或者换一种说法,两个向量互相垂直点乘结果为0,夹角大于90度时,Label在球体后面。
对于 问题#2,Label太多了,我们需要一些方法来决定显示哪些。一种方式是只显示大国的Label,我们加载的数据包含一个国家包含经纬度的最大和最小值,从中我们可以计算出一个区域,然后用它来判断是否显示国家。
开始的时候我们先计算区域面积
const labelParentElem = document.querySelector('#labels');
for (const countryInfo of countryInfos) {
const {lat, lon, min, max, name} = countryInfo;
// 调整helper,旋转指向经纬度点的位置
lonHelper.rotation.y = THREE.MathUtils.degToRad(lon) + lonFudge;
latHelper.rotation.x = THREE.MathUtils.degToRad(lat) + latFudge;
// 获取经纬度位置
positionHelper.updateWorldMatrix(true, false);
const position = new THREE.Vector3();
positionHelper.getWorldPosition(position);
countryInfo.position = position;
+ // 计算每个国家的面积
+ const width = max[0] - min[0];
+ const height = max[1] - min[1];
+ const area = width * height;
+ countryInfo.area = area;
// a给每一个国家添加一个Label
const elem = document.createElement('div');
elem.textContent = name;
labelParentElem.appendChild(elem);
countryInfo.elem = elem;
}
然后在渲染时让我们根据区域来决定是否显示Label
+const large = 20 * 20;
const maxVisibleDot = 0.2;
// 获取表示相机相对方向的变换矩阵
normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
// 获取相机的世界坐标
camera.getWorldPosition(cameraPosition);
for (const countryInfo of countryInfos) {
- const {position, elem} = countryInfo;
+ const {position, elem, area} = countryInfo;
+ // large enough?
+ if (area < large) {
+ elem.style.display = 'none';
+ continue;
+ }
...
最后,由于我不确定这些值设多少好,于是添加一个GUI,就可以调试了
import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
+import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
+const settings = {
+ minArea: 20,
+ maxVisibleDot: -0.2,
+};
+const gui = new GUI({width: 300});
+gui.add(settings, 'minArea', 0, 50).onChange(requestRenderIfNotRequested);
+gui.add(settings, 'maxVisibleDot', -1, 1, 0.01).onChange(requestRenderIfNotRequested);
function updateLabels() {
if (!countryInfos) {
return;
}
- const large = 20 * 20;
- const maxVisibleDot = -0.2;
+ const large = settings.minArea * settings.minArea;
// 获取表示相机相对方向的变换矩阵
normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
// 获取相机的世界坐标
camera.getWorldPosition(cameraPosition);
for (const countryInfo of countryInfos) {
...
// 如果方向不面向我们,隐藏它
- if (dot > maxVisibleDot) {
+ if (dot > settings.maxVisibleDot) {
elem.style.display = 'none';
continue;
}
结果出来了
你可以看到,随着你的旋转,后面地球的Label消失了。
调整 minVisibleDot 可以查看阈值的变化。
你也可以调整 minArea 可以看到更大或更小的国家出现。
我在这方面做得越多,就越意识到谷歌地图做了多少工作。他们还必须决定使用哪些Label来显示。我很确定他们使用各种信息,例如你现在的位置、你的默认语言设置、你的帐户设置(如果你有的话),他们可能使用人口数量或人气程度,他们可能会优先考虑到视图中心的国家,等等……要考虑很多。
无论如何,我希望这些示例能让你了解如何用HTML对齐你的3D元素,我也或许会做出小小的贡献。
下一步我们来实现 拾取和高亮一个城市。