15강 - 텍스트 렌더링
텍스트 렌더링은 매우 복잡한 과정입니다. State of Text Rendering 2024가 이를 아주 자세히 소개하므로, 읽어 보기를 권합니다.
이번 강의에서는 다음 내용을 학습합니다.
- TextMetrics란 무엇이며, 서버와 브라우저에서 어떻게 얻는지
- 셰이핑(Shaping)이란 무엇인가
- 텍스트 분할과 자동 줄바꿈, BiDi, 복합 문자
- SDF 아틀라스를 생성하고 그리기에 사용하는 방법
- 이모지를 처리하는 방법
브라우저가 제공하는 Canvas 2D의 Drawing text나 SVG에 익숙한 개발자에게는, 텍스트 렌더링의 복잡도가 상상 이상일 수 있습니다. 아래 그림은 Modern text rendering with Linux: Overview에서 가져온 것으로, 텍스트에서 글리프, 래스터화, 그리고 최종적으로 화면에 그리는 단계까지의 데이터 흐름을 보여 줍니다. HarfBuzz, FreeType, Bidi, 그리고 현재 가장 널리 쓰이는 글꼴 형식인 OpenType은 뒤에서 간단히 소개합니다. 흥미롭게도 HarfBuzz는 "open type"을 뜻하는 페르시아어 이름입니다.
웹 기반 시각화 프로젝트에서의 이전 경험에 비추어 보면, Text rendering in mapbox가 더 실용적인 참고가 됩니다. 앞에서 말한 처리 사슬의 도구 대부분을 직접 다루지는 않기 때문입니다. 그래도 과정을 가능한 한 많이 이해해 두면 텍스트 렌더링의 본질을 파악하는 데 도움이 됩니다.
셰이핑
입력부터 살펴보겠습니다. 한 조각의 텍스트와 글꼴이 주어지며, 글꼴은 fontstack 형태로 제공되므로 배열입니다:
{
"text-field": "Hey",
"text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"]
}fontstack
fontstack은 기본 글꼴과 선택적인 대체 글꼴로 이루어진 순서 있는 목록입니다. https://docs.mapbox.com/help/troubleshooting/manage-fontstacks/
셰이핑이란 무엇일까요? 아래 그림은 Text rendering in mapbox에서 가져온 것으로, 한 줄로 요약하면 각 문자의 위치를 순서대로 배치하는 일입니다. 물론 줄바꿈을 만나는 경우처럼 여러 상황을 고려합니다.

다음 그림은 Modern text rendering with Linux: Overview에서 가져온 것입니다.
문자를 올바른 위치에 놓으려면 TextMetrics를 얻어야 합니다. 가장 단순한 나란히 배치만 하더라도, 최소한 각 문자의 너비는 알아야 합니다. 아래에서는 브라우저 쪽에서 이를 얻는 방법에 집중합니다.
TextMetrics
HTML 명세는 TextMetrics의 정의를 제공하며, 수평 방향과 수직 방향의 두 묶음으로 나뉩니다:
// @see https://html.spec.whatwg.org/multipage/canvas.html#textmetrics
interface TextMetrics {
// x-direction
readonly attribute double width; // advance width
readonly attribute double actualBoundingBoxLeft;
readonly attribute double actualBoundingBoxRight;
// y-direction
readonly attribute double fontBoundingBoxAscent;
readonly attribute double fontBoundingBoxDescent;
readonly attribute double actualBoundingBoxAscent;
readonly attribute double actualBoundingBoxDescent;
readonly attribute double emHeightAscent;
readonly attribute double emHeightDescent;
readonly attribute double hangingBaseline;
readonly attribute double alphabeticBaseline;
readonly attribute double ideographicBaseline;
};먼저 수평 방향의 세 속성에 주목합니다. 아래 그림은 Differences between width and actualBoundingBoxLeft(Right)에서 가져온 것으로, width와 actualBoundingBoxLeft/Right 사이에 차이가 있음을 알 수 있습니다. 간단히 말해 후자는 문자의 최대 경계를 나타냅니다(수직 방향의 actualBoundingBoxAscent/Descent도 같습니다). 반면 전자(파란색)는 후자의 합(빨간색 + 초록색)보다 얼마든지 작을 수 있습니다. 예를 들어 그림 오른쪽의 이탤릭 f는 font-kerning을 고려하면, 다음 문자도 f일 때 두 f가 서로 끼워지듯 나란히 놓일 수 있습니다.

수직 방향 속성을 살펴봅니다. 아래 그림은 Meaning of top, ascent, baseline, descent, bottom, and leading in Android's FontMetrics에서 가져온 것입니다. 먼저 text-baseline을 찾고, Ascent/Descent로 끝나는 속성은 이를 기준으로 합니다.

text-baseline의 값은 text-baseline을 참고할 수 있으며, 아래 그림과 같습니다. 예를 들어 hanging 값을 취하면 hangingBaseline의 값은 0이고, alphabeticBaseline의 값은 alphabetic에서 hanging까지의 거리입니다.

마지막으로 fontBoundingBoxAscent/Descent는 글꼴 자체의 최대 경계이고, actualBoundingBoxAscent/Descent는 실제로 그릴 때 글꼴의 최대 경계입니다. 따라서 전자는 내용이 바뀌어도 높낮이가 들쭉날쭉하지 않은, 일관된 텍스트 배경을 그리는 데 적합합니다.
measureText
TextMetrics는 어떻게 얻을까요? Canvas 2D API는 measureText를 제공하지만, 실제 사용할 때는 width만 쓸 수 있는 경우를 고려해야 합니다. PIXI.TextMetrics를 예로 들면, 구현에서 이 점을 반영하여 actualBoundingBoxLeft/Right가 모두 0이면 width를 사용합니다:
let textMetrics = PIXI.TextMetrics.measureText('Your text', style);
// @see https://github.com/pixijs/pixijs/blob/dev/src/scene/text/canvas/CanvasTextMetrics.ts#L334
const metrics = context.measureText(text);
let metricWidth = metrics.width;
const actualBoundingBoxLeft = -metrics.actualBoundingBoxLeft;
const actualBoundingBoxRight = metrics.actualBoundingBoxRight;
let boundsWidth = actualBoundingBoxRight - actualBoundingBoxLeft;
return Math.max(metricWidth, boundsWidth);글꼴 관련 속성에서는 Ascent/Descent를 측정할 수 있도록 대표적인 문자 몇 개를 고르고, 값을 얻지 못하면 사용자가 직접 입력한 fontSize 값을 사용합니다.
// @see https://github.com/pixijs/pixijs/blob/dev/src/scene/text/canvas/CanvasTextMetrics.ts#L779
context.font = font;
const metrics = context.measureText(
CanvasTextMetrics.METRICS_STRING + // |ÉqÅ
CanvasTextMetrics.BASELINE_SYMBOL, // M
);
const properties = {
ascent: metrics.actualBoundingBoxAscent,
descent: metrics.actualBoundingBoxDescent,
fontSize:
metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent,
};
if (fontProperties.fontSize === 0) {
fontProperties.fontSize = style.fontSize as number;
fontProperties.ascent = style.fontSize as number;
}Canvas 2D API 외에도 브라우저 쪽에는 다음과 같은 선택지가 있습니다.
- opentype.js
- use-gpu는 ab-glyph 기반의 use-gpu-text를 사용합니다
- 점점 더 많은 애플리케이션이 harfbuzzjs를 사용합니다. 참고: State of Text Rendering 2024
웹에서 HarfBuzz를 쓰는 사례가 늘고 있습니다. 처음에는 JavaScript로 트랜스파일되었고, 최근에는 harfbuzzjs를 통해 WebAssembly로 크로스 컴파일됩니다. 온라인 사진 편집기인 Photopea 같은 앱이 이 방식을 사용합니다. Simon Cozens의 Crowbar는 HarfBuzz의 buffer-messaging API로 만든 OpenType 셰이핑 디버거 웹 앱입니다. Sploot은 Simon이 만든 또 다른 웹 앱으로, 글꼴 검사기입니다. Prezi와 Figma도 웹 앱에서 HarfBuzz를 사용합니다.
What HarfBuzz doesn't do에서 말하듯, 개별 문자의 측정 문제를 해결하는 것만으로는 한참 부족합니다. 다음으로 letterSpacing을 살펴봅니다.
HarfBuzz는 셰이핑된 텍스트 조각의 너비를 알려 줄 수 있으며, 이는 양쪽 정렬 알고리즘에 유용한 입력입니다. 그러나 문단, 줄, 줄 길이에 대해서는 아무것도 모릅니다. 단어를 한 줄에 비례하여 맞추기 위해 단어 사이의 간격을 조정하지도 않습니다.
letterSpacing
Canvas 2D API는 문자 사이 간격을 조정하는 letterSpacing을 제공합니다. 텍스트를 측정하기 전에 이 값을 설정합니다:
measureText(
text: string,
letterSpacing: number,
context: ICanvasRenderingContext2D
) {
context.letterSpacing = `${letterSpacing}px`;
// Omitted measurement process
}이후 각 문자의 위치를 배치할 때도 이를 고려해야 하며, 쿼드 생성에서 소개합니다.
문단 레이아웃
개별 문자가 모여 문장이 되고, 문장이 모여 문단이 됩니다. 아래 그림은 Text layout is a loose hierarchy of segmentation에서 가져온 것으로, 텍스트 레이아웃의 계층 구조를 아래에서 위로 보여 줍니다.
Canvas 2D API는 텍스트 그리기에서 문단 관련 기능을 제공하지 않고, 가장 기본적인 Drawing text 함수만 제공합니다. Skia 기반의 CanvasKit은 이를 확장하여 drawParagraph 메서드를 추가로 제공합니다. 참고: CanvasKit Text Shaping
CanvasKit이 HTML Canvas API보다 제공하는 가장 큰 기능 중 하나는 문단 셰이핑입니다.
const paragraph = builder.build();
paragraph.layout(290); // width in pixels to use when wrapping text
canvas.drawParagraph(paragraph, 10, 10);문단 분할부터 시작합니다.
문단 분할
가장 단순한 분할 기준은 명시적인 줄바꿈입니다.
const newlines: number[] = [
0x000a, // line feed
0x000d, // carriage return
];또한 자동 줄바꿈도 고려해야 합니다. 동시에 각 줄의 길이가 가능한 한 비슷해지도록 합니다. 참고: Beautifying map labels with better line breaking
const breakingSpaces: number[] = [
0x0009, // character tabulation
0x0020, // space
0x2000, // en quad
0x2001, // em quad
0x2002, // en space
0x2003, // em space
0x2004, // three-per-em space
0x2005, // four-per-em space
0x2006, // six-per-em space
0x2008, // punctuation space
0x2009, // thin space
0x200a, // hair space
0x205f, // medium mathematical space
0x3000, // ideographic space
];CJK에서는 어떤 문자는 줄의 맨 앞에 올 수 없고, 어떤 문자는 줄의 맨 끝에 올 수 없습니다. 예를 들어 중국어에서는 대부분의 문장 부호가 줄의 맨 앞에 올 수 없습니다. 구체적인 규칙은 Line breaking rules in East Asian languages를 참고합니다. pixi-cjk는 이러한 경우를 처리합니다.

자동 줄바꿈의 또 다른 흔한 경우는 최대 너비를 지정하고, 그 너비를 넘으면 다음 줄로 넘기는 것입니다. 더 나아가 최대 줄 수를 설정할 수 있고, 이 한도를 넘으면 text-overflow 속성이 적용됩니다.
{
wordWrap: true,
wordWrapWidth: 100,
maxLines: 3,
textOverflow: TextOverflow.ELLIPSIS,
}Pixi.js의 구현을 참고할 수 있습니다. CanvasTextMetrics
for (let i = 0; i < chars.length; i++) {
const char = chars[i];
const prevChar = text[i - 1];
const nextChar = text[i + 1];
const charWidth = calcWidth(char);
if (currentWidth > 0 && currentWidth + charWidth > maxWidth) {
if (currentIndex + 1 >= maxLines) {
// 超出最大行数,添加省略号
appendEllipsis(currentIndex);
break;
}
currentIndex++;
currentWidth = 0;
lines[currentIndex] = '';
if (isBreakingSpace(char)) {
continue;
}
}
}BiDi
HarfBuzz는 BiDi를 처리하지 않습니다. What HarfBuzz doesn't do를 참고합니다.
HarfBuzz는 양방향성을 도와주지 않습니다.
BiDi
왼쪽에서 오른쪽(영어)과 오른쪽에서 왼쪽(아랍어 또는 히브리어)이 섞인 텍스트를 처리하기 위한 지원입니다.
예를 들어 LTR과 RTL이 섞인 내용 ABCאבגDEF는, 올바른 효과를 표시하려면 RTL 텍스트의 순서를 직접 바꿔야 합니다:
'ABCאבגDEF'.split(''); // ['A', 'B', 'C', 'ג' ,'ב' ,'א', 'D', 'E', 'F']이 문제는 브라우저에서 쉽게 풀리지 않습니다. 참고: BiDi in Pixi.js. mapbox-gl-rtl-text와 rtl-text는 International Components for Unicode (ICU)를 WASM으로 이식했는데, 그러면 JS 번들 크기가 커집니다. 다만 지금은 bidi-js로 처리할 수 있으며, RTL 문자를 만나면 직접 순서를 뒤집어야 합니다:
import bidiFactory from 'bidi-js';
this.#bidi = bidiFactory();
const embeddingLevels = this.#bidi.getEmbeddingLevels(text);
let bidiChars = '';
for (const segment of segmentStack[0]!) {
const { text, direction } = segment;
bidiChars += direction === 'ltr' ? text : text.split('').reverse().join('');
}인쇄된 아랍어에서 각 문자는 “고립(isolated)”, “어두(initial)”, “어중(medial)”, “어말(final)” 형태를 가질 수 있습니다. 예를 들어 아랍어 글자 “meem”(U+0645)의 네 가지 형태는 다음과 같습니다. 참고: Improving Arabic and Hebrew text in map labels.

ICU에 비해 훨씬 가벼운 JavaScript-Arabic-Reshaper를 선택합니다.
import ArabicReshaper from 'arabic-reshaper';
if (direction === 'ltr') {
bidiChars += text;
} else {
bidiChars += ArabicReshaper.convertArabic(text)
.split('')
.reverse()
.join('');
}BiDi를 해결한 뒤에는 복합 문자 문제를 다뤄야 합니다. 다음 예는 Rust로 구현된 여러 줄 텍스트 처리 라이브러리인 cosmic-text에서 가져온 것입니다.
for line in BidiParagraphs::new(&text) {
for grapheme in line.graphemes(true) {
for c in grapheme.chars() {}
}
}복합 문자
모든 문자가 단일 문자로 이루어지지는 않습니다. clusters는 HarfBuzz가 복합 문자를 다룰 때 쓰는 용어입니다.
텍스트 셰이핑에서 클러스터는 하나의 나눌 수 없는 단위로 다루어야 하는 문자의 나열입니다.
예를 들어 이모지가 그렇습니다. 단일 문자로 처리하면(예를 들어 split('')을 사용하면) 측정이든 이후의 그리기든 잘못 잘리게 됩니다:
'🌹'.length; // 2
'🌹'[0]; // '\uD83C'
'🌹'[1]; //'\uDF39'브라우저 쪽에서는 grapheme-splitter 같은 라이브러리를 사용할 수 있으며, 사용법은 다음과 같습니다:
var splitter = new GraphemeSplitter();
// plain latin alphabet - nothing spectacular
splitter.splitGraphemes('abcd'); // returns ["a", "b", "c", "d"]
// two-char emojis and six-char combined emoji
splitter.splitGraphemes('🌷🎁💩😜👍🏳️🌈'); // returns ["🌷","🎁","💩","😜","👍","🏳️🌈"]하지만 그 크기(압축 후에도 22kB)는 무시할 수 없습니다. BundlePhobia grapheme-splitter를 참고합니다. 그래서 Pixi.js에서는 기본적으로 브라우저 자체의 Intl.Segmenter로 복합 문자를 처리하며, 이 기능은 대부분의 최신 브라우저에서 지원됩니다:
// @see https://github.com/pixijs/pixijs/blob/dev/src/scene/text/canvas/CanvasTextMetrics.ts#L121C19-L131C10
const splitGraphemes: (s: string) => string[] = (() => {
if (typeof (Intl as IIntl)?.Segmenter === 'function') {
const segmenter = new (Intl as IIntl).Segmenter();
return (s: string) => [...segmenter.segment(s)].map((x) => x.segment);
}
return (s: string) => [...s];
})();우리도 이 방식을 채택하며, 나중에 문자의 SDF를 생성할 때 사용합니다.
text-align
text-align을 구현하는 일은 간단합니다.

let offsetX = 0;
if (textAlign === 'center') {
offsetX -= width / 2;
} else if (textAlign === 'right' || textAlign === 'end') {
offsetX -= width;
}하지만 수직 정렬을 구현하는 일은 그렇게 간단하지 않습니다. text-baseline은 한 줄 텍스트에만 해당하고, 많은 경우 문단 전체를 가운데에 맞추고 싶습니다. flex 레이아웃을 다룰 때 소개하겠습니다.
렌더링
아래 그림은 이 과정에서 FreeType 글꼴 렌더링 엔진의 위치를 보여 줍니다.
구체적인 텍스트 렌더링 기법에 집중해 보겠습니다. 문자 집합이 제한된 라틴 문자라면, 글리프 아틀라스를 오프라인으로 생성한 뒤 런타임에 GPU로 올리는 것이 충분히 가능하며, freetype-gl이 이 방식으로 구현되어 있습니다.
글리프 아틀라스는 타일의 나머지 데이터와 함께 GPU에 텍스처로 업로드되는 하나의 이미지입니다. 다음은 글리프 아틀라스를 시각적으로 나타낸 것입니다.
다음 그림은 Drawing Text with Signed Distance Fields in Mapbox GL에서 가져온 것입니다.

하지만 이 비벡터 방식의 문제는 확대하면 문자가 매우 흐려진다는 점입니다. 아래 그림은 Distance field fonts에서 가져온 것입니다.

현재 주류인 SDF 기반 해법은 Valve의 논문 Improved Alpha-Tested Magnification for Vector Textures and Special Effects에서 나왔으며, 이 해법의 장점은 다음과 같습니다.
- 확대해도 선명하게 유지됩니다
- 안티앨리어싱, 헤일로(Halo), 그림자 등의 기능을 구현하기 쉽습니다
하지만 가장자리가 충분히 날카롭지 않다는 문제가 있습니다. 다만 시각화 장면에서는 허용 범위가 더 넓고, msdf 같은 방법으로 최적화할 수도 있습니다. 또한 SDF는 font hinting도 지원하지 못합니다.
먼저 단일 문자의 SDF 생성 상황을 살펴보겠습니다.
SDF 생성
아래 그림은 msdf-atlas-gen에서 가져온 것이며, SDF와 MSDF 두 종류의 아틀라스에 주목합니다.
| 하드 마스크 | 소프트 마스크 | SDF | PSDF | MSDF | MTSDF | |
|---|---|---|---|---|---|---|
| 채널: | 1 (1-bit) | 1 | 1 | 1 | 3 | 4 |
| 안티앨리어싱: | - | 예 | 예 | 예 | 예 | 예 |
| 확대 가능: | - | - | 예 | 예 | 예 | 예 |
| 날카로운 모서리: | - | - | - | - | 예 | 예 |
| 부드러운 효과: | - | - | 예 | - | - | 예 |
| 하드 효과: | - | - | - | 예 | 예 | 예 |
오프라인 생성이 허용된다면 msdf-atlas-gen이나 node-fontnik을 사용할 수 있습니다(mapbox는 서버에서 protocol buffer로 인코딩된 SDF를 생성하는 데 이를 사용합니다). 하지만 CJK 문자를 고려하면 런타임 생성에 tiny-sdf를 사용할 수 있으며, 사용법은 다음과 같습니다. 글꼴 관련 속성을 넘기면 픽셀 데이터를 얻습니다.
const tinySdf = new TinySDF({
fontSize: 24,
fontFamily: 'sans-serif',
});
const glyph = tinySdf.draw('泽'); // Contains pixel data, width and height, character metrics, etc.생성 원리를 간단히 분석해 보겠습니다. 먼저 브라우저 Canvas2D API인 getImageData로 픽셀 데이터를 얻지만, 쓸 때는 버퍼 여백을 둡니다. 이는 헤일로(Halo) 구현을 고려한 것입니다.
const size = (this.size = fontSize + buffer * 4);가장 무식한 전수 탐색인
2차원 격자에서 거리 필드의 “거리”는 유클리드 거리이므로, EDT(Euclidean Distance Transform)는 다음과 같이 정의됩니다. 여기서
여기서 첫 부분은
따라서 1차원 거리 제곱만 고려하면 됩니다.
위의 1차원 거리 제곱 필드 계산식을 기하학적으로 이해하면, 사실 포물선들의 집합입니다. 각 포물선의 최저점은

따라서 이 포물선 집합의 하한, 즉 아래 그림의 실선 부분이 EDT의 계산 결과를 나타냅니다.

이 하한을 찾으려면 임의의 두 포물선의 교점을 계산해야 합니다. 예를 들어 두 포물선
이제 EDT 1D를 계산할 준비가 되었습니다. 하한에서 가장 오른쪽 포물선의 일련번호를

완전한 알고리즘은 다음과 같으며, tiny-sdf가 이를 구현합니다(EDT 1D). 변수 이름까지 일치합니다.

Sub-pixel Distance Transform은 이 알고리즘의 아이디어를 자세히 소개합니다. 안쪽 거리 필드와 바깥쪽 거리 필드를 따로 계산한 뒤 둘을 합칩니다.
부호 있는 거리 필드를 만들려면 안쪽과 바깥쪽에 대해 각각 이렇게 한 다음, 둘을 안쪽 − 바깥쪽, 또는 그 반대로 합칩니다.
가장 기본적인 1D에서 시작합니다. 먼저 입력이 검은색과 흰색뿐이라고, 즉 앞에서 말한 하드 마스크라고 가정하고, 바깥쪽 거리 필드를 먼저 계산합니다.

다음으로 안쪽 거리 필드를 계산합니다. I는 O를 뒤집은 것입니다.
마지막으로 합칩니다. 바깥쪽 거리 필드에서 안쪽 거리 필드를 뺍니다.
tiny-sdf의 구현에서 위 과정은 a가 1일 때의 처리 논리에 해당합니다:
// @see https://github.com/mapbox/tiny-sdf/blob/main/index.js#L85
gridOuter.fill(INF, 0, len);
gridInner.fill(0, 0, len);
for (let y = 0; y < glyphHeight; y++) {
for (let x = 0; x < glyphWidth; x++) {
if (a === 1) {
// fully drawn pixels
gridOuter[j] = 0;
gridInner[j] = INF;
} else {
}
}
}
edt(outer, 0, 0, wp, hp, wp, this.f, this.z, this.v);
edt(inner, pad, pad, w, h, wp, this.f, this.z, this.v);
for (let i = 0; i < np; i++) {
const d = Math.sqrt(outer[i]) - Math.sqrt(inner[i]);
out[i] = Math.max(
0,
Math.min(255, Math.round(255 - 255 * (d / this.radius + this.cutoff))),
);
}2D EDT의 계산은 이 절의 처음에 소개한 대로, 1차원 거리 제곱을 두 패스로 나눈 뒤 마지막으로 제곱근을 구합니다. 후처리의 가우시안 블러와 비슷합니다.
푸리에 변환과 같이, 각 행 X에 수평으로 적용한 다음 각 열 Y에 수직으로 적용하면(또는 그 반대로 하면) 2D 이미지에 적용할 수 있습니다.
여기서 height * width 크기의 격자에 대해 복잡도가
// @see https://github.com/mapbox/tiny-sdf/blob/main/index.js#L110
function edt(data, width, height, f, d, v, z) {
// Pass 1
for (var x = 0; x < width; x++) {
for (var y = 0; y < height; y++) {
f[y] = data[y * width + x];
}
// Fix x and calculate 1D distance squared
edt1d(f, d, v, z, height);
for (y = 0; y < height; y++) {
data[y * width + x] = d[y];
}
}
// Pass 2
for (y = 0; y < height; y++) {
for (x = 0; x < width; x++) {
f[x] = data[y * width + x];
}
edt1d(f, d, v, z, width);
for (x = 0; x < width; x++) {
// Take the square root to get the Euclidean distance
data[y * width + x] = Math.sqrt(d[x]);
}
}
}단일 문자의 SDF 생성 문제를 해결했으면, 다음 단계는 모든 문자의 SDF를 하나의 큰 이미지로 합치는 것입니다. 이 이미지는 나중에 텍스처로 사용됩니다.
글리프 아틀라스
개별 SDF는 나중에 텍스처로 쓰기 위해 하나의 큰 이미지로 합쳐야 하며, CSS Sprite와 비슷합니다. 이런 문제를 Bin packing problem이라고 합니다. 작은 상자를 큰 상자에 넣으면서 공간을 합리적으로 써서 틈을 줄입니다. 우리는 potpack을 선택합니다. 이 알고리즘은 가능한 한 정사각형에 가까운 결과를 얻지만, 레이아웃을 생성한 뒤에는 수정할 수 없고 다시 생성만 할 수 있다는 단점이 있습니다.

아틀라스는 텍스트 배치가 현재 사용하는 글리프를 저장합니다. 첫 빌드에는 빈 텍스트도 유효한 메트릭을 갖도록 공백 글리프를 함께 넣습니다. 새 문자가 생기거나 글꼴 패밀리, 굵기, 스타일, 채우기가 바뀌면 아틀라스를 다시 만들 수 있고, 글꼴 크기만 바꿀 때는 그대로 재사용합니다. 보이는 문자만 생성하면, 라벨이 많은 SVG를 가져올 때 텍스트 스타일마다 쓰이지 않는 ASCII 글리프 96개를 미리 구워 넣는 일을 피할 수 있습니다.
이 텍스처는 채널 하나만 쓰면 됩니다. WebGL이라면 gl.ALPHA 형식을 쓸 수 있지만, WebGPU에는 대응하는 형식이 없습니다. 그래서 Format.U8_R_NORM 형식을 사용합니다. 이는 WebGL에서는 gl.LUMINANCE이고 WebGPU에서는 r8unorm입니다.
this.glyphAtlasTexture = device.createTexture({
...makeTextureDescriptor2D(Format.U8_R_NORM, atlasWidth, atlasHeight, 1),
pixelStore: {
unpackFlipY: false,
unpackAlignment: 1,
},
});그런 다음 셰이더에서 r 채널로부터 부호 있는 거리를 얻습니다:
uniform sampler2D u_SDFMap; // glyph atlas
varying vec2 v_UV;
float dist = texture2D(u_SDFMap, v_UV).r;위는 프래그먼트 셰이더의 그리기 논리입니다. 정점 셰이더로 넘기는 각 문자의 위치를 어떻게 계산하는지 살펴보겠습니다.
쿼드 생성
먼저 각 글리프를 글리프 데이터에서 두 개의 삼각형(이를 “quad”라고 합니다)으로 바꿔야 하며, 정점은 네 개입니다.

단순화한 글리프 데이터 SymbolQuad는 다음과 같이 정의됩니다:
// @see https://github.com/mapbox/mapbox-gl-js/blob/main/src/symbol/quads.ts#L42
export type SymbolQuad = {
tl: Point; // The coordinates of the four vertices in local coordinates
tr: Point;
bl: Point;
br: Point;
tex: {
x: number; // Texture coordinates
y: number;
w: number;
h: number;
};
};각 글리프 데이터는 네 개의 정점으로 나누며, uv와 offset을 하나의 stride로 합친 뒤 정점 셰이더에서 a_UvOffset으로 접근합니다:
glyphQuads.forEach((quad) => {
charUVOffsetBuffer.push(quad.tex.x, quad.tex.y, quad.tl.x, quad.tl.y);
charUVOffsetBuffer.push(
quad.tex.x + quad.tex.w,
quad.tex.y,
quad.tr.x,
quad.tr.y,
);
charUVOffsetBuffer.push(
quad.tex.x + quad.tex.w,
quad.tex.y + quad.tex.h,
quad.br.x,
quad.br.y,
);
charUVOffsetBuffer.push(
quad.tex.x,
quad.tex.y + quad.tex.h,
quad.bl.x,
quad.bl.y,
);
charPositionsBuffer.push(x, y, x, y, x, y, x, y);
indexBuffer.push(0 + i, 2 + i, 1 + i);
indexBuffer.push(2 + i, 0 + i, 3 + i);
i += 4;
});정점 셰이더에서는 uv를 텍스처 크기로 나누어, 프래그먼트 셰이더로 넘겨 글리프 아틀라스를 샘플링할 텍스처 좌표를 얻습니다. 동시에 offset에 글꼴 스케일을 곱해 앵커 포인트 기준의 오프셋을 구하고, 마지막으로 위치와 오프셋을 더해 최종 정점 위치를 얻습니다:
v_Uv = a_UvOffset.xy / u_AtlasSize;
vec2 offset = a_UvOffset.zw * fontScale;
gl_Position = vec4((u_ProjectionMatrix
* u_ViewMatrix
* u_ModelMatrix
* vec3(a_Position + offset, 1)).xy, zIndex, 1);마지막으로 글리프 데이터를 어떻게 계산하는지 살펴보겠습니다. 이전 절에서 문단을 분할한 뒤 여러 줄의 문자열 배열 lines를 얻었고, textAlign letterSpacing fontMetrics와 함께 각 문자의 앵커 포인트 기준 위치 정보를 계산합니다.
function layout(
lines: string[], // after paragraph segmentation
fontStack: string,
lineHeight: number,
textAlign: CanvasTextAlign,
letterSpacing: number,
fontMetrics: globalThis.TextMetrics & { fontSize: number },
): PositionedGlyph[] {}여기서는 mapbox-gl-js shaping.ts의 구현을 참고합니다:
export type PositionedGlyph = {
glyph: number; // charCode
x: number;
y: number;
scale: number; // The scale ratio calculated according to the zoom level
fontStack: string;
};lines.forEach((line) => {
const lineStartIndex = positionedGlyphs.length;
canvasTextMetrics.splitGraphemes(line).forEach((char) => {});
});개선
현재 렌더링된 텍스트는 확대하면 뚜렷한 “인공적인 흔적”이 있습니다. 분명한 이유 하나는 생성된 SDF의 해상도입니다. Mapbox의 현재 구현에서는 SDF_SCALE로 이를 제어합니다. 생성된 SDF의 해상도가 높을수록 더 섬세하지만, 성능도 떨어집니다.
https://github.com/mapbox/mapbox-gl-js/blob/main/src/render/glyph_manager.ts#L34
SDF_SCALE의 선택은 성능과 품질 사이의 절충입니다. 글리프 생성 시간은 스케일에 따라 제곱으로 늘어나지만, 품질 향상은 스케일이 기기의 픽셀 비율보다 커지면 급격히 줄어듭니다. 스케일 2는 받아들일 수 있는 비용으로 HDPI 화면에서 눈에 띄는 개선을 얻습니다.
아래 그림은 SDF_SCALE이 1과 4일 때 확대한 비교 결과입니다.
| SDF_SCALE = 1 | SDF_SCALE = 4 |
|---|---|
![]() | ![]() |
하지만 먼저 SDF 해상도는 조정하지 않겠습니다. tiny-sdf의 현재 구현에는 여전히 문제가 있습니다.
서브픽셀 거리 변환
Sub-pixel Distance Transform은 tiny-sdf 구현에 문제가 있음을 지적하고, 개선된 구현을 제시합니다.
먼저 Canvas로 흑백만 있는 하드 마스크가 아니라 회색조 이미지를 생성합니다.

tiny-sdf의 구현에서 a가 1이 아닐 때는 다음과 같이 처리합니다:
// @see https://github.com/mapbox/tiny-sdf/blob/main/index.js#L89
if (a === 1) {
} else {
const d = 0.5 - a; // aliased pixels
gridOuter[j] = d > 0 ? d * d : 0;
gridInner[j] = d < 0 ? d * d : 0;
}
MSDF
저해상도 거리 필드로 복원하면 문자의 모서리가 너무 부드러워져 원래의 날카로움을 유지하지 못합니다. 이제 이 문제를 해결해 보겠습니다.

거리 필드에는 집합 연산을 적용할 수 있습니다. 아래 그림은 Shape Decomposition for Multi-channel Distance Fields에서 가져온 것으로, 두 거리 필드를 비트맵의 두 성분(R, G)에 각각 저장합니다. 복원할 때 이 두 거리 필드는 모서리에서 부드럽지만, 교집합을 쓰면 날카롭게 복원하는 효과를 얻을 수 있습니다.

분해 알고리즘은 원논문 Shape Decomposition for Multi-channel Distance Fields의 4.4절 Direct multi-channel distance field construction을 참고할 수 있습니다. 실제 사용에서는 저자가 msdfgen을 제공하며, 저해상도에서 MSDF의 효과가 훨씬 좋고 더 높은 해상도의 SDF보다도 낫다는 것을 알 수 있습니다.
생성 도구로는 온라인 도구인 MSDF font generator와 CLI 도구인 msdf-bmfont-xml이 있습니다. 이 도구들은 MSDF 아틀라스를 생성하는 동시에, 이후 그리기에 쓸 각 문자의 레이아웃 정보가 담긴 fnt 또는 json 파일도 생성합니다. pixi-msdf-text는 Pixi.js를 사용한 완전한 예이며, BitmapFontLoader로 fnt 파일을 불러옵니다. 우리 프로젝트도 그 구현을 참고합니다.
프래그먼트 셰이더에서 median으로 복원할 때:
float median(float r, float g, float b) {
return max(min(r, g), min(max(r, g), b));
}
#ifdef USE_MSDF
vec3 s = texture(SAMPLER_2D(u_Texture), v_Uv).rgb;
float dist = median(s.r, s.g, s.b);
#else아래 예에서는 확대해도 문자의 가장자리가 날카롭게 유지되는 것을 볼 수 있습니다.
font-kerning
미리 생성한 비트맵 글꼴에는 kernings를 넣을 수 있으며, 고정된 순서의 두 문자 사이 간격을 조정할 수 있습니다.
예를 들어 https://pixijs.com/assets/bitmap-font/desyrel.xml
<kernings count="1816">
<kerning first="102" second="102" amount="2" />
<kerning first="102" second="106" amount="-2" />
</kernings>바운딩 박스 계산과 레이아웃 모두 이를 고려해야 합니다. 다음 예는 그 차이를 보여 줍니다.
런타임에 font-kerning을 얻으려면 https://github.com/mapbox/tiny-sdf/issues/6#issuecomment-1532395796에서 제시한 방법을 참고할 수 있습니다.

const unkernedWidth =
tinySdf.ctx.measureText('A').width + tinySdf.ctx.measureText('V').width;
const kernedWidth = tinySdf.ctx.measureText('AV').width;
const kerning = kernedWidth - unkernedWidth; // a negative value indicates you should adjust the SDFs closer together by that much이모지
EmojiEngine처럼 일부 이모지 렌더링 구현은 미리 생성한 이모지 아틀라스 방식을 사용합니다. 하지만 이 방식은 텍스처 크기 제한이 있을 뿐 아니라, 플랫폼마다 다른 이모지 모양을 유지하지도 못합니다. 그래서 SDF와 비슷하게, 현재 플랫폼의 런타임에서 필요할 때 이모지를 생성하고자 합니다.
이 방식과 SDF의 가장 큰 차이는 알파 채널만 남길 수 없고, 이후의 거리 필드 복원을 위해 RGB 세 채널을 모두 보존해야 한다는 점입니다.
SVG 내보내기
여러 줄 텍스트를 SVG로 내보낼 때는 <tspan>을 사용할 수 있으며, dy를 측정한 줄 높이로 설정합니다:
<text
x="0"
y="0"
dominant-baseline="ideographic"
id="node-text-5"
font-family="Gaegu"
font-size="16"
fill="black"
transform="matrix(1,0,0,1,50,234)"
>
<tspan x="0" dy="17.368">Abcdef</tspan>
<tspan x="0" dy="17.368">ghijklm</tspan>
<tspan x="0" dy="17.368">nop(ide</tspan>
</text>더 읽어보기
- State of Text Rendering 2024
- Signed Distance Field Fonts - basics
- Approaches to robust realtime text rendering in threejs (and WebGL in general)
- Easy Scalable Text Rendering on the GPU
- Text Visualization Browser
- Rive Text Overview
- Texture-less Text Rendering
- Text layout is a loose hierarchy of segmentation
- End-To-End Tour of Text Layout/Rendering
- Text rendering in mapbox
- Rendering Crispy Text On The GPU
- Localization, languages, and listening
- RTL languages in Figma

