资讯动态

OpenGL轮廓文字渲染:旋转、描边与性能优化实战

发布时间:2026/10/13 1:23:39 来源:尧图企业网站定制
1. 轮廓文字渲染的整体设计思路1.1 为什么选择轮廓文字而不是实心文字做过OpenGL文字渲染的人都知道把一段文字画到屏幕上这件事看起来简单实际上坑特别多。最常见的做法是把整个字体的字符集预先烘焙成一张纹理图集每个字符对应图集里的一块区域渲染的时候按字符取纹理坐标拼出一整段文字。这种做法叫位图字体优点是快缺点是放大就糊而且没法做描边、发光这类效果。轮廓文字走的是另一条路。它不把字符当成一张图片而是把字符的矢量轮廓提取出来用一系列线段或曲线描述每个字符的边界再把这些边界转成三角形或者直接用线条画出来。这样做的好处很直接无限放大不糊因为轮廓是数学描述描边、填充、旋转都可以分开控制因为轮廓和填充是两套几何数据。我当初做这个功能起因是一个交互式标注工具的需求。用户需要在地图或者三维场景上标注文字文字要能跟着场景旋转还要有清晰的描边保证在任何背景色上都看得清。位图字体旋转之后边缘会有锯齿放大之后更明显所以只能走轮廓这条路。1.2 核心思路拆解从字形到屏幕像素整个流程可以拆成四步我用一个生活化的类比来解释。想象你要用铁丝弯出一个汉字然后把这个铁丝字挂到墙上。第一步是拿到字形的轮廓数据。这就像拿到这个汉字的“铁丝弯折图纸”。字体文件里其实存的就是这些矢量数据TrueType字体用的是二次贝塞尔曲线OpenType/CFF用的是三次贝塞尔曲线。我们需要一个库把这些数据解析出来常用的有FreeType、stb_truetype等。第二步是把曲线离散成线段。铁丝图纸上画的是平滑曲线但实际弯铁丝的时候只能一段一段折。这一步就是把贝塞尔曲线按精度采样成一系列首尾相连的直线段。采样精度越高字形越平滑但顶点数也越多。第三步是把线段组织成可渲染的几何。如果只画轮廓那每条线段就是一个独立的线段图元如果要填充就需要做三角剖分把闭合轮廓内部切成三角形。这一步是最容易出问题的地方因为汉字有很多笔画交叉、自相交的情况三角剖分算法要能处理这些。第四步是变换和绘制。把顶点数据传到GPU用模型矩阵做旋转、缩放、平移然后分别用两套着色器画填充和描边。旋转的关键在于变换矩阵要作用在轮廓顶点上而不是作用在已经栅格化的纹理上这正是轮廓文字相对位图文字的核心优势。1.3 方案选型几个关键决策点在动手之前有几个选型问题需要想清楚每个选择都会影响后续的实现复杂度。字体解析库的选择。FreeType功能最全支持各种字体格式还能直接拿到轮廓的线段化结果但它是C库集成到不同平台需要编译配置。stb_truetype是单头文件库集成极其简单适合快速原型但它对复杂字体的支持不如FreeType完善。我最后选了FreeType因为项目里要处理多种语言的字体FreeType的兼容性更让人放心。曲线离散化的精度控制。这里有个权衡精度太高顶点数爆炸一个汉字可能几千个顶点渲染开销大精度太低字形失真尤其是小字号下笔画会粘连。我的做法是按字形的实际尺寸动态调整用一个和字号相关的容差参数来控制采样密度。填充的三角剖分方案。可选的有耳切法、扫描线法、以及基于约束德劳内三角剖分的方案。耳切法实现简单但对带孔洞的轮廓比如“回”字处理麻烦。我最终用的是把轮廓交给一个成熟的三角剖分库处理自己只负责把轮廓数据整理成它需要的格式。描边的实现方式。描边有两种做法一种是把轮廓线加粗成一条带子另一种是把填充结果向外扩张一圈再减去原填充。前者实现简单但转角处会有缺口后者效果好但计算量大。我选了前者然后在转角处用圆角或者斜接来补缺口。2. 字形轮廓数据的获取与处理细节2.1 用FreeType加载字体并提取轮廓FreeType的使用流程比较固定但有几个细节不注意就会踩坑。首先是初始化FT_Init_FreeType只需要调用一次整个程序生命周期内复用。然后是加载字体面FT_New_Face会读取字体文件这一步要注意字体文件的路径和编码问题尤其是在不同操作系统上。加载完字体面之后设置字号用FT_Set_Char_Size或者FT_Set_Pixel_Sizes。这里有个容易混淆的点FT_Set_Char_Size的参数单位是1/64磅而FT_Set_Pixel_Sizes直接按像素。如果你希望文字大小和屏幕像素精确对应用后者更直观。真正提取轮廓的调用是FT_Load_Glyph关键参数是FT_LOAD_NO_BITMAP这个标志告诉FreeType不要生成位图只保留轮廓数据。加载之后face-glyph-outline里就是轮廓信息n_contours是轮廓数量n_points是顶点总数points数组里存的是每个点的坐标tags数组标记每个点是直线点还是曲线控制点。FT_Library library; FT_Face face; FT_Init_FreeType(library); FT_New_Face(library, font.ttf, 0, face); FT_Set_Pixel_Sizes(face, 0, 48); FT_Load_Char(face, 永, FT_LOAD_NO_BITMAP); FT_Outline* outline face-glyph-outline;注意FreeType的坐标原点是基线y轴向上而OpenGL的纹理坐标或者屏幕坐标往往y轴向下这个坐标系差异必须在后续处理中统一否则字形会上下颠倒。2.2 贝塞尔曲线的离散化把曲线变成线段FreeType给出的轮廓里点分为两类在线点on-curve和离线点off-curve。在线点是曲线真正经过的点离线点是贝塞尔曲线的控制点。TrueType用的是二次贝塞尔两个在线点之间如果有一个离线点就构成一段二次贝塞尔曲线如果有两个连续的离线点中间隐含一个在线点。离散化的核心就是把每段贝塞尔曲线按参数t从0到1采样成若干直线段。采样的段数怎么定我试过固定段数效果不好——大字号下曲线不够平滑小字号下又浪费顶点。后来改成根据曲线长度和控制点距离动态计算公式大致是segments clamp(ceil(sqrt(distance) * factor), minSeg, maxSeg)其中distance是起点到终点的直线距离factor是一个经验系数我用的值是0.5左右minSeg取2maxSeg取32。这个公式的意思是曲线越长需要的段数越多但增长是开方关系避免长曲线段数爆炸。二次贝塞尔曲线的采样公式很直接B(t) (1-t)^2 * P0 2*(1-t)*t * P1 t^2 * P2P0和P2是在线点P1是离线控制点。把t从0到1均匀取segments1个值算出对应的点就得到了一串线段顶点。2.3 轮廓的闭合与方向处理离散化之后每个轮廓变成了一串顶点。但这里有个隐藏问题轮廓的方向。TrueType规范里外轮廓是顺时针内轮廓孔洞是逆时针。这个方向信息在三角剖分和描边的时候至关重要如果搞反了填充会变成挖空描边会画到内部。我的处理方式是在离散化的时候保留每个轮廓的方向信息计算轮廓的有向面积面积为正说明是逆时针为负说明是顺时针。然后在后续处理中根据方向决定这个轮廓是填充还是挖空。另一个问题是轮廓的闭合。FreeType给出的轮廓点序列最后一个点和第一个点之间可能没有显式地连起来需要手动补一段。如果最后一个点是离线点还要根据它和第一个在线点补一段贝塞尔曲线。2.4 顶点数据的组织与上传处理完所有轮廓之后我得到的是一个顶点数组每个顶点包含位置x, y和一个标记属于哪个轮廓、是填充还是描边。为了减少draw call我把所有字符的轮廓顶点合并到一个大的VBO里用偏移量来区分不同字符。顶点格式我设计成这样的结构struct GlyphVertex { float x, y; // 位置 float u, v; // 备用用于纹理 int contourId; // 轮廓编号 int flags; // 标记位 };实际渲染的时候填充和描边用不同的着色器程序但共用同一份顶点数据。填充用三角形图元描边用线段图元或者加粗后的三角形带。实操心得顶点数据最好按字符缓存起来不要每次渲染都重新从FreeType提取。我一开始没做缓存结果每帧都在解析字体帧率直接掉到个位数。后来加了一个LRU缓存按字符编码和字号做key性能立刻上来了。3. 旋转与变换的数学原理及实现3.1 为什么旋转要在顶点层面做这是轮廓文字和位图文字最本质的区别。位图文字旋转是把一张已经栅格化的图片旋转旋转之后每个像素都要重新采样边缘会出现锯齿和模糊。轮廓文字旋转是在顶点层面做矩阵乘法旋转之后重新栅格化边缘始终是清晰的。用类比来说位图文字旋转像是把一张印好的海报转个角度拍照转得越多越模糊轮廓文字旋转像是把海报的矢量源文件转个角度重新印刷转多少度都清晰。具体到实现旋转矩阵是标准的2D旋转[cosθ -sinθ] [x] [sinθ cosθ] [y]但实际项目中文字往往是在3D场景里需要的是完整的4D齐次变换矩阵。我的做法是把2D轮廓顶点扩展成3D坐标z0然后用一个4x4的模型矩阵做变换这样旋转、缩放、平移可以统一处理。3.2 旋转中心的选取与锚点对齐旋转中心选在哪里直接决定了旋转的视觉效果。如果旋转中心在字符的原点通常是左下角或者基线起点旋转之后文字会绕着这个点转看起来像是甩出去的。如果旋转中心在字符的中心旋转看起来更自然。我的做法是在生成字符轮廓的时候同时计算这个字符的包围盒把包围盒中心作为默认的旋转锚点。然后在顶点着色器里先把顶点平移到以锚点为原点做旋转再平移回去。vec3 rotated rotate(vertexPos - anchor, angle) anchor;这个锚点也可以暴露给上层让调用者指定。比如做环形文字排列的时候锚点可能设在字符底部让每个字符都朝外。3.3 旋转后的描边宽度保持这里有个容易被忽略的问题如果描边是通过把轮廓线加粗实现的那么旋转之后描边的宽度会不会变答案是如果描边宽度是在顶点层面按固定像素值扩张的旋转不会改变宽度但如果描边宽度是在屏幕空间按固定值扩张的旋转也不会改变宽度。真正会出问题的是非均匀缩放——如果模型矩阵里有非均匀的缩放描边宽度会跟着变形。我的处理方式是把描边宽度的扩张放在顶点着色器里用屏幕空间的固定像素值来做这样无论怎么旋转缩放描边宽度始终一致。具体做法是把顶点位置变换到裁剪空间之后再根据法线方向偏移固定的NDC距离。3.4 旋转对三角剖分的影响三角剖分是在字符的局部坐标系里做的旋转只是对剖分结果的顶点做变换不会改变三角形的拓扑结构。所以旋转本身不会影响剖分的正确性。但有一个例外如果旋转之后字符超出了视口需要做裁剪而裁剪可能会把三角形切开这时候就需要处理裁剪后的三角形重建。我的做法比较简单粗暴不做精细裁剪直接靠GPU的视口裁剪。对于超出视口的部分GPU会自动丢弃虽然有一点性能浪费但实现简单而且对于文字这种小图元来说浪费可以忽略。4. 填充与描边的着色器实现4.1 填充着色器的核心逻辑填充着色器相对简单因为三角剖分已经把填充区域变成了三角形着色器只需要把顶点变换到屏幕空间然后输出颜色就行。但有几个细节需要注意。第一个是抗锯齿。轮廓文字如果不做抗锯齿边缘会有明显的阶梯。我的做法是在片段着色器里用轮廓的距离场做平滑。具体来说在生成顶点数据的时候同时计算每个顶点到轮廓边界的距离插值到片段之后用smoothstep做一个软过渡。第二个是颜色混合。如果文字要半透明需要开启混合并且注意预乘alpha的问题。我用的混合方程是标准的src_alpha, one_minus_src_alpha。// 片段着色器 float alpha smoothstep(0.0, 1.0, distanceField); vec4 color vec4(textColor.rgb, textColor.a * alpha);4.2 描边着色器的实现方式描边的实现我试过三种方案最后选了第三种。方案一画两遍先画粗的填充作为描边再画细的填充覆盖。这个方案最简单但有个致命问题如果描边颜色和填充颜色都需要半透明叠加之后颜色会不对。方案二把轮廓线加粗成三角形带。这个方案效果最好但实现复杂尤其是转角处的处理。我用的加粗方法是对每个线段沿法线方向向两侧扩张半个描边宽度形成两个三角形。转角处用斜接miter连接如果斜接长度超过阈值就退化成斜角bevel。方案三在片段着色器里用距离场判断。这个方案最优雅只需要一份填充几何在片段着色器里根据到边界的距离决定是填充色还是描边色。具体做法是计算片段到最近轮廓边的距离如果距离小于描边宽度输出描边色否则输出填充色。我最后选了方案三因为它只需要一份几何数据而且描边宽度可以动态调整不需要重新生成顶点。float dist distanceToOutline; if (dist strokeWidth) { fragColor strokeColor; } else { fragColor fillColor; }4.3 距离场的计算与优化方案三的关键是如何快速计算片段到轮廓的距离。最直接的方法是对每个片段遍历所有轮廓线段算点到线段的距离取最小值。但这样复杂度是O(片段数 × 线段数)对于复杂汉字来说太慢了。我的优化方式是在顶点数据里预计算每个顶点到轮廓的距离然后在片段着色器里插值。这个做法在轮廓比较平滑的时候效果不错但在轮廓拐角处会有误差。为了减小误差我在拐角处增加了顶点密度。另一个优化是用空间划分加速。把字符的包围盒划分成网格每个网格记录可能影响它的线段列表片段着色器只需要查询所在网格的线段列表。这个优化在字符复杂的时候效果明显但实现复杂度也上去了。我最后用的是预计算距离场纹理的方案把字符的距离场烘焙到一张纹理里片段着色器直接采样。这个方案兼顾了效果和性能缺点是纹理分辨率有限放大之后会有精度损失。4.4 旋转状态下的着色器适配旋转之后距离场的计算会变得复杂因为距离是在局部坐标系里算的旋转之后局部坐标系和屏幕坐标系不再对齐。我的处理方式是在顶点着色器里把顶点变换到屏幕空间同时把轮廓线段也变换到屏幕空间然后在屏幕空间里算距离。这样无论怎么旋转距离场都是准确的。但这样做有个代价轮廓线段需要在着色器里重新计算增加了顶点着色器的负担。为了平衡我把轮廓线段的数据放在一个纹理里顶点着色器根据顶点索引去采样对应的线段数据。这个方案有点绕但性能可以接受。5. 常见问题与排查技巧实录5.1 字形显示不全或笔画缺失这是最常见的问题表现是某些笔画没画出来或者整个字符只显示了一部分。原因通常有几个轮廓方向搞反了。前面说过外轮廓顺时针、内轮廓逆时针如果三角剖分的时候没考虑方向填充区域会算错。排查方法是把轮廓单独画出来看方向对不对。三角剖分失败。汉字有很多自相交的轮廓比如“十”字的横竖交叉处如果剖分算法不支持自相交就会漏掉一部分。排查方法是把剖分结果可视化看三角形覆盖是否完整。顶点索引越界。如果顶点数组和索引数组对不上会画出乱七八糟的三角形。排查方法是检查索引的最大值是否小于顶点数。5.2 旋转后文字位置偏移这个问题的表现是文字旋转之后位置和预期不符要么偏了要么绕着奇怪的点转。原因通常是旋转锚点没设对。如果锚点在字符原点旋转之后字符会绕着原点转看起来就是偏的。排查方法是把锚点可视化出来看它是不是在字符中心。如果不在检查包围盒的计算是否正确。包围盒的计算要注意不能只算在线点离线点也要算进去因为贝塞尔曲线可能超出控制点围成的凸包。5.3 描边宽度不均匀这个问题的表现是旋转或者缩放之后描边的宽度在某些方向变粗某些方向变细。原因是描边宽度是在局部坐标系里算的经过非均匀变换之后变形了。解决方法是把描边宽度的计算放到屏幕空间。具体做法是在顶点着色器里把顶点变换到裁剪空间然后根据法线方向在裁剪空间里偏移。这样无论怎么变换描边宽度都是屏幕空间的固定值。5.4 性能问题帧率低、卡顿轮廓文字的性能瓶颈通常在两个地方顶点数太多和draw call太多。顶点数太多是因为曲线离散化精度太高。解决方法是动态调整精度小字号用低精度大字号用高精度。另外可以把多个字符的顶点合并到一个VBO里减少状态切换。draw call太多是因为每个字符单独绘制。解决方法是把同一段文字的所有字符合并成一个draw call用实例化或者批处理的方式。5.5 常见问题速查表问题现象可能原因排查方法解决方案笔画缺失轮廓方向错误可视化轮廓方向按有向面积判断内外轮廓填充有洞三角剖分失败可视化三角形换用支持自相交的剖分库旋转偏移锚点设置错误可视化锚点位置用包围盒中心作为锚点描边不均局部空间计算宽度检查变换矩阵改到屏幕空间计算帧率低顶点数过多统计顶点数动态精度顶点合并边缘锯齿无抗锯齿放大看边缘距离场平滑文字颠倒坐标系不一致检查y轴方向统一坐标系避坑技巧在开发初期一定要做一个调试模式把轮廓线、三角剖分结果、距离场都可视化出来。我当初跳过这一步结果一个笔画缺失的问题查了两天最后发现是轮廓方向的问题。有了可视化这种问题十分钟就能定位。6. 从单字符到整段文字的工程化扩展6.1 字符间距与排版基础单个字符画出来之后下一步是把它们排成一行。这里涉及到字距advance的概念。每个字符在字体文件里都有一个advance值表示这个字符画完之后下一个字符的起点应该往右移动多少。这个值通常不等于字符的宽度因为字符两侧有留白。FreeType在加载字形的时候会给出glyph-advance.x单位是1/64像素。排版的时候把每个字符的顶点按累计的advance平移就能得到正确的间距。但汉字排版有个特殊之处汉字是等宽的每个汉字的advance基本相同。所以对于纯中文文本可以简化成固定间距。但中英混排的时候英文的advance是变化的必须按实际值来。6.2 多字符的顶点合并与批处理如果每个字符单独draw call一段20个字的文本就是20次draw call性能很差。我的做法是把所有字符的顶点合并到一个大数组里用一次draw call画完。合并的时候要注意每个字符的顶点需要先按自己的advance平移到正确位置然后再合并。合并之后顶点数据里不再区分字符只区分填充和描边。这样填充和描边各一次draw call总共两次性能很好。但这样做有个代价如果文本内容变了整个顶点数组都要重新生成。对于静态文本没问题对于动态文本比如实时输入需要做增量更新。我的做法是维护一个顶点池文本变化时只更新变化的部分。6.3 旋转文本的布局计算如果整段文字要一起旋转布局计算和单字符旋转类似只是锚点变成了整段文字的包围盒中心。但如果每个字符要独立旋转比如环形排列就需要对每个字符单独计算变换矩阵。环形排列的做法是把每个字符放在一个圆周上字符的位置由角度决定字符的旋转角度等于它所在位置的角度。这样每个字符的变换矩阵都不同需要逐个计算。for (int i 0; i text.length(); i) { float angle startAngle i * angleStep; float x centerX radius * cos(angle); float y centerY radius * sin(angle); mat4 model translate(x, y) * rotate(angle); drawGlyph(text[i], model); }6.4 缓存策略与内存管理轮廓文字的数据量比位图文字大得多一个汉字可能有几百到几千个顶点每个顶点几十个字节一个汉字就是几十KB。如果缓存所有用到的字符内存占用会很快上去。我的缓存策略是LRU 分级。常用的字符比如ASCII常驻内存不常用的字符按LRU淘汰。另外不同字号的字符分开缓存因为字号不同轮廓数据也不同。内存管理上我用的是自己实现的池分配器避免频繁的malloc/free。顶点数据存在连续的内存块里用偏移量索引这样缓存友好访问速度快。实操心得缓存的大小要留有余量不要卡着上限。我当初把缓存设得太小结果频繁淘汰和重建性能反而比不缓存还差。后来把缓存调到能容纳常用字符集的1.5倍性能就稳定了。7. 实际项目中的性能调优记录7.1 顶点数统计与优化前后对比在优化之前我统计了一下一个48像素的汉字平均有800个顶点复杂的字比如“囊”有2000多个。一段20个字的文本就是16000到40000个顶点。这个量级对于现代GPU来说不算大但如果每帧都要重新生成顶点数据CPU开销就很可观了。优化之后我把顶点数降到了平均300个复杂的字也不超过800个。降幅主要来自两个方面一是曲线离散化的精度从固定段数改成了动态段数二是去掉了重复的顶点相邻线段共享端点。优化项优化前优化后降幅平均顶点数/字80030062%复杂字顶点数200080060%每帧CPU时间12ms3ms75%帧率45fps120fps167%7.2 GPU与CPU负载的平衡轮廓文字的负载分布和位图文字不同。位图文字主要是GPU负载纹理采样CPU负载很轻。轮廓文字则是CPU和GPU都有负载CPU负责生成顶点数据GPU负责光栅化。我的调优思路是把能预计算的都预计算减少每帧的CPU开销。具体来说字符的轮廓数据在第一次使用的时候生成并缓存之后每帧只需要更新变换矩阵不需要重新生成顶点。变换矩阵的更新在顶点着色器里做CPU只传一个矩阵。这样优化之后每帧的CPU开销从12ms降到了不到1msGPU开销因为顶点数减少也降了不少。7.3 不同字号下的自适应策略字号变化的时候轮廓数据的精度需求也不同。小字号下笔画密集如果精度太高顶点会挤在一起反而影响渲染效果大字号下精度太低曲线会显得很粗糙。我的自适应策略是根据字号计算一个目标精度字号越小精度越低。具体公式是tolerance baseTolerance * (fontSize / referenceSize)其中baseTolerance是参考字号下的容差referenceSize是参考字号我用的48。这个公式的意思是字号减半容差也减半采样段数相应减少。实测下来这个策略在8px到200px的字号范围内都能保持不错的效果顶点数也不会失控。7.4 批量渲染与状态切换的优化状态切换是渲染性能的隐形杀手。每次切换着色器程序、绑定纹理、改变混合模式都有开销。轮廓文字的渲染涉及填充和描边两套着色器如果每个字符都切换一次开销很大。我的优化方式是把所有字符的填充顶点合并用填充着色器一次性画完再把所有字符的描边顶点合并用描边着色器一次性画完。这样整个文本只需要两次状态切换开销降到最低。如果文本里既有填充又有描边而且描边和填充的颜色不同那就需要两次draw call。但如果用距离场的方案填充和描边在同一个着色器里处理就只需要一次draw call。8. 轮廓文字旋转功能的延伸应用8.1 三维场景中的文字标注轮廓文字最大的用武之地是三维场景。在三维场景里文字需要跟着相机旋转或者贴在物体表面。位图文字在这种情况下要么糊要么需要生成多个角度的纹理很麻烦。轮廓文字则天然支持任意角度的旋转只需要改变模型矩阵。我在一个三维标注工具里用了这个方案每个标注文字是一个独立的四边形四边形上渲染轮廓文字。四边形跟着相机做billboard旋转文字始终面向相机。因为文字是轮廓渲染的无论相机怎么转文字都是清晰的。8.2 动态效果描边动画与渐变填充轮廓文字因为填充和描边是分开的做动态效果很方便。比如描边动画可以让描边宽度随时间变化或者让描边颜色渐变。填充也可以做渐变在片段着色器里根据位置插值颜色。我做过一个效果文字从无到有描边先画出来然后填充从下往上填充。实现方式是描边宽度从0渐变到目标值填充用一个裁剪平面从下往上移动。因为填充和描边是分开的几何可以独立控制。8.3 与其他渲染元素的混合轮廓文字渲染出来的结果是带alpha的可以和其他渲染元素混合。比如在文字后面加一个半透明的背景框或者让文字和场景里的其他物体做深度测试。深度测试这里有个坑轮廓文字的顶点z值如果都是0和场景里的其他物体做深度测试时会有问题。我的做法是给文字一个固定的z偏移保证它总是在其他物体前面或者根据需求调整z值。8.4 跨平台适配的注意事项轮廓文字的方案在不同平台上基本一致因为核心是OpenGL跨平台性很好。但有几个平台相关的细节需要注意。移动端GPU的精度问题。移动端GPU对浮点数的精度支持不如桌面端距离场的计算可能会有误差。我的做法是在移动端降低距离场的精度要求或者改用查表的方式。不同OpenGL版本的兼容性。OpenGL ES 2.0不支持一些高级特性比如整数纹理、实例化渲染。如果要在ES 2.0上跑需要做降级处理。我的做法是抽象一层渲染接口根据平台选择不同的实现。字体文件的授权问题。这个不是技术问题但很重要。商用项目里使用的字体必须有相应的授权否则会有法律风险。我一般推荐用开源字体比如思源系列授权清晰字形质量也不错。9. 我在这个项目里踩过的坑与最终方案9.1 曲线离散化的精度陷阱最开始我用的是固定段数每段贝塞尔曲线固定采样16个点。结果小字号下顶点太多大字号下曲线又不够平滑。后来改成动态段数根据曲线长度和控制点距离计算效果好多了。但动态段数也有坑如果曲线特别长段数会很多顶点数爆炸。我加了一个上限最多32段超过就截断。实测下来32段对于大多数情况都够了极端的曲线比如很长的弧线会有轻微的多边形感但可以接受。9.2 三角剖分的自相交问题汉字的轮廓经常有自相交比如“又”字的交叉处。标准的耳切法处理不了自相交会把交叉区域剖分错。我试过几个开源库最后用的是一个基于扫描线的剖分库它能处理自相交把交叉区域正确地分成多个多边形。但扫描线剖分也有代价它需要先把轮廓转成单调多边形这个过程对于复杂轮廓比较慢。我的优化是对于简单的轮廓顶点数少、无自相交用快速的耳切法对于复杂的轮廓才用扫描线法。这样大部分字符都能快速处理。9.3 旋转锚点的动态调整旋转锚点一开始我设的是字符原点结果旋转的时候文字会甩出去。后来改成包围盒中心好多了。但有些场景下包围盒中心也不是最佳选择。比如做环形文字的时候锚点应该在字符底部这样字符才会朝外。我的最终方案是默认用包围盒中心但允许调用者指定锚点。锚点的坐标是相对于字符包围盒的归一化坐标(0.5, 0.5)是中心(0.5, 0)是底部中心。这样既方便默认使用又保留了灵活性。9.4 描边转角的处理描边在转角处如果处理不好会有缺口或者重叠。我试过几种方案圆角连接、斜接连接、斜角连接。圆角连接效果最好但需要额外的几何斜接连接最简单但锐角处会伸出很长斜角连接是折中效果一般。最后我用的是自适应方案根据转角的角度选择连接方式。角度接近180度几乎直线用斜接角度小于90度用斜角中间用圆角。这样在大多数情况下都能得到不错的效果。9.5 最终方案的架构总结整个项目的最终架构是这样的字体解析层用FreeType加载字体提取轮廓数据。几何处理层曲线离散化、轮廓闭合、方向判断、三角剖分。缓存层按字符和字号缓存几何数据LRU淘汰。渲染层填充和描边两套着色器支持旋转、缩放、平移。排版层字符间距计算、多字符合并、旋转布局。这个架构的优点是层次清晰每一层可以独立替换。比如字体解析层可以从FreeType换成stb_truetype几何处理层可以从耳切法换成扫描线法渲染层可以从距离场换成几何加粗。这种灵活性在项目迭代中很有价值。最后再分享一个小技巧如果你的项目里文字不需要频繁变化可以在初始化的时候把所有用到的字符预生成好运行时直接查表。这样运行时的开销几乎为零性能最好。我现在的项目就是这么做的启动时花几百毫秒预生成运行时帧率稳定在垂直同步的上限。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑