# Canvas 纹理

这篇文章是此篇 关于纹理 文章的延续，如果你还没有读过，你或许应当从那篇开始。

在上一篇讲解纹理的文章中，我们主要使用图像文件来生成动态纹理，有时候我们想在运行时生成一个纹理。一种可行的方式是使用 `CanvasTexture`。

Canvas纹理 使用一个`<canvas>` 作为它的输入， 如果你还不知道如何使用2D Canvas API来在画布上绘制内容，MDN上有一篇很好的文章。

我们来写一段简单的Canvas代码，这是一个在随机位置上绘制随机颜色的点的程序。

    const ctx = document.createElement('canvas').getContext('2d');
    document.body.appendChild(ctx.canvas);
    ctx.canvas.width = 256;
    ctx.canvas.height = 256;
    ctx.fillStyle = '#FFF';
    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);

    function randInt(min, max) {
      if (max === undefined) {
        max = min;
        min = 0;
      }
      return Math.random() * (max - min) + min | 0;
    }

    function drawRandomDot() {
      ctx.fillStyle = `#${randInt(0x1000000).toString(16).padStart(6, '0')}`;
      ctx.beginPath();

      const x = randInt(256);
      const y = randInt(256);
      const radius = randInt(10, 64);
      ctx.arc(x, y, radius, 0, Math.PI * 2);
      ctx.fill();
    }

    function render() {
      drawRandomDot();
      requestAnimationFrame(render);
    }
    requestAnimationFrame(render);

这实在太简单了。

点击在新窗口打开

现在让我们用它来绘制纹理。我们会用从 上一篇文章 中绘制立方体纹理的例子开始。 我们将删除加载图像的代码，取而代之的是使用我们的Canvas，通过创建一个`CanvasTexture`，然后把我们创建好的Canvas对象传入。

    const cubes = [];  // 我们使用这个数组来旋转这些立方体
    -const loader = new THREE.TextureLoader();
    -
    +const ctx = document.createElement('canvas').getContext('2d');
    +ctx.canvas.width = 256;
    +ctx.canvas.height = 256;
    +ctx.fillStyle = '#FFF';
    +ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
    +const texture = new THREE.CanvasTexture(ctx.canvas);

    const material = new THREE.MeshBasicMaterial({
    -  map: loader.load('resources/images/wall.jpg'),
    +  map: texture,
    });
    const cube = new THREE.Mesh(geometry, material);
    scene.add(cube);
    cubes.push(cube);  // 添加到cube list中方便旋转

然后调用代码，在我们的渲染循环中绘制一个随机点。

    function render(time) {
      time *= 0.001;

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

    +  drawRandomDot();
    +  texture.needsUpdate = true;

      cubes.forEach((cube, ndx) => {
        const speed = .2 + ndx * .1;
        const rot = time * speed;
        cube.rotation.x = rot;
        cube.rotation.y = rot;
      });

      renderer.render(scene, camera);

      requestAnimationFrame(render);
    }

我们只需要做额外的一件事，设置了 `CanvasTexture` 的 `needsUpdate`属性来告诉THREE.js来更新纹理画布的最新内容。

这样，我们就有了一个用Canvas绘制纹理的立方体。

点击在新窗口打开

请注意，如果你想使用THREE.js绘制到Canvas中，你最好用 `RenderTarget`，在 这篇文章 中有提到。

纹理画布的一个常见用法是在场景中绘制文本。例如，你想把一个人的名字放在他们角色上面作为一个徽标(Badge)，你也许需要使用Canvas来绘制徽标纹理。

让我们创建一个有3个人的场景，并给每个人绘制一个徽标或者标签(Label)。

让我们用上面的例子，移除所有相关的立方体。然后设置背景为白色，然后添加两个灯光。

    const scene = new THREE.Scene();
    +scene.background = new THREE.Color('white');
    +
    +function addLight(position) {
    +  const color = 0xFFFFFF;
    +  const intensity = 1;
    +  const light = new THREE.DirectionalLight(color, intensity);
    +  light.position.set(...position);
    +  scene.add(light);
    +  scene.add(light.target);
    +}
    +addLight([-3, 1, 1]);
    +addLight([ 2, 1, .5]);

让我们写一些代码以使用2D Canvas绘制标签

    +function makeLabelCanvas(size, name) {
    +  const borderSize = 2;
    +  const ctx = document.createElement('canvas').getContext('2d');
    +  const font =  `${size}px bold sans-serif`;
    +  ctx.font = font;
    +  // 测量一下name有多长
    +  const doubleBorderSize = borderSize * 2;
    +  const width = ctx.measureText(name).width + doubleBorderSize;
    +  const height = size + doubleBorderSize;
    +  ctx.canvas.width = width;
    +  ctx.canvas.height = height;
    +
    +  // 注意，调整画布后需要重新修改字体
    +  ctx.font = font;
    +  ctx.textBaseline = 'top';
    +
    +  ctx.fillStyle = 'blue';
    +  ctx.fillRect(0, 0, width, height);
    +  ctx.fillStyle = 'white';
    +  ctx.fillText(name, borderSize, borderSize);
    +
    +  return ctx.canvas;
    +}

然后我们将用一个圆柱体作为身体，一个球体作为头部，一个平面作为标签来制作一个简单的人。

首先我们开始制作共享几何体。

    +const bodyRadiusTop = .4;
    +const bodyRadiusBottom = .2;
    +const bodyHeight = 2;
    +const bodyRadialSegments = 6;
    +const bodyGeometry = new THREE.CylinderGeometry(
    +    bodyRadiusTop, bodyRadiusBottom, bodyHeight, bodyRadialSegments);
    +
    +const headRadius = bodyRadiusTop * 0.8;
    +const headLonSegments = 12;
    +const headLatSegments = 5;
    +const headGeometry = new THREE.SphereGeometry(
    +    headRadius, headLonSegments, headLatSegments);
    +
    +const labelGeometry = new THREE.PlaneGeometry(1, 1);

然后我们写一个函数把这些部分组合成一个人。

    +function makePerson(x, size, name, color) {
    +  const canvas = makeLabelCanvas(size, name);
    +  const texture = new THREE.CanvasTexture(canvas);
    +  // 因为我们的Canvas长宽都不太可能是2的倍数，所以将filtering设置合理一些
    +  texture.minFilter = THREE.LinearFilter;
    +  texture.wrapS = THREE.ClampToEdgeWrapping;
    +  texture.wrapT = THREE.ClampToEdgeWrapping;
    +
    +  const labelMaterial = new THREE.MeshBasicMaterial({
    +    map: texture,
    +    side: THREE.DoubleSide,
    +    transparent: true,
    +  });
    +  const bodyMaterial = new THREE.MeshPhongMaterial({
    +    color,
    +    flatShading: true,
    +  });
    +
    +  const root = new THREE.Object3D();
    +  root.position.x = x;
    +
    +  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
    +  root.add(body);
    +  body.position.y = bodyHeight / 2;
    +
    +  const head = new THREE.Mesh(headGeometry, bodyMaterial);
    +  root.add(head);
    +  head.position.y = bodyHeight + headRadius * 1.1;
    +
    +  const label = new THREE.Mesh(labelGeometry, labelMaterial);
    +  root.add(label);
    +  label.position.y = bodyHeight * 4 / 5;
    +  label.position.z = bodyRadiusTop * 1.01;
    +
    +  // 如果单位是米， 那这里0.01就是将标签的尺寸转化为厘米
    +  const labelBaseScale = 0.01;
    +  label.scale.x = canvas.width  * labelBaseScale;
    +  label.scale.y = canvas.height * labelBaseScale;
    +
    +  scene.add(root);
    +  return root;
    +}

在上面你可以看到，我们把身体、头部、标签放在了一个根`Object3D` 上并且调整了他们的位置。这样如果我们想移动人的话直接移动根对象就可以了。身体是2个单位的高度，如果1个单位等于1米，那么上面的代码会尝试用厘米为单位制作标签，它们使用厘米作为宽高，以更好的适合文本。

然后我们可以制作带标签的人

    +makePerson(-3, 32, 'Purple People Eater', 'purple');
    +makePerson(-0, 32, 'Green Machine', 'green');
    +makePerson(+3, 32, 'Red Menace', 'red');

剩下的就是添加 `OrbitControls` 这样我们就可以移动相机了。

    import * as THREE from 'three';
    +import {OrbitControls} from 'three/addons/controls/OrbitControls.js';

    const fov = 75;
    const aspect = 2;  // Canvas默认值
    const near = 0.1;
    -const far = 5;
    +const far = 50;
    const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
    -camera.position.z = 2;
    +camera.position.set(0, 2, 5);

    +const controls = new OrbitControls(camera, canvas);
    +controls.target.set(0, 2, 0);
    +controls.update();

然后我们得到了一些简单的标签。

点击在新窗口打开

注意事项：

- 如果你过度放大，标签的分辨率会降低。

没有简单的解决方案，还有更复杂的字体渲染技术，据我所知没有插件可以解决这个问题。另外，还需要用户下载字体数据文件，这会变得很慢。

一种方案是增加标签的分辨率，尝试让尺寸变成现在的2倍，然后设置 `labelBaseScale` 是现在的一半。

- 名字越长，标签越长。

如果你想解决这个问题，你需要指定标签的固定大小，然后挤压文本。

这很容易做到。传入一个基本宽度并缩放文本以适应。

    -function makeLabelCanvas(size, name) {
    +function makeLabelCanvas(baseWidth, size, name) {
      const borderSize = 2;
      const ctx = document.createElement('canvas').getContext('2d');
      const font =  `${size}px bold sans-serif`;
      ctx.font = font;
      // 测量一下name有多长
    +  const textWidth = ctx.measureText(name).width;

      const doubleBorderSize = borderSize * 2;
    -  const width = ctx.measureText(name).width + doubleBorderSize;
    +  const width = baseWidth + doubleBorderSize;
      const height = size + doubleBorderSize;
      ctx.canvas.width = width;
      ctx.canvas.height = height;

      // 注意，调整画布后需要重新修改字体
      ctx.font = font;
    -  ctx.textBaseline = 'top';
    +  ctx.textBaseline = 'middle';
    +  ctx.textAlign = 'center';

      ctx.fillStyle = 'blue';
      ctx.fillRect(0, 0, width, height);

    +  // 缩放以适应，但是不要拉伸
    +  const scaleFactor = Math.min(1, baseWidth / textWidth);
    +  ctx.translate(width / 2, height / 2);
    +  ctx.scale(scaleFactor, 1);
      ctx.fillStyle = 'white';
      ctx.fillText(name, borderSize, borderSize);

      return ctx.canvas;
    }

然后我们可以传入预期标签的长度

    -function makePerson(x, size, name, color) {
    -  const canvas = makeLabelCanvas(size, name);
    +function makePerson(x, labelWidth, size, name, color) {
    +  const canvas = makeLabelCanvas(labelWidth, size, name);

    ...

    }

    -makePerson(-3, 32, 'Purple People Eater', 'purple');
    -makePerson(-0, 32, 'Green Machine', 'green');
    -makePerson(+3, 32, 'Red Menace', 'red');
    +makePerson(-3, 150, 32, 'Purple People Eater', 'purple');
    +makePerson(-0, 150, 32, 'Green Machine', 'green');
    +makePerson(+3, 150, 32, 'Red Menace', 'red');

我们将文本居中并缩放以适应标签的尺寸。

点击在新窗口打开

上面我们为每一个纹理使用了单独的Canvas，是否为每个纹理使用单独的Canvas取决于你。如果你需要经常单独更新它们，每个纹理一个Canvas是一个比较好的选择。如果它们很少或者从不更新，那么你可以用一个Canvas，通过THREE.js来生成多个纹理。让我们更改上面的代码来完成这一点。

    +const ctx = document.createElement('canvas').getContext('2d');
    function makeLabelCanvas(baseWidth, size, name) {
      const borderSize = 2;
    -  const ctx = document.createElement('canvas').getContext('2d');
      const font =  `${size}px bold sans-serif`;

      ...

    }

    +const forceTextureInitialization = function() {
    +  const material = new THREE.MeshBasicMaterial();
    +  const geometry = new THREE.PlaneGeometry();
    +  const scene = new THREE.Scene();
    +  scene.add(new THREE.Mesh(geometry, material));
    +  const camera = new THREE.Camera();
    +
    +  return function forceTextureInitialization(texture) {
    +    material.map = texture;
    +    renderer.render(scene, camera);
    +  };
    +}();

    function makePerson(x, labelWidth, size, name, color) {
      const canvas = makeLabelCanvas(labelWidth, size, name);
      const texture = new THREE.CanvasTexture(canvas);
      // 因为我们的Canvas长宽都不太可能是2的倍数，所以将filtering设置合理一些
      texture.minFilter = THREE.LinearFilter;
      texture.wrapS = THREE.ClampToEdgeWrapping;
      texture.wrapT = THREE.ClampToEdgeWrapping;
    +  forceTextureInitialization(texture);

      ...

点击在新窗口打开

另一个问题是标签并不总是面向相机，如果你使用标签作为徽标，这可能是一件好事。 如果你使用标签来放置3D游戏中玩家的名字，也许你希望标签总是面对相机。 具体内容在 广告牌(Billboards)文章 有覆盖到。

特别是对于标签，另一种解决方案是使用HTML， 本文中的标签是 位于3D场景中 ，如果你想要他们被其他对象遮挡是很好的，因为 HTML 标签 总是在最上层。