原创 孙_华鹏 2024-11-19 08:30 重庆
点击关注公众号,“技术干货” 及时达!
本文主要讲解怎么将ttf文字文件转成threejs可用的json文件,从而通过加载器将指定文字的路径和顶点信息获取到,并且兼容孔洞的处理,既然有路径和顶点信息,可做的效果有很多,比如你可以做一个立体字,做一个霓虹灯,等等等等...,代码中提供了两种字体,和两种字体对应的精简版,你也可以找UI要一个比较可爱的字体替换成原文的字体效果~
另:考虑到每个人的电脑性能不一样,所以演示代码中不加发光效果,可以在下载后自己运行(本地运行默认开启发光特效)
众所周知,在网上下载的字体,都是ttf或者woff格式的,并不能直接给threejs用,所以就需要一个工具转成threejs的FontLoader支持的json文件,这里介绍一个工具:ttf_to_json,内含html文件,打开即用,双击打开后需要上传一个文件,如果想转全量字体,文件上传完毕之后点击convert按钮,等待下载,这样的话,文件比较大,如果是复杂的字体,文件能达到18MB,这时就需要选择,如果你只需要几个字而已,则可以通过下面的Restrict character set.选项去限制字符,这样转换出来的文件就会小很多,示例提供了4种字体文件,两款不同的字体,和他们的限制版,下面是工具样式的展示:
通过同居的转换,我们得到了一个json格式的文字数据,下面就来将字体加载到threejs中吧!
import { Font, FontLoader } from 'three/addons/loaders/FontLoader.js';// 字体加载器const loader = new FontLoader();// 加载字体const loadFont = (url: string): Promise<Font> => {return new Promise((res) => {loader.load(`${import.meta.env.VITE_ASSETS_URL}assets/font/${url}.json`, function (response: Font) {res(response)});})}
加载器的load方法支持4个回调,分别是url(文件路径),onLoad(加载完毕),onProgress(加载进度)和onError(加载失败),这些通过源码也可以了解到
load(url: string,onLoad?: (data: Font) => void,onProgress?: (event: ProgressEvent) => void,onError?: (err: unknown) => void,): void;
字体文件结构
其中onLoad的回调接受一个参数Font,这个是加载字体方法的实例,支持方法generateShapes,可以通过这个方法得到字体文件的形状Shapes,
generateShapes(text: string, size: number): Shape[];
第一个参数是字符,第二个参数是尺寸,就是加载后字体的尺寸,决定了形状的大小。
加载字体
所以根据Font提供的generateShapes方法,我们可以将指定字符并获得这个字符的形状
const shapes = font.generateShapes(t, 4);console.log('shapes',shapes);
看这结构眼熟不,加载svg文件也是得到的这些形状,接下来就是根据形状生成形状几何体ShapeGeometry,我们需要加几行代码,用于绘制形状const color = 0x006699;const matDark = new THREE.LineBasicMaterial({color: color,side: THREE.DoubleSide});for (let i = 0; i < shapes.length; i++) {const shape = shapes[i];const points = shape.getPoints();const geometry = new THREE.BufferGeometry().setFromPoints(points);const lineMesh = new THREE.Line(geometry, matDark);lineText.add(lineMesh);}
第一步遍历形状内的线段,第二步通过getPoints获取到线段的顶点信息,将顶点信息赋值给geometry,第三步根据线条的材质和顶点信息生成一个线段Line,于是得到了以下的文字
镂空点位
发现问题了么,我加载的是“猫” 字,得到的只有外框,没有孔洞的形状,所以需要进一步加工一下,将孔洞加载出来,在前面打印shapes时候是包含holes孔洞信息的。
所以我们需要单独将这些孔洞遍历一下并应用到shapes中,在遍历shapes之前,我们需要先获取到孔洞信息
for (let i = 0; i < shapes.length; i++) {const shape = shapes[i];if (shape.holes && shape.holes.length > 0) {for (let j = 0; j < shape.holes.length; j++) {const hole = shape.holes[j];holeShapes.push(hole);}}}// 将孔洞信息添加到shapes中shapes.push.apply(shapes, holeShapes);
通过这样的遍历,shapes就包含了外框的顶点信息和孔洞的信息,再去遍历shapes就会将孔洞的线也加载出来,看一下效果吧。
调整位置
当然,我们不能只加载一个文字,如果是多个字该怎么办?将generateShapes的第一个参数写成多个字?
对!哈哈哈
那我们以猫啃什锦黑这几个字作为示例搞一下
确实可以将多个字一起绘制成Line,那么还有一个问题,线条的原点是左下角,但是我想要的是居中的效果,这样比较好操作,什么?用position修改?也可以,那就需要通过box3来计算出线条的尺寸再用公式去改变position,相对我下面说的方法比较复杂,直接修改geometry,在前面遍历shapes用来生成线段的时候就进行修改,通过 geometry.translate去修改形状的偏移量
首先需要获取偏移量的具体数值,主要通过BufferGeometry中的boundingBox属性去获取尺寸,因为需要获取文字整体的尺寸来计算偏移量,所以我们需要计算shapes所有的顶点。在遍历它之前
// 根据形状获得几何体const geometry = new THREE.ShapeGeometry(shapes);// 更新几何体边界geometry.computeBoundingBox();// 获取几何体尺寸 目的是居中const xMid = - 0.5 * ((geometry?.boundingBox?.max?.x || 0) - (geometry?.boundingBox?.min.x || 0));const yMid = - 0.5 * ((geometry?.boundingBox?.max?.y || 0) - (geometry?.boundingBox?.min.y || 0));
调用computeBoundingBox是必须的,不然的话geometry?.boundingBox是默认值null,获取到shapes的x和y轴的偏移量就要在遍历的时候去应用了:
for (let i = 0; i < shapes.length; i++) {...let offset = new THREE.Vector3(xMid, yMid, 0)const geometry = new THREE.BufferGeometry().setFromPoints(points);geometry.translate(offset.x, offset.y, offset.z);...}
这样的话,文字整体就在整个坐标系的正中间了。
调整相机
现在文字已经加载到坐标系的中心,但是还有一个问题,为了适配更多文字的显示,相机不能是固定的角度去观察视图,这样会导致文字太多会显示不下,文字太小或者太少,又显得视图很空,所以需要动态调整,又两种方式可以去修改相机的视角,代码里的方法是根据条件修改fov(摄像机视锥体垂直视野角度)可以参考官网-常见问题-如何在窗口调整大小时保持场景比例不变?。这里给的方案是屏幕尺寸改变后怎么调整fov,同理咱们的内容修改了,也可以参照这个方法,只不过不是调整当前屏幕尺寸和修改前屏幕尺寸的比例,文中修改的是文字的宽度和上次文字宽度的比例,let lastHeight = 27.339999496936798这个数字是在fov为45,文字单个尺寸为4时,固定猫啃什锦黑这几个文字的尺寸,在保证这个尺寸的文字能完全在视图中显示为准,做一个标准数据,下面文字不管多少,都按照这个统一尺寸去衡量,
camera.aspect = window.innerWidth / window.innerHeight;camera.fov = ( 360 / Math.PI ) * Math.atan( tanFOV * ( (xMid * 2) / lastHeight ) );camera.updateProjectionMatrix();
第一行:修改屏幕比例的代码,其实可以去掉第二行:根据初始尺寸lastHeight和当前文字尺寸做的比例,tanFOV是初始fov比例,默认为tanFOV = Math.tan(((Math.PI / 180) * camera.fov / 2));,第三行:更新相机配置
支持动态输入文字
现在我们来写一个input输入框,在点击按钮的时候获取到输入的内容并绘制到canvas里,
<div class="input-btn"><form action="#"><input type="text" id="text" /><br /><input type="submit" value="提交" /></form></div>
var form = document.querySelector('form');if(form) {form.addEventListener('submit', function (e) {e.preventDefault();var text =( document.getElementById('text') as any)?.value;// alert("你输入的内容是:" + text);createText(text)});}
通过input获得到text后通过封装好的createText 方法进行绘制,大概如下效果
在初始文字后重新渲染了一个文字,文字也绘制出来了,相机fov也调整了,那么问题来了,前面的文字没有清空,lineText.removeFromParent()添加这行代码,就将原有的组清空掉,再新建一个组添加到scene中,lineText是用来存放绘制好的文字线段的,直接删除就可以了。
飞线的代码在源码中src/utils/fly.ts文件中,具体讲解在之前的文章threejs开发可视化数字城市效果,源码在gitee上,可以自行下载克隆
let Fly = new TFly()实例化构造函数后,需要在render方法中调用update方法,Fly.upDate(),使用也是老少皆宜,调用setFly方法,传入相应参数即可
const createFly = (points: THREE.Vector3[]) => {const flyGroup = Fly.setFly({index: 0,num: Math.floor(points.length * .2),points: points,spaced: 1000, // 要将曲线划分为的分段数。默认是 5starColor: new THREE.Color(color),endColor: new THREE.Color(color),size: 0.2})flyLineGroup.add(flyGroup)}
参数讲解
interface SetFly {index: number, // 截取起点num: number, // 截取长度 // 要小于lengthpoints: Vector3[],spaced: number // 控制速度,要将曲线划分为的分段数。默认是 5starColor: Color,endColor: Color,size: number, // 飞线顶端顶点尺寸,后面会依次变小}
参照[threejs渲染高级感可视化涡轮模型](https://juejin.cn/post/7301486808236130345)一文,内部详细讲解。
发光效果
点击关注公众号,“技术干货” 及时达!
