在macOS和iOS平台上做自定义文字渲染时,Core Graphics提供的填充规则是一个绕不开的话题。字体文件中的每一个字形本质上是一组由二次或三次贝塞尔曲线构成的封闭轮廓,当这些轮廓通过CTFontCreatePathForGlyph转换成CGPath对象后,交由CGContext填充时,采用非零环绕数规则还是奇偶规则,会得到完全不同的像素结果。理解这一点对于做字形编辑器、矢量文字导出、特殊字体渲染器的开发者来说尤为重要。

一、从字形到路径:CTFontCreatePathForGlyph做了什么
CTFontCreatePathForGlyph是Core Text提供的一个桥接函数,它读取字体文件中指定字形的轮廓描述(通常是TrueType的glyf表或PostScript的charstring),把其中的线段和曲线指令翻译成CGPath中的元素:moveToPoint、addLineToPoint和addCurveToPoint。转换完成后,你得到的是一个坐标系以字形原点为基准的CGPathRef,可以像操作任意矢量路径一样对它做填充、描边、变换甚至布尔运算。
需要特别注意的是,这个函数返回的路径保留了字体文件中记录的轮廓方向。TrueType规范规定外轮廓为顺时针方向(在数学坐标系中),内轮廓(也就是字形中的孔洞部分)为逆时针方向;而PostScript字体恰好相反。这种方向上的差异不会影响非零环绕数规则的填充结果,却是决定奇偶规则表现的关键因素之一,后文会详细展开。
下面是一段完整的Swift示例,展示如何提取字形路径并用两种规则分别填充:
import CoreText
import AppKit
func drawGlyphWithContext(context: CGContext,
font: CTFont,
glyph: CGGlyph,
at position: CGPoint) {
// 提取字形对应的CGPath,若字形没有轮廓(如空格)则返回nil
guard let path = CTFontCreatePathForGlyph(font, glyph, nil) else {
return
}
context.saveGState()
// Core Text坐标系与CGContext坐标系Y轴方向相反,需要翻转
let transform = CGAffineTransform(translationX: position.x,
y: position.y).scaledBy(x: 1, y: -1)
context.translateBy(x: 0, y: 0)
let cgPath = path.copy(using: &transform)!
let pathObj = CGPath(from: cgPath)
// 左侧:非零环绕数规则(文字渲染的标准做法)
context.addPath(pathObj)
context.setFillColor(NSColor.black.cgColor)
context.fillPath()
// 右侧:奇偶规则,用来观察差异
context.translateBy(x: 200, y: 0)
context.addPath(pathObj)
context.fillPath(using: .evenOdd)
context.restoreGState()
}运行这段代码后,对大多数简单字形(比如字母L、方块汉字的实心部分)来说,两种规则画出来的东西是一样的。差异只在带孔洞的字形上暴露出来。
二、非零环绕数规则与奇偶规则的判定原理
两种规则的核心都是回答同一个问题:对于平面上的任意一点,它到底在不在填充区域内部?答案的判定方式决定了轮廓嵌套和方向的语义。
非零环绕数规则的做法是:从待判定点向任意方向引一条射线,统计射线穿过的每条路径边界的方向。逆时针穿过记加一,顺时针穿过记减一,最终的总和不为零则该点在填充区内。这意味着如果外轮廓是顺时针、内轮廓是逆时针,嵌套区域的方向会相互抵消——但只有当方向配置正确时,孔洞位置的环绕数才会恰好为零,从而不被填充。TrueType字体正是利用这一机制来描述字形的孔洞:外圈一个方向,内圈反方向,嵌套部分自然抵消。
奇偶规则则不管方向,只数射线穿过了几条边界:奇数次在内部,偶数次在外部。这个规则对轮廓方向完全不敏感,只关心嵌套层级。对于字形来说,只要孔洞轮廓确实是独立闭合的子路径,奇偶规则同样能正确地挖出孔洞。
两者在字形渲染中的实际差异出现在方向不规范的路径上。假设某个字形的内孔轮廓方向与外轮廓相同(某些设计粗糙的手工字体或经过布尔运算损坏的路径会出现这种情况),非零规则会把孔洞也填满,而奇偶规则依然能正确挖空。反过来,如果两个同层级的轮廓部分重叠(字形本身不该出现,但路径运算后可能出现),奇偶规则会挖空重叠区,非零规则则会保留它。下面的表格总结了差异:
| 场景 | 非零环绕数规则 | 奇偶规则 |
|---|---|---|
| 方向正确的嵌套轮廓(字母O) | 正确挖空孔洞 | 正确挖空孔洞 |
| 内外轮廓同向的字形 | 孔洞被填满(错误) | 正确挖空孔洞 |
| 两个重叠的独立轮廓 | 重叠区被填充 | 重叠区被挖空 |
三、实际渲染中的应用场景与常见陷阱
在绝大多数文字渲染场景中,非零环绕数规则是默认且正确的选择。Core Graphics的CGContextFillPath默认就采用非零规则,Core Text内部光栅化字形时同样基于方向抵消的原理处理孔洞。如果你只是把CGPath交给系统去画,几乎不需要操心。
第一个常见陷阱是坐标系翻转后忘记检查填充结果。CTFontCreatePathForGlyph返回的路径在字形坐标系中Y轴朝上,而macOS的CGContext(尤其是从NSView的drawRect获得的上下文)在未翻转时Y轴朝下。直接绘制会导致字形上下颠倒。很多开发者会套用一个带负Y缩放的仿射变换,这个变换本身没问题,但要注意CGAffineTransformScale(1, -1)是镜像变换,它会把轮廓的绕向也翻过来——原本顺时针的外轮廓变成逆时针。好消息是镜像对所有子路径一视同仁,内外轮廓的方向关系不变,非零规则的填充结果依然正确。但如果你后续要拿这个路径和别的路径做并集运算,方向不一致可能导致意外的结果,此时建议用CGPathCreateCopyByNormalizing windingOrder一类的手段统一方向(或在AppKit中借助NSBezierPath的windingRule处理)。
第二个陷阱出现在把字形路径导出到第三方格式时。比如导出SVG时,SVG的fill-rule属性支持nonzero和evenodd两个值。如果字体本身的方向信息是完整的,写nonzero最保险;但若路径在导出管道中经历过裁剪、合并等运算,方向可能已经丢失,这时显式改写轮廓方向或者使用evenodd更稳妥。下面是一段检测路径子路径方向的示例代码:
// 遍历路径元素,通过有向面积判断子路径的绕向
func windingDirection(of path: CGPath) -> Bool {
var signedArea: CGFloat = 0
var lastPoint: CGPoint = .zero
path.applyWithBlock { elementPointer in
let element = elementPointer.pointee
switch element.type {
case .moveToPoint:
lastPoint = element.points[0]
case .addLineToPoint:
let p = element.points[0]
// 有向面积累加:叉积的一半
signedArea += lastPoint.x * p.y - p.x * lastPoint.y
lastPoint = p
case .addCurveToPoint:
let p = element.points[2]
signedArea += lastPoint.x * p.y - p.x * lastPoint.y
lastPoint = p
default:
break
}
}
// signedArea为正表示逆时针,为负表示顺时针
return signedArea > 0
}第三个实践要点是抗锯齿边缘的质量。字形光栅化对边缘精度极其敏感,直接用CGPath填充时,系统的抗锯齿是基于环绕数做覆盖率计算的,即使像素中心落在轮廓外,只要环绕数覆盖了像素的一部分,就会得到部分透明的颜色。这就是为什么用路径方式渲染小字号文字往往不如Core Text直接排版清晰——建议只在需要矢量效果(如超大标题字、文字沿路径变形)时才走路径方案,常规文本仍交给CTLineDraw。
四、性能与选型建议
CTFontCreatePathForGlyph每次调用都会重新遍历字形的轮廓数据并构建CGPath,频繁调用会产生可观的内存分配开销。对于静态文字效果,应当缓存字形路径:以font + glyph + size为键建立字典,字形路径在字体尺寸不变时可以重复缩放使用。需要注意路径放大后再填充会轻微改变抗锯齿的采样位置,追求像素级一致时应按目标尺寸重新提取。
规则选型可以归纳成一句话:忠实渲染字体原意用非零规则,路径经过运算或方向不可信时用奇偶规则兜底。做字形设计工具的开发者还需要了解,两种规则在用户手动编辑轮廓时会表现出不同的容错性——奇偶规则对方向错误免疫,但会在重叠笔画上产生意料之外的挖空,这一点在实现文字变形特效(例如爆炸拆解、笔画散开动画)时尤其明显。掌握环绕数的底层判定逻辑,比死记结论更能帮助你在遇到渲染异常时快速定位问题。
CTFontCreatePathForGlyphCore Text填充规则修改时间:2026-09-10 14:06:57