Featured image of post Почему мелкий шрифт остается четким и читаемым: математика рендеринга шрифтов, кривых Безье, виртуальной машины хинтинга и субпиксельного сглаживания

Почему мелкий шрифт остается четким и читаемым: математика рендеринга шрифтов, кривых Безье, виртуальной машины хинтинга и субпиксельного сглаживания

Глубокий математический и системный разбор современного рендеринга шрифтов: базисные многочлены Бернштейна, квадратичные и кубические кривые Безье, алгоритм де Кастельжо, топология ненулевого индекса обхода, аналитическое интегрирование площади трапеций, стековая виртуальная машина хинтинга TrueType, 5-отводный КИХ-фильтр ClearType, линейное альфа-смешивание с гамма-коррекцией, многоканальные знаковые поля расстояний (MSDF) и прямые GPU-шейдеры Безье (Slug).

Представьте, что вы рассматриваете текст на этом экране через мощную лупу. При стандартном кегле основного текста $12\,\text{px}$ ($9\,\text{pt}$ на классическом мониторе с разрешением $96\,\text{DPI}$) вся знакоместо буквы представляет собой крошечную дискретную шахматную доску размером всего $12$ на $12$ пикселей — лишь $144$ микроскопических светящихся квадратика.

Тем не менее внутри этой тесной сетки $12 \times 12$ операционная система десятки тысяч раз в секунду отрисовывает сложнейшие глифы: от иероглифа кандзи «鬱» из $29$ черт до двухъярусной латинской строчной антиквы «g» или буквы «m» с тремя равноудаленными вертикальными штрихами (Stems). Каждый вертикальный штрих сохраняет идеально ровную толщину и насыщенность, плотные горизонтальные перекладины не сливаются в черное пятно и не проваливаются между центрами пикселей (Dropout), а диагональные кривые лишены ступенчатых зазубрин (Jaggies).

С точки зрения теории информации и цифровой обработки сигналов (DSP) это повседневное явление — настоящее инженерное чудо, бросающее вызов теореме Котельникова (Найквиста — Шеннона). При наивном геометрическом уменьшении векторного контура на сетку $12 \times 12$ высокие пространственные частоты выше предела Найквиста породили бы катастрофический алиасинг, превратив текст в нечитаемое серое пятно.

За четкостью мелкого текста стоит полвека достижений информатики: вычислительная геометрия (многочлены Бернштейна и сплайны Безье), алгебраическая топология (индекс обхода ориентированных контуров), виртуальные машины (исполняемый байт-код внутри каждого файла .ttf), психофизика зрения и теория КИХ-фильтров (субпиксельный рендеринг ClearType), радиометрия (альфа-смешивание в линейном световом пространстве) и параллельные алгоритмы GPU-шейдеров (MSDF и прямое вычисление кривых Slug).


1. От растрового плена к независимым от разрешения контурным шрифтам

Ранние компьютерные системы полагались на растровые шрифты (Bitmap Fonts), хранившие каждый глиф как бинарную матрицу $M \in \{0, 1\}^{H \times W}$. Несмотря на четкость в родном размере, они страдали от фатальной зависимости от разрешения: масштабирование или поворот порождали «лесенки» или размытие, а экранные шрифты $72\,\text{DPI}$ не годились для лазерных принтеров ($300\,\text{DPI}$) и фотонаборных автоматов ($2400\,\text{DPI}$).

Прорыв совершили контурные векторные шрифты (Outline Fonts): сначала PostScript Type 1 (1984), созданный Джоном Уорноком и Чаком Гешке в Adobe на базе кубических кривых Безье, затем TrueType (.ttf, 1991) от Apple и Microsoft на базе квадратичных кривых Безье и открытой стековой виртуальной машины хинтинга. В 1996 году обе технологии объединились в стандарте OpenType (таблицы glyf и CFF/CFF2).

  flowchart TD
    A["Файл шрифта OpenType (.ttf / .otf)<br/>Таблицы SFNT: head, cmap, GSUB/GPOS, glyf или CFF"] --> B["1. Шейпинг текста (HarfBuzz / DirectWrite / CoreText)<br/>Кодовые точки Unicode → ID глифов и позиции"]
    B --> C["2. Извлечение контуров и аффинное преобразование<br/>Масштабирование из EM-квадрата (2048 upem) в сетку PPEM"]
    C --> D["3. Выполнение Grid-Fitting / Хинтинга<br/>Байт-код виртуальной машины TrueType (fpgm, prep, cvt) или автохинтер FreeType<br/>Привязка штрихов к целочисленным границам пикселей"]
    D --> E["4. Линеаризация кривых и топология строк развертки<br/>Подразделение де Кастельжо / Формула Ванга + Правило ненулевого индекса"]
    E --> F["5. Аналитическое интегрирование трапеций и субпиксельная фильтрация<br/>3-кратная горизонтальная передискретизация + 5-отводный КИХ-фильтр"]
    F --> G["6. Линейное альфа-смешивание с гамма-коррекцией<br/>Перевод sRGB в линейную яркость → Кадровый буфер"]

2. Вычислительная геометрия контуров: базис Бернштейна и кривые Безье

Четыре фундаментальных свойства многочленов Бернштейна

Явные многочлены в степенном базисе $y = a_3 x^3 + a_2 x^2 + a_1 x + a_0$ не способны описать вертикальные касательные и численно неустойчивы при поворотах. Инженеры Поль де Кастельжо (Citroën, 1959) и Пьер Безье (Renault, 1962) определили параметрические кривые через базисные многочлены Бернштейна степени $n$ и $n+1$ опорных точек $\mathbf{P}_0, \dots, \mathbf{P}_n \in \mathbb{R}^2$:

$$\mathbf{B}_n(t) = \sum_{i=0}^{n} B_{i,n}(t)\,\mathbf{P}_i, \qquad B_{i,n}(t) = \binom{n}{i} (1 - t)^{n - i} t^i, \qquad t \in [0, 1]$$
  1. Неотрицательность, разбиение единицы и свойство выпуклой оболочки: На отрезке $t \in [0, 1]$ всегда $B_{i,n}(t) \ge 0$, а по биному Ньютона сумма всех базисных функций тождественно равна $1$: $$\sum_{i=0}^{n} B_{i,n}(t) = \bigl((1 - t) + t\bigr)^n = 1$$ Являясь выпуклой комбинацией опорных точек, кривая $\mathbf{B}_n(t)$ целиком лежит внутри их выпуклой оболочки (Convex Hull), что позволяет за $O(1)$ отсекать кривые по ограничивающему прямоугольнику (Bounding Box).
  2. Аффинная инвариантность: Для любого аффинного преобразования $\Phi(\mathbf{x}) = A\mathbf{x} + \mathbf{b}$ справедливо $\Phi(\mathbf{B}_n(t)) = \sum B_{i,n}(t)\,\Phi(\mathbf{P}_i)$: достаточно преобразовать только опорные точки.
  3. Интерполяция концов и управление касательными: $\mathbf{B}_n(0) = \mathbf{P}_0$, $\mathbf{B}_n(1) = \mathbf{P}_n$, а векторы производных на концах равны $\mathbf{B}_n'(0) = n(\mathbf{P}_1 - \mathbf{P}_0)$ и $\mathbf{B}_n'(1) = n(\mathbf{P}_n - \mathbf{P}_{n-1})$.
  4. Свойство уменьшения вариации: Никакая прямая не пересекает кривую Безье большее число раз, чем её контрольную ломаную.

Квадратичные кривые ($n=2$, TrueType) против кубических ($n=3$, OpenType CFF)

  • Квадратичные кривые Безье ($n=2$, таблица glyf TrueType): $$\mathbf{B}_2(t) = (1-t)^2 \mathbf{P}_0 + 2(1-t)t \mathbf{P}_1 + t^2 \mathbf{P}_2$$ Пересечение со строкой развертки находится напрямую через квадратное уравнение. Кроме того, TrueType использует сжатие через неявные срединные точки на кривой (Implicit On-Curve Midpoints): если в потоке данных подряд идут две внеконтурные точки $\mathbf{P}_1, \mathbf{P}_2$, декодер автоматически восстанавливает узловую точку на кривой ровно посередине $\mathbf{M} = \frac{1}{2}(\mathbf{P}_1 + \mathbf{P}_2)$, гарантируя гладкую стыковку $C^1$ и экономя около $30\%$ объема файла.
  • Кубические кривые Безье ($n=3$, PostScript / таблица CFF OpenType): $$\mathbf{B}_3(t) = (1-t)^3 \mathbf{P}_0 + 3(1-t)^2 t \mathbf{P}_1 + 3(1-t)t^2 \mathbf{P}_2 + t^3 \mathbf{P}_3$$ Поскольку вторая производная квадратичной кривой постоянна ($\mathbf{B}_2''(t) = \text{const}$), один квадратичный сегмент не может содержать точку перегиба (например, изгиб буквы «S»). У кубической кривой вторая производная линейна по $t$, что позволяет описывать S-образные перегибы одним сегментом и независимо регулировать кривизну $G^2$ на концах.
ХарактеристикаКвадратичная Безье ($n=2$, TrueType glyf)Кубическая Безье ($n=3$, OpenType CFF)
Уравнение $\mathbf{B}(t)$$(1-t)^2\mathbf{P}_0 + 2(1-t)t\mathbf{P}_1 + t^2\mathbf{P}_2$$(1-t)^3\mathbf{P}_0 + 3(1-t)^2t\mathbf{P}_1 + 3(1-t)t^2\mathbf{P}_2 + t^3\mathbf{P}_3$
Точки перегиба на сегмент$0$ (дуга параболы с постоянным знаком кривизны)До $2$ (описывает «S» одним сегментом)
Степени свободы (при фикс. касательных)$0$ ($\mathbf{P}_1$ жестко задана пересечением касательных)$2$ (длины рычагов управляют кривизной $G^2$)
Бинарное сжатиеНеявные середины $\mathbf{M} = \frac{1}{2}(\mathbf{P}_1 + \mathbf{P}_2)$Стековые подпрограммы CFF CharString и дельты

С помощью алгоритма де Кастельжо $\mathbf{P}_i^{(r)}(t) = (1-t)\mathbf{P}_i^{(r-1)}(t) + t\mathbf{P}_{i+1}^{(r-1)}(t)$ (сводящегося при $t = \frac{1}{2}$ к сложению и битовому сдвигу >> 1) и формулы Ванга (1985)

$$N = \left\lceil \sqrt{ \frac{n(n-1)}{8\tau} \max_{0 \le i \le n-2} \|\mathbf{P}_{i+2} - 2\mathbf{P}_{i+1} + \mathbf{P}_i\| } \right\rceil$$

кривая без рекурсии разбивается на $N$ отрезков с точностью $\tau$.


3. Сканирующая растеризация: индекс обхода и аналитическое интегрирование трапеций

Четно-нечетное правило против правила ненулевого индекса обхода

Чтобы отличить тело глифа от внутрибуквенных просветов (дырок в «O» или «8»), четно-нечетное правило (Even-Odd) дает сбой при самопересечении или наложении штрихов (например, в вариативных шрифтах OpenType), оставляя белые дыры на перекрестьях. Поэтому растеризаторы используют топологическое правило ненулевого индекса обхода (Non-Zero Winding Rule):

$$w(P) = \frac{1}{2\pi} \oint_{\partial\Gamma} d\theta = \sum_{\text{crossings}} \operatorname{sgn}(\Delta y) \ne 0$$

Внешние контуры (по часовой стрелке CW в TrueType, $-1$) и внутренние отверстия (против часовой стрелки CCW, $+1$) взаимно уничтожаются ($w(P) = 0$) внутри отверстий, тогда как перекрывающиеся однонаправленные штрихи дают $w(P) = -2 \ne 0$ и остаются полностью закрашенными.

Аналитическое интегрирование площади трапеций (Libart / FreeType ftgrays / stb_truetype)

Вместо медленного суперсэмплинга алгоритм Рафа Левина (применяемый в FreeType ftgrays.c, stb_truetype и font-rs) вычисляет для каждого отрезка внутри пикселя $(i, j)$ (со знаковым приращением $\Delta y = y_1 - y_0$ и средней абсциссой $\bar{x} \in [0, 1]$) точную площадь трапеции справа от него $\text{delta\_area}[i] \mathrel{+}= (1 - \bar{x})\Delta y$ и приращение полного покрытия $\text{delta\_cover}[i+1] \mathrel{+}= \Delta y$ для всех пикселей правее. Один линейный проход префиксной суммы (Prefix Sum) слева направо дает точное покрытие $\alpha_i \in [0, 1]$ с 256 градациями:

$$C_i = C_{i-1} + \text{delta\_cover}[i], \qquad A_i = C_i + \text{delta\_area}[i], \qquad \alpha_i = \min\bigl(1.0,\; |A_i|\bigr)$$

4. Секретная виртуальная машина внутри шрифта: хинтинг и три философии рендеринга

Без хинтинга при $12\,\text{px}$ три одинаковых вертикальных штриха буквы «m» попадают в разную фазу пиксельной сетки: первый ложится в один пиксель ($\alpha = 0.80$, четкая черная линия), а второй пересекает границу пикселей и размазывается в мутную двухпиксельную серую полосу ($\alpha = 0.60$ и $\alpha = 0.20$).

Для борьбы с этим каждый файл TrueType содержит байт-код (cvt , fpgm, prep, glyf), исполняемый стековой виртуальной машиной в формате с фиксированной запятой $26.6$. С помощью вектора проекции ($\mathbf{P}$) и вектора свободы ($\mathbf{F}$) инструкции MDAP и MIRP привязывают опорные точки к границам пикселей, выравнивают ширину штрихов по глобальной таблице cvt, защищают тонкие линии от исчезновения (Minimum Distance), вносят точечные поправки через DELTAP1 и плавно интерполируют остальные точки кривой командами IUP[0] / IUP[1].

  • Философия Microsoft (GDI / ClearType): Максимальная резкость и контраст за счет жесткой привязки штрихов к пиксельной сетке даже ценой искажения пропорций гарнитуры.
  • Философия Apple (Quartz / CoreText): Абсолютная верность замыслу шрифтового дизайнера (WYSIWYG) и игнорирование байт-кода хинтинга, раскрывшаяся во всей красе на экранах Retina.
  • Автохинтер FreeType (autofit) и вертикальный хинтинг (Y-only): Автоматический геометрический анализ штрихов и синих зон (Blue Zones) во время выполнения с легкой привязкой только по вертикальной оси Y.

5. Субпиксельный рендеринг (ClearType) и линейное смешивание с гамма-коррекцией

Каждый пиксель ЖК-панели состоит из трех вертикальных субпикселей ($R, G, B$) с шагом $\frac{1}{3}$ пикселя по горизонтали. Поскольку человеческое зрение обладает высокой разрешающей способностью по яркости ($Y = 0.2126R + 0.7152G + 0.0722B$) и низкой по цветности, растеризатор выполняет горизонтальную выборку с $3$-кратным разрешением. Для подавления цветных ореолов (Color Fringing) применяется 5-отводный симметричный КИХ-фильтр Гибсона ($\frac{1}{3}[1,1,1] * \frac{1}{3}[1,1,1]$):

$$h_{\text{Gibson}} = \left[\frac{1}{9},\; \frac{2}{9},\; \frac{3}{9},\; \frac{2}{9},\; \frac{1}{9}\right]$$

Сумма коэффициентов с шагом 3 равна $\frac{3}{9} = \frac{2}{9} + \frac{1}{9} = \frac{1}{3}$, что гарантирует идеальный цветовой баланс. Наконец, альфа-смешивание обязано выполняться в линейном пространстве света ($\gamma \approx 2.2$), чтобы избежать искажения толщины шрифта между светлой и темной темами:

$$C_{\text{out}, c} = \Bigl( \alpha_c \, C_{\text{fg}, c}^{\gamma} + (1 - \alpha_c) \, C_{\text{bg}, c}^{\gamma} \Bigr)^{1/\gamma}, \qquad c \in \{R, G, B\},\; \gamma \approx 2.2$$

6. Эпоха GPU: SDF, MSDF (msdfgen) и прямые шейдеры Безье (Slug)

  flowchart TD
    subgraph CPU["Растеризация на CPU (FreeType / DirectWrite)"]
        C1["Кривые Безье + ВМ хинтинга"] --> C2["Интегрирование трапеций + 5-отводный КИХ-фильтр"]
        C2 --> C3["Кэш растровых битмапов по размерам<br/>Плюс: Идеальная четкость при 10–14px<br/>Минус: Размытие или пересчет при 3D-масштабировании"]
    end
    subgraph MSDF["Поля расстояний на GPU (Valve SDF / Chlumský MSDF)"]
        S1["Офлайн-запекание знакового поля расстояний"] --> S2["MSDF: RGB-кодирование ребер для сохранения острых углов"]
        S2 --> S3["Фрагментный шейдер: d = median(r, g, b) + smoothstep<br/>Плюс: Острые углы при любом увеличении из малой текстуры"]
    end
    subgraph Slug["Прямые GPU-шейдеры Безье (Eric Lengyel 'Slug' / 'Vello')"]
        G1["Опорные точки Безье напрямую в буфере GPU"] --> G2["Пространственное разбиение на полосы (Bands)"]
        G2 --> G3["Аналитическое решение квадратных уравнений и индекса обхода в шейдере<br/>Плюс: Без текстурных атласов, мгновенная отрисовка 50 000 кандзи"]
    end

Для 3D-движков Крис Грин (Valve, 2007) предложил знаковые поля расстояний (SDF), хранящие евклидово расстояние $d(\mathbf{p})$ до контура. Чтобы острые углы не скруглялись при увеличении, Виктор Хлумский (2015) разработал MSDF (msdfgen), кодирующий смежные ребра угла в каналах RGB и восстанавливающий угол в шейдере через медиану:

$$d_{\text{MSDF}} = \operatorname{median}(r, g, b) = \max\Bigl(\min(r, g),\; \min\bigl(\max(r, g),\; b\bigr)\Bigr)$$

А для предельно сложных иероглифов алгоритмы прямой оценки на GPU — Slug Эрика Ленгиела (2017) и Vello Рафа Левина — вычисляют пересечения с кривыми Безье и индекс обхода непосредственно во фрагментном или вычислительном шейдере без промежуточных текстур.

comments powered by Disqus