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

12 KiB

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:

// 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
pub mod ttf_parser;
  • Step 4: Commit
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

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:

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

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

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

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

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

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

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

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:

pub ttf_font: Option<crate::ttf_font::TtfFont>,

Add helper method:

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:

// 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
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
git commit -m "docs: update STATUS.md and DEFERRED.md with TTF font support"