three.js 实现几个好看的文本内容效果

前言

three.js中目前作者已知创建文本的方法有四种:

  1. Canvas2D + THREE.CanvasTexture
  2. THREE.TextGeometry / THREE.TextBufferGeometry(3D 实体文字)
  3. 使用插件 troika-three-text
  4. CSS2DRenderer / CSS3DRenderer(HTML 文字)

今天给大家分享一下如何使用 Canvas2D + THREE.CanvasTexture 的方法实现创建多个不同文本样式内容的效果

涉及的Three.js核心API方法介绍

1.THREE.CanvasTexture

CanvasTextureTHREE.Texture 的一个子类,它的纹理数据来源是 HTML <canvas> 元素,而不是图片文件。

2.THREE.Sprite

Sprite 是一种 始终面向相机的平面对象 (billboard)。

适合于用来显示 标签、粒子、UI 图标,因为无论相机怎么转,它都会一直正对相机。

将创建不同样式文本的逻辑进行统一处理

这里我们将几个创建不同的文本方法名都进行定义

然后定义createCanvasTexture方法用于将创建的文本画布内容进行添加到three.js场景中

js 复制代码
/**
 * 文本canvas类型
 */
export enum CANVAS_METHOD {
  CreatePlainTextCanvas = 'CreatePlainTextCanvas',
  CreateFixedCanvas = 'CreateFixedCanvas',
  CreateCampusCanvas = 'CreateCampusCanvas',
  CreateTechCanvas = 'CreateTechCanvas',
}
/**
 * 文本参数类
 */
export interface TextOptions {
  color: string;
  fontSize: number;
  textContent: string;
}

// 不同画布方法对象
const canvasMap = {
  [CANVAS_METHOD.CreatePlainTextCanvas]: createPlainTextCanvas,
  [CANVAS_METHOD.CreateFixedCanvas]: createFixedCanvas,
  [CANVAS_METHOD.CreateCampusCanvas]: createCampusCanvas,
  [CANVAS_METHOD.CreateTechCanvas]: createTechCanvas,
};

// 添加分辨率缩放因子,提高canvas的分辨率以解决近距离模糊问题
const RESOLUTION_SCALE = 4;
// 画布放大比例
const CANVAS_SCALE = 1.5;

  /**
   * 创建canvas纹理
   * @param textMethod 调用的文本方法
   * @returns null
   */
  createCanvasTexture(textMethod:CANVAS_METHOD) {
    // 创建canvans画布方法
    const canvas = canvasMap[textMethod]();
    // 创建 canvas 纹理
    const texture = new THREE.CanvasTexture(canvas);
    // 设置纹理的过滤方式,使缩小时保持清晰
    texture.minFilter = THREE.LinearFilter;
    texture.magFilter = THREE.LinearFilter;
    texture.needsUpdate = true;
    canvas.remove();
    const material = new THREE.SpriteMaterial({
      map: texture,
      transparent: true,
    });

    const sprite = new THREE.Sprite(material);
    // 设置精灵的适当比例,保持宽高比
    // 因为画布分辨率提高了RESOLUTION_SCALE倍,这里需要对应调整
    const aspectRatio = canvas.width / canvas.height;
    const baseScale = 1;
    sprite.scale.set(baseScale * aspectRatio, baseScale, 1);
    // 添加到场景中去
    store?.sceneApi?.scene?.add(sprite);
  }

定义一个文本换行的方法,用于处理canvas文本换行

canvas 画布内容不能像css那样有识别换行的属性,因此针对文本超出容器内容后我们需要单独写一个方法进行绘制文本换行逻辑

js 复制代码
/**
 * 文本换行处理函数
 * @param ctx Canvas上下文
 * @param textContent 需要换行的文本
 * @param maxWidth 每行最大宽度
 * @returns 换行后的文本数组
 */
function wrapText(
  ctx: CanvasRenderingContext2D,
  textContent: string,
  maxWidth: number
): string[] {
  // 函数逻辑保持不变,因为它处理的是相对关系
  // 如果文本为空,返回空数组
  if (!textContent) return [];
  // 按空格分割文本(针对英文)
  const words = textContent.split(' ');
  const lines: string[] = [];
  let currentLine = '';

  // 如果只有一个词,检查是否需要按字符拆分(针对中文或无空格的长文本)
  if (words.length === 1) {
    const chars = textContent.split('');
    currentLine = chars[0] || '';

    for (let i = 1; i < chars.length; i++) {
      const testLine = currentLine + chars[i];
      const metrics = ctx.measureText(testLine);

      if (metrics.width > maxWidth) {
        lines.push(currentLine);
        currentLine = chars[i];
      } else {
        currentLine = testLine;
      }
    }

    if (currentLine) {
      lines.push(currentLine);
    }

    return lines;
  }

  // 处理有空格的文本(英文)
  currentLine = words[0] || '';

  for (let i = 1; i < words.length; i++) {
    const testLine = currentLine + ' ' + words[i];
    const metrics = ctx.measureText(testLine);

    if (metrics.width > maxWidth) {
      lines.push(currentLine);
      currentLine = words[i];
    } else {
      currentLine = testLine;
    }
  }

  if (currentLine) {
    lines.push(currentLine);
  }

  return lines;
}

创建一个纯文本内容

这里在定义一个 createPlainTextCanvas 方法用来创建一个纯文本内容的 canvas 画布

js 复制代码
/**
 * 创建纯文本canvas,没有背景和装饰
 * @param textOptions 文本选项
 * @returns canvas
 */
function createPlainTextCanvas(textOptions?: TextOptions) {
  const {
    textContent = 'three.js 实现几个好看的文本内容效果',
    color = '#fff',
    fontSize = 16,
  } = textOptions || {};

  // 创建临时canvas来测量文本尺寸
  const measureCanvas = document.createElement('canvas');
  const measureCtx = measureCanvas.getContext('2d') as CanvasRenderingContext2D;
  const fontSizeScaled = fontSize * RESOLUTION_SCALE; // 使用传入的字体大小乘以分辨率因子
  measureCtx.font = `${fontSizeScaled}px Arial`;

  // 文本换行处理
  const paddingX = 20 * RESOLUTION_SCALE * CANVAS_SCALE; // 左右内边距
  const paddingY = 10 * RESOLUTION_SCALE * CANVAS_SCALE; // 上下内边距
  const maxWidth = 300 * RESOLUTION_SCALE * CANVAS_SCALE; // 最大宽度限制
  const lines = wrapText(measureCtx, textContent, maxWidth);

  // 计算每行宽度,找出最大宽度
  let textWidth = 0;
  for (const line of lines) {
    const metrics = measureCtx.measureText(line);
    textWidth = Math.max(textWidth, metrics.width);
  }

  // 计算画布尺寸
  const lineHeight = fontSizeScaled * 1.2;
  const canvasWidth = textWidth + paddingX * 2;
  const canvasHeight = lines.length * lineHeight + paddingY * 2;

  // 创建最终canvas
  const canvas = document.createElement('canvas');
  canvas.width = canvasWidth;
  canvas.height = canvasHeight;
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;

  // 透明背景
  ctx.fillStyle = 'rgba(0,0,0,0)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // 设置文本样式
  ctx.font = `${fontSizeScaled}px Arial`;
  ctx.fillStyle = color;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // 绘制文本 - 居中对齐
  const textCenterX = canvas.width / 2;
  const startY = paddingY + lineHeight / 2;

  lines.forEach((line, i) => {
    const y = startY + i * lineHeight;
    ctx.fillText(line, textCenterX, y);
  });

  return canvas;
}

创建一个带有简单样式的文本内容

js 复制代码
/**
 * 创建固定样式canvas
 * @param textOptions 文本选项
 * @returns canvas
 */
function createFixedCanvas(textOptions?: TextOptions) {
  const {
    textContent = '创建一个带有简单样式的文本内容',
    color = '#fff',
    fontSize = 16,
  } = textOptions || {};
  // 创建固定大小的canvas和上下文
  const canvas = document.createElement('canvas');
  canvas.width = 180 * RESOLUTION_SCALE * CANVAS_SCALE; // 固定宽度
  canvas.height = 130 * RESOLUTION_SCALE * CANVAS_SCALE; // 固定高度
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
  const fontSizeScaled = fontSize * RESOLUTION_SCALE; // 使用传入的字体大小乘以分辨率因子

  // 设置矩形高度(总高度的75%)
  const rectHeight = canvas.height * 0.75;
  const rectWidth = canvas.width;
  const borderWidth = 2 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 创建背景渐变效果
  const bgGradient = ctx.createLinearGradient(0, 0, 0, rectHeight);
  bgGradient.addColorStop(0, 'rgba(25, 35, 45, 0.85)');
  bgGradient.addColorStop(1, 'rgba(15, 25, 35, 0.9)');

  // 绘制主体背景(带圆角)
  const radius = 6 * RESOLUTION_SCALE * CANVAS_SCALE;

  ctx.beginPath();
  ctx.moveTo(radius, 0);
  ctx.lineTo(rectWidth - radius, 0);
  ctx.quadraticCurveTo(rectWidth, 0, rectWidth, radius);
  ctx.lineTo(rectWidth, rectHeight - radius);
  ctx.quadraticCurveTo(rectWidth, rectHeight, rectWidth - radius, rectHeight);
  ctx.lineTo(radius, rectHeight);
  ctx.quadraticCurveTo(0, rectHeight, 0, rectHeight - radius);
  ctx.lineTo(0, radius);
  ctx.quadraticCurveTo(0, 0, radius, 0);
  ctx.closePath();

  ctx.fillStyle = bgGradient;
  ctx.fill();

  // 绘制内发光效果
  const innerGlow = ctx.createLinearGradient(0, 0, 0, rectHeight);
  innerGlow.addColorStop(0, 'rgba(0, 200, 255, 0.1)');
  innerGlow.addColorStop(0.5, 'rgba(0, 200, 255, 0.05)');
  innerGlow.addColorStop(1, 'rgba(0, 200, 255, 0.1)');

  // 内发光路径(比主体小一点)
  const glowPadding = 2 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.beginPath();
  ctx.moveTo(radius + glowPadding, glowPadding);
  ctx.lineTo(rectWidth - radius - glowPadding, glowPadding);
  ctx.quadraticCurveTo(
    rectWidth - glowPadding,
    glowPadding,
    rectWidth - glowPadding,
    radius + glowPadding
  );
  ctx.lineTo(rectWidth - glowPadding, rectHeight - radius - glowPadding);
  ctx.quadraticCurveTo(
    rectWidth - glowPadding,
    rectHeight - glowPadding,
    rectWidth - radius - glowPadding,
    rectHeight - glowPadding
  );
  ctx.lineTo(radius + glowPadding, rectHeight - glowPadding);
  ctx.quadraticCurveTo(
    glowPadding,
    rectHeight - glowPadding,
    glowPadding,
    rectHeight - radius - glowPadding
  );
  ctx.lineTo(glowPadding, radius + glowPadding);
  ctx.quadraticCurveTo(
    glowPadding,
    glowPadding,
    radius + glowPadding,
    glowPadding
  );
  ctx.closePath();

  ctx.strokeStyle = innerGlow;
  ctx.lineWidth = 1 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.stroke();

  // 绘制边框(渐变色)
  const borderGradient = ctx.createLinearGradient(0, 0, rectWidth, rectHeight);
  borderGradient.addColorStop(0, '#00c8ff');
  borderGradient.addColorStop(0.5, '#00a8ff');
  borderGradient.addColorStop(1, '#00c8ff');

  ctx.beginPath();
  ctx.moveTo(radius, borderWidth / 2);
  ctx.lineTo(rectWidth - radius, borderWidth / 2);
  ctx.quadraticCurveTo(
    rectWidth - borderWidth / 2,
    borderWidth / 2,
    rectWidth - borderWidth / 2,
    radius
  );
  ctx.lineTo(rectWidth - borderWidth / 2, rectHeight - radius);
  ctx.quadraticCurveTo(
    rectWidth - borderWidth / 2,
    rectHeight - borderWidth / 2,
    rectWidth - radius,
    rectHeight - borderWidth / 2
  );
  ctx.lineTo(radius, rectHeight - borderWidth / 2);
  ctx.quadraticCurveTo(
    borderWidth / 2,
    rectHeight - borderWidth / 2,
    borderWidth / 2,
    rectHeight - radius
  );
  ctx.lineTo(borderWidth / 2, radius);
  ctx.quadraticCurveTo(
    borderWidth / 2,
    borderWidth / 2,
    radius,
    borderWidth / 2
  );

  ctx.strokeStyle = borderGradient;
  ctx.lineWidth = borderWidth;
  ctx.stroke();
  // 绘制底部连接线和点
  const lineStartX = canvas.width / 2;
  const lineStartY = rectHeight;
  const lineEndY = canvas.height - 10 * RESOLUTION_SCALE * CANVAS_SCALE;
  // 虚线连接
  const dashLength = 3 * RESOLUTION_SCALE * CANVAS_SCALE;
  const dashGap = 2 * RESOLUTION_SCALE * CANVAS_SCALE;
  let dashY = lineStartY;
  ctx.strokeStyle = '#00c8ff';
  ctx.lineWidth = 2 * RESOLUTION_SCALE * CANVAS_SCALE;
  // 绘制虚线
  while (dashY < lineEndY - 5 * RESOLUTION_SCALE * CANVAS_SCALE) {
    ctx.beginPath();
    ctx.moveTo(lineStartX, dashY);
    ctx.lineTo(
      lineStartX,
      Math.min(
        dashY + dashLength,
        lineEndY - 5 * RESOLUTION_SCALE * CANVAS_SCALE
      )
    );
    ctx.stroke();
    dashY += dashLength + dashGap;
  }

  // 绘制圆点(带发光效果)
  const dotRadius = 6 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 外发光
  const dotGlow = ctx.createRadialGradient(
    lineStartX,
    lineEndY,
    0,
    lineStartX,
    lineEndY,
    dotRadius * 1.5
  );
  dotGlow.addColorStop(0, 'rgba(0, 200, 255, 0.8)');
  dotGlow.addColorStop(0.5, 'rgba(0, 200, 255, 0.3)');
  dotGlow.addColorStop(1, 'rgba(0, 200, 255, 0)');
  ctx.beginPath();
  ctx.arc(lineStartX, lineEndY, dotRadius * 1.5, 0, Math.PI * 2);
  ctx.fillStyle = dotGlow;
  ctx.fill();

  // 主体圆点
  const dotGradient = ctx.createRadialGradient(
    lineStartX,
    lineEndY,
    0,
    lineStartX,
    lineEndY,
    dotRadius
  );
  dotGradient.addColorStop(0, '#00ffff');
  dotGradient.addColorStop(1, '#00a0ff');
  ctx.beginPath();
  ctx.arc(lineStartX, lineEndY, dotRadius, 0, Math.PI * 2);
  ctx.fillStyle = dotGradient;
  ctx.fill();
  // 添加高光点
  ctx.beginPath();
  ctx.arc(
    lineStartX - dotRadius / 3,
    lineEndY - dotRadius / 3,
    dotRadius / 3,
    0,
    Math.PI * 2
  );
  ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
  ctx.fill();
  // 添加文本(支持自动换行)
  ctx.font = `${fontSizeScaled}px Arial`;
  ctx.fillStyle = color;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';
  // 文本换行处理
  const maxWidth = rectWidth - 20 * RESOLUTION_SCALE * CANVAS_SCALE; // 留出左右边距
  const lineHeight = fontSizeScaled * 1.2; // 行高
  const lines = wrapText(ctx, textContent, maxWidth);
  // 计算所有行文本的总高度
  const totalTextHeight = lines.length * lineHeight;
  // 文本在矩形中垂直居中的起始Y坐标
  const startY = (rectHeight - totalTextHeight) / 2 + lineHeight / 2;
  // 绘制每一行文本
  lines.forEach((line, index) => {
    const y = startY + index * lineHeight;
    ctx.fillText(line, rectWidth / 2, y);
  });
  return canvas;
}

创建一个校园风格的文本内容

这里我们继续定义一下 createCampusCanvas 方法内容

js 复制代码
/**
 * 创建青春校园风格canvas
 * @param textOptions 文本选项
 * @returns canvas
 */
function createCampusCanvas(textOptions?: TextOptions) {
  const {
    textContent = '创建一个校园风格的文本内容',
    color = '#fff',
    fontSize = 16,
  } = textOptions || {};
  // 创建固定大小的canvas和上下文
  const canvas = document.createElement('canvas');
  canvas.width = 200 * RESOLUTION_SCALE * CANVAS_SCALE;
  canvas.height = 150 * RESOLUTION_SCALE * CANVAS_SCALE;
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
  const fontSizeScaled = fontSize * RESOLUTION_SCALE;

  // 清除背景
  ctx.fillStyle = 'rgba(0, 0, 0, 0)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // 绘制校园笔记本背景
  const noteX = 15 * RESOLUTION_SCALE * CANVAS_SCALE;
  const noteY = 15 * RESOLUTION_SCALE * CANVAS_SCALE;
  const noteWidth = canvas.width - 30 * RESOLUTION_SCALE * CANVAS_SCALE;
  const noteHeight = 85 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 笔记本纸张 - 浅黄色
  ctx.fillStyle = 'rgba(255, 251, 235, 0.95)';
  ctx.beginPath();
  ctx.roundRect(
    noteX,
    noteY,
    noteWidth,
    noteHeight,
    8 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.fill();

  // 添加笔记本上的横线 (模仿作业本)
  ctx.strokeStyle = 'rgba(100, 180, 255, 0.3)';
  ctx.lineWidth = 1 * RESOLUTION_SCALE * CANVAS_SCALE;

  const lineGap = 10 * RESOLUTION_SCALE * CANVAS_SCALE;
  const lineStart = noteY + 15 * RESOLUTION_SCALE * CANVAS_SCALE;
  const lineEnd = noteY + noteHeight - 10 * RESOLUTION_SCALE * CANVAS_SCALE;

  for (let y = lineStart; y <= lineEnd; y += lineGap) {
    ctx.beginPath();
    ctx.moveTo(noteX + 5 * RESOLUTION_SCALE * CANVAS_SCALE, y);
    ctx.lineTo(noteX + noteWidth - 5 * RESOLUTION_SCALE * CANVAS_SCALE, y);
    ctx.stroke();
  }

  // 添加左侧红线 (像作业本的界线)
  ctx.strokeStyle = 'rgba(255, 80, 80, 0.5)';
  ctx.lineWidth = 1.5 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.beginPath();
  ctx.moveTo(
    noteX + 25 * RESOLUTION_SCALE * CANVAS_SCALE,
    noteY + 5 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.lineTo(
    noteX + 25 * RESOLUTION_SCALE * CANVAS_SCALE,
    noteY + noteHeight - 5 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.stroke();

  // 添加装饰贴纸
  // 绘制彩色贴纸 - 右上角
  const stickerSize = 22 * RESOLUTION_SCALE * CANVAS_SCALE;
  const stickerX = noteX + noteWidth - 25 * RESOLUTION_SCALE * CANVAS_SCALE;
  const stickerY = noteY - 5 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 旋转贴纸
  ctx.save();
  ctx.translate(stickerX, stickerY);
  ctx.rotate(Math.PI / 12); // 稍微旋转一点

  // 贴纸背景
  const stickerGradient = ctx.createLinearGradient(
    -stickerSize / 2,
    -stickerSize / 2,
    stickerSize / 2,
    stickerSize / 2
  );
  stickerGradient.addColorStop(0, '#FF9ED8'); // 粉色
  stickerGradient.addColorStop(1, '#FFC6E7');

  ctx.fillStyle = stickerGradient;
  ctx.beginPath();
  ctx.arc(0, 0, stickerSize / 2, 0, Math.PI * 2);
  ctx.fill();

  // 贴纸上的小星星
  ctx.fillStyle = 'white';
  const starPoints = 5;
  const starOuterRadius = stickerSize / 4;
  const starInnerRadius = starOuterRadius / 2;

  ctx.beginPath();
  for (let i = 0; i < starPoints * 2; i++) {
    const radius = i % 2 === 0 ? starOuterRadius : starInnerRadius;
    const angle = (i * Math.PI) / starPoints;
    ctx.lineTo(Math.cos(angle) * radius, Math.sin(angle) * radius);
  }
  ctx.closePath();
  ctx.fill();

  ctx.restore(); // 恢复旋转

  // 绘制回形针
  const clipX = noteX + 10 * RESOLUTION_SCALE * CANVAS_SCALE;
  const clipY = noteY - 2 * RESOLUTION_SCALE * CANVAS_SCALE;
  const clipWidth = 10 * RESOLUTION_SCALE * CANVAS_SCALE;
  const clipHeight = 25 * RESOLUTION_SCALE * CANVAS_SCALE;

  ctx.strokeStyle = '#6BB5FF'; // 淡蓝色回形针
  ctx.lineWidth = 2 * RESOLUTION_SCALE * CANVAS_SCALE;

  ctx.beginPath();
  // 回形针形状
  ctx.moveTo(clipX, clipY);
  ctx.lineTo(clipX, clipY + clipHeight);
  ctx.lineTo(clipX + clipWidth, clipY + clipHeight);
  ctx.lineTo(clipX + clipWidth, clipY + clipHeight / 3);
  ctx.lineTo(clipX + clipWidth / 2, clipY + clipHeight / 3);
  ctx.lineTo(clipX + clipWidth / 2, clipY + (clipHeight * 2) / 3);
  ctx.lineTo(clipX + clipWidth, clipY + (clipHeight * 2) / 3);
  ctx.lineTo(clipX + clipWidth, clipY);
  ctx.stroke();

  // 添加底部连接线 - 铅笔形状
  const pencilTopX = canvas.width / 2;
  const pencilTopY = noteY + noteHeight;
  const pencilLength = 35 * RESOLUTION_SCALE * CANVAS_SCALE;
  const pencilWidth = 6 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 铅笔杆
  ctx.fillStyle = '#FFD15C'; // 黄色铅笔
  ctx.beginPath();
  ctx.moveTo(pencilTopX - pencilWidth / 2, pencilTopY);
  ctx.lineTo(pencilTopX + pencilWidth / 2, pencilTopY);
  ctx.lineTo(
    pencilTopX + pencilWidth / 2,
    pencilTopY + pencilLength - pencilWidth
  );
  ctx.lineTo(
    pencilTopX - pencilWidth / 2,
    pencilTopY + pencilLength - pencilWidth
  );
  ctx.closePath();
  ctx.fill();

  // 铅笔尖
  ctx.fillStyle = '#FF7043'; // 红褐色铅笔芯
  ctx.beginPath();
  ctx.moveTo(
    pencilTopX - pencilWidth / 2,
    pencilTopY + pencilLength - pencilWidth
  );
  ctx.lineTo(
    pencilTopX + pencilWidth / 2,
    pencilTopY + pencilLength - pencilWidth
  );
  ctx.lineTo(pencilTopX, pencilTopY + pencilLength);
  ctx.closePath();
  ctx.fill();

  // 铅笔橡皮
  ctx.fillStyle = '#FF94C5'; // 粉色橡皮
  ctx.beginPath();
  ctx.rect(
    pencilTopX - pencilWidth / 2,
    pencilTopY,
    pencilWidth,
    -pencilWidth * 1.5
  );
  ctx.fill();

  // 添加文本
  // 使用圆润可爱的字体
  ctx.font = `bold ${fontSizeScaled}px 'Comic Sans MS', 'Arial Rounded MT Bold', sans-serif`;
  ctx.fillStyle = color;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // 文本换行处理
  const maxWidth = noteWidth - 40 * RESOLUTION_SCALE * CANVAS_SCALE;
  const lineHeight = fontSizeScaled * 1.2;
  const lines = wrapText(ctx, textContent, maxWidth);

  // 计算所有行文本的总高度
  const totalTextHeight = lines.length * lineHeight;

  // 文本在笔记本区域居中
  const startY = noteY + (noteHeight - totalTextHeight) / 2 + lineHeight / 2;

  // 添加可爱的手写效果
  // 使用轻微抖动来模拟手写感
  lines.forEach((line, i) => {
    const y = startY + i * lineHeight;
    const centerX =
      noteX + noteWidth / 2 + 10 * RESOLUTION_SCALE * CANVAS_SCALE;

    // 添加轻微阴影
    ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
    ctx.shadowBlur = 1 * RESOLUTION_SCALE * CANVAS_SCALE;
    ctx.shadowOffsetX = 0.5 * RESOLUTION_SCALE * CANVAS_SCALE;
    ctx.shadowOffsetY = 0.5 * RESOLUTION_SCALE * CANVAS_SCALE;

    // 绘制稍微倾斜的文本
    ctx.save();
    ctx.translate(centerX, y);
    ctx.rotate(Math.PI / 60); // 轻微倾斜
    ctx.fillText(line, 0, 0);
    ctx.restore();

    // 重置阴影
    ctx.shadowColor = 'transparent';
    ctx.shadowBlur = 0;
    ctx.shadowOffsetX = 0;
    ctx.shadowOffsetY = 0;
  });

  return canvas;
}

创建一个侠客风格的文本内容

这里定义一个 createTechCanvas 方法用于创建一个古代侠客风格的文本样式

js 复制代码
/**
 * 创建侠客风格canvas (中国古代侠客风)
 * @param textOptions 文本选项
 * @returns canvas
 */
function createTechCanvas(textOptions?: TextOptions) {
  const {
    textContent = '默认文本',
    color = '#fff',
    fontSize = 16,
  } = textOptions || {};
  // 创建固定大小的canvas和上下文
  const canvas = document.createElement('canvas');
  canvas.width = 200 * RESOLUTION_SCALE * CANVAS_SCALE;
  canvas.height = 120 * RESOLUTION_SCALE * CANVAS_SCALE;
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
  const fontSizeScaled = fontSize * RESOLUTION_SCALE; // 使用传入的字体大小乘以分辨率因子

  // 清除背景
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 主内容区域参数
  const contentX = 10 * RESOLUTION_SCALE * CANVAS_SCALE;
  const contentY = 10 * RESOLUTION_SCALE * CANVAS_SCALE;
  const contentWidth = canvas.width - 20 * RESOLUTION_SCALE * CANVAS_SCALE;
  const contentHeight = 70 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 绘制卷轴背景
  const scrollGradient = ctx.createLinearGradient(
    contentX,
    contentY,
    contentX,
    contentY + contentHeight
  );
  scrollGradient.addColorStop(0, 'rgba(240, 230, 210, 0.9)'); // 纸色淡黄
  scrollGradient.addColorStop(0.1, 'rgba(235, 225, 205, 0.9)');
  scrollGradient.addColorStop(0.9, 'rgba(220, 210, 190, 0.9)');
  scrollGradient.addColorStop(1, 'rgba(210, 200, 180, 0.9)');

  // 绘制卷轴主体(略微不规则,增加手工感)
  ctx.beginPath();
  ctx.moveTo(contentX, contentY + 4 * RESOLUTION_SCALE * CANVAS_SCALE);
  ctx.bezierCurveTo(
    contentX + contentWidth * 0.1,
    contentY,
    contentX + contentWidth * 0.9,
    contentY,
    contentX + contentWidth,
    contentY + 4 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.lineTo(
    contentX + contentWidth,
    contentY + contentHeight - 4 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.bezierCurveTo(
    contentX + contentWidth * 0.9,
    contentY + contentHeight,
    contentX + contentWidth * 0.1,
    contentY + contentHeight,
    contentX,
    contentY + contentHeight - 4 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.closePath();

  // 填充卷轴底色
  ctx.fillStyle = scrollGradient;
  ctx.fill();

  // 增加些微纹理(模拟宣纸肌理)
  for (let i = 0; i < 40; i++) {
    const x = contentX + Math.random() * contentWidth;
    const y = contentY + Math.random() * contentHeight;
    const size = Math.random() * 1.5 + 0.5;

    ctx.beginPath();
    ctx.arc(x, y, size, 0, Math.PI * 2);
    ctx.fillStyle = `rgba(0, 0, 0, ${Math.random() * 0.03 + 0.01})`;
    ctx.fill();
  }

  // 添加上下两端卷轴木轴
  const rodHeight = 6 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 上端木轴
  ctx.beginPath();
  ctx.rect(
    contentX - 5 * RESOLUTION_SCALE * CANVAS_SCALE,
    contentY - rodHeight,
    contentWidth + 10 * RESOLUTION_SCALE * CANVAS_SCALE,
    rodHeight
  );
  const rodGradient = ctx.createLinearGradient(
    0,
    contentY - rodHeight,
    0,
    contentY
  );
  rodGradient.addColorStop(0, '#5a3d2b');
  rodGradient.addColorStop(0.5, '#8c6142');
  rodGradient.addColorStop(1, '#5a3d2b');
  ctx.fillStyle = rodGradient;
  ctx.fill();

  // 下端木轴
  ctx.beginPath();
  ctx.rect(
    contentX - 5 * RESOLUTION_SCALE * CANVAS_SCALE,
    contentY + contentHeight,
    contentWidth + 10 * RESOLUTION_SCALE * CANVAS_SCALE,
    rodHeight
  );
  const rodGradient2 = ctx.createLinearGradient(
    0,
    contentY + contentHeight,
    0,
    contentY + contentHeight + rodHeight
  );
  rodGradient2.addColorStop(0, '#5a3d2b');
  rodGradient2.addColorStop(0.5, '#8c6142');
  rodGradient2.addColorStop(1, '#5a3d2b');
  ctx.fillStyle = rodGradient2;
  ctx.fill();

  // 添加简单装饰 - 水墨风格的山水轮廓
  ctx.beginPath();
  const mountainBase =
    contentY + contentHeight - 10 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 简约山峰轮廓
  ctx.moveTo(contentX + 10 * RESOLUTION_SCALE * CANVAS_SCALE, mountainBase);
  ctx.lineTo(
    contentX + 20 * RESOLUTION_SCALE * CANVAS_SCALE,
    mountainBase - 15 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.lineTo(
    contentX + 30 * RESOLUTION_SCALE * CANVAS_SCALE,
    mountainBase - 5 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.lineTo(
    contentX + 45 * RESOLUTION_SCALE * CANVAS_SCALE,
    mountainBase - 25 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.lineTo(contentX + 60 * RESOLUTION_SCALE * CANVAS_SCALE, mountainBase);

  ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
  ctx.lineWidth = 1 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.stroke();

  // 右侧添加简约竹子
  const bambooX =
    contentX + contentWidth - 25 * RESOLUTION_SCALE * CANVAS_SCALE;
  const bambooY = contentY + 20 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 竹竿
  ctx.beginPath();
  ctx.moveTo(bambooX, bambooY);
  ctx.lineTo(bambooX, bambooY + 40 * RESOLUTION_SCALE * CANVAS_SCALE);
  ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)';
  ctx.lineWidth = 1.5 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.stroke();

  // 竹节
  for (let i = 0; i < 3; i++) {
    ctx.beginPath();
    const nodeY =
      bambooY +
      10 * RESOLUTION_SCALE * CANVAS_SCALE +
      i * 12 * RESOLUTION_SCALE * CANVAS_SCALE;
    ctx.moveTo(bambooX - 2 * RESOLUTION_SCALE * CANVAS_SCALE, nodeY);
    ctx.lineTo(bambooX + 2 * RESOLUTION_SCALE * CANVAS_SCALE, nodeY);
    ctx.stroke();
  }

  // 几片简单的竹叶
  ctx.beginPath();
  ctx.moveTo(bambooX, bambooY + 10 * RESOLUTION_SCALE * CANVAS_SCALE);
  ctx.quadraticCurveTo(
    bambooX - 10 * RESOLUTION_SCALE * CANVAS_SCALE,
    bambooY + 5 * RESOLUTION_SCALE * CANVAS_SCALE,
    bambooX - 20 * RESOLUTION_SCALE * CANVAS_SCALE,
    bambooY + 10 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.strokeStyle = 'rgba(0, 0, 0, 0.4)';
  ctx.lineWidth = 0.8 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.stroke();

  ctx.beginPath();
  ctx.moveTo(bambooX, bambooY + 20 * RESOLUTION_SCALE * CANVAS_SCALE);
  ctx.quadraticCurveTo(
    bambooX - 12 * RESOLUTION_SCALE * CANVAS_SCALE,
    bambooY + 15 * RESOLUTION_SCALE * CANVAS_SCALE,
    bambooX - 18 * RESOLUTION_SCALE * CANVAS_SCALE,
    bambooY + 22 * RESOLUTION_SCALE * CANVAS_SCALE
  );
  ctx.stroke();

  // 添加印章/落款
  const sealSize = 12 * RESOLUTION_SCALE * CANVAS_SCALE;
  const sealX = contentX + contentWidth - 20 * RESOLUTION_SCALE * CANVAS_SCALE;
  const sealY = contentY + contentHeight - 15 * RESOLUTION_SCALE * CANVAS_SCALE;

  ctx.beginPath();
  ctx.rect(sealX - sealSize / 2, sealY - sealSize / 2, sealSize, sealSize);
  ctx.fillStyle = 'rgba(170, 0, 0, 0.7)';
  ctx.fill();

  // 印章内简单纹路
  ctx.beginPath();
  ctx.moveTo(sealX - sealSize / 3, sealY - sealSize / 3);
  ctx.lineTo(sealX + sealSize / 3, sealY + sealSize / 3);
  ctx.moveTo(sealX + sealSize / 3, sealY - sealSize / 3);
  ctx.lineTo(sealX - sealSize / 3, sealY + sealSize / 3);
  ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
  ctx.lineWidth = 0.8 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.stroke();

  // 添加连接线 - 模拟卷轴悬挂的绳索
  const ropeX = canvas.width / 2;
  const ropeTopY = contentY + contentHeight + rodHeight;
  const ropeBottomY = canvas.height - 10 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.beginPath();
  ctx.moveTo(ropeX, ropeTopY);
  ctx.lineTo(ropeX, ropeBottomY);
  ctx.strokeStyle = '#8c6142';
  ctx.lineWidth = 1 * RESOLUTION_SCALE * CANVAS_SCALE;
  ctx.stroke();
  // 绳索末端的流苏装饰
  const tassleWidth = 8 * RESOLUTION_SCALE * CANVAS_SCALE;
  const tassleHeight = 6 * RESOLUTION_SCALE * CANVAS_SCALE;

  ctx.beginPath();
  ctx.rect(ropeX - tassleWidth / 2, ropeBottomY, tassleWidth, tassleHeight);
  const tassleGradient = ctx.createLinearGradient(
    ropeX - tassleWidth / 2,
    ropeBottomY,
    ropeX + tassleWidth / 2,
    ropeBottomY
  );
  tassleGradient.addColorStop(0, '#8c1616');
  tassleGradient.addColorStop(1, '#b32d2d');
  ctx.fillStyle = tassleGradient;
  ctx.fill();
  // 添加装饰性线条
  for (let i = 0; i < 3; i++) {
    const lineY =
      ropeBottomY + tassleHeight + i * 1.5 * RESOLUTION_SCALE * CANVAS_SCALE;
    ctx.beginPath();
    ctx.moveTo(ropeX - tassleWidth / 2, lineY);
    ctx.lineTo(ropeX + tassleWidth / 2, lineY);
    ctx.strokeStyle = '#8c1616';
    ctx.lineWidth = 0.5 * RESOLUTION_SCALE * CANVAS_SCALE;
    ctx.stroke();
  }
  // 设置毛笔字风格 - 提前设置字体,确保wrapText函数能正确计算文本宽度
  ctx.font = `${fontSizeScaled}px "KaiTi", "楷体", serif`;
  ctx.fillStyle = color;
  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  // 文本换行处理
  const maxWidth = contentWidth - 40 * RESOLUTION_SCALE * CANVAS_SCALE; // 留出竹子和印章的空间
  const lineHeight = fontSizeScaled * 1.4;
  const lines = wrapText(ctx, textContent, maxWidth);

  // 计算文本总高度并限制在卷轴区域内
  const totalTextHeight = Math.min(
    lines.length * lineHeight,
    contentHeight - 20 * RESOLUTION_SCALE * CANVAS_SCALE
  );

  // 文本垂直居中
  const startY =
    contentY +
    (contentHeight - totalTextHeight) / 2 +
    5 * RESOLUTION_SCALE * CANVAS_SCALE;

  // 绘制每一行文本
  lines.forEach((line, i) => {
    if (
      i <
      Math.floor(
        (contentHeight - 20 * RESOLUTION_SCALE * CANVAS_SCALE) / lineHeight
      )
    ) {
      const y = startY + i * lineHeight;
      ctx.fillText(
        line,
        contentX + contentWidth / 2 - 10 * RESOLUTION_SCALE * CANVAS_SCALE,
        y
      );
    }
  });
  return canvas;
}

结语

ok, 以上就是作者个人总结出在three.js创建几个不同样式文本的方法,如果你有更好的方式欢迎留言沟通

相关推荐
wwy_frontend16 分钟前
useState 的 9个常见坑与最佳实践
前端·react.js
w_y_fan17 分钟前
flutter_riverpod: ^2.6.1 应用笔记 (一)
前端·flutter
Jerry19 分钟前
Compose 界面工具包
前端
Focusbe20 分钟前
从0到1开发一个AI助手
前端·人工智能·面试
egghead2631620 分钟前
React组件通信
前端·react.js
RIKA21 分钟前
【前端工具】使用 Node.js 脚本实现项目打包后自动压缩
前端
橙某人28 分钟前
🖼️照片展示新境界!等高不等宽自适应布局完整教程⚡⚡⚡
前端·javascript·css
咕噜分发企业签名APP加固彭于晏30 分钟前
市面上有多少智能体平台
前端·后端
尝尝你的优乐美31 分钟前
man!在console中随心所欲的打印图片和字符画
前端·javascript·vue.js
一个专注api接口开发的小白1 小时前
Python/Node.js 调用taobao API:构建实时商品详情数据采集服务
前端·数据挖掘·api