打造高性能二维图纸渲染引擎系列(三):高性能 CAD 文本渲染背后的隐藏工程

当我们第一次在浏览器中打开一个复杂的 DWG/DXF 图纸时,文本渲染往往成为性能瓶颈。

与线条、弧线、几何图形那样可以批量渲染的对象不同,文本 需要加载字体、解析字形、构建网格、管理布局------这些操作在 CPU 和内存上都代价不菲。

在我们开源项目 CAD-Viewer (Gitlab or Github) 中,我们在文本渲染上投入了大量精力,以便能够交互式地处理大型建筑与机械图纸。本文将介绍我们所使用的关键技术。

注意:

为什么文本渲染比几何更复杂

大多数 CAD 图纸包含数千个文本实体:尺寸标注、标签、表格、标题等。在 AutoCAD 的 MTEXT 中,一个实体可能混合使用 TrueType / OpenType 字体与 SHX 字体,使用不同的字号、颜色、粗体 / 斜体,甚至在同一个块中包含堆叠分数。

主要挑战包括:

  • 字体加载:在绘制之前,我们需要解析字体文件(TTF、OTF 或 SHX),获取其字形 (glyph) 数据。
  • 网格生成:为了保证高质量渲染和缩放,每个字形需要被转换为三角化的几何网格。
  • 布局计算:MTEXT 可能要拆分为多行、处理对齐方式、应用变换等。
  • 重复使用频繁 :许多字形(例如 0--9A--Z. 等)在几乎每个图纸中都会出现,对它们反复解析会浪费大量时间。

如果没有缓存与并行化支持,打开一个大型图纸单纯准备文本内容就可能需要几秒钟甚至更久。

重用并优化 Three.js 的字体类

为了避免重复造轮子,我们重用了 three/examples/jsm/loaders/FontLoader.js 中的 FontFontData 类来解析字体轮廓(glyph outlines)。这大大简化了读取字形曲线和路径的逻辑。

但这些类最初设计用于 3D 文本,用来生成文字对应的 Mesh。默认 Mesh 会包含 UV 和法线 (normal) 数据,而这些在 2D CAD 文本中是完全没必要的,会消耗大量内存。为此,在我们在类 MeshTextShapetoGeometry 方法中:

  • 删除 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 中:

  1. 检查字体是否已缓存(通过哈希或版本号)
  2. 若未缓存,则通过 FontManager 解析
  3. 将解析后的字体数据作为一个记录保存到 IndexedDB

后续加载时,我们可跳过繁重的解析步骤,直接读取字形数据。

这一设计本身就能将文本渲染的冷启动 (cold-start) 延迟降低一个数量级。

在内存中缓存字形几何 (Glyph Geometry)

即使持久化缓存了字体,生成字形网格仍可能很昂贵。因此,我们还在内存中做字形几何缓存。CharGeometryCache 存储一个映射 (fontId, character) → geometry

存在两种策略:

  1. 缓存所有字形,以获得最佳性能(适合内存充足的设备)
  2. 仅缓存常用字符(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 或图形查看器,可能会觉得这些思路颇具参考价值。

欢迎访问与参与:

我们非常欢迎你的反馈、讨论与贡献。

相关推荐
CAD老兵4 小时前
打造高性能二维图纸渲染引擎系列(二):创建结构化和可扩展的渲染场景
前端·webgl·three.js
王木风4 小时前
1分钟理解什么是MySQL的Buffer Pool和LRU 算法?
前端·mysql
Jerry_Rod4 小时前
vue 项目如何使用 mqtt 通信
前端·vue.js
云中雾丽5 小时前
Flutter中路由配置的各种方案
前端
不一样的少年_5 小时前
女朋友炸了:刚打开的网页怎么又没了?我反手甩出一键恢复按钮!
前端·javascript·浏览器
Renounce5 小时前
【Android】让 Android 界面 “动” 起来:动画知识点大起底
前端
Asort5 小时前
JavaScript设计模式(十四)——命令模式:解耦请求发送者与接收者
前端·javascript·设计模式
小茴香3535 小时前
Vue 脚手架(Vue CLI)
前端·javascript·vue.js
午安~婉5 小时前
ESLint
前端·eslint·检查