# VR - 用目光进行选择

注意：本页示例需要支持VR的设备。没有这样的设备则无法运行。参见 上一篇文章 了解原因

在 上一篇文章 中，我们介绍了一个使用 three.js 的非常简单的 VR 示例，并讨论了各种类型的 VR 系统。

最简单且可能是最常见的类型是谷歌 Cardboard 风格的 VR，它基本上就是将手机放入一个 5 到 50 美元的面罩中。这种 VR 没有控制器，因此人们必须想出创造性的解决方案来实现用户输入。

最常见的解决方案是“用目光进行选择”，即如果用户将头部对准某个物体一段时间，该物体就会被选中。

让我们来实现“用目光进行选择”功能！我们将从 上一篇文章中的示例 开始，并添加我们在 拾取文章 中创建的 `PickHelper`。代码如下：

    class PickHelper {
      constructor() {
        this.raycaster = new THREE.Raycaster();
        this.pickedObject = null;
        this.pickedObjectSavedColor = 0;
      }
      pick(normalizedPosition, scene, camera, time) {
        // 如果有被选中的物体，则恢复其颜色
        if (this.pickedObject) {
          this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
          this.pickedObject = undefined;
        }

        // 从视锥体发射一条射线
        this.raycaster.setFromCamera(normalizedPosition, camera);
        // 获取射线相交的物体列表
        const intersectedObjects = this.raycaster.intersectObjects(scene.children);
        if (intersectedObjects.length) {
          // 选择第一个物体。它是最接近的那个
          this.pickedObject = intersectedObjects[0].object;
          // 保存其颜色
          this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
          // 将其自发光颜色设置为闪烁的红/黄色
          this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
        }
      }
    }

有关该代码的解释，请参见 拾取文章。

要使用它，我们只需创建一个实例并在渲染循环中调用它：

    +const pickHelper = new PickHelper();

    ...
    function render(time) {
      time *= 0.001;

      ...

    +  // 0, 0 是归一化坐标中视图的中心。
    +  pickHelper.pick({x: 0, y: 0}, scene, camera, time);

在原始的拾取示例中，我们将鼠标坐标从 CSS 像素转换为归一化坐标，该坐标在画布上从 -1 到 +1。

但在这种情况下，我们将始终选择相机所对准的位置，即屏幕中心，因此我们为 `x` 和 `y` 都传入 `0`，这在归一化坐标中就是中心。

这样，当我们注视物体时，它们就会闪烁

点击此处以在新窗口中打开

通常我们不希望选择是立即发生的。相反，我们要求用户将相机对准他们想要选择的物体几秒钟，以便他们有机会避免意外选择某些东西。

为此，我们需要某种计量器或指示器，或某种方式来传达用户必须持续注视以及需要注视多长时间。

一种简单的方法是制作一个双色纹理，并使用纹理偏移在模型上滑动纹理。

让我们先单独实现这个效果，看看它如何工作，然后再将其添加到 VR 示例中。

首先，我们创建一个 `正交相机`

    const left = -2;    // 使用左、右、上、下
    const right = 2;    // 的值来匹配默认
    const top = 1;      // 画布大小。
    const bottom = -1;
    const near = -1;
    const far = 1;
    const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);

当然，如果画布大小改变，我们也需要更新它

    function render(time) {
      time *= 0.001;

      if (resizeRendererToDisplaySize(renderer)) {
        const canvas = renderer.domElement;
        const aspect = canvas.clientWidth / canvas.clientHeight;
    +    camera.left = -aspect;
    +    camera.right = aspect;
        camera.updateProjectionMatrix();
      }
      ...

现在我们有了一个相机，它显示中心上下各 2 个单位，左右各 aspect 个单位。

接下来，让我们制作一个双色纹理。我们将使用 `DataTexture`， 它在其他地方和示例中也用过。

    function makeDataTexture(data, width, height) {
      const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat);
      texture.minFilter = THREE.NearestFilter;
      texture.magFilter = THREE.NearestFilter;
      texture.needsUpdate = true;
      return texture;
    }

    const cursorColors = new Uint8Array([
      64, 64, 64, 64,       // 深灰色
      255, 255, 255, 255,   // 白色
    ]);
    const cursorTexture = makeDataTexture(cursorColors, 2, 1);

然后我们将该纹理应用于一个 `TorusGeometry`

    const ringRadius = 0.4;
    const tubeRadius = 0.1;
    const tubeSegments = 4;
    const ringSegments = 64;
    const cursorGeometry = new THREE.TorusGeometry(
        ringRadius, tubeRadius, tubeSegments, ringSegments);

    const cursorMaterial = new THREE.MeshBasicMaterial({
      color: 'white',
      map: cursorTexture,
      transparent: true,
      blending: THREE.CustomBlending,
      blendSrc: THREE.OneMinusDstColorFactor,
      blendDst: THREE.OneMinusSrcColorFactor,
    });
    const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
    scene.add(cursor);

然后在 `render` 中调整纹理的偏移

    function render(time) {
      time *= 0.001;

      if (resizeRendererToDisplaySize(renderer)) {
        const canvas = renderer.domElement;
        const aspect = canvas.clientWidth / canvas.clientHeight;
        camera.left = -aspect;
        camera.right = aspect;
        camera.updateProjectionMatrix();
      }

    +  const fromStart = 0;
    +  const fromEnd = 2;
    +  const toStart = -0.5;
    +  const toEnd = 0.5;
    +  cursorTexture.offset.x = THREE.MathUtils.mapLinear(
    +      time % 2,
    +      fromStart, fromEnd,
    +      toStart, toEnd);

      renderer.render(scene, camera);
    }

`THREE.MathUtils.mapLinear` 将一个在 `fromStart` 和 `fromEnd` 之间变化的值映射到 `toStart` 和 `toEnd` 之间的值。在上面的例子中，我们取 `time % 2`，即一个从 0 到 2 变化的值，并将其映射到从 -0.5 到 0.5 变化的值。

纹理 使用从 0 到 1 的归一化纹理坐标映射到几何体上。这意味着我们的 2x1 像素图像，设置为默认的 `THREE.ClampToEdge` 包装模式，如果我们调整纹理坐标为 -0.5，则整个网格将显示第一种颜色；如果调整为 +0.5，则整个网格将显示第二种颜色。在两者之间，由于过滤设置为 `THREE.NearestFilter`，我们能够将两种颜色之间的过渡移动通过几何体。

让我们顺便添加一个背景纹理，就像我们在 背景文章 中介绍的那样。我们将只使用一组 2x2 的颜色，但设置纹理的重复属性，使其形成一个 8x8 的网格。这样可以为我们的光标提供一个渲染背景，以便我们检查它在不同颜色上的显示效果。

    +const backgroundColors = new Uint8Array([
    +    0,   0,   0, 255,  // 黑色
    +   90,  38,  38, 255,  // 深红色
    +  100, 175, 103, 255,  // 中等绿色
    +  255, 239, 151, 255,  // 浅黄色
    +]);
    +const backgroundTexture = makeDataTexture(backgroundColors, 2, 2);
    +backgroundTexture.wrapS = THREE.RepeatWrapping;
    +backgroundTexture.wrapT = THREE.RepeatWrapping;
    +backgroundTexture.repeat.set(4, 4);

    const scene = new THREE.Scene();
    +scene.background = backgroundTexture;

现在如果我们运行它，你会看到我们得到了一个类似圆圈的计量器，并且我们可以设置计量器的位置。

点击此处以在新窗口中打开

请注意并尝试以下几点：

- 我们设置了 `cursorMaterial` 的 `blending`、`blendSrc` 和 `blendDst` 属性如下：

        blending: THREE.CustomBlending,
        blendSrc: THREE.OneMinusDstColorFactor,
        blendDst: THREE.OneMinusSrcColorFactor,

  这产生了一种反相效果。注释掉这三行代码，你就能看到区别。我猜测这种反相效果在这里是最好的，因为这样无论光标在什么颜色上，我们都应该能看到它。

- 我们使用了 `TorusGeometry` 而不是 `RingGeometry`

  出于某些原因，`RingGeometry` 使用了平面的 UV 映射方案。因此，如果我们使用 `RingGeometry`，纹理会在环上水平滑动，而不是像上面那样环绕它。

  尝试一下，将 `TorusGeometry` 改为 `RingGeometry`（在上面的示例中它只是被注释掉了），你就会明白我的意思。

  （在某种定义下的）正确做法是：要么使用 `RingGeometry` 但修正纹理坐标，使其环绕环形；要么自己生成环形几何体。但是，圆环体效果很好。直接放置在相机前方，使用 `MeshBasicMaterial`，它看起来会完全像一个环，并且纹理坐标环绕环形，因此它符合我们的需求。

让我们将它与上面的 VR 代码集成起来。

    class PickHelper {
    -  constructor() {
    +  constructor(camera) {
        this.raycaster = new THREE.Raycaster();
        this.pickedObject = null;
    -    this.pickedObjectSavedColor = 0;

    +    const cursorColors = new Uint8Array([
    +      64, 64, 64, 64,       // 深灰色
    +      255, 255, 255, 255,   // 白色
    +    ]);
    +    this.cursorTexture = makeDataTexture(cursorColors, 2, 1);
    +
    +    const ringRadius = 0.4;
    +    const tubeRadius = 0.1;
    +    const tubeSegments = 4;
    +    const ringSegments = 64;
    +    const cursorGeometry = new THREE.TorusGeometry(
    +        ringRadius, tubeRadius, tubeSegments, ringSegments);
    +
    +    const cursorMaterial = new THREE.MeshBasicMaterial({
    +      color: 'white',
    +      map: this.cursorTexture,
    +      transparent: true,
    +      blending: THREE.CustomBlending,
    +      blendSrc: THREE.OneMinusDstColorFactor,
    +      blendDst: THREE.OneMinusSrcColorFactor,
    +    });
    +    const cursor = new THREE.Mesh(cursorGeometry, cursorMaterial);
    +    // 将光标作为相机的子对象添加
    +    camera.add(cursor);
    +    // 并将其移动到相机前方
    +    cursor.position.z = -1;
    +    const scale = 0.05;
    +    cursor.scale.set(scale, scale, scale);
    +    this.cursor = cursor;
    +
    +    this.selectTimer = 0;
    +    this.selectDuration = 2;
    +    this.lastTime = 0;
      }
      pick(normalizedPosition, scene, camera, time) {
    +    const elapsedTime = time - this.lastTime;
    +    this.lastTime = time;

    -    // 如果有被选中的物体，则恢复其颜色
    -    if (this.pickedObject) {
    -      this.pickedObject.material.emissive.setHex(this.pickedObjectSavedColor);
    -      this.pickedObject = undefined;
    -    }

    +    const lastPickedObject = this.pickedObject;
    +    this.pickedObject = undefined;

        // 从视锥体发射一条射线
        this.raycaster.setFromCamera(normalizedPosition, camera);
        // 获取射线相交的物体列表
        const intersectedObjects = this.raycaster.intersectObjects(scene.children);
        if (intersectedObjects.length) {
          // 选择第一个物体。它是最接近的那个
          this.pickedObject = intersectedObjects[0].object;
    -      // 保存其颜色
    -      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    -      // 将其自发光颜色设置为闪烁的红/黄色
    -      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
        }

    +    // 仅当光标击中物体时才显示
    +    this.cursor.visible = this.pickedObject ? true : false;
    +
    +    let selected = false;
    +
    +    // 如果我们正在注视的物体与之前相同
    +    // 则增加选择计时器的时间
    +    if (this.pickedObject && lastPickedObject === this.pickedObject) {
    +      this.selectTimer += elapsedTime;
    +      if (this.selectTimer >= this.selectDuration) {
    +        this.selectTimer = 0;
    +        selected = true;
    +      }
    +    } else {
    +      this.selectTimer = 0;
    +    }
    +
    +    // 设置光标材质以显示计时器状态
    +    const fromStart = 0;
    +    const fromEnd = this.selectDuration;
    +    const toStart = -0.5;
    +    const toEnd = 0.5;
    +    this.cursorTexture.offset.x = THREE.MathUtils.mapLinear(
    +        this.selectTimer,
    +        fromStart, fromEnd,
    +        toStart, toEnd);
    +
    +    return selected ? this.pickedObject : undefined;
      }
    }

你可以看到上面的代码中，我们添加了所有创建光标几何体、纹理和材质的代码，并将其作为相机的子对象添加，因此它将始终位于相机前方。请注意，我们需要将相机添加到场景中，否则光标将不会被渲染。

    +scene.add(camera);

然后我们检查这次拾取的物体是否与上次相同。如果是，我们将经过的时间加到计时器中，如果计时器达到其限制，我们就返回选中的项目。

现在让我们使用它来选择立方体。作为一个简单的例子，我们还将添加 3 个球体。当一个立方体被选中时，我们将隐藏该立方体并显示相应的球体。

因此，首先我们创建一个球体几何体

    const boxWidth = 1;
    const boxHeight = 1;
    const boxDepth = 1;
    -const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    +const boxGeometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
    +
    +const sphereRadius = 0.5;
    +const sphereGeometry = new THREE.SphereGeometry(sphereRadius);

然后让我们创建 3 对立方体和球体网格。我们将使用 `Map`，以便我们可以将每个 `Mesh` 与其对应的伙伴关联起来。

    -const cubes = [
    -  makeInstance(geometry, 0x44aa88,  0),
    -  makeInstance(geometry, 0x8844aa, -2),
    -  makeInstance(geometry, 0xaa8844,  2),
    -];
    +const meshToMeshMap = new Map();
    +[
    +  { x:  0, boxColor: 0x44aa88, sphereColor: 0xFF4444, },
    +  { x:  2, boxColor: 0x8844aa, sphereColor: 0x44FF44, },
    +  { x: -2, boxColor: 0xaa8844, sphereColor: 0x4444FF, },
    +].forEach((info) => {
    +  const {x, boxColor, sphereColor} = info;
    +  const sphere = makeInstance(sphereGeometry, sphereColor, x);
    +  const box = makeInstance(boxGeometry, boxColor, x);
    +  // 隐藏球体
    +  sphere.visible = false;
    +  // 将球体映射到立方体
    +  meshToMeshMap.set(box, sphere);
    +  // 将立方体映射到球体
    +  meshToMeshMap.set(sphere, box);
    +});

在 `render` 中，当我们旋转立方体时，需要遍历 `meshToMeshMap` 而不是 `cubes`。

    -cubes.forEach((cube, ndx) => {
    +let ndx = 0;
    +for (const mesh of meshToMeshMap.keys()) {
      const speed = 1 + ndx * .1;
      const rot = time * speed;
    -  cube.rotation.x = rot;
    -  cube.rotation.y = rot;
    -});
    +  mesh.rotation.x = rot;
    +  mesh.rotation.y = rot;
    +  ++ndx;
    +}

现在我们可以使用我们新的 `PickHelper` 实现来选择其中一个物体。当物体被选中时，我们隐藏该物体并显示其对应的伙伴物体。

    // 0, 0 是归一化坐标中视图的中心。
    -pickHelper.pick({x: 0, y: 0}, scene, camera, time);
    +const selectedObject = pickHelper.pick({x: 0, y: 0}, scene, camera, time);
    +if (selectedObject) {
    +  selectedObject.visible = false;
    +  const partnerObject = meshToMeshMap.get(selectedObject);
    +  partnerObject.visible = true;
    +}

有了这些，我们就应该有了一个相当不错的“注视选择”实现。

点击此处以在新窗口中打开

希望这个示例能给你一些关于如何实现像 Google Cardboard 级别的“注视选择”用户体验的想法。使用纹理坐标偏移来滑动纹理也是一种常用且有用的技术。

接下来，让我们允许拥有 VR 控制器的用户指向并移动物体。