# 002 — 심볼: 글리프와 아이콘을 한 바이너리 형식으로 2026-09-27. Pure [로드맵](/srv/git/pure.git) 중기 3 "단일 심볼 벡터 규격". 착수 기준: - 바꿀 결정: 글자와 아이콘을 그릴 때 실행 중에 폰트 파일(TrueType 테이블, 힌팅 프로그램)과 SVG(XML과 경로 문자열)를 해석할지, 빌드할 때 한 번 곡선 레코드로 바꿔 두고 실행 중에는 한 번 검증한 뒤 제자리에서(매핑 그대로) 쓸지. - 남은 질문: 2차 곡선 레코드만으로, GPU 셰이더로 옮길 수 있는 픽셀별 알고리즘이 FreeType만큼의 커버리지를 내는가? 같은 글리프를 담는 크기와 여는 비용은 폰트·SVG와 비교해 어떤가? - 최소 확인: DejaVu Sans의 글리프를 여러 크기로 참조 래스터라이저와 FreeType(힌팅 없음, 8비트 회색)으로 그려 픽셀 차이를 재고, 크기와 여는 시간을 잰다. SVG 아이콘 몇 개를 같은 형식으로 바꿔 그려 본다. - 중단 기준: FreeType과의 평균 차이가 256단계 중 8을 넘으면 형식이나 알고리즘을 다시 본다. ## 형식 (`VEGASYM3`, 리틀 엔디언) | 오프셋 | 내용 | |---|---| | 0 | `VEGASYM3` | | 8 | u16 단위(em당), u16 0 | | 12 | u32 심볼 수, u32 점 수, u32 파일 크기, i16 올림(ascent), i16 내림(descent) | | 28 | 심볼 × 24 B, 코드 순: u32 코드(글리프는 코드 포인트, 아이콘은 빌드 도구가 준 번호), u32 첫 점, u32 점 수, i16 경계 왼쪽·아래·오른쪽·위, i16 advance, u16 플래그(0) | | 28 + 24n | 점 × 4 B: i16 2x + 곡선 밖, i16 2y + 윤곽 시작 | - 좌표는 단위 공간, y가 위로 는다(폰트와 같다). 채우기는 0이 아닌 감김수다. - 올림과 내림은 글꼴의 줄(기준선에서의 높이, 내림은 음수)이다. 글자를 줄에 놓는 표면이 줄 높이와 기준선을 여기서 얻는다. 아이콘만 든 파일은 둘 다 0이다. (3판에서 더했다. 2판의 이 자리는 0이어야 했다.) - 윤곽은 점의 사슬이다(TrueType의 점 모델을 압축 없이). 윤곽은 곡선 위의 시작 점으로 열고, 그 점으로 돌아온 점으로 닫는다. 곡선 밖 점은 2차 곡선의 제어점이고, 곡선 밖 점 둘이 이어지면 그 가운데에 곡선 위 점이 있는 것으로 친다(적지 않는다). 곡선 위 점 둘이 이어지면 직선이다(제어점이 없다). - 그래서 곡선 하나는 점 하나와 그 이웃 둘로 정해진다. 곡선 밖 점은 한 곡선의 제어점이고, 같은 윤곽의 곡선 위 점이 뒤따르는 곡선 위 점은 한 직선의 시작이다. GPU는 곡선 번호 대신 점 번호로 곡선을 가리키고 4바이트 셋을 읽는다. 점 하나가 32비트 한 워드라서 점 표가 그대로 `RG16I` 텍스처나 저장 버퍼가 된다(해석 없이 올린다). - 여는 쪽은 심볼마다 한 번 검증한다. 크기가 맞고, 코드가 오름차순이며, 점 범위가 점 표 안이고, 경계가 뒤집히지 않았고, 첫 점이 곡선 위의 윤곽 시작이며 마지막 점이 곡선 위다. 그러면 곡선 밖 점의 이웃은 늘 같은 심볼 안에 있어서 그다음 읽기는 심볼 밖으로 나가지 않는다. 점 하나하나는 보지 않는다(경계를 벗어난 점은 잘못 그려질 뿐이다). 여는 비용은 점 수가 아니라 심볼 수에 비례한다. - 좌표는 ±16,383 단위 안이다(플래그가 한 비트씩 쓴다). 빌드 도구는 모든 점이 짝수인 동안 단위를 반으로 줄이므로 TrueType 글꼴은 제 단위로 손실 없이 담긴다. - 3차 곡선(OpenType CFF, SVG의 `C`와 호)은 빌드할 때 2차 곡선 사슬로 바꾼다. cu2qu(fontTools)의 방법으로, 이음점이 이웃 제어점의 가운데에 오게 해서(적지 않는다) 오차 1단위 안에 드는 가장 적은 조각을 고른다. ## 그리기 참조 래스터라이저(`vega_coverage`)는 GPU에서 픽셀마다 돌릴 계산을 그대로 C로 쓴 것이다. 픽셀이 덮인 넓이를 정확히 구한다. 곡선마다 픽셀 [0, 1]² 안에 든 부분에서 clamp(1 − x, 0, 1) dy를 적분해 더하면, 닫힌 윤곽 전체의 합이 덮인 넓이가 된다(그린 정리, 반시계면 음수). 곡선은 픽셀의 네 변을 지나는 t(2차 방정식의 근, 최대 8개)에서 나뉘고, 조각마다 픽셀의 왼쪽이면 dy를 통째로, 오른쪽이면 0을, 걸쳐 있으면 t의 3차 다항식을 닫힌 꼴로 적분한다. 겹친 윤곽은 감김수를 넓이로 더한 뒤 절댓값을 1로 자른다 (FreeType과 같다). 처음 계획은 Slug(Lengyel, JCGT 2017)처럼 픽셀 중심에서 +x와 +y로 광선을 쏘아 교차 위치를 픽셀 폭 안에서 잘라 더하고 두 값을 평균하는 방식이었다. DejaVu Sans ASCII에서 FreeType과의 평균 차이가 12px 15.4, 16px 12.3, 32px 6.8(최악 79–82)로 중단 기준을 넘었다. 한 줄의 1차원 커버리지 둘의 평균은 가는 가로·세로 획이 픽셀 중심을 지나느냐에 따라 크게 틀린다. 같은 정렬로 16×16 슈퍼샘플링을 재면 2.5, 정확 넓이는 1.8이어서, 정렬이 아니라 알고리즘 문제였다. 정확 넓이는 광선 방식보다 느리지도 않았다 (12px 글리프당 20 µs 대 21 µs, CPU 참조 구현). ## 측정 (Raspberry Pi 5, FreeType 힌팅 없음 8비트 회색, 폰트 원점에 맞춤) | 글꼴 | 8px | 12px | 16px | 32px | 64px | |---|---|---|---|---|---| | DejaVu Sans ASCII (TrueType) 평균 차이 / 최악 | 1.86 / 15 | 1.83 / 17 | 1.51 / 20 | 0.70 / 23 | 0.40 / 26 | | Nimbus Sans ASCII (CFF) | 3.50 / 31 | 3.22 / 33 | 2.71 / 38 | 1.57 / 47 | 0.83 / 49 | | Baekmuk Batang 한글 11,172자 (TrueType) | | | 1.21 / 24 | | | | Noto Sans CJK 한글 11,172자 (CFF) | | | 1.71 / 45 | | | 차이가 큰 픽셀을 64×64 슈퍼샘플링(곡선 256분할)과 대 보면 우리 값은 0.5단계 안에서 맞고 FreeType 값이 벗어나 있다. 남은 차이는 FreeType 쪽 곡선 근사(특히 CFF)다. 변환은 TrueType에서 손실이 없다. 글리프 넓이가 fontTools가 원본 glyf에서 잰 넓이와 1단위² 안에서 같고 advance가 hmtx와 같다 (검사가 지킨다). FreeType은 암시 점을 정수 나눗셈으로 구하므로 윤곽을 두 배로 늘린 뒤에 걷고, 다 쓴 뒤 단위를 되돌린다. CFF는 em² 대비 평균 2.6e-5, 최악 1.5e-4 차이다. SVG는 Adwaita 기호 아이콘 575개 중 458개를 변환했고(불투명도 82, 변환 23, evenodd 11, 획 1은 거부), fontTools의 SVG 경로 해석과 넓이 차이가 아이콘 픽셀² 평균 0.013, 최악 0.19다. 크기와 읽는 비용. 윤곽 표는 fontTools로 해당 글자만 남기고 힌팅을 뺀 부분 폰트의 glyf+loca 또는 CFF다. 1판은 곡선마다 12바이트 레코드였다. | 경우 | 점 / 곡선 | 1판 | 2판 | 윤곽 표 | gzip 2판 / 부분 폰트 | 윤곽 하나 (우리 / FreeType) | |---|---|---|---|---|---|---| | DejaVu ASCII | 1,933 / 1,463 | 19,864 B | 10,040 B | 7,860 B | 6,944 / 6,465 B | 271 / 539 ns | | Nimbus ASCII (CFF) | 2,444 / 1,873 | 22,948 B | 12,084 B | 5,960 B | 8,726 / 5,851 B | 334 / 3,529 ns | | Baekmuk 한글 | 3.28M / 1.93M | 23.4 MB | 13.4 MB | 9.55 MB | 5.50 / 1.98 MB | 3.6 / 4.2 µs | | Noto CJK 한글 (CFF) | 723K / 598K | 7.10 MB | 3.16 MB | 1.08 MB | 1.37 / 0.80 MB | 1.0 / 9.3 µs | | Adwaita 아이콘 458개 | | 303 KB | 173 KB | SVG 452 KB | 97 / 205 KB | | - 여는 비용: 2판은 DejaVu ASCII 2.5 µs(FreeType 12.7 µs), 한글 11,172자 0.34 ms(FreeType 0.03–0.39 ms). 1판은 곡선마다 경계를 검사해 한글에서 19 ms였다. - 크기: 점을 나누고 직선의 제어점과 암시 점을 빼서 1판의 절반 안팎이 되었다. TrueType 원본의 1.3–1.4배(부분 폰트 파일 전체와는 비슷하다), CFF의 2.0–2.9배다. CFF는 3차 곡선과 서브루틴, 가변 길이 정수로 적으므로 고정 크기 점으로는 따라가기 어렵다. 전송은 gzip이 차이를 줄인다. SVG보다는 2.6배 작다. - 윤곽 하나에 닿는 비용: 점마다 이웃을 보고 암시 점을 만들어야 해서 1판(곡선을 그대로 읽는다)보다 느려졌다. 곡선 위·밖 플래그가 데이터에 따라 바뀌어 분기 예측이 틀리므로 해독은 분기 없이 산술로 한다(점당 9.7 ns). 그래도 TrueType보다 1.2–2배, CFF보다 9–10배 싸다. GPU는 해독하지 않고 점을 그대로 읽는다. - 참조 래스터라이저: 16px 글리프당 24 µs(CPU, 픽셀마다 모든 곡선). GPU에서 띠(band)로 곡선을 추리는 것이 다음이다. 처음 계획은 12바이트 곡선 레코드(1판)였다. 윤곽에 닿는 비용은 더 쌌지만(119 ns) 폰트의 2.1–6.6배 크기였고, 여는 비용이 곡선 수에 비례했다. "초경량"이라는 목표에 맞지 않아서 점 사슬로 바꿨다. ## GPU (Raspberry Pi 5 V3D 7.1, OpenGL ES 3.1, Mesa 26.2.2) `symbols/symbols-gl.c`: 한 크기에서 쓸 심볼들을 CPU가 아틀라스(8비트 한 장)에 정확 넓이로 그리고(`vega_symbol_raster`), 글은 그 아틀라스를 읽는 사각형(글리프마다 인스턴스 하나, 정수 픽셀의 펜 위치)으로 GPU가 합성한다. 크기가 계속 바뀌는 경우(확대 애니메이션)는 그 크기의 아틀라스를 다시 그린다. 경로는 하나다. `vega-symbols gpu`로 잰 것(표면 없는 EGL 문맥). 심볼마다 제 칸의 펜 위치에 합성한 것을 읽어 같은 픽셀의 CPU 래스터와 댄다. | | 12px | 16px | 32px | 64px | |---|---|---|---|---| | DejaVu ASCII: 합성과 CPU 래스터의 최악 차이 | 0 | 0 | 0 | 0 | | 아틀라스 채우기(95자, CPU 한 스레드) | 2.1 ms | 2.7 ms | 6.9 ms | 20.9 ms | | 1920×1080 한 쪽 합성(글리프 수) | 1.48 ms (20,212) | 1.07 ms (11,382) | 0.61 ms (2,805) | 0.44 ms (645) | | Noto 한글 11,172자: 채우기 / 한 쪽 합성 / 최악 | 0.68 s / 1.56 ms / 0 | 1.06 s / 1.45 ms / 1 | 1.83 s(24px) / 1.00 ms / 0 | | 이렇게 되기까지 GPU로 넓이를 계산하는 두 길을 쟀다. 둘 다 CPU보다 느렸다. - **프래그먼트마다 직접.** 심볼의 상자를 덮는 사각형의 픽셀마다 띠(가로 16개, 올릴 때 CPU가 곡선을 나눠 적는다) 안의 곡선의 넓이를 더했다. CPU 참조와 평균 0.001–0.1단계로 맞았지만 1080p 한 쪽이 142–757 ms였다. 시간의 83%가 넓이 계산이었다. 교차점 배열(`float ts[10]`, 동적 첨자와 삽입 정렬)을 레지스터 안의 정렬망(vec4 min/max)으로 바꾸면 2.3배 빨라졌지만 이 드라이버에서 몇몇 행 전체가 틀린 값을 냈다. - **계산 셰이더로 아틀라스.** 같은 정렬망 판이 정수 픽셀에 맞춘 격자에서(글리프의 기준선이 픽셀 경계와 겹친다) 틀렸다. 기준선 바로 아래 행이 통째로 달랐다. 같은 곡선 입력을 C로 옮긴 판과, 넓이 함수 하나만 도는 계산 셰이더는 맞게 계산한다. 그 함수를 띠의 반복문 안에 넣은 셰이더에서만 틀리므로 드라이버 컴파일러 쪽으로 본다(최소 재현은 아직 없다). 삽입 정렬 판은 맞지만 16px 95자에 35 ms, 64px에 119 ms였다. CPU(Cortex-A76 한 코어)는 같은 일을 2.7 ms와 20.9 ms에 한다. 그래서 넓이는 C 구현 하나로 계산하고, GPU는 합성만 한다. 셰이더에 넓이 계산이 없어 드라이버 결함에 걸릴 곳도 없다. GPU 계산이 빠른 장치에서 다시 잴 수 있도록 측정은 남긴다(이 문서와 커밋 기록). 다음: 필요할 때 글리프를 더하는 아틀라스(화면에 나온 것만 굽기). 한글 한 글리프의 곡선은 약 54개이고 CPU 래스터는 픽셀마다 모든 곡선을 본다. 행마다 걸치는 곡선만 보면 몇 배 빨라질 것이다. ## 도구 `vega-symbols build [--font 폰트 --codes 0x20-0x7e,…] [--svg 번호=아이콘.svg …] [--units N] -o 심볼.vsym`: FreeType으로 글리프 윤곽을 단위 그대로(`FT_LOAD_NO_SCALE`) 읽고(단위는 글꼴 단위의 두 배에서 시작해 줄인다. 아이콘만이면 4,096), SVG는 `path`의 `d`를 읽어 viewBox(없으면 width·height)의 높이를 em으로 맞춘다. 호(`A`)는 4분의 1 바퀴 이하의 3차 곡선으로, 3차 곡선은 2차 곡선으로 나눈다. 칠해진 경로만 받는다. 변환, 불투명도, evenodd, 획, 경로 밖의 도형을 쓰는 아이콘은 이름을 대고 거부한다(빠뜨리고 그리면 틀린 그림이 된다). 해석은 여기서 한 번이다. `compare`(FreeType과 픽셀 차이, 여는 시간, 윤곽 하나에 닿는 시간), `info`(심볼마다 점·곡선 수, 경계, 넓이), `render`(PGM), `gpu`(GPU가 합성한 것과 CPU 래스터의 차이, 아틀라스를 채우고 한 쪽을 합성하는 시간)는 검증과 측정용이다. `tests/check.py`의 10번이 이것들로 형식을 지킨다.