07.图元之 3D 文字

07 图元之 3D 文字

在 Three.js 所有内置的图元中,TextBufferGeometry 是最为特殊的一个。

**特殊之处在于:在使用 TextBufferGeometry 创建 文字几何对象之前,需要先加载 3D 字体数据。 **

字体数据文件通常为 .json 文件,Three.js 提供了一个专门负责加载字体数据的类:FontLoader

由于需要加载外部字体数据文件,所以创建 3D 文字这个过程是异步的。

字体数据的补充说明:

  1. 字体数据 准确来说是描述字体轮廓的
  2. 字体数据 究竟包含哪些字符由 制作 3D 软件决定的,例如有些字体数据只针对字母,并不支持汉字。
  3. 若某个字符并不包含在 字体数据中,那么 Three.js 会将该字符替换为 问号(?)

我们暂且先不考虑 字体数据文件 是如何在第 3 方 3D 软件中创建、导出的,先看一下如何加载字体数据文件。

FontLoader 用法分析

FontLoader:

我先看一下 FontLoader.d.ts 的内容:

这是本系列文章 第一次 从 .d.ts 文件角度来分析、推理 某个类的用法。

这也体现了使用 TypeScript 的好处,你可以随时去查看对应的 .d.ts 文件,去查看各种类的具体的使用方法

import { Loader } from "./Loader";
import { LoadingManager } from "./LoadingManager";
import { Font } from "./../extras/core/Font";

export class FontLoader extends Loader {
  constructor(manager?: LoadingManager);

  load(
    url: string,
    onLoad?: (responseFont: Font) => void,
    onProgress?: (event: ProgressEvent) => void,
    onError?: (event: ErrorEvent) => void
  ): void;
  parse(json: any): Font;
}

从上面可以看出:

  1. FontLoader 继承于 Loader

    不难想象,在 Three.js 中一定还有负责加载其他资源类型的 Loader

  2. 构造函数接收一个 LoadingManager 实例

  3. 方法 load( url, onLoad, onProgress, onError ),从字面上就能推测出:

    1. url:资源加载地址
    2. onLoad:加载完成后,触发的事件回调函数
    3. onProgress:加载过程中,触发的事件回调函数
    4. onError:加载失败,触发的事件回调函数
  4. 方法 parse( json ) ,用来解析 JSON 数据,并返回 Font 实例

延展说明:

FontLoader 中牵扯到了另外 3 个类:Loader、LoadingManager、Font。

Loader 和 LoadingManager 内部封装了加载和解析数据的过程,我们暂时不用深究他们的源码和用法,接下来重点看一下 Font。

Font:

import { Shape } from './Shape';

export class Font {

	constructor( jsondata: any );

	/**
	 * @default 'Font'
	 */
	type: string;

	data: string;

	generateShapes( text: string, size: number ): Shape[];

}

从上面可以看出:

  1. Font 类是将 原始的字体数据 从 JSON 转化为 Three.js 内部可识别的 字体数据。

  2. Font 构造函数接收的参数就是 JSON 数据

  3. 属性 type 默认值为 ‘Font’

  4. 属性 data 数据类型为字符串,我猜出 data 就是用来保存构造函数中 jsondata 数据的

  5. 方法 generateShapes( text, size ): Shape[],根据参数来生成所有的 形状(shape)

    Shape 这个类在前面示例中使用过多次,shape 单词的本意就是 形状

    Shape[] 表示这是一个 元祖数组,数组的每一个元素都必须是 Shape 实例

至此,对于 FontLoader、Font 已有大致了解,接下来该去尝试如何使用他们了。

使用 FontLoader 加载字体数据

我们使用 FontLoader 加载线上的一个字体数据:https://threejsfundamentals.org/threejs/resources/threejs/fonts/helvetiker_regular.typeface.json

示例 1:使用基础的方式进行加载

const loader = new FontLoader()
const url = 'https://threejsfundamentals.org/threejs/resources/threejs/fonts/helvetiker_regular.typeface.json'

const onLoadHandle = (responseFont: Font) => {
    console.log(responseFont)
}
const onProgressHandle = (event: ProgressEvent<EventTarget>) => {
    console.log(event)
}
const onErrorHandle = (error: ErrorEvent) => {
    console.log(error)
}

loader.load(url, onLoadHandle, onProgressHandle, onErrorHandle)

以上代码中,采用最原始,基础的方式来加载 字体数据。

字体数据加载完成对应的 onLoadHandle 处理函数中,可以放置后续的操作。

示例 2:使用 async/await 封装加载过程

我们封装的目标:将异步加载过程封装好,然后就可以像写同步代码一样去获取异步结果。

首先分析一下 示例 1 中几个关键点:

  1. new FontLoader() 实例化一个 加载器
  2. url:加载地址
  3. onLoadHandle、onProgressHandle、onErrorHandle 3 个加载事件处理函数

封装思路分析:

  1. 实现方式肯定使用 promise + async/awiat

  2. promise 中的 resolve 刚好对应 onLoadHandle

  3. promise 中的 reject 刚好对应 onErrorHandle

  4. 至于加载过程 onProgressHandle,我们基本用不到他,所以直接选择忽略该回到函数

    届时我们会传递一个 undefined 来替代 onProgressHandle

封装加载过程:

const loadFont: (url: string) => Promise<Font> = (url) => {
    const loader = new FontLoader()
    return new Promise((resolve, reject: (error: ErrorEvent) => void) => {
        loader.load(url, resolve, undefined, reject)
    })
}

只有在 async 函数中才可以使用到 Promise,所以我们还需要定义以下函数:

const createText = async () => {

    const url = 'https://threejsfundamentals.org/threejs/resources/threejs/fonts/helvetiker_regular.typeface.json'

    const font = await loadFont(url) //请注意这行代码,我们可以想使用同步编写的方式,获取到 字体数据

    //开始创建 3D 字体 几何对象
    ...
}

createText()

改造我们之前写的 HelloPrimitives

改造原因:

  1. 由于 TextBufferGeometry 创建过程为异步,async/await 具有函数异步传染性,因此我们需要将 index.tsx 中的代码也修改成异步
  2. 之前 index.tsx 中 useEffect( … ) 内容稍显复杂,我们特意将其中 随机生成材质、获得摆放位置 的响应代码从 useEffect 中提取出来,放到外部。

my-text.ts

import { Font, FontLoader, Mesh, Object3D, TextBufferGeometry } from "three";
import { createMaterial } from './index'

const loadFont: (url: string) => Promise<Font> = (url) => {
    const loader = new FontLoader()
    return new Promise((resolve, reject: (error: ErrorEvent) => void) => {
        loader.load(url, resolve, undefined, reject)
    })
}

const createText = async () => {

    const url = 'https://threejsfundamentals.org/threejs/resources/threejs/fonts/helvetiker_regular.typeface.json'

    const font = await loadFont(url) //异步加载 字体数据

    //第一个参数 'puxiao' 可以替换成任何其他的英文字母
    //特别注意:由于目前我们加载的 字体数据 只是针对英文字母的字体轮廓描述,并没有包含中文字体轮廓
    //所以如果设置成 汉字,则场景无法正常渲染出文字
    //对于无法渲染的字符,会被渲染成 问号(?) 作为替代
    //第二个参数对应的是文字外观配置
    const geometry = new TextBufferGeometry('puxiao', {
        font: font,
        size: 3.0,
        height: .2,
        curveSegments: 12,
        bevelEnabled: true,
        bevelThickness: 0.15,
        bevelSize: .3,
        bevelSegments: 5,
    })

    const mesh = new Mesh(geometry, createMaterial())

    //Three.js默认是以文字左侧为中心旋转点,下面的代码是将文字旋转点位置改为文字中心
    //实现的思路是:用文字的网格去套进另外一个网格,通过 2 个网格之间的落差来实现将旋转中心点转移到文字中心位置
    //具体代码细节,会在以后 场景 中详细学习,此刻你只需要照着以下代码敲就可以
    geometry.computeBoundingBox()
    geometry.boundingBox?.getCenter(mesh.position).multiplyScalar(-1)

    const text = new Object3D()
    text.add(mesh)

    return text
}

export default createText

index.tsx

import { useRef, useEffect, useCallback } from "react";
import * as Three from "three";

import "./index.scss";

import myBox from "./my-box";
import myCircle from "./my-circle";
import myCone from "./my-cone";
import myCylinder from "./my-cylinder";
import myDodecahedron from "./my-dodecahedron";
import myEdges from "./my-edges";
import myExtrude from "./my-extrude";
import myIcosahedron from "./my-icosahedron";
import myLathe from "./my-lathe";
import myOctahedron from "./my-octahedron";
import myParametric from "./my-parametric";
import myPlane from "./my-plane";
import myPolyhedron from "./my-polyhedron";
import myRing from "./my-ring";
import myShape from "./my-shape";
import mySphere from "./my-sphere";
import myTetrahedron from "./my-tetrahedron";
import myTorus from "./my-torus";
import myTorusKnot from "./my-torus-knot";
import myTube from "./my-tube";
import myWireframe from "./my-wireframe";
import createText from "./my-text";

const meshArr: (Three.Mesh | Three.LineSegments | Three.Object3D)[] = []; //保存所有图形的元数组

export const createMaterial = () => {
  const material = new Three.MeshPhongMaterial({ side: Three.DoubleSide });

  const hue = Math.floor(Math.random() * 100) / 100; //随机获得一个色相
  const saturation = 1; //饱和度
  const luminance = 0.5; //亮度

  material.color.setHSL(hue, saturation, luminance);

  return material;
};

//定义物体在画面中显示的网格布局
const eachRow = 5; //每一行显示 5 个
const spread = 15; //行高 和 列宽

const getPositionByIndex = (index: number) => {
  //我们设定的排列是每行显示 eachRow,即 5 个物体、行高 和 列宽 均为 spread 即 15
  //因此每个物体根据顺序,计算出自己所在的位置
  const row = Math.floor(index / eachRow); //计算出所在行
  const column = index % eachRow; //计算出所在列

  const x = (column - 2) * spread; //为什么要 -2 ?
  //因为我们希望将每一行物体摆放的单元格,依次是:-2、-1、0、1、2,这样可以使每一整行物体处于居中显示
  const y = (2 - row) * spread;

  return { x, y };
};

const HelloPrimitives = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const rendererRef = useRef<Three.WebGLRenderer | null>(null);
  const cameraRef = useRef<Three.PerspectiveCamera | null>(null);

  const createInit = useCallback(async () => {
    if (canvasRef.current === null) {
      return;
    }

    meshArr.length = 0; //以防万一,先清空原有数组

    //初始化场景
    const scene = new Three.Scene();
    scene.background = new Three.Color(0xaaaaaa);

    //初始化镜头
    const camera = new Three.PerspectiveCamera(40, 2, 0.1, 1000);
    camera.position.z = 120;
    cameraRef.current = camera;

    //初始化渲染器
    const renderer = new Three.WebGLRenderer({
      canvas: canvasRef.current as HTMLCanvasElement,
    });
    rendererRef.current = renderer;

    //添加 2 盏灯光
    const light0 = new Three.DirectionalLight(0xffffff, 1);
    light0.position.set(-1, 2, 4);
    scene.add(light0);

    const light1 = new Three.DirectionalLight(0xffffff, 1);
    light0.position.set(1, -2, -4);
    scene.add(light1);

    //获得各个 solid 类型的图元实例,并添加到 solidPrimitivesArr 中
    const solidPrimitivesArr: Three.BufferGeometry[] = [];
    solidPrimitivesArr.push(
      myBox,
      myCircle,
      myCone,
      myCylinder,
      myDodecahedron
    );
    solidPrimitivesArr.push(
      myExtrude,
      myIcosahedron,
      myLathe,
      myOctahedron,
      myParametric
    );
    solidPrimitivesArr.push(myPlane, myPolyhedron, myRing, myShape, mySphere);
    solidPrimitivesArr.push(myTetrahedron, myTorus, myTorusKnot, myTube);

    //将各个 solid 类型的图元实例转化为网格,并添加到 primitivesArr 中
    solidPrimitivesArr.forEach((item) => {
      const material = createMaterial(); //随机获得一种颜色材质
      const mesh = new Three.Mesh(item, material);
      meshArr.push(mesh); //将网格添加到网格数组中
    });

    //创建 3D 文字,并添加到 mesArr 中,请注意此函数为异步函数
    meshArr.push(await createText());

    //获得各个 line 类型的图元实例,并添加到 meshArr 中
    const linePrimitivesArr: Three.BufferGeometry[] = [];
    linePrimitivesArr.push(myEdges, myWireframe);

    //将各个 line 类型的图元实例转化为网格,并添加到 meshArr 中
    linePrimitivesArr.forEach((item) => {
      const material = new Three.LineBasicMaterial({ color: 0x000000 });
      const mesh = new Three.LineSegments(item, material);
      meshArr.push(mesh);
    });

    //配置每一个图元实例,转化为网格,并位置和材质后,将其添加到场景中
    meshArr.forEach((mesh, index) => {
      const { x, y } = getPositionByIndex(index);

      mesh.position.x = x;
      mesh.position.y = y;

      scene.add(mesh); //将网格添加到场景中
    });

    //添加自动旋转渲染动画
    const render = (time: number) => {
      time = time * 0.001;
      meshArr.forEach((item) => {
        item.rotation.x = time;
        item.rotation.y = time;
      });

      renderer.render(scene, camera);
      window.requestAnimationFrame(render);
    };
    window.requestAnimationFrame(render);
  }, [canvasRef]);

  const resizeHandle = () => {
    //根据窗口大小变化,重新修改渲染器的视椎
    if (rendererRef.current === null || cameraRef.current === null) {
      return;
    }

    const canvas = rendererRef.current.domElement;
    cameraRef.current.aspect = canvas.clientWidth / canvas.clientHeight;
    cameraRef.current.updateProjectionMatrix();
    rendererRef.current.setSize(canvas.clientWidth, canvas.clientHeight, false);
  };

  //组件首次装载到网页后触发,开始创建并初始化 3D 场景
  useEffect(() => {
    createInit();
    resizeHandle();
    window.addEventListener("resize", resizeHandle);
    return () => {
      window.removeEventListener("resize", resizeHandle);
    };
  }, [canvasRef, createInit]);

  return <canvas ref={canvasRef} className="full-screen" />;
};

export default HelloPrimitives;

特别提醒:虽然针对 index.tsx 进行了修改,但是并不影响之前创建的其他图元,其他图元并不需要修改任何代码。

你是否想也赶紧自己去创建一份可以显示中文的 3D 字体数据?
这需要你会一些 3D 软件,例如 C4D(收费软件)、blender(免费开源软件)
在后续的学习中,一定会涉及到自定义字体样式、自定义几何图形,自己建模的,目前主要任务还是先系统学习 Three.js。

至此,Three.js 中内置的 22 种图元,均逐一尝试完毕。

同时也意味着,我们 Three.js 的 hello world 之旅完成,通过 HelloThreejs、HelloPrimitives,我们该体验的代码也都体验过了。

接下来就要逐个开始深入、详细学习 具体的各个模块的用法。

加油!

上一页
下一页