07.图元之 3D 文字
07 图元之 3D 文字
在 Three.js 所有内置的图元中,TextBufferGeometry 是最为特殊的一个。
**特殊之处在于:在使用 TextBufferGeometry 创建 文字几何对象之前,需要先加载 3D 字体数据。 **
字体数据文件通常为 .json 文件,Three.js 提供了一个专门负责加载字体数据的类:FontLoader
由于需要加载外部字体数据文件,所以创建 3D 文字这个过程是异步的。
字体数据的补充说明:
- 字体数据 准确来说是描述字体轮廓的
- 字体数据 究竟包含哪些字符由 制作 3D 软件决定的,例如有些字体数据只针对字母,并不支持汉字。
- 若某个字符并不包含在 字体数据中,那么 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;
}
从上面可以看出:
-
FontLoader 继承于 Loader
不难想象,在 Three.js 中一定还有负责加载其他资源类型的 Loader
-
构造函数接收一个 LoadingManager 实例
-
方法 load( url, onLoad, onProgress, onError ),从字面上就能推测出:
- url:资源加载地址
- onLoad:加载完成后,触发的事件回调函数
- onProgress:加载过程中,触发的事件回调函数
- onError:加载失败,触发的事件回调函数
-
方法 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[];
}
从上面可以看出:
-
Font 类是将 原始的字体数据 从 JSON 转化为 Three.js 内部可识别的 字体数据。
-
Font 构造函数接收的参数就是 JSON 数据
-
属性 type 默认值为 ‘Font’
-
属性 data 数据类型为字符串,我猜出 data 就是用来保存构造函数中 jsondata 数据的
-
方法 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 中几个关键点:
- new FontLoader() 实例化一个 加载器
- url:加载地址
- onLoadHandle、onProgressHandle、onErrorHandle 3 个加载事件处理函数
封装思路分析:
-
实现方式肯定使用 promise + async/awiat
-
promise 中的 resolve 刚好对应 onLoadHandle
-
promise 中的 reject 刚好对应 onErrorHandle
-
至于加载过程 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
改造原因:
- 由于 TextBufferGeometry 创建过程为异步,async/await 具有函数异步传染性,因此我们需要将 index.tsx 中的代码也修改成异步
- 之前 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,我们该体验的代码也都体验过了。
接下来就要逐个开始深入、详细学习 具体的各个模块的用法。
加油!