텍스트 렌더링 구현을 위한 코딩 어드벤처
요약
본 기사는 '텍스트 렌더링'이라는 주제로 코딩 어드벤처를 진행하며, 폰트 파일(TTF)의 내부 구조를 분석하는 과정을 다룹니다. 화자는 TTF 파일 포맷을 이해하기 위해 참조 매뉴얼을 탐독하지만, 복잡한 용어들 때문에 어려움을 겪습니다. 결국 핵심은 저해상도에서 텍스트가 명확하게 표시되도록 보장하는 것이며, 글리프 테이블(Glyph Table)을 찾는 것을 목표로 합니다. 초기 파일 읽기 과정에서 데이터 타입과 엔디언 방식(Big Endian vs Little Endian)의 차이점을 발견하고, 이를 해결하기 위한 폰트 리더 클래스를 구현합니다.
핵심 포인트
- 텍스트 렌더링은 단순히 글자를 보여주는 것을 넘어 저해상도 환경에서도 명확성을 보장하는 복잡한 과정이다.
- TrueType Font (TTF) 파일 포맷을 이해하려면 참조 매뉴얼이 필요하며, 여기에는 필수 테이블(Mandatory Tables)과 같은 구조가 포함되어 있다.
- 폰트 파일을 읽기 위해서는 폰트 디렉토리(Font Directory)를 통해 테이블의 개수와 위치를 파악해야 한다.
- 파일 포맷이 Big Endian 방식일 경우, Little Endian 시스템에서 데이터를 정확히 읽어오기 위해 엔디언 변환 처리가 필수적이다.
비디오: Coding Adventure: Rendering Text
채널: Sebastian Lague
재생 시간: 70분 54초
언어: 영어
전체 여러분, Coding Adventures의 새로운 에피소드에 오신 것을 환영합니다. 오늘은 제가 항상 당연하게 여겨왔던 것 중 하나인 텍스트 렌더링 (Rendering Text)을 시도해 보려고 합니다. 우선 폰트 (Font)가 필요한데, 제 하드 드라이브에서 찾은 첫 번째 폰트인 JetBrains Mono Bold를 가져왔습니다. 안에 무엇이 들어있는지 확인하기 위해 텍스트 에디터로 열어보겠습니다. 예상대로 원본 내용은 전혀 이해할 수 없는 상태이므로, 이를 해독하기 위한 가이드가 필요할 것입니다. 이것은 TTF, 즉 TrueType Font 파일인데, 1980년대 후반 Apple에서 개발한 형식이라고 들었습니다. 그래서 그들의 개발자 웹사이트를 살펴보고 이 참조 매뉴얼 (Reference Manual)을 찾아냈습니다. 부디 이해하기 쉽고 간단하기를 바랍니다. 좋습니다, 매뉴얼을 훑어보고 있는데, 신비로운 다이어그램들과 길게 나열된 난해한 바이트코드 명령어 (Bytecode Instructions)들, 게다가 프리덤 벡터 (Freedom Vectors), 플룹 값 (Ploop values), 트와일라잇 존 (Twilight zones) 같은 이상한 용어들 때문에 점점 더 당혹스럽고 혼란스러워지고 있습니다. 트와일라잇 존이 대체 무엇인가요? 그것은 빛과 그림자 사이의 중간 지대입니다. 전혀 도움이 안 되네요. 어쨌든, 속도를 조금 늦추고 더 주의 깊게 읽어본 결과, 이러한 복잡성의 대부분은 단 하나의 문제, 즉 저해상도에서 텍스트가 명확하게 표시되거나 인쇄되도록 보장하는 데 집중되어 있다는 것을 깨달았습니다. 불운한 스케일링 (Scaling)으로 인해 글자의 한 부분이 다른 부분보다 두 배 더 두껍게 보여 글자를 읽기 어려워지는 상황 같은 것 말이죠. 따라서 적어도 오늘 우리의 작은 실험에서는 실제로 걱정해야 할 부분은 아니라고 생각하며, 이는 분명 다행스러운 일입니다. 그럼 매뉴얼의 다음 섹션으로 넘어가 보겠습니다. 여기에는 모든 폰트가 반드시 포함해야 하는 필수 테이블 (Mandatory Tables) 목록이 나와 있는데, 저는 모양이 저장되어 있을 것 같은 글리프 테이블 (Glyph Table)이 가장 기대됩니다.
우리의 첫 번째 목표는 단순히 그 테이블을 찾는 것입니다. 그리고 이 부분이 유망해 보이는데, 폰트 디렉토리 (Font Directory)는 폰트 파일의 내용에 대한 가이드 역할을 합니다. 정말 듣던 중 반가운 소리네요. 이것이 파일에서 우리가 마주하게 될 가장 첫 번째 데이터 블록이며, 테이블의 개수를 제외하고는 이 데이터 자체에 대해서는 크게 신경 쓸 필요가 없을 것 같습니다. 따라서 우리는 32비트 정수 (32-bit integer) 하나를 건너뛴 다음, 이 16비트 정수 (16-bit integer)를 읽어 들여야 합니다. 바로 그렇게 수행하기 위한 간단한 C# 코드는 다음과 같습니다. 참고로 저는 여기서 테이블의 개수를 출력하고 있는데, 이는 그 값이 타당한지 확인하기 위함입니다. 매뉴얼에 나열된 테이블들을 토대로 생각했을 때, 필수적인 9개에서 최대 50개 사이의 값일 것이라 예상하기 때문입니다. 그럼 빠르게 실행해 보겠습니다. 테이블의 개수가 4352로 나오네요. 벌써 제가 무언가 잘못하고 있는 걸까요? 좋습니다, 드디어 이 osdev 위키 항목에서 답을 찾았습니다. 바로 파일 포맷이 빅 엔디언 (Big Endian) 방식이라는 것입니다. 이는 각 값의 개별 바이트가 제 리틀 엔디언 (Little Endian) 컴퓨터가 예상하는 것과 반대 순서로 저장되어 있음을 의미합니다. 그래서 저는 16비트 값을 읽어 들일 때 백그라운드에서 이 리틀 엔디언 변환이 일어나도록 하는 간단한 폰트 리더 (Font Reader) 클래스를 빠르게 만들었습니다. 이렇게 하면 매번 변환을 걱정할 필요가 없습니다. 좋습니다, 그것을 사용하여 프로그램을 다시 실행해 보겠습니다. 이제 테이블의 개수가 17로 나오는데, 훨씬 믿을 만한 수치입니다. 이는 우리가 테이블 디렉토리 (Table Directory)로 진행할 수 있음을 의미합니다. 여기서 얻을 수 있는 것은 각 테이블을 식별하는 네 글자의 태그 (Tag)이며, 우리는 이미 그것들이 어떻게 생겼는지 잠시 살펴보았습니다. 그리고 그와 함께 테이블의 위치가 바이트 오프셋 (Byte Offset) 형태로 제공됩니다. 그래서 코드에 전체 테이블 개수에 대한 작은 루프를 추가했고, 그 안에서 단순히 각 테이블의 메타데이터 (Metadata)를 읽어 들이도록 했습니다. 물론 이 과정에서 태그와 32비트 정수를 실제로 읽기 위한 두 개의 새로운 함수를 추가하여 리더 클래스를 확장해야 했습니다. 작업이 잘 되었기를 바라며, 실행해 보겠습니다.
그리고 이 태그들은 조금 이상해 보입니다. 예를 들어, 여기 아주 작은 머리 모양이 있고 그 옆에 커다란 물음표가 있는데, 이는 공교롭게도 현재 제가 느끼는 기분을 요약해 주는 것 같네요. 아, 첫 번째 테이블에서 우리가 딱히 신경 쓰지 않아도 되었던 나머지 내용들을 건너뛰는 것을 깜빡했다는 사실을 방금 깨달았습니다. 그러니 빠르게 처리해 보죠. 제 기억으로는 16비트(16-bit) 값 세 개였으니, 총 6바이트를 건너뛰어야 합니다. 좋습니다, 이제 테이블을 확보했습니다. 여기 글리프 테이블 (glyph table) 항목을 발견했는데, 이는 우리를 35436 바이트 위치로 안내하고 있습니다. 다음 단계로 그곳을 향해 가보죠. 하지만 먼저 매뉴얼을 빠르게 살펴보겠습니다. 우리가 받게 될 첫 번째 정보는 테이블의 첫 번째 글리프를 구성하는 윤곽선 (contours)의 개수인 것 같습니다. 흥미롭게도 그 값은 음수일 수 있는데, 이는 이것이 다른 글리프들로 구성된 복합 글리프 (compound glyph)임을 나타내지만, 그 부분은 나중에 걱정하도록 하죠. 그다음에는 글리프의 경계 상자 (bounding box)를 알려주는 일련의 16비트 (16-bit) 값들이 있고, 그 뒤를 이어 여기 있는 x 및 y 좌표와 같은 실제 데이터가 나옵니다. 이 데이터들을 빨리 다뤄보고 싶군요. 다만 이 좌표들은 이전 좌표에 대한 상대적인 값이라는 점에 유의해야 합니다. 그래서 일종의 오프셋 (offsets)이라고 말할 수 있겠네요. 또한 이들은 8비트 (8-bit) 또는 16비트 (16-bit) 형식 중 하나일 수 있는데, 여기 있는 플래그 (flags)들이 그 정보를 알려줄 것이라 기대합니다. 그다음에는 명령어 (instructions) 배열이 있는데, 이것이 아마도 우리가 건너뛰고 있는 무시무시한 바이트코드 (bytecode) 관련 내용인 것 같습니다. 마지막으로, 아니 사실 제가 왜 이 테이블을 거꾸로 읽었는지 모르겠지만, 각 윤곽선의 끝점 (endpoints)에 대한 인덱스 (indices)가 있습니다. 제가 제대로 이해하고 있다면, 우리는 실제 점들을 구성할 수 있는 일련의 오프셋 (offsets)들을 읽어 들일 것이고, 예를 들어 3과 6처럼 두 개의 윤곽선 끝 인덱스를 얻는다고 가정해 봅시다. 그것은 첫 번째 윤곽선이 점 0, 1, 2, 3을 연결하고 다시 0으로 돌아가며, 두 번째 윤곽선은 점 4, 5, 6을 연결하고 다시 4로 돌아간다는 것을 의미합니다.
그것은 충분히 쉬워 보이므로, 다른 하나 살펴볼 것은 8비트 (8bit) 플래그 (flag) 값입니다. 비록 실제로는 6개의 비트 (bit)만 사용되는 것으로 보이지만 말입니다. 따라서 각 점 (point)마다 이러한 플래그 값 중 하나를 가지게 되며, 매뉴얼 (manual)에 따르면 비트 (bit) 1과 2는 해당 점의 오프셋 (offset)이 부호 없는 1바이트 (1byte) 형식으로 저장되는지, 아니면 부호 있는 2바이트 (2byte) 형식으로 저장되는지를 알려줍니다. 그다음 약간 복잡해지는데, 비트 (bit) 4와 5는 1바이트 (1byte) 표현을 사용하는지 또는 2바이트 (2byte) 표현을 사용하는지에 따라 두 가지 다른 의미를 가집니다. 첫 번째 경우, 이는 해당 바이트 (byte)를 양수로 간주해야 하는지 또는 음수로 간주해야 하는지를 알려줍니다. 두 번째 경우, 부호가 오프셋 (offset) 자체에 포함되어 있으므로 그것은 필요하지 않으며, 대신 오프셋 (offset)을 건너뛰어야 하는지 여부를 알려줍니다. 그렇게 하면 오프셋 (offset)이 0일 때, 파일 (file)에 실제로 저장할 필요가 없습니다. 이를 통해 여기서 기본적인 압축 (compression)이 일어나고 있음을 알 수 있습니다. 그 점에 대해서 말하자면, 비트 (bit) 3을 살펴봅시다. 이것이 켜져 있으면, 동일한 플래그 (flag)의 반복되는 복사본을 저장하여 공간을 낭비하지 않도록, 이 플래그 (flag)를 몇 번 반복해야 하는지 알아내기 위해 파일 (file)의 다음 바이트 (byte)를 읽으라는 것을 알려줍니다. 마지막으로, 비트 (bit) 0은 점 (point)이 곡선 (curve) 위에 있는지 또는 곡선 (curve) 밖에 있는지를 알려줍니다. 지금 당장은 그것이 무엇을 의미하는지 완전히 확실하지 않지만, 나중에 걱정해도 됩니다. 우선 이 점 (point)들을 먼저 로드 (load)해 봅시다. 참고로, 플래그 (flag) 내의 특정 비트 (bit)가 켜져 있는지 또는 꺼져 있는지 실제로 테스트하기 위해, 저는 여기 이 작은 함수 (function)를 사용하고 있습니다. 이 함수 (function)는 단순히 관심 있는 비트 (bit)가 첫 번째 자리에 오도록 모든 비트 (bit)를 시프트 (shift)한 다음, 마스킹 (masking)을 수행하여 다른 모든 비트 (bit)가 0으로 설정되게 하고, 마지막으로 결과 값이 1과 같은지 확인합니다. 좋습니다, 그리고 여기 이러한 단순한 글리프 (glyph) 중 하나를 실제로 로드 (load)하기 위해 제가 작업해 온 함수 (function)가 있습니다.
그리고 이 함수는 방금 우리가 이야기한 작업을 정확히 수행합니다. 윤곽선 끝 인덱스 (contour end indices)를 읽어 들인 다음, 모든 플래그 (flag) 값들을 읽어 들이며, 물론 각 플래그가 일정 횟수만큼 반복되어야 하는지 확인합니다. 마지막으로 x 및 y 좌표를 읽어 들인 후, 그 모든 데이터를 단순히 반환합니다. 좌표를 읽어 들이기 위해 여기 또 다른 함수가 있는데, 각 좌표는 이전 좌표의 값으로 시작합니다. 그런 다음 이 점에 대한 플래그를 가져오며, 그 플래그에 따라 오프셋 (offset)을 위해 단일 바이트를 읽어 들여 플래그가 지시하는 대로 더하거나 빼거나, 혹은 플래그가 이 점을 건너뛰라고 지시하지 않는 경우에만 16비트 오프셋을 읽어 들입니다. 좋습니다, 이것이 어떤 결과로 나올지 기대되네요. 그래서 여기로 돌아와서, 테이블 태그 (table tags)를 위치로 매핑하기 위한 딕셔너리 (dictionary)를 만들었습니다. 이를 사용하여 리더 (reader)의 위치를 글리프 테이블 (glyph table)의 시작 부분으로 설정한 다음, 단순히 첫 번째 글리프를 읽어 들여 그 데이터를 출력할 수 있습니다. 그럼 빠르게 어떻게 보이는지 확인해 보죠. 좋아, 유망해 보이지만, 실제로 그려보기 전까지는 이것이 의미 없는 데이터인지 아닌지 정말로 알 수 없습니다. 그래서 Unity 엔진에서 이 점들을 빠르게 찍어 보았는데, 이것이 무엇이 되어야 하는지는 모르겠지만, 윤곽선 (contours)을 그리면 분명 명확해질 것이라고 확신합니다. 좋아, 약간 흐트러져 보이긴 하지만, 이것이 누락된 문자 글리프 (character glyph)라고 믿습니다. 그리고 실제로 글리프 테이블의 맨 처음에 위치해야 한다는 내용을 읽었던 기억이 나는데, 그러니 말이 됩니다. 분명히 몇 가지 버그를 해결해야 할 것 같아서 코드를 약간 만져 보았고, 이제는 다음과 같이 작동합니다. 우리는 단순히 모든 끝 인덱스 (end indices)를 루프 (loop) 돌린 다음, 배열에 대한 작은 윈도우 (window)를 생성합니다. 이는 현재 윤곽선에 있는 포인트들에만 접근할 수 있게 해주는데, 분명히 저는 스스로를 믿을 수 없기 때문입니다. 그런 다음 이 포인트들 사이에 선을 그리며, 마지막에는 윤곽선을 닫기 위해 윤곽선의 첫 번째 포인트로 다시 루프를 돌립니다. 좋습니다, 한번 시도해 보죠. 훨씬 좋아 보이네요.
하지만 글리프 (glyph) 하나만으로는 부족합니다. 저는 모든 글리프를 원합니다. 이를 위해 우리는 maxp 테이블로 이동하여 폰트의 전체 글리프 수를 조회하고, 그다음 head 테이블로 이동합니다. 이때 지금 당장 중요하지 않은 여러 항목을 건너뛰어, 글리프의 위치가 2바이트 (2-byte) 또는 4바이트 (4-byte) 형식으로 저장되어 있는지 확인합니다. 마지막으로 loca 테이블로 들어가 glyph table에 있는 모든 글리프의 위치를 추출할 수 있습니다. 그런 다음 그것들을 읽어오기만 하면 됩니다. 폰트 파일을 파싱 (parsing)하는 것이 하루 중 가장 스릴 넘치는 일 목록에서 꽤 낮은 순위에 있겠지만, 이 모든 글자들이 여기에 나타나는 것을 보니 꽤나 흥분된다는 점은 인정해야겠네요. 어쨌든, 이 글리프 중 하나, 예를 들어 커다란 B 같은 것에 줌 인 (zoom in) 해보면, 글리프들이 분명 매우 아름답긴 하지만 아마 약간 각져 보일 것입니다. 그래서 우리의 다음 단계는 이 녀석들을 베지에 (bezier) 곡선으로 만드는 것이 되어야 한다고 생각합니다. 이 채널에서 베지에에 대해 이미 여러 번 떠들었지만, 짧게 설명하자면, 두 점이 있고 그 사이를 곡선으로 그리고 싶다면 곡선이 어떻게 휘어질지를 제어하기 위해 최소 하나 이상의 추가 제어점 (control point)이 필요합니다. 이제 이 곡선을 시각화하기 위해, 한 점이 시작점에서 출발하여 이 중간 제어점을 향해 일정한 속도로 이동한다고 상상해 봅시다. 동시에 두 번째 점은 첫 번째 점이 여정을 마치는 데 걸리는 시간과 동일한 시간 동안 끝점을 향해 이동합니다. 이런 식으로 말이죠. 그리 대단히 흥미롭지는 않지만, 인생의 많은 일이 그렇듯 레이저 (laser)를 추가하면 더 흥미진진해질 수 있습니다. 그러니 두 이동하는 점 사이에 레이저 빔을 그려봅시다. 이것만으로도 벌써 좀 나아졌네요. 여기에 화려함을 더하기 위해, 이전 빔들의 잔상 (ghost trail)도 남겨봅시다. 이제 정말 제대로 되어가고 있군요. 이 빔들이 그려내는 곡선이 바로 우리의 베지에 곡선 (bezier curve)이며, 이제 우리에게 필요한 것은 이 곡선을 수학적으로 기술할 방법뿐입니다. 그리고 그것은 놀라울 정도로 쉽습니다.
마지막으로 하나의 이동하는 점을 상상하면 됩니다. 이 점은 처음 두 이동하는 점 사이를 움직이며, 동일한 시간 동안 여정을 마칩니다. 이 세 번째 점의 경로를 관찰하면, 이 점이 우리가 만든 곡선을 따라 완벽하게 이동하는 것을 볼 수 있습니다. 여기 이 점들의 위치를 계산하는 데 도움이 되는 작은 함수가 있습니다. 이 함수는 시작 위치와 목적지, 그리고 여정의 진행도를 측정하는 0과 1 사이의 값인 일종의 시간 값 (time value)을 입력으로 받습니다. 주어진 시간에서의 점의 위치는 시작 지점에 시작점에서 끝 지점까지 이동하는 오프셋 (offset)을 곱한 값을 더하여 계산됩니다. 이렇게 선형 보간 (linear interpolation)을 통해 우리는 베지에 보간 (bezier interpolation)을 구축할 수 있으며, 이는 중간 단계인 A와 B 지점을 계산합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 YouTube Sebastian Lague (절차적 생성)의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기