08.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,我们该体验的代码也都体验过了。

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

加油!