当我们第一次在浏览器中打开一个复杂的 DWG/DXF 图纸时,文本渲染往往成为性能瓶颈。
与线条、弧线、几何图形那样可以批量渲染的对象不同,文本 需要加载字体、解析字形、构建网格、管理布局------这些操作在 CPU 和内存上都代价不菲。
在我们开源项目 CAD-Viewer (Gitlab or Github) 中,我们在文本渲染上投入了大量精力,以便能够交互式地处理大型建筑与机械图纸。本文将介绍我们所使用的关键技术。
注意:
- CAD-Viewer 的代码仓库迁移到 https://github.com/mlightcad/cad-viewer 了
为什么文本渲染比几何更复杂
大多数 CAD 图纸包含数千个文本实体:尺寸标注、标签、表格、标题等。在 AutoCAD 的 MTEXT 中,一个实体可能混合使用 TrueType / OpenType 字体与 SHX 字体,使用不同的字号、颜色、粗体 / 斜体,甚至在同一个块中包含堆叠分数。
主要挑战包括:
- 字体加载:在绘制之前,我们需要解析字体文件(TTF、OTF 或 SHX),获取其字形 (glyph) 数据。
- 网格生成:为了保证高质量渲染和缩放,每个字形需要被转换为三角化的几何网格。
- 布局计算:MTEXT 可能要拆分为多行、处理对齐方式、应用变换等。
- 重复使用频繁 :许多字形(例如
0--9
、A--Z
、.
等)在几乎每个图纸中都会出现,对它们反复解析会浪费大量时间。
如果没有缓存与并行化支持,打开一个大型图纸单纯准备文本内容就可能需要几秒钟甚至更久。
重用并优化 Three.js 的字体类
为了避免重复造轮子,我们重用了 three/examples/jsm/loaders/FontLoader.js
中的 Font
和 FontData
类来解析字体轮廓(glyph outlines)。这大大简化了读取字形曲线和路径的逻辑。
但这些类最初设计用于 3D 文本,用来生成文字对应的 Mesh。默认 Mesh 会包含 UV 和法线 (normal) 数据,而这些在 2D CAD 文本中是完全没必要的,会消耗大量内存。为此,在我们在类 MeshTextShape 的 toGeometry
方法中:
- 删除 UV 数据
- 删除法线数据
- 只保留用于 2D 渲染所需的顶点位置和索引
这样的改动降低了每个文字 Mesh 内存占用,并提升了渲染性能,尤其是在文本密集的图纸中。
为了避免在生成字形时做无用计算(比如 normal、uv),我们还设计了一个 NormalComputationToggle 类,可以在创建字形网格期间临时将 computeVertexNormals
替换为空操作,从而节省 CPU 时间。
另外,为了进一步降低字形 Geometry 占用的内存,我们调用 three/examples/jsm/utils/BufferGeometryUtils.js
中的 mergeVertices
,将字形 Geometry 合并为索引数组缓冲 (indexed array buffer):
ts
toGeometry(): THREE.BufferGeometry {
let geometry = this.font.cache.getGeometry(this.char, this.fontSize)
if (geometry == null) {
// Use NormalComputationToggle to disable generating 'normal' data in returned geometry
// to save computation cost because rendering font characters don't need it.
geometry =
NormalComputationToggle.runWithoutNormals<THREE.BufferGeometry>(() => {
const geometry = new TextGeometry(this.char, {
font: this.font.font,
depth: 0,
size: this.fontSize,
curveSegments: 3, // change this to increase/decrease display precision
bevelSegments: 3,
// Pass dummy uv generator to save computation cost because rendering font characters
// always use color material and don't need 'uv' data.
UVGenerator: {
generateTopUV: () => [_tmp, _tmp, _tmp],
generateSideWallUV: () => [_tmp, _tmp, _tmp, _tmp]
}
})
if (geometry.hasAttribute('uv')) {
geometry.deleteAttribute('uv')
}
if (geometry.hasAttribute('normal')) {
geometry.deleteAttribute('normal')
}
return mergeVertices(geometry, 1e-6)
})
}
return geometry
}
在 IndexedDB 中缓存字体
第一个优化是:将解析好的字体持久化存储在本地,这样以后遇到相同字体就可以瞬间加载。我们使用 IndexedDB 作为持久化存储,并使用 idb
库来简化访问。数据库模式 (schema) 定义如下:
typescript
import type { DBSchema } from 'idb'
import { FontData } from '../font/font'
export const DB_STORES = {
fonts: 'fonts'
} as const
export interface DbFontCacheSchema extends DBSchema {
[DB_STORES.fonts]: {
key: string // font name
value: FontData // parsed font data
}
}
export const dbSchema = [
{
version: 1,
stores: [
{
name: DB_STORES.fonts,
keyPath: 'name'
}
]
}
]
每种字体被存为
fonts
存储区 (store) 下的一个记录 (record),以字体的name
作为键 (key)。
为什么选择 "一字体一记录"
乍一看,将每个字形存为一个记录似乎很自然,这样可以单独更新或加载每个字形。但我们发现,在 IndexedDB 中写入数万个小记录非常慢,尤其是在第一次缓存时。相反,我们将整份解析后的字体 ------ 包括元数据 (metadata) 和字形曲线 ------ 序列化为一个 JSON blob,存为一条记录,这样写入性能大为提升。
缓存数据示例
在浏览器 DevTools 中,你可以在 fonts
存储区看到如下条目:
- 类型为
mesh
的字体来自 TTF/OTF 字体,包含字形几何数据 - 类型为
shx
的字体来自 AutoCAD 的 SHX 笔画字体,存储紧凑的笔画指令
这清晰地表明:每种字体被存为一个 IndexedDB 记录。
是缓存字形笔画 (strokes) 还是缓存渲染几何 (geometry)?
高效的字体几何缓存对处理大型 MTEXT 实体至关重要。我们评估了两种缓存策略,最终选取兼顾内存效率与渲染性能的方案。
缓存字形笔画(Glyph Strokes)
该方式将原始的字形笔画数据缓存,也就是与字体文件 (TTF/OTF 轮廓或 SHX 笔画) 中所定义的向量指令一致的数据。
优点:
- 内存与缓存存储占用最小:我们不存储庞大的顶点或三角形数据,仅存紧凑的矢量指令。
- 在不同设备 / 不同分辨率下一致:可以在运行时重新三角化 (tessellate) 以适配不同渲染精度。
- 灵活性高:今后可根据渲染质量或性能要求选择不同的三角化策略。
缺点:
- 需要运行时处理:必须在渲染前解析字形笔画并执行三角化 (生成三角形或线段)。
- 初次渲染开销较高:首次渲染新字形可能较慢,但结果可被缓存。
缓存渲染几何(Rendered Geometry)
该方式缓存的是最终渲染几何 (如三角化网格、线段等)。
优点:
- 渲染直接可用:省去运行时解析/三角化,渲染速度媲美普通预计算网格或线批处理。
- 渲染阶段 CPU 负载低:因为跳过了几何生成步骤。
缺点:
- 内存 / 缓存占用高:每个字形的几何通常远大于其笔画描述。
- 对分辨率变化响应较差:若渲染分辨率(如缩放、Hi-DPI)变化,缓存几何可能需要重新生成。
我们的选择
我们采用缓存字形笔画的策略,以在内存占用与可伸缩性之间取得平衡。虽然会带来一些运行时开销,但我们通过以下方式加以缓解:
- 在
MeshTextShape
中使用优化后的三角化算法 - 利用 Web Worker 来并行化几何生成
- 缓存生成后的几何,用于后续渲染同一字形
这一策略使我们能够在不消耗过多 GPU/CPU 内存的前提下,处理具有多样字体的大型文档,同时保持可接受的渲染性能。
实际流程
在我们的 FontCacheManager 中:
- 检查字体是否已缓存(通过哈希或版本号)
- 若未缓存,则通过 FontManager 解析
- 将解析后的字体数据作为一个记录保存到 IndexedDB
后续加载时,我们可跳过繁重的解析步骤,直接读取字形数据。
这一设计本身就能将文本渲染的冷启动 (cold-start) 延迟降低一个数量级。
在内存中缓存字形几何 (Glyph Geometry)
即使持久化缓存了字体,生成字形网格仍可能很昂贵。因此,我们还在内存中做字形几何缓存。CharGeometryCache 存储一个映射 (fontId, character) → geometry
。
存在两种策略:
- 缓存所有字形,以获得最佳性能(适合内存充足的设备)
- 仅缓存常用字符(ASCII 字母、数字、标点等),节省内存
对于拥有成千上万字形的大型亚洲字体,缓存全部字形可能占用数百兆字节。我们常用第二种策略(只缓存常用字形)。
这种缓存消除了重复网格生成,使得在平移 / 缩放过程中帧耗几乎稳定。
在 Web Worker 中渲染文本 --- 并行化处理
字体解析与字形网格生成是 CPU 密集型任务,会阻塞主线程,导致 UI 冻结。为保持查看器响应性,我们将这些任务移至 Web Worker 中。
我们的 WebWorkerRenderer 负责:
- 从主线程接收批量文本实体
- 使用缓存的字体和字形生成几何
- 将可渲染网格回传给主线程
此外,我们不使用单一 worker,而是使用多个 worker 并行处理。
在 mtext-renderer
示例应用中,我们记录渲染时间,发现使用 4 个 Web Worker 时,MTEXT 渲染时间大约比主线程渲染快 50%。
这种多 worker 的设置在多核设备上能带来真正的性能提升。
渐进式渲染(Progressive Rendering)
在 cad-viewer 中,我们采用简单且可靠的渐进式渲染模式:使用 setTimeout
安排几何转换工作,并让转换函数是 async
,这样它会 await
每个实体的异步 draw()
调用。这样浏览器在批次之间可以更新 UI,并将每个生成的几何安全地加入场景,内存占用也可控。
在 AcTrView2d
中使用的具体模式如下:
javascript
addEntity(entity: AcDbEntity | AcDbEntity[]) {
const entities = Array.isArray(entity) ? entity : [entity]
this._numOfEntitiesToProcess += entities.length
// Schedule batch conversion asynchronously so the caller does not block.
setTimeout(async () => {
await this.batchConvert(entities)
})
}
为什么用 setTimeout
?
setTimeout(..., 0)
会在当前调用栈返回后安排batchConvert
执行,给浏览器留出绘制和处理输入的机会。这样多个addEntity
调用可以迅速累加_numOfEntitiesToProcess
而不被阻塞,而真正的重工作会被异步启动。- 它防止了几何转换阻塞调用者(通常是 UI 代码)。
在 batchConvert
内部,每个实体被转换/绘制后立刻加入场景,并调用释放逻辑、更新进度。关键代码如下:
kotlin
await threeEntity
.draw()
.then(() => {
this._scene.addEntity(threeEntity, isExtendBbox)
// Release memory occupied by this entity
threeEntity.dispose()
this._isDirty = true
})
.finally(() => {
this.decreaseNumOfEntitiesToProcess()
})
该逻辑保证:
threeEntity.draw()
是异步操作(通常利用缓存字体数据或 Web Worker 输出)。await
该 promise 会将控制权交还给事件循环,直到绘制完成------这样 UI 在几何准备过程中可以保持响应。- 在
.then()
回调中我们立即将准备好的几何加入场景 (_scene.addEntity
),使其尽快可见;然后调用threeEntity.dispose()
释放临时内存(如三角化缓冲、临时数组等)------控制内存使用;并将_isDirty = true
标记场景需要重绘。 .finally()
中递减_numOfEntitiesToProcess
,即使转换出错也能保证进度计数正确。
为什么这个模式在实践中效果良好
- 非阻塞 :用
setTimeout
+await
将长时工作的执行移出主调用栈,让浏览器在各批次之间能有机会绘制和响应交互。 - 渐进可见:每个已完成的实体会立即加入场景,用户能逐步看到标签和文本出现,而不用等所有转换完成。
- 内存可控 :每个
threeEntity
加入之后即释放中间缓冲,不会积累过多临时数据。 - 进度准确 :
_numOfEntitiesToProcess
在入队时加、一旦处理完毕(包括错误情况)就减,保持 UI / 进度指示器同步。
若你希望了解完整实现及周边逻辑,可以查看仓库中的源文件 AcTrView2d.ts。
整体流程整合
下面是 cad-viewer 中文本渲染的简化流程:
scss
Load Drawing → Identify Fonts → Load Cached Fonts (IndexedDB)
↓
[if not cached] Parse & Cache Fonts
↓
多个 Worker 并行转换文本实体 → 利用字形缓存 → 生成几何
↓
批量结果回传 → 渐进渲染到屏幕上
这一管道结合了持久化字体缓存、内存字形缓存、多 worker 并行处理与渐进渲染,使得即使在复杂 CAD 图纸中也能实现高性能文本渲染。
采用这些优化后,我们观察到:
- 文本渲染的"热加载"延迟从几秒钟下降到 100 毫秒以内
- 使用 4 个 Worker 时,MTEXT 渲染比单线程快约 50%
- 即使在文本密集的图纸中,平移 / 缩放亦可保持流畅交互
- 采用常用字形策略与精简几何(无 UV 或法线)可减少内存占用
接下来要做什么
我们正在探索以下方向:
- 使用 WebGPU 加速字形三角化以获得进一步性能提升
- 为缺失字体提供更好的回退策略
如果你正在为 Web 构建自己的 CAD 或图形查看器,可能会觉得这些思路颇具参考价值。
欢迎访问与参与:
我们非常欢迎你的反馈、讨论与贡献。