Threejs:3D打字特效

72 阅读3分钟

目标实现下图的文字特效

Canvas sampling

在canvas上写字,然后对像素采样,属于文字部分的像素点的颜色大于0,记录下坐标,最终得到的坐标集合就作为需要渲染的点的集合。

  const textCanvas = document.createElement("canvas");
  const textCtx = textCanvas.getContext("2d");
  document.body.appendChild(textCanvas);

  // 计算宽高
  const lines = string.split("\n");
  const linesMaxLength = [...lines].sort((a, b) => b.length - a.length)[0]
    .length;
  const wTexture = textureFontSize * 0.7 * linesMaxLength;
  const hTexture = lines.length * textureFontSize;

  // 绘制
  const lineNumber = lines.length;
  textCanvas.width = wTexture;
  textCanvas.height = hTexture;
  textCtx.font = "100 " + textureFontSize + "px " + fontName;
  textCtx.fillStyle = "#2a9d8f";
  textCtx.clearRect(0, 0, textCanvas.width, textCanvas.height);

  for (let i = 0; i < lineNumber; i++) {
    textCtx.fillText(lines[i], 0, ((i + 0.8) * hTexture) / lineNumber);
    // 横坐标为0,纵坐标为字左下角
  }

  const textureCoordinates = [];
  const samplingStep = 2;
  if (wTexture > 0) {
    const imageData = textCtx.getImageData(
      0,
      0,
      textCanvas.width,
      textCanvas.height
    );
    // 获得width*height的一维数组
    console.log("-------imageData.length", imageData.data.length);
    for (let i = 0; i < textCanvas.height; i += samplingStep) {
      for (let j = 0; j < textCanvas.width; j += samplingStep) {
        if (imageData.data[(j + i * textCanvas.width) * 4] > 0) {
          textureCoordinates.push({
            x: j,
            y: i,
          });
        }
      }
    }
  }

Points粒子系统

使用points粒子系统,几何体的数据由上一步采样的坐标得到,纹理可以设置纯色或者图片,最终渲染的效果如下

function createParticles(textureCoordinates, stringBox) {
  const geometry = new THREE.BufferGeometry();
  const material = new THREE.PointsMaterial({
    color: 0xff0000,
    size: 1,
  });
  const vertices = [];
  for (let i = 0; i < textureCoordinates.length; i++) {
    vertices.push(
      textureCoordinates[i].x,
      stringBox.hScene -textureCoordinates[i].y,
      5 * Math.random()
    );
  }
  geometry.setAttribute(
    "position",
    new THREE.Float32BufferAttribute(vertices, 3)
  );
  const particles = new THREE.Points(geometry, material);
  particles.position.x = -0.5 * stringBox.wScene;
  particles.position.y = -0.5 * stringBox.hScene;
  return particles;
  }

instancedMesh

因为points渲染机制只能通过pointsize设置点的大小,如果要将每个点渲染成给定的3d模型就做不到了。使用instancedMesh,可以指定每个实例的几何体,纹理,个数,也就可以指定每个实例的3d渲染方式,然后通过API设置每个实例的变换矩阵就可以高效的渲染多个粒子。

  const geo = new THREE.TorusGeometry(0.35, 0.15, 16, 50);
  const material = new THREE.MeshNormalMaterial({ });
  const instancedMesh = new THREE.InstancedMesh(geo, material, coordinates.length)
  instancedMesh.position.x = -.5 * stringBox.wScene
  instancedMesh.position.y = -.5 * stringBox.hScene

用户输入

使用contenteditable的div接受用户输入的文字,获取dom的包围盒得到纹理的长宽。contenteditable的div换行后会增加div子元素,第二行的文字就是新增的div的内容,所以为了获取实际的输入文字,需要对innerHTML做一些处理。

得到用户输入后,重新调用生成instancedmesh的方法,去掉旧的实例,将新的instancedmesh加入

document.addEventListener('keyup', () => {
  handleInput()
  refreshText()

})

function handleInput() {
    //...
    
    
    // 处理得到用户输入
       string = textInputEl.innerHTML
    .replaceAll("<p>", "\n")
    .replaceAll("</p>", "")
    .replaceAll("<div>", "\n")
    .replaceAll("</div>", "")
    .replaceAll("<br>", "")
    .replaceAll("<br/>", "")
    .replaceAll("&nbsp;", " ");
}

光标

光标渲染是一个三维盒子,只要设置好宽高即可,加入到scene。每次输入后,使用range api计算出光标的位置,更新mesh的位置

const cursorGeometry = new THREE.BoxGeometry(.3, 4.5, .03);
cursorGeometry.translate(.5, -2.7, 0)
const cursorMaterial = new THREE.MeshNormalMaterial({
    transparent: true,
});
cursorMesh = new THREE.Mesh(cursorGeometry, cursorMaterial);
scene.add(cursorMesh);


function updateCursorPosition() {
  cursorMesh.position.x = -.5 * stringBox.wScene + stringBox.caretPosScene[0];
  cursorMesh.position.y = .5 * stringBox.hScene - stringBox.caretPosScene[1];
}

计算光标的函数

    function getCaretCoordinates() {
      const range = window.getSelection().getRangeAt(0);
      const needsToWorkAroundNewlineBug = (range.startContainer.nodeName.toLowerCase() === 'div' && range.startOffset === 0);
      if (needsToWorkAroundNewlineBug) {
          return [
              range.startContainer.offsetLeft,
              range.startContainer.offsetTop
          ]
      } else {
          const rects = range.getClientRects();
          if (rects[0]) {
              return [rects[0].left, rects[0].top]
          } else {
              // since getClientRects() gets buggy in FF
              document.execCommand('selectAll', false, null);
              return [
                  0, 0
              ]
          }
      }
  }

光标闪烁

在render函数中不断改变光标的透明度即可。使用THREE的clock获取时间,计算出透明度,当网页聚焦时就设置计算出的透明度,否则设置为0不展示光标

function updateCursorOpacity() {
  if (document.hasFocus() && document.activeElement === textInputEl) {
      cursorMesh.material.opacity = roundPulse(2 * clock.getElapsedTime());
  } else {
      cursorMesh.material.opacity = 0;
  }
}
let roundPulse = (t) => Math.sign(Math.sin(t * Math.PI)) * Math.pow(Math.sin((t % 1) * 3.14), .2);

动画

目前只有光标闪烁的动画,可以给每个mesh增加旋转、缩放的动画。

前面我们使用坐标给每个mesh设置变换矩阵,接下来我们实现一个类来实现mesh的变换,将坐标传入,该类内部随机生成旋转,缩放,grow函数则更新状态,在生成instancedmesh时,我们使用该类去设置变换矩阵,并且在render函数中调用grow更新状态,重设变换矩阵,就可以给mesh加上动画效果

function Particle([x, y]) {
  this.x = x;
  this.y = y;
  this.z = 0;
  this.rotationX = Math.random() * 2 * Math.PI;
  this.rotationY = Math.random() * 2 * Math.PI;
  this.rotationZ = Math.random() * 2 * Math.PI;
  this.scale = 0;

  this.deltaRotation = 0.2 * (Math.random() - 0.5);
  this.deltaScale = 0.03 + 0.1 * Math.random();

  this.toDelete = false;

  this.grow = function () {
    this.rotationX += this.deltaRotation;
    this.rotationY += this.deltaRotation;
    this.rotationZ += this.deltaRotation;

    if (this.toDelete) {
      this.scale -= this.deltaScale;
      if (this.scale <= 0) {
        this.scale = 0;
      }
    } else if (this.scale < 1) {
      this.scale += this.deltaScale;
    }
  };
}

输入、删除动画效果

当前每次输入,都会重新生成mesh,并且重新生成记录变换状态的particle实例。我们希望当输入时旧的文字接着上次的动画继续变,而新的文字从小到大变出来,删除的文字从大到小消失。

为了达到这种效果,在生成坐标后,我们需要区分哪些是新的,哪些是旧的坐标。旧的坐标不生成particle,而是返回原有的particle实例,因为是同一个实例,所以状态是上次的状态,动画就能流畅进行,而新的坐标生成新的particle,新生成的particle的scale从0变大,就产生了从小变大的效果。至于删除的坐标,我们先不删除,而是做标记,并且particle类也做上标记,让它的scale从1到0减小,就产生了从大到小消失的效果。而在下一次输入时,我们就删除之前遗留的要被删除的坐标和particle