08.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,我们该体验的代码也都体验过了。
接下来就要逐个开始深入、详细学习 具体的各个模块的用法。
加油!