Files
game_engine/docs/superpowers/plans/2026-03-26-ttf-font.md
tolelom 74974dbff0 docs: add TTF font implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:04:43 +09:00

422 lines
12 KiB
Markdown

# TTF Font Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Self-implemented TTF parser + rasterizer with on-demand glyph cache atlas for variable-width Unicode text rendering in the editor.
**Architecture:** 4 modules: TtfParser (binary parsing) → Rasterizer (outline→bitmap) → GlyphCache (atlas management) → TtfFont (unified interface). All pure CPU logic, no GPU dependency for core. GPU integration only in editor_demo.
**Tech Stack:** Rust, std only (no external crates). wgpu for atlas texture upload in editor integration.
**Spec:** `docs/superpowers/specs/2026-03-26-ttf-font-design.md`
**Test font:** Tests need a real TTF file. Use a system font or download a small open-source font. For tests, embed a minimal subset or use `C:/Windows/Fonts/arial.ttf` which is available on Windows.
---
### Task 1: TtfParser — table directory + head/hhea/maxp
**Files:**
- Create: `crates/voltex_editor/src/ttf_parser.rs`
- Modify: `crates/voltex_editor/src/lib.rs`
- [ ] **Step 1: Write tests and implement table directory parsing**
Create `ttf_parser.rs` with byte-reading helpers and table directory parsing:
```rust
// Byte reading helpers (big-endian, TTF standard)
fn read_u16(data: &[u8], offset: usize) -> u16
fn read_i16(data: &[u8], offset: usize) -> i16
fn read_u32(data: &[u8], offset: usize) -> u32
// Table directory entry
struct TableRecord {
tag: [u8; 4],
offset: u32,
length: u32,
}
pub struct TtfParser {
data: Vec<u8>,
tables: HashMap<[u8; 4], TableRecord>,
pub units_per_em: u16,
pub num_glyphs: u16,
pub ascender: i16,
pub descender: i16,
pub line_gap: i16,
pub num_h_metrics: u16,
pub loca_format: i16,
}
```
Parse: offset table → table directory → head (units_per_em, indexToLocFormat) → hhea (ascender, descender, lineGap, numberOfHMetrics) → maxp (numGlyphs).
Tests:
- `test_parse_loads_tables`: Load a TTF file, verify tables exist (head, cmap, glyf, etc.)
- `test_head_values`: units_per_em > 0, loca_format is 0 or 1
- `test_hhea_values`: ascender > 0, descender < 0 (typically)
- `test_maxp_values`: num_glyphs > 0
- [ ] **Step 2: Run tests**
Run: `cargo test -p voltex_editor --lib ttf_parser -- --nocapture`
- [ ] **Step 3: Add module to lib.rs**
```rust
pub mod ttf_parser;
```
- [ ] **Step 4: Commit**
```bash
git commit -m "feat(editor): add TTF parser with table directory and header parsing"
```
---
### Task 2: TtfParser — cmap (Unicode → glyph index)
**Files:**
- Modify: `crates/voltex_editor/src/ttf_parser.rs`
- [ ] **Step 1: Implement cmap Format 4 parsing**
Add `glyph_index(codepoint: u32) -> u16` method:
- Find cmap table → find Format 4 subtable (platformID=3, encodingID=1 or platformID=0)
- Parse segCount, endCode[], startCode[], idDelta[], idRangeOffset[]
- Binary search for the segment containing the codepoint
- Calculate glyph index
Tests:
- `test_cmap_ascii`: 'A' (0x41) → non-zero glyph index
- `test_cmap_space`: ' ' (0x20) → valid glyph index
- `test_cmap_unmapped`: very high codepoint → 0 (missing glyph)
- `test_cmap_multiple_chars`: several chars return distinct indices
- [ ] **Step 2: Run tests, commit**
```bash
git commit -m "feat(editor): add cmap Format 4 parsing for Unicode glyph mapping"
```
---
### Task 3: TtfParser — glyf (glyph outlines) + hmtx (metrics)
**Files:**
- Modify: `crates/voltex_editor/src/ttf_parser.rs`
- [ ] **Step 1: Implement loca + glyf parsing**
Add:
```rust
pub struct OutlinePoint {
pub x: f32,
pub y: f32,
pub on_curve: bool,
}
pub struct GlyphOutline {
pub contours: Vec<Vec<OutlinePoint>>,
pub x_min: i16,
pub y_min: i16,
pub x_max: i16,
pub y_max: i16,
}
pub struct GlyphMetrics {
pub advance_width: u16,
pub left_side_bearing: i16,
}
```
Methods:
- `glyph_offset(glyph_id: u16) -> usize` — loca table lookup
- `glyph_outline(glyph_id: u16) -> Option<GlyphOutline>` — glyf table parse
- Simple glyph: endPtsOfContours, flags, x/y deltas
- Handle on_curve/off_curve: insert implicit on-curve points between consecutive off-curve
- Compound glyph: skip for now (return None)
- `glyph_metrics(glyph_id: u16) -> GlyphMetrics` — hmtx table
Tests:
- `test_glyph_outline_has_contours`: 'A' outline has >= 1 contour
- `test_glyph_outline_points`: contour points count > 0
- `test_glyph_metrics_advance`: 'A' advance > 0
- `test_space_glyph_no_contours`: ' ' has 0 contours (or None)
- [ ] **Step 2: Run tests, commit**
```bash
git commit -m "feat(editor): add glyf outline and hmtx metrics parsing"
```
---
### Task 4: Rasterizer
**Files:**
- Create: `crates/voltex_editor/src/rasterizer.rs`
- Modify: `crates/voltex_editor/src/lib.rs`
- [ ] **Step 1: Implement bezier flattening + scanline rasterizer**
```rust
pub struct RasterResult {
pub width: u32,
pub height: u32,
pub bitmap: Vec<u8>, // R8 alpha
pub offset_x: f32,
pub offset_y: f32,
}
/// Flatten quadratic bezier to line segments.
fn flatten_quadratic(p0: (f32,f32), p1: (f32,f32), p2: (f32,f32), segments: &mut Vec<(f32,f32)>);
/// Rasterize outline at given scale.
pub fn rasterize(outline: &GlyphOutline, scale: f32, units_per_em: u16) -> RasterResult;
```
Rasterize steps:
1. Scale outline points: `px = (x - x_min) * scale`, `py = (y_max - y) * scale` (Y flip)
2. Walk contours: on_curve→on_curve = line, on_curve→off_curve→on_curve = quadratic bezier
3. Flatten all curves into edges (line segments)
4. For each scanline y: find all edge intersections, sort by x
5. Fill using non-zero winding rule
6. Output alpha bitmap
Tests:
- `test_rasterize_produces_bitmap`: rasterize 'A' at 32px → width>0, height>0, bitmap non-empty
- `test_rasterize_has_filled_pixels`: bitmap has at least some non-zero pixels
- `test_rasterize_space`: space glyph → 0-width or empty bitmap
- `test_flatten_quadratic`: known bezier → correct number of segments
- [ ] **Step 2: Add module to lib.rs, run tests, commit**
```bash
git commit -m "feat(editor): add glyph rasterizer with bezier flattening and scanline fill"
```
---
### Task 5: GlyphCache (on-demand atlas)
**Files:**
- Create: `crates/voltex_editor/src/glyph_cache.rs`
- Modify: `crates/voltex_editor/src/lib.rs`
- [ ] **Step 1: Implement GlyphCache**
```rust
use std::collections::HashMap;
pub const ATLAS_SIZE: u32 = 1024;
pub struct GlyphInfo {
pub uv: [f32; 4], // u0, v0, u1, v1
pub width: f32,
pub height: f32,
pub advance: f32,
pub bearing_x: f32,
pub bearing_y: f32,
}
pub struct GlyphCache {
pub atlas_data: Vec<u8>,
pub atlas_width: u32,
pub atlas_height: u32,
glyphs: HashMap<char, GlyphInfo>,
cursor_x: u32,
cursor_y: u32,
row_height: u32,
pub dirty: bool,
}
```
Methods:
- `new(width, height)` → zeroed atlas_data
- `get(ch) -> Option<&GlyphInfo>`
- `insert(ch, bitmap, width, height, advance, bearing_x, bearing_y) -> &GlyphInfo`
- Copy bitmap into atlas at (cursor_x, cursor_y)
- Advance cursor, handle row overflow
- Set dirty = true
- `clear_dirty()`
Tests:
- `test_insert_and_get`: insert a 10x10 glyph → get returns correct UV
- `test_cache_hit`: insert then get same char → same UV
- `test_row_wrap`: insert many wide glyphs → cursor_y advances to next row
- `test_uv_coordinates`: UV values are in 0.0-1.0 range and match position
- [ ] **Step 2: Add module, run tests, commit**
```bash
git commit -m "feat(editor): add GlyphCache on-demand atlas manager"
```
---
### Task 6: TtfFont (unified interface)
**Files:**
- Create: `crates/voltex_editor/src/ttf_font.rs`
- Modify: `crates/voltex_editor/src/lib.rs`
- [ ] **Step 1: Implement TtfFont**
```rust
pub struct TtfFont {
parser: TtfParser,
cache: GlyphCache,
pub font_size: f32,
pub line_height: f32,
pub ascender: f32,
pub descender: f32,
scale: f32,
}
impl TtfFont {
pub fn new(data: &[u8], font_size: f32) -> Result<Self, String>;
pub fn glyph(&mut self, ch: char) -> &GlyphInfo; // cache lookup or rasterize+insert
pub fn text_width(&mut self, text: &str) -> f32;
pub fn atlas_data(&self) -> &[u8];
pub fn atlas_size(&self) -> (u32, u32);
pub fn is_dirty(&self) -> bool;
pub fn clear_dirty(&mut self);
}
```
The `glyph` method:
1. Check cache.get(ch)
2. If miss: parser.glyph_index → parser.glyph_outline → rasterize → cache.insert
3. Return &GlyphInfo
Tests:
- `test_ttf_font_new`: loads TTF file, font_size = 24
- `test_glyph_caches`: call glyph('A') twice → second is cache hit (dirty resets)
- `test_text_width`: "Hello" width > 0, "Hello World" width > "Hello" width
- `test_korean_glyph`: '가' glyph loads without panic (if font supports it)
- [ ] **Step 2: Add module, run tests, commit**
```bash
git commit -m "feat(editor): add TtfFont unified interface with glyph caching"
```
---
### Task 7: UI integration + editor_demo
**Files:**
- Modify: `crates/voltex_editor/src/ui_context.rs`
- Modify: `crates/voltex_editor/src/renderer.rs`
- Modify: `examples/editor_demo/src/main.rs`
- [ ] **Step 1: Add ttf_font to UiContext + draw_text helper**
In `ui_context.rs`, add:
```rust
pub ttf_font: Option<crate::ttf_font::TtfFont>,
```
Add helper method:
```rust
pub fn draw_text(&mut self, text: &str, x: f32, y: f32, color: [u8; 4]) {
if let Some(ref mut ttf) = self.ttf_font {
let mut cx = x;
for ch in text.chars() {
let glyph = ttf.glyph(ch);
let gx = cx + glyph.bearing_x;
let gy = y + self.font.glyph_height as f32 - glyph.bearing_y; // baseline align
let (u0, v0, u1, v1) = (glyph.uv[0], glyph.uv[1], glyph.uv[2], glyph.uv[3]);
if glyph.width > 0.0 && glyph.height > 0.0 {
self.draw_list.add_rect_uv(gx, gy, glyph.width, glyph.height, u0, v0, u1, v1, color);
}
cx += glyph.advance;
}
} else {
// Fallback to bitmap font
let gw = self.font.glyph_width as f32;
let gh = self.font.glyph_height as f32;
let mut cx = x;
for ch in text.chars() {
let (u0, v0, u1, v1) = self.font.glyph_uv(ch);
self.draw_list.add_rect_uv(cx, y, gw, gh, u0, v0, u1, v1, color);
cx += gw;
}
}
}
pub fn ttf_text_width(&mut self, text: &str) -> f32 {
if let Some(ref mut ttf) = self.ttf_font {
ttf.text_width(text)
} else {
text.len() as f32 * self.font.glyph_width as f32
}
}
```
- [ ] **Step 2: Update UiRenderer for TTF atlas**
In `renderer.rs`, add support for a second texture (TTF atlas):
- When TtfFont is active, create a TTF atlas texture (R8Unorm, 1024x1024)
- Each frame: check dirty → upload atlas data
- Create a second bind group for TTF texture
- DrawCommands need to know which texture to use
Simplest approach: add a `ttf_atlas_texture` and `ttf_bind_group` to UiRenderer. When rendering, if TTF is active, use the TTF bind group for all draws (since TTF atlas includes all rendered glyphs).
Actually, simpler: just replace the font atlas texture with the TTF atlas when TTF is active. The shader is the same (R8 alpha sampling).
- [ ] **Step 3: Update editor_demo**
In `resumed`:
```rust
// Load TTF font
let ttf_data = std::fs::read("C:/Windows/Fonts/arial.ttf")
.or_else(|_| std::fs::read("C:/Windows/Fonts/malgun.ttf"))
.ok();
if let Some(data) = ttf_data {
if let Ok(font) = TtfFont::new(&data, 16.0) {
state.ui.ttf_font = Some(font);
}
}
```
- [ ] **Step 4: Build and test**
Run: `cargo build -p editor_demo`
Run: `cargo test -p voltex_editor`
- [ ] **Step 5: Commit**
```bash
git commit -m "feat(editor): integrate TTF font rendering into UI system and editor_demo"
```
---
### Task 8: Update docs
**Files:**
- Modify: `docs/STATUS.md`
- Modify: `docs/DEFERRED.md`
- [ ] **Step 1: Update STATUS.md**
Add: `- voltex_editor: TtfParser, Rasterizer, GlyphCache, TtfFont (TTF rendering, Unicode)`
Update test count.
- [ ] **Step 2: Update DEFERRED.md**
```
- ~~**TTF 폰트**~~ ✅ 자체 TTF 파서 + 래스터라이저 + 온디맨드 캐시 완료. 한글/유니코드 지원.
```
- [ ] **Step 3: Commit**
```bash
git commit -m "docs: update STATUS.md and DEFERRED.md with TTF font support"
```