klyff 0.1.3

Text rendering library for games with MSDF support
Documentation
//! Integration tests that render text with each feature set enabled and assert
//! no runtime panics occur.

use klyff::{
    Color, EncoderContext, Features, Glow, GlyphColoring, Outline, Rect, Shadow, StyledText,
    TextRenderer, TextStyle, TextureAtlas, TextureAtlasDescriptor,
};

// ---------------------------------------------------------------------------
// GPU + font setup helpers
// ---------------------------------------------------------------------------

const WIDTH: u32 = 512;
const HEIGHT: u32 = 128;
const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;

async fn request_device() -> Option<(wgpu::Device, wgpu::Queue)> {
    let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
    let adapter = instance
        .request_adapter(&wgpu::RequestAdapterOptions {
            power_preference: wgpu::PowerPreference::None,
            compatible_surface: None,
            force_fallback_adapter: false,
        })
        .await
        .ok()?;
    adapter
        .request_device(&wgpu::DeviceDescriptor::default())
        .await
        .ok()
}

fn make_font_system() -> klyff::cosmic_text::FontSystem {
    let mut db = klyff::fontdb::Database::new();
    db.load_font_data(include_bytes!("../../../fonts/OpenSans-Regular.ttf").to_vec());
    db.load_font_data(include_bytes!("../../../fonts/NotoSansSC-Regular.ttf").to_vec());
    db.load_font_data(include_bytes!("../../../fonts/NotoColorEmoji.ttf").to_vec());
    klyff::cosmic_text::FontSystem::new_with_locale_and_db("en-US".to_string(), db)
}

fn make_text(font_system: &mut klyff::cosmic_text::FontSystem, style: TextStyle) -> StyledText {
    use klyff::cosmic_text::{Attrs, Family, Metrics};

    let default_attrs = Attrs::new().family(Family::Name("Open Sans"));
    let mut text_builder = klyff::StyledTextBuilder::new(
        Rect::from_xywh(0.0, 0.0, WIDTH as f32, HEIGHT as f32),
        font_system,
        Metrics::new(48.0, 60.0),
    );
    text_builder.push_text("klyff", &default_attrs, style);
    text_builder.finish(font_system, &default_attrs)
}

fn render_frame(
    device: &wgpu::Device,
    queue: &wgpu::Queue,
    renderer: &mut TextRenderer,
    font_system: &mut klyff::cosmic_text::FontSystem,
    texts: &[StyledText],
) {
    let mut atlas = TextureAtlas::new(device, TextureAtlasDescriptor::default());
    let target = device.create_texture(&wgpu::TextureDescriptor {
        label: Some("render target"),
        size: wgpu::Extent3d {
            width: WIDTH,
            height: HEIGHT,
            depth_or_array_layers: 1,
        },
        mip_level_count: 1,
        sample_count: 1,
        dimension: wgpu::TextureDimension::D2,
        format: SURFACE_FORMAT,
        usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
        view_formats: &[],
    });
    let view = target.create_view(&wgpu::TextureViewDescriptor::default());

    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor::default());

    renderer.prepare(
        EncoderContext {
            atlas: &mut atlas,
            device,
            queue,
            cmd_encoder: &mut encoder,
            font_system,
        },
        (WIDTH, HEIGHT),
        texts,
    );

    {
        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
            label: Some("render pass"),
            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
                view: &view,
                resolve_target: None,
                depth_slice: None,
                ops: wgpu::Operations {
                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
                    store: wgpu::StoreOp::Store,
                },
            })],
            depth_stencil_attachment: None,
            occlusion_query_set: None,
            timestamp_writes: None,
        });
        renderer.render(&mut pass, &atlas);
    }

    queue.submit(std::iter::once(encoder.finish()));
    let _ = device.poll(wgpu::PollType::Wait {
        submission_index: None,
        timeout: None,
    });
}

// ---------------------------------------------------------------------------
// Per-feature styles
// ---------------------------------------------------------------------------

fn style() -> TextStyle {
    TextStyle {
        color: GlyphColoring::Gradient {
            primary: Color::from_rgb(1.0, 0.5, 0.0),
            secondary: Color::from_rgb(0.0, 0.5, 1.0),
            direction: glam::vec2(0.0, 1.0),
        },
        stroke_out: Outline {
            color: GlyphColoring::Gradient {
                primary: Color::from_rgb(0.8, 0.0, 0.0),
                secondary: Color::from_rgb(1.0, 0.4, 0.0),
                direction: glam::vec2(0.0, 1.0),
            },
            width: 0.1,
            roundness: 0.5,
        },
        stroke_in: Outline {
            color: GlyphColoring::Solid(Color::from_rgba(0.1, 0.05, 0.0, 0.8)),
            width: 0.05,
            roundness: 0.5,
        },
        glow_out: Glow {
            color: GlyphColoring::Solid(Color::from_rgba(0.0, 0.8, 1.0, 0.6)),
            width: 0.3,
            spread: 0.0,
            roundness: 0.5,
        },
        glow_in: Glow {
            color: GlyphColoring::Solid(Color::from_rgba(1.0, 0.95, 0.6, 0.4)),
            width: 0.2,
            spread: 0.1,
            roundness: 0.5,
        },
        shadow: Shadow {
            color: GlyphColoring::Solid(Color::from_rgba(0.0, 0.0, 0.0, 0.7)),
            direction: glam::vec2(0.06, 0.06),
            additional_width: 0.2,
            spread: 0.0,
            roundness: 0.5,
        },
    }
}

// ---------------------------------------------------------------------------
// Test macro
// ---------------------------------------------------------------------------

macro_rules! render_test {
    ($name:ident, $features:expr) => {
        #[test]
        fn $name() {
            let Some((device, queue)) = pollster::block_on(request_device()) else {
                return;
            };
            let mut font_system = make_font_system();
            let text = make_text(&mut font_system, style());
            let mut renderer = TextRenderer::new(&device, SURFACE_FORMAT);
            render_frame(&device, &queue, &mut renderer, &mut font_system, &[text]);
        }
    };
}

render_test!(render_empty, Features::empty());
render_test!(render_stroke_out, Features::STROKE_OUT);
render_test!(render_stroke_in, Features::STROKE_IN);
render_test!(render_glow_out, Features::GLOW_OUT);
render_test!(render_glow_in, Features::GLOW_IN);
render_test!(render_shadow, Features::SHADOW);
render_test!(render_all, Features::all());

// ---------------------------------------------------------------------------
// Multi-pass render tests
// ---------------------------------------------------------------------------

macro_rules! multipass_render_test {
    ($name:ident, $features:expr) => {
        #[test]
        fn $name() {
            let Some((device, queue)) = pollster::block_on(request_device()) else {
                return;
            };
            let features = $features;
            let mut font_system = make_font_system();
            let text = make_text(&mut font_system, style());
            let mut renderer = TextRenderer::with_styling(features, &device, SURFACE_FORMAT);
            render_frame(&device, &queue, &mut renderer, &mut font_system, &[text]);
        }
    };
}

multipass_render_test!(render_stroke_out_multipass, Features::STROKE_OUT);
multipass_render_test!(render_glow_out_multipass, Features::GLOW_OUT);
multipass_render_test!(render_shadow_multipass, Features::SHADOW);
multipass_render_test!(render_all_multipass, Features::all());

// ---------------------------------------------------------------------------
// Multi-style / script coverage tests
// ---------------------------------------------------------------------------

fn alt_style() -> TextStyle {
    TextStyle {
        color: GlyphColoring::Solid(Color::from_rgb(0.2, 0.9, 0.4)),
        stroke_out: Outline {
            color: GlyphColoring::Solid(Color::from_rgb(0.0, 0.3, 0.1)),
            width: 0.08,
            roundness: 0.5,
        },
        ..Default::default()
    }
}

fn make_multi_style_text(font_system: &mut klyff::cosmic_text::FontSystem) -> StyledText {
    use klyff::cosmic_text::{Attrs, Family, Metrics};

    let default_attrs = Attrs::new().family(Family::Name("Open Sans"));
    let mut text_builder = klyff::StyledTextBuilder::new(
        Rect::from_xywh(0.0, 0.0, WIDTH as f32, HEIGHT as f32),
        font_system,
        Metrics::new(48.0, 60.0),
    );
    text_builder.push_text("kly", &default_attrs, style());
    text_builder.push_text("ff", &default_attrs, alt_style());
    text_builder.finish(font_system, &default_attrs)
}

fn make_text_with_font(
    font_system: &mut klyff::cosmic_text::FontSystem,
    content: &str,
    family: &str,
) -> StyledText {
    use klyff::cosmic_text::{Attrs, Family, Metrics};

    let default_attrs = Attrs::new().family(Family::Name(family));
    let mut text_builder = klyff::StyledTextBuilder::new(
        Rect::from_xywh(0.0, 0.0, WIDTH as f32, HEIGHT as f32),
        font_system,
        Metrics::new(48.0, 60.0),
    );
    text_builder.push_text(content, &default_attrs, style());
    text_builder.finish(font_system, &default_attrs)
}

#[test]
fn render_multiple_styles() {
    let Some((device, queue)) = pollster::block_on(request_device()) else {
        return;
    };
    let mut font_system = make_font_system();
    let text = make_multi_style_text(&mut font_system);
    let mut renderer = TextRenderer::new(&device, SURFACE_FORMAT);
    render_frame(&device, &queue, &mut renderer, &mut font_system, &[text]);
}

#[test]
fn render_cjk() {
    let Some((device, queue)) = pollster::block_on(request_device()) else {
        return;
    };
    let mut font_system = make_font_system();
    let text = make_text_with_font(&mut font_system, "δ½ ε₯½δΈ–η•Œ", "Noto Sans SC");
    let mut renderer = TextRenderer::new(&device, SURFACE_FORMAT);
    render_frame(&device, &queue, &mut renderer, &mut font_system, &[text]);
}

#[test]
fn render_custom_glyphs() {
    use klyff::cosmic_text::{Attrs, Family, Metrics};

    let Some((device, queue)) = pollster::block_on(request_device()) else {
        return;
    };
    let mut font_system = make_font_system();
    let custom_font = klyff::setup_custom_glyph_font(&mut font_system);

    let default_attrs = Attrs::new().family(Family::Name("Open Sans"));
    let mut text_builder = klyff::StyledTextBuilder::new(
        Rect::from_xywh(0.0, 0.0, WIDTH as f32, HEIGHT as f32),
        &mut font_system,
        Metrics::new(48.0, 60.0),
    );
    text_builder.push_text("ab", &default_attrs, style());
    text_builder.push_custom_glyph(42, 40.0, 30.0);
    text_builder.push_text("cd", &default_attrs, style());
    text_builder.push_custom_glyph(7, 24.0, 24.0);
    let text = text_builder.finish(&mut font_system, &default_attrs);

    let mut renderer = TextRenderer::new(&device, SURFACE_FORMAT);
    renderer.set_custom_glyph_font(Some(custom_font));
    render_frame(&device, &queue, &mut renderer, &mut font_system, &[text]);

    let custom = renderer.custom_glyphs();
    assert_eq!(custom.len(), 2, "expected two detected custom glyphs");

    let ids: Vec<u64> = custom.iter().map(|g| g.id).collect();
    assert!(ids.contains(&42) && ids.contains(&7), "ids: {ids:?}");

    for g in custom {
        assert_eq!(g.text_id, 0);
        assert!(g.rect.width() > 0.0, "custom glyph should reserve width");
        assert!(g.rect.height() > 0.0, "custom glyph should reserve height");
    }

    // The first custom glyph (id 42) requests a 40x30 box; it should be wider than the
    // second (id 7) which requests 24x24.
    let g42 = custom.iter().find(|g| g.id == 42).unwrap();
    let g7 = custom.iter().find(|g| g.id == 7).unwrap();
    assert!(g42.rect.width() > g7.rect.width());
}

#[test]
fn render_emoji() {
    let Some((device, queue)) = pollster::block_on(request_device()) else {
        return;
    };
    let mut font_system = make_font_system();
    let text = make_text_with_font(&mut font_system, "πŸ˜€πŸŽ‰πŸš€", "Noto Color Emoji");
    let mut renderer = TextRenderer::new(&device, SURFACE_FORMAT);
    render_frame(&device, &queue, &mut renderer, &mut font_system, &[text]);
}