# 使用纹理索引来拾取和着色

这篇文章是 对齐HTML元素到3D对象 的延续。 如果你还没有读过上篇文章，你应该先从那里开始，然后再回来继续阅读。

有时候使用Three.js需要提出一些创造性的解决思路。我不确定这是一个很好的解决方案，但我想我会分享它，你可以看看是否可以为你的需求提供了一些解决思路或方案。

在 上一篇文章中，我们在3D地球周围显示了国家名称，那么我们如何做到，让用户选中一个国家并高亮他的选择？

第一个想法是为每个国家生成几何图形，我们可以 使用射线拾取 ，就像之前介绍的那样。 我们将为每个国家构建3D几何对象。如果用户点击代表那个国家的网格对象，我们就会知道对应的国家被点击了。

所以，为了验证这个解决方案，我尝试生成所有国家的3D网格对象，使用了在上一篇文章中和我生成轮廓一样的数据。 结果生成了15.5m的二进制GLTF(.glb)文件，让用户下载15.5m的数据对于我来说实在太多了。

有很多方法可以压缩数据。第一种可能是应用一些算法来降低轮廓的分辨率，但是我没有花时间来研究它。可能出现美国边界变大而加拿大边界变小的情况。

另一种解决方案是仅使用数据压缩，比如gzip将其降至11m，这减少了30%，但是还不够。

我们可以将所有数据存储为16位而不是32位浮点值。或者我们也可以使用像draco 压缩 这种东西也许就够了。不过我没有去试，我推荐你去试下回来告诉我是怎么回事，因为我很想知道😅

就我而言，我考虑使用 GPU拾取方案， 这在上一篇 关于拾取的文章 的最后有提到。这种方案中，我们使用一种独特的颜色代表不同网格对象的ID，然后我们绘制了所有网格，看看哪个颜色被点击了。

基于这种灵感，我们可以预先生成一张国家的地图，每个国家的颜色是它在国家数组中的索引号。我们可以使用类似GPU拾取技术，我们使用索引纹理绘制一个离屏全局画布，查看颜色会告诉我们用户点击了那个国家ID。

因此，我 写了一些代码 生成这样的一个纹理，在这里：

注意：生成这份纹理的数据来源于 这个网站 ，使用的协议是 CC-BY-SA。

它只有217k，比国家网格对象的15m要好得多，事实上我们可以使用更低的分辨率，但现在217k似乎已经足够了。

所以让我们试着用它来选择国家。

从 GPU拾取案例中 获取代码，我们需要一个场景来做拾取。

    const pickingScene = new THREE.Scene();
    pickingScene.background = new THREE.Color(0);

我们需要将带有索引纹理的地球添加到拾取场景中。

    {
      const loader = new THREE.TextureLoader();
      const geometry = new THREE.SphereGeometry(1, 64, 32);

    +  const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
    +  indexTexture.minFilter = THREE.NearestFilter;
    +  indexTexture.magFilter = THREE.NearestFilter;
    +
    +  const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
    +  pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));

      const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
      const material = new THREE.MeshBasicMaterial({map: texture});
      scene.add(new THREE.Mesh(geometry, material));
    }

然后我们把 `GPUPickingHelper`这个类拷贝下，在使用前我们需要做一些小改动

    class GPUPickHelper {
      constructor() {
        // 创造一个 1x1 的渲染对象
        this.pickingTexture = new THREE.WebGLRenderTarget(1, 1);
        this.pixelBuffer = new Uint8Array(4);
    -    this.pickedObject = null;
    -    this.pickedObjectSavedColor = 0;
      }
      pick(cssPosition, scene, camera) {
        const {pickingTexture, pixelBuffer} = this;

        // 将视图偏移设置为仅表示鼠标下单个元素
        const pixelRatio = renderer.getPixelRatio();
        camera.setViewOffset(
            renderer.getContext().drawingBufferWidth,   // full width
            renderer.getContext().drawingBufferHeight,  // full top
            cssPosition.x * pixelRatio | 0,             // rect x
            cssPosition.y * pixelRatio | 0,             // rect y
            1,                                          // rect width
            1,                                          // rect height
        );
        // 渲染场景
        renderer.setRenderTarget(pickingTexture);
        renderer.render(scene, camera);
        renderer.setRenderTarget(null);
        // 清除视图偏移，使渲染恢复正常
        camera.clearViewOffset();
        // 读取像素
        renderer.readRenderTargetPixels(
            pickingTexture,
            0,   // x
            0,   // y
            1,   // width
            1,   // height
            pixelBuffer);

    +    const id =
    +        (pixelBuffer[0] << 16) |
    +        (pixelBuffer[1] <<  8) |
    +        (pixelBuffer[2] <<  0);
    +
    +    return id;
    -    const id =
    -        (pixelBuffer[0] << 16) |
    -        (pixelBuffer[1] <<  8) |
    -        (pixelBuffer[2]      );
    -    const intersectedObject = idToObject[id];
    -    if (intersectedObject) {
    -      // 获取第一个对象，它是离我们最近的
    -      this.pickedObject = intersectedObject;
    -      // 保存它的颜色
    -      this.pickedObjectSavedColor = this.pickedObject.material.emissive.getHex();
    -      // 将其自发光颜色设置为闪烁的红色/黄色
    -      this.pickedObject.material.emissive.setHex((time * 8) % 2 > 1 ? 0xFFFF00 : 0xFF0000);
    -    }
      }
    }

现在我们可以用它来选择国家了。

    const pickHelper = new GPUPickHelper();

    function getCanvasRelativePosition(event) {
      const rect = canvas.getBoundingClientRect();
      return {
        x: (event.clientX - rect.left) * canvas.width  / rect.width,
        y: (event.clientY - rect.top ) * canvas.height / rect.height,
      };
    }

    function pickCountry(event) {
      // 如果我们还没有加载好数据，退出
      if (!countryInfos) {
        return;
      }

      const position = getCanvasRelativePosition(event);
      const id = pickHelper.pick(position, pickingScene, camera);
      if (id > 0) {
        // 我们点击了一个国家，修改它的selected属性
        const countryInfo = countryInfos[id - 1];
        const selected = !countryInfo.selected;
        // 如果我们选中这个国家，并且没有按住控制键，取消所有选中的国家
        if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
          unselectAllCountries();
        }
        numCountriesSelected += selected ? 1 : -1;
        countryInfo.selected = selected;
      } else if (numCountriesSelected) {
        // 海洋或者天空被选中了
        unselectAllCountries();
      }
      requestRenderIfNotRequested();
    }

    function unselectAllCountries() {
      numCountriesSelected = 0;
      countryInfos.forEach((countryInfo) => {
        countryInfo.selected = false;
      });
    }

    canvas.addEventListener('pointerup', pickCountry);

上面的代码，设置/重置了国家数组元素的 `selected` 属性。如果 `shift` 或 `ctrl` 或 `cmd` 被按下了，你就可以选择多个国家。

剩下的就是显示选择的国家，现在让我们更新标签

    function updateLabels() {
      // 如果我们还没有加载好数据，退出
      if (!countryInfos) {
        return;
      }

      const large = settings.minArea * settings.minArea;
      // 获取表示相机正对方向的矩阵
      normalMatrix.getNormalMatrix(camera.matrixWorldInverse);
      // 获取相机位置
      camera.getWorldPosition(cameraPosition);
      for (const countryInfo of countryInfos) {
    -    const {position, elem, area} = countryInfo;
    -    // 足够大了？
    -    if (area < large) {
    +    const {position, elem, area, selected} = countryInfo;
    +    const largeEnough = area >= large;
    +    const show = selected || (numCountriesSelected === 0 && largeEnough);
    +    if (!show) {
          elem.style.display = 'none';
          continue;
        }

        ...

通过上面的代码，我们就有能力拾取对应的国家了。

点击在新窗口打开

代码仍然会根据地区显示对应的国家。不过如果你点击一个，只会显示对应的标签。

所以这似乎是选择国家的有效解决方案，但是如何突出显示选定的国家？

我们可以从 调色板图形算法 中获取灵感。

调色板算法 或者 索引颜色 是被旧的系统，比如Atari 800、Amiga、NES、Super Nintendolder及IBM PCs所使用的。并非以RGBA的形式给每个颜色存储8位、每个像素至少32字节的位图，他们存储位图是8位或者更少。每个像素都是一个调色板的索引值， 所以举例一个像素值为3，表示“显示3号颜色值”，而定义3号颜色值的地方就叫“调色板”。

在JavaScript中就像这样：

    const face7x7PixelImageData = [
      0, 1, 1, 1, 1, 1, 0,
      1, 0, 0, 0, 0, 0, 1,
      1, 0, 2, 0, 2, 0, 1,
      1, 0, 0, 0, 0, 0, 1,
      1, 0, 3, 3, 3, 0, 1,
      1, 0, 0, 0, 0, 0, 1,
      0, 1, 1, 1, 1, 1, 1,
    ];

    const palette = [
      [255, 255, 255],  // 白
      [  0,   0,   0],  // 黑
      [  0, 255, 255],  // 青
      [255,   0,   0],  // 红
    ];

图像数据中每个像素都是调色板的索引，如果你分析上面调色板的数据你会得到这个图像：

在我们的例子中，上面已经有一个用不同id代表不同国家的纹理了，所以我们可以通过调色板使用相同的纹理赋予每个国家各自的颜色。通过更改调色板颜色我们可以为单独的国家着色。比如通过设置整个调色板纹理为黑色，给某个国家使用不同的颜色，就可以凸显那个国家了。

要做调色板索引的话，需要一些自定义着色器代码，让我们修改Three.js中默认的着色器，这样我们也可以根据需要使用照明或其他特性。

就像我们在 大量移动物体的优化 这篇文章中提到的，通过 `onBeforeCompile` 属性，我们可以通过向材质添加函数来修改默认的着色器。

默认的片元着色器在编译之前看起来就像这样：

    #include <common>
    #include <color_pars_fragment>
    #include <uv_pars_fragment>
    #include <map_pars_fragment>
    #include <alphamap_pars_fragment>
    #include <aomap_pars_fragment>
    #include <lightmap_pars_fragment>
    #include <envmap_pars_fragment>
    #include <fog_pars_fragment>
    #include <specularmap_pars_fragment>
    #include <logdepthbuf_pars_fragment>
    #include <clipping_planes_pars_fragment>
    void main() {
        #include <clipping_planes_fragment>
        vec4 diffuseColor = vec4( diffuse, opacity );
        #include <logdepthbuf_fragment>
        #include <map_fragment>
        #include <color_fragment>
        #include <alphamap_fragment>
        #include <alphatest_fragment>
        #include <specularmap_fragment>
        ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
        #ifdef USE_LIGHTMAP
            reflectedLight.indirectDiffuse += texture2D( lightMap, vLightMapUv ).xyz * lightMapIntensity;
        #else
            reflectedLight.indirectDiffuse += vec3( 1.0 );
        #endif
        #include <aomap_fragment>
        reflectedLight.indirectDiffuse *= diffuseColor.rgb;
        vec3 outgoingLight = reflectedLight.indirectDiffuse;
        #include <envmap_fragment>
        gl_FragColor = vec4( outgoingLight, diffuseColor.a );
        #include <premultiplied_alpha_fragment>
        #include <tonemapping_fragment>
        #include <colorspace_fragment>
        #include <fog_fragment>
    }

查看所有的片段 我们发现THREE.js使用了一个名为 `diffuseColor` 的变量去管理基本材质颜色。它在这里设置： `<color_fragment>` 片段 所以我们应该能够在这部分之后进行修改。

`diffuseColor` 在这个时刻应该已经是从我们轮廓纹理中获取的颜色了，所以我们应该可以从调色盘中获取颜色，然后把他们和最终颜色混合。

就像我们 之前做的那样 ， 在`Material.onBeforeCompile`我们使用一个用来搜索和替换着色器代码的数组。

    {
      const loader = new THREE.TextureLoader();
      const geometry = new THREE.SphereGeometry(1, 64, 32);

      const indexTexture = loader.load('resources/data/world/country-index-texture.png', render);
      indexTexture.minFilter = THREE.NearestFilter;
      indexTexture.magFilter = THREE.NearestFilter;

      const pickingMaterial = new THREE.MeshBasicMaterial({map: indexTexture});
      pickingScene.add(new THREE.Mesh(geometry, pickingMaterial));

    +  const fragmentShaderReplacements = [
    +    {
    +      from: '#include <common>',
    +      to: `
    +        #include <common>
    +        uniform sampler2D indexTexture;
    +        uniform sampler2D paletteTexture;
    +        uniform float paletteTextureWidth;
    +      `,
    +    },
    +    {
    +      from: '#include <color_fragment>',
    +      to: `
    +        #include <color_fragment>
    +        {
    +          vec4 indexColor = texture2D(indexTexture, vUv);
    +          float index = indexColor.r * 255.0 + indexColor.g * 255.0 * 256.0;
    +          vec2 paletteUV = vec2((index + 0.5) / paletteTextureWidth, 0.5);
    +          vec4 paletteColor = texture2D(paletteTexture, paletteUV);
    +          // diffuseColor.rgb += paletteColor.rgb;   // 白轮廓
    +          diffuseColor.rgb = paletteColor.rgb - diffuseColor.rgb;  // 黑轮廓
    +        }
    +      `,
    +    },
    +  ];

      const texture = loader.load('resources/data/world/country-outlines-4k.png', render);
      const material = new THREE.MeshBasicMaterial({map: texture});
    +  material.onBeforeCompile = function(shader) {
    +    fragmentShaderReplacements.forEach((rep) => {
    +      shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
    +    });
    +  };
      scene.add(new THREE.Mesh(geometry, material));
    }

在上面可以看到我们添加了3个uniforms变量，`indexTexture`, `paletteTexture`, and `paletteTextureWidth`。我们从 `indexTexture` 获取颜色，并且把它转化成索引下标。 `vUv` 是由Three.js提供的纹理坐标。然后我们使用索引下标从调色板中获取颜色。然后我们使用当前的 `diffuseColor`和最终的结果作混合。 `diffuseColor`在此时是我们黑色纹理，而调色盘是白色纹理。所以如果我们相加两个颜色，得出的是白色轮廓。如果我们二者相减，得出的是黑色轮廓。

在我们渲染前，我们还需要设置调色板纹理，以及这3个uniforms变量。

对于调色板纹理，它只需要足够宽即可。每个国家保留一种颜色 + 一种海洋颜色。这里有240个国家或地区，我们可以等到国家列表加载完成后以获取确切的数字来查找。不过选择一些更大的数字没有危害，所以让我们选择512。

这里是创建调色板的代码

    const maxNumCountries = 512;
    const paletteTextureWidth = maxNumCountries;
    const paletteTextureHeight = 1;
    const palette = new Uint8Array(paletteTextureWidth * 4);
    const paletteTexture = new THREE.DataTexture(
        palette, paletteTextureWidth, paletteTextureHeight);
    paletteTexture.minFilter = THREE.NearestFilter;
    paletteTexture.magFilter = THREE.NearestFilter;

一个`DataTexture` 提供原始的纹理数据。在这种情况下，我们给他512个RGBA颜色，每个颜色4字节，每个包含0-255的红、绿、蓝分量。

让我们用随机颜色填充它，只是为了看看它是否有效

    for (let i = 1; i < palette.length; ++i) {
      palette[i] = Math.random() * 256;
    }
    // 设置海洋颜色 (索引 #0)
    palette.set([100, 200, 255, 255], 0);
    paletteTexture.needsUpdate = true;

任何时候我们想要Three.js通过 `palette` 数组的内容来更新调色板上的纹理，我们需要去设置`paletteTexture.needsUpdate` 为 `true`。

然后我们还需要设置材质上的uniforms变量

    const geometry = new THREE.SphereGeometry(1, 64, 32);
    const material = new THREE.MeshBasicMaterial({map: texture});
    material.onBeforeCompile = function(shader) {
      fragmentShaderReplacements.forEach((rep) => {
        shader.fragmentShader = shader.fragmentShader.replace(rep.from, rep.to);
      });
    +  shader.uniforms.paletteTexture = {value: paletteTexture};
    +  shader.uniforms.indexTexture = {value: indexTexture};
    +  shader.uniforms.paletteTextureWidth = {value: paletteTextureWidth};
    };
    scene.add(new THREE.Mesh(geometry, material));

这样我们就得到了随机着色的国家

click here to open in a separate window

现在我们可以看到索引和调色板纹理生效了，让我们控制调色板进行高亮显示

首先让我们创建一个函数，我们传入一个THREE.js颜色，并格式化为可以放入调色板纹理的值。

    const tempColor = new THREE.Color();
    function get255BasedColor(color) {
      tempColor.set(color);
      const base = tempColor.toArray().map(v => v * 255);
      base.push(255); // alpha
      return base;
    }

像这样来调用 `color = get255BasedColor('red')` 会返回一个像 `[255, 0, 0, 255]`这样的数组。

接下来让我们用它来生成一些颜色并填充调色板。

    const selectedColor = get255BasedColor('red');
    const unselectedColor = get255BasedColor('#444');
    const oceanColor = get255BasedColor('rgb(100,200,255)');
    resetPalette();

    function setPaletteColor(index, color) {
      palette.set(color, index * 4);
    }

    function resetPalette() {
      // 让所有的颜色都是未选择状态的颜色
      for (let i = 1; i < maxNumCountries; ++i) {
        setPaletteColor(i, unselectedColor);
      }

      // 设置海洋颜色 (索引 #0)
      setPaletteColor(0, oceanColor);
      paletteTexture.needsUpdate = true;
    }

现在让我们使用这些函数来更新调色板，当一个国家被选中时：

    function getCanvasRelativePosition(event) {
      const rect = canvas.getBoundingClientRect();
      return {
        x: (event.clientX - rect.left) * canvas.width  / rect.width,
        y: (event.clientY - rect.top ) * canvas.height / rect.height,
      };
    }

    function pickCountry(event) {
      // 如果我们还没有加载好数据，退出
      if (!countryInfos) {
        return;
      }

      const position = getCanvasRelativePosition(event);
      const id = pickHelper.pick(position, pickingScene, camera);
      if (id > 0) {
        const countryInfo = countryInfos[id - 1];
        const selected = !countryInfo.selected;
        if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
          unselectAllCountries();
        }
        numCountriesSelected += selected ? 1 : -1;
        countryInfo.selected = selected;
    +    setPaletteColor(id, selected ? selectedColor : unselectedColor);
    +    paletteTexture.needsUpdate = true;
      } else if (numCountriesSelected) {
        unselectAllCountries();
      }
      requestRenderIfNotRequested();
    }

    function unselectAllCountries() {
      numCountriesSelected = 0;
      countryInfos.forEach((countryInfo) => {
        countryInfo.selected = false;
      });
    +  resetPalette();
    }

我们应该能够突出显示1个或多个国家。

点击在新窗口打开

这看起来有效！

一件小事是我们不能不改变选中状态就渲染地球。如果我们选择一个国家然后想要旋转地球，选中状态将改变。

让我们尝试解决这个问题。我认为，我们可以检查两件事情。点击和松开经过了多少时间；用户是否移动了鼠标。如果时间很短，或者他们没有移动鼠标，那么这个行为可能是点击。否则他们可能正在尝试拖动地球。

    +const maxClickTimeMs = 200;
    +const maxMoveDeltaSq = 5 * 5;
    +const startPosition = {};
    +let startTimeMs;
    +
    +function recordStartTimeAndPosition(event) {
    +  startTimeMs = performance.now();
    +  const pos = getCanvasRelativePosition(event);
    +  startPosition.x = pos.x;
    +  startPosition.y = pos.y;
    +}

    function getCanvasRelativePosition(event) {
      const rect = canvas.getBoundingClientRect();
      return {
        x: (event.clientX - rect.left) * canvas.width  / rect.width,
        y: (event.clientY - rect.top ) * canvas.height / rect.height,
      };
    }

    function pickCountry(event) {
      // exit if we have not loaded the data yet
      if (!countryInfos) {
        return;
      }

    +  // 如果用户触发后已经过了一段时间了
    +  // 就认为这是一个拖动行为
    +  const clickTimeMs = performance.now() - startTimeMs;
    +  if (clickTimeMs > maxClickTimeMs) {
    +    return;
    +  }
    +
    +  // 如果鼠标移动了，就认为这是一个拖动行为
    +  const position = getCanvasRelativePosition(event);
    +  const moveDeltaSq = (startPosition.x - position.x) ** 2 +
    +                      (startPosition.y - position.y) ** 2;
    +  if (moveDeltaSq > maxMoveDeltaSq) {
    +    return;
    +  }

    -  const position = {x: event.clientX, y: event.clientY};
      const id = pickHelper.pick(position, pickingScene, camera);
      if (id > 0) {
        const countryInfo = countryInfos[id - 1];
        const selected = !countryInfo.selected;
        if (selected && !event.shiftKey && !event.ctrlKey && !event.metaKey) {
          unselectAllCountries();
        }
        numCountriesSelected += selected ? 1 : -1;
        countryInfo.selected = selected;
        setPaletteColor(id, selected ? selectedColor : unselectedColor);
        paletteTexture.needsUpdate = true;
      } else if (numCountriesSelected) {
        unselectAllCountries();
      }
      requestRenderIfNotRequested();
    }

    function unselectAllCountries() {
      numCountriesSelected = 0;
      countryInfos.forEach((countryInfo) => {
        countryInfo.selected = false;
      });
      resetPalette();
    }

    +canvas.addEventListener('pointerdown', recordStartTimeAndPosition);
    canvas.addEventListener('pointerup', pickCountry);

添加了这些操作，这看起来 对我有效。

点击在新窗口打开

我不是用户交互的专家，所以我很想知道是否有更好的解决方案。

我希望这能让你了解图形索引的用处，以及如何修改Three.js的着色器以添加简单的功能。对于如何使用GLSL，编写着色器语言对于本文来说太多了，这个链接有一些少量的信息，参考 关于后处理的这篇文章。