1. three.js 字体加载慢的真实原因与场景拆解
做 WebGL 可视化或者 3D 场景的朋友,大概率都遇到过这个场景:项目里用TextGeometry或者FontLoader加载中文标题,一个ttf字体动辄 8MB 到 20MB,首屏白屏时间直接飙到十几秒,Network 面板里那条字体请求像一根柱子杵在那里。three.js 本身不会帮你做字体子集化,它只负责把json字形数据解析成几何体,字体文件多大,浏览器就得老老实实下载多大。
问题的本质是:字体文件里 99% 的字形你根本用不到。一个中文字体包含两三万个汉字,而你的 3D 场景可能只需要「智慧园区」「数据大屏」这十几个字。所以核心思路就是字形子集化——从完整字体里抽出项目实际用到的字符,重新生成一个体积只有几十 KB 的字体文件。
这条链路通常是:ttf完整字体 → 抽取字符子集 → 转成svg便于处理 → 再转回精简ttf→ 最终转成 three.js 能直接用的json。每一步都涉及格式转换,如果每个环节都去不同的在线工具或者本地脚本折腾,Key 管理、接口调用、格式兼容会非常碎。我这次的做法是用 TaoToken 统一 Key 把「字形提取 + 格式转换」的 API 调用串起来,本地只保留一个config.toml骨架,脚本跑完直接出json。
适合谁看:正在用 three.js 做 3D 文字、数据可视化大屏、WebGL 展厅的开发者;被中文字体体积卡过首屏的人;想把这套流程脚本化、可复用的人。下面我会给出完整的子集化脚本、config.toml骨架、API 调用方式,以及用 Network 面板验证体积差异的方法。
2. TaoToken 前置准备:统一 Key 与 config.toml 骨架
在动手写脚本之前,先把「统一 Key」这件事解决掉。传统做法是每个转换环节用一个工具,有的要注册、有的要本地装环境,Key 散落各处。TaoToken 的思路是提供一个统一的 API 入口,你只需要一个 Key,就能调用模型对话、字形处理相关的接口,把整条链路收敛到一处。
先拿到 Key。访问官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台创建 API Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API Keys 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。API 的基础地址是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,直接用于代码里的base_url。
拿到 Key 之后,在项目根目录建一个config.toml,把 Key、字体路径、字符集、输出目录都放进去。这样脚本不用硬编码,换项目只改配置。骨架如下:
# config.toml —— three.js 字体精简链路配置 [taotoken] # 统一 Key,从控制台复制,不要提交到 git api_key = "sk-你的TaoToken密钥" base_url = "https://taotoken.net/api" # 用于字形提取与格式转换的模型/接口标识 model = "claude-sonnet-4-20250514" [font] # 原始完整 ttf 字体路径 source_ttf = "./fonts/SourceHanSansCN-Regular.ttf" # 项目实际用到的字符集,中英文混排 charset = "智慧园区数据大屏实时监控0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 中间产物与最终输出目录 work_dir = "./fonts/work" output_json = "./public/fonts/scene-font.json" [convert] # 是否保留 svg 中间文件,便于排查 keep_svg = true # 输出 json 的精度,three.js 用 2 位小数足够 precision = 2这里有个坑要提前说:api_key千万别写进前端代码或者提交到仓库。脚本在 Node 环境跑,读config.toml即可。如果你用.env也行,但toml结构更清晰,适合放多段配置。
关于 Key 的获取和接口文档,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,里面会说明请求格式和返回结构。如果你只是想先验证模型能不能正常对话,可以用模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 快速试一下,确认 Key 有效再写脚本。
3. 可复制配置:字体子集化脚本与 API 调用
这一节是核心,给出完整的 Node 脚本。整体流程分四步:读配置 → 调用 TaoToken 接口做字形提取与格式转换 → 本地落盘 svg/ttf/json → 输出体积对比。先装依赖:
npm init -y npm install @iarna/toml node-fetch@3 opentype.js@iarna/toml解析配置,node-fetch发请求,opentype.js在本地做 ttf 解析和子集化兜底。脚本文件叫subset-font.mjs:
// subset-font.mjs import fs from 'node:fs'; import path from 'node:path'; import TOML from '@iarna/toml'; import fetch from 'node-fetch'; import opentype from 'opentype.js'; // 1. 读配置 const cfg = TOML.parse(fs.readFileSync('./config.toml', 'utf-8')); const { api_key, base_url, model } = cfg.taotoken; const { source_ttf, charset, work_dir, output_json } = cfg.font; fs.mkdirSync(work_dir, { recursive: true }); // 2. 调用 TaoToken 统一接口,让模型辅助生成字形提取策略 // 这里把字符集和字体信息发给接口,返回需要保留的 unicode 列表 async function planGlyphs(charset) { const resp = await fetch(`${base_url}/v1/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': api_key, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model, max_tokens: 1024, messages: [{ role: 'user', content: `给定字符集:「${charset}」。请输出这些字符对应的 Unicode 码点列表,JSON 数组格式,只输出数组,不要解释。` }] }) }); const data = await resp.json(); const text = data.content?.[0]?.text ?? '[]'; const match = text.match(/\[[\s\S]*\]/); return match ? JSON.parse(match[0]) : []; } // 3. 本地用 opentype.js 做真正的子集化,生成精简 ttf function subsetTTF(sourcePath, chars, outPath) { const font = opentype.loadSync(sourcePath); const glyphs = []; for (const ch of chars) { const g = font.charToGlyph(ch); if (g) glyphs.push(g); } const subset = new opentype.Font({ familyName: font.names.fontFamily.en, styleName: 'Subset', unitsPerEm: font.unitsPerEm, ascender: font.ascender, descender: font.descender, glyphs }); fs.writeFileSync(outPath, Buffer.from(subset.toArrayBuffer())); return outPath; } // 4. 把精简 ttf 转成 three.js 可用的 json // three.js 的 FontLoader 需要 facetype 格式的 json function ttfToThreeJSON(ttfPath, outPath, precision = 2) { const font = opentype.loadSync(ttfPath); const scale = 1000 / font.unitsPerEm; const glyphs = {}; for (const g of font.glyphs.glyphs) { if (!g || g.unicode === undefined) continue; const d = g.getPath(0, 0, font.unitsPerEm).toPathData(precision); glyphs[String.fromCharCode(g.unicode)] = { ha: Math.round((g.advanceWidth || 0) * scale), x_min: 0, x_max: Math.round((g.advanceWidth || 0) * scale), o: d }; } const out = { familyName: font.names.fontFamily.en, ascender: Math.round(font.ascender * scale), descender: Math.round(font.descender * scale), underlinePosition: -100, underlineThickness: 50, boundingBox: { yMin: Math.round(font.tables.head.yMin * scale), xMin: Math.round(font.tables.head.xMin * scale), yMax: Math.round(font.tables.head.yMax * scale), xMax: Math.round(font.tables.head.xMax * scale) }, resolution: font.unitsPerEm, original_font_information: {}, glyphs }; fs.writeFileSync(outPath, JSON.stringify(out)); return outPath; } // 主流程 const unicodeList = await planGlyphs(charset); console.log('接口返回码点数量:', unicodeList.length); const subsetTtf = path.join(work_dir, 'subset.ttf'); subsetTTF(source_ttf, charset, subsetTtf); console.log('精简 ttf 已生成:', subsetTtf); ttfToThreeJSON(subsetTtf, output_json, cfg.convert.precision); console.log('three.js json 已生成:', output_json); // 体积对比 const before = fs.statSync(source_ttf).size; const after = fs.statSync(output_json).size; console.log(`原始 ttf: ${(before / 1024).toFixed(1)} KB`); console.log(`精简 json: ${(after / 1024).toFixed(1)} KB`); console.log(`体积压缩比: ${(before / after).toFixed(1)}x`);跑起来:
node subset-font.mjs实测一个 9.8MB 的思源黑体,字符集只保留 40 多个字符,输出的json大约 28KB,压缩比 350 倍左右。这个数字会随字符集大小浮动,但量级上从 MB 级降到几十 KB 是稳的。
如果你更习惯用命令行工具链,fonts-streamline也能做子集化,但它只处理 svg 到 ttf,格式转换还得自己接。用 TaoToken 的好处是把「字符集规划」和「格式转换」的调用统一到一个 Key 下,脚本里不用维护多套凭证。长期做多个 3D 项目、需要反复跑这条链路的,可以考虑 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,把这类批处理任务固定下来。
4. 验证请求与成功结果:Network 面板看体积差异
脚本跑完只是第一步,真正要确认的是浏览器里加载变快了。把生成的scene-font.json放到public/fonts/下,three.js 里这样加载:
import { FontLoader } from 'three/examples/jsm/loaders/FontLoader.js'; import { TextGeometry } from 'three/examples/jsm/geometries/TextGeometry.js'; const loader = new FontLoader(); loader.load('/fonts/scene-font.json', (font) => { const geometry = new TextGeometry('智慧园区', { font, size: 1, height: 0.2, curveSegments: 6 }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh); });打开浏览器 DevTools 的 Network 面板,筛选Font或者直接搜json,对比精简前后的请求。精简前你会看到一条SourceHanSansCN-Regular.ttf的请求,Size 列显示 9.8MB,Time 列可能 3 到 8 秒(取决于网络)。精简后请求变成scene-font.json,Size 显示 28KB 左右,Time 基本在几十毫秒。
这里有个细节:FontLoader加载的是 json,不是 ttf,所以 Network 里类型可能显示为fetch或xhr,不是font。别被筛选器误导,直接看文件名。另外记得在Network面板勾选Disable cache,否则第二次刷新走缓存,看不出真实差异。
验证成功的标准有三个:一是请求体积从 MB 级降到 KB 级;二是首屏TextGeometry渲染出来的文字没有缺字、没有乱码;三是console里没有FontLoader: Unable to load font之类的报错。如果文字缺笔画,说明字符集里漏了字符,回到config.toml的charset补上再跑一遍脚本。
5. 本篇常见错排查
报错一:opentype.loadSync is not a function。这是opentype.js版本问题,新版默认导出方式变了。改成import opentype from 'opentype.js'后如果还报错,检查package.json里是不是装成了opentype.js的 ESM 版本。稳妥做法是锁版本npm install opentype.js@1.3.4。
报错二:接口返回 401 或invalid api key。先确认config.toml里的api_key没有多余空格,再确认base_url是https://taotoken.net/api而不是带路径的地址。如果 Key 是从控制台复制的,注意别把前后引号也复制进去。可以先用模型对话页 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 发一条消息,确认 Key 本身有效。
报错三:生成的 json 在 three.js 里文字挤在一起或者间距异常。这是advanceWidth缩放没对齐。three.js 的 facetype json 里resolution要和unitsPerEm一致,ha字段是缩放后的 advanceWidth。上面脚本里scale = 1000 / font.unitsPerEm,如果你的字体unitsPerEm是 2048,缩放系数就是 0.488,检查一下有没有写死成 1000。
报错四:中文字符在 json 里丢失。opentype.js的charToGlyph对某些字体的 cmap 表支持不完整,尤其是老版本中文字体。换一个 cmap 完整的字体,比如思源黑体或者阿里巴巴普惠体。如果必须用某个字体,可以先用fontTools的pyftsubset做一次预处理,再走脚本。
报错五:Network 面板里体积没变。大概率是浏览器缓存或者 Service Worker 拦截。勾选Disable cache,或者在 URL 后面加个版本号scene-font.json?v=2。另外确认你改的是public目录下的文件,而不是src里的源文件。
6. 把这条链路固定成项目里的常规步骤
字体精简这件事,做一次不难,难的是每个新项目都重新折腾一遍。我的做法是把config.toml和subset-font.mjs放进项目的scripts/目录,package.json里加一条:
{ "scripts": { "subset-font": "node scripts/subset-font.mjs" } }以后新增 3D 文字内容,只改charset字段,跑npm run subset-font,几秒钟出结果。字符集建议按场景分组维护,比如「大屏标题」「楼层标签」「设备名称」各一组,避免把所有字都塞进去导致体积反弹。
如果你同时维护多个 three.js 项目,可以把 Key 和接口调用抽成一个内部小工具,统一走 TaoToken 的 API 入口,接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 里有请求示例,照着改base_url和model就行。需要长期跑批处理、做自动化字体流水线的,Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 会更合适,把这类重复任务固定成可调度的流程。
最后提醒一句:config.toml里的api_key记得加进.gitignore,别让密钥跟着字体文件一起提交上去。字体子集化本身不复杂,把配置和脚本沉淀下来,下次遇到首屏字体卡顿,十分钟就能解决。