# 对齐HTML元素到3D对象

本文是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);
    }

这就是我们得到的结果

点击在新窗口打开

整出了密集恐惧症！

现在有两个问题：

1. 出现了背对我们的Label

2. 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元素，我也或许会做出小小的贡献。

下一步我们来实现 拾取和高亮一个城市。